Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 222b8a79ff | |||
| 42f5cc287b | |||
| 03a9693666 | |||
| b25cefe318 | |||
| ef3ba18168 | |||
| 465a163e27 | |||
| a33d579177 | |||
| 393a0ed36e | |||
| e8f9e4ee50 | |||
| 659c893b29 | |||
| 8d0da34e62 |
@@ -74,6 +74,9 @@ jobs:
|
||||
- run: zig build test-example-mobile-canvas-lib-ios-store
|
||||
- run: zig build test-webview-system-link
|
||||
- run: zig build test-webview-smoke
|
||||
# The zero-config TypeScript runner must load app.zon menus before
|
||||
# automation can select their registered command ids.
|
||||
- run: zig build test-menu-bar-smoke
|
||||
# Signed-package seal pin: an ad-hoc signed package must pass
|
||||
# codesign --verify --strict (macOS runners are the only tier with
|
||||
# codesign; the step skips loudly anywhere else).
|
||||
|
||||
+46
-3
@@ -2,12 +2,57 @@
|
||||
|
||||
All notable changes to the Native SDK (formerly zero-native) will be documented in this file.
|
||||
|
||||
## 0.9.1
|
||||
## 0.9.3
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
### New Features
|
||||
|
||||
- **Model-driven TypeScript theme state**: Zero-config TypeScript apps can now derive the built-in pack, color scheme, and accent from committed model state while preserving manifest fallback, live system accessibility settings, deterministic replay, and the existing `themePack` helper (#378).
|
||||
- **Platform-correct line deletion**: Command+Backspace on macOS now deletes to the start of a field or logical textarea line across every editable canvas control, with matching TypeScript text helpers, controlled-state behavior, undo, and replay (#377).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Precise macOS file-drop routing**: AppKit drops now retain labeled canvas and WebView targets with top-left, view-local coordinates, while unlabeled window regions fall back to content coordinates (#374).
|
||||
- **Manifest menus in generated runners**: Zero-config TypeScript and Zig-core apps now load `app.zon` commands, shortcuts, and menus consistently in live and replay runners, including ejected-runner fallbacks (#376).
|
||||
- **Large TypeScript message unions compile reliably**: Generated shims now derive comptime scan quotas from message shape and identifier size, allowing wide unions to compile across persistence, channels, environment routing, and the full external-core pipeline (#375).
|
||||
- **Correct combobox Enter precedence**: A bound `on-submit` now handles Enter before trigger activation, so query submission no longer opens the picker or dispatches the wrong command (#373).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @MohakBajaj
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Flash-free accessory startup**: Apps can opt into accessory activation from `app.zon` to launch without a Dock icon or foreground flash, with tray-affordance validation, runtime composition, packaging support, and an updated menu-bar example (#358).
|
||||
- **Logical canvas radio groups**: Nested radios now form accessible single-selection groups with roving focus and consistent keyboard, pointer, handler, and naming semantics (#361).
|
||||
- **Budget-aware photo decoding**: Dynamic encoded images are downsampled across desktop and mobile codecs to fit a configurable registered-pixel budget, with independent source bounds, deterministic replay, and platform-level regression coverage (#366).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Correct anchored surfaces**: Floating and modal surfaces now dismiss without requiring focus, relayout after scroll restoration, resolve against the correct root, and behave consistently across window contexts (#363).
|
||||
- **Reliable autofocus and caret reveal**: Keyboard focus, autofocus, and automation now transactionally reveal offscreen targets while preserving collapsed end-caret selections in text editors (#364).
|
||||
- **Explicit link decoration**: Linked text spans now honor their underline flag while Markdown-generated links retain conventional underlines (#368).
|
||||
- **Stable macOS window geometry**: Fresh windows now distinguish restored, explicit, and default placement, while AppKit and CEF frame events consistently report content geometry without titlebar drift (#369, #370).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Consistent canvas controls and surfaces**: Checkbox and radio labels can contain markup consistently, while actionable states, disabled colors, variant accents, selection geometry, compact layouts, and zero-width strokes now render uniformly across the schema, runtime, accessibility tree, and documentation (#367).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @sepehr-safari
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### New Features
|
||||
|
||||
- **Multi-item macOS menu bars**: Apps can now manage independent, keyed status items with model-driven updates, events, automation, journaling, and regression coverage (#343).
|
||||
- **Complete TypeScript file effects**: Secure, permission-gated effects now support bounded streaming reads, atomic writes, stat, append, and deletion while preserving deterministic record and replay behavior (#339, #350).
|
||||
- **Actionable desktop notifications**: Notification replacement identifiers and actions dispatch through the ordinary command path on macOS, Windows, and Linux (#347).
|
||||
@@ -31,8 +76,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented
|
||||
- @ElSebas41
|
||||
- @johnlindquist
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -209,6 +209,24 @@ pub fn build(b: *std.Build) void {
|
||||
const app_runner_window_placement_mod = module(b, target, optimize, "src/app_runner/window_placement.zig");
|
||||
app_runner_window_placement_mod.addImport("native_sdk", desktop_mod);
|
||||
const app_runner_window_placement_tests = testArtifact(b, app_runner_window_placement_mod);
|
||||
const app_runner_options = b.addOptions();
|
||||
app_runner_options.addOption([]const u8, "platform", "null");
|
||||
app_runner_options.addOption([]const u8, "trace", "off");
|
||||
app_runner_options.addOption([]const u8, "web_engine", "system");
|
||||
app_runner_options.addOption(bool, "debug_overlay", false);
|
||||
app_runner_options.addOption(bool, "automation", false);
|
||||
app_runner_options.addOption(bool, "web_layer", false);
|
||||
const app_runner_mod = module(b, target, optimize, "src/app_runner/root.zig");
|
||||
app_runner_mod.addImport("native_sdk", desktop_mod);
|
||||
app_runner_mod.addImport("build_options", app_runner_options.createModule());
|
||||
app_runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("tests/app-runner/menu_commands_fixture.zon") }));
|
||||
const app_runner_migrations_mod = module(b, target, optimize, "src/app_runner/no_migrations.zig");
|
||||
app_runner_migrations_mod.addImport("native_sdk", desktop_mod);
|
||||
app_runner_mod.addImport("relational_migrations", app_runner_migrations_mod);
|
||||
const app_runner_tests = testArtifact(b, app_runner_mod);
|
||||
const app_runner_test_run = b.addRunArtifact(app_runner_tests);
|
||||
const app_runner_test_step = b.step("test-app-runner", "Run framework app-runner manifest fallback tests");
|
||||
app_runner_test_step.dependOn(&app_runner_test_run.step);
|
||||
desktop_mod.link_libc = true;
|
||||
if (target.result.os.tag == .macos) {
|
||||
const flags: []const []const u8 = if (b.sysroot) |sysroot|
|
||||
@@ -607,6 +625,7 @@ pub fn build(b: *std.Build) void {
|
||||
test_step.dependOn(&b.addRunArtifact(json_tests).step);
|
||||
test_step.dependOn(&b.addRunArtifact(app_runner_assets_tests).step);
|
||||
test_step.dependOn(&b.addRunArtifact(app_runner_window_placement_tests).step);
|
||||
test_step.dependOn(&app_runner_test_run.step);
|
||||
test_step.dependOn(&b.addRunArtifact(canvas_tests).step);
|
||||
test_step.dependOn(&b.addRunArtifact(record_store_tests).step);
|
||||
test_step.dependOn(&file_crash_run.step);
|
||||
@@ -1513,6 +1532,14 @@ pub fn build(b: *std.Build) void {
|
||||
.{ .path = "src/platform/macos/root.zig", .pattern = ".high_contrast = event.high_contrast != 0" },
|
||||
.{ .path = "src/platform/macos/root.zig", .pattern = ".appearance_changed => state.emit" },
|
||||
});
|
||||
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-tray-segment-source-selection", "Verify both macOS tray hosts keep segmented selection model-owned", &.{
|
||||
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "@property(nonatomic, assign) NSInteger sourceSelectedSegment;" },
|
||||
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (options[i].selected != 0) control.sourceSelectedSegment = (NSInteger)i;" },
|
||||
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "for (NSInteger index = 0; index < control.segmentCount; index++) {\n [control setSelected:index == sourceSelected forSegment:index];\n }\n self.trayCallback" },
|
||||
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "@property(nonatomic, assign) NSInteger sourceSelectedSegment;" },
|
||||
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "if (options[i].selected != 0) control.sourceSelectedSegment = (NSInteger)i;" },
|
||||
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "for (NSInteger index = 0; index < control.segmentCount; index++) {\n [control setSelected:index == sourceSelected forSegment:index];\n }\n self.trayCallback" },
|
||||
});
|
||||
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-docs-builtin-bridge-policy", "Verify bridge policy docs include guarded dialog commands", &.{
|
||||
.{ .path = "docs/src/app/docs/security/page.mdx", .pattern = ".{ .name = \"native-sdk.dialog.saveFile\"" },
|
||||
.{ .path = "docs/src/app/docs/bridge/builtin-commands/page.mdx", .pattern = ".{ .name = \"native-sdk.dialog.saveFile\"" },
|
||||
@@ -2158,6 +2185,52 @@ pub fn build(b: *std.Build) void {
|
||||
native_shell_smoke_run.step.dependOn(&cli_exe.step);
|
||||
native_shell_smoke_step.dependOn(&native_shell_smoke_run.step);
|
||||
|
||||
const menu_bar_smoke_step = b.step("test-menu-bar-smoke", "Run zero-config TypeScript app-menu automation smoke test");
|
||||
const menu_bar_smoke_build = managedExampleRun(b, cli_exe, &.{ "build", "-Dplatform=macos", "-Dweb-engine=system", "-Dautomation=true", "-Doptimize=Debug" });
|
||||
menu_bar_smoke_build.setCwd(b.path("examples/menu-bar"));
|
||||
const menu_bar_smoke_run = b.addSystemCommand(&.{
|
||||
"sh", "-c",
|
||||
\\set -eu
|
||||
\\cd examples/menu-bar
|
||||
\\app="zig-out/bin/menu-bar"
|
||||
\\cli="$1"
|
||||
\\case "$cli" in /*) ;; *) cli="../../$cli" ;; esac
|
||||
\\automation_dir=".zig-cache/native-sdk-automation"
|
||||
\\mkdir -p "$automation_dir"
|
||||
\\rm -f "$automation_dir/snapshot.txt" "$automation_dir/accessibility.txt" "$automation_dir/windows.txt" "$automation_dir"/command*.txt
|
||||
\\"$app" > .zig-cache/native-sdk-menu-bar-smoke.log 2>&1 &
|
||||
\\pid=$!
|
||||
\\trap 'status=$?; kill "$pid" >/dev/null 2>&1 || true; wait "$pid" >/dev/null 2>&1 || true; if [ "$status" -ne 0 ]; then echo "---- app log (.zig-cache/native-sdk-menu-bar-smoke.log) ----" >&2; cat .zig-cache/native-sdk-menu-bar-smoke.log >&2 2>/dev/null || true; fi' EXIT
|
||||
\\ready="$("$cli" automate wait 2>&1)"
|
||||
\\case "$ready" in *"ready=true"*) ;; *) echo "menu-bar automation snapshot was not ready" >&2; exit 1 ;; esac
|
||||
\\before="$(cat "$automation_dir/snapshot.txt" 2>/dev/null || true)"
|
||||
\\case "$before" in *'command id="player.next" title="Next Track" enabled=true checked=false'*) ;; *) echo "app.zon command catalog was not loaded by the zero-config runner" >&2; exit 1 ;; esac
|
||||
\\case "$before" in *'app-menu title="Player" items=6'*) ;; *) echo "app.zon Player menu was not loaded by the zero-config runner" >&2; exit 1 ;; esac
|
||||
\\case "$before" in *'app-menu-item label="Next Track" command="player.next" enabled=true checked=false key="n" modifiers=(primary=true,command=false,control=false,option=false,shift=false)'*) ;; *) echo "app.zon player.next menu item was not loaded by the zero-config runner" >&2; exit 1 ;; esac
|
||||
\\case "$before" in
|
||||
\\ *'Ambient Coast'*) expected='Night Drive' ;;
|
||||
\\ *'Night Drive'*) expected='Paper Planes' ;;
|
||||
\\ *'Paper Planes'*) expected='Ambient Coast' ;;
|
||||
\\ *) echo "menu-bar snapshot did not expose the current TypeScript model track" >&2; exit 1 ;;
|
||||
\\esac
|
||||
\\"$cli" automate menu-command player.next >/dev/null 2>&1
|
||||
\\attempts=0
|
||||
\\while [ "$attempts" -lt 50 ]; do
|
||||
\\ snapshot="$(cat "$automation_dir/snapshot.txt" 2>/dev/null || true)"
|
||||
\\ case "$snapshot" in *"$expected"*) break ;; esac
|
||||
\\ attempts=$((attempts + 1))
|
||||
\\ sleep 0.1
|
||||
\\done
|
||||
\\case "$snapshot" in *"$expected"*) ;; *) echo "app.zon menu command did not reach the zero-config TypeScript commandMsg mapper" >&2; exit 1 ;; esac
|
||||
\\echo "menu-bar smoke ok"
|
||||
,
|
||||
"sh",
|
||||
});
|
||||
menu_bar_smoke_run.addFileArg(cli_exe.getEmittedBin());
|
||||
menu_bar_smoke_run.step.dependOn(&menu_bar_smoke_build.step);
|
||||
menu_bar_smoke_run.step.dependOn(&cli_exe.step);
|
||||
menu_bar_smoke_step.dependOn(&menu_bar_smoke_run.step);
|
||||
|
||||
const gpu_surface_smoke_step = b.step("test-gpu-surface-smoke", "Run macOS GPU surface automation smoke test");
|
||||
// The GPU smoke apps are managed examples (no build.zig of their own),
|
||||
// so their binaries come from the CLI verb. -Doptimize=Debug keeps the
|
||||
@@ -2648,7 +2721,7 @@ pub fn build(b: *std.Build) void {
|
||||
\\case "$ready_snapshot" in *'view @w1/components-canvas kind=gpu_surface'*'gpu_nonblank=true'*'canvas_frame_gpu_packet_representable=true'*) ;; *) echo "component gallery GPU surface was not ready" >&2; exit 1 ;; esac
|
||||
\\case "$ready_snapshot" in *'view @w1/main kind=webview'*) echo "component gallery created an implicit WebView" >&2; exit 1 ;; *) ;; esac
|
||||
\\"$cli" automate assert 'role=tree name="Components"' 'role=treeitem name="Components".*state=\[expanded\]' 'role=treeitem name="Accordion".*state=\[selected\]' 'role=group name="Details".*state=\[selected,expanded\]' 'name="Accordion details are visible. The model owns this expanded state."'
|
||||
\\"$cli" automate assert 'role=group name="Theme"' 'role=button name="Default".*state=\[selected\]' 'role=button name="Geist"'
|
||||
\\"$cli" automate assert 'role=group name="Theme pack"' 'role=group name="Color scheme"' 'role=group name="Theme accent"' 'role=button name="Default".*state=\[selected\]' 'role=button name="Geist"' 'role=button name="System".*state=\[selected\]' 'role=button name="Pink"' 'role=button name="Teal"'
|
||||
\\"$cli" automate screenshot components-canvas >/dev/null 2>&1
|
||||
\\cp "$automation_dir/screenshot-components-canvas.png" "$automation_dir/screenshot-components-house.png"
|
||||
\\rm -f "$automation_dir/screenshot-components-canvas.png"
|
||||
@@ -3515,6 +3588,9 @@ fn tsCoreE2eArtifact(
|
||||
// against (the same module the generated shims stage).
|
||||
conformance_mod.addImport("corewire_rt", module(b, target, optimize, "tools/corewire/shim_rt.zig"));
|
||||
conformance_mod.addImport("shim_markup_core", sidecarShimModule(b, target, optimize, corewire_exe, b.path("tests/sidecar/markup_fixture.contract.json")));
|
||||
// Compile-cost guard: this generated mirror carries 160 realistically
|
||||
// named Msg arms and must need no quota setting in app or test code.
|
||||
conformance_mod.addImport("shim_wide_core", sidecarShimModule(b, target, optimize, corewire_exe, b.path("tests/sidecar/wide_msg_fixture.contract.json")));
|
||||
// The integer-class fixture: a hand-written sidecar attesting mixed
|
||||
// i64/u64 slot classes, so the suite drives boundary and full-range
|
||||
// integer values through a generated mirror's decode paths.
|
||||
|
||||
@@ -311,11 +311,11 @@ The optional `commands` list declares shared command metadata. The runtime still
|
||||
|
||||
An app can define up to 256 commands. Command ids can be up to 128 bytes and titles can be up to 128 bytes.
|
||||
|
||||
Generated runners load manifest commands into `RuntimeOptions.commands`. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
|
||||
Generated zero-config TypeScript and Zig-core runners load manifest commands into `RuntimeOptions.commands`; ejected runners use the same fallback. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
|
||||
|
||||
## `shortcuts`
|
||||
|
||||
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated runners load these automatically:
|
||||
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
|
||||
|
||||
```zig
|
||||
.shortcuts = .{
|
||||
@@ -334,7 +334,7 @@ Chromium builds are currently macOS-only; use the Linux system WebView backend w
|
||||
|
||||
## `menus`
|
||||
|
||||
The optional `menus` list defines native app menus. Generated runners load these automatically:
|
||||
The optional `menus` list defines native app menus. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
|
||||
|
||||
```zig
|
||||
.menus = .{
|
||||
|
||||
@@ -36,7 +36,7 @@ When the runtime publishes a snapshot, it writes these files to the automation d
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>snapshot.txt</code></td>
|
||||
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
|
||||
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, the configured command and app-menu catalogs, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>accessibility.txt</code></td>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
`combobox` is a trigger-only primitive like [select](/docs/components/select), but the trigger is a text entry with a menu affordance: `on-input` names a Msg variant that receives every edit as a text-input event (`canvas.TextInputEvent` in a Zig core; the `TextInputEvent` union from `@native-sdk/core/text` in a TypeScript core), and the model filters the options as the user types. The options themselves are composed the same way as the select's — an anchored [dropdown-menu](/docs/components/dropdown-menu) of menu-items beside the trigger in a `stack`, rendered under an `if`, with `on-dismiss` clearing the model's open flag when Escape or a click outside closes the surface.
|
||||
|
||||
Enter submits when `on-submit` is bound; otherwise Enter opens the picker. Space and the open-arrow keys (Down/Up) always open it. Once focus moves into the open menu, Enter selects the focused `menu-item` as usual.
|
||||
|
||||
<ComponentPreview name="combobox" alt="A combobox rendered by the engine" caption="a combobox trigger with its search placeholder" />
|
||||
|
||||
## Markup
|
||||
@@ -13,8 +15,8 @@ The model owns the query and the open flag; the `for` source is the model-filter
|
||||
|
||||
```html
|
||||
<stack width="240">
|
||||
<combobox placeholder="Search frameworks" text="{framework_query}" on-input="framework_edited" on-press="open_framework_menu" />
|
||||
<if test="{framework_menu_open}">
|
||||
<combobox placeholder="Search frameworks" text="{frameworkQuery}" on-input="framework_edited" on-submit="commit_framework_query" on-press="open_framework_menu" />
|
||||
<if test="{frameworkMenuOpen}">
|
||||
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_framework_menu">
|
||||
<for each="matchingFrameworks" key="id" as="f">
|
||||
<menu-item on-press="pick_framework:{f.id}">{f.name}</menu-item>
|
||||
@@ -24,6 +26,19 @@ The model owns the query and the open flag; the `for` source is the model-filter
|
||||
</stack>
|
||||
```
|
||||
|
||||
In the primary TypeScript core, the submit arm commits the current model-owned query. The open and submit messages remain separate, so Enter can commit without toggling the picker:
|
||||
|
||||
```ts
|
||||
export type Msg =
|
||||
| { readonly kind: "framework_edited"; readonly edit: TextInputEvent }
|
||||
| { readonly kind: "open_framework_menu" }
|
||||
| { readonly kind: "close_framework_menu" }
|
||||
| { readonly kind: "commit_framework_query" };
|
||||
|
||||
case "commit_framework_query":
|
||||
return { ...model, committedFrameworkQuery: model.frameworkQuery, frameworkMenuOpen: false };
|
||||
```
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
|
||||
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_input` takes a comptime message constructor: `Ui.inputMsg(.tag)` builds `Msg{ .tag = edit }` for each `canvas.TextInputEvent`.
|
||||
@@ -34,6 +49,7 @@ ui.stack(.{ .width = 240 }, .{
|
||||
.placeholder = "Search frameworks",
|
||||
.text = model.framework_query,
|
||||
.on_input = Ui.inputMsg(.framework_edited),
|
||||
.on_submit = .commit_framework_query,
|
||||
.on_press = .open_framework_menu,
|
||||
}, .{}),
|
||||
if (model.framework_menu_open) ui.el(.dropdown_menu, .{
|
||||
@@ -56,6 +72,7 @@ ui.stack(.{ .width = 240 }, .{
|
||||
"disabled",
|
||||
"on-press",
|
||||
"on-input",
|
||||
"on-submit",
|
||||
"on-dismiss",
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,10 @@ pub fn email(model: *const Model) []const u8 {
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
## Editing keys
|
||||
|
||||
The built-in editor follows the platform keymap in `input`, `text-field`, `search-field`, and `combobox`: Backspace/Delete remove one caret unit, Option+Backspace/Delete on macOS (Ctrl+Backspace/Delete elsewhere) remove one word, and Command+Backspace on macOS (with or without Shift) deletes from the caret to the beginning of the field. A non-empty selection always wins and is deleted by itself. The semantic edit still arrives through `on-input`, so `applyTextInputEvent` and `TextBuffer` keep controlled fields synchronized and one undo restores the whole deletion.
|
||||
|
||||
## Search field
|
||||
|
||||
`search-field` renders the search affordance but binds exactly like an input; pair it with a model-filtered list. Whenever the field holds text it also shows a built-in clear affordance — a small x inside its trailing edge — and pressing it (or pressing Escape while focused) clears through the standard text-edit path, so the `on-input` handler receives the clear like any other edit and a model-owned buffer empties with it. No attribute enables or disables this; searchable fields simply carry it. For text entry that opens a menu of suggestions, see [combobox](/docs/components/combobox).
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. By default, Enter (and Shift+Enter) inserts a newline; when a textarea carries `on-submit`, submission rides Cmd+Enter on macOS or Ctrl+Enter elsewhere. Chat composers can set `submit-on-enter="true"`: plain Enter then submits, Shift+Enter still inserts a newline, and the primary chord still submits. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
|
||||
|
||||
Textarea editing uses the same platform shortcuts as [input](/docs/components/input#editing-keys). On macOS, Command+Backspace (with or without Shift) deletes from the caret to the beginning of the current hard-newline-delimited line (or deletes the active selection); soft-wrapped visual lines use their logical line start in this first version.
|
||||
|
||||
<ComponentPreview name="textarea" alt="A textarea rendered by the engine" />
|
||||
|
||||
## Markup
|
||||
|
||||
@@ -14,7 +14,7 @@ The Native SDK can configure native app menus from `app.zon` or typed Zig data.
|
||||
},
|
||||
```
|
||||
|
||||
Generated runners load `app.zon` menus automatically. Pass `menus` to `runWithOptions` when an app needs to override the manifest at runtime:
|
||||
Generated zero-config runners load `app.zon` menus automatically for both TypeScript cores and Zig cores. Ejected runners use the same fallback. Pass a non-null `menus` slice to `runWithOptions` when lower-level Zig wiring needs to override the manifest at runtime (an explicit empty slice disables manifest menus):
|
||||
|
||||
```zig
|
||||
const view_items = [_]native_sdk.MenuItem{
|
||||
|
||||
@@ -320,7 +320,7 @@ export function dropMsg(drop: FileDropEvent): Msg | null {
|
||||
}
|
||||
```
|
||||
|
||||
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. A host that cannot resolve the target view leaves `viewLabel` empty and `point` null.
|
||||
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. The macOS system host reports labeled, view-local points for canvas and WebView drops; an unlabeled window region keeps an empty `viewLabel` and reports a window-content point. A host that cannot resolve either leaves `viewLabel` empty and `point` null.
|
||||
|
||||
## Native scrolling and context menus
|
||||
|
||||
|
||||
@@ -28,21 +28,84 @@ const MyApp = native_sdk.UiApp(Model, Msg);
|
||||
.theme = app_runner.manifestThemePack(),
|
||||
```
|
||||
|
||||
In a zero-config TypeScript app, export a single-model `themePack` helper when the pack itself belongs in live app state. The generated launcher recognizes this helper and wires the stock-token path automatically:
|
||||
In a zero-config TypeScript app, export `themeState(model)` when pack, color scheme, or accent belongs in live app state. The generated launcher recognizes the helper and wires the stock-token path automatically:
|
||||
|
||||
```ts
|
||||
import { type ThemeState } from "@native-sdk/core/events";
|
||||
|
||||
export type ThemePack = "house" | "geist";
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
export interface Model {
|
||||
readonly theme: ThemePack;
|
||||
readonly themePreference: ThemePreference;
|
||||
readonly pinkAccent: boolean;
|
||||
}
|
||||
|
||||
export function themePack(model: Model): ThemePack {
|
||||
return model.theme;
|
||||
export function themeState(model: Model): ThemeState {
|
||||
if (model.pinkAccent) {
|
||||
return {
|
||||
pack: model.theme,
|
||||
colorScheme: model.themePreference,
|
||||
accent: "#df2670",
|
||||
};
|
||||
}
|
||||
return { pack: model.theme, colorScheme: model.themePreference };
|
||||
}
|
||||
```
|
||||
|
||||
Change `model.theme` through ordinary messages (for example, from a pair of model-driven `toggle-button`s). The helper is evaluated on every rebuild. It changes only the built-in pack: system light/dark, high contrast, reduced motion, manifest `theme_accent`, and each surface's scale remain live runtime inputs. Without the helper, `app.zon` remains the static pack choice.
|
||||
`ThemeState` has three optional fields:
|
||||
|
||||
```ts
|
||||
export type ThemeState = {
|
||||
readonly pack?: "house" | "geist";
|
||||
readonly colorScheme?: "light" | "dark" | "system";
|
||||
readonly accent?: string; // exactly #rrggbb
|
||||
};
|
||||
```
|
||||
|
||||
Change the model through ordinary messages (for example, model-driven `toggle-button`s). The helper is evaluated after every committed update. Omitted fields inherit the next lower layer; omitted `colorScheme` and `"system"` both follow the OS. A malformed accent is a loud runtime teaching error, never a silent fallback. High contrast and reduced motion remain live OS inputs; high contrast suppresses both manifest and model accent overrides so accessibility wins.
|
||||
|
||||
The stock-theme precedence is:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Axis</th>
|
||||
<th>Highest to lowest precedence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Complete tokens</td>
|
||||
<td><code>tokens_fn</code> → static <code>tokens</code> → stock theme composition below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pack</td>
|
||||
<td><code>themeState.pack</code> → <code>app.zon theme</code> → <code>house</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Color scheme</td>
|
||||
<td>forced <code>themeState.colorScheme</code> → OS appearance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Accent</td>
|
||||
<td><code>themeState.accent</code> → <code>app.zon theme_accent</code> → selected pack</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>High contrast / reduced motion</td>
|
||||
<td>OS appearance (high contrast suppresses accent overrides)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Surface scale / text measurement</td>
|
||||
<td>Runtime-stamped last</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
`themeState` deliberately controls canvas design tokens in v1. Native title bars and WebViews still follow the platform's effective appearance; forcing dark canvas content does not call `NSApp.appearance` or impose a scheme on embedded web content.
|
||||
|
||||
The earlier `themePack(model): "house" | "geist"` helper remains supported unchanged for apps that only switch packs. It preserves live OS scheme, manifest accent, high contrast, reduced motion, and surface scale. Export `themePack` or `themeState`, never both; the checker and adapter report that conflict as a teaching error.
|
||||
|
||||
Apps that derive their own tokens select the pack directly — `ThemeOptions.pack` is just another theme axis, exactly as switchable at runtime as the scheme:
|
||||
|
||||
@@ -113,7 +176,7 @@ pub fn brandTokens(scheme: canvas.ColorScheme, contrast: canvas.ColorContrast) c
|
||||
}
|
||||
```
|
||||
|
||||
Hand it to your app via `tokens_fn` (model-owned, follows the system scheme through your model) or `tokens` (fixed). The runtime stamps `pixel_snap.scale` and text measurement after your function runs, so never cache those.
|
||||
Hand it to your app via `tokens_fn` (model-owned, follows the system scheme through your model) or `tokens` (fixed). These complete-token paths take precedence over `themeState`; the runtime stamps `pixel_snap.scale` and text measurement after your function runs, so never cache those.
|
||||
|
||||
## What themes cannot do
|
||||
|
||||
|
||||
@@ -116,6 +116,21 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
|
||||
<td><code>ShortcutModifiers</code></td>
|
||||
<td><code>.{}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>segmented</code></td>
|
||||
<td><code>?TraySegmentedRow</code></td>
|
||||
<td><code>null</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>metric</code></td>
|
||||
<td><code>?TrayMetricRow</code></td>
|
||||
<td><code>null</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chart</code></td>
|
||||
<td><code>?TrayChartRow</code></td>
|
||||
<td><code>null</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -138,7 +153,7 @@ The singular `*Tray` methods are compatibility wrappers for reserved status-item
|
||||
|
||||
## TypeScript: model-derived status items
|
||||
|
||||
In a TypeScript app, export `statusItem(model)` from `src/core.ts`. The generated launcher installs it from the committed boot model and re-runs it after every model update. Shell, presentation, and menu are hashed independently, so changing the icon, tooltip, click hooks, title, width, tone, icon opacity, number style, or rows patches only that channel and never recreates the native status item.
|
||||
In a TypeScript app, export `statusItem(model)` from `src/core.ts`. The generated launcher installs it from the committed boot model and re-runs it after every model update. Shell, presentation, and menu are hashed independently, so changing the icon, tooltip, click hooks, title, width, tone, icon opacity, typography, or rows patches only that channel and never recreates the native status item.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
|
||||
@@ -157,6 +172,8 @@ export function statusItem(model: Model): StatusItemState {
|
||||
tone: model.failed ? "critical" : "normal",
|
||||
iconOpacity: model.stale ? 0.5 : 1,
|
||||
monospaced: true,
|
||||
fontSize: 13,
|
||||
fontWeight: "semibold",
|
||||
},
|
||||
items: [
|
||||
{ id: 10, label: model.today, command: asciiBytes(""), separator: false, enabled: false, detail: model.quota, role: "hero", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
@@ -171,7 +188,80 @@ export function statusItem(model: Model): StatusItemState {
|
||||
|
||||
For multiple independent items, export `statusItems(model): readonly StatusItemDescriptor[]` instead. Each descriptor has the same shell, presentation, and row fields plus a stable non-zero `id` and live `visible` flag. Presence creates, absence removes, and changed fields patch only that identifier; menus update without replacing their `NSStatusItem`. Export either `statusItem` or `statusItems`, not both. This is the Vercel-shaped split: one spend indicator can appear or disappear while a separate control-menu item persists.
|
||||
|
||||
Rows use the exact `StatusItemMenuItem` record. `role` is `command`, `info`, `header`, `hero`, `agent`, or `context`; capable macOS hosts render the readout roles as native rich content while simpler hosts degrade them to text. `detail` carries secondary readout content, and `key` plus the five explicit `modifiers` fields declares a menu equivalent. Actionable rows need unique non-zero ids; separators conventionally use id 0 and empty byte fields. The menu may contain at most 32 rows. Map every row/click/open command to an ordinary message with `commandMsg(name): Msg | null`; no Zig `status_item_fn` glue is needed.
|
||||
Persistent menu-bar composition uses that collection directly: return one descriptor for compact context text, another for a larger live metric, and another icon-only descriptor that owns the dropdown. Each descriptor independently chooses optional `fontSize` (omitted or `0` keeps the platform default), optional `fontWeight` (omitted means `regular`; otherwise `regular`, `medium`, `semibold`, or `bold`), `monospaced`, width, tone, and icon opacity. They remain separate native status items, so each can appear, disappear, or update without recreating its neighbors.
|
||||
|
||||
Rows use the exact `StatusItemMenuItem` record. `role` is `command`, `info`, `header`, `hero`, `agent`, `context`, `segmented`, or `chart`; capable macOS hosts render the readout roles as native rich content while simpler hosts degrade them to text. `detail` carries secondary readout content, and `key` plus the five explicit `modifiers` fields declares a menu equivalent. Actionable rows need unique non-zero ids; separators conventionally use id 0 and empty byte fields. The menu may contain at most 32 rows. Map every row/click/open command to an ordinary message with `commandMsg(name): Msg | null`; no Zig `status_item_fn` glue is needed.
|
||||
|
||||
### Typed rich rows
|
||||
|
||||
A segmented row carries its choices as data rather than encoding them into `label` or `detail`. Each option has its own stable `id`, label, command, selected state, and enabled state. macOS renders the row as `NSSegmentedControl`; selecting a segment emits its option id through the same tray-action → command → `commandMsg` route as an ordinary command row. Other hosts may expose the options as separate command items.
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 0,
|
||||
label: asciiBytes(""),
|
||||
command: asciiBytes(""),
|
||||
separator: false,
|
||||
enabled: true,
|
||||
detail: asciiBytes(""),
|
||||
role: "segmented",
|
||||
key: asciiBytes(""),
|
||||
modifiers: { primary: false, command: false, control: false, option: false, shift: false },
|
||||
segmented: {
|
||||
options: [
|
||||
{ id: 20, label: asciiBytes("Day"), command: asciiBytes("range.day"), selected: model.range === "day", enabled: true },
|
||||
{ id: 21, label: asciiBytes("Week"), command: asciiBytes("range.week"), selected: model.range === "week", enabled: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A typed metric row keeps prominent primary and secondary text inside the dropdown, separate from the persistent menu-bar title:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 0,
|
||||
label: asciiBytes(""),
|
||||
command: asciiBytes(""),
|
||||
separator: false,
|
||||
enabled: false,
|
||||
detail: asciiBytes(""),
|
||||
role: "hero",
|
||||
key: asciiBytes(""),
|
||||
modifiers: { primary: false, command: false, control: false, option: false, shift: false },
|
||||
metric: {
|
||||
primaryText: asciiBytes("2,494 requests"),
|
||||
secondaryText: utf8Bytes("Today · production"),
|
||||
accessibilityLabel: asciiBytes("2,494 requests today in production"),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A chart row carries 1–32 finite values plus an explicit numeric domain, leading caption, trailing summary, and required accessibility label. Every value must fall inside `minValue...maxValue`. The macOS system host draws native AppKit bars in a custom `NSView`; hosts without custom tray rows retain the caption and summary as text.
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 0,
|
||||
label: asciiBytes(""),
|
||||
command: asciiBytes(""),
|
||||
separator: false,
|
||||
enabled: false,
|
||||
detail: asciiBytes(""),
|
||||
role: "chart",
|
||||
key: asciiBytes(""),
|
||||
modifiers: { primary: false, command: false, control: false, option: false, shift: false },
|
||||
chart: {
|
||||
values: model.cpuHistory,
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
leadingCaption: asciiBytes("CPU"),
|
||||
trailingSummary: model.cpuSummary,
|
||||
accessibilityLabel: model.cpuAccessibility,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A segmented row admits at most eight options and at most one selected option. Option ids share the enclosing menu’s command-id namespace, including other segmented rows. The 32-row menu budget is also the fallback budget: a segmented row consumes one fallback row per option, preventing silent truncation on plain-menu hosts.
|
||||
|
||||
Use `utf8Bytes` for titles, labels, tooltips, and details; it preserves characters such as `…`, `·`, and emoji as UTF-8. Use `asciiBytes` for guaranteed-ASCII command names, keys, paths, and empty byte fields. Passing non-ASCII literal/template text to `asciiBytes` is an NS1064 build error.
|
||||
|
||||
|
||||
@@ -463,7 +463,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
activationCommand: asciiBytes("app.sync"),
|
||||
alternateActivationCommand: asciiBytes(""),
|
||||
openCommand: asciiBytes("app.sync"),
|
||||
presentation: { title: model.syncing ? utf8Bytes("SYNC…") : utf8Bytes("READY"), width: 62, tone: model.failed ? "critical" : "normal", iconOpacity: model.stale ? 0.5 : 1, monospaced: true },
|
||||
presentation: { title: model.syncing ? utf8Bytes("SYNC…") : utf8Bytes("READY"), width: 62, tone: model.failed ? "critical" : "normal", iconOpacity: model.stale ? 0.5 : 1, monospaced: true, fontSize: 13, fontWeight: "semibold" },
|
||||
items: [
|
||||
{ id: 1, label: utf8Bytes("Open"), command: asciiBytes("app.open"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 2, label: utf8Bytes("Sync now…"), command: asciiBytes("app.sync"), separator: false, enabled: !model.syncing, detail: asciiBytes(""), role: "command", key: asciiBytes("r"), modifiers: { primary: true, command: false, control: false, option: false, shift: false } },
|
||||
@@ -472,7 +472,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
}
|
||||
```
|
||||
|
||||
Import the canonical records and unions from `@native-sdk/core/events`. Presentation includes byte `title`, numeric `width`, `normal | warning | critical` tone, `iconOpacity` in 0…1, and `monospaced`. Rows include id/label/command/separator/enabled plus secondary `detail`, semantic `role`, key equivalent, and all five modifier booleans. Actionable ids are unique and non-zero, and there are at most 32 rows. `commandMsg(name): Msg | null` maps row selection, status-button activation, Option-activation, and menu-open refresh into the ordinary update loop. See [System Tray](/docs/tray) and the zero-Zig `examples/menu-bar` app for the full hide/Open/Quit lifecycle.
|
||||
Import the canonical records and unions from `@native-sdk/core/events`. Presentation includes byte `title`, numeric `width`, tone, `iconOpacity`, `monospaced`, `fontSize`, and `fontWeight`; `statusItems` composes several independently styled persistent menu-bar items. Rows include id/label/command/separator/enabled plus secondary `detail`, semantic `role`, key equivalent, and all five modifier booleans. Actionable ids are unique and non-zero, and there are at most 32 rows. `commandMsg(name): Msg | null` maps row selection, status-button activation, Option-activation, and menu-open refresh into the ordinary update loop. See [System Tray](/docs/tray).
|
||||
|
||||
Export `statusItems(model): readonly StatusItemDescriptor[]` when the app needs several independent items. Each descriptor adds stable non-zero `id` identity and a live `visible` flag to the same shell/presentation/menu record. Adding/removing descriptors creates/removes only those ids; icon, title, tooltip, visibility, activation/open commands, and menu changes patch in place. Export either the singular or collection helper, not both. macOS supports up to eight simultaneous items; every item keeps its own 32-row menu.
|
||||
|
||||
@@ -536,7 +536,7 @@ A markup text control (`<text-field text="{draft}" on-input="draft_edit" />`) ne
|
||||
|
||||
## Splitting a core into modules
|
||||
|
||||
A core that outgrows one file splits into modules under `src/` except `src/services/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). The core may not import service files, even type-only; shared subset-legal shapes live in an ordinary core-class module which a service may import. Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the core's flat namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, `themePack` / `statusItem` / `statusItems` / `windows`, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event and shell vocabulary (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`, status-item records, and `WindowDescriptor`) so no core re-types it — compiled into your core when imported and absent when not.
|
||||
A core that outgrows one file splits into modules under `src/` except `src/services/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). The core may not import service files, even type-only; shared subset-legal shapes live in an ordinary core-class module which a service may import. Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the core's flat namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, `themeState` / `themePack` / `statusItem` / `statusItems` / `windows`, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event and shell vocabulary (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, `ThemeState`, the chrome records, `AudioState`/`AudioEvent`, status-item records, and `WindowDescriptor`) so no core re-types it — compiled into your core when imported and absent when not.
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ This example shows guarded OS capabilities from trusted WebView code:
|
||||
- Clipboard text read and write.
|
||||
- Message dialogs.
|
||||
- Credential set, get, and delete.
|
||||
- File-drop events delivered to Zig and the WebView event bridge.
|
||||
- File-drop events delivered to Zig and the WebView event bridge, plus a real canvas `drop_files` target.
|
||||
- File association and custom URL scheme packaging metadata.
|
||||
- App activation and deactivation events.
|
||||
|
||||
@@ -24,6 +24,8 @@ Run the headless test path:
|
||||
zig build test -Dplatform=null
|
||||
```
|
||||
|
||||
For the macOS host integration check, run the app with the system backend and drag a Finder file onto the right-hand **Drop files here** canvas. The status bar must report `Widget target 2 fired` and the dropped path. Dropping over the left WebView must still report the ordinary app-level drop without a widget target. The guest-VM harness cannot synthesize an AppKit drag session yet, so this is the documented manual receipt for the real host path.
|
||||
|
||||
Run all native-first example tests from the repository root:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"webview",
|
||||
"js_bridge",
|
||||
"native_views",
|
||||
"gpu_surfaces",
|
||||
"open_url",
|
||||
"reveal_path",
|
||||
"recent_documents",
|
||||
@@ -39,11 +40,13 @@
|
||||
.title = "Native SDK Capabilities",
|
||||
.width = 900,
|
||||
.height = 620,
|
||||
.min_width = 770,
|
||||
.restore_policy = "center_on_primary",
|
||||
.views = .{
|
||||
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true },
|
||||
.{ .label = "statusbar", .kind = "statusbar", .edge = "bottom", .height = 34, .role = "Status" },
|
||||
.{ .label = "status-label", .kind = "label", .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .text = "Ready." },
|
||||
.{ .label = "drop-canvas", .kind = "gpu_surface", .edge = "right", .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = "metal" },
|
||||
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true, .min_width = 520 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,10 @@ const manifest_url_schemes = if (@hasField(@TypeOf(app_manifest), "url_schemes")
|
||||
|
||||
const window_width: f32 = 900;
|
||||
const window_height: f32 = 620;
|
||||
const window_min_width: f32 = 770;
|
||||
const statusbar_height: f32 = 34;
|
||||
const drop_canvas_label = "drop-canvas";
|
||||
const drop_target_id: native_sdk.canvas.ObjectId = 2;
|
||||
|
||||
const html =
|
||||
\\<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -54,24 +57,30 @@ const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
|
||||
.{ .name = "native-sdk.credentials.delete", .permissions = &credential_permission, .origins = &bridge_origins },
|
||||
};
|
||||
const shell_views = [_]native_sdk.ShellView{
|
||||
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true },
|
||||
.{ .label = "statusbar", .kind = .statusbar, .edge = .bottom, .height = statusbar_height, .layer = 20, .role = "Status" },
|
||||
.{ .label = "status-label", .kind = .label, .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .layer = 21, .text = "Ready." },
|
||||
.{ .label = drop_canvas_label, .kind = .gpu_surface, .edge = .right, .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = .metal },
|
||||
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true, .min_width = 520 },
|
||||
};
|
||||
const shell_windows = [_]native_sdk.ShellWindow{.{
|
||||
.label = "main",
|
||||
.title = "Native SDK Capabilities",
|
||||
.width = window_width,
|
||||
.height = window_height,
|
||||
.min_width = window_min_width,
|
||||
.views = &shell_views,
|
||||
}};
|
||||
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
|
||||
|
||||
const CapabilitiesApp = struct {
|
||||
drop_count: u32 = 0,
|
||||
widget_drop_count: u32 = 0,
|
||||
activation_count: u32 = 0,
|
||||
deactivation_count: u32 = 0,
|
||||
last_drop_paths: []const []const u8 = &.{},
|
||||
last_drop_target_id: native_sdk.canvas.ObjectId = 0,
|
||||
pending_drop_target_id: ?native_sdk.canvas.ObjectId = null,
|
||||
drop_target_installed: bool = false,
|
||||
|
||||
fn app(self: *@This()) native_sdk.App {
|
||||
return .{
|
||||
@@ -96,9 +105,34 @@ const CapabilitiesApp = struct {
|
||||
self.last_drop_paths = drop.paths;
|
||||
var status_buffer: [160]u8 = undefined;
|
||||
const first_path = if (drop.paths.len > 0) drop.paths[0] else "";
|
||||
const status = try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
|
||||
const drop_target_id_value = self.pending_drop_target_id;
|
||||
self.pending_drop_target_id = null;
|
||||
const status = if (drop_target_id_value) |target_id|
|
||||
try std.fmt.bufPrint(&status_buffer, "Widget target {d} fired; app drop {d}: {d} file(s): {s}", .{ target_id, self.drop_count, drop.paths.len, first_path })
|
||||
else
|
||||
try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
|
||||
_ = try runtime.updateView(drop.window_id, "status-label", .{ .text = status });
|
||||
},
|
||||
.canvas_widget_file_drop => |drop| {
|
||||
self.widget_drop_count += 1;
|
||||
if (drop.target) |target| {
|
||||
self.last_drop_target_id = target.id;
|
||||
self.pending_drop_target_id = target.id;
|
||||
} else {
|
||||
self.pending_drop_target_id = null;
|
||||
}
|
||||
},
|
||||
.gpu_surface_frame => |frame| {
|
||||
if (!self.drop_target_installed and std.mem.eql(u8, frame.label, drop_canvas_label)) {
|
||||
try installDropTarget(runtime, frame.window_id, frame.label, frame.size);
|
||||
self.drop_target_installed = true;
|
||||
}
|
||||
},
|
||||
.gpu_surface_resized => |resize| {
|
||||
if (self.drop_target_installed and std.mem.eql(u8, resize.label, drop_canvas_label)) {
|
||||
try installDropTarget(runtime, resize.window_id, resize.label, resize.frame.size());
|
||||
}
|
||||
},
|
||||
.lifecycle => |lifecycle| switch (lifecycle) {
|
||||
.activate => {
|
||||
self.activation_count += 1;
|
||||
@@ -110,11 +144,31 @@ const CapabilitiesApp = struct {
|
||||
},
|
||||
else => {},
|
||||
},
|
||||
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_frame, .gpu_surface_resized, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
|
||||
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn installDropTarget(runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, label: []const u8, size: native_sdk.geometry.SizeF) !void {
|
||||
const canvas = native_sdk.canvas;
|
||||
const margin: f32 = 24;
|
||||
const target = canvas.Widget{
|
||||
.id = drop_target_id,
|
||||
.kind = .button,
|
||||
.frame = native_sdk.geometry.RectF.init(margin, margin, @max(1, size.width - margin * 2), @max(1, size.height - margin * 2)),
|
||||
.text = "Drop files here",
|
||||
.semantics = .{ .label = "Drop files here", .actions = .{ .drop_files = true } },
|
||||
};
|
||||
var nodes: [2]canvas.WidgetLayoutNode = undefined;
|
||||
const layout = try canvas.layoutWidgetTree(
|
||||
.{ .id = 1, .kind = .panel, .children = &.{target} },
|
||||
native_sdk.geometry.RectF.init(0, 0, size.width, size.height),
|
||||
&nodes,
|
||||
);
|
||||
_ = try runtime.setCanvasWidgetLayout(window_id, label, layout);
|
||||
_ = try runtime.emitCanvasWidgetDisplayList(window_id, label, .{});
|
||||
}
|
||||
|
||||
pub fn main(init: std.process.Init) !void {
|
||||
var app = CapabilitiesApp{};
|
||||
try runner.runWithOptions(app.app(), .{
|
||||
@@ -139,6 +193,7 @@ pub fn main(init: std.process.Init) !void {
|
||||
test "capabilities bridge gates native services and dispatches file drops" {
|
||||
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{ .size = native_sdk.geometry.SizeF.init(window_width, window_height) });
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
harness.runtime.options.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies };
|
||||
harness.runtime.options.security = .{
|
||||
.permissions = &app_permissions,
|
||||
@@ -155,6 +210,30 @@ test "capabilities bridge gates native services and dispatches file drops" {
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
|
||||
var views_buffer: [8]native_sdk.ViewInfo = undefined;
|
||||
const views = harness.runtime.listViews(1, &views_buffer);
|
||||
const webview = viewByLabel(views, "main").?;
|
||||
const drop_canvas = viewByLabel(views, drop_canvas_label).?;
|
||||
const statusbar = viewByLabel(views, "statusbar").?;
|
||||
try std.testing.expect(webview.parent == null);
|
||||
try std.testing.expect(drop_canvas.parent == null);
|
||||
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, 0, 650, window_height - statusbar_height), webview.frame);
|
||||
try std.testing.expectEqual(native_sdk.geometry.RectF.init(650, 0, 250, window_height - statusbar_height), drop_canvas.frame);
|
||||
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, window_height - statusbar_height, window_width, statusbar_height), statusbar.frame);
|
||||
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
|
||||
.window_id = 1,
|
||||
.label = drop_canvas_label,
|
||||
.size = native_sdk.geometry.SizeF.init(250, window_height - statusbar_height),
|
||||
.frame_index = 1,
|
||||
.nonblank = true,
|
||||
} });
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_resized = .{
|
||||
.window_id = 1,
|
||||
.label = drop_canvas_label,
|
||||
.frame = native_sdk.geometry.RectF.init(0, 0, 350, window_height - statusbar_height),
|
||||
} });
|
||||
|
||||
try dispatchBridge(harness, app, "{\"id\":\"notify\",\"command\":\"native-sdk.os.showNotification\",\"payload\":{\"title\":\"Capabilities\",\"subtitle\":\"native-sdk\",\"body\":\"Done\"}}");
|
||||
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
|
||||
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.notificationCount());
|
||||
@@ -194,15 +273,37 @@ test "capabilities bridge gates native services and dispatches file drops" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":true") != null);
|
||||
|
||||
const dropped_paths = [_][]const u8{ "/tmp/one\nname.txt", "/tmp/two.txt" };
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
|
||||
.window_id = 1,
|
||||
.paths = &dropped_paths,
|
||||
} });
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{
|
||||
.files_dropped = .{
|
||||
.window_id = 1,
|
||||
.view_label = drop_canvas_label,
|
||||
// x=300 is outside the initial 250-point surface and proves the
|
||||
// resize event rebuilt the retained widget hit-test geometry.
|
||||
.point = native_sdk.geometry.PointF.init(300, 40),
|
||||
.paths = &dropped_paths,
|
||||
},
|
||||
});
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
|
||||
try std.testing.expectEqual(drop_target_id, app_state.last_drop_target_id);
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.drop_count);
|
||||
try std.testing.expectEqual(@as(usize, 2), app_state.last_drop_paths.len);
|
||||
try std.testing.expectEqualStrings("/tmp/one\nname.txt", app_state.last_drop_paths[0]);
|
||||
try std.testing.expectEqualStrings("/tmp/two.txt", app_state.last_drop_paths[1]);
|
||||
try std.testing.expectEqualStrings("drop:files", harness.null_platform.lastWindowEventName());
|
||||
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Widget target 2 fired"));
|
||||
try std.testing.expect(app_state.pending_drop_target_id == null);
|
||||
|
||||
const webview_paths = [_][]const u8{"/tmp/webview.txt"};
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
|
||||
.window_id = 1,
|
||||
.view_label = "main",
|
||||
.point = native_sdk.geometry.PointF.init(40, 40),
|
||||
.paths = &webview_paths,
|
||||
} });
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
|
||||
try std.testing.expect(app_state.pending_drop_target_id == null);
|
||||
try std.testing.expectEqual(@as(u32, 2), app_state.drop_count);
|
||||
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Received file drop 2"));
|
||||
|
||||
try harness.runtime.dispatchPlatformEvent(app, .app_activated);
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.activation_count);
|
||||
@@ -213,6 +314,9 @@ test "capabilities bridge gates native services and dispatches file drops" {
|
||||
}
|
||||
|
||||
test "capabilities manifest declares package integration metadata" {
|
||||
try std.testing.expectEqual(window_min_width, app_manifest.shell.windows[0].min_width);
|
||||
try std.testing.expectEqual(window_min_width, shell_windows[0].min_width);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), manifest_file_associations.len);
|
||||
try std.testing.expectEqualStrings("Native SDK Capability Document", manifest_file_associations[0].name);
|
||||
try std.testing.expectEqualStrings("viewer", manifest_file_associations[0].role);
|
||||
@@ -231,3 +335,17 @@ fn dispatchBridge(harness: *native_sdk.TestHarness(), app: native_sdk.App, bytes
|
||||
.webview_label = "main",
|
||||
} });
|
||||
}
|
||||
|
||||
fn nullViewText(harness: *native_sdk.TestHarness(), label: []const u8) []const u8 {
|
||||
for (harness.null_platform.views[0..harness.null_platform.view_count]) |view| {
|
||||
if (std.mem.eql(u8, view.label, label)) return view.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
fn viewByLabel(views: []const native_sdk.ViewInfo, label: []const u8) ?native_sdk.ViewInfo {
|
||||
for (views) |view| {
|
||||
if (std.mem.eql(u8, view.label, label)) return view;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
An isolated gallery of the built-in Native UI components, authored entirely in **TypeScript + Native markup**. There is no app-owned Zig: `src/core.ts` owns controlled state, `src/app.native` owns the component tree and specimens, and `app.zon` describes the desktop shell.
|
||||
|
||||
The left pane has a live Default/Geist theme-pack selector and a real disclosure `tree` whose rows use the built-in roving keyboard focus and scroll-into-view behavior. The right pane renders only the selected component. The selector changes the pack in the TypeScript model while the runtime keeps following system appearance. Accordion disclosure, dropdown/select/combobox menus, modal surfaces, fields, sliders, tabs, lists, and the focused Tree specimen are all interactive examples of the public markup API.
|
||||
The left pane has live pack, System/Light/Dark scheme, and accent selectors backed by the TypeScript model and one `themeState(model)` helper. “Default” accent inherits the purple `app.zon` accent; Pink and Teal override it. The pane also contains a real disclosure `tree` whose rows use the built-in roving keyboard focus and scroll-into-view behavior. The right pane renders only the selected component. Accordion disclosure, dropdown/select/combobox menus, modal surfaces, fields, sliders, tabs, lists, and the focused Tree specimen are all interactive examples of the public markup API.
|
||||
|
||||
Run the app with the repository CLI:
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
.display_name = "GPU Components",
|
||||
.description = "An isolated gallery of interactive Native UI components authored in TypeScript and Native markup.",
|
||||
.version = "0.1.0",
|
||||
.theme_accent = "#7c3aed",
|
||||
.platforms = .{"macos"},
|
||||
.permissions = .{ "view", "command" },
|
||||
.capabilities = .{ "native_views", "gpu_surfaces" },
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,22 @@
|
||||
<row background="background">
|
||||
<column width="248" padding="12" gap="8" background="surface" label="Component navigation">
|
||||
<column gap="6">
|
||||
<text foreground="text_muted">Theme</text>
|
||||
<toggle-group gap="2" label="Theme">
|
||||
<toggle-button size="sm" selected="{themePack == 'house'}" on-toggle="theme_house">Default</toggle-button>
|
||||
<toggle-button size="sm" selected="{themePack == 'geist'}" on-toggle="theme_geist">Geist</toggle-button>
|
||||
<text foreground="text_muted">Pack</text>
|
||||
<toggle-group gap="2" label="Theme pack">
|
||||
<toggle-button size="sm" selected="{theme == 'house'}" on-toggle="theme_house">Default</toggle-button>
|
||||
<toggle-button size="sm" selected="{theme == 'geist'}" on-toggle="theme_geist">Geist</toggle-button>
|
||||
</toggle-group>
|
||||
<text foreground="text_muted">Scheme</text>
|
||||
<toggle-group gap="2" label="Color scheme">
|
||||
<toggle-button size="sm" selected="{themeColorScheme == 'system'}" on-toggle="theme_system">System</toggle-button>
|
||||
<toggle-button size="sm" selected="{themeColorScheme == 'light'}" on-toggle="theme_light">Light</toggle-button>
|
||||
<toggle-button size="sm" selected="{themeColorScheme == 'dark'}" on-toggle="theme_dark">Dark</toggle-button>
|
||||
</toggle-group>
|
||||
<text foreground="text_muted">Accent</text>
|
||||
<toggle-group gap="2" label="Theme accent">
|
||||
<toggle-button size="sm" selected="{themeAccent == 'manifest'}" on-toggle="accent_manifest">Default</toggle-button>
|
||||
<toggle-button size="sm" selected="{themeAccent == 'pink'}" on-toggle="accent_pink">Pink</toggle-button>
|
||||
<toggle-button size="sm" selected="{themeAccent == 'teal'}" on-toggle="accent_teal">Teal</toggle-button>
|
||||
</toggle-group>
|
||||
</column>
|
||||
<separator />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// controlled component state and the messages produced by interaction.
|
||||
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
import { type ThemeState } from "@native-sdk/core/events";
|
||||
import {
|
||||
applyTextInputEvent,
|
||||
clampedInsertEvent,
|
||||
@@ -109,6 +110,8 @@ function applyDraft(value: Draft, event: TextInputEvent): Draft {
|
||||
|
||||
export type Density = "default" | "comfortable";
|
||||
export type ThemePack = "house" | "geist";
|
||||
export type ThemeColorScheme = "system" | "light" | "dark";
|
||||
export type ThemeAccent = "manifest" | "pink" | "teal";
|
||||
export type DropdownChoice = "none" | "duplicate" | "rename" | "download" | "delete";
|
||||
export type SelectChoice = "production" | "staging" | "development";
|
||||
export type Tab = "account" | "password" | "team";
|
||||
@@ -120,6 +123,8 @@ export interface Model {
|
||||
readonly components: readonly ComponentItem[];
|
||||
readonly selectedComponentId: number;
|
||||
readonly theme: ThemePack;
|
||||
readonly themeColorScheme: ThemeColorScheme;
|
||||
readonly themeAccent: ThemeAccent;
|
||||
readonly catalogExpanded: boolean;
|
||||
readonly accordionOpen: boolean;
|
||||
readonly checkboxChecked: boolean;
|
||||
@@ -157,6 +162,12 @@ export type Msg =
|
||||
| { readonly kind: "select_component"; readonly componentId: number }
|
||||
| { readonly kind: "theme_house" }
|
||||
| { readonly kind: "theme_geist" }
|
||||
| { readonly kind: "theme_system" }
|
||||
| { readonly kind: "theme_light" }
|
||||
| { readonly kind: "theme_dark" }
|
||||
| { readonly kind: "accent_manifest" }
|
||||
| { readonly kind: "accent_pink" }
|
||||
| { readonly kind: "accent_teal" }
|
||||
| { readonly kind: "toggle_catalog" }
|
||||
| { readonly kind: "action" }
|
||||
| { readonly kind: "toggle_accordion" }
|
||||
@@ -220,7 +231,6 @@ export type Msg =
|
||||
|
||||
// These records are intentionally read only through binding helpers.
|
||||
export const viewUnbound = [
|
||||
"theme",
|
||||
"comboboxDraft",
|
||||
"inputDraft",
|
||||
"textareaDraft",
|
||||
@@ -232,6 +242,8 @@ export function initialModel(): Model {
|
||||
components: COMPONENTS,
|
||||
selectedComponentId: 1,
|
||||
theme: "house",
|
||||
themeColorScheme: "system",
|
||||
themeAccent: "manifest",
|
||||
catalogExpanded: true,
|
||||
accordionOpen: true,
|
||||
checkboxChecked: true,
|
||||
@@ -285,11 +297,17 @@ export function selectedLabel(model: Model): Uint8Array {
|
||||
return asciiBytes("Component");
|
||||
}
|
||||
|
||||
// The default TypeScript launcher recognizes this exported single-model
|
||||
// helper and selects the built-in pack on every rebuild. System light/dark,
|
||||
// contrast, reduced-motion, accent, and surface scale remain runtime-owned.
|
||||
export function themePack(model: Model): ThemePack {
|
||||
return model.theme;
|
||||
// One model helper owns the stock theme's author-facing axes. Omitting the
|
||||
// accent inherits app.zon's theme_accent; `system` follows the OS. High
|
||||
// contrast/reduced motion and each surface's scale stay runtime-owned.
|
||||
export function themeState(model: Model): ThemeState {
|
||||
if (model.themeAccent === "pink") {
|
||||
return { pack: model.theme, colorScheme: model.themeColorScheme, accent: "#df2670" };
|
||||
}
|
||||
if (model.themeAccent === "teal") {
|
||||
return { pack: model.theme, colorScheme: model.themeColorScheme, accent: "#00786f" };
|
||||
}
|
||||
return { pack: model.theme, colorScheme: model.themeColorScheme };
|
||||
}
|
||||
|
||||
export function selectLabel(model: Model): Uint8Array {
|
||||
@@ -338,6 +356,18 @@ export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
return [{ ...model, theme: "house" }, Cmd.none];
|
||||
case "theme_geist":
|
||||
return [{ ...model, theme: "geist" }, Cmd.none];
|
||||
case "theme_system":
|
||||
return [{ ...model, themeColorScheme: "system" }, Cmd.none];
|
||||
case "theme_light":
|
||||
return [{ ...model, themeColorScheme: "light" }, Cmd.none];
|
||||
case "theme_dark":
|
||||
return [{ ...model, themeColorScheme: "dark" }, Cmd.none];
|
||||
case "accent_manifest":
|
||||
return [{ ...model, themeAccent: "manifest" }, Cmd.none];
|
||||
case "accent_pink":
|
||||
return [{ ...model, themeAccent: "pink" }, Cmd.none];
|
||||
case "accent_teal":
|
||||
return [{ ...model, themeAccent: "teal" }, Cmd.none];
|
||||
case "toggle_catalog":
|
||||
return [{ ...model, catalogExpanded: !model.catalogExpanded }, Cmd.none];
|
||||
case "action":
|
||||
|
||||
@@ -4,6 +4,15 @@ This three-column board is authored entirely in TypeScript + Native markup. `src
|
||||
|
||||
Drop one or more files anywhere on the board to add their basenames as Todo cards. The desktop host sends the native file-drop event through the runtime, and the core's `dropMsg` maps the full path list into one deterministic `files_dropped` message before `update` changes the board.
|
||||
|
||||
To manually verify the real macOS host path (the guest-VM harness cannot synthesize an AppKit drag session yet):
|
||||
|
||||
1. Run `native dev` on macOS.
|
||||
2. Drag a file from Finder onto a visible card in the canvas, not the titlebar.
|
||||
3. Verify a new Todo card appears with the dropped file's basename.
|
||||
4. Repeat over empty board space; the app-level drop still works there.
|
||||
|
||||
The first drop traverses the labeled `kanban-canvas` destination with a view-local, top-left-origin point before the app-level `dropMsg` runs. That is the same host data widget `drop_files` hit-testing consumes; the second verifies the ordinary canvas-level fallback remains intact.
|
||||
|
||||
Drag any card within a column or across Todo, Doing, and Done. The card itself lifts under the pointer at full opacity, leaving one blank, card-sized slot behind. As the pointer reaches another candidate position, that same reserved slot moves from the source to the candidate and neighboring cards glide around it—there are never two spaces for one card. On release, the same floating card eases from the pointer into the slot. Press Escape during a drag to cancel it and carry the card back to its source slot. Cards can move forwards, backwards, or directly across the board.
|
||||
|
||||
Each card represents an agent-owned ticket: the title sits above a compact metadata row with a Jira/Linear-style issue key and the assigned OpenAI or Claude avatar. The avatar artwork is rasterized from SVGL's [OpenAI](https://svgl.app/library/openai.svg) and [Claude AI](https://svgl.app/library/claude-ai-icon.svg) SVGs so it can travel through the app manifest's static image channel.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ This app is the complete hide-to-tray lifecycle in the default TypeScript + Nati
|
||||
- `app.zon` sets `dock_visible = false`, so macOS selects Accessory before creating a window and no Dock tile flashes.
|
||||
- The main window starts with `initially_hidden = true` and uses `close_policy = "hide"`; the status item is the only open/re-show affordance.
|
||||
- `src/core.ts` exports `statusItem(model)`, whose presentation and menu update from committed playback state.
|
||||
- Tray commands pass through `commandMsg`; Open uses `Cmd.showWindow("main")`, and Quit uses `Cmd.quitApp()` for graceful termination.
|
||||
- `app.zon` declares the app command catalog, native Player menu, and keyboard shortcuts; the zero-config runner loads all three automatically.
|
||||
- App-menu, shortcut, and tray commands pass through `commandMsg`; Open uses `Cmd.showWindow("main")`, and Quit uses `Cmd.quitApp()` for graceful termination.
|
||||
- Playback changes issue `Cmd.persist()`, so the engine restores the last playing/track state from its atomic app-data snapshot on the next launch.
|
||||
- `src/app.native` is the ordinary player window. No app-owned Zig glue is involved.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
.display_name = "Menu Bar",
|
||||
.version = "0.1.0",
|
||||
.platforms = .{"macos"},
|
||||
.capabilities = .{ "native_views", "gpu_surfaces", "tray", "persist" },
|
||||
.capabilities = .{ "native_views", "gpu_surfaces", "tray", "persist", "menus", "shortcuts" },
|
||||
// Launch as an Accessory app. The activation policy is selected before
|
||||
// the startup window exists, so no Dock tile or cmd+Tab entry flashes.
|
||||
.dock_visible = false,
|
||||
@@ -16,6 +16,32 @@
|
||||
.err = "restore_failed",
|
||||
},
|
||||
},
|
||||
// The zero-config framework runner reads all three declarations and
|
||||
// routes them through core.ts's commandMsg mapper. No runner.zig is
|
||||
// needed in the app tree.
|
||||
.commands = .{
|
||||
.{ .id = "app.open", .title = "Open Player" },
|
||||
.{ .id = "player.toggle", .title = "Toggle Playback" },
|
||||
.{ .id = "player.next", .title = "Next Track" },
|
||||
.{ .id = "app.quit", .title = "Quit" },
|
||||
},
|
||||
.shortcuts = .{
|
||||
.{ .id = "player.toggle", .key = "p", .modifiers = .{ "primary" } },
|
||||
.{ .id = "player.next", .key = "n", .modifiers = .{ "primary" } },
|
||||
},
|
||||
.menus = .{
|
||||
.{
|
||||
.title = "Player",
|
||||
.items = .{
|
||||
.{ .label = "Open Player", .command = "app.open" },
|
||||
.{ .separator = true },
|
||||
.{ .label = "Play or Pause", .command = "player.toggle", .key = "p", .modifiers = .{ "primary" } },
|
||||
.{ .label = "Next Track", .command = "player.next", .key = "n", .modifiers = .{ "primary" } },
|
||||
.{ .separator = true },
|
||||
.{ .label = "Quit", .command = "app.quit", .key = "q", .modifiers = .{ "primary" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
.shell = .{
|
||||
.windows = .{
|
||||
.{
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "TypeScript + Native markup menu-bar lifecycle example.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.1"
|
||||
"@native-sdk/core": "0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@native-sdk/core",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@native-sdk/core",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"dependencies": {
|
||||
"scriptc": "0.0.31"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/core",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The TypeScript authoring tier: the app-core subset, its checker and contract frontend, the exact-pinned core compiler dependency, and the SDK module cores import",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Vendored
+40
-4
@@ -1,6 +1,14 @@
|
||||
export type { TextCaretDirection, TextCaretMove, TextSelection, TextInputEvent } from "./text.js";
|
||||
export type ThemeStatePack = "house" | "geist";
|
||||
export type ThemeStateColorScheme = "light" | "dark" | "system";
|
||||
export type ThemeState = {
|
||||
readonly pack?: ThemeStatePack;
|
||||
readonly colorScheme?: ThemeStateColorScheme;
|
||||
readonly accent?: string;
|
||||
};
|
||||
export type StatusItemTone = "normal" | "warning" | "critical";
|
||||
export type StatusItemMenuRole = "command" | "info" | "header" | "hero" | "agent" | "context";
|
||||
export type StatusItemFontWeight = "regular" | "medium" | "semibold" | "bold";
|
||||
export type StatusItemMenuRole = "command" | "info" | "header" | "hero" | "agent" | "context" | "segmented" | "chart";
|
||||
export interface StatusItemModifiers {
|
||||
readonly primary: boolean;
|
||||
readonly command: boolean;
|
||||
@@ -8,14 +16,39 @@ export interface StatusItemModifiers {
|
||||
readonly option: boolean;
|
||||
readonly shift: boolean;
|
||||
}
|
||||
export interface StatusItemPresentation {
|
||||
export type StatusItemPresentation = {
|
||||
readonly title: Uint8Array;
|
||||
readonly width: number;
|
||||
readonly tone: StatusItemTone;
|
||||
readonly iconOpacity: number;
|
||||
readonly monospaced: boolean;
|
||||
readonly fontSize?: number;
|
||||
readonly fontWeight?: StatusItemFontWeight;
|
||||
};
|
||||
export interface StatusItemSegmentOption {
|
||||
readonly id: number;
|
||||
readonly label: Uint8Array;
|
||||
readonly command: Uint8Array;
|
||||
readonly selected: boolean;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
export interface StatusItemMenuItem {
|
||||
export interface StatusItemSegmentedRow {
|
||||
readonly options: readonly StatusItemSegmentOption[];
|
||||
}
|
||||
export interface StatusItemMetricRow {
|
||||
readonly primaryText: Uint8Array;
|
||||
readonly secondaryText: Uint8Array;
|
||||
readonly accessibilityLabel: Uint8Array;
|
||||
}
|
||||
export interface StatusItemChartRow {
|
||||
readonly values: readonly number[];
|
||||
readonly minValue: number;
|
||||
readonly maxValue: number;
|
||||
readonly leadingCaption: Uint8Array;
|
||||
readonly trailingSummary: Uint8Array;
|
||||
readonly accessibilityLabel: Uint8Array;
|
||||
}
|
||||
export type StatusItemMenuItem = {
|
||||
readonly id: number;
|
||||
readonly label: Uint8Array;
|
||||
readonly command: Uint8Array;
|
||||
@@ -25,7 +58,10 @@ export interface StatusItemMenuItem {
|
||||
readonly role: StatusItemMenuRole;
|
||||
readonly key: Uint8Array;
|
||||
readonly modifiers: StatusItemModifiers;
|
||||
}
|
||||
readonly segmented?: StatusItemSegmentedRow;
|
||||
readonly metric?: StatusItemMetricRow;
|
||||
readonly chart?: StatusItemChartRow;
|
||||
};
|
||||
export interface StatusItemState {
|
||||
readonly iconPath: Uint8Array;
|
||||
readonly tooltip: Uint8Array;
|
||||
|
||||
+71
-11
@@ -37,6 +37,8 @@
|
||||
// - `AppearanceEvent`, `ChromeEvent`, `AudioEvent`: the full arm
|
||||
// payload shapes, canonical and importable for helper signatures
|
||||
// (an arm value is structurally assignable to its event record).
|
||||
// - `ThemeState`: the model-derived built-in theme axes returned by
|
||||
// `themeState(model)`; omitted fields inherit the manifest/system.
|
||||
// - `StatusItemState`, `StatusItemDescriptor`, and their nested records:
|
||||
// the model-derived shell returned by `statusItem(model)` or keyed
|
||||
// `statusItems(model)`; the generated launcher refreshes shell,
|
||||
@@ -44,15 +46,31 @@
|
||||
|
||||
export type { TextCaretDirection, TextCaretMove, TextSelection, TextInputEvent } from "./text.ts";
|
||||
|
||||
/// The stock theme axes a TypeScript core may derive from committed model
|
||||
/// state. Omit `pack`/`accent` to inherit app.zon; omit `colorScheme` (or
|
||||
/// return `"system"`) to follow the OS. High contrast and reduced motion
|
||||
/// remain system-owned, and high contrast suppresses accent overrides.
|
||||
export type ThemeStatePack = "house" | "geist";
|
||||
|
||||
export type ThemeStateColorScheme = "light" | "dark" | "system";
|
||||
|
||||
export type ThemeState = {
|
||||
readonly pack?: ThemeStatePack;
|
||||
readonly colorScheme?: ThemeStateColorScheme;
|
||||
readonly accent?: string;
|
||||
};
|
||||
|
||||
/// One row in a menu-bar status item's menu. Non-separator rows need a
|
||||
/// unique non-zero `id`, a non-empty `label`, and (when actionable) a
|
||||
/// command name accepted by `commandMsg`. Rich readout roles may carry
|
||||
/// secondary `detail`; command rows may carry a key equivalent. Spell
|
||||
/// every field explicitly because app-core records have one exact shape.
|
||||
/// secondary `detail`; command rows may carry a key equivalent. Core row
|
||||
/// records have one exact shape; only the documented rich payloads and
|
||||
/// presentation typography may be omitted.
|
||||
/// A status item may expose at most 32 rows.
|
||||
export type StatusItemTone = "normal" | "warning" | "critical";
|
||||
export type StatusItemFontWeight = "regular" | "medium" | "semibold" | "bold";
|
||||
|
||||
export type StatusItemMenuRole = "command" | "info" | "header" | "hero" | "agent" | "context";
|
||||
export type StatusItemMenuRole = "command" | "info" | "header" | "hero" | "agent" | "context" | "segmented" | "chart";
|
||||
|
||||
export interface StatusItemModifiers {
|
||||
readonly primary: boolean;
|
||||
@@ -62,15 +80,46 @@ export interface StatusItemModifiers {
|
||||
readonly shift: boolean;
|
||||
}
|
||||
|
||||
export interface StatusItemPresentation {
|
||||
export type StatusItemPresentation = {
|
||||
readonly title: Uint8Array;
|
||||
readonly width: number;
|
||||
readonly tone: StatusItemTone;
|
||||
readonly iconOpacity: number;
|
||||
readonly monospaced: boolean;
|
||||
/// Omit to keep the platform menu-bar default size.
|
||||
readonly fontSize?: number;
|
||||
/// Omit to keep regular weight.
|
||||
readonly fontWeight?: StatusItemFontWeight;
|
||||
};
|
||||
|
||||
export interface StatusItemSegmentOption {
|
||||
readonly id: number;
|
||||
readonly label: Uint8Array;
|
||||
readonly command: Uint8Array;
|
||||
readonly selected: boolean;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export interface StatusItemMenuItem {
|
||||
export interface StatusItemSegmentedRow {
|
||||
readonly options: readonly StatusItemSegmentOption[];
|
||||
}
|
||||
|
||||
export interface StatusItemMetricRow {
|
||||
readonly primaryText: Uint8Array;
|
||||
readonly secondaryText: Uint8Array;
|
||||
readonly accessibilityLabel: Uint8Array;
|
||||
}
|
||||
|
||||
export interface StatusItemChartRow {
|
||||
readonly values: readonly number[];
|
||||
readonly minValue: number;
|
||||
readonly maxValue: number;
|
||||
readonly leadingCaption: Uint8Array;
|
||||
readonly trailingSummary: Uint8Array;
|
||||
readonly accessibilityLabel: Uint8Array;
|
||||
}
|
||||
|
||||
export type StatusItemMenuItem = {
|
||||
readonly id: number;
|
||||
readonly label: Uint8Array;
|
||||
readonly command: Uint8Array;
|
||||
@@ -80,7 +129,13 @@ export interface StatusItemMenuItem {
|
||||
readonly role: StatusItemMenuRole;
|
||||
readonly key: Uint8Array;
|
||||
readonly modifiers: StatusItemModifiers;
|
||||
}
|
||||
/// Present exactly when `role` is `segmented`.
|
||||
readonly segmented?: StatusItemSegmentedRow;
|
||||
/// Present on a `hero` row to declare a typed dropdown metric block.
|
||||
readonly metric?: StatusItemMetricRow;
|
||||
/// Present exactly when `role` is `chart`.
|
||||
readonly chart?: StatusItemChartRow;
|
||||
};
|
||||
|
||||
/// The generated launcher's model-derived menu-bar status item. Export
|
||||
/// `statusItem(model: Model): StatusItemState` from `src/core.ts`; its
|
||||
@@ -247,8 +302,10 @@ export interface PinchEvent {
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/// A file drop's optional point in view-local canvas coordinates. Desktop
|
||||
/// hosts that only know the target window leave `FileDropEvent.point` null.
|
||||
/// A file drop's optional point in top-left-origin local coordinates. A
|
||||
/// non-empty `FileDropEvent.viewLabel` names its canvas or WebView coordinate
|
||||
/// space; an empty label may carry window-content coordinates. Hosts that
|
||||
/// cannot resolve local coordinates leave `FileDropEvent.point` null.
|
||||
export interface FileDropPoint {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
@@ -256,9 +313,12 @@ export interface FileDropPoint {
|
||||
|
||||
/// The app-level file-drop channel's record (`dropMsg(drop)`). `windowId`
|
||||
/// and `viewLabel` identify the source; `point` is present when the host can
|
||||
/// resolve view-local coordinates. Paths are byte text so arbitrary UTF-8
|
||||
/// filesystem names cross without becoming runtime JS strings. Return null
|
||||
/// from `dropMsg` to ignore a drop, or map it to an ordinary Msg.
|
||||
/// resolve local coordinates. The macOS system host reports labeled canvas or
|
||||
/// WebView coordinates and degrades unlabeled regions to window-content
|
||||
/// coordinates; hosts without either leave `point` null. Paths are byte text
|
||||
/// so arbitrary UTF-8 filesystem names cross without becoming runtime JS
|
||||
/// strings. Return null from `dropMsg` to ignore a drop, or map it to an
|
||||
/// ordinary Msg.
|
||||
export interface FileDropEvent {
|
||||
readonly windowId: number;
|
||||
readonly viewLabel: string;
|
||||
|
||||
Vendored
+4
@@ -22,6 +22,10 @@ export type TextInputEvent = {
|
||||
readonly kind: "delete_word_backward";
|
||||
} | {
|
||||
readonly kind: "delete_word_forward";
|
||||
} | {
|
||||
readonly kind: "delete_to_start";
|
||||
} | {
|
||||
readonly kind: "delete_to_line_start";
|
||||
} | {
|
||||
readonly kind: "clear";
|
||||
} | {
|
||||
|
||||
@@ -56,6 +56,8 @@ export type TextInputEvent =
|
||||
| { readonly kind: "delete_forward" }
|
||||
| { readonly kind: "delete_word_backward" }
|
||||
| { readonly kind: "delete_word_forward" }
|
||||
| { readonly kind: "delete_to_start" }
|
||||
| { readonly kind: "delete_to_line_start" }
|
||||
| { readonly kind: "clear" }
|
||||
| { readonly kind: "move_caret"; readonly move: TextCaretMove }
|
||||
| { readonly kind: "set_selection"; readonly selection: TextSelection }
|
||||
@@ -417,6 +419,51 @@ function deleteWordForwardTextEdit(state: TextEditState, capacity: number): Text
|
||||
);
|
||||
}
|
||||
|
||||
function textLineStartOffset(text: Uint8Array, offset: number): number {
|
||||
let cursor = snapTextOffset(text, offset);
|
||||
while (cursor > 0 && text[cursor - 1] !== 0x0a) cursor -= 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function deleteToStartTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
||||
const range = activeTextReplaceRange(state);
|
||||
if (!rangeIsCollapsed(range, state.text.length)) {
|
||||
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
||||
}
|
||||
const caret = snapTextCaretOffset(state.text, state.selection.focus);
|
||||
if (caret === 0) {
|
||||
return { text: state.text, selection: caretSelectionAt(0, 0), composition: null };
|
||||
}
|
||||
return replaceTextEditRange(
|
||||
state,
|
||||
{ start: 0, end: caret },
|
||||
new Uint8Array(0),
|
||||
capacity,
|
||||
null,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function deleteToLineStartTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
||||
const range = activeTextReplaceRange(state);
|
||||
if (!rangeIsCollapsed(range, state.text.length)) {
|
||||
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
||||
}
|
||||
const caret = snapTextCaretOffset(state.text, state.selection.focus);
|
||||
const lineStart = textLineStartOffset(state.text, caret);
|
||||
if (lineStart === caret) {
|
||||
return { text: state.text, selection: caretSelectionAt(caret, caret), composition: null };
|
||||
}
|
||||
return replaceTextEditRange(
|
||||
state,
|
||||
{ start: lineStart, end: caret },
|
||||
new Uint8Array(0),
|
||||
capacity,
|
||||
null,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function moveTextCaret(state: TextEditState, move: TextCaretMove): TextEditState {
|
||||
const range = selectionRange(state.selection, state.text.length);
|
||||
const focus = snapTextCaretOffset(state.text, state.selection.focus);
|
||||
@@ -472,6 +519,10 @@ export function applyTextInputEvent(
|
||||
return deleteWordBackwardTextEdit(normalized, capacity);
|
||||
case "delete_word_forward":
|
||||
return deleteWordForwardTextEdit(normalized, capacity);
|
||||
case "delete_to_start":
|
||||
return deleteToStartTextEdit(normalized, capacity);
|
||||
case "delete_to_line_start":
|
||||
return deleteToLineStartTextEdit(normalized, capacity);
|
||||
case "clear":
|
||||
return { text: new Uint8Array(0), selection: { anchor: 0, focus: 0 }, composition: null };
|
||||
case "move_caret":
|
||||
|
||||
@@ -507,6 +507,7 @@ export class SubsetChecker {
|
||||
this.checkModelBindingSurface();
|
||||
this.checkMigrationHook();
|
||||
this.checkThemePackHelper();
|
||||
this.checkThemeStateHelper();
|
||||
this.checkStatusItemHelper();
|
||||
this.checkStatusItemsHelper();
|
||||
this.checkWindowsHelper();
|
||||
@@ -843,6 +844,55 @@ export class SubsetChecker {
|
||||
}
|
||||
}
|
||||
|
||||
/// `themeState(model)` subsumes themePack with one exact, projection-safe
|
||||
/// record. Optional properties are intentional: omission is the manifest /
|
||||
/// system inheritance signal that crosses the helper ABI as null.
|
||||
private checkThemeStateHelper(): void {
|
||||
const decl = this.entryExportedFunction("themeState");
|
||||
if (decl === null) return;
|
||||
|
||||
if (this.table.modelHelperDecls().some((candidate) => candidate.name === "themePack")) {
|
||||
this.report("NS1033", "Export either `themePack` or `themeState`, not both.", decl.name ?? decl);
|
||||
}
|
||||
|
||||
const helper = this.table.modelHelperDecls().find(
|
||||
(candidate) => candidate.name === "themeState" && candidate.decl === decl,
|
||||
);
|
||||
if (helper === undefined || decl.type === undefined) {
|
||||
this.report(
|
||||
"NS1033",
|
||||
"`themeState` is not a single-Model-parameter helper with an explicit return type.",
|
||||
decl.name ?? decl,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const returns = this.table.resolveTypeNode(decl.type);
|
||||
const state = returns.k === "struct" ? this.table.structs.get(returns.name) : undefined;
|
||||
const names = state?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
const field = (name: string) => state?.fields.find((candidate) => candidate.tsName === name);
|
||||
const pack = field("pack");
|
||||
const colorScheme = field("colorScheme");
|
||||
const accent = field("accent");
|
||||
const optionalEnumMembersAre = (candidate: typeof pack, expected: readonly string[]): boolean => {
|
||||
if (candidate?.type.k !== "optional" || candidate.type.inner.k !== "enum") return false;
|
||||
const found = this.table.enums.get(candidate.type.inner.name)?.members.slice().sort() ?? [];
|
||||
return found.join(",") === expected.slice().sort().join(",");
|
||||
};
|
||||
const valid =
|
||||
names.join(",") === "accent,colorScheme,pack" &&
|
||||
optionalEnumMembersAre(pack, ["house", "geist"]) &&
|
||||
optionalEnumMembersAre(colorScheme, ["light", "dark", "system"]) &&
|
||||
accent?.type.k === "optional" && accent.type.inner.k === "string";
|
||||
if (!valid) {
|
||||
this.report(
|
||||
"NS1033",
|
||||
"`themeState` must return the exact canonical `ThemeState` record (`pack?`, `colorScheme?`, `accent?`); import it from `@native-sdk/core/events`.",
|
||||
decl.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistence migration is a pure entry hook, not a model helper. It
|
||||
/// receives the previous canonical snapshot and monotonic schema version;
|
||||
/// returning the current Model succeeds, while throwing closes as
|
||||
@@ -942,6 +992,8 @@ export class SubsetChecker {
|
||||
const tone = presentation?.fields.find((field) => field.tsName === "tone");
|
||||
const iconOpacity = presentation?.fields.find((field) => field.tsName === "iconOpacity");
|
||||
const monospaced = presentation?.fields.find((field) => field.tsName === "monospaced");
|
||||
const fontSize = presentation?.fields.find((field) => field.tsName === "fontSize");
|
||||
const fontWeight = presentation?.fields.find((field) => field.tsName === "fontWeight");
|
||||
const itemType = items?.type.k === "slice" && items.type.elem.k === "struct" ? items.type.elem : null;
|
||||
const item = itemType === null ? undefined : this.table.structs.get(itemType.name);
|
||||
const itemNames = item?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
@@ -954,9 +1006,27 @@ export class SubsetChecker {
|
||||
const role = item?.fields.find((field) => field.tsName === "role");
|
||||
const key = item?.fields.find((field) => field.tsName === "key");
|
||||
const modifiersField = item?.fields.find((field) => field.tsName === "modifiers");
|
||||
const segmentedField = item?.fields.find((field) => field.tsName === "segmented");
|
||||
const metricField = item?.fields.find((field) => field.tsName === "metric");
|
||||
const chartField = item?.fields.find((field) => field.tsName === "chart");
|
||||
const modifiersType = modifiersField?.type.k === "struct" ? modifiersField.type : null;
|
||||
const modifiers = modifiersType === null ? undefined : this.table.structs.get(modifiersType.name);
|
||||
const modifierNames = modifiers?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
const optionalStruct = (field: typeof segmentedField) =>
|
||||
field?.type.k === "optional" && field.type.inner.k === "struct" ? this.table.structs.get(field.type.inner.name) : undefined;
|
||||
const segmented = optionalStruct(segmentedField);
|
||||
const segmentedNames = segmented?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
const segmentOptions = segmented?.fields.find((field) => field.tsName === "options");
|
||||
const segmentOptionType = segmentOptions?.type.k === "slice" && segmentOptions.type.elem.k === "struct" ? segmentOptions.type.elem : null;
|
||||
const segmentOption = segmentOptionType === null ? undefined : this.table.structs.get(segmentOptionType.name);
|
||||
const segmentOptionNames = segmentOption?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
const segmentOptionField = (name: string) => segmentOption?.fields.find((field) => field.tsName === name);
|
||||
const chart = optionalStruct(chartField);
|
||||
const chartNames = chart?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
const chartFieldNamed = (name: string) => chart?.fields.find((field) => field.tsName === name);
|
||||
const metric = optionalStruct(metricField);
|
||||
const metricNames = metric?.fields.map((field) => field.tsName).sort() ?? [];
|
||||
const metricFieldNamed = (name: string) => metric?.fields.find((field) => field.tsName === name);
|
||||
const allByteFields = [iconPath, tooltip, activationCommand, alternateActivationCommand, openCommand].every(
|
||||
(field) => field?.type.k === "bytes",
|
||||
);
|
||||
@@ -969,27 +1039,54 @@ export class SubsetChecker {
|
||||
const numericId = id !== undefined && ["number", "i64", "f64", "numAlias"].includes(id.type.k);
|
||||
const numericWidth = width !== undefined && ["number", "i64", "f64", "numAlias"].includes(width.type.k);
|
||||
const numericOpacity = iconOpacity !== undefined && ["number", "i64", "f64", "numAlias"].includes(iconOpacity.type.k);
|
||||
const optionalNumeric = (field: typeof fontSize): boolean =>
|
||||
field?.type.k === "optional" && ["number", "i64", "f64", "numAlias"].includes(field.type.inner.k);
|
||||
const optionalEnumMembersAre = (field: typeof fontWeight, expected: readonly string[]): boolean => {
|
||||
if (field?.type.k !== "optional" || field.type.inner.k !== "enum") return false;
|
||||
const found = this.table.enums.get(field.type.inner.name)?.members.slice().sort() ?? [];
|
||||
return found.join(",") === expected.slice().sort().join(",");
|
||||
};
|
||||
const valid =
|
||||
stateNames.join(",") === "activationCommand,alternateActivationCommand,iconPath,items,openCommand,presentation,tooltip" &&
|
||||
allByteFields &&
|
||||
presentationNames.join(",") === "iconOpacity,monospaced,title,tone,width" &&
|
||||
presentationNames.join(",") === "fontSize,fontWeight,iconOpacity,monospaced,title,tone,width" &&
|
||||
title?.type.k === "bytes" &&
|
||||
numericWidth &&
|
||||
enumMembersAre(tone, ["normal", "warning", "critical"]) &&
|
||||
numericOpacity &&
|
||||
monospaced?.type.k === "bool" &&
|
||||
optionalNumeric(fontSize) &&
|
||||
optionalEnumMembersAre(fontWeight, ["regular", "medium", "semibold", "bold"]) &&
|
||||
item !== undefined &&
|
||||
itemNames.join(",") === "command,detail,enabled,id,key,label,modifiers,role,separator" &&
|
||||
itemNames.join(",") === "chart,command,detail,enabled,id,key,label,metric,modifiers,role,segmented,separator" &&
|
||||
numericId &&
|
||||
label?.type.k === "bytes" &&
|
||||
command?.type.k === "bytes" &&
|
||||
separator?.type.k === "bool" &&
|
||||
enabled?.type.k === "bool" &&
|
||||
detail?.type.k === "bytes" &&
|
||||
enumMembersAre(role, ["command", "info", "header", "hero", "agent", "context"]) &&
|
||||
enumMembersAre(role, ["command", "info", "header", "hero", "agent", "context", "segmented", "chart"]) &&
|
||||
key?.type.k === "bytes" &&
|
||||
modifierNames.join(",") === "command,control,option,primary,shift" &&
|
||||
boolModifierFields;
|
||||
boolModifierFields &&
|
||||
segmentedNames.join(",") === "options" &&
|
||||
segmentOptionNames.join(",") === "command,enabled,id,label,selected" &&
|
||||
segmentOptionField("id") !== undefined && ["number", "i64", "f64", "numAlias"].includes(segmentOptionField("id")!.type.k) &&
|
||||
segmentOptionField("label")?.type.k === "bytes" &&
|
||||
segmentOptionField("command")?.type.k === "bytes" &&
|
||||
segmentOptionField("selected")?.type.k === "bool" &&
|
||||
segmentOptionField("enabled")?.type.k === "bool" &&
|
||||
metricNames.join(",") === "accessibilityLabel,primaryText,secondaryText" &&
|
||||
metricFieldNamed("primaryText")?.type.k === "bytes" &&
|
||||
metricFieldNamed("secondaryText")?.type.k === "bytes" &&
|
||||
metricFieldNamed("accessibilityLabel")?.type.k === "bytes" &&
|
||||
chartNames.join(",") === "accessibilityLabel,leadingCaption,maxValue,minValue,trailingSummary,values" &&
|
||||
chartFieldNamed("values")?.type.k === "slice" && chartFieldNamed("values")?.type.elem.k === "number" &&
|
||||
chartFieldNamed("minValue") !== undefined && ["number", "i64", "f64", "numAlias"].includes(chartFieldNamed("minValue")!.type.k) &&
|
||||
chartFieldNamed("maxValue") !== undefined && ["number", "i64", "f64", "numAlias"].includes(chartFieldNamed("maxValue")!.type.k) &&
|
||||
chartFieldNamed("leadingCaption")?.type.k === "bytes" &&
|
||||
chartFieldNamed("trailingSummary")?.type.k === "bytes" &&
|
||||
chartFieldNamed("accessibilityLabel")?.type.k === "bytes";
|
||||
if (!valid) {
|
||||
this.report(
|
||||
"NS1033",
|
||||
@@ -1302,6 +1399,8 @@ export class SubsetChecker {
|
||||
const tone = presentationFieldNamed("tone");
|
||||
const iconOpacity = presentationFieldNamed("iconOpacity");
|
||||
const monospaced = presentationFieldNamed("monospaced");
|
||||
const fontSize = presentationFieldNamed("fontSize");
|
||||
const fontWeight = presentationFieldNamed("fontWeight");
|
||||
const items = field("items");
|
||||
const itemType = items?.type.k === "slice" && items.type.elem.k === "struct" ? items.type.elem : null;
|
||||
const item = itemType === null ? undefined : this.table.structs.get(itemType.name);
|
||||
@@ -1316,41 +1415,87 @@ export class SubsetChecker {
|
||||
const role = itemField("role");
|
||||
const key = itemField("key");
|
||||
const modifiersField = itemField("modifiers");
|
||||
const segmentedField = itemField("segmented");
|
||||
const metricField = itemField("metric");
|
||||
const chartField = itemField("chart");
|
||||
const modifiersType = modifiersField?.type.k === "struct" ? modifiersField.type : null;
|
||||
const modifiers = modifiersType === null ? undefined : this.table.structs.get(modifiersType.name);
|
||||
const modifierNames = modifiers?.fields.map((candidate) => candidate.tsName).sort() ?? [];
|
||||
const optionalStruct = (candidate: typeof segmentedField) =>
|
||||
candidate?.type.k === "optional" && candidate.type.inner.k === "struct" ? this.table.structs.get(candidate.type.inner.name) : undefined;
|
||||
const segmented = optionalStruct(segmentedField);
|
||||
const segmentedNames = segmented?.fields.map((candidate) => candidate.tsName).sort() ?? [];
|
||||
const segmentOptions = segmented?.fields.find((candidate) => candidate.tsName === "options");
|
||||
const segmentOptionType = segmentOptions?.type.k === "slice" && segmentOptions.type.elem.k === "struct" ? segmentOptions.type.elem : null;
|
||||
const segmentOption = segmentOptionType === null ? undefined : this.table.structs.get(segmentOptionType.name);
|
||||
const segmentOptionNames = segmentOption?.fields.map((candidate) => candidate.tsName).sort() ?? [];
|
||||
const segmentOptionField = (name: string) => segmentOption?.fields.find((candidate) => candidate.tsName === name);
|
||||
const chart = optionalStruct(chartField);
|
||||
const chartNames = chart?.fields.map((candidate) => candidate.tsName).sort() ?? [];
|
||||
const chartFieldNamed = (name: string) => chart?.fields.find((candidate) => candidate.tsName === name);
|
||||
const chartValues = chartFieldNamed("values");
|
||||
const metric = optionalStruct(metricField);
|
||||
const metricNames = metric?.fields.map((candidate) => candidate.tsName).sort() ?? [];
|
||||
const metricFieldNamed = (name: string) => metric?.fields.find((candidate) => candidate.tsName === name);
|
||||
const numericId = id !== undefined && ["number", "i64", "f64", "numAlias"].includes(id.type.k);
|
||||
const numericItemId = itemId !== undefined && ["number", "i64", "f64", "numAlias"].includes(itemId.type.k);
|
||||
const numericWidth = width !== undefined && ["number", "i64", "f64", "numAlias"].includes(width.type.k);
|
||||
const numericOpacity = iconOpacity !== undefined && ["number", "i64", "f64", "numAlias"].includes(iconOpacity.type.k);
|
||||
const optionalNumeric = (candidate: typeof fontSize): boolean =>
|
||||
candidate?.type.k === "optional" && ["number", "i64", "f64", "numAlias"].includes(candidate.type.inner.k);
|
||||
const enumMembersAre = (candidate: typeof tone, expected: readonly string[]): boolean => {
|
||||
if (candidate?.type.k !== "enum") return false;
|
||||
const found = this.table.enums.get(candidate.type.name)?.members.slice().sort() ?? [];
|
||||
return found.join(",") === expected.slice().sort().join(",");
|
||||
};
|
||||
const optionalEnumMembersAre = (candidate: typeof fontWeight, expected: readonly string[]): boolean => {
|
||||
if (candidate?.type.k !== "optional" || candidate.type.inner.k !== "enum") return false;
|
||||
const found = this.table.enums.get(candidate.type.inner.name)?.members.slice().sort() ?? [];
|
||||
return found.join(",") === expected.slice().sort().join(",");
|
||||
};
|
||||
const boolModifierFields = modifiers !== undefined && modifiers.fields.every((candidate) => candidate.type.k === "bool");
|
||||
const valid =
|
||||
names.join(",") === "activationCommand,alternateActivationCommand,iconPath,id,items,openCommand,presentation,tooltip,visible" &&
|
||||
numericId &&
|
||||
visible?.type.k === "bool" &&
|
||||
byteFields.every((candidate) => candidate?.type.k === "bytes") &&
|
||||
presentationNames.join(",") === "iconOpacity,monospaced,title,tone,width" &&
|
||||
presentationNames.join(",") === "fontSize,fontWeight,iconOpacity,monospaced,title,tone,width" &&
|
||||
title?.type.k === "bytes" &&
|
||||
numericWidth &&
|
||||
enumMembersAre(tone, ["normal", "warning", "critical"]) &&
|
||||
numericOpacity &&
|
||||
monospaced?.type.k === "bool" &&
|
||||
itemNames.join(",") === "command,detail,enabled,id,key,label,modifiers,role,separator" &&
|
||||
optionalNumeric(fontSize) &&
|
||||
optionalEnumMembersAre(fontWeight, ["regular", "medium", "semibold", "bold"]) &&
|
||||
itemNames.join(",") === "chart,command,detail,enabled,id,key,label,metric,modifiers,role,segmented,separator" &&
|
||||
numericItemId &&
|
||||
label?.type.k === "bytes" &&
|
||||
command?.type.k === "bytes" &&
|
||||
separator?.type.k === "bool" &&
|
||||
enabled?.type.k === "bool" &&
|
||||
detail?.type.k === "bytes" &&
|
||||
enumMembersAre(role, ["command", "info", "header", "hero", "agent", "context"]) &&
|
||||
enumMembersAre(role, ["command", "info", "header", "hero", "agent", "context", "segmented", "chart"]) &&
|
||||
key?.type.k === "bytes" &&
|
||||
modifierNames.join(",") === "command,control,option,primary,shift" &&
|
||||
boolModifierFields;
|
||||
boolModifierFields &&
|
||||
segmentedNames.join(",") === "options" &&
|
||||
segmentOptionNames.join(",") === "command,enabled,id,label,selected" &&
|
||||
segmentOptionField("id") !== undefined && ["number", "i64", "f64", "numAlias"].includes(segmentOptionField("id")!.type.k) &&
|
||||
segmentOptionField("label")?.type.k === "bytes" &&
|
||||
segmentOptionField("command")?.type.k === "bytes" &&
|
||||
segmentOptionField("selected")?.type.k === "bool" &&
|
||||
segmentOptionField("enabled")?.type.k === "bool" &&
|
||||
metricNames.join(",") === "accessibilityLabel,primaryText,secondaryText" &&
|
||||
metricFieldNamed("primaryText")?.type.k === "bytes" &&
|
||||
metricFieldNamed("secondaryText")?.type.k === "bytes" &&
|
||||
metricFieldNamed("accessibilityLabel")?.type.k === "bytes" &&
|
||||
chartNames.join(",") === "accessibilityLabel,leadingCaption,maxValue,minValue,trailingSummary,values" &&
|
||||
chartValues?.type.k === "slice" && chartValues.type.elem.k === "number" &&
|
||||
chartFieldNamed("minValue") !== undefined && ["number", "i64", "f64", "numAlias"].includes(chartFieldNamed("minValue")!.type.k) &&
|
||||
chartFieldNamed("maxValue") !== undefined && ["number", "i64", "f64", "numAlias"].includes(chartFieldNamed("maxValue")!.type.k) &&
|
||||
chartFieldNamed("leadingCaption")?.type.k === "bytes" &&
|
||||
chartFieldNamed("trailingSummary")?.type.k === "bytes" &&
|
||||
chartFieldNamed("accessibilityLabel")?.type.k === "bytes";
|
||||
if (!valid) {
|
||||
this.report(
|
||||
"NS1033",
|
||||
@@ -1871,7 +2016,7 @@ export class SubsetChecker {
|
||||
/// entry points, but the exports themselves live in the entry module.
|
||||
private static readonly entryOnlyExports = new Set([
|
||||
"update", "initialModel", "subscriptions", "migrate",
|
||||
"commandMsg", "keyMsg", "frameMsg", "pinchMsg", "dropMsg", "appearanceMsg", "chromeMsg", "envMsgs", "themePack", "statusItem", "statusItems", "windows",
|
||||
"commandMsg", "keyMsg", "frameMsg", "pinchMsg", "dropMsg", "appearanceMsg", "chromeMsg", "envMsgs", "themePack", "themeState", "statusItem", "statusItems", "windows",
|
||||
"viewUnbound", "modelUnbound", "msgUnbound",
|
||||
]);
|
||||
|
||||
@@ -2039,6 +2184,13 @@ export class SubsetChecker {
|
||||
: this.table.unions.get(typeName)?.arms.flatMap((a) => [...a.fields]) ?? [];
|
||||
for (const f of fields) {
|
||||
const decl = f.decl;
|
||||
if (ts.isPropertySignature(decl) && decl.questionToken) {
|
||||
this.report(
|
||||
"NS1012",
|
||||
`Optional field \`${f.tsName}?\` in the ${typeName} tree has an implicit undefined state — spell the empty explicitly as \`${f.tsName}: T | null\`.`,
|
||||
decl,
|
||||
);
|
||||
}
|
||||
if (ts.isPropertySignature(decl) && decl.type && ts.isFunctionTypeNode(decl.type)) {
|
||||
this.report("NS1003", `\`${f.tsName}\` stores a function in the ${typeName} tree.`, decl);
|
||||
}
|
||||
|
||||
@@ -457,10 +457,11 @@ class ContractEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `statusItem` is consumed by the generated launcher, not markup. It
|
||||
// is still an ordinary Model helper in the core ABI, so teach native
|
||||
// check that this one helper is intentionally shell-bound without
|
||||
// making every app repeat it in `viewUnbound`.
|
||||
// These helpers are consumed by the generated launcher, not markup.
|
||||
// They remain ordinary Model helpers in the core ABI, so teach native
|
||||
// check that they are intentionally shell-bound without making every
|
||||
// app repeat them in `viewUnbound`.
|
||||
if (helperNames.includes("themeState") && !model.includes("themeState")) model.push("themeState");
|
||||
if (helperNames.includes("statusItem") && !model.includes("statusItem")) model.push("statusItem");
|
||||
if (helperNames.includes("statusItems") && !model.includes("statusItems")) model.push("statusItems");
|
||||
if (helperNames.includes("windows") && !model.includes("windows")) model.push("windows");
|
||||
|
||||
@@ -5,8 +5,8 @@ import fs from "node:fs";
|
||||
// recordings.
|
||||
// EffectFileOp appended `delete`; the reflected journal layout fingerprint
|
||||
// moves so older recordings refuse cleanly instead of decoding op 8 wrongly.
|
||||
export const journalFormatFingerprint = 0xb3bd2e83971de44dn;
|
||||
export const automationProtocolFingerprint = 0x59d66f39803fd602n;
|
||||
export const journalFormatFingerprint = 0xc510d4b0292ac71an;
|
||||
export const automationProtocolFingerprint = 0x51f7889bbe3305e7n;
|
||||
|
||||
const requestKeyBase = 0x5453525100000000n;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
@@ -125,7 +125,7 @@ export const rules = {
|
||||
id: "NS1014",
|
||||
title: "the core's entry points live in core.ts",
|
||||
fix: "Move this export into src/core.ts (imported modules may hold the helpers it calls and the types it uses).",
|
||||
why: "The build wires `update`, `initialModel`, `subscriptions`, the host-event channels, `themePack`, and `viewUnbound` from the entry module only, so an entry export in an imported file would be silently ignored.",
|
||||
why: "The build wires `update`, `initialModel`, `subscriptions`, the host-event channels, `themeState` / `themePack`, and `viewUnbound` from the entry module only, so an entry export in an imported file would be silently ignored.",
|
||||
class: "guarantee",
|
||||
},
|
||||
NS1015: {
|
||||
@@ -257,7 +257,7 @@ export const rules = {
|
||||
NS1033: {
|
||||
id: "NS1033",
|
||||
title: "wiring exports match their runtime shapes",
|
||||
fix: "Declare the channel exactly: `commandMsg(name: string)` / `keyMsg(key: KeyEvent)` / `frameMsg(model: Model, frame: FrameEvent)` / `pinchMsg(pinch: PinchEvent)` / `dropMsg(drop: FileDropEvent)` returning `Msg | null`; `themePack(model: Model): ThemePack`; singular `statusItem(model: Model): StatusItemState` or collection `statusItems(model: Model): readonly StatusItemDescriptor[]`; `windows(model: Model): readonly WindowDescriptor[]`, with each entry constructed by `windowDescriptor` and a literal `label: asciiBytes(\"name\")` matching `src/windows/name.native`; `appearanceMsg` / `chromeMsg` naming an arm with that channel's record shape; `envMsgs` entries targeting one-`Uint8Array`-field arms; and persistence ok/none routes naming void arms while err names a one-`Uint8Array`-field arm. Import canonical records from the SDK modules.",
|
||||
fix: "Declare the channel exactly: `commandMsg(name: string)` / `keyMsg(key: KeyEvent)` / `frameMsg(model: Model, frame: FrameEvent)` / `pinchMsg(pinch: PinchEvent)` / `dropMsg(drop: FileDropEvent)` returning `Msg | null`; `themePack(model: Model): ThemePack` or `themeState(model: Model): ThemeState` (not both); singular `statusItem(model: Model): StatusItemState` or collection `statusItems(model: Model): readonly StatusItemDescriptor[]`; `windows(model: Model): readonly WindowDescriptor[]`, with each entry constructed by `windowDescriptor` and a literal `label: asciiBytes(\"name\")` matching `src/windows/name.native`; `appearanceMsg` / `chromeMsg` naming an arm with that channel's record shape; `envMsgs` entries targeting one-`Uint8Array`-field arms; and persistence ok/none routes naming void arms while err names a one-`Uint8Array`-field arm. Import canonical records from the SDK modules.",
|
||||
why: "The generated wiring builds host events, persistence restore results, model-derived theme/status/window declarations, and their typed callbacks structurally at build time; a wrong shape would otherwise surface as a Zig compile error inside generated code instead of a teaching diagnostic here.",
|
||||
class: "guarantee",
|
||||
},
|
||||
|
||||
@@ -258,6 +258,16 @@ class ServiceShapeTable {
|
||||
if (this.listed.has(name)) return;
|
||||
const info = this.table.structs.get(name);
|
||||
if (!info) throw new ServiceShapeError(`The shared type table has no record named \`${name}\``, this.fallbackSite);
|
||||
const optionalField = info.fields.find((field) =>
|
||||
(ts.isPropertySignature(field.decl) || ts.isPropertyDeclaration(field.decl)) &&
|
||||
field.decl.questionToken !== undefined
|
||||
);
|
||||
if (optionalField) {
|
||||
throw new ServiceShapeError(
|
||||
`Service boundary record \`${name}\` has optional property \`${optionalField.tsName}?\`; spell absence explicitly as \`${optionalField.tsName}: T | null\` so both service codecs encode the same state`,
|
||||
optionalField.decl,
|
||||
);
|
||||
}
|
||||
this.listed.add(name);
|
||||
const record: ServiceRecordType = {
|
||||
name,
|
||||
|
||||
@@ -340,18 +340,22 @@ export class TypedAst {
|
||||
|
||||
/// Properties of an object-literal type alias body, in declaration
|
||||
/// order — null unless every member is a plain record property (an
|
||||
/// identifier-named, annotated, non-optional property signature), so
|
||||
/// identifier-named, annotated property signature), so
|
||||
/// a shape this walk cannot carry whole refuses as an unsupported
|
||||
/// alias instead of registering a struct with silently missing
|
||||
/// fields.
|
||||
propsOfTypeLiteral(node: tsImpl.TypeLiteralNode): PropInfo[] | null {
|
||||
/// alias instead of registering a struct with silently missing fields.
|
||||
/// A tiny closed set of SDK-owned shell records carries omission
|
||||
/// intentionally (ThemeState inheritance, optional tray typography, and
|
||||
/// optional rich tray payloads);
|
||||
/// their caller opts in explicitly so authored/service records keep the
|
||||
/// fixed-shape rule.
|
||||
propsOfTypeLiteral(node: tsImpl.TypeLiteralNode, allowOptional = false): PropInfo[] | null {
|
||||
const out: PropInfo[] = [];
|
||||
for (const member of node.members) {
|
||||
if (!tsImpl.isPropertySignature(member) || !member.name || !tsImpl.isIdentifier(member.name)) return null;
|
||||
if (member.questionToken || !member.type) return null;
|
||||
if (!tsImpl.isPropertySignature(member) || !member.name || !tsImpl.isIdentifier(member.name) || !member.type) return null;
|
||||
if (member.questionToken && !allowOptional) return null;
|
||||
out.push({
|
||||
name: member.name.text,
|
||||
optional: false,
|
||||
optional: member.questionToken !== undefined,
|
||||
readonly: hasReadonlyModifier(member),
|
||||
typeNode: member.type,
|
||||
declaration: member,
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
// T | null/undefined -> ?T (R7)
|
||||
// Uint8Array -> []const u8 (R3)
|
||||
|
||||
import { ts, TypedAst, hasExportModifier, exportListBindings, type PropInfo } from "./typed_ast.ts";
|
||||
import path from "node:path";
|
||||
import { ts, TypedAst, hasExportModifier, exportListBindings, sdkLibraryModules, type PropInfo } from "./typed_ast.ts";
|
||||
import { mutatingMethodNames } from "./ownership.ts";
|
||||
|
||||
export type ZType =
|
||||
@@ -265,14 +266,17 @@ export class TypeTable {
|
||||
this.declOrder.push(name);
|
||||
continue;
|
||||
}
|
||||
if (ts.isTypeLiteralNode(stmt.type) && this.tast.propsOfTypeLiteral(stmt.type) !== null) {
|
||||
const projectOptional = this.isCanonicalOptionalSdkRecord(stmt);
|
||||
if (ts.isTypeLiteralNode(stmt.type) && this.tast.propsOfTypeLiteral(stmt.type, projectOptional) !== null) {
|
||||
// A plain-record object-literal alias is a struct exactly like
|
||||
// an interface; the alias FORM is how a contract projection
|
||||
// spells a value-stored record (interfaces spell node
|
||||
// storage), and the storage itself still comes from the
|
||||
// promotion walk. Shapes the plain-record walk cannot carry
|
||||
// whole (quoted or optional properties) stay unclassified and
|
||||
// refuse at emission instead of losing fields silently.
|
||||
// refuse at emission instead of losing fields silently. The
|
||||
// A tiny closed set of SDK shell records may carry optional
|
||||
// properties; authored records still spell absence as `| null`.
|
||||
this.structs.set(name, {
|
||||
name,
|
||||
decl: stmt,
|
||||
@@ -319,18 +323,32 @@ export class TypeTable {
|
||||
}
|
||||
const structInfo = this.structs.get(stmt.name.text);
|
||||
if (structInfo && structInfo.decl === stmt && ts.isTypeLiteralNode(stmt.type)) {
|
||||
const props = this.tast.propsOfTypeLiteral(stmt.type);
|
||||
if (props) structInfo.fields = props.map((p) => this.fieldOf(p));
|
||||
const projectOptional = this.isCanonicalOptionalSdkRecord(stmt);
|
||||
const props = this.tast.propsOfTypeLiteral(stmt.type, projectOptional);
|
||||
if (props) structInfo.fields = props.map((p) => this.fieldOf(p, projectOptional));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fieldOf(p: PropInfo): ZField {
|
||||
private isCanonicalOptionalSdkRecord(decl: ts.TypeAliasDeclaration): boolean {
|
||||
const events = sdkLibraryModules.get("@native-sdk/core/events");
|
||||
return (decl.name.text === "ThemeState" || decl.name.text === "StatusItemPresentation" || decl.name.text === "StatusItemMenuItem") && events !== undefined &&
|
||||
path.resolve(decl.getSourceFile().fileName) === path.resolve(events);
|
||||
}
|
||||
|
||||
private fieldOf(p: PropInfo, projectOptional = false): ZField {
|
||||
const resolved = p.typeNode ? this.resolveTypeNode(p.typeNode) : { k: "void" } as ZType;
|
||||
return {
|
||||
tsName: p.name,
|
||||
zigName: zigDeclName(p.name),
|
||||
type: p.typeNode ? this.resolveTypeNode(p.typeNode) : { k: "void" },
|
||||
// Only canonical SDK shell records project JS `undefined` omission
|
||||
// onto the contract's ordinary optional slot. Applying this globally would
|
||||
// let service records acquire an absent state while their generated
|
||||
// codecs still accept only explicit null.
|
||||
type: projectOptional && p.optional && resolved.k !== "void" && resolved.k !== "optional"
|
||||
? { k: "optional", inner: resolved }
|
||||
: resolved,
|
||||
decl: p.declaration,
|
||||
};
|
||||
}
|
||||
@@ -748,7 +766,7 @@ export class TypeTable {
|
||||
}
|
||||
|
||||
/// The canvas text-input event vocabulary — a union carrying exactly these
|
||||
/// eleven tags is the declared mirror the markup engines resolve `on-input`
|
||||
/// thirteen tags is the declared mirror the markup engines resolve `on-input`
|
||||
/// through (matched structurally on the Zig side; see
|
||||
/// ui_markup_reflect.declaredTextInputUnion).
|
||||
const textInputMirrorTags = [
|
||||
@@ -757,6 +775,8 @@ const textInputMirrorTags = [
|
||||
"delete_forward",
|
||||
"delete_word_backward",
|
||||
"delete_word_forward",
|
||||
"delete_to_start",
|
||||
"delete_to_line_start",
|
||||
"clear",
|
||||
"move_caret",
|
||||
"set_selection",
|
||||
|
||||
@@ -1093,7 +1093,7 @@ export function update(model: Model, msg: Msg): Model {
|
||||
assert.ok(d, `expected NS1032, got ${ruleIds(result)}`);
|
||||
assert.ok(d.message.includes('"nope"'), d.message);
|
||||
// The two valid entries alone are clean.
|
||||
const clean = checkOnly(`
|
||||
const clean = check(`
|
||||
export interface Model { readonly count: number; }
|
||||
export type Msg = { readonly kind: "add" } | { readonly kind: "tick"; readonly at: number };
|
||||
export const viewUnbound = ["count", "tick"] as const;
|
||||
@@ -1139,6 +1139,70 @@ export function update(model: Model, msg: Msg): Model { return model; }
|
||||
assert.ok(ruleIds(wrongShape).includes("NS1033"), `got ${ruleIds(wrongShape)}`);
|
||||
});
|
||||
|
||||
test("NS1033 themeState is the exact model-derived appearance record and excludes themePack", () => {
|
||||
const clean = check(`
|
||||
import { type ThemeState } from "@native-sdk/core/events";
|
||||
export type Scheme = "light" | "dark" | "system";
|
||||
export interface Model { readonly scheme: Scheme; readonly branded: boolean; }
|
||||
export type Msg = { readonly kind: "toggle" } | { readonly kind: "noop" };
|
||||
export function initialModel(): Model { return { scheme: "system", branded: false }; }
|
||||
export function themeState(model: Model): ThemeState {
|
||||
return model.branded
|
||||
? { pack: "geist", colorScheme: model.scheme, accent: "#df2670" }
|
||||
: { colorScheme: model.scheme };
|
||||
}
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
`);
|
||||
assert.equal(clean.ok, true, clean.diagnostics.map((d) => d.message).join("\n"));
|
||||
assert.ok(!ruleIds(clean).includes("NS1033"), `got ${ruleIds(clean)}`);
|
||||
|
||||
const lookalike = checkOnly(`
|
||||
export type ThemeState = {
|
||||
readonly pack?: "house" | "geist";
|
||||
readonly colorScheme?: "light" | "dark" | "system";
|
||||
readonly accent?: string;
|
||||
};
|
||||
export interface Model { readonly enabled: boolean; }
|
||||
export type Msg = { readonly kind: "tick" };
|
||||
export function initialModel(): Model { return { enabled: false }; }
|
||||
export function themeState(model: Model): ThemeState { return {}; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
`);
|
||||
assert.ok(ruleIds(lookalike).includes("NS1033"), `got ${ruleIds(lookalike)}`);
|
||||
|
||||
const wrongRecord = checkOnly(`
|
||||
export interface WrongThemeState { readonly pack: "house" | "geist"; readonly accent: Uint8Array; }
|
||||
export interface Model { readonly enabled: boolean; }
|
||||
export type Msg = { readonly kind: "tick" };
|
||||
export function initialModel(): Model { return { enabled: false }; }
|
||||
export function themeState(model: Model): WrongThemeState { return { pack: "house", accent: new Uint8Array(0) }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
`);
|
||||
assert.ok(ruleIds(wrongRecord).includes("NS1033"), `got ${ruleIds(wrongRecord)}`);
|
||||
|
||||
const wrongShape = checkOnly(`
|
||||
import { type ThemeState } from "@native-sdk/core/events";
|
||||
export interface Model { readonly enabled: boolean; }
|
||||
export type Msg = { readonly kind: "tick" };
|
||||
export function initialModel(): Model { return { enabled: false }; }
|
||||
export function themeState(): ThemeState { return {}; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
`);
|
||||
assert.ok(ruleIds(wrongShape).includes("NS1033"), `got ${ruleIds(wrongShape)}`);
|
||||
|
||||
const both = checkOnly(`
|
||||
import { type ThemeState } from "@native-sdk/core/events";
|
||||
export type ThemePack = "house" | "geist";
|
||||
export interface Model { readonly pack: ThemePack; }
|
||||
export type Msg = { readonly kind: "tick" };
|
||||
export function initialModel(): Model { return { pack: "house" }; }
|
||||
export function themePack(model: Model): ThemePack { return model.pack; }
|
||||
export function themeState(model: Model): ThemeState { return { pack: model.pack }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
`);
|
||||
assert.ok(ruleIds(both).includes("NS1033"), `got ${ruleIds(both)}`);
|
||||
});
|
||||
|
||||
test("NS1033 validates migrate hooks exported through an export list", () => {
|
||||
const valid = checkOnly(`
|
||||
export interface Model { readonly count: number; }
|
||||
@@ -1178,6 +1242,9 @@ export function statusItem(model: Model): StatusItemState {
|
||||
openCommand: asciiBytes("refresh"),
|
||||
presentation: { title: asciiBytes(model.playing ? "MB on" : "MB"), width: 52, tone: "normal", iconOpacity: 1, monospaced: true },
|
||||
items: [
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: false, enabled: false, detail: asciiBytes(""), role: "hero", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false }, metric: { primaryText: asciiBytes("2,494 requests"), secondaryText: asciiBytes("Today"), accessibilityLabel: asciiBytes("2,494 requests today") } },
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: false, enabled: true, detail: asciiBytes(""), role: "segmented", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false }, segmented: { options: [{ id: 11, label: asciiBytes("On"), command: asciiBytes("enable"), selected: model.playing, enabled: true }] } },
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: false, enabled: false, detail: asciiBytes(""), role: "chart", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false }, chart: { values: [0.25, 0.5, 1], minValue: 0, maxValue: 1, leadingCaption: asciiBytes("Load"), trailingSummary: asciiBytes("50%"), accessibilityLabel: asciiBytes("Load history, 50 percent") } },
|
||||
{ id: 1, label: asciiBytes(model.playing ? "Pause" : "Play"), command: asciiBytes("toggle"), separator: false, enabled: true, detail: asciiBytes("configured"), role: "agent", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
],
|
||||
|
||||
@@ -199,6 +199,42 @@ export function statusItem(model: Model): StatusItemState {
|
||||
assert.ok(structs.includes("StatusItemMenuItem"), `structs: ${structs.join(", ")}`);
|
||||
assert.ok(structs.includes("StatusItemPresentation"), `structs: ${structs.join(", ")}`);
|
||||
assert.ok(structs.includes("StatusItemModifiers"), `structs: ${structs.join(", ")}`);
|
||||
assert.ok(structs.includes("StatusItemSegmentedRow"), `structs: ${structs.join(", ")}`);
|
||||
assert.ok(structs.includes("StatusItemSegmentOption"), `structs: ${structs.join(", ")}`);
|
||||
assert.ok(structs.includes("StatusItemMetricRow"), `structs: ${structs.join(", ")}`);
|
||||
assert.ok(structs.includes("StatusItemChartRow"), `structs: ${structs.join(", ")}`);
|
||||
const presentation = (doc.types as { structs: { name: string; fields: { name: string; type: unknown }[] }[] }).structs
|
||||
.find((record) => record.name === "StatusItemPresentation");
|
||||
assert.ok(presentation);
|
||||
assert.deepEqual(presentation.fields.slice(-2), [
|
||||
{ name: "fontSize", type: { kind: "optional", inner: { kind: "i64" } } },
|
||||
{ name: "fontWeight", type: { kind: "optional", inner: { kind: "enum", name: "StatusItemFontWeight" } } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("themeState projects optional pack, scheme, and string accent as a launcher-bound record", () => {
|
||||
const doc = contractOf(`
|
||||
import { type ThemeState } from "@native-sdk/core/events";
|
||||
export interface Model { dark: boolean; }
|
||||
export type Msg = { kind: "toggle" } | { kind: "noop" };
|
||||
export function initialModel(): Model { return { dark: false }; }
|
||||
export function update(model: Model, msg: Msg): Model { return model; }
|
||||
export function themeState(model: Model): ThemeState {
|
||||
return model.dark ? { colorScheme: "dark", accent: "#df2670" } : { colorScheme: "system" };
|
||||
}
|
||||
`);
|
||||
const helpers = doc.model_helpers as { name: string; returns: { kind: string; name: string } }[];
|
||||
assert.deepEqual(helpers.map((helper) => helper.name), ["themeState"]);
|
||||
assert.deepEqual(helpers[0].returns, { kind: "value", name: "ThemeState" });
|
||||
assert.deepEqual(doc.model_unbound, ["themeState"]);
|
||||
const state = (doc.types as { structs: { name: string; fields: { name: string; type: unknown }[] }[] }).structs
|
||||
.find((record) => record.name === "ThemeState");
|
||||
assert.ok(state);
|
||||
assert.deepEqual(state.fields, [
|
||||
{ name: "pack", type: { kind: "optional", inner: { kind: "enum", name: "ThemeStatePack" } } },
|
||||
{ name: "colorScheme", type: { kind: "optional", inner: { kind: "enum", name: "ThemeStateColorScheme" } } },
|
||||
{ name: "accent", type: { kind: "optional", inner: { kind: "bytes" } } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("statusItems is projected as a launcher-bound descriptor slice", () => {
|
||||
|
||||
@@ -265,6 +265,51 @@ export function roundTrip(request: BoundaryRecord): BoundaryRecord { return requ
|
||||
assert.match(result.servicesClient!, /serviceUnionBytes/);
|
||||
});
|
||||
|
||||
test("optional service record properties refuse before generating an undefined-unsafe codec", () => {
|
||||
const files = {
|
||||
"core.ts": `
|
||||
import { Cmd } from "@native-sdk/core";
|
||||
import { preferencesSave } from "@native-sdk/services";
|
||||
import type { Preferences } from "./shared.ts";
|
||||
export interface Model { readonly saved: boolean; }
|
||||
export type Msg =
|
||||
| { readonly kind: "save" }
|
||||
| { readonly kind: "saved"; readonly bytes: Uint8Array }
|
||||
| { readonly kind: "failed"; readonly error: Uint8Array };
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "save": {
|
||||
const request: Preferences = {};
|
||||
return [model, preferencesSave(request, { ok: "saved", err: "failed" })];
|
||||
}
|
||||
case "saved": return { saved: true };
|
||||
case "failed": return model;
|
||||
}
|
||||
}`,
|
||||
"shared.ts": `export type Preferences = { readonly accent?: Uint8Array };`,
|
||||
"services/preferences.ts": `
|
||||
import type { Preferences } from "../shared.ts";
|
||||
export function save(request: Preferences): Uint8Array {
|
||||
return request.accent ?? new Uint8Array(0);
|
||||
}`,
|
||||
};
|
||||
for (const declaration of [
|
||||
`export type Preferences = { readonly accent?: Uint8Array };`,
|
||||
`export interface Preferences { readonly accent?: Uint8Array }`,
|
||||
]) {
|
||||
const result = checkFiles({ ...files, "shared.ts": declaration }, {
|
||||
contractEntry: "src/core.ts",
|
||||
servicesContract: true,
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.diagnostics.some((diagnostic) =>
|
||||
diagnostic.id === "NS1067" && /(resolves to void|has optional property)/.test(diagnostic.message)
|
||||
), JSON.stringify(result.diagnostics));
|
||||
assert.equal(result.servicesContract, null);
|
||||
assert.equal(result.servicesClient, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("generated clients parenthesize composite slice element types", () => {
|
||||
const result = checkFiles({
|
||||
"core.ts": serviceCore,
|
||||
|
||||
@@ -62,6 +62,34 @@ test("text reducer snaps editable endpoints out of CRLF", () => {
|
||||
assert.deepEqual(inserted.selection, { anchor: 4, focus: 4 });
|
||||
});
|
||||
|
||||
test("text reducer deletes to hard line start", () => {
|
||||
const midLine = apply(state("first\nsecond line", 12), { kind: "delete_to_line_start" });
|
||||
assert.equal(decoder.decode(midLine.text), "first\n line");
|
||||
assert.deepEqual(midLine.selection, { anchor: 6, focus: 6 });
|
||||
|
||||
const atStart = apply(state("first\nsecond", 6), { kind: "delete_to_line_start" });
|
||||
assert.equal(decoder.decode(atStart.text), "first\nsecond");
|
||||
assert.deepEqual(atStart.selection, { anchor: 6, focus: 6 });
|
||||
|
||||
const selection = apply(state("first\nsecond", 7, 10), { kind: "delete_to_line_start" });
|
||||
assert.equal(decoder.decode(selection.text), "first\nsnd");
|
||||
assert.deepEqual(selection.selection, { anchor: 7, focus: 7 });
|
||||
|
||||
const crlf = apply(state("one\r\ntwo", 8), { kind: "delete_to_line_start" });
|
||||
assert.equal(decoder.decode(crlf.text), "one\r\n");
|
||||
assert.deepEqual(crlf.selection, { anchor: 5, focus: 5 });
|
||||
});
|
||||
|
||||
test("text reducer deletes to field start across raw line breaks", () => {
|
||||
const collapsed = apply(state("first\nsecond line", 12), { kind: "delete_to_start" });
|
||||
assert.equal(decoder.decode(collapsed.text), " line");
|
||||
assert.deepEqual(collapsed.selection, { anchor: 0, focus: 0 });
|
||||
|
||||
const selection = apply(state("first\nsecond", 7, 10), { kind: "delete_to_start" });
|
||||
assert.equal(decoder.decode(selection.text), "first\nsnd");
|
||||
assert.deepEqual(selection.selection, { anchor: 7, focus: 7 });
|
||||
});
|
||||
|
||||
test("text reducer retains exact composition ownership across CRLF", () => {
|
||||
const initial = state("a\nb", 1);
|
||||
const preview = apply(initial, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-darwin-arm64",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for macOS on Apple silicon (arm64)",
|
||||
"os": [
|
||||
"darwin"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-darwin-x64",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for macOS on Intel (x64)",
|
||||
"os": [
|
||||
"darwin"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-linux-arm64-gnu",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for Linux arm64 (glibc)",
|
||||
"os": [
|
||||
"linux"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-linux-arm64-musl",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for Linux arm64 (musl)",
|
||||
"os": [
|
||||
"linux"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-linux-x64-gnu",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for Linux x64 (glibc)",
|
||||
"os": [
|
||||
"linux"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-linux-x64-musl",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for Linux x64 (musl)",
|
||||
"os": [
|
||||
"linux"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-win32-arm64",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for Windows on ARM (arm64)",
|
||||
"os": [
|
||||
"win32"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli-win32-x64",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The native CLI binary for Windows x64",
|
||||
"os": [
|
||||
"win32"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@native-sdk/cli",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"description": "The Native SDK: the complete toolkit for building native desktop applications — declarative markup, native rendering, WebView surfaces, and OS capabilities",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -35,14 +35,14 @@
|
||||
"scriptc": "0.0.31"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@native-sdk/cli-darwin-arm64": "0.9.1",
|
||||
"@native-sdk/cli-darwin-x64": "0.9.1",
|
||||
"@native-sdk/cli-linux-arm64-gnu": "0.9.1",
|
||||
"@native-sdk/cli-linux-arm64-musl": "0.9.1",
|
||||
"@native-sdk/cli-linux-x64-gnu": "0.9.1",
|
||||
"@native-sdk/cli-linux-x64-musl": "0.9.1",
|
||||
"@native-sdk/cli-win32-arm64": "0.9.1",
|
||||
"@native-sdk/cli-win32-x64": "0.9.1"
|
||||
"@native-sdk/cli-darwin-arm64": "0.9.3",
|
||||
"@native-sdk/cli-darwin-x64": "0.9.3",
|
||||
"@native-sdk/cli-linux-arm64-gnu": "0.9.3",
|
||||
"@native-sdk/cli-linux-arm64-musl": "0.9.3",
|
||||
"@native-sdk/cli-linux-x64-gnu": "0.9.3",
|
||||
"@native-sdk/cli-linux-x64-musl": "0.9.3",
|
||||
"@native-sdk/cli-win32-arm64": "0.9.3",
|
||||
"@native-sdk/cli-win32-x64": "0.9.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
+3
-1
@@ -56,7 +56,7 @@
|
||||
# example suite (frontends, native incl.
|
||||
# canvas-preview, mobile), the Chromium host link check (cef-host-link),
|
||||
# the macOS automation smokes (gpu-surface,
|
||||
# gpu-dashboard, gpu-components, canvas-preview, writeback; skipped off-macOS), a
|
||||
# gpu-dashboard, gpu-components, menu-bar, canvas-preview, writeback; skipped off-macOS), a
|
||||
# markup check over every example markup file, and the docs check if docs/ changed
|
||||
# vs base-ref or --all was passed. --perf additionally runs the percentile
|
||||
# GPU perf check (test-gpu-dashboard-perf; macOS only, slow, load-sensitive —
|
||||
@@ -376,6 +376,7 @@ else # full
|
||||
run_step "smoke-gpu-components" zig build test-gpu-components-smoke
|
||||
run_step "smoke-webview" zig build test-webview-smoke
|
||||
run_step "smoke-native-shell" zig build test-native-shell-smoke
|
||||
run_step "smoke-menu-bar" zig build test-menu-bar-smoke
|
||||
run_step "smoke-canvas-preview" zig build test-canvas-preview-smoke
|
||||
run_step "smoke-writeback" zig build test-writeback-smoke
|
||||
else
|
||||
@@ -384,6 +385,7 @@ else # full
|
||||
skip_step "smoke-gpu-components" "macOS only"
|
||||
skip_step "smoke-webview" "macOS only"
|
||||
skip_step "smoke-native-shell" "macOS only"
|
||||
skip_step "smoke-menu-bar" "macOS only"
|
||||
skip_step "smoke-canvas-preview" "macOS only"
|
||||
skip_step "smoke-writeback" "macOS only"
|
||||
fi
|
||||
|
||||
@@ -117,9 +117,10 @@ Semantics:
|
||||
10. Use `native automate widget-key <view-label> <key> [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS).
|
||||
11. Use `native automate widget-pinch <view-label> <scale> [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1`. `<scale>` is the FINAL multiplicative zoom for the gesture — the cumulative gesture scale (the product of `1 + delta`) lands exactly on it — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`).
|
||||
12. Use `native automate screenshot <view-label> [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-<view-label>.png` (the CLI prints the artifact path and waits for the file).
|
||||
13. Use `native automate tray-action <item-id>` for the primary status item, or `native automate tray-action <status-item-id> <item-id>` for an explicit item. Both select a dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). Live items appear in `snapshot.txt` as `tray #status-id title="..." visible=... items=N` followed by ` tray-item #item-id ...` rows — the menu bar is outside every window capture, so this is the automation evidence for every model-driven item. Unknown id pairs degrade into the dispatch-error ring as `automation.tray_action`.
|
||||
14. Use `native automate reload` to request a WebView reload.
|
||||
15. Use `native automate profile on` to enable per-stage frame timing: while on, `snapshot.txt` carries a `frame_profile` line with rolling p50/p90/max microseconds per pipeline stage (`rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, `host_draw`), each with a lifetime sample count (`<stage>_n=`). Drive some interactions, then `native automate snapshot | grep -o 'frame_profile.*'` to read where frame time goes; `profile off` stops recording and drops the line. Turning it on starts a fresh sample window.
|
||||
13. Before driving an app-menu command, inspect the snapshot's `command id="..."` catalog and `app-menu` / `app-menu-item` rows to prove the running app loaded the expected `app.zon` or runner declarations. Then use `native automate menu-command <id>` to dispatch the same `.menu_command` platform event a real selection emits; the verb remains a raw event injector, so the snapshot receipt is what validates registration.
|
||||
14. Use `native automate tray-action <item-id>` for the primary status item, or `native automate tray-action <status-item-id> <item-id>` for an explicit item. Both select a dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). Live items appear in `snapshot.txt` as `tray #status-id title="..." visible=... items=N` followed by ` tray-item #item-id ...` rows — the menu bar is outside every window capture, so this is the automation evidence for every model-driven item. Unknown id pairs degrade into the dispatch-error ring as `automation.tray_action`.
|
||||
15. Use `native automate reload` to request a WebView reload.
|
||||
16. Use `native automate profile on` to enable per-stage frame timing: while on, `snapshot.txt` carries a `frame_profile` line with rolling p50/p90/max microseconds per pipeline stage (`rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, `host_draw`), each with a lifetime sample count (`<stage>_n=`). Drive some interactions, then `native automate snapshot | grep -o 'frame_profile.*'` to read where frame time goes; `profile off` stops recording and drops the line. Turning it on starts a fresh sample window.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
activationCommand: asciiBytes("app.refresh"),
|
||||
alternateActivationCommand: asciiBytes("player.toggle"),
|
||||
openCommand: asciiBytes("app.refresh"),
|
||||
presentation: { title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"), width: 62, tone: "normal", iconOpacity: 1, monospaced: true },
|
||||
presentation: { title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"), width: 62, tone: "normal", iconOpacity: 1, monospaced: true, fontSize: 13, fontWeight: "semibold" },
|
||||
items: [
|
||||
{ id: 1, label: utf8Bytes("Open"), command: asciiBytes("app.open"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
@@ -44,7 +44,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
}
|
||||
```
|
||||
|
||||
Presentation fields are `title`, `width`, `tone`, `iconOpacity`, and `monospaced`. Rows add secondary `detail`, a semantic `command | info | header | hero | agent | context` role, a key equivalent, and all five modifier booleans to the ordinary id/label/command/separator/enabled fields. Actionable rows need unique non-zero whole u32 ids; separators conventionally use id 0, and the menu cap is 32 rows. `examples/menu-bar` is the full TypeScript + Native markup hide/Open/Quit lifecycle. The lower-level Zig `Options.status_item` / `status_item_fn` recipe appears below for Zig cores and custom wiring.
|
||||
Presentation fields style the persistent menu-bar item. Inside its dropdown, `metric` carries prominent primary/secondary text, `segmented` carries up to eight typed command options, and `chart` carries 1–32 bounded values with captions and accessibility. These are generic composable row payloads, not text conventions.
|
||||
|
||||
## App wiring (Zig cores and extensions only)
|
||||
|
||||
@@ -397,7 +397,7 @@ Color and radius come from the design tokens, referenced by token NAME — liter
|
||||
</row>
|
||||
```
|
||||
|
||||
References resolve against the app's LIVE tokens on every rebuild (`finalizeWithTokens`), so a themed app (`tokens`/`tokens_fn`) re-resolves them when the theme changes — dark mode flips `surface` automatically. The DEFAULT theme follows the system appearance: an app that sets neither `tokens` nor `tokens_fn` derives the stock tokens from the OS light/dark setting (plus high-contrast and reduce-motion) and re-themes live when the user flips it. A TypeScript core may export `themePack(model: Model): ThemePack` (`ThemePack = "house" | "geist"`) for a model-owned built-in pack toggle; the generated adapter changes only the pack and preserves those live system axes, manifest accent, and surface scale. Pass explicit `tokens` for a fixed look, or `tokens_fn` for a fully model-owned custom palette (usually still following the system scheme through `on_appearance`). An explicit `style` value set in Zig always wins over a token ref on the same field. Unknown token names are validation/compile errors.
|
||||
References resolve against the app's LIVE tokens on every rebuild (`finalizeWithTokens`), so a themed app (`tokens`/`tokens_fn`) re-resolves them when the theme changes — dark mode flips `surface` automatically. The DEFAULT theme follows the system appearance: an app that sets neither `tokens` nor `tokens_fn` derives the stock tokens from the OS light/dark setting (plus high-contrast and reduce-motion) and re-themes live when the user flips it. A TypeScript core may export `themeState(model: Model): ThemeState` (from `@native-sdk/core/events`) to derive optional `pack`, `colorScheme` (`light`/`dark`/`system`), and `accent` (`#rrggbb`) from model state. Omitted fields inherit app.zon, and omitted/system scheme follows the OS; high contrast still suppresses accents. `themePack(model)` remains supported for pack-only apps, but never export both. Pass explicit `tokens` for a fixed look, or `tokens_fn` for a fully model-owned custom palette; those complete-token paths take precedence over themeState. Forced scheme is canvas-token scope in v1 — native title bars and WebViews continue following the OS. An explicit `style` value set in Zig always wins over a token ref on the same field. Unknown token names and malformed model accents are validation errors.
|
||||
|
||||
One Zig-only style knob rides beside the tokens: `ElementOptions.style = .{ .quiet_hover = true }` silences a pressable surface's pointer HOVER wash only — press and selection fills, the focus ring, cursor intent, and hit testing stay — for image-forward content tiles (cover art, photo cards) where the pointer rests on content rather than a control register. Acting controls (list rows, menu items, buttons, tab triggers) keep their washes: there the hover fill IS the affordance.
|
||||
|
||||
@@ -565,6 +565,8 @@ The markup binds the FN (`text="{draft}"`), never the buffer: binding a `TextBuf
|
||||
|
||||
The runtime owns cmd/ctrl+C/X/V in editable text: copy writes the current selection to the system clipboard, cut copies then delivers the removal to your `on-input` handler as an `insert_text ""` edit, and paste arrives as an ordinary `insert_text` edit — the TEA mirror above stays consistent with zero extra code. Paste is clamped to the view's text capacity: when bytes were dropped, the keyboard event carries `edit_truncated = true` and your `TextBuffer` mirror sets its own `truncated` flag (check it if lost paste bytes matter to your UX; `TextBuffer` clamps oversized insertions at a UTF-8 boundary rather than dropping the edit). Shift+arrows/home/end extend the selection from the keyboard.
|
||||
|
||||
Deletion is platform-correct and semantic: Backspace/Delete remove one caret unit, Option+Backspace/Delete on macOS (Ctrl+Backspace/Delete elsewhere) remove one word, and Command+Backspace on macOS (with or without Shift) emits `delete_to_start` for single-line fields or `delete_to_line_start` for a textarea. Either event removes the active selection when non-empty; otherwise the former removes caret-to-offset-0 and the latter removes caret-to-hard-line-start (with CRLF atomic). Soft-wrapped textarea lines intentionally use the hard line start in v1. Apply the event through the same `TextBuffer` / TypeScript `applyTextInputEvent` mirror — it is one edit and therefore one undo step.
|
||||
|
||||
Static text is selectable too: click-drag inside one `text` leaf or `paragraph` (markdown bodies included) selects with a highlight, cmd/ctrl+C copies it, and pressing anywhere else clears it. Selection and pressing coexist inside pressable rows — dragging selects (and presses nothing), a plain click collapses the selection and lands on the row's `on-press`. Selection is per-widget by design — there is no document model ordering text across widgets, so a drag cannot span two paragraphs (copy per paragraph). The selection survives rebuilds while that widget's text bytes are unchanged, and shows up in semantics/automation snapshots as `selection=a..b` on the widget line. Clipboard access from `update` is `fx.writeClipboard` / `fx.readClipboard` on the effects channel (see Effects) — never a `pbcopy` spawn; `runtime.readClipboard(&buffer)` / `runtime.writeClipboard(text)` remain for code that holds the runtime.
|
||||
|
||||
## Effects in Zig cores: subprocesses and HTTP from update
|
||||
|
||||
@@ -197,7 +197,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
activationCommand: asciiBytes("app.refresh"),
|
||||
alternateActivationCommand: asciiBytes("player.toggle"),
|
||||
openCommand: asciiBytes("app.refresh"),
|
||||
presentation: { title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"), width: 62, tone: "normal", iconOpacity: 1, monospaced: true },
|
||||
presentation: { title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"), width: 62, tone: "normal", iconOpacity: 1, monospaced: true, fontSize: 13, fontWeight: "semibold" },
|
||||
items: [
|
||||
{ id: 1, label: utf8Bytes("Open"), command: asciiBytes("app.open"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
@@ -207,7 +207,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
}
|
||||
```
|
||||
|
||||
The canonical records live in `@native-sdk/core/events`. Shell fields are `iconPath`, `tooltip`, `activationCommand`, `alternateActivationCommand`, and `openCommand`; all commands route through `commandMsg(name): Msg | null`. Presentation is exact: `title`, `width` (0 = host default), `tone` (`normal | warning | critical`), `iconOpacity` (0…1), and `monospaced`. Every row has id/label/command/separator/enabled plus byte `detail`, `role` (`command | info | header | hero | agent | context`), byte `key`, and the five explicit modifier booleans. Actionable rows need unique non-zero whole u32 ids and a non-empty label, separators conventionally use id 0, and the runtime cap is 32 rows per item. Row membership and every shell/presentation/menu field may derive from the model. Linux does not implement status items today.
|
||||
The canonical records live in `@native-sdk/core/events`. Shell fields are `iconPath`, `tooltip`, `activationCommand`, `alternateActivationCommand`, and `openCommand`; all commands route through `commandMsg(name): Msg | null`. Presentation requires `title`, `width` (0 = host default), `tone`, `iconOpacity`, and `monospaced`; optional `fontSize` (omitted/0 = platform default) and `fontWeight` (omitted = `regular`; otherwise `regular | medium | semibold | bold`) add typography without breaking older cores. Use `statusItems` to compose several persistent menu-bar text/icon items with independent typography; attach the dropdown rows to whichever descriptor should open it. Actionable rows need unique non-zero whole u32 ids, and the runtime cap is 32 rows per item. Linux does not implement status items today.
|
||||
|
||||
For multiple items export `statusItems(model): readonly StatusItemDescriptor[]` instead (never both helpers). A descriptor has the singular fields plus stable non-zero `id` identity and live `visible`. Presence creates, absence removes, and changed icon/title/tooltip/visibility/activation/menu fields patch only that id; one menu update never recreates any native item. macOS supports eight simultaneous items. Row ids are scoped to one menu, so different status items may reuse them. This is the Vercel-shaped spend-indicator plus persistent-control-item surface.
|
||||
|
||||
@@ -291,7 +291,7 @@ One caveat for node-side pokes: the build resolves the `@native-sdk/core*` speci
|
||||
- **`src/` is the boundary**: `../` escapes and absolute paths are taught (NS1034); bare npm specifiers are taught (NS1035 - vendor the code under `src/` or make the import `import type`). Only `@native-sdk/core` (the intrinsic Cmd/Sub/asciiBytes/utf8Bytes surface) and the SDK library modules below carry runtime meaning from outside.
|
||||
- **Everything module-level is importable**: interfaces, literal-union aliases, discriminated unions, module `const` numbers and tables, and helper functions all cross files (renamed imports and `import * as ns` namespace aliases both work — the alias is dot-syntax over the same flat namespace, never a value of its own). Export lists and value re-exports work too: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name (a renamed binding emits as a flat-namespace alias). Type names and EXPORTED value names must be unique across the core's files (NS1038 - declare once, import where used; renamed exports claim their new names in the same namespace); colliding PRIVATE helpers are fine (the compile uniques them per module).
|
||||
- **No runtime import cycles** (NS1036). `import type` back-edges are legal and idiomatic: a helper module type-imports `Model` from `./core.ts` while `core.ts` runtime-imports the helpers - that is the expected shape, not a smell.
|
||||
- **The entry contract (NS1014)**: `update`, `initialModel`, `subscriptions`, the wiring channels (`commandMsg`/`keyMsg`/`frameMsg`/`pinchMsg`/`dropMsg`/`appearanceMsg`/`chromeMsg`/`envMsgs`), the model-derived launcher helpers (`themePack`/`statusItem`), and `viewUnbound` are DECLARED in `core.ts` and exported under their own names (`export` on the declaration or an un-renamed `export { update }` list entry — a rename or a re-export from an imported module cannot bind an entry point) - imports may FEED them, never replace them. The markup binding surface is also entry-only: an exported single-Model-parameter helper binds (`{doneCount}`) only when it is DECLARED in `core.ts` — export lists participate under their exported names (`export { taskTotal as taskCount }` binds `{taskCount}`), but a re-export of an imported helper does not bind (under node the app's module object is the entry's exports, so it would bind natively but not exist under node). Imported modules export cross-module API for update and the entry helpers to call.
|
||||
- **The entry contract (NS1014)**: `update`, `initialModel`, `subscriptions`, the wiring channels (`commandMsg`/`keyMsg`/`frameMsg`/`pinchMsg`/`dropMsg`/`appearanceMsg`/`chromeMsg`/`envMsgs`), the model-derived launcher helpers (`themeState`/`themePack`/`statusItem`), and `viewUnbound` are DECLARED in `core.ts` and exported under their own names (`export` on the declaration or an un-renamed `export { update }` list entry — a rename or a re-export from an imported module cannot bind an entry point) - imports may FEED them, never replace them. The markup binding surface is also entry-only: an exported single-Model-parameter helper binds (`{doneCount}`) only when it is DECLARED in `core.ts` — export lists participate under their exported names (`export { taskTotal as taskCount }` binds `{taskCount}`), but a re-export of an imported helper does not bind (under node the app's module object is the entry's exports, so it would bind natively but not exist under node). Imported modules export cross-module API for update and the entry helpers to call.
|
||||
- **SDK library modules**: `@native-sdk/core/text` ships the byte-splice text engine - `applyTextInputEvent(state, event, capacity)` / `clampedInsertEvent` over `TextEditState` (the full caret/word/selection/IME reducer for markup text controls), plus `containsIgnoreCase`, `orderIgnoreCase`, and `trimAsciiSpaces`. `@native-sdk/core/events` ships the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchEvent`, `FileDropEvent`, `ColorScheme`/`AppearanceEvent`, `ChromeInsets`/`ChromeButtons`/`ChromeEvent`, `AudioState`/`AudioEvent`, `AudioCaptureState`/`AudioCaptureSource`/`AudioCaptureEvent`) so no core re-types the vocabulary. Unlike `@native-sdk/core` (intrinsic, never compiled into the core) these are ordinary subset TypeScript, compiled INTO your core when imported and absent when not. Under node they resolve like the core module itself. One namespace rule to know (NS1038): module-scope names are unique across the whole import graph, so a core that imports an SDK event type deletes its own in-file mirror of that name.
|
||||
|
||||
The reference splits are `examples/soundboard-ts` (core.ts + library.ts + player.ts + the SDK text engine), `examples/system-monitor-ts` (core.ts + parsers.ts + table.ts + the SDK text engine), and `examples/chatbot` (core.ts + api.ts — the JSON-over-bytes wire-format reference: request encoding and a targeted parse walk that returns `null` on anything malformed) in the SDK repo.
|
||||
@@ -355,6 +355,8 @@ export type TextInputEvent =
|
||||
| { readonly kind: "delete_forward" }
|
||||
| { readonly kind: "delete_word_backward" }
|
||||
| { readonly kind: "delete_word_forward" }
|
||||
| { readonly kind: "delete_to_start" }
|
||||
| { readonly kind: "delete_to_line_start" }
|
||||
| { readonly kind: "clear" }
|
||||
| { readonly kind: "move_caret"; readonly move: TextCaretMove }
|
||||
| { readonly kind: "set_selection"; readonly selection: TextSelection }
|
||||
@@ -391,7 +393,8 @@ export type Msg = /* ... */ | { readonly kind: "library_scrolled"; readonly scro
|
||||
The generated wiring detects each channel from an export (export exists → wired; a wrong shape is a taught NS1033). Event records are matched by field name, STRUCTURALLY: import them from `@native-sdk/core/events` (`FrameEvent`, `KeyEvent`, `PinchEvent`, `FileDropEvent`, `ColorScheme`, `ChromeInsets`/`ChromeButtons`) or declare the same shapes in your core — both emit as your module's types and match identically. The `appearanceMsg`/`chromeMsg` arms themselves stay inline union members (`kind` plus the event's fields — the subset has no intersection arms); the SDK's `AppearanceEvent`/`ChromeEvent` records are those arms' canonical payload shapes, importable for helper signatures.
|
||||
|
||||
- **`commandMsg(name: string): Msg | null`** — menus, shortcuts, and chrome tabs, by command id (string equality works on `string` values).
|
||||
- **`statusItem(model: Model): StatusItemState` / `statusItems(model: Model): readonly StatusItemDescriptor[]`** — one complete menu-bar item, or a stable-id collection: live icon/tooltip/visibility/click hooks, presentation, and dropdown. The generated launcher reconciles after committed updates; import the exact records from `@native-sdk/core/events`.
|
||||
- **`statusItem(model: Model): StatusItemState` / `statusItems(model: Model): readonly StatusItemDescriptor[]`** — one complete menu-bar item, or a stable-id collection. Presentation includes generic title typography. Dropdown rows include a typed `metric` block, typed segmented options, and bounded chart data; never encode these as text conventions. The generated launcher reconciles after committed updates.
|
||||
- **`themeState(model: Model): ThemeState`** — the stock theme's model-owned pack, color scheme, and accent. Import `ThemeState` from `@native-sdk/core/events`; each field is optional (`pack`, `colorScheme`, `accent`). Omit a field to inherit app.zon, use `colorScheme: "system"` (or omit it) to follow the OS, and spell accents exactly `#rrggbb`. High contrast/reduced motion remain system-owned and high contrast suppresses accents. `themePack(model)` remains the pack-only compatibility helper; export one or the other, never both. Complete `tokens_fn`/`tokens` wiring takes precedence. Forced scheme applies to canvas tokens in v1; native chrome and WebViews remain OS-themed.
|
||||
- **`windows(model: Model): readonly WindowDescriptor[]`** — the model-declared secondary-window set. The launcher compiles `src/windows/<label>.native`, reconciles descriptor presence after committed updates, projects `closePolicy`, and routes a `.quit` `onCloseCommand` through `commandMsg`.
|
||||
- **`frameMsg(model: Model, frame: FrameEvent): Msg | null`** — presented frames. `FrameEvent` is exactly `{ width, height, timestampMs, intervalMs }` numbers (canvas points; fractional milliseconds). Return null for frames that change nothing — the idle law holds exactly when an idle app dispatches nothing (a frame arm that always returns a Msg would spin the loop at full frame rate). The installing frame is excluded; the first PRESENTED frame corrects any seeded value.
|
||||
- **`keyMsg(key: KeyEvent): Msg | null`** — the app-level key FALLBACK (a focused widget's own keys and editable text always win first). `KeyEvent` is exactly `{ key: string; shift: boolean; control: boolean; alt: boolean; super: boolean }`; the key NAME arrives lowercased (`key.key === "space"`).
|
||||
@@ -441,7 +444,7 @@ Every diagnostic carries one of these IDs plus the fix and the why, and every ru
|
||||
- **NS1031 exported model helpers join the model's binding surface.** An exported single-Model-parameter helper becomes a Model declaration markup binds (`doneCount` → `{doneCount}`); two members with one binding name would be ambiguous — rename one.
|
||||
- **NS1032 viewUnbound names update-only model state.** `export const viewUnbound = [...] as const` entries must be string literals naming Model fields, exported model helpers, or Msg kinds — by their TypeScript spellings (`"nextId"`); anything else would silence nothing and hide a typo.
|
||||
- **NS1030 effect arguments respect the engine's limits.** A compile-time-knowable value outside an engine bound (a path literal over 1024 bytes, a URL literal over 2 KiB, more than 8 headers, a header block over 1 KiB, a delay literal outside 1ms..one year) stops the build instead of shipping a guaranteed runtime rejection. Dynamic values stay the engine's to validate — they surface through the `err` arm.
|
||||
- **NS1033 wiring channel exports match their host event shapes.** `frameMsg`/`keyMsg`/`pinchMsg`/`dropMsg` take their exact event records and return `Msg | null`; `appearanceMsg`/`chromeMsg` are string literals naming arms with those channels' record shapes; `envMsgs` entries carry `env` and a one-`Uint8Array`-field `msg` arm; `themePack`, `statusItem`, `statusItems`, and `windows` return their exact model-derived shell shapes. The generated wiring builds these host values structurally from your declarations, so a wrong shape is taught here instead of surfacing as a Zig error inside generated code.
|
||||
- **NS1033 wiring channel exports match their host event shapes.** `frameMsg`/`keyMsg`/`pinchMsg`/`dropMsg` take their exact event records and return `Msg | null`; `appearanceMsg`/`chromeMsg` are string literals naming arms with those channels' record shapes; `envMsgs` entries carry `env` and a one-`Uint8Array`-field `msg` arm; `themeState`, `themePack`, `statusItem`, `statusItems`, and `windows` return their exact model-derived shell shapes. The generated wiring builds these host values structurally from your declarations, so a wrong shape is taught here instead of surfacing as a Zig error inside generated code.
|
||||
- **NS1034 core imports stay inside src/.** `../` escapes and absolute paths are rejected: the entry module's directory is the core's whole world - the build ships exactly that tree.
|
||||
- **NS1035 npm packages do not run inside a core.** No JS engine ships in the binary; vendor the logic under `src/` or make the import type-only.
|
||||
- **NS1036 core modules do not import in a cycle.** Runtime cycles only work through JS live-binding indirection; hoist shared declarations, or make the back-edge `import type` (which is exempt and idiomatic).
|
||||
@@ -547,7 +550,7 @@ export function update(model: Model, msg: Msg): Model {
|
||||
|
||||
## Running a core as an app
|
||||
|
||||
A `native init` app needs NONE of this section: the build detects `src/core.ts` and stages the wiring itself (a core exporting `commandMsg(name: string): Msg | null` automatically receives menu/shortcut/status-item/window-close command events as Msgs). A core can also export `themePack(model: Model): ThemePack`, either status-item helper, and `windows(model): readonly WindowDescriptor[]`; the adapter wires the matching UiApp seams, while the generated launcher supplies secondary views from `src/windows/<label>.native`. The section below is for hand-Zig wiring — embedding a core in an existing Zig app or customizing the UiApp surface.
|
||||
A `native init` app needs NONE of this section: the build detects `src/core.ts` and stages the wiring itself (a core exporting `commandMsg(name: string): Msg | null` automatically receives menu/shortcut/status-item/window-close command events as Msgs). A core can also export `themeState(model: Model): ThemeState` (or legacy pack-only `themePack`), either status-item helper, and `windows(model): readonly WindowDescriptor[]`; the adapter wires the matching UiApp seams, while the generated launcher supplies secondary views from `src/windows/<label>.native`. The section below is for hand-Zig wiring — embedding a core in an existing Zig app or customizing the UiApp surface.
|
||||
|
||||
The compiled core runs as a full desktop app through `native_sdk.TsUiApp(core)` — the committed TS model IS the app model, no shim, no glue:
|
||||
|
||||
@@ -567,7 +570,7 @@ var app = Adapter.init(allocator, .{ .audio_cache_dir = resolved_cache_dir }, .{
|
||||
});
|
||||
```
|
||||
|
||||
- `update`/`update_fx`/`init_fx` belong to the adapter (it runs your `initialModel`, `update`, and `subscriptions` through the effect bridge), and the adapter wires `on_command`/`on_key`/`on_pinch`/`on_drop`/`on_appearance`/`on_chrome`/`on_frame` from the core's channel exports automatically. It likewise wires `theme_fn` from `themePack(model)` and the singular `status_item_fn` or collection `status_items_fn` from the matching helper. A wiring that also sets one of those seams is a teaching panic. Everything else is ordinary wiring: `tokens_fn`/`windows_fn` derive from `*const core.Model`, and `CoreOptions` carries the adapter-owned knobs (`audio_cache_dir`, `boot_images`, `env_values`).
|
||||
- `update`/`update_fx`/`init_fx` belong to the adapter (it runs your `initialModel`, `update`, and `subscriptions` through the effect bridge), and the adapter wires `on_command`/`on_key`/`on_pinch`/`on_drop`/`on_appearance`/`on_chrome`/`on_frame` from the core's channel exports automatically. It likewise wires `theme_state_fn` from `themeState(model)` or `theme_fn` from legacy `themePack(model)`, and the singular `status_item_fn` or collection `status_items_fn` from the matching helper. A wiring that also sets one of those seams is a teaching panic. Everything else is ordinary wiring: `tokens_fn`/`windows_fn` derive from `*const core.Model`, and `CoreOptions` carries the adapter-owned knobs (`audio_cache_dir`, `boot_images`, `env_values`).
|
||||
- Markup binds your model's field names EXACTLY as you wrote them: `nextId` binds as `{nextId}` (the core's model keeps the TS spellings). Record arrays iterate with `<for each="tasks" as="t" key="id">` and items bind their fields (`{t.title}`); optional scalars gate with `<if test="{selected}">` (null is falsy); string-literal unions bind as their member name (`{filter}` renders `all` — compare against a quoted literal, `selected="{sortKey == 'cpu'}"`); exported single-model helpers bind as derived values (`{doneCount}`) and slice-returning ones drive `for each`. Markup `<chart>` series bind number arrays too — `<series kind="bar" values="{cpuSpark}" />` over a field or helper returning `readonly number[]` (f64, narrowed per sample into the chart pipeline); pad a filling window's leading gap with `NaN` samples, which draw nothing.
|
||||
- `Options.sync` does not exist for TS apps (a committed model cannot be mutated in place): keep continuous controls model-driven — bind the widget's value and echo `on-change`/`on-scroll` Msgs back into the model.
|
||||
- One live app per core module per process: two apps over the SAME core would share one committed root — and a process carries ONE compiled core (the archive owns a fixed-prefix C ABI symbol set).
|
||||
|
||||
+208
-6
@@ -4,7 +4,9 @@ const native_sdk = @import("native_sdk");
|
||||
const app_manifest = @import("app_manifest_zon");
|
||||
const built_relational_migrations = @import("relational_migrations");
|
||||
const window_placement = @import("window_placement.zig");
|
||||
const manifest_commands = if (@hasField(@TypeOf(app_manifest), "commands")) app_manifest.commands else .{};
|
||||
const manifest_shortcuts = if (@hasField(@TypeOf(app_manifest), "shortcuts")) app_manifest.shortcuts else .{};
|
||||
const manifest_menus = if (@hasField(@TypeOf(app_manifest), "menus")) app_manifest.menus else .{};
|
||||
const manifest_windows = if (@hasField(@TypeOf(app_manifest), "windows")) app_manifest.windows else .{};
|
||||
|
||||
fn manifestImagePixelBudget() usize {
|
||||
@@ -62,7 +64,8 @@ pub const RunOptions = struct {
|
||||
builtin_bridge: native_sdk.BridgePolicy = .{},
|
||||
js_window_api: bool = false,
|
||||
security: native_sdk.SecurityPolicy = .{},
|
||||
menus: []const native_sdk.Menu = &.{},
|
||||
commands: ?[]const native_sdk.Command = null,
|
||||
menus: ?[]const native_sdk.Menu = null,
|
||||
shortcuts: ?[]const native_sdk.Shortcut = null,
|
||||
/// Filled by `runWithOptions` from the manifest capability. App entry
|
||||
/// points do not set this themselves; the field only carries the owned
|
||||
@@ -142,6 +145,72 @@ pub const RunOptions = struct {
|
||||
fn resolvedShortcuts(self: RunOptions, storage: *ShortcutStorage) []const native_sdk.Shortcut {
|
||||
return self.shortcuts orelse storage.fromManifest();
|
||||
}
|
||||
|
||||
fn resolvedCommands(self: RunOptions, storage: *CommandStorage) []const native_sdk.Command {
|
||||
return self.commands orelse storage.fromManifest();
|
||||
}
|
||||
|
||||
fn resolvedMenus(self: RunOptions, storage: *MenuStorage) []const native_sdk.Menu {
|
||||
return self.menus orelse storage.fromManifest();
|
||||
}
|
||||
};
|
||||
|
||||
const CommandStorage = struct {
|
||||
commands: [native_sdk.app_manifest.max_commands]native_sdk.Command = undefined,
|
||||
|
||||
fn fromManifest(self: *CommandStorage) []const native_sdk.Command {
|
||||
comptime {
|
||||
if (manifest_commands.len > native_sdk.app_manifest.max_commands) {
|
||||
@compileError("app.zon defines too many commands");
|
||||
}
|
||||
}
|
||||
|
||||
inline for (manifest_commands, 0..) |command, index| {
|
||||
self.commands[index] = .{
|
||||
.id = command.id,
|
||||
.title = if (@hasField(@TypeOf(command), "title")) command.title else "",
|
||||
.enabled = if (@hasField(@TypeOf(command), "enabled")) command.enabled else true,
|
||||
.checked = if (@hasField(@TypeOf(command), "checked")) command.checked else false,
|
||||
};
|
||||
}
|
||||
return self.commands[0..manifest_commands.len];
|
||||
}
|
||||
};
|
||||
|
||||
const MenuStorage = struct {
|
||||
menus: [native_sdk.platform.max_menus]native_sdk.Menu = undefined,
|
||||
items: [native_sdk.platform.max_menu_items]native_sdk.MenuItem = undefined,
|
||||
|
||||
fn fromManifest(self: *MenuStorage) []const native_sdk.Menu {
|
||||
comptime {
|
||||
if (manifest_menus.len > native_sdk.platform.max_menus) {
|
||||
@compileError("app.zon defines too many menus");
|
||||
}
|
||||
var item_count: usize = 0;
|
||||
for (manifest_menus) |menu| {
|
||||
const items = if (@hasField(@TypeOf(menu), "items")) menu.items else .{};
|
||||
item_count += items.len;
|
||||
}
|
||||
if (item_count > native_sdk.platform.max_menu_items) {
|
||||
@compileError("app.zon defines too many menu items");
|
||||
}
|
||||
}
|
||||
|
||||
var item_index: usize = 0;
|
||||
inline for (manifest_menus, 0..) |menu, menu_index| {
|
||||
const items = if (@hasField(@TypeOf(menu), "items")) menu.items else .{};
|
||||
const first_item = item_index;
|
||||
inline for (items) |item| {
|
||||
self.items[item_index] = menuItem(item);
|
||||
item_index += 1;
|
||||
}
|
||||
self.menus[menu_index] = .{
|
||||
.title = menu.title,
|
||||
.items = self.items[first_item..item_index],
|
||||
};
|
||||
}
|
||||
return self.menus[0..manifest_menus.len];
|
||||
}
|
||||
};
|
||||
|
||||
const ShortcutStorage = struct {
|
||||
@@ -558,6 +627,18 @@ fn manifestDeclaresCredentials() bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn menuItem(comptime item: anytype) native_sdk.MenuItem {
|
||||
return .{
|
||||
.label = if (@hasField(@TypeOf(item), "label")) item.label else "",
|
||||
.command = if (@hasField(@TypeOf(item), "command")) item.command else "",
|
||||
.key = if (@hasField(@TypeOf(item), "key")) item.key else "",
|
||||
.modifiers = shortcutModifiers(item),
|
||||
.separator = if (@hasField(@TypeOf(item), "separator")) item.separator else false,
|
||||
.enabled = if (@hasField(@TypeOf(item), "enabled")) item.enabled else true,
|
||||
.checked = if (@hasField(@TypeOf(item), "checked")) item.checked else false,
|
||||
};
|
||||
}
|
||||
|
||||
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
|
||||
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
|
||||
var modifiers: native_sdk.ShortcutModifiers = .{};
|
||||
@@ -697,6 +778,10 @@ fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !vo
|
||||
runtime_trace_sink = filtered_trace_sink.sink();
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
var menu_storage: MenuStorage = .{};
|
||||
const menus = options.resolvedMenus(&menu_storage);
|
||||
var command_storage: CommandStorage = .{};
|
||||
const commands = options.resolvedCommands(&command_storage);
|
||||
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
|
||||
// stack overflows on a stack instance, so construct it on the heap.
|
||||
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
|
||||
@@ -717,7 +802,8 @@ fn runNull(app: native_sdk.App, options: RunOptions, init: std.process.Init) !vo
|
||||
.web_layer = webLayerEnabled(),
|
||||
.gpu_surface_frame_diagnostics = false,
|
||||
.security = options.security,
|
||||
.menus = options.menus,
|
||||
.commands = commands,
|
||||
.menus = menus,
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
@@ -766,6 +852,10 @@ fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
|
||||
runtime_trace_sink = filtered_trace_sink.sink();
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
var menu_storage: MenuStorage = .{};
|
||||
const menus = options.resolvedMenus(&menu_storage);
|
||||
var command_storage: CommandStorage = .{};
|
||||
const commands = options.resolvedCommands(&command_storage);
|
||||
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
|
||||
// stack overflows on a stack instance, so construct it on the heap.
|
||||
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
|
||||
@@ -786,7 +876,8 @@ fn runMacos(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
|
||||
.web_layer = webLayerEnabled(),
|
||||
.gpu_surface_frame_diagnostics = false,
|
||||
.security = options.security,
|
||||
.menus = options.menus,
|
||||
.commands = commands,
|
||||
.menus = menus,
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
@@ -832,6 +923,10 @@ fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
|
||||
runtime_trace_sink = filtered_trace_sink.sink();
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
var menu_storage: MenuStorage = .{};
|
||||
const menus = options.resolvedMenus(&menu_storage);
|
||||
var command_storage: CommandStorage = .{};
|
||||
const commands = options.resolvedCommands(&command_storage);
|
||||
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
|
||||
// stack overflows on a stack instance, so construct it on the heap.
|
||||
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
|
||||
@@ -852,7 +947,8 @@ fn runLinux(app: native_sdk.App, options: RunOptions, init: std.process.Init) !v
|
||||
.web_layer = webLayerEnabled(),
|
||||
.gpu_surface_frame_diagnostics = false,
|
||||
.security = options.security,
|
||||
.menus = options.menus,
|
||||
.commands = commands,
|
||||
.menus = menus,
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
@@ -897,6 +993,10 @@ fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init)
|
||||
runtime_trace_sink = filtered_trace_sink.sink();
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
var menu_storage: MenuStorage = .{};
|
||||
const menus = options.resolvedMenus(&menu_storage);
|
||||
var command_storage: CommandStorage = .{};
|
||||
const commands = options.resolvedCommands(&command_storage);
|
||||
// The Runtime is multi-megabyte; Linux's default 8 MB main-thread
|
||||
// stack overflows on a stack instance, so construct it on the heap.
|
||||
const runtime = try std.heap.page_allocator.create(native_sdk.Runtime);
|
||||
@@ -917,7 +1017,8 @@ fn runWindows(app: native_sdk.App, options: RunOptions, init: std.process.Init)
|
||||
.web_layer = webLayerEnabled(),
|
||||
.gpu_surface_frame_diagnostics = false,
|
||||
.security = options.security,
|
||||
.menus = options.menus,
|
||||
.commands = commands,
|
||||
.menus = menus,
|
||||
.shortcuts = shortcuts,
|
||||
.automation = if (build_options.automation) native_sdk.automation.Server.init(init.io, ".zig-cache/native-sdk-automation", app_info.resolvedWindowTitle()) else null,
|
||||
.window_state_store = store,
|
||||
@@ -1058,6 +1159,12 @@ fn runSessionReplay(app: native_sdk.App, options: RunOptions, init: std.process.
|
||||
// (and disarm the producer wake bindings) before the runtime
|
||||
// storage itself goes.
|
||||
defer runtime.deinit();
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
var menu_storage: MenuStorage = .{};
|
||||
const menus = options.resolvedMenus(&menu_storage);
|
||||
var command_storage: CommandStorage = .{};
|
||||
const commands = options.resolvedCommands(&command_storage);
|
||||
// Bridge policy and security must match what the recording ran
|
||||
// under (they gate replayed bridge_message dispatch); automation,
|
||||
// window-state restore, and tracing stay off — replay consumes only
|
||||
@@ -1070,7 +1177,9 @@ fn runSessionReplay(app: native_sdk.App, options: RunOptions, init: std.process.
|
||||
.js_window_api = options.js_window_api,
|
||||
.web_layer = webLayerEnabled(),
|
||||
.security = options.security,
|
||||
.menus = options.menus,
|
||||
.commands = commands,
|
||||
.menus = menus,
|
||||
.shortcuts = shortcuts,
|
||||
});
|
||||
|
||||
const verify = if (init.environ_map.get("NATIVE_SDK_SESSION_VERIFY")) |value|
|
||||
@@ -1142,6 +1251,99 @@ fn webEngine() native_sdk.WebEngine {
|
||||
return .system;
|
||||
}
|
||||
|
||||
test "RunOptions resolves manifest commands menus and shortcuts" {
|
||||
const options: RunOptions = .{
|
||||
.app_name = "runner-fixture",
|
||||
.bundle_id = "dev.native_sdk.runner_fixture",
|
||||
};
|
||||
|
||||
var command_storage: CommandStorage = .{};
|
||||
const commands = options.resolvedCommands(&command_storage);
|
||||
try std.testing.expectEqual(@as(usize, 2), commands.len);
|
||||
try std.testing.expectEqualStrings("app.refresh", commands[0].id);
|
||||
try std.testing.expectEqualStrings("Refresh", commands[0].title);
|
||||
try std.testing.expect(!commands[0].enabled);
|
||||
try std.testing.expect(commands[0].checked);
|
||||
try std.testing.expectEqualStrings("app.defaults", commands[1].id);
|
||||
try std.testing.expectEqualStrings("", commands[1].title);
|
||||
try std.testing.expect(commands[1].enabled);
|
||||
try std.testing.expect(!commands[1].checked);
|
||||
|
||||
var menu_storage: MenuStorage = .{};
|
||||
const menus = options.resolvedMenus(&menu_storage);
|
||||
try std.testing.expectEqual(@as(usize, 2), menus.len);
|
||||
try std.testing.expectEqualStrings("View", menus[0].title);
|
||||
try std.testing.expectEqual(@as(usize, 3), menus[0].items.len);
|
||||
try std.testing.expectEqualStrings("Refresh", menus[0].items[0].label);
|
||||
try std.testing.expectEqualStrings("app.refresh", menus[0].items[0].command);
|
||||
try std.testing.expectEqualStrings("r", menus[0].items[0].key);
|
||||
try std.testing.expect(menus[0].items[0].modifiers.option);
|
||||
try std.testing.expect(menus[0].items[0].modifiers.shift);
|
||||
try std.testing.expect(!menus[0].items[0].enabled);
|
||||
try std.testing.expect(menus[0].items[0].checked);
|
||||
try std.testing.expect(menus[0].items[1].separator);
|
||||
try std.testing.expectEqualStrings("Defaults", menus[0].items[2].label);
|
||||
try std.testing.expect(menus[0].items[2].enabled);
|
||||
try std.testing.expect(!menus[0].items[2].checked);
|
||||
try std.testing.expectEqualStrings("Help", menus[1].title);
|
||||
try std.testing.expectEqual(@as(usize, 0), menus[1].items.len);
|
||||
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
try std.testing.expectEqual(@as(usize, 1), shortcuts.len);
|
||||
try std.testing.expectEqualStrings("app.refresh", shortcuts[0].id);
|
||||
try std.testing.expectEqualStrings("r", shortcuts[0].key);
|
||||
try std.testing.expect(shortcuts[0].modifiers.primary);
|
||||
}
|
||||
|
||||
test "RunOptions explicit command menu and shortcut slices override manifest values" {
|
||||
const override_commands = [_]native_sdk.Command{
|
||||
.{ .id = "override.command", .title = "Override Command" },
|
||||
};
|
||||
const override_items = [_]native_sdk.MenuItem{
|
||||
.{ .label = "Override Item", .command = "override.command" },
|
||||
};
|
||||
const override_menus = [_]native_sdk.Menu{
|
||||
.{ .title = "Override Menu", .items = &override_items },
|
||||
};
|
||||
const override_shortcuts = [_]native_sdk.Shortcut{
|
||||
.{ .id = "override.command", .key = "o", .modifiers = .{ .primary = true } },
|
||||
};
|
||||
const options: RunOptions = .{
|
||||
.app_name = "runner-fixture",
|
||||
.bundle_id = "dev.native_sdk.runner_fixture",
|
||||
.commands = &override_commands,
|
||||
.menus = &override_menus,
|
||||
.shortcuts = &override_shortcuts,
|
||||
};
|
||||
|
||||
var command_storage: CommandStorage = .{};
|
||||
const commands = options.resolvedCommands(&command_storage);
|
||||
try std.testing.expectEqual(@as(usize, 1), commands.len);
|
||||
try std.testing.expectEqualStrings("override.command", commands[0].id);
|
||||
|
||||
var menu_storage: MenuStorage = .{};
|
||||
const menus = options.resolvedMenus(&menu_storage);
|
||||
try std.testing.expectEqual(@as(usize, 1), menus.len);
|
||||
try std.testing.expectEqualStrings("Override Menu", menus[0].title);
|
||||
|
||||
var shortcut_storage: ShortcutStorage = .{};
|
||||
const shortcuts = options.resolvedShortcuts(&shortcut_storage);
|
||||
try std.testing.expectEqual(@as(usize, 1), shortcuts.len);
|
||||
try std.testing.expectEqualStrings("override.command", shortcuts[0].id);
|
||||
|
||||
const empty_options: RunOptions = .{
|
||||
.app_name = "runner-fixture",
|
||||
.bundle_id = "dev.native_sdk.runner_fixture",
|
||||
.commands = &.{},
|
||||
.menus = &.{},
|
||||
.shortcuts = &.{},
|
||||
};
|
||||
try std.testing.expectEqual(@as(usize, 0), empty_options.resolvedCommands(&command_storage).len);
|
||||
try std.testing.expectEqual(@as(usize, 0), empty_options.resolvedMenus(&menu_storage).len);
|
||||
try std.testing.expectEqual(@as(usize, 0), empty_options.resolvedShortcuts(&shortcut_storage).len);
|
||||
}
|
||||
|
||||
const StateBuffers = struct {
|
||||
state_dir: [1024]u8 = undefined,
|
||||
file_path: [1200]u8 = undefined,
|
||||
|
||||
@@ -477,6 +477,7 @@ fn manifestPersistDebounce(comptime config: anytype) u32 {
|
||||
}
|
||||
|
||||
fn validatePersistRoutes(comptime routes: anytype) void {
|
||||
@setEvalBranchQuota(Adapter.persist_route_scan_quota);
|
||||
if (!persistRouteMatches(routes.ok, void)) {
|
||||
@compileError("app.zon .persist.restore.ok must name a void Msg arm in src/core.ts");
|
||||
}
|
||||
@@ -489,6 +490,7 @@ fn validatePersistRoutes(comptime routes: anytype) void {
|
||||
}
|
||||
|
||||
fn persistRouteMatches(comptime name: []const u8, comptime Payload: type) bool {
|
||||
@setEvalBranchQuota(Adapter.msg_scan_quota);
|
||||
const info = @typeInfo(core.Msg);
|
||||
if (info != .@"union") return false;
|
||||
inline for (info.@"union".fields) |field| {
|
||||
|
||||
@@ -55,7 +55,11 @@ pub const fingerprint: u64 = layout_fingerprint.hash(layoutDescription(semantic_
|
||||
/// visibility, and `tray-action` gained the explicit
|
||||
/// `<status-item-id> <menu-item-id>` form (the old one-id primary-item
|
||||
/// shorthand remains valid).
|
||||
pub const semantic_epoch: u32 = 2;
|
||||
/// Epoch 3: automation snapshots expose the app menus configured on the
|
||||
/// runtime, including command ids and enabled/checked/key state.
|
||||
/// Epoch 4: command and app-menu catalog strings in snapshots use
|
||||
/// JSON-style escapes so every catalog entry remains exactly one line.
|
||||
pub const semantic_epoch: u32 = 4;
|
||||
|
||||
/// The canonical description the protocol fingerprint hashes: the
|
||||
/// command vocabulary (the `Action` enum, reflected — names and values,
|
||||
|
||||
+210
-3
@@ -1,5 +1,6 @@
|
||||
const std = @import("std");
|
||||
const geometry = @import("geometry");
|
||||
const app_manifest = @import("app_manifest");
|
||||
const platform = @import("../platform/root.zig");
|
||||
const protocol = @import("protocol.zig");
|
||||
|
||||
@@ -227,6 +228,9 @@ pub const TrayItem = struct {
|
||||
role: platform.TrayItemRole = .command,
|
||||
key: []const u8 = "",
|
||||
modifiers: platform.ShortcutModifiers = .{},
|
||||
segmented: ?platform.TraySegmentedRow = null,
|
||||
metric: ?platform.TrayMetricRow = null,
|
||||
chart: ?platform.TrayChartRow = null,
|
||||
};
|
||||
|
||||
/// The live status item (tray): current button title + dropdown items.
|
||||
@@ -290,6 +294,12 @@ pub const Input = struct {
|
||||
windows: []const Window,
|
||||
views: []const platform.ViewInfo = &.{},
|
||||
widgets: []const Widget = &.{},
|
||||
/// Static command and app-menu catalogs configured on the runtime.
|
||||
/// These receipts let automation prove generated/ejected runners
|
||||
/// loaded their manifest declarations before injecting command-source
|
||||
/// events that cannot drive an OS menu tracking loop directly.
|
||||
commands: []const app_manifest.Command = &.{},
|
||||
menus: []const platform.Menu = &.{},
|
||||
diagnostics: Diagnostics = .{},
|
||||
/// Per-stage frame timing, non-null while `profile on` is active —
|
||||
/// printed as the `frame_profile` line right after the header.
|
||||
@@ -595,6 +605,40 @@ pub fn writeText(input: Input, writer: anytype) !void {
|
||||
try writeWidgetContextMenu(widget, writer);
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
for (input.commands) |command| {
|
||||
try writer.writeAll("command id=");
|
||||
try writeQuotedSnapshotText(command.id, writer);
|
||||
try writer.writeAll(" title=");
|
||||
try writeQuotedSnapshotText(command.title, writer);
|
||||
try writer.print(" enabled={any} checked={any}\n", .{ command.enabled, command.checked });
|
||||
}
|
||||
for (input.menus) |menu| {
|
||||
try writer.writeAll("app-menu title=");
|
||||
try writeQuotedSnapshotText(menu.title, writer);
|
||||
try writer.print(" items={d}\n", .{menu.items.len});
|
||||
for (menu.items) |item| {
|
||||
if (item.separator) {
|
||||
try writer.writeAll(" app-menu-item separator\n");
|
||||
continue;
|
||||
}
|
||||
try writer.writeAll(" app-menu-item label=");
|
||||
try writeQuotedSnapshotText(item.label, writer);
|
||||
try writer.writeAll(" command=");
|
||||
try writeQuotedSnapshotText(item.command, writer);
|
||||
try writer.print(" enabled={any} checked={any} key=", .{
|
||||
item.enabled,
|
||||
item.checked,
|
||||
});
|
||||
try writeQuotedSnapshotText(item.key, writer);
|
||||
try writer.print(" modifiers=(primary={any},command={any},control={any},option={any},shift={any})\n", .{
|
||||
item.modifiers.primary,
|
||||
item.modifiers.command,
|
||||
item.modifiers.control,
|
||||
item.modifiers.option,
|
||||
item.modifiers.shift,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (input.trays) |tray| {
|
||||
try writer.print("tray #{d} title=\"{s}\" visible={any} items={d}\n", .{ tray.id, tray.title, tray.visible, tray.items.len });
|
||||
for (tray.items) |item| {
|
||||
@@ -602,6 +646,42 @@ pub fn writeText(input: Input, writer: anytype) !void {
|
||||
try writer.writeAll(" tray-item separator\n");
|
||||
continue;
|
||||
}
|
||||
if (item.segmented) |segmented| {
|
||||
try writer.print(" tray-item #{d} role=segmented options={d}\n", .{ item.id, segmented.options.len });
|
||||
for (segmented.options) |option| {
|
||||
try writer.print(" tray-segment #{d} label=", .{option.id});
|
||||
try writeQuotedSnapshotText(option.label, writer);
|
||||
try writer.writeAll(" command=");
|
||||
try writeQuotedSnapshotText(option.command, writer);
|
||||
try writer.print(" selected={any} enabled={any}\n", .{ option.selected, option.enabled });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (item.metric) |metric| {
|
||||
try writer.print(" tray-item #{d} role=metric primary=", .{item.id});
|
||||
try writeQuotedSnapshotText(metric.primary_text, writer);
|
||||
try writer.writeAll(" secondary=");
|
||||
try writeQuotedSnapshotText(metric.secondary_text, writer);
|
||||
try writer.writeAll(" accessibility=");
|
||||
try writeQuotedSnapshotText(metric.accessibility_label, writer);
|
||||
try writer.writeByte('\n');
|
||||
continue;
|
||||
}
|
||||
if (item.chart) |chart| {
|
||||
try writer.print(" tray-item #{d} role=chart caption=", .{item.id});
|
||||
try writeQuotedSnapshotText(chart.leading_caption, writer);
|
||||
try writer.writeAll(" summary=");
|
||||
try writeQuotedSnapshotText(chart.trailing_summary, writer);
|
||||
try writer.writeAll(" accessibility=");
|
||||
try writeQuotedSnapshotText(chart.accessibility_label, writer);
|
||||
try writer.print(" domain=({d},{d}) values=", .{ chart.min_value, chart.max_value });
|
||||
for (chart.values, 0..) |value, index| {
|
||||
if (index > 0) try writer.writeByte(',');
|
||||
try writer.print("{d}", .{value});
|
||||
}
|
||||
try writer.writeByte('\n');
|
||||
continue;
|
||||
}
|
||||
try writer.print(" tray-item #{d} label=\"{s}\" command=\"{s}\" enabled={any} detail=\"{s}\" role={s} key=\"{s}\" modifiers=(primary={any},command={any},control={any},option={any},shift={any})\n", .{
|
||||
item.id,
|
||||
item.label,
|
||||
@@ -726,6 +806,31 @@ pub fn writeA11yText(input: Input, writer: anytype) !void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Catalog values are user-authored but snapshots are line-oriented. Keep
|
||||
/// each configured command/menu record on exactly one line and preserve its
|
||||
/// byte identity with JSON-style escapes for delimiters and control bytes.
|
||||
fn writeQuotedSnapshotText(value: []const u8, writer: anytype) !void {
|
||||
const hex = "0123456789abcdef";
|
||||
try writer.writeByte('"');
|
||||
for (value) |byte| {
|
||||
switch (byte) {
|
||||
'"' => try writer.writeAll("\\\""),
|
||||
'\\' => try writer.writeAll("\\\\"),
|
||||
'\n' => try writer.writeAll("\\n"),
|
||||
'\r' => try writer.writeAll("\\r"),
|
||||
'\t' => try writer.writeAll("\\t"),
|
||||
else => if (byte < 0x20 or byte == 0x7f) {
|
||||
try writer.writeAll("\\u00");
|
||||
try writer.writeByte(hex[byte >> 4]);
|
||||
try writer.writeByte(hex[byte & 0x0f]);
|
||||
} else {
|
||||
try writer.writeByte(byte);
|
||||
},
|
||||
}
|
||||
}
|
||||
try writer.writeByte('"');
|
||||
}
|
||||
|
||||
fn writeWidgetParent(widget: Widget, writer: anytype) !void {
|
||||
if (widget.parent_id) |parent_id| try writer.print(" parent=#{d}", .{parent_id});
|
||||
}
|
||||
@@ -916,24 +1021,36 @@ test "snapshot emits the frame_profile line only while profiling" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, off_writer.buffered(), "frame_profile") == null);
|
||||
}
|
||||
|
||||
test "snapshot emits tray title and dropdown items" {
|
||||
var buffer: [1024]u8 = undefined;
|
||||
test "snapshot emits tray title and typed dropdown items" {
|
||||
var buffer: [2048]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buffer);
|
||||
const windows = [_]Window{.{ .title = "Test", .bounds = geometry.RectF.init(0, 0, 100, 100) }};
|
||||
const segments = [_]platform.TraySegmentOption{
|
||||
.{ .id = 20, .label = "Day", .command = "range.day", .selected = true },
|
||||
.{ .id = 21, .label = "Week", .command = "range.week", .enabled = false },
|
||||
};
|
||||
const chart_values = [_]f32{ 0.25, 0.5, 1 };
|
||||
const items = [_]TrayItem{
|
||||
.{ .id = 1, .label = "Refresh", .command = "app.refresh" },
|
||||
.{ .role = .hero, .metric = .{ .primary_text = "2,494 requests", .secondary_text = "Today · production", .accessibility_label = "2,494 requests today in production" } },
|
||||
.{ .separator = true },
|
||||
.{ .id = 10, .label = "Fix crash on resize", .command = "issue.select.0", .enabled = false, .detail = "warning ⚠", .role = .agent, .key = "q", .modifiers = .{ .command = true } },
|
||||
.{ .role = .segmented, .segmented = .{ .options = &segments } },
|
||||
.{ .role = .chart, .chart = .{ .values = &chart_values, .min_value = 0, .max_value = 1, .leading_caption = "CPU", .trailing_summary = "50%", .accessibility_label = "CPU history, 50 percent" } },
|
||||
};
|
||||
try writeText(.{
|
||||
.windows = &windows,
|
||||
.trays = &.{.{ .id = 7, .title = "ZN 3", .visible = false, .items = &items }},
|
||||
}, &writer);
|
||||
const text = writer.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "\ntray #7 title=\"ZN 3\" visible=false items=3\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "\ntray #7 title=\"ZN 3\" visible=false items=6\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " tray-item #1 label=\"Refresh\" command=\"app.refresh\" enabled=true detail=\"\" role=command key=\"\" modifiers=(primary=false,command=false,control=false,option=false,shift=false)\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " tray-item separator\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " tray-item #0 role=metric primary=\"2,494 requests\" secondary=\"Today · production\" accessibility=\"2,494 requests today in production\"\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " tray-item #10 label=\"Fix crash on resize\" command=\"issue.select.0\" enabled=false detail=\"warning ⚠\" role=agent key=\"q\" modifiers=(primary=false,command=true,control=false,option=false,shift=false)\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " tray-item #0 role=segmented options=2\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " tray-segment #20 label=\"Day\" command=\"range.day\" selected=true enabled=true\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " tray-item #0 role=chart caption=\"CPU\" summary=\"50%\" accessibility=\"CPU history, 50 percent\" domain=(0,1) values=0.25,0.5,1\n") != null);
|
||||
|
||||
// No tray -> no tray lines.
|
||||
var empty_buffer: [512]u8 = undefined;
|
||||
@@ -942,6 +1059,96 @@ test "snapshot emits tray title and dropdown items" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, empty_writer.buffered(), "tray") == null);
|
||||
}
|
||||
|
||||
test "snapshot escapes hostile typed tray row text" {
|
||||
var buffer: [4096]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buffer);
|
||||
const windows = [_]Window{.{ .title = "Test", .bounds = geometry.RectF.init(0, 0, 100, 100) }};
|
||||
const segments = [_]platform.TraySegmentOption{.{
|
||||
.id = 20,
|
||||
.label = "Day\"\nnext\\",
|
||||
.command = "range.\"day\n",
|
||||
}};
|
||||
const chart_values = [_]f32{0.5};
|
||||
const items = [_]TrayItem{
|
||||
.{ .role = .segmented, .segmented = .{ .options = &segments } },
|
||||
.{ .role = .hero, .metric = .{
|
||||
.primary_text = "2\"\nrequests",
|
||||
.secondary_text = "Today\r\\production",
|
||||
.accessibility_label = "metric\t\x01",
|
||||
} },
|
||||
.{ .role = .chart, .chart = .{
|
||||
.values = &chart_values,
|
||||
.leading_caption = "CPU\"\n",
|
||||
.trailing_summary = "50%\\\r",
|
||||
.accessibility_label = "chart\t\x7f",
|
||||
} },
|
||||
};
|
||||
try writeText(.{
|
||||
.windows = &windows,
|
||||
.trays = &.{.{ .items = &items }},
|
||||
}, &writer);
|
||||
const text = writer.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "tray-segment #20 label=\"Day\\\"\\nnext\\\\\" command=\"range.\\\"day\\n\"") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "role=metric primary=\"2\\\"\\nrequests\" secondary=\"Today\\r\\\\production\" accessibility=\"metric\\t\\u0001\"") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "role=chart caption=\"CPU\\\"\\n\" summary=\"50%\\\\\\r\" accessibility=\"chart\\t\\u007f\"") != null);
|
||||
// Header, window, tray, segmented wrapper, segment, metric, and chart:
|
||||
// hostile values inject no additional line-oriented records.
|
||||
try std.testing.expectEqual(@as(usize, 7), std.mem.count(u8, text, "\n"));
|
||||
}
|
||||
|
||||
test "snapshot emits configured command and app-menu catalogs" {
|
||||
var buffer: [2048]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buffer);
|
||||
const windows = [_]Window{.{ .title = "Test", .bounds = geometry.RectF.init(0, 0, 100, 100) }};
|
||||
const commands = [_]app_manifest.Command{
|
||||
.{ .id = "app.refresh", .title = "Refresh" },
|
||||
};
|
||||
const items = [_]platform.MenuItem{
|
||||
.{ .label = "Refresh", .command = "app.refresh", .key = "r", .modifiers = .{ .primary = true } },
|
||||
.{ .separator = true },
|
||||
};
|
||||
const menus = [_]platform.Menu{
|
||||
.{ .title = "View", .items = &items },
|
||||
};
|
||||
try writeText(.{
|
||||
.windows = &windows,
|
||||
.commands = &commands,
|
||||
.menus = &menus,
|
||||
}, &writer);
|
||||
const text = writer.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "\ncommand id=\"app.refresh\" title=\"Refresh\" enabled=true checked=false\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "app-menu title=\"View\" items=2\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item label=\"Refresh\" command=\"app.refresh\" enabled=true checked=false key=\"r\" modifiers=(primary=true,command=false,control=false,option=false,shift=false)\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item separator\n") != null);
|
||||
}
|
||||
|
||||
test "snapshot escapes hostile command and app-menu catalog text" {
|
||||
var buffer: [2048]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buffer);
|
||||
const windows = [_]Window{.{ .title = "Test", .bounds = geometry.RectF.init(0, 0, 100, 100) }};
|
||||
const commands = [_]app_manifest.Command{
|
||||
.{ .id = "app.\"quoted", .title = "Line 1\nLine 2\\tail\t\x01\x7f Café" },
|
||||
};
|
||||
const items = [_]platform.MenuItem{
|
||||
.{ .label = "Open \"now\"\rnext\\", .command = "app.\"run", .key = "r\t" },
|
||||
};
|
||||
const menus = [_]platform.Menu{
|
||||
.{ .title = "Tools\"\nInjected", .items = &items },
|
||||
};
|
||||
try writeText(.{
|
||||
.windows = &windows,
|
||||
.commands = &commands,
|
||||
.menus = &menus,
|
||||
}, &writer);
|
||||
const text = writer.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "\ncommand id=\"app.\\\"quoted\" title=\"Line 1\\nLine 2\\\\tail\\t\\u0001\\u007f Café\" enabled=true checked=false\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "app-menu title=\"Tools\\\"\\nInjected\" items=1\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item label=\"Open \\\"now\\\"\\rnext\\\\\" command=\"app.\\\"run\" enabled=true checked=false key=\"r\\t\" modifiers=(primary=false,command=false,control=false,option=false,shift=false)\n") != null);
|
||||
// Header, window, command, menu, and item: hostile values inject no
|
||||
// additional records into the line-oriented snapshot.
|
||||
try std.testing.expectEqual(@as(usize, 5), std.mem.count(u8, text, "\n"));
|
||||
}
|
||||
|
||||
test "accessibility snapshot uses visible view text as name" {
|
||||
var buffer: [512]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buffer);
|
||||
|
||||
@@ -705,6 +705,44 @@ typedef struct {
|
||||
|
||||
typedef void (*native_sdk_appkit_tray_callback_t)(void *context, uint32_t status_item_id, uint32_t item_id);
|
||||
|
||||
typedef struct {
|
||||
uint32_t item_id;
|
||||
const char *label;
|
||||
size_t label_len;
|
||||
int selected;
|
||||
int enabled;
|
||||
} native_sdk_appkit_tray_segment_option_t;
|
||||
|
||||
typedef struct {
|
||||
size_t row_index;
|
||||
const native_sdk_appkit_tray_segment_option_t *options;
|
||||
size_t option_count;
|
||||
} native_sdk_appkit_tray_segmented_row_t;
|
||||
|
||||
typedef struct {
|
||||
size_t row_index;
|
||||
const char *primary_text;
|
||||
size_t primary_text_len;
|
||||
const char *secondary_text;
|
||||
size_t secondary_text_len;
|
||||
const char *accessibility_label;
|
||||
size_t accessibility_label_len;
|
||||
} native_sdk_appkit_tray_metric_row_t;
|
||||
|
||||
typedef struct {
|
||||
size_t row_index;
|
||||
const float *values;
|
||||
size_t value_count;
|
||||
double min_value;
|
||||
double max_value;
|
||||
const char *leading_caption;
|
||||
size_t leading_caption_len;
|
||||
const char *trailing_summary;
|
||||
size_t trailing_summary_len;
|
||||
const char *accessibility_label;
|
||||
size_t accessibility_label_len;
|
||||
} native_sdk_appkit_tray_chart_row_t;
|
||||
|
||||
/* One native scroll driver's desired state (see PlatformServices
|
||||
* set_gpu_surface_scroll_drivers_fn). Frame coordinates are view-local
|
||||
* canvas points (top-left origin, y-down); the host flips to AppKit
|
||||
@@ -772,15 +810,16 @@ int native_sdk_appkit_show_context_menu(native_sdk_appkit_host_t *host, uint64_t
|
||||
native_sdk_appkit_open_dialog_result_t native_sdk_appkit_show_open_dialog(native_sdk_appkit_host_t *host, const native_sdk_appkit_open_dialog_opts_t *opts, char *buffer, size_t buffer_len);
|
||||
size_t native_sdk_appkit_show_save_dialog(native_sdk_appkit_host_t *host, const native_sdk_appkit_save_dialog_opts_t *opts, char *buffer, size_t buffer_len);
|
||||
int native_sdk_appkit_show_message_dialog(native_sdk_appkit_host_t *host, const native_sdk_appkit_message_dialog_opts_t *opts);
|
||||
void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *icon_path, size_t icon_path_len, const char *title, size_t title_len, const char *tooltip, size_t tooltip_len, int visible, double width, int tone, double icon_opacity, int monospaced, const char *activation_command, size_t activation_command_len, const char *alternate_activation_command, size_t alternate_activation_command_len, const char *open_command, size_t open_command_len);
|
||||
void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *icon_path, size_t icon_path_len, const char *title, size_t title_len, const char *tooltip, size_t tooltip_len, int visible, double width, int tone, double icon_opacity, int monospaced, double font_size, int font_weight, const char *activation_command, size_t activation_command_len, const char *alternate_activation_command, size_t alternate_activation_command_len, const char *open_command, size_t open_command_len);
|
||||
void native_sdk_appkit_update_tray_shell(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *icon_path, size_t icon_path_len, const char *tooltip, size_t tooltip_len, int visible, const char *activation_command, size_t activation_command_len, const char *alternate_activation_command, size_t alternate_activation_command_len, const char *open_command, size_t open_command_len);
|
||||
void native_sdk_appkit_update_tray_menu(native_sdk_appkit_host_t *host, uint32_t status_item_id, const uint32_t *item_ids, const char *const *labels, const size_t *label_lens, const int *separators, const int *enabled_flags, const char *const *details, const size_t *detail_lens, const int *roles, const char *const *keys, const size_t *key_lens, const uint32_t *modifiers, size_t count);
|
||||
void native_sdk_appkit_update_tray_rich_rows(native_sdk_appkit_host_t *host, uint32_t status_item_id, const native_sdk_appkit_tray_segmented_row_t *segmented_rows, size_t segmented_count, const native_sdk_appkit_tray_metric_row_t *metric_rows, size_t metric_count, const native_sdk_appkit_tray_chart_row_t *chart_rows, size_t chart_count);
|
||||
/* Retitle the live status item's button without re-creating it (create
|
||||
* would flicker and reshuffle the menu bar). Empty title falls back to
|
||||
* the icon-only square well, or the app-name initial when there is no
|
||||
* icon either — the same fallbacks as create. */
|
||||
void native_sdk_appkit_update_tray_title(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len);
|
||||
void native_sdk_appkit_update_tray_presentation(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len, double width, int tone, double icon_opacity, int monospaced);
|
||||
void native_sdk_appkit_update_tray_presentation(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len, double width, int tone, double icon_opacity, int monospaced, double font_size, int font_weight);
|
||||
void native_sdk_appkit_remove_tray(native_sdk_appkit_host_t *host, uint32_t status_item_id);
|
||||
void native_sdk_appkit_set_tray_callback(native_sdk_appkit_host_t *host, native_sdk_appkit_tray_callback_t callback, void *context);
|
||||
|
||||
|
||||
@@ -483,7 +483,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
|
||||
- (BOOL)emitSetSelectionAccessibilityValue:(id)value;
|
||||
@end
|
||||
|
||||
@interface NativeSdkMetalSurfaceView : NSView <NSTextInputClient>
|
||||
@interface NativeSdkMetalSurfaceView : NSView <NSTextInputClient, NSDraggingDestination>
|
||||
@property(nonatomic, strong) id<MTLDevice> device;
|
||||
@property(nonatomic, strong) id<MTLCommandQueue> commandQueue;
|
||||
@property(nonatomic, strong) CAMetalLayer *metalLayer;
|
||||
@@ -502,6 +502,7 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
|
||||
@property(nonatomic, assign) NativeSdkAppKitHost *host;
|
||||
@property(nonatomic, assign) uint64_t windowId;
|
||||
@property(nonatomic, strong) NSString *surfaceLabel;
|
||||
@property(nonatomic, strong) NSString *viewLabel;
|
||||
@property(nonatomic, assign) NSUInteger frameIndex;
|
||||
/* Whether this surface has completed at least one REAL present. Gates the
|
||||
* occluded short-circuit: until the first present lands, occluded frames
|
||||
@@ -773,6 +774,10 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
|
||||
@property(nonatomic, assign) uint32_t modifiers;
|
||||
@end
|
||||
|
||||
@interface NativeSdkTraySegmentedControl : NSSegmentedControl
|
||||
@property(nonatomic, assign) NSInteger sourceSelectedSegment;
|
||||
@end
|
||||
|
||||
@interface NativeSdkStatusItemEntry : NSObject
|
||||
@property(nonatomic, assign) uint32_t identifier;
|
||||
@property(nonatomic, strong) NSStatusItem *item;
|
||||
@@ -783,6 +788,8 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
|
||||
@property(nonatomic, assign) int presentationTone;
|
||||
@property(nonatomic, assign) double presentationIconOpacity;
|
||||
@property(nonatomic, assign) BOOL presentationMonospaced;
|
||||
@property(nonatomic, assign) double presentationFontSize;
|
||||
@property(nonatomic, assign) int presentationFontWeight;
|
||||
@property(nonatomic, strong) NSString *activationCommand;
|
||||
@property(nonatomic, strong) NSString *alternateActivationCommand;
|
||||
@property(nonatomic, strong) NSString *openCommand;
|
||||
@@ -1094,13 +1101,14 @@ static int NativeSdkCredentialStatus(OSStatus status, int missingCode) {
|
||||
- (NativeSdkStatusItemEntry *)statusEntryForMenu:(NSMenu *)menu;
|
||||
- (void)emitStatusCommand:(NSString *)command statusItemId:(uint32_t)statusItemId;
|
||||
- (void)statusItemActivated:(id)sender;
|
||||
- (void)traySegmentChanged:(NSSegmentedControl *)control;
|
||||
- (uint64_t)activeCommandWindowId;
|
||||
- (void)setMenusWithTitles:(const char *const *)menuTitles titleLengths:(const size_t *)menuTitleLengths count:(size_t)menuCount itemMenuIndices:(const uint32_t *)itemMenuIndices itemLabels:(const char *const *)itemLabels itemLabelLengths:(const size_t *)itemLabelLengths itemCommands:(const char *const *)itemCommands itemCommandLengths:(const size_t *)itemCommandLengths itemKeys:(const char *const *)itemKeys itemKeyLengths:(const size_t *)itemKeyLengths itemModifiers:(const uint32_t *)itemModifiers itemSeparators:(const int *)itemSeparators itemEnabled:(const int *)itemEnabled itemChecked:(const int *)itemChecked itemCount:(size_t)itemCount;
|
||||
- (void)runWithCallback:(native_sdk_appkit_event_callback_t)callback context:(void *)context;
|
||||
- (void)stop;
|
||||
- (BOOL)drainPendingPreRunStop;
|
||||
- (void)emitEvent:(native_sdk_appkit_event_t)event;
|
||||
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId;
|
||||
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId viewLabel:(NSString *)viewLabel point:(NSPoint)point;
|
||||
- (void)startApplicationActivationObservers;
|
||||
- (void)stopApplicationActivationObservers;
|
||||
- (void)applicationDidBecomeActive:(NSNotification *)notification;
|
||||
@@ -1199,6 +1207,14 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert an AppKit-local point to the runtime's top-left-origin space.
|
||||
// Unflipped canvas/content views need the y inversion; flipped views such as
|
||||
// WKWebView already use the runtime's orientation and must pass through.
|
||||
static NSPoint NativeSdkViewLocalYDownPoint(NSView *view, NSPoint point) {
|
||||
if (!view.isFlipped) point.y = view.bounds.size.height - point.y;
|
||||
return point;
|
||||
}
|
||||
|
||||
@implementation NativeSdkWindowDelegate
|
||||
|
||||
- (void)windowDidResize:(NSNotification *)notification {
|
||||
@@ -1291,9 +1307,9 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
|
||||
|
||||
// The window is a dragging destination now that the main WebView (whose
|
||||
// registration used to catch every drop) is lazy: NSWindow forwards
|
||||
// these to its delegate, and the emit path is byte-identical to the
|
||||
// WebView's. A present main/child WebView still wins (views outrank the
|
||||
// window for registered types), and its handler emits the same event.
|
||||
// these to its delegate. A present main/child WebView or canvas surface
|
||||
// still wins (views outrank the window for registered types); the fallback
|
||||
// hit-tests anyway so adopted/layered content keeps the most specific label.
|
||||
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
|
||||
(void)sender;
|
||||
return NSDragOperationCopy;
|
||||
@@ -1303,7 +1319,36 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
|
||||
NSPasteboard *pasteboard = sender.draggingPasteboard;
|
||||
NSArray<NSURL *> *urls = [pasteboard readObjectsForClasses:@[ [NSURL class] ]
|
||||
options:@{ NSPasteboardURLReadingFileURLsOnlyKey : @YES }];
|
||||
return [self.host emitDroppedFileURLs:urls windowId:self.windowId];
|
||||
NSWindow *window = self.host.windows[@(self.windowId)] ?: (self.windowId == 1 ? self.host.window : nil);
|
||||
NSView *contentView = window.contentView;
|
||||
if (!contentView) return NO;
|
||||
|
||||
const NSPoint windowPoint = sender.draggingLocation;
|
||||
const NSPoint contentPoint = [contentView convertPoint:windowPoint fromView:nil];
|
||||
NSView *targetView = [contentView hitTest:contentPoint];
|
||||
NSString *viewLabel = @"";
|
||||
for (NSView *candidate = targetView; candidate; candidate = candidate.superview) {
|
||||
if ([candidate isKindOfClass:[NativeSdkMetalSurfaceView class]]) {
|
||||
NSString *label = ((NativeSdkMetalSurfaceView *)candidate).viewLabel;
|
||||
if (label.length > 0) {
|
||||
targetView = candidate;
|
||||
viewLabel = label;
|
||||
break;
|
||||
}
|
||||
} else if ([candidate isKindOfClass:[NativeSdkWebView class]]) {
|
||||
NSString *label = ((NativeSdkWebView *)candidate).viewLabel;
|
||||
if (label.length > 0) {
|
||||
targetView = candidate;
|
||||
viewLabel = label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (candidate == contentView) break;
|
||||
}
|
||||
|
||||
NSView *coordinateView = viewLabel.length > 0 ? targetView : contentView;
|
||||
NSPoint point = NativeSdkViewLocalYDownPoint(coordinateView, [coordinateView convertPoint:windowPoint fromView:nil]);
|
||||
return [self.host emitDroppedFileURLs:urls windowId:self.windowId viewLabel:viewLabel point:point];
|
||||
}
|
||||
|
||||
// close_policy .hide: the USER's close affordance (the red button,
|
||||
@@ -1407,7 +1452,8 @@ static void NativeSdkEmitGpuSurfaceResizes(NSView *view) {
|
||||
NSPasteboard *pasteboard = sender.draggingPasteboard;
|
||||
NSArray<NSURL *> *urls = [pasteboard readObjectsForClasses:@[[NSURL class]]
|
||||
options:@{ NSPasteboardURLReadingFileURLsOnlyKey: @YES }];
|
||||
return [self.host emitDroppedFileURLs:urls windowId:self.windowId];
|
||||
NSPoint point = NativeSdkViewLocalYDownPoint(self, [self convertPoint:sender.draggingLocation fromView:nil]);
|
||||
return [self.host emitDroppedFileURLs:urls windowId:self.windowId viewLabel:self.viewLabel point:point];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -3713,6 +3759,14 @@ static void NativeSdkPremultiplyStraightRgba8(const uint8_t *source, uint8_t *de
|
||||
|
||||
@implementation NativeSdkMetalSurfaceView
|
||||
|
||||
- (NSString *)viewLabel {
|
||||
return self.surfaceLabel;
|
||||
}
|
||||
|
||||
- (void)setViewLabel:(NSString *)viewLabel {
|
||||
self.surfaceLabel = viewLabel ?: @"";
|
||||
}
|
||||
|
||||
- (instancetype)initWithFrame:(NSRect)frameRect {
|
||||
self = [super initWithFrame:frameRect];
|
||||
if (!self) return nil;
|
||||
@@ -3761,7 +3815,7 @@ static void NativeSdkPremultiplyStraightRgba8(const uint8_t *source, uint8_t *de
|
||||
- (void)configureWithHost:(NativeSdkAppKitHost *)host windowId:(uint64_t)windowId label:(NSString *)label {
|
||||
self.host = host;
|
||||
self.windowId = windowId;
|
||||
self.surfaceLabel = label ?: @"";
|
||||
self.viewLabel = label;
|
||||
__weak NativeSdkMetalSurfaceView *weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NativeSdkMetalSurfaceView *strongSelf = weakSelf;
|
||||
@@ -3772,6 +3826,19 @@ static void NativeSdkPremultiplyStraightRgba8(const uint8_t *source, uint8_t *de
|
||||
});
|
||||
}
|
||||
|
||||
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
|
||||
(void)sender;
|
||||
return NSDragOperationCopy;
|
||||
}
|
||||
|
||||
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
|
||||
NSPasteboard *pasteboard = sender.draggingPasteboard;
|
||||
NSArray<NSURL *> *urls = [pasteboard readObjectsForClasses:@[[NSURL class]]
|
||||
options:@{ NSPasteboardURLReadingFileURLsOnlyKey: @YES }];
|
||||
NSPoint point = NativeSdkViewLocalYDownPoint(self, [self convertPoint:sender.draggingLocation fromView:nil]);
|
||||
return [self.host emitDroppedFileURLs:urls windowId:self.windowId viewLabel:self.viewLabel point:point];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopDisplayTimer];
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
@@ -7019,7 +7086,7 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
|
||||
|
||||
- (void)emitInputEventWithKind:(NSInteger)kind point:(NSPoint)point timestampNs:(uint64_t)timestampNs modifiers:(uint32_t)modifiers keyText:(NSString *)keyText inputText:(NSString *)inputText button:(NSInteger)button deltaX:(double)deltaX deltaY:(double)deltaY {
|
||||
if (!self.host || self.surfaceLabel.length == 0) return;
|
||||
CGFloat y = self.bounds.size.height - point.y;
|
||||
const NSPoint yDownPoint = NativeSdkViewLocalYDownPoint(self, point);
|
||||
const char *labelBytes = self.surfaceLabel.UTF8String ?: "";
|
||||
NSString *safeKeyText = keyText ?: @"";
|
||||
NSString *safeInputText = inputText ?: @"";
|
||||
@@ -7029,8 +7096,8 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
|
||||
.kind = NATIVE_SDK_APPKIT_EVENT_GPU_SURFACE_INPUT,
|
||||
.window_id = self.windowId,
|
||||
.timestamp_ns = timestampNs,
|
||||
.x = point.x,
|
||||
.y = y,
|
||||
.x = yDownPoint.x,
|
||||
.y = yDownPoint.y,
|
||||
.view_label = labelBytes,
|
||||
.view_label_len = [self.surfaceLabel lengthOfBytesUsingEncoding:NSUTF8StringEncoding],
|
||||
.key_text = keyBytes,
|
||||
@@ -7420,6 +7487,9 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
|
||||
@implementation NativeSdkShortcut
|
||||
@end
|
||||
|
||||
@implementation NativeSdkTraySegmentedControl
|
||||
@end
|
||||
|
||||
@implementation NativeSdkStatusItemEntry
|
||||
@end
|
||||
|
||||
@@ -8449,6 +8519,7 @@ static float NativeSdkCaptureReadRemixedSample(const AudioBufferList *buffers, c
|
||||
case NATIVE_SDK_APPKIT_VIEW_GPU_SURFACE: {
|
||||
NativeSdkMetalSurfaceView *surface = [[NativeSdkMetalSurfaceView alloc] initWithFrame:NSZeroRect];
|
||||
if (![surface isAvailable]) return nil;
|
||||
[surface registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
|
||||
view = surface;
|
||||
break;
|
||||
}
|
||||
@@ -12036,7 +12107,7 @@ static void NativeSdkVideoFittedSize(double naturalWidth, double naturalHeight,
|
||||
}];
|
||||
}
|
||||
|
||||
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId {
|
||||
- (BOOL)emitDroppedFileURLs:(NSArray<NSURL *> *)urls windowId:(uint64_t)windowId viewLabel:(NSString *)viewLabel point:(NSPoint)point {
|
||||
if (urls.count == 0) return NO;
|
||||
NSMutableArray<NSString *> *paths = [NSMutableArray array];
|
||||
for (NSURL *url in urls) {
|
||||
@@ -12053,9 +12124,15 @@ static void NativeSdkVideoFittedSize(double naturalWidth, double naturalHeight,
|
||||
[data appendData:pathData];
|
||||
}
|
||||
if (data.length == 0) return NO;
|
||||
NSString *safeViewLabel = viewLabel ?: @"";
|
||||
const char *viewLabelBytes = safeViewLabel.UTF8String ?: "";
|
||||
[self emitEvent:(native_sdk_appkit_event_t){
|
||||
.kind = NATIVE_SDK_APPKIT_EVENT_FILES_DROPPED,
|
||||
.window_id = windowId,
|
||||
.x = point.x,
|
||||
.y = point.y,
|
||||
.view_label = viewLabelBytes,
|
||||
.view_label_len = [safeViewLabel lengthOfBytesUsingEncoding:NSUTF8StringEncoding],
|
||||
.drop_paths = data.bytes,
|
||||
.drop_paths_len = data.length,
|
||||
}];
|
||||
@@ -12130,6 +12207,22 @@ static void NativeSdkVideoFittedSize(double naturalWidth, double naturalHeight,
|
||||
}
|
||||
}
|
||||
|
||||
- (void)traySegmentChanged:(NSSegmentedControl *)control {
|
||||
NSInteger selected = control.selectedSegment;
|
||||
if (selected < 0 || !self.trayCallback) return;
|
||||
NSInteger itemId = [control tagForSegment:selected];
|
||||
if (itemId <= 0) return;
|
||||
// Selection is model-owned. AppKit applies its optimistic selection
|
||||
// before sending the action; restore the declared state before dispatch
|
||||
// so an ignored or failed command cannot leave native chrome ahead of
|
||||
// the model. A successful dispatch rebuilds the row from the new state.
|
||||
NSInteger sourceSelected = [(NativeSdkTraySegmentedControl *)control sourceSelectedSegment];
|
||||
for (NSInteger index = 0; index < control.segmentCount; index++) {
|
||||
[control setSelected:index == sourceSelected forSegment:index];
|
||||
}
|
||||
self.trayCallback(self.trayContext, (uint32_t)control.tag, (uint32_t)itemId);
|
||||
}
|
||||
|
||||
- (NativeSdkStatusItemEntry *)statusEntryForId:(uint32_t)identifier {
|
||||
return self.statusItems[@(identifier)];
|
||||
}
|
||||
@@ -13277,20 +13370,33 @@ static NSImage *NativeSdkTrayImageWithOpacity(NSImage *source, double opacity) {
|
||||
return image;
|
||||
}
|
||||
|
||||
static void NativeSdkApplyTrayPresentation(NativeSdkAppKitHost *object, NativeSdkStatusItemEntry *entry, NSString *requestedTitle, double width, int tone, double iconOpacity, BOOL monospaced) {
|
||||
static NSFontWeight NativeSdkTrayFontWeight(int weight) {
|
||||
switch (weight) {
|
||||
case 1: return NSFontWeightMedium;
|
||||
case 2: return NSFontWeightSemibold;
|
||||
case 3: return NSFontWeightBold;
|
||||
default: return NSFontWeightRegular;
|
||||
}
|
||||
}
|
||||
|
||||
static void NativeSdkApplyTrayPresentation(NativeSdkAppKitHost *object, NativeSdkStatusItemEntry *entry, NSString *requestedTitle, double width, int tone, double iconOpacity, BOOL monospaced, double fontSize, int fontWeight) {
|
||||
if (!entry.item) return;
|
||||
entry.presentationTitle = requestedTitle ?: @"";
|
||||
entry.presentationWidth = width;
|
||||
entry.presentationTone = tone;
|
||||
entry.presentationIconOpacity = iconOpacity;
|
||||
entry.presentationMonospaced = monospaced;
|
||||
entry.presentationFontSize = fontSize;
|
||||
entry.presentationFontWeight = fontWeight;
|
||||
NSString *title = requestedTitle ?: @"";
|
||||
if (!entry.baseImage && title.length == 0) {
|
||||
title = object.appName.length > 0 ? [object.appName substringToIndex:MIN(1, object.appName.length)] : @"Z";
|
||||
}
|
||||
CGFloat resolvedSize = fontSize > 0 ? fontSize : 11;
|
||||
NSFontWeight resolvedWeight = NativeSdkTrayFontWeight(fontWeight);
|
||||
NSFont *font = monospaced
|
||||
? ([NSFont fontWithName:@"Geist Mono" size:11] ?: [NSFont monospacedDigitSystemFontOfSize:11 weight:NSFontWeightRegular])
|
||||
: [NSFont systemFontOfSize:11];
|
||||
? [NSFont monospacedSystemFontOfSize:resolvedSize weight:resolvedWeight]
|
||||
: [NSFont systemFontOfSize:resolvedSize weight:resolvedWeight];
|
||||
NSStatusBarButton *button = entry.item.button;
|
||||
if (tone == 0) {
|
||||
button.title = title;
|
||||
@@ -13331,7 +13437,7 @@ static void NativeSdkApplyTrayShell(NativeSdkAppKitHost *object, NativeSdkStatus
|
||||
entry.item.visible = visible != 0;
|
||||
}
|
||||
|
||||
void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *icon_path, size_t icon_path_len, const char *title, size_t title_len, const char *tooltip, size_t tooltip_len, int visible, double width, int tone, double icon_opacity, int monospaced, const char *activation_command, size_t activation_command_len, const char *alternate_activation_command, size_t alternate_activation_command_len, const char *open_command, size_t open_command_len) {
|
||||
void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *icon_path, size_t icon_path_len, const char *title, size_t title_len, const char *tooltip, size_t tooltip_len, int visible, double width, int tone, double icon_opacity, int monospaced, double font_size, int font_weight, const char *activation_command, size_t activation_command_len, const char *alternate_activation_command, size_t alternate_activation_command_len, const char *open_command, size_t open_command_len) {
|
||||
NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host;
|
||||
@autoreleasepool {
|
||||
NSNumber *key = @(status_item_id);
|
||||
@@ -13348,7 +13454,7 @@ void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t stat
|
||||
entry.item = [[NSStatusBar systemStatusBar] statusItemWithLength:hasTitle ? NSVariableStatusItemLength : NSSquareStatusItemLength];
|
||||
NSString *titleString = hasTitle ? ([[NSString alloc] initWithBytes:title length:title_len encoding:NSUTF8StringEncoding] ?: @"") : @"";
|
||||
NativeSdkApplyTrayShell(object, entry, icon_path, icon_path_len, tooltip, tooltip_len, visible, activation_command, activation_command_len, alternate_activation_command, alternate_activation_command_len, open_command, open_command_len);
|
||||
NativeSdkApplyTrayPresentation(object, entry, titleString, width, tone, icon_opacity, monospaced != 0);
|
||||
NativeSdkApplyTrayPresentation(object, entry, titleString, width, tone, icon_opacity, monospaced != 0, font_size, font_weight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13358,7 +13464,7 @@ void native_sdk_appkit_update_tray_shell(native_sdk_appkit_host_t *host, uint32_
|
||||
NativeSdkStatusItemEntry *entry = [object statusEntryForId:status_item_id];
|
||||
if (!entry) return;
|
||||
NativeSdkApplyTrayShell(object, entry, icon_path, icon_path_len, tooltip, tooltip_len, visible, activation_command, activation_command_len, alternate_activation_command, alternate_activation_command_len, open_command, open_command_len);
|
||||
NativeSdkApplyTrayPresentation(object, entry, entry.presentationTitle, entry.presentationWidth, entry.presentationTone, entry.presentationIconOpacity, entry.presentationMonospaced);
|
||||
NativeSdkApplyTrayPresentation(object, entry, entry.presentationTitle, entry.presentationWidth, entry.presentationTone, entry.presentationIconOpacity, entry.presentationMonospaced, entry.presentationFontSize, entry.presentationFontWeight);
|
||||
if (entry.activationCommand.length == 0 && entry.alternateActivationCommand.length == 0) entry.item.menu = entry.menu;
|
||||
else entry.item.menu = nil;
|
||||
}
|
||||
@@ -13371,8 +13477,115 @@ enum {
|
||||
NativeSdkTrayRoleHero = 3,
|
||||
NativeSdkTrayRoleAgent = 4,
|
||||
NativeSdkTrayRoleContext = 5,
|
||||
NativeSdkTrayRoleSegmented = 6,
|
||||
NativeSdkTrayRoleChart = 7,
|
||||
};
|
||||
|
||||
@interface NativeSdkTrayBarChartView : NSView
|
||||
@property(nonatomic, copy) NSArray<NSNumber *> *values;
|
||||
@property(nonatomic, assign) double minValue;
|
||||
@property(nonatomic, assign) double maxValue;
|
||||
@end
|
||||
|
||||
@implementation NativeSdkTrayBarChartView
|
||||
- (BOOL)isFlipped { return YES; }
|
||||
- (void)drawRect:(NSRect)dirtyRect {
|
||||
[super drawRect:dirtyRect];
|
||||
if (self.values.count == 0 || !(self.maxValue > self.minValue)) return;
|
||||
CGFloat gap = 2;
|
||||
CGFloat barWidth = MAX(1, (NSWidth(self.bounds) - gap * (self.values.count - 1)) / self.values.count);
|
||||
CGFloat x = 0;
|
||||
[NSColor.controlAccentColor setFill];
|
||||
for (NSNumber *number in self.values) {
|
||||
double fraction = (number.doubleValue - self.minValue) / (self.maxValue - self.minValue);
|
||||
fraction = MIN(MAX(fraction, 0), 1);
|
||||
CGFloat height = MAX(fraction > 0 ? 1 : 0, floor(fraction * NSHeight(self.bounds)));
|
||||
NSRectFill(NSMakeRect(x, NSHeight(self.bounds) - height, barWidth, height));
|
||||
x += barWidth + gap;
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
static NSString *NativeSdkTrayString(const char *bytes, size_t len) {
|
||||
return bytes && len > 0 ? ([[NSString alloc] initWithBytes:bytes length:len encoding:NSUTF8StringEncoding] ?: @"") : @"";
|
||||
}
|
||||
|
||||
static NSView *NativeSdkTraySegmentedView(NativeSdkAppKitHost *object, uint32_t statusItemId, const native_sdk_appkit_tray_segment_option_t *options, size_t count) {
|
||||
NSView *row = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 38)];
|
||||
NativeSdkTraySegmentedControl *control = [[NativeSdkTraySegmentedControl alloc] initWithFrame:NSMakeRect(14, 5, 292, 28)];
|
||||
control.segmentCount = count;
|
||||
control.segmentStyle = NSSegmentStyleAutomatic;
|
||||
control.trackingMode = NSSegmentSwitchTrackingSelectOne;
|
||||
control.target = object;
|
||||
control.action = @selector(traySegmentChanged:);
|
||||
control.tag = (NSInteger)statusItemId;
|
||||
control.sourceSelectedSegment = -1;
|
||||
control.autoresizingMask = NSViewWidthSizable;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
[control setLabel:NativeSdkTrayString(options[i].label, options[i].label_len) forSegment:i];
|
||||
[control setEnabled:options[i].enabled != 0 forSegment:i];
|
||||
[control setTag:(NSInteger)options[i].item_id forSegment:i];
|
||||
[control setSelected:options[i].selected != 0 forSegment:i];
|
||||
if (options[i].selected != 0) control.sourceSelectedSegment = (NSInteger)i;
|
||||
}
|
||||
[row addSubview:control];
|
||||
row.accessibilityRole = NSAccessibilityGroupRole;
|
||||
return row;
|
||||
}
|
||||
|
||||
static NSView *NativeSdkTrayMetricView(const native_sdk_appkit_tray_metric_row_t *metric) {
|
||||
NSView *row = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 58)];
|
||||
NSTextField *primary = [NSTextField labelWithString:NativeSdkTrayString(metric->primary_text, metric->primary_text_len)];
|
||||
NSTextField *secondary = [NSTextField labelWithString:NativeSdkTrayString(metric->secondary_text, metric->secondary_text_len)];
|
||||
primary.frame = NSMakeRect(14, 25, 292, 28);
|
||||
secondary.frame = NSMakeRect(14, 6, 292, 16);
|
||||
primary.font = [NSFont fontWithName:@"Geist Mono" size:20] ?: [NSFont monospacedDigitSystemFontOfSize:20 weight:NSFontWeightMedium];
|
||||
secondary.font = [NSFont systemFontOfSize:11];
|
||||
secondary.textColor = NSColor.secondaryLabelColor;
|
||||
primary.autoresizingMask = NSViewWidthSizable;
|
||||
secondary.autoresizingMask = NSViewWidthSizable;
|
||||
primary.accessibilityElement = NO;
|
||||
secondary.accessibilityElement = NO;
|
||||
[row addSubview:primary];
|
||||
[row addSubview:secondary];
|
||||
row.accessibilityLabel = NativeSdkTrayString(metric->accessibility_label, metric->accessibility_label_len);
|
||||
row.accessibilityRole = NSAccessibilityGroupRole;
|
||||
return row;
|
||||
}
|
||||
|
||||
static NSView *NativeSdkTrayChartView(const native_sdk_appkit_tray_chart_row_t *chart) {
|
||||
NSString *leading = NativeSdkTrayString(chart->leading_caption, chart->leading_caption_len);
|
||||
NSString *trailing = NativeSdkTrayString(chart->trailing_summary, chart->trailing_summary_len);
|
||||
NSView *row = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 58)];
|
||||
NSTextField *leadingField = [NSTextField labelWithString:leading];
|
||||
NSTextField *trailingField = [NSTextField labelWithString:trailing];
|
||||
leadingField.frame = NSMakeRect(14, 3, 142, 15);
|
||||
trailingField.frame = NSMakeRect(164, 3, 142, 15);
|
||||
trailingField.alignment = NSTextAlignmentRight;
|
||||
leadingField.font = [NSFont systemFontOfSize:10];
|
||||
trailingField.font = [NSFont monospacedDigitSystemFontOfSize:10 weight:NSFontWeightRegular];
|
||||
leadingField.textColor = NSColor.secondaryLabelColor;
|
||||
trailingField.textColor = NSColor.secondaryLabelColor;
|
||||
leadingField.autoresizingMask = NSViewWidthSizable;
|
||||
trailingField.autoresizingMask = NSViewMinXMargin;
|
||||
leadingField.accessibilityElement = NO;
|
||||
trailingField.accessibilityElement = NO;
|
||||
NativeSdkTrayBarChartView *bars = [[NativeSdkTrayBarChartView alloc] initWithFrame:NSMakeRect(14, 21, 292, 32)];
|
||||
NSMutableArray<NSNumber *> *values = [NSMutableArray arrayWithCapacity:chart->value_count];
|
||||
for (size_t i = 0; i < chart->value_count; i++) [values addObject:@(chart->values[i])];
|
||||
bars.values = values;
|
||||
bars.minValue = chart->min_value;
|
||||
bars.maxValue = chart->max_value;
|
||||
bars.autoresizingMask = NSViewWidthSizable;
|
||||
bars.accessibilityElement = NO;
|
||||
[row addSubview:leadingField];
|
||||
[row addSubview:trailingField];
|
||||
[row addSubview:bars];
|
||||
row.accessibilityLabel = NativeSdkTrayString(chart->accessibility_label, chart->accessibility_label_len);
|
||||
row.accessibilityRole = NSAccessibilityGroupRole;
|
||||
return row;
|
||||
}
|
||||
|
||||
static NSView *NativeSdkTrayHeroView(NSString *headline, NSString *quota) {
|
||||
NSArray<NSString *> *parts = [headline componentsSeparatedByString:@"\n"];
|
||||
NSString *value = parts.count > 0 ? parts[0] : @"";
|
||||
@@ -13600,6 +13813,7 @@ void native_sdk_appkit_update_tray_menu(native_sdk_appkit_host_t *host, uint32_t
|
||||
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:label ?: @""
|
||||
action:@selector(trayMenuItemClicked:)
|
||||
keyEquivalent:@""];
|
||||
item.representedObject = @(i);
|
||||
item.tag = (NSInteger)(((uint64_t)status_item_id << 32) | item_ids[i]);
|
||||
item.target = object;
|
||||
item.enabled = enabled_flags[i] != 0;
|
||||
@@ -13673,6 +13887,46 @@ void native_sdk_appkit_update_tray_menu(native_sdk_appkit_host_t *host, uint32_t
|
||||
}
|
||||
}
|
||||
|
||||
void native_sdk_appkit_update_tray_rich_rows(native_sdk_appkit_host_t *host, uint32_t status_item_id, const native_sdk_appkit_tray_segmented_row_t *segmented_rows, size_t segmented_count, const native_sdk_appkit_tray_metric_row_t *metric_rows, size_t metric_count, const native_sdk_appkit_tray_chart_row_t *chart_rows, size_t chart_count) {
|
||||
NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host;
|
||||
@autoreleasepool {
|
||||
NativeSdkStatusItemEntry *entry = [object statusEntryForId:status_item_id];
|
||||
if (!entry || !entry.menu) return;
|
||||
for (size_t i = 0; i < segmented_count; i++) {
|
||||
const native_sdk_appkit_tray_segmented_row_t *row = &segmented_rows[i];
|
||||
NSMenuItem *item = nil;
|
||||
for (NSMenuItem *candidate in entry.menu.itemArray) {
|
||||
if ([candidate.representedObject isEqual:@(row->row_index)]) { item = candidate; break; }
|
||||
}
|
||||
if (!item) continue;
|
||||
item.action = NULL;
|
||||
item.target = nil;
|
||||
item.view = NativeSdkTraySegmentedView(object, status_item_id, row->options, row->option_count);
|
||||
}
|
||||
for (size_t i = 0; i < metric_count; i++) {
|
||||
NSMenuItem *item = nil;
|
||||
for (NSMenuItem *candidate in entry.menu.itemArray) {
|
||||
if ([candidate.representedObject isEqual:@(metric_rows[i].row_index)]) { item = candidate; break; }
|
||||
}
|
||||
if (!item) continue;
|
||||
item.action = NULL;
|
||||
item.target = nil;
|
||||
item.view = NativeSdkTrayMetricView(&metric_rows[i]);
|
||||
}
|
||||
for (size_t i = 0; i < chart_count; i++) {
|
||||
const native_sdk_appkit_tray_chart_row_t *row = &chart_rows[i];
|
||||
NSMenuItem *item = nil;
|
||||
for (NSMenuItem *candidate in entry.menu.itemArray) {
|
||||
if ([candidate.representedObject isEqual:@(row->row_index)]) { item = candidate; break; }
|
||||
}
|
||||
if (!item) continue;
|
||||
item.action = NULL;
|
||||
item.target = nil;
|
||||
item.view = NativeSdkTrayChartView(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void native_sdk_appkit_update_tray_title(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len) {
|
||||
NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host;
|
||||
@autoreleasepool {
|
||||
@@ -13686,18 +13940,20 @@ void native_sdk_appkit_update_tray_title(native_sdk_appkit_host_t *host, uint32_
|
||||
entry.presentationWidth,
|
||||
entry.presentationTone,
|
||||
entry.presentationIconOpacity,
|
||||
entry.presentationMonospaced
|
||||
entry.presentationMonospaced,
|
||||
entry.presentationFontSize,
|
||||
entry.presentationFontWeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void native_sdk_appkit_update_tray_presentation(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len, double width, int tone, double icon_opacity, int monospaced) {
|
||||
void native_sdk_appkit_update_tray_presentation(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len, double width, int tone, double icon_opacity, int monospaced, double font_size, int font_weight) {
|
||||
NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host;
|
||||
@autoreleasepool {
|
||||
NativeSdkStatusItemEntry *entry = [object statusEntryForId:status_item_id];
|
||||
if (!entry) return;
|
||||
NSString *value = title ? ([[NSString alloc] initWithBytes:title length:title_len encoding:NSUTF8StringEncoding] ?: @"") : @"";
|
||||
NativeSdkApplyTrayPresentation(object, entry, value, width, tone, icon_opacity, monospaced != 0);
|
||||
NativeSdkApplyTrayPresentation(object, entry, value, width, tone, icon_opacity, monospaced != 0, font_size, font_weight);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -607,6 +607,10 @@ static const char *NativeSdkCefBridgeScript() {
|
||||
@property(nonatomic, assign) uint32_t modifiers;
|
||||
@end
|
||||
|
||||
@interface NativeSdkCefTraySegmentedControl : NSSegmentedControl
|
||||
@property(nonatomic, assign) NSInteger sourceSelectedSegment;
|
||||
@end
|
||||
|
||||
@interface NativeSdkChromiumStatusItemEntry : NSObject
|
||||
@property(nonatomic, assign) uint32_t identifier;
|
||||
@property(nonatomic, strong) NSStatusItem *item;
|
||||
@@ -617,6 +621,8 @@ static const char *NativeSdkCefBridgeScript() {
|
||||
@property(nonatomic, assign) int presentationTone;
|
||||
@property(nonatomic, assign) double presentationIconOpacity;
|
||||
@property(nonatomic, assign) BOOL presentationMonospaced;
|
||||
@property(nonatomic, assign) double presentationFontSize;
|
||||
@property(nonatomic, assign) int presentationFontWeight;
|
||||
@property(nonatomic, strong) NSString *activationCommand;
|
||||
@property(nonatomic, strong) NSString *alternateActivationCommand;
|
||||
@property(nonatomic, strong) NSString *openCommand;
|
||||
@@ -769,6 +775,7 @@ static const char *NativeSdkCefBridgeScript() {
|
||||
- (BOOL)handleShortcutEvent:(NSEvent *)event;
|
||||
- (void)emitShortcutWithId:(NSString *)identifier key:(NSString *)key modifiers:(uint32_t)modifiers event:(NSEvent *)event;
|
||||
- (void)trayMenuItemClicked:(NSMenuItem *)menuItem;
|
||||
- (void)traySegmentChanged:(NSSegmentedControl *)control;
|
||||
- (void)menuWillOpen:(NSMenu *)menu;
|
||||
- (NativeSdkChromiumStatusItemEntry *)statusEntryForId:(uint32_t)identifier;
|
||||
- (NativeSdkChromiumStatusItemEntry *)statusEntryForMenu:(NSMenu *)menu;
|
||||
@@ -779,6 +786,9 @@ static const char *NativeSdkCefBridgeScript() {
|
||||
@implementation NativeSdkChromiumShortcut
|
||||
@end
|
||||
|
||||
@implementation NativeSdkCefTraySegmentedControl
|
||||
@end
|
||||
|
||||
@implementation NativeSdkChromiumStatusItemEntry
|
||||
@end
|
||||
|
||||
@@ -2132,6 +2142,20 @@ static const char *NativeSdkCefBridgeScript() {
|
||||
}
|
||||
}
|
||||
|
||||
- (void)traySegmentChanged:(NSSegmentedControl *)control {
|
||||
NSInteger selected = control.selectedSegment;
|
||||
if (selected < 0 || !self.trayCallback) return;
|
||||
NSInteger itemId = [control tagForSegment:selected];
|
||||
if (itemId <= 0) return;
|
||||
// Keep the native control on the model-declared selection until the
|
||||
// dispatched command commits a new model and rebuilds this menu row.
|
||||
NSInteger sourceSelected = [(NativeSdkCefTraySegmentedControl *)control sourceSelectedSegment];
|
||||
for (NSInteger index = 0; index < control.segmentCount; index++) {
|
||||
[control setSelected:index == sourceSelected forSegment:index];
|
||||
}
|
||||
self.trayCallback(self.trayContext, (uint32_t)control.tag, (uint32_t)itemId);
|
||||
}
|
||||
|
||||
- (NativeSdkChromiumStatusItemEntry *)statusEntryForId:(uint32_t)identifier {
|
||||
return self.statusItems[@(identifier)];
|
||||
}
|
||||
@@ -3604,16 +3628,29 @@ static NSImage *NativeSdkCefTrayImageWithOpacity(NSImage *source, double opacity
|
||||
return image;
|
||||
}
|
||||
|
||||
static void NativeSdkCefApplyTrayPresentation(NativeSdkChromiumHost *object, NativeSdkChromiumStatusItemEntry *entry, NSString *requestedTitle, double width, int tone, double iconOpacity, BOOL monospaced) {
|
||||
static NSFontWeight NativeSdkCefTrayFontWeight(int weight) {
|
||||
switch (weight) {
|
||||
case 1: return NSFontWeightMedium;
|
||||
case 2: return NSFontWeightSemibold;
|
||||
case 3: return NSFontWeightBold;
|
||||
default: return NSFontWeightRegular;
|
||||
}
|
||||
}
|
||||
|
||||
static void NativeSdkCefApplyTrayPresentation(NativeSdkChromiumHost *object, NativeSdkChromiumStatusItemEntry *entry, NSString *requestedTitle, double width, int tone, double iconOpacity, BOOL monospaced, double fontSize, int fontWeight) {
|
||||
if (!entry.item) return;
|
||||
entry.presentationTitle = requestedTitle ?: @"";
|
||||
entry.presentationWidth = width;
|
||||
entry.presentationTone = tone;
|
||||
entry.presentationIconOpacity = iconOpacity;
|
||||
entry.presentationMonospaced = monospaced;
|
||||
entry.presentationFontSize = fontSize;
|
||||
entry.presentationFontWeight = fontWeight;
|
||||
NSString *title = requestedTitle ?: @"";
|
||||
if (!entry.baseImage && title.length == 0) title = object.appName.length > 0 ? [object.appName substringToIndex:MIN(1, object.appName.length)] : @"Z";
|
||||
NSFont *font = monospaced ? [NSFont monospacedDigitSystemFontOfSize:11 weight:NSFontWeightRegular] : [NSFont systemFontOfSize:11];
|
||||
CGFloat resolvedSize = fontSize > 0 ? fontSize : 11;
|
||||
NSFontWeight resolvedWeight = NativeSdkCefTrayFontWeight(fontWeight);
|
||||
NSFont *font = monospaced ? [NSFont monospacedSystemFontOfSize:resolvedSize weight:resolvedWeight] : [NSFont systemFontOfSize:resolvedSize weight:resolvedWeight];
|
||||
if (tone == 0) {
|
||||
entry.item.button.title = title;
|
||||
entry.item.button.font = font;
|
||||
@@ -3650,7 +3687,7 @@ static void NativeSdkCefApplyTrayShell(NativeSdkChromiumHost *object, NativeSdkC
|
||||
entry.item.visible = visible != 0;
|
||||
}
|
||||
|
||||
void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *icon_path, size_t icon_path_len, const char *title, size_t title_len, const char *tooltip, size_t tooltip_len, int visible, double width, int tone, double icon_opacity, int monospaced, const char *activation_command, size_t activation_command_len, const char *alternate_activation_command, size_t alternate_activation_command_len, const char *open_command, size_t open_command_len) {
|
||||
void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *icon_path, size_t icon_path_len, const char *title, size_t title_len, const char *tooltip, size_t tooltip_len, int visible, double width, int tone, double icon_opacity, int monospaced, double font_size, int font_weight, const char *activation_command, size_t activation_command_len, const char *alternate_activation_command, size_t alternate_activation_command_len, const char *open_command, size_t open_command_len) {
|
||||
NativeSdkChromiumHost *object = (__bridge NativeSdkChromiumHost *)host;
|
||||
@autoreleasepool {
|
||||
NSNumber *key = @(status_item_id);
|
||||
@@ -3669,7 +3706,7 @@ void native_sdk_appkit_create_tray(native_sdk_appkit_host_t *host, uint32_t stat
|
||||
entry.item = [[NSStatusBar systemStatusBar] statusItemWithLength:hasTitle ? NSVariableStatusItemLength : NSSquareStatusItemLength];
|
||||
NSString *titleString = hasTitle ? ([[NSString alloc] initWithBytes:title length:title_len encoding:NSUTF8StringEncoding] ?: @"") : @"";
|
||||
NativeSdkCefApplyTrayShell(object, entry, icon_path, icon_path_len, tooltip, tooltip_len, visible, activation_command, activation_command_len, alternate_activation_command, alternate_activation_command_len, open_command, open_command_len);
|
||||
NativeSdkCefApplyTrayPresentation(object, entry, titleString, width, tone, icon_opacity, monospaced != 0);
|
||||
NativeSdkCefApplyTrayPresentation(object, entry, titleString, width, tone, icon_opacity, monospaced != 0, font_size, font_weight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3679,7 +3716,7 @@ void native_sdk_appkit_update_tray_shell(native_sdk_appkit_host_t *host, uint32_
|
||||
NativeSdkChromiumStatusItemEntry *entry = [object statusEntryForId:status_item_id];
|
||||
if (!entry) return;
|
||||
NativeSdkCefApplyTrayShell(object, entry, icon_path, icon_path_len, tooltip, tooltip_len, visible, activation_command, activation_command_len, alternate_activation_command, alternate_activation_command_len, open_command, open_command_len);
|
||||
NativeSdkCefApplyTrayPresentation(object, entry, entry.presentationTitle, entry.presentationWidth, entry.presentationTone, entry.presentationIconOpacity, entry.presentationMonospaced);
|
||||
NativeSdkCefApplyTrayPresentation(object, entry, entry.presentationTitle, entry.presentationWidth, entry.presentationTone, entry.presentationIconOpacity, entry.presentationMonospaced, entry.presentationFontSize, entry.presentationFontWeight);
|
||||
if (entry.activationCommand.length == 0 && entry.alternateActivationCommand.length == 0) entry.item.menu = entry.menu;
|
||||
else entry.item.menu = nil;
|
||||
}
|
||||
@@ -3694,6 +3731,107 @@ static NSEventModifierFlags NativeSdkCefTrayModifiers(uint32_t modifiers) {
|
||||
return flags;
|
||||
}
|
||||
|
||||
@interface NativeSdkCefTrayBarChartView : NSView
|
||||
@property(nonatomic, copy) NSArray<NSNumber *> *values;
|
||||
@property(nonatomic, assign) double minValue;
|
||||
@property(nonatomic, assign) double maxValue;
|
||||
@end
|
||||
|
||||
@implementation NativeSdkCefTrayBarChartView
|
||||
- (BOOL)isFlipped { return YES; }
|
||||
- (void)drawRect:(NSRect)dirtyRect {
|
||||
[super drawRect:dirtyRect];
|
||||
if (self.values.count == 0 || !(self.maxValue > self.minValue)) return;
|
||||
CGFloat gap = 2;
|
||||
CGFloat barWidth = MAX(1, (NSWidth(self.bounds) - gap * (self.values.count - 1)) / self.values.count);
|
||||
CGFloat x = 0;
|
||||
[NSColor.controlAccentColor setFill];
|
||||
for (NSNumber *number in self.values) {
|
||||
double fraction = (number.doubleValue - self.minValue) / (self.maxValue - self.minValue);
|
||||
fraction = MIN(MAX(fraction, 0), 1);
|
||||
CGFloat height = MAX(fraction > 0 ? 1 : 0, floor(fraction * NSHeight(self.bounds)));
|
||||
NSRectFill(NSMakeRect(x, NSHeight(self.bounds) - height, barWidth, height));
|
||||
x += barWidth + gap;
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
static NSString *NativeSdkCefTrayString(const char *bytes, size_t len) {
|
||||
return bytes && len > 0 ? ([[NSString alloc] initWithBytes:bytes length:len encoding:NSUTF8StringEncoding] ?: @"") : @"";
|
||||
}
|
||||
|
||||
static NSView *NativeSdkCefTraySegmentedView(NativeSdkChromiumHost *object, uint32_t statusItemId, const native_sdk_appkit_tray_segment_option_t *options, size_t count) {
|
||||
NSView *row = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 38)];
|
||||
NativeSdkCefTraySegmentedControl *control = [[NativeSdkCefTraySegmentedControl alloc] initWithFrame:NSMakeRect(14, 5, 292, 28)];
|
||||
control.segmentCount = count;
|
||||
control.trackingMode = NSSegmentSwitchTrackingSelectOne;
|
||||
control.target = object;
|
||||
control.action = @selector(traySegmentChanged:);
|
||||
control.tag = (NSInteger)statusItemId;
|
||||
control.sourceSelectedSegment = -1;
|
||||
control.autoresizingMask = NSViewWidthSizable;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
[control setLabel:NativeSdkCefTrayString(options[i].label, options[i].label_len) forSegment:i];
|
||||
[control setEnabled:options[i].enabled != 0 forSegment:i];
|
||||
[control setTag:(NSInteger)options[i].item_id forSegment:i];
|
||||
[control setSelected:options[i].selected != 0 forSegment:i];
|
||||
if (options[i].selected != 0) control.sourceSelectedSegment = (NSInteger)i;
|
||||
}
|
||||
[row addSubview:control];
|
||||
return row;
|
||||
}
|
||||
|
||||
static NSView *NativeSdkCefTrayMetricView(const native_sdk_appkit_tray_metric_row_t *metric) {
|
||||
NSView *row = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 58)];
|
||||
NSTextField *primary = [NSTextField labelWithString:NativeSdkCefTrayString(metric->primary_text, metric->primary_text_len)];
|
||||
NSTextField *secondary = [NSTextField labelWithString:NativeSdkCefTrayString(metric->secondary_text, metric->secondary_text_len)];
|
||||
primary.frame = NSMakeRect(14, 25, 292, 28);
|
||||
secondary.frame = NSMakeRect(14, 6, 292, 16);
|
||||
primary.font = [NSFont fontWithName:@"Geist Mono" size:20] ?: [NSFont monospacedDigitSystemFontOfSize:20 weight:NSFontWeightMedium];
|
||||
secondary.font = [NSFont systemFontOfSize:11];
|
||||
secondary.textColor = NSColor.secondaryLabelColor;
|
||||
primary.autoresizingMask = NSViewWidthSizable;
|
||||
secondary.autoresizingMask = NSViewWidthSizable;
|
||||
primary.accessibilityElement = NO;
|
||||
secondary.accessibilityElement = NO;
|
||||
[row addSubview:primary];
|
||||
[row addSubview:secondary];
|
||||
row.accessibilityLabel = NativeSdkCefTrayString(metric->accessibility_label, metric->accessibility_label_len);
|
||||
row.accessibilityRole = NSAccessibilityGroupRole;
|
||||
return row;
|
||||
}
|
||||
|
||||
static NSView *NativeSdkCefTrayChartView(const native_sdk_appkit_tray_chart_row_t *chart) {
|
||||
NSView *row = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 58)];
|
||||
NSTextField *leading = [NSTextField labelWithString:NativeSdkCefTrayString(chart->leading_caption, chart->leading_caption_len)];
|
||||
NSTextField *trailing = [NSTextField labelWithString:NativeSdkCefTrayString(chart->trailing_summary, chart->trailing_summary_len)];
|
||||
leading.frame = NSMakeRect(14, 3, 142, 15);
|
||||
trailing.frame = NSMakeRect(164, 3, 142, 15);
|
||||
trailing.alignment = NSTextAlignmentRight;
|
||||
leading.font = [NSFont systemFontOfSize:10];
|
||||
trailing.font = [NSFont monospacedDigitSystemFontOfSize:10 weight:NSFontWeightRegular];
|
||||
leading.textColor = NSColor.secondaryLabelColor;
|
||||
trailing.textColor = NSColor.secondaryLabelColor;
|
||||
leading.autoresizingMask = NSViewWidthSizable;
|
||||
trailing.autoresizingMask = NSViewMinXMargin;
|
||||
leading.accessibilityElement = NO;
|
||||
trailing.accessibilityElement = NO;
|
||||
NativeSdkCefTrayBarChartView *bars = [[NativeSdkCefTrayBarChartView alloc] initWithFrame:NSMakeRect(14, 21, 292, 32)];
|
||||
NSMutableArray<NSNumber *> *values = [NSMutableArray arrayWithCapacity:chart->value_count];
|
||||
for (size_t i = 0; i < chart->value_count; i++) [values addObject:@(chart->values[i])];
|
||||
bars.values = values;
|
||||
bars.minValue = chart->min_value;
|
||||
bars.maxValue = chart->max_value;
|
||||
bars.autoresizingMask = NSViewWidthSizable;
|
||||
bars.accessibilityElement = NO;
|
||||
[row addSubview:leading];
|
||||
[row addSubview:trailing];
|
||||
[row addSubview:bars];
|
||||
row.accessibilityLabel = NativeSdkCefTrayString(chart->accessibility_label, chart->accessibility_label_len);
|
||||
row.accessibilityRole = NSAccessibilityGroupRole;
|
||||
return row;
|
||||
}
|
||||
|
||||
void native_sdk_appkit_update_tray_menu(native_sdk_appkit_host_t *host, uint32_t status_item_id, const uint32_t *item_ids, const char *const *labels, const size_t *label_lens, const int *separators, const int *enabled_flags, const char *const *details, const size_t *detail_lens, const int *roles, const char *const *keys, const size_t *key_lens, const uint32_t *modifiers, size_t count) {
|
||||
NativeSdkChromiumHost *object = (__bridge NativeSdkChromiumHost *)host;
|
||||
@autoreleasepool {
|
||||
@@ -3716,9 +3854,10 @@ void native_sdk_appkit_update_tray_menu(native_sdk_appkit_host_t *host, uint32_t
|
||||
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:label ?: @""
|
||||
action:@selector(trayMenuItemClicked:)
|
||||
keyEquivalent:@""];
|
||||
item.representedObject = @(i);
|
||||
item.tag = (NSInteger)(((uint64_t)status_item_id << 32) | item_ids[i]);
|
||||
item.target = object;
|
||||
item.enabled = enabled_flags[i] != 0 && (roles[i] == 0 || roles[i] == 4);
|
||||
item.enabled = enabled_flags[i] != 0 && (roles[i] == 0 || roles[i] == 4 || roles[i] == 6);
|
||||
if (details[i] && detail_lens[i] > 0) {
|
||||
NSString *detail = [[NSString alloc] initWithBytes:details[i] length:detail_lens[i] encoding:NSUTF8StringEncoding] ?: @"";
|
||||
if (detail.length > 0 && roles[i] != 0) item.title = [NSString stringWithFormat:@"%@ — %@", label ?: @"", detail];
|
||||
@@ -3735,6 +3874,47 @@ void native_sdk_appkit_update_tray_menu(native_sdk_appkit_host_t *host, uint32_t
|
||||
}
|
||||
}
|
||||
|
||||
void native_sdk_appkit_update_tray_rich_rows(native_sdk_appkit_host_t *host, uint32_t status_item_id, const native_sdk_appkit_tray_segmented_row_t *segmented_rows, size_t segmented_count, const native_sdk_appkit_tray_metric_row_t *metric_rows, size_t metric_count, const native_sdk_appkit_tray_chart_row_t *chart_rows, size_t chart_count) {
|
||||
NativeSdkChromiumHost *object = (__bridge NativeSdkChromiumHost *)host;
|
||||
@autoreleasepool {
|
||||
NativeSdkChromiumStatusItemEntry *entry = [object statusEntryForId:status_item_id];
|
||||
if (!entry || !entry.menu) return;
|
||||
for (size_t i = 0; i < segmented_count; i++) {
|
||||
NSMenuItem *item = nil;
|
||||
for (NSMenuItem *candidate in entry.menu.itemArray) {
|
||||
if ([candidate.representedObject isEqual:@(segmented_rows[i].row_index)]) { item = candidate; break; }
|
||||
}
|
||||
if (item) {
|
||||
item.action = NULL;
|
||||
item.target = nil;
|
||||
item.view = NativeSdkCefTraySegmentedView(object, status_item_id, segmented_rows[i].options, segmented_rows[i].option_count);
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < metric_count; i++) {
|
||||
NSMenuItem *item = nil;
|
||||
for (NSMenuItem *candidate in entry.menu.itemArray) {
|
||||
if ([candidate.representedObject isEqual:@(metric_rows[i].row_index)]) { item = candidate; break; }
|
||||
}
|
||||
if (item) {
|
||||
item.action = NULL;
|
||||
item.target = nil;
|
||||
item.view = NativeSdkCefTrayMetricView(&metric_rows[i]);
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < chart_count; i++) {
|
||||
NSMenuItem *item = nil;
|
||||
for (NSMenuItem *candidate in entry.menu.itemArray) {
|
||||
if ([candidate.representedObject isEqual:@(chart_rows[i].row_index)]) { item = candidate; break; }
|
||||
}
|
||||
if (item) {
|
||||
item.action = NULL;
|
||||
item.target = nil;
|
||||
item.view = NativeSdkCefTrayChartView(&chart_rows[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int native_sdk_appkit_set_window_content_min_size(native_sdk_appkit_host_t *host, uint64_t window_id, double min_width, double min_height) {
|
||||
NativeSdkChromiumHost *object = (__bridge NativeSdkChromiumHost *)host;
|
||||
NSWindow *window = object.windows[@(window_id)];
|
||||
@@ -3761,18 +3941,20 @@ void native_sdk_appkit_update_tray_title(native_sdk_appkit_host_t *host, uint32_
|
||||
entry.presentationWidth,
|
||||
entry.presentationTone,
|
||||
entry.presentationIconOpacity,
|
||||
entry.presentationMonospaced
|
||||
entry.presentationMonospaced,
|
||||
entry.presentationFontSize,
|
||||
entry.presentationFontWeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void native_sdk_appkit_update_tray_presentation(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len, double width, int tone, double icon_opacity, int monospaced) {
|
||||
void native_sdk_appkit_update_tray_presentation(native_sdk_appkit_host_t *host, uint32_t status_item_id, const char *title, size_t title_len, double width, int tone, double icon_opacity, int monospaced, double font_size, int font_weight) {
|
||||
NativeSdkChromiumHost *object = (__bridge NativeSdkChromiumHost *)host;
|
||||
@autoreleasepool {
|
||||
NativeSdkChromiumStatusItemEntry *entry = [object statusEntryForId:status_item_id];
|
||||
if (!entry) return;
|
||||
NSString *value = title ? ([[NSString alloc] initWithBytes:title length:title_len encoding:NSUTF8StringEncoding] ?: @"") : @"";
|
||||
NativeSdkCefApplyTrayPresentation(object, entry, value, width, tone, icon_opacity, monospaced != 0);
|
||||
NativeSdkCefApplyTrayPresentation(object, entry, value, width, tone, icon_opacity, monospaced != 0, font_size, font_weight);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+178
-8
@@ -414,15 +414,50 @@ const widget_action_drop_files: u32 = 1 << 9;
|
||||
const widget_action_dismiss: u32 = 1 << 10;
|
||||
|
||||
const AppKitTrayCallback = *const fn (context: ?*anyopaque, status_item_id: u32, item_id: u32) callconv(.c) void;
|
||||
const AppKitTraySegmentOption = extern struct {
|
||||
item_id: u32,
|
||||
label: [*]const u8,
|
||||
label_len: usize,
|
||||
selected: c_int,
|
||||
enabled: c_int,
|
||||
};
|
||||
const AppKitTraySegmentedRow = extern struct {
|
||||
row_index: usize,
|
||||
options: [*]const AppKitTraySegmentOption,
|
||||
option_count: usize,
|
||||
};
|
||||
const AppKitTrayMetricRow = extern struct {
|
||||
row_index: usize,
|
||||
primary_text: [*]const u8,
|
||||
primary_text_len: usize,
|
||||
secondary_text: [*]const u8,
|
||||
secondary_text_len: usize,
|
||||
accessibility_label: [*]const u8,
|
||||
accessibility_label_len: usize,
|
||||
};
|
||||
const AppKitTrayChartRow = extern struct {
|
||||
row_index: usize,
|
||||
values: [*]const f32,
|
||||
value_count: usize,
|
||||
min_value: f64,
|
||||
max_value: f64,
|
||||
leading_caption: [*]const u8,
|
||||
leading_caption_len: usize,
|
||||
trailing_summary: [*]const u8,
|
||||
trailing_summary_len: usize,
|
||||
accessibility_label: [*]const u8,
|
||||
accessibility_label_len: usize,
|
||||
};
|
||||
|
||||
extern fn native_sdk_appkit_show_open_dialog(host: *AppKitHost, opts: *const AppKitOpenDialogOpts, buffer: [*]u8, buffer_len: usize) AppKitOpenDialogResult;
|
||||
extern fn native_sdk_appkit_show_save_dialog(host: *AppKitHost, opts: *const AppKitSaveDialogOpts, buffer: [*]u8, buffer_len: usize) usize;
|
||||
extern fn native_sdk_appkit_show_message_dialog(host: *AppKitHost, opts: *const AppKitMessageDialogOpts) c_int;
|
||||
extern fn native_sdk_appkit_create_tray(host: *AppKitHost, status_item_id: u32, icon_path: [*]const u8, icon_path_len: usize, title: [*]const u8, title_len: usize, tooltip: [*]const u8, tooltip_len: usize, visible: c_int, width: f64, tone: c_int, icon_opacity: f64, monospaced: c_int, activation_command: [*]const u8, activation_command_len: usize, alternate_activation_command: [*]const u8, alternate_activation_command_len: usize, open_command: [*]const u8, open_command_len: usize) void;
|
||||
extern fn native_sdk_appkit_create_tray(host: *AppKitHost, status_item_id: u32, icon_path: [*]const u8, icon_path_len: usize, title: [*]const u8, title_len: usize, tooltip: [*]const u8, tooltip_len: usize, visible: c_int, width: f64, tone: c_int, icon_opacity: f64, monospaced: c_int, font_size: f64, font_weight: c_int, activation_command: [*]const u8, activation_command_len: usize, alternate_activation_command: [*]const u8, alternate_activation_command_len: usize, open_command: [*]const u8, open_command_len: usize) void;
|
||||
extern fn native_sdk_appkit_update_tray_shell(host: *AppKitHost, status_item_id: u32, icon_path: [*]const u8, icon_path_len: usize, tooltip: [*]const u8, tooltip_len: usize, visible: c_int, activation_command: [*]const u8, activation_command_len: usize, alternate_activation_command: [*]const u8, alternate_activation_command_len: usize, open_command: [*]const u8, open_command_len: usize) void;
|
||||
extern fn native_sdk_appkit_update_tray_menu(host: *AppKitHost, status_item_id: u32, item_ids: [*]const u32, labels: [*]const [*]const u8, label_lens: [*]const usize, separators: [*]const c_int, enabled_flags: [*]const c_int, details: [*]const [*]const u8, detail_lens: [*]const usize, roles: [*]const c_int, keys: [*]const [*]const u8, key_lens: [*]const usize, modifiers: [*]const u32, count: usize) void;
|
||||
extern fn native_sdk_appkit_update_tray_rich_rows(host: *AppKitHost, status_item_id: u32, segmented_rows: [*]const AppKitTraySegmentedRow, segmented_count: usize, metric_rows: [*]const AppKitTrayMetricRow, metric_count: usize, chart_rows: [*]const AppKitTrayChartRow, chart_count: usize) void;
|
||||
extern fn native_sdk_appkit_update_tray_title(host: *AppKitHost, status_item_id: u32, title: [*]const u8, title_len: usize) void;
|
||||
extern fn native_sdk_appkit_update_tray_presentation(host: *AppKitHost, status_item_id: u32, title: [*]const u8, title_len: usize, width: f64, tone: c_int, icon_opacity: f64, monospaced: c_int) void;
|
||||
extern fn native_sdk_appkit_update_tray_presentation(host: *AppKitHost, status_item_id: u32, title: [*]const u8, title_len: usize, width: f64, tone: c_int, icon_opacity: f64, monospaced: c_int, font_size: f64, font_weight: c_int) void;
|
||||
extern fn native_sdk_appkit_remove_tray(host: *AppKitHost, status_item_id: u32) void;
|
||||
extern fn native_sdk_appkit_set_tray_callback(host: *AppKitHost, callback: AppKitTrayCallback, context: ?*anyopaque) void;
|
||||
|
||||
@@ -999,11 +1034,7 @@ fn appkitCallback(context: ?*anyopaque, event: *const AppKitEvent) callconv(.c)
|
||||
.wake => state.emit(.wake),
|
||||
.files_dropped => {
|
||||
var paths_buffer: [platform_mod.max_drop_paths][]const u8 = undefined;
|
||||
const paths = platform_mod.splitDropPaths(event.drop_paths[0..event.drop_paths_len], paths_buffer[0..]);
|
||||
state.emit(.{ .files_dropped = .{
|
||||
.window_id = event.window_id,
|
||||
.paths = paths,
|
||||
} });
|
||||
state.emit(.{ .files_dropped = fileDropEventFromAppKitEvent(event, paths_buffer[0..]) });
|
||||
},
|
||||
.gpu_surface_frame => state.emit(.{ .gpu_surface_frame = .{
|
||||
.window_id = event.window_id,
|
||||
@@ -1141,6 +1172,17 @@ fn gpuSurfaceInputEventFromAppKitEvent(event: *const AppKitEvent) platform_mod.G
|
||||
};
|
||||
}
|
||||
|
||||
fn fileDropEventFromAppKitEvent(event: *const AppKitEvent, paths_buffer: [][]const u8) platform_mod.FileDropEvent {
|
||||
return .{
|
||||
.window_id = event.window_id,
|
||||
.view_label = appKitEventBytes(event.view_label, event.view_label_len),
|
||||
// AppKit's system host converts every accepted drop to the same
|
||||
// top-left-origin view space as gpu-surface input before emitting.
|
||||
.point = geometry.PointF.init(@floatCast(event.x), @floatCast(event.y)),
|
||||
.paths = platform_mod.splitDropPaths(appKitEventBytes(event.drop_paths, event.drop_paths_len), paths_buffer),
|
||||
};
|
||||
}
|
||||
|
||||
fn readClipboard(context: ?*anyopaque, buffer: []u8) anyerror![]const u8 {
|
||||
const self: *MacPlatform = @ptrCast(@alignCast(context.?));
|
||||
const len = native_sdk_appkit_clipboard_read(self.host, buffer.ptr, buffer.len);
|
||||
@@ -2683,6 +2725,8 @@ fn createTray(context: ?*anyopaque, status_item_id: platform_mod.StatusItemId, o
|
||||
@intFromEnum(presentation.tone),
|
||||
presentation.icon_opacity,
|
||||
if (presentation.monospaced) 1 else 0,
|
||||
presentation.font_size,
|
||||
@intFromEnum(presentation.font_weight),
|
||||
options.activation_command.ptr,
|
||||
options.activation_command.len,
|
||||
options.alternate_activation_command.ptr,
|
||||
@@ -2732,6 +2776,65 @@ fn updateTrayMenu(context: ?*anyopaque, status_item_id: platform_mod.StatusItemI
|
||||
(@as(u32, @intFromBool(item.modifiers.shift)) << 4);
|
||||
}
|
||||
native_sdk_appkit_update_tray_menu(self.host, status_item_id, &ids, &labels, &label_lens, &separators, &enabled_flags, &details, &detail_lens, &roles, &keys, &key_lens, &modifiers, count);
|
||||
|
||||
var segment_options: [max_tray_items * platform_mod.max_tray_segment_options]AppKitTraySegmentOption = undefined;
|
||||
var segmented_rows: [max_tray_items]AppKitTraySegmentedRow = undefined;
|
||||
var metric_rows: [max_tray_items]AppKitTrayMetricRow = undefined;
|
||||
var chart_rows: [max_tray_items]AppKitTrayChartRow = undefined;
|
||||
var option_count: usize = 0;
|
||||
var segmented_count: usize = 0;
|
||||
var metric_count: usize = 0;
|
||||
var chart_count: usize = 0;
|
||||
for (items[0..count], 0..) |item, row_index| {
|
||||
if (item.segmented) |segmented| {
|
||||
const start = option_count;
|
||||
for (segmented.options) |option| {
|
||||
segment_options[option_count] = .{
|
||||
.item_id = option.id,
|
||||
.label = option.label.ptr,
|
||||
.label_len = option.label.len,
|
||||
.selected = if (option.selected) 1 else 0,
|
||||
.enabled = if (option.enabled) 1 else 0,
|
||||
};
|
||||
option_count += 1;
|
||||
}
|
||||
segmented_rows[segmented_count] = .{
|
||||
.row_index = row_index,
|
||||
.options = segment_options[start..option_count].ptr,
|
||||
.option_count = option_count - start,
|
||||
};
|
||||
segmented_count += 1;
|
||||
}
|
||||
if (item.metric) |metric| {
|
||||
metric_rows[metric_count] = .{
|
||||
.row_index = row_index,
|
||||
.primary_text = metric.primary_text.ptr,
|
||||
.primary_text_len = metric.primary_text.len,
|
||||
.secondary_text = metric.secondary_text.ptr,
|
||||
.secondary_text_len = metric.secondary_text.len,
|
||||
.accessibility_label = metric.accessibility_label.ptr,
|
||||
.accessibility_label_len = metric.accessibility_label.len,
|
||||
};
|
||||
metric_count += 1;
|
||||
}
|
||||
if (item.chart) |chart| {
|
||||
chart_rows[chart_count] = .{
|
||||
.row_index = row_index,
|
||||
.values = chart.values.ptr,
|
||||
.value_count = chart.values.len,
|
||||
.min_value = chart.min_value,
|
||||
.max_value = chart.max_value,
|
||||
.leading_caption = chart.leading_caption.ptr,
|
||||
.leading_caption_len = chart.leading_caption.len,
|
||||
.trailing_summary = chart.trailing_summary.ptr,
|
||||
.trailing_summary_len = chart.trailing_summary.len,
|
||||
.accessibility_label = chart.accessibility_label.ptr,
|
||||
.accessibility_label_len = chart.accessibility_label.len,
|
||||
};
|
||||
chart_count += 1;
|
||||
}
|
||||
}
|
||||
native_sdk_appkit_update_tray_rich_rows(self.host, status_item_id, &segmented_rows, segmented_count, &metric_rows, metric_count, &chart_rows, chart_count);
|
||||
}
|
||||
|
||||
fn updateTrayTitle(context: ?*anyopaque, status_item_id: platform_mod.StatusItemId, title: []const u8) anyerror!void {
|
||||
@@ -2741,7 +2844,7 @@ fn updateTrayTitle(context: ?*anyopaque, status_item_id: platform_mod.StatusItem
|
||||
|
||||
fn updateTrayPresentation(context: ?*anyopaque, status_item_id: platform_mod.StatusItemId, presentation: platform_mod.TrayPresentation) anyerror!void {
|
||||
const self: *MacPlatform = @ptrCast(@alignCast(context.?));
|
||||
native_sdk_appkit_update_tray_presentation(self.host, status_item_id, presentation.title.ptr, presentation.title.len, presentation.width, @intFromEnum(presentation.tone), presentation.icon_opacity, if (presentation.monospaced) 1 else 0);
|
||||
native_sdk_appkit_update_tray_presentation(self.host, status_item_id, presentation.title.ptr, presentation.title.len, presentation.width, @intFromEnum(presentation.tone), presentation.icon_opacity, if (presentation.monospaced) 1 else 0, presentation.font_size, @intFromEnum(presentation.font_weight));
|
||||
}
|
||||
|
||||
fn removeTray(context: ?*anyopaque, status_item_id: platform_mod.StatusItemId) anyerror!void {
|
||||
@@ -2814,6 +2917,17 @@ test "mac status lifecycle hooks emit tray commands" {
|
||||
}
|
||||
}
|
||||
|
||||
test "mac typed tray rows cross the AppKit ABI without text conventions" {
|
||||
for ([_][]const u8{ @embedFile("appkit_host.m"), @embedFile("cef_host.mm") }) |source| {
|
||||
try std.testing.expect(std.mem.indexOf(u8, source, "TraySegmentedView") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, source, "NSSegmentedControl") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, source, "TrayChartView") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, source, "native_sdk_appkit_update_tray_rich_rows") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, source, "range_selected") == null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, source, "chart|") == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "mac status menu updates preserve the menu being opened" {
|
||||
for ([_][]const u8{ @embedFile("appkit_host.m"), @embedFile("cef_host.mm") }) |host_source| {
|
||||
try std.testing.expect(std.mem.indexOf(u8, host_source, "NSMenu *menu = entry.menu;") != null);
|
||||
@@ -3169,6 +3283,62 @@ test "mac gpu surface input preserves key and text" {
|
||||
try std.testing.expect(input.modifiers.shift);
|
||||
}
|
||||
|
||||
test "mac file drop bridge preserves view label point and paths" {
|
||||
const label = "kanban-canvas";
|
||||
const paths = "/tmp/spec.txt\x00/tmp/design.pdf";
|
||||
var event = std.mem.zeroes(AppKitEvent);
|
||||
event.kind = .files_dropped;
|
||||
event.window_id = 7;
|
||||
event.view_label = label.ptr;
|
||||
event.view_label_len = label.len;
|
||||
event.x = 42.5;
|
||||
event.y = 91.25;
|
||||
event.drop_paths = paths.ptr;
|
||||
event.drop_paths_len = paths.len;
|
||||
|
||||
var paths_buffer: [platform_mod.max_drop_paths][]const u8 = undefined;
|
||||
const drop = fileDropEventFromAppKitEvent(&event, paths_buffer[0..]);
|
||||
try std.testing.expectEqual(@as(platform_mod.WindowId, 7), drop.window_id);
|
||||
try std.testing.expectEqualStrings("kanban-canvas", drop.view_label);
|
||||
try std.testing.expectEqualDeep(geometry.PointF.init(42.5, 91.25), drop.point.?);
|
||||
try std.testing.expectEqual(@as(usize, 2), drop.paths.len);
|
||||
try std.testing.expectEqualStrings("/tmp/spec.txt", drop.paths[0]);
|
||||
try std.testing.expectEqualStrings("/tmp/design.pdf", drop.paths[1]);
|
||||
}
|
||||
|
||||
test "mac file drops and pointer input share the host y-down conversion" {
|
||||
const host_source = @embedFile("appkit_host.m");
|
||||
try std.testing.expectEqual(@as(usize, 5), std.mem.count(u8, host_source, "NativeSdkViewLocalYDownPoint("));
|
||||
try std.testing.expect(std.mem.indexOf(
|
||||
u8,
|
||||
host_source,
|
||||
"if (!view.isFlipped) point.y = view.bounds.size.height - point.y;",
|
||||
) != null);
|
||||
try std.testing.expect(std.mem.indexOf(
|
||||
u8,
|
||||
host_source,
|
||||
"const NSPoint yDownPoint = NativeSdkViewLocalYDownPoint(self, point);",
|
||||
) != null);
|
||||
try std.testing.expect(std.mem.indexOf(
|
||||
u8,
|
||||
host_source,
|
||||
"NativeSdkViewLocalYDownPoint(self, [self convertPoint:sender.draggingLocation fromView:nil])",
|
||||
) != null);
|
||||
|
||||
const label = "canvas";
|
||||
var event = std.mem.zeroes(AppKitEvent);
|
||||
event.view_label = label.ptr;
|
||||
event.view_label_len = label.len;
|
||||
event.x = 128.5;
|
||||
event.y = 73.25;
|
||||
|
||||
var paths_buffer: [platform_mod.max_drop_paths][]const u8 = undefined;
|
||||
const drop = fileDropEventFromAppKitEvent(&event, paths_buffer[0..]);
|
||||
const input = gpuSurfaceInputEventFromAppKitEvent(&event);
|
||||
try std.testing.expectEqual(input.x, drop.point.?.x);
|
||||
try std.testing.expectEqual(input.y, drop.point.?.y);
|
||||
}
|
||||
|
||||
test "mac gpu surface input preserves ime composition cursor" {
|
||||
const label = "canvas";
|
||||
const text = "compose";
|
||||
|
||||
@@ -45,6 +45,10 @@ const max_tray_title_bytes = types.max_tray_title_bytes;
|
||||
const max_tray_tooltip_bytes = types.max_tray_tooltip_bytes;
|
||||
const max_tray_item_label_bytes = types.max_tray_item_label_bytes;
|
||||
const max_tray_item_command_bytes = types.max_tray_item_command_bytes;
|
||||
const max_tray_segment_options = types.max_tray_segment_options;
|
||||
const max_tray_segment_label_bytes = types.max_tray_segment_label_bytes;
|
||||
const max_tray_chart_values = types.max_tray_chart_values;
|
||||
const max_tray_chart_text_bytes = types.max_tray_chart_text_bytes;
|
||||
const max_drop_paths_bytes = types.max_drop_paths_bytes;
|
||||
const max_drop_paths = types.max_drop_paths;
|
||||
const max_window_event_name_bytes = types.max_window_event_name_bytes;
|
||||
@@ -319,6 +323,16 @@ const NullStatusItem = struct {
|
||||
open_command_len: usize = 0,
|
||||
presentation: TrayPresentation = .{},
|
||||
items: [max_tray_items]TrayMenuItem = undefined,
|
||||
segment_options: [max_tray_items * max_tray_segment_options]types.TraySegmentOption = undefined,
|
||||
segment_option_label_storage: [max_tray_items * max_tray_segment_options][max_tray_segment_label_bytes]u8 = undefined,
|
||||
segment_option_command_storage: [max_tray_items * max_tray_segment_options][max_tray_item_command_bytes]u8 = undefined,
|
||||
metric_primary_storage: [max_tray_items][max_tray_item_label_bytes]u8 = undefined,
|
||||
metric_secondary_storage: [max_tray_items][types.max_tray_item_detail_bytes]u8 = undefined,
|
||||
metric_accessibility_storage: [max_tray_items][max_tray_chart_text_bytes]u8 = undefined,
|
||||
chart_values: [max_tray_items * max_tray_chart_values]f32 = undefined,
|
||||
chart_leading_caption_storage: [max_tray_items][max_tray_chart_text_bytes]u8 = undefined,
|
||||
chart_trailing_summary_storage: [max_tray_items][max_tray_chart_text_bytes]u8 = undefined,
|
||||
chart_accessibility_storage: [max_tray_items][max_tray_chart_text_bytes]u8 = undefined,
|
||||
item_count: usize = 0,
|
||||
};
|
||||
|
||||
@@ -1789,7 +1803,42 @@ pub const NullPlatform = struct {
|
||||
const self: *NullPlatform = @ptrCast(@alignCast(context.?));
|
||||
const status_item = self.findStatusItem(status_item_id) orelse return error.InvalidTrayOptions;
|
||||
if (items.len > status_item.items.len) return error.InvalidTrayOptions;
|
||||
for (items, 0..) |item, index| status_item.items[index] = item;
|
||||
for (items, 0..) |item, index| {
|
||||
status_item.items[index] = item;
|
||||
if (item.segmented) |segmented| {
|
||||
const start = index * max_tray_segment_options;
|
||||
for (segmented.options, 0..) |option, option_index| {
|
||||
const flat_index = start + option_index;
|
||||
status_item.segment_options[flat_index] = .{
|
||||
.id = option.id,
|
||||
.label = try copyInto(&status_item.segment_option_label_storage[flat_index], option.label),
|
||||
.command = try copyInto(&status_item.segment_option_command_storage[flat_index], option.command),
|
||||
.selected = option.selected,
|
||||
.enabled = option.enabled,
|
||||
};
|
||||
}
|
||||
status_item.items[index].segmented = .{ .options = status_item.segment_options[start .. start + segmented.options.len] };
|
||||
}
|
||||
if (item.metric) |metric| {
|
||||
status_item.items[index].metric = .{
|
||||
.primary_text = try copyInto(&status_item.metric_primary_storage[index], metric.primary_text),
|
||||
.secondary_text = try copyInto(&status_item.metric_secondary_storage[index], metric.secondary_text),
|
||||
.accessibility_label = try copyInto(&status_item.metric_accessibility_storage[index], metric.accessibility_label),
|
||||
};
|
||||
}
|
||||
if (item.chart) |chart| {
|
||||
const start = index * max_tray_chart_values;
|
||||
@memcpy(status_item.chart_values[start .. start + chart.values.len], chart.values);
|
||||
status_item.items[index].chart = .{
|
||||
.values = status_item.chart_values[start .. start + chart.values.len],
|
||||
.min_value = chart.min_value,
|
||||
.max_value = chart.max_value,
|
||||
.leading_caption = try copyInto(&status_item.chart_leading_caption_storage[index], chart.leading_caption),
|
||||
.trailing_summary = try copyInto(&status_item.chart_trailing_summary_storage[index], chart.trailing_summary),
|
||||
.accessibility_label = try copyInto(&status_item.chart_accessibility_storage[index], chart.accessibility_label),
|
||||
};
|
||||
}
|
||||
}
|
||||
status_item.item_count = items.len;
|
||||
self.tray_update_count += 1;
|
||||
}
|
||||
|
||||
@@ -330,6 +330,59 @@ test "null platform preserves the tray title when presentation styles are set" {
|
||||
try std.testing.expectEqual(types.TrayTone.warning, retitled.tone);
|
||||
}
|
||||
|
||||
test "null platform owns retained rich tray row bytes" {
|
||||
var null_platform = NullPlatform.init(.{});
|
||||
defer null_platform.deinit();
|
||||
|
||||
var segment_label = [_]u8{ 'D', 'a', 'y' };
|
||||
var segment_command = [_]u8{ 'r', 'a', 'n', 'g', 'e' };
|
||||
var metric_primary = [_]u8{ '2', '4', '9', '4' };
|
||||
var metric_secondary = [_]u8{ 'T', 'o', 'd', 'a', 'y' };
|
||||
var metric_accessibility = [_]u8{ 'M', 'e', 't', 'r', 'i', 'c' };
|
||||
var chart_caption = [_]u8{ 'C', 'P', 'U' };
|
||||
var chart_summary = [_]u8{ '5', '0', '%' };
|
||||
var chart_accessibility = [_]u8{ 'C', 'P', 'U', ' ', '5', '0' };
|
||||
const chart_values = [_]f32{0.5};
|
||||
|
||||
try null_platform.platform().services.createTray(.{ .items = &.{
|
||||
.{ .role = .segmented, .segmented = .{ .options = &.{.{
|
||||
.id = 20,
|
||||
.label = &segment_label,
|
||||
.command = &segment_command,
|
||||
}} } },
|
||||
.{ .role = .hero, .metric = .{
|
||||
.primary_text = &metric_primary,
|
||||
.secondary_text = &metric_secondary,
|
||||
.accessibility_label = &metric_accessibility,
|
||||
} },
|
||||
.{ .role = .chart, .chart = .{
|
||||
.values = &chart_values,
|
||||
.leading_caption = &chart_caption,
|
||||
.trailing_summary = &chart_summary,
|
||||
.accessibility_label = &chart_accessibility,
|
||||
} },
|
||||
} });
|
||||
|
||||
@memset(&segment_label, 'X');
|
||||
@memset(&segment_command, 'Y');
|
||||
@memset(&metric_primary, 'P');
|
||||
@memset(&metric_secondary, 'S');
|
||||
@memset(&metric_accessibility, 'A');
|
||||
@memset(&chart_caption, 'Z');
|
||||
@memset(&chart_summary, 'W');
|
||||
@memset(&chart_accessibility, 'Q');
|
||||
|
||||
const items = null_platform.trayItems();
|
||||
try std.testing.expectEqualStrings("Day", items[0].segmented.?.options[0].label);
|
||||
try std.testing.expectEqualStrings("range", items[0].segmented.?.options[0].command);
|
||||
try std.testing.expectEqualStrings("2494", items[1].metric.?.primary_text);
|
||||
try std.testing.expectEqualStrings("Today", items[1].metric.?.secondary_text);
|
||||
try std.testing.expectEqualStrings("Metric", items[1].metric.?.accessibility_label);
|
||||
try std.testing.expectEqualStrings("CPU", items[2].chart.?.leading_caption);
|
||||
try std.testing.expectEqualStrings("50%", items[2].chart.?.trailing_summary);
|
||||
try std.testing.expectEqualStrings("CPU 50", items[2].chart.?.accessibility_label);
|
||||
}
|
||||
|
||||
test "null platform records OS actions" {
|
||||
var null_platform = NullPlatform.init(.{});
|
||||
defer null_platform.deinit();
|
||||
|
||||
@@ -43,6 +43,10 @@ pub const max_tray_tooltip_bytes = types.max_tray_tooltip_bytes;
|
||||
pub const max_tray_item_label_bytes = types.max_tray_item_label_bytes;
|
||||
pub const max_tray_item_command_bytes = types.max_tray_item_command_bytes;
|
||||
pub const max_tray_item_detail_bytes = types.max_tray_item_detail_bytes;
|
||||
pub const max_tray_segment_options = types.max_tray_segment_options;
|
||||
pub const max_tray_segment_label_bytes = types.max_tray_segment_label_bytes;
|
||||
pub const max_tray_chart_values = types.max_tray_chart_values;
|
||||
pub const max_tray_chart_text_bytes = types.max_tray_chart_text_bytes;
|
||||
pub const max_drop_paths_bytes = types.max_drop_paths_bytes;
|
||||
pub const max_drop_paths = types.max_drop_paths;
|
||||
pub const max_window_event_name_bytes = types.max_window_event_name_bytes;
|
||||
@@ -142,8 +146,13 @@ pub const TrayShell = types.TrayShell;
|
||||
pub const trayShell = types.trayShell;
|
||||
pub const TrayTone = types.TrayTone;
|
||||
pub const TrayPresentation = types.TrayPresentation;
|
||||
pub const TrayFontWeight = types.TrayFontWeight;
|
||||
pub const TrayMenuItem = types.TrayMenuItem;
|
||||
pub const TrayItemRole = types.TrayItemRole;
|
||||
pub const TraySegmentOption = types.TraySegmentOption;
|
||||
pub const TraySegmentedRow = types.TraySegmentedRow;
|
||||
pub const TrayMetricRow = types.TrayMetricRow;
|
||||
pub const TrayChartRow = types.TrayChartRow;
|
||||
pub const NativeCommandEvent = types.NativeCommandEvent;
|
||||
pub const MenuCommandEvent = types.MenuCommandEvent;
|
||||
pub const TrayCommandEvent = types.TrayCommandEvent;
|
||||
|
||||
@@ -266,6 +266,10 @@ pub const max_tray_tooltip_bytes: usize = 256;
|
||||
pub const max_tray_item_label_bytes: usize = 256;
|
||||
pub const max_tray_item_command_bytes: usize = 128;
|
||||
pub const max_tray_item_detail_bytes: usize = 256;
|
||||
pub const max_tray_segment_options: usize = 8;
|
||||
pub const max_tray_segment_label_bytes: usize = 64;
|
||||
pub const max_tray_chart_values: usize = 32;
|
||||
pub const max_tray_chart_text_bytes: usize = 128;
|
||||
pub const max_drop_paths_bytes: usize = 8192;
|
||||
pub const max_drop_paths: usize = max_drop_paths_bytes / 2 + 1;
|
||||
pub const max_window_event_name_bytes: usize = 64;
|
||||
@@ -1466,6 +1470,13 @@ pub const TrayTone = enum(u8) {
|
||||
critical,
|
||||
};
|
||||
|
||||
pub const TrayFontWeight = enum(u8) {
|
||||
regular,
|
||||
medium,
|
||||
semibold,
|
||||
bold,
|
||||
};
|
||||
|
||||
/// The model-derived part of a status item. Keeping this separate from
|
||||
/// icon/tooltip/activation options lets UiApp patch presentation without
|
||||
/// recreating the native item.
|
||||
@@ -1477,6 +1488,9 @@ pub const TrayPresentation = struct {
|
||||
tone: TrayTone = .normal,
|
||||
icon_opacity: f32 = 1,
|
||||
monospaced: bool = false,
|
||||
/// Explicit title size in points; zero keeps the host's menu-bar default.
|
||||
font_size: f32 = 0,
|
||||
font_weight: TrayFontWeight = .regular,
|
||||
};
|
||||
|
||||
pub const TrayOptions = struct {
|
||||
@@ -1531,6 +1545,45 @@ pub const TrayItemRole = enum(u8) {
|
||||
hero,
|
||||
agent,
|
||||
context,
|
||||
segmented,
|
||||
chart,
|
||||
};
|
||||
|
||||
/// One choice inside a typed segmented tray row. Its stable id and command
|
||||
/// participate in the same namespace and dispatch route as an ordinary tray
|
||||
/// command row; the containing row itself is display structure, not an
|
||||
/// action. At most one option in a row may be selected.
|
||||
pub const TraySegmentOption = struct {
|
||||
id: TrayItemId,
|
||||
label: []const u8,
|
||||
command: []const u8,
|
||||
selected: bool = false,
|
||||
enabled: bool = true,
|
||||
};
|
||||
|
||||
pub const TraySegmentedRow = struct {
|
||||
options: []const TraySegmentOption = &.{},
|
||||
};
|
||||
|
||||
/// A prominent two-line metric block inside a tray menu. This is semantic
|
||||
/// dropdown content, distinct from the persistent menu-bar title.
|
||||
pub const TrayMetricRow = struct {
|
||||
primary_text: []const u8,
|
||||
secondary_text: []const u8 = "",
|
||||
accessibility_label: []const u8,
|
||||
};
|
||||
|
||||
/// A bounded bar-chart/sparkline readout. Values are finite and must fall in
|
||||
/// the explicit `min_value...max_value` domain. The text fields remain
|
||||
/// semantic data on every host; capable macOS hosts place them around a
|
||||
/// native AppKit-drawn bar chart.
|
||||
pub const TrayChartRow = struct {
|
||||
values: []const f32 = &.{},
|
||||
min_value: f32 = 0,
|
||||
max_value: f32 = 1,
|
||||
leading_caption: []const u8 = "",
|
||||
trailing_summary: []const u8 = "",
|
||||
accessibility_label: []const u8 = "",
|
||||
};
|
||||
|
||||
pub const TrayMenuItem = struct {
|
||||
@@ -1543,6 +1596,11 @@ pub const TrayMenuItem = struct {
|
||||
role: TrayItemRole = .command,
|
||||
key: []const u8 = "",
|
||||
modifiers: ShortcutModifiers = .{},
|
||||
/// Typed rich-row payloads. Exactly one is present for its matching role;
|
||||
/// both stay null for every existing command/readout row.
|
||||
segmented: ?TraySegmentedRow = null,
|
||||
metric: ?TrayMetricRow = null,
|
||||
chart: ?TrayChartRow = null,
|
||||
};
|
||||
|
||||
pub const NativeCommandEvent = struct {
|
||||
|
||||
@@ -1519,6 +1519,34 @@ fn showNotification(context: ?*anyopaque, options: platform_mod.NotificationOpti
|
||||
|
||||
const max_tray_items: usize = 32;
|
||||
|
||||
const TrayFallbackText = struct {
|
||||
label: []const u8,
|
||||
detail: []const u8,
|
||||
};
|
||||
|
||||
/// Project a rich row onto Win32's plain label/detail menu surface. Chart
|
||||
/// captions are optional, but the accessibility label is required, so a
|
||||
/// captionless chart must still remain visible instead of becoming a blank
|
||||
/// disabled row.
|
||||
fn trayFallbackText(item: platform_mod.TrayMenuItem) TrayFallbackText {
|
||||
if (item.metric) |metric| return .{
|
||||
.label = metric.primary_text,
|
||||
.detail = metric.secondary_text,
|
||||
};
|
||||
if (item.chart) |chart| {
|
||||
if (chart.leading_caption.len > 0) return .{
|
||||
.label = chart.leading_caption,
|
||||
.detail = chart.trailing_summary,
|
||||
};
|
||||
if (chart.trailing_summary.len > 0) return .{
|
||||
.label = chart.trailing_summary,
|
||||
.detail = "",
|
||||
};
|
||||
return .{ .label = chart.accessibility_label, .detail = "" };
|
||||
}
|
||||
return .{ .label = item.label, .detail = item.detail };
|
||||
}
|
||||
|
||||
fn createTray(context: ?*anyopaque, status_item_id: platform_mod.StatusItemId, options: platform_mod.TrayOptions) anyerror!void {
|
||||
const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
|
||||
if (status_item_id != platform_mod.primary_status_item_id) return error.UnsupportedService;
|
||||
@@ -1545,7 +1573,7 @@ fn updateTrayMenu(context: ?*anyopaque, status_item_id: platform_mod.StatusItemI
|
||||
const self: *WindowsPlatform = @ptrCast(@alignCast(context.?));
|
||||
if (status_item_id != platform_mod.primary_status_item_id) return error.UnsupportedService;
|
||||
if (self.web_engine != .system) return error.UnsupportedService;
|
||||
const count = @min(items.len, max_tray_items);
|
||||
var count: usize = 0;
|
||||
var ids: [max_tray_items]u32 = undefined;
|
||||
var labels: [max_tray_items][*]const u8 = undefined;
|
||||
var label_lens: [max_tray_items]usize = undefined;
|
||||
@@ -1563,23 +1591,44 @@ fn updateTrayMenu(context: ?*anyopaque, status_item_id: platform_mod.StatusItemI
|
||||
// returning). Doubling covers the all-ampersands worst case.
|
||||
var label_pool: [max_tray_items * (platform_mod.max_tray_item_label_bytes + platform_mod.max_tray_item_detail_bytes) * 2]u8 = undefined;
|
||||
var pool_used: usize = 0;
|
||||
for (items[0..count], 0..) |item, index| {
|
||||
const label = escapeMenuLabelAmpersands(item.label, &label_pool, &pool_used);
|
||||
const detail = escapeMenuLabelAmpersands(item.detail, &label_pool, &pool_used);
|
||||
ids[index] = item.id;
|
||||
labels[index] = label.ptr;
|
||||
label_lens[index] = label.len;
|
||||
separators[index] = if (item.separator) 1 else 0;
|
||||
for (items) |item| {
|
||||
if (item.segmented) |segmented| {
|
||||
for (segmented.options) |option| {
|
||||
const label = escapeMenuLabelAmpersands(option.label, &label_pool, &pool_used);
|
||||
ids[count] = option.id;
|
||||
labels[count] = label.ptr;
|
||||
label_lens[count] = label.len;
|
||||
separators[count] = 0;
|
||||
enabled_flags[count] = if (option.enabled) 1 else 0;
|
||||
const selected_detail = if (option.selected) "Selected" else "";
|
||||
details[count] = selected_detail.ptr;
|
||||
detail_lens[count] = selected_detail.len;
|
||||
roles[count] = @intFromEnum(platform_mod.TrayItemRole.command);
|
||||
keys[count] = "".ptr;
|
||||
key_lens[count] = 0;
|
||||
modifiers[count] = 0;
|
||||
count += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const fallback = trayFallbackText(item);
|
||||
const label = escapeMenuLabelAmpersands(fallback.label, &label_pool, &pool_used);
|
||||
const detail = escapeMenuLabelAmpersands(fallback.detail, &label_pool, &pool_used);
|
||||
ids[count] = item.id;
|
||||
labels[count] = label.ptr;
|
||||
label_lens[count] = label.len;
|
||||
separators[count] = if (item.separator) 1 else 0;
|
||||
// Windows has no custom status-menu row seam. Preserve semantic
|
||||
// readouts as visible detail text, while only command/agent rows can
|
||||
// become actions.
|
||||
enabled_flags[index] = if (item.enabled and (item.role == .command or item.role == .agent)) 1 else 0;
|
||||
details[index] = detail.ptr;
|
||||
detail_lens[index] = detail.len;
|
||||
roles[index] = @intFromEnum(item.role);
|
||||
keys[index] = item.key.ptr;
|
||||
key_lens[index] = item.key.len;
|
||||
modifiers[index] = shortcutModifierFlags(item.modifiers);
|
||||
enabled_flags[count] = if (item.enabled and (item.role == .command or item.role == .agent)) 1 else 0;
|
||||
details[count] = detail.ptr;
|
||||
detail_lens[count] = detail.len;
|
||||
roles[count] = @intFromEnum(item.role);
|
||||
keys[count] = item.key.ptr;
|
||||
key_lens[count] = item.key.len;
|
||||
modifiers[count] = shortcutModifierFlags(item.modifiers);
|
||||
count += 1;
|
||||
}
|
||||
if (native_sdk_windows_update_tray_menu(self.host, &ids, &labels, &label_lens, &separators, &enabled_flags, &details, &detail_lens, &roles, &keys, &key_lens, &modifiers, count) == 0) return error.UnsupportedService;
|
||||
}
|
||||
@@ -2183,6 +2232,30 @@ test "windows tray carries lifecycle commands rich rows and key equivalents into
|
||||
try std.testing.expect(std.mem.indexOf(u8, host_source, "tray_event == NIN_SELECT || tray_event == NIN_KEYSELECT || tray_event == WM_LBUTTONUP") != null);
|
||||
}
|
||||
|
||||
test "windows chart fallback uses accessibility text when captions are absent" {
|
||||
const values = [_]f32{0.5};
|
||||
const fallback = trayFallbackText(.{
|
||||
.role = .chart,
|
||||
.chart = .{
|
||||
.values = &values,
|
||||
.accessibility_label = "CPU history, 50 percent",
|
||||
},
|
||||
});
|
||||
try std.testing.expectEqualStrings("CPU history, 50 percent", fallback.label);
|
||||
try std.testing.expectEqualStrings("", fallback.detail);
|
||||
|
||||
const summarized = trayFallbackText(.{
|
||||
.role = .chart,
|
||||
.chart = .{
|
||||
.values = &values,
|
||||
.trailing_summary = "50%",
|
||||
.accessibility_label = "CPU history, 50 percent",
|
||||
},
|
||||
});
|
||||
try std.testing.expectEqualStrings("50%", summarized.label);
|
||||
try std.testing.expectEqualStrings("", summarized.detail);
|
||||
}
|
||||
|
||||
test "windows refuses a tray-less .hide main window at platform init instead of stranding it hidden" {
|
||||
// The pre-created MAIN window never passes through the runtime's
|
||||
// create gate, so the platform's init gate must hold the same line
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const geometry = @import("geometry");
|
||||
const canvas = @import("root.zig");
|
||||
const text_model = @import("text.zig");
|
||||
@@ -540,6 +541,7 @@ fn widgetKeyboardKeyDownTextEditEvent(event: WidgetKeyboardEvent) ?TextInputEven
|
||||
if (widgetKeyboardCommandTextNavigationEvent(event)) |edit| return edit;
|
||||
if (widgetKeyboardWordTextNavigationEvent(event)) |edit| return edit;
|
||||
if (widgetKeyboardWordDeleteTextEditEvent(event)) |edit| return edit;
|
||||
if (widgetKeyboardLineDeleteTextEditEvent(event)) |edit| return edit;
|
||||
if (event.modifiers.hasNavigationModifier()) return null;
|
||||
if (std.ascii.eqlIgnoreCase(event.key, "backspace")) return .delete_backward;
|
||||
if (std.ascii.eqlIgnoreCase(event.key, "delete")) return .delete_forward;
|
||||
@@ -566,13 +568,49 @@ fn widgetKeyboardWordTextNavigationEvent(event: WidgetKeyboardEvent) ?TextInputE
|
||||
}
|
||||
|
||||
fn widgetKeyboardWordDeleteTextEditEvent(event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
if (event.modifiers.super or event.modifiers.shift) return null;
|
||||
return widgetKeyboardWordDeleteTextEditEventForPlatform(builtin.os.tag, event);
|
||||
}
|
||||
|
||||
fn widgetKeyboardWordDeleteTextEditEventForPlatform(comptime os_tag: @TypeOf(builtin.os.tag), event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
// Ctrl-primary hosts project Ctrl into BOTH `control` and `super`.
|
||||
// Accept that folded shape off macOS so Ctrl+Backspace keeps its
|
||||
// platform word-delete meaning; a bare Super/Meta chord stays inert.
|
||||
if (event.modifiers.shift) return null;
|
||||
if (event.modifiers.super) {
|
||||
if (comptime os_tag == .macos) return null;
|
||||
if (!event.modifiers.control) return null;
|
||||
}
|
||||
if (event.modifiers.alt == event.modifiers.control) return null;
|
||||
if (std.ascii.eqlIgnoreCase(event.key, "backspace")) return .delete_word_backward;
|
||||
if (std.ascii.eqlIgnoreCase(event.key, "delete")) return .delete_word_forward;
|
||||
return null;
|
||||
}
|
||||
|
||||
fn widgetKeyboardLineDeleteTextEditEvent(event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
return widgetKeyboardLineDeleteTextEditEventForPlatform(builtin.os.tag, event);
|
||||
}
|
||||
|
||||
fn widgetKeyboardLineDeleteTextEditEventForPlatform(comptime os_tag: @TypeOf(builtin.os.tag), event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
// Command+Backspace is Cocoa's deleteToBeginningOfLine:. Keep it
|
||||
// macOS-only: elsewhere Primary is Ctrl and belongs to word delete.
|
||||
// Textareas deliberately use the hard newline boundary in v1; visual
|
||||
// soft-wrap deletion can layer on runtime geometry in a follow-up.
|
||||
if (comptime os_tag != .macos) return null;
|
||||
if (!event.modifiers.super or event.modifiers.control or event.modifiers.alt) return null;
|
||||
if (std.ascii.eqlIgnoreCase(event.key, "backspace")) return .delete_to_line_start;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Resolve the generic keyboard vocabulary against a concrete editor kind.
|
||||
/// A single-line field presents model-provided line breaks as spaces, so its
|
||||
/// Command+Backspace target is always offset 0; a textarea keeps the hard-line
|
||||
/// boundary carried by `.delete_to_line_start`.
|
||||
pub fn widgetKeyboardTextEditEventForWidget(widget: Widget, event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
const edit = event.textEditEvent() orelse return null;
|
||||
if (edit == .delete_to_line_start and widgetKindSingleLineTextEntry(widget.kind)) return .delete_to_start;
|
||||
return edit;
|
||||
}
|
||||
|
||||
fn widgetKeyboardSelectAllTextEditEvent(event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
if (!event.modifiers.hasCommandModifier() or event.modifiers.alt or event.modifiers.shift) return null;
|
||||
if (!std.ascii.eqlIgnoreCase(event.key, "a")) return null;
|
||||
@@ -710,6 +748,9 @@ fn widgetSemanticPressControlIntent(widget: Widget, actions: WidgetActions) Widg
|
||||
}
|
||||
|
||||
pub fn isWidgetActivationKey(key: []const u8) bool {
|
||||
// Host adapters normalize the physical Return key to "enter" before
|
||||
// it reaches this canonical event vocabulary. AppKit maps both its
|
||||
// carriage-return key event and insertNewline: selector to that name.
|
||||
return std.ascii.eqlIgnoreCase(key, "space") or std.ascii.eqlIgnoreCase(key, "enter");
|
||||
}
|
||||
|
||||
@@ -727,6 +768,43 @@ pub fn isWidgetTextEntry(widget: Widget) bool {
|
||||
};
|
||||
}
|
||||
|
||||
test "line delete is macOS-only and folded Ctrl-primary remains word delete elsewhere" {
|
||||
const command_backspace = WidgetKeyboardEvent{
|
||||
.phase = .key_down,
|
||||
.key = "backspace",
|
||||
.modifiers = .{ .super = true },
|
||||
};
|
||||
try std.testing.expectEqual(TextInputEvent.delete_to_line_start, widgetKeyboardLineDeleteTextEditEventForPlatform(.macos, command_backspace).?);
|
||||
try std.testing.expect(widgetKeyboardLineDeleteTextEditEventForPlatform(.linux, command_backspace) == null);
|
||||
try std.testing.expect(widgetKeyboardLineDeleteTextEditEventForPlatform(.windows, command_backspace) == null);
|
||||
|
||||
const folded_control_backspace = WidgetKeyboardEvent{
|
||||
.phase = .key_down,
|
||||
.key = "backspace",
|
||||
.modifiers = .{ .super = true, .control = true },
|
||||
};
|
||||
try std.testing.expect(widgetKeyboardWordDeleteTextEditEventForPlatform(.macos, folded_control_backspace) == null);
|
||||
try std.testing.expectEqual(TextInputEvent.delete_word_backward, widgetKeyboardWordDeleteTextEditEventForPlatform(.linux, folded_control_backspace).?);
|
||||
try std.testing.expectEqual(TextInputEvent.delete_word_backward, widgetKeyboardWordDeleteTextEditEventForPlatform(.windows, folded_control_backspace).?);
|
||||
|
||||
const shifted_command_backspace = WidgetKeyboardEvent{
|
||||
.phase = .key_down,
|
||||
.key = "backspace",
|
||||
.modifiers = .{ .super = true, .shift = true },
|
||||
};
|
||||
try std.testing.expectEqual(TextInputEvent.delete_to_line_start, widgetKeyboardLineDeleteTextEditEventForPlatform(.macos, shifted_command_backspace).?);
|
||||
|
||||
// Widget-kind resolution is downstream of platform recognition. Stamp
|
||||
// the recognized semantic edit so these assertions stay host-neutral;
|
||||
// the explicit-platform assertions above own the macOS chord mapping.
|
||||
const recognized_line_delete = WidgetKeyboardEvent{
|
||||
.phase = .key_down,
|
||||
.edit = .delete_to_line_start,
|
||||
};
|
||||
try std.testing.expectEqual(TextInputEvent.delete_to_start, widgetKeyboardTextEditEventForWidget(.{ .kind = .input }, recognized_line_delete).?);
|
||||
try std.testing.expectEqual(TextInputEvent.delete_to_line_start, widgetKeyboardTextEditEventForWidget(.{ .kind = .textarea }, recognized_line_delete).?);
|
||||
}
|
||||
|
||||
/// The arrow keys that open a closed select/combobox trigger's picker
|
||||
/// (and, once it is mounted, walk into it).
|
||||
pub fn isWidgetMenuOpenArrowKey(key: []const u8) bool {
|
||||
|
||||
@@ -645,6 +645,7 @@ pub const WidgetClipboardAction = event_model.WidgetClipboardAction;
|
||||
pub const widgetKeyboardClipboardAction = event_model.widgetKeyboardClipboardAction;
|
||||
pub const widgetKeyboardNewlineTextEditEvent = event_model.widgetKeyboardNewlineTextEditEvent;
|
||||
pub const widgetCodeTabTextEditEvent = event_model.widgetCodeTabTextEditEvent;
|
||||
pub const widgetKeyboardTextEditEventForWidget = event_model.widgetKeyboardTextEditEventForWidget;
|
||||
pub const widgetKindSingleLineTextEntry = event_model.widgetKindSingleLineTextEntry;
|
||||
pub const sanitizedSingleLineTextInputEvent = event_model.sanitizedSingleLineTextInputEvent;
|
||||
pub const widgetKeyboardControlIntent = event_model.widgetKeyboardControlIntent;
|
||||
|
||||
@@ -103,6 +103,8 @@ pub const TextInputEvent = union(enum) {
|
||||
delete_forward,
|
||||
delete_word_backward,
|
||||
delete_word_forward,
|
||||
delete_to_start,
|
||||
delete_to_line_start,
|
||||
clear,
|
||||
move_caret: TextCaretMove,
|
||||
set_selection: TextSelection,
|
||||
@@ -136,6 +138,8 @@ pub fn applyTextInputEvent(state: TextEditState, event: TextInputEvent, output:
|
||||
.delete_forward => deleteForwardTextEdit(normalized, output),
|
||||
.delete_word_backward => deleteWordBackwardTextEdit(normalized, output),
|
||||
.delete_word_forward => deleteWordForwardTextEdit(normalized, output),
|
||||
.delete_to_start => deleteToStartTextEdit(normalized, output),
|
||||
.delete_to_line_start => deleteToLineStartTextEdit(normalized, output),
|
||||
.clear => .{
|
||||
.text = "",
|
||||
.selection = TextSelection.collapsed(0),
|
||||
@@ -251,6 +255,25 @@ fn deleteWordForwardTextEdit(state: TextEditState, output: []u8) Error!TextEditS
|
||||
return replaceTextEditRange(state, TextRange.init(caret, nextTextWordOffset(state.text, caret)), "", output, null, 0);
|
||||
}
|
||||
|
||||
fn deleteToStartTextEdit(state: TextEditState, output: []u8) Error!TextEditState {
|
||||
const range = activeTextReplaceRange(state);
|
||||
if (!range.isCollapsed(state.text.len)) return replaceTextEditRange(state, range, "", output, null, 0);
|
||||
|
||||
const caret = snapTextCaretOffset(state.text, state.selection.focus);
|
||||
if (caret == 0) return .{ .text = state.text, .selection = TextSelection.collapsed(0), .composition = null };
|
||||
return replaceTextEditRange(state, TextRange.init(0, caret), "", output, null, 0);
|
||||
}
|
||||
|
||||
fn deleteToLineStartTextEdit(state: TextEditState, output: []u8) Error!TextEditState {
|
||||
const range = activeTextReplaceRange(state);
|
||||
if (!range.isCollapsed(state.text.len)) return replaceTextEditRange(state, range, "", output, null, 0);
|
||||
|
||||
const caret = snapTextCaretOffset(state.text, state.selection.focus);
|
||||
const line_start = textLineStartOffset(state.text, caret);
|
||||
if (line_start == caret) return .{ .text = state.text, .selection = TextSelection.collapsed(caret), .composition = null };
|
||||
return replaceTextEditRange(state, TextRange.init(line_start, caret), "", output, null, 0);
|
||||
}
|
||||
|
||||
fn moveTextCaret(state: TextEditState, move: TextCaretMove) TextEditState {
|
||||
const range = state.selection.range(state.text.len);
|
||||
const focus = snapTextCaretOffset(state.text, state.selection.focus);
|
||||
|
||||
@@ -429,6 +429,31 @@ test "text edit state applies utf8-aware caret insert and delete events" {
|
||||
try std.testing.expectEqualStrings(" cafe", state.text);
|
||||
try std.testing.expectEqualDeep(TextSelection.collapsed(0), state.selection);
|
||||
|
||||
state = TextEditState{ .text = "first\nsecond line", .selection = TextSelection.collapsed(12) };
|
||||
state = try state.apply(.delete_to_start, &storage_b);
|
||||
try std.testing.expectEqualStrings(" line", state.text);
|
||||
try std.testing.expectEqualDeep(TextSelection.collapsed(0), state.selection);
|
||||
|
||||
state = TextEditState{ .text = "first\nsecond line", .selection = TextSelection.collapsed(12) };
|
||||
state = try state.apply(.delete_to_line_start, &storage_a);
|
||||
try std.testing.expectEqualStrings("first\n line", state.text);
|
||||
try std.testing.expectEqualDeep(TextSelection.collapsed(6), state.selection);
|
||||
|
||||
state = TextEditState{ .text = "first\nsecond", .selection = TextSelection.collapsed(6) };
|
||||
state = try state.apply(.delete_to_line_start, &storage_b);
|
||||
try std.testing.expectEqualStrings("first\nsecond", state.text);
|
||||
try std.testing.expectEqualDeep(TextSelection.collapsed(6), state.selection);
|
||||
|
||||
state = TextEditState{ .text = "first\nsecond", .selection = .{ .anchor = 7, .focus = 10 } };
|
||||
state = try state.apply(.delete_to_line_start, &storage_a);
|
||||
try std.testing.expectEqualStrings("first\nsnd", state.text);
|
||||
try std.testing.expectEqualDeep(TextSelection.collapsed(7), state.selection);
|
||||
|
||||
state = TextEditState{ .text = "one\r\ntwo", .selection = TextSelection.collapsed(8) };
|
||||
state = try state.apply(.delete_to_line_start, &storage_b);
|
||||
try std.testing.expectEqualStrings("one\r\n", state.text);
|
||||
try std.testing.expectEqualDeep(TextSelection.collapsed(5), state.selection);
|
||||
|
||||
state = TextEditState.init("");
|
||||
state = try state.apply(.{ .insert_text = "AxB" }, &storage_b);
|
||||
try std.testing.expectEqualStrings("AxB", state.text);
|
||||
@@ -732,6 +757,26 @@ test "widget keyboard events map to text edit events" {
|
||||
try std.testing.expectEqualStrings("hello brave ", nav_state.text);
|
||||
try std.testing.expectEqualDeep(TextSelection.collapsed(12), nav_state.selection);
|
||||
|
||||
const command_backspace = (WidgetKeyboardEvent{ .phase = .key_down, .key = "backspace", .modifiers = .{ .super = true } }).textEditEvent();
|
||||
if (comptime @import("builtin").os.tag == .macos) {
|
||||
try std.testing.expectEqual(TextInputEvent.delete_to_line_start, command_backspace.?);
|
||||
} else {
|
||||
try std.testing.expect(command_backspace == null);
|
||||
}
|
||||
const shifted_command_backspace = (WidgetKeyboardEvent{ .phase = .key_down, .key = "backspace", .modifiers = .{ .super = true, .shift = true } }).textEditEvent();
|
||||
if (comptime @import("builtin").os.tag == .macos) {
|
||||
try std.testing.expectEqual(TextInputEvent.delete_to_line_start, shifted_command_backspace.?);
|
||||
} else {
|
||||
try std.testing.expect(shifted_command_backspace == null);
|
||||
}
|
||||
|
||||
const folded_control_backspace = (WidgetKeyboardEvent{ .phase = .key_down, .key = "backspace", .modifiers = .{ .super = true, .control = true } }).textEditEvent();
|
||||
if (comptime @import("builtin").os.tag == .macos) {
|
||||
try std.testing.expect(folded_control_backspace == null);
|
||||
} else {
|
||||
try std.testing.expectEqual(TextInputEvent.delete_word_backward, folded_control_backspace.?);
|
||||
}
|
||||
|
||||
nav_state = TextEditState{ .text = "hello brave world", .selection = TextSelection.collapsed(0) };
|
||||
const control_delete = (WidgetKeyboardEvent{ .phase = .key_down, .key = "delete", .modifiers = .{ .control = true } }).textEditEvent().?;
|
||||
nav_state = try nav_state.apply(control_delete, &nav_storage);
|
||||
|
||||
@@ -1044,6 +1044,8 @@ pub fn Ui(comptime Msg: type) type {
|
||||
.delete_forward => @unionInit(Payload, "delete_forward", {}),
|
||||
.delete_word_backward => @unionInit(Payload, "delete_word_backward", {}),
|
||||
.delete_word_forward => @unionInit(Payload, "delete_word_forward", {}),
|
||||
.delete_to_start => @unionInit(Payload, "delete_to_start", {}),
|
||||
.delete_to_line_start => @unionInit(Payload, "delete_to_line_start", {}),
|
||||
.clear => @unionInit(Payload, "clear", {}),
|
||||
.move_caret => |move| blk: {
|
||||
const Move = @FieldType(Payload, "move_caret");
|
||||
@@ -1471,13 +1473,14 @@ pub fn Ui(comptime Msg: type) type {
|
||||
if (widget.semantics.role == .treeitem and keyboard.focus_moved) {
|
||||
if (self.msgFor(target_id, .change)) |msg| return msg;
|
||||
}
|
||||
// A list row prefers a bound submit handler on plain
|
||||
// Enter: Enter is the row's PRIMARY action (open the
|
||||
// record, play the track — the desktop list convention),
|
||||
// while Space keeps the select activation below. Only
|
||||
// rows that bind `on_submit` take this branch; everything
|
||||
// else resolves exactly as before.
|
||||
if (widget.kind == .list_item and isSubmitKeyboard(widget, keyboard)) {
|
||||
// List rows and comboboxes prefer a bound submit handler
|
||||
// on plain Enter before their activation intent: Enter is
|
||||
// the row's PRIMARY action or commits the combobox text,
|
||||
// while Space and the combobox open arrows keep the
|
||||
// activation below. Only widgets that bind `on_submit`
|
||||
// return from this branch; everything else resolves
|
||||
// exactly as before.
|
||||
if ((widget.kind == .list_item or widget.kind == .combobox) and isSubmitKeyboard(widget, keyboard)) {
|
||||
if (self.msgFor(target_id, .submit)) |msg| return msg;
|
||||
}
|
||||
if (canvas.widgetKeyboardControlIntent(widget, keyboard)) |intent| {
|
||||
@@ -1510,7 +1513,7 @@ pub fn Ui(comptime Msg: type) type {
|
||||
} else {
|
||||
const locally_derived = canvas.widgetCodeTabTextEditEvent(widget, keyboard) orelse
|
||||
canvas.widgetKeyboardNewlineTextEditEvent(widget, keyboard) orelse
|
||||
keyboard.textEditEvent();
|
||||
canvas.widgetKeyboardTextEditEventForWidget(widget, keyboard);
|
||||
if (locally_derived) |text_edit| {
|
||||
// Direct Tree consumers still sanitize locally:
|
||||
// these bytes have not crossed the runtime seam.
|
||||
|
||||
@@ -993,6 +993,9 @@ test "compiled catalog elements match the interpreter and the hand-written view"
|
||||
interpreted.msgForKeyboard(input.id, submit).?,
|
||||
compiled.msgForKeyboard(input.id, submit).?,
|
||||
);
|
||||
const combobox = fixture.findByKind(compiled.root, .combobox).?;
|
||||
try testing.expectEqual(fixture.CatalogMsg.submit_query, compiled.msgForKeyboard(combobox.id, submit).?);
|
||||
try testing.expectEqual(fixture.CatalogMsg.open_picker, compiled.msgForKeyboard(combobox.id, .{ .phase = .key_down, .key = "space" }).?);
|
||||
}
|
||||
|
||||
test "compiled catalog stays in parity when conditional surfaces flip" {
|
||||
|
||||
@@ -106,9 +106,10 @@ pub fn isZeroArgFn(comptime T: type, comptime DeclType: type) bool {
|
||||
/// declared-shape predicate below stays std-only. A drift test in
|
||||
/// `ui_markup_contract_tests.zig` holds this list equal to the real union.
|
||||
pub const text_input_event_tags = [_][]const u8{
|
||||
"insert_text", "delete_backward", "delete_forward", "delete_word_backward",
|
||||
"delete_word_forward", "clear", "move_caret", "set_selection",
|
||||
"set_composition", "commit_composition", "cancel_composition",
|
||||
"insert_text", "delete_backward", "delete_forward", "delete_word_backward",
|
||||
"delete_word_forward", "delete_to_start", "delete_to_line_start", "clear",
|
||||
"move_caret", "set_selection", "set_composition", "commit_composition",
|
||||
"cancel_composition",
|
||||
};
|
||||
|
||||
/// The caret-direction member vocabulary (`canvas.TextCaretDirection`).
|
||||
@@ -125,8 +126,8 @@ pub const text_caret_affinity_members = [_][]const u8{
|
||||
/// where the emitted module declares its own mirror union (type identity
|
||||
/// cannot cross the emission boundary). Matched structurally, by the same
|
||||
/// contract everywhere markup resolves `on-input`:
|
||||
/// - a tagged union carrying exactly the eleven canvas event tags;
|
||||
/// - `insert_text` a bytes payload; the seven verb arms void;
|
||||
/// - a tagged union carrying exactly the thirteen canvas event tags;
|
||||
/// - `insert_text` a bytes payload; the nine verb arms void;
|
||||
/// - `move_caret` a record of `direction` (an enum with exactly the six
|
||||
/// caret-direction member names) and `extend: bool`;
|
||||
/// - `set_selection` a record of numeric `anchor`/`focus`, optionally
|
||||
|
||||
@@ -2214,7 +2214,7 @@ pub const catalog_markup_source =
|
||||
\\ </row>
|
||||
\\ <row gap="8">
|
||||
\\ <input text="{query}" placeholder="Name" autofocus="true" on-input="query_edit" on-submit="submit_query" grow="1" />
|
||||
\\ <combobox text="{query}" placeholder="Search fruit" on-input="query_edit" />
|
||||
\\ <combobox text="{query}" placeholder="Search fruit" on-input="query_edit" on-press="open_picker" on-submit="submit_query" />
|
||||
\\ </row>
|
||||
\\ <radio-group gap="4" label="Formatting">
|
||||
\\ <radio checked="{bold}" on-change="toggle_bold" label="Bold" />
|
||||
@@ -2344,7 +2344,7 @@ pub fn handCatalogView(ui: *CatalogUi, model: *const CatalogModel) CatalogUi.Nod
|
||||
}),
|
||||
ui.row(.{ .gap = 8 }, .{
|
||||
ui.el(.input, .{ .text = model.query, .placeholder = "Name", .autofocus = true, .on_input = CatalogUi.inputMsg(.query_edit), .on_submit = .submit_query, .grow = 1 }, .{}),
|
||||
ui.el(.combobox, .{ .text = model.query, .placeholder = "Search fruit", .on_input = CatalogUi.inputMsg(.query_edit) }, .{}),
|
||||
ui.el(.combobox, .{ .text = model.query, .placeholder = "Search fruit", .on_input = CatalogUi.inputMsg(.query_edit), .on_press = .open_picker, .on_submit = .submit_query }, .{}),
|
||||
}),
|
||||
ui.el(.radio_group, .{ .gap = 4, .semantics = .{ .label = "Formatting" } }, .{
|
||||
ui.el(.radio, .{ .checked = model.bold, .on_change = .toggle_bold }, .{}),
|
||||
@@ -2489,6 +2489,10 @@ test "catalog elements build the hand-written tree and dispatch typed messages"
|
||||
try testing.expectEqualStrings("q", markup_tree.msgForKeyboard(input.id, typed).?.query_edit.insert_text);
|
||||
const submit = canvas.WidgetKeyboardEvent{ .phase = .key_down, .key = "enter" };
|
||||
try testing.expectEqual(CatalogMsg.submit_query, markup_tree.msgForKeyboard(input.id, submit).?);
|
||||
const combobox = findByKind(markup_tree.root, .combobox).?;
|
||||
try testing.expectEqual(CatalogMsg.submit_query, markup_tree.msgForKeyboard(combobox.id, submit).?);
|
||||
try testing.expectEqual(CatalogMsg.open_picker, markup_tree.msgForKeyboard(combobox.id, .{ .phase = .key_down, .key = "space" }).?);
|
||||
try testing.expectEqual(CatalogMsg.open_picker, markup_tree.msgForKeyboard(combobox.id, .{ .phase = .key_down, .key = "arrowdown" }).?);
|
||||
|
||||
// The whole catalog lays out through the canvas engine.
|
||||
var nodes: [256]canvas.WidgetLayoutNode = undefined;
|
||||
@@ -4660,6 +4664,8 @@ pub const MirrorTextInputEvent = union(enum) {
|
||||
delete_forward,
|
||||
delete_word_backward,
|
||||
delete_word_forward,
|
||||
delete_to_start,
|
||||
delete_to_line_start,
|
||||
clear,
|
||||
move_caret: MirrorCaretMove,
|
||||
set_selection: MirrorSelection,
|
||||
@@ -4687,6 +4693,8 @@ test "declaredTextInputUnion accepts the emitted mirror shape and rejects near-m
|
||||
delete_forward,
|
||||
delete_word_backward,
|
||||
delete_word_forward,
|
||||
delete_to_start,
|
||||
delete_to_line_start,
|
||||
clear,
|
||||
move_caret: MirrorCaretMove,
|
||||
set_selection: MirrorSelectionWithAffinity,
|
||||
@@ -4703,6 +4711,8 @@ test "declaredTextInputUnion accepts the emitted mirror shape and rejects near-m
|
||||
delete_forward,
|
||||
delete_word_backward,
|
||||
delete_word_forward,
|
||||
delete_to_start,
|
||||
delete_to_line_start,
|
||||
clear,
|
||||
move_caret: MirrorCaretMove,
|
||||
set_selection: MirrorSelection,
|
||||
@@ -4716,6 +4726,8 @@ test "declaredTextInputUnion accepts the emitted mirror shape and rejects near-m
|
||||
delete_forward,
|
||||
delete_word_backward,
|
||||
delete_word_forward,
|
||||
delete_to_start,
|
||||
delete_to_line_start,
|
||||
clear,
|
||||
move_caret: MirrorCaretMove,
|
||||
set_selection: MirrorSelection,
|
||||
@@ -4731,6 +4743,8 @@ test "declaredTextInputUnion accepts the emitted mirror shape and rejects near-m
|
||||
delete_forward,
|
||||
delete_word_backward,
|
||||
delete_word_forward,
|
||||
delete_to_start,
|
||||
delete_to_line_start,
|
||||
clear,
|
||||
move_caret: struct { direction: WrongDirection, extend: bool },
|
||||
set_selection: MirrorSelection,
|
||||
@@ -4984,6 +4998,8 @@ test "the interpreter binds on-input to a declared mirror union and translates e
|
||||
try testing.expectEqualStrings("abc", inserted.edit.insert_text);
|
||||
// Void verbs map by tag.
|
||||
try testing.expectEqual(MirrorTextInputEvent.delete_backward, tree.msgForTextEdit(field.id, .delete_backward).?.edit);
|
||||
try testing.expectEqual(MirrorTextInputEvent.delete_to_start, tree.msgForTextEdit(field.id, .delete_to_start).?.edit);
|
||||
try testing.expectEqual(MirrorTextInputEvent.delete_to_line_start, tree.msgForTextEdit(field.id, .delete_to_line_start).?.edit);
|
||||
// Caret moves translate the direction enum by member name.
|
||||
const moved = tree.msgForTextEdit(field.id, .{ .move_caret = .{ .direction = .previous_word, .extend = true } }).?;
|
||||
try testing.expectEqual(MirrorCaretDirection.previous_word, moved.edit.move_caret.direction);
|
||||
|
||||
@@ -349,6 +349,43 @@ test "keyboard events resolve activation and submit messages" {
|
||||
try testing.expectEqual(@as(?Msg, null), tree.msgForKeyboard(checkbox.id, letter));
|
||||
}
|
||||
|
||||
test "combobox Enter prefers submit while its other open keys still press" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
var ui = InboxUi.init(arena_state.allocator());
|
||||
const tree = try ui.finalize(ui.column(.{}, .{
|
||||
ui.el(.combobox, .{
|
||||
.text = "both handlers",
|
||||
.on_press = .load_more,
|
||||
.on_submit = .add,
|
||||
}, .{}),
|
||||
ui.el(.combobox, .{
|
||||
.text = "press only",
|
||||
.on_press = .load_more,
|
||||
}, .{}),
|
||||
ui.el(.combobox, .{
|
||||
.text = "submit only",
|
||||
.on_submit = .add,
|
||||
}, .{}),
|
||||
}));
|
||||
|
||||
const both = tree.root.children[0];
|
||||
const press_only = tree.root.children[1];
|
||||
const submit_only = tree.root.children[2];
|
||||
const enter = canvas.WidgetKeyboardEvent{ .phase = .key_down, .key = "enter" };
|
||||
const space = canvas.WidgetKeyboardEvent{ .phase = .key_down, .key = "space" };
|
||||
const arrow_down = canvas.WidgetKeyboardEvent{ .phase = .key_down, .key = "arrowdown" };
|
||||
const arrow_up = canvas.WidgetKeyboardEvent{ .phase = .key_down, .key = "arrowup" };
|
||||
|
||||
try testing.expectEqual(Msg.add, tree.msgForKeyboard(both.id, enter).?);
|
||||
try testing.expectEqual(Msg.load_more, tree.msgForKeyboard(both.id, space).?);
|
||||
try testing.expectEqual(Msg.load_more, tree.msgForKeyboard(both.id, arrow_down).?);
|
||||
try testing.expectEqual(Msg.load_more, tree.msgForKeyboard(both.id, arrow_up).?);
|
||||
try testing.expectEqual(Msg.load_more, tree.msgForKeyboard(press_only.id, enter).?);
|
||||
try testing.expectEqual(Msg.add, tree.msgForKeyboard(submit_only.id, enter).?);
|
||||
}
|
||||
|
||||
test "tree keyboard navigation can select without dispatching pointer activation" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
@@ -2403,6 +2403,19 @@ test "the accent override desaturates its dark-scheme focus ring" {
|
||||
try std.testing.expectEqualDeep(gray, canvas.accentFocusRing(gray, .dark));
|
||||
}
|
||||
|
||||
test "accent overrides compose over either built-in pack without moving unrelated tokens" {
|
||||
const accent = Color.rgb8(0, 120, 111);
|
||||
for ([_]canvas.ThemePack{ .house, .geist }) |pack| {
|
||||
const base = DesignTokens.theme(.{ .pack = pack, .color_scheme = .dark });
|
||||
const branded = base.withOverrides(canvas.accentOverrides(accent, .dark));
|
||||
try std.testing.expectEqualDeep(accent, branded.colors.accent);
|
||||
try std.testing.expectEqualDeep(canvas.accentFocusRing(accent, .dark), branded.colors.focus_ring);
|
||||
try std.testing.expectEqualDeep(accent, branded.controls.slider.active_background);
|
||||
try std.testing.expectEqual(base.metrics.control_height, branded.metrics.control_height);
|
||||
try std.testing.expectEqualDeep(base.colors.background, branded.colors.background);
|
||||
}
|
||||
}
|
||||
|
||||
test "the dark accent focus ring holds the non-text contrast floor across hues" {
|
||||
// Rings draw OUTSIDE controls, so the tones that matter are the
|
||||
// dark containers controls commonly sit on, across BOTH shipped
|
||||
|
||||
@@ -253,6 +253,10 @@ pub const TrayItemId = platform.TrayItemId;
|
||||
pub const TrayOptions = platform.TrayOptions;
|
||||
pub const TrayShell = platform.TrayShell;
|
||||
pub const TrayMenuItem = platform.TrayMenuItem;
|
||||
pub const TraySegmentOption = platform.TraySegmentOption;
|
||||
pub const TraySegmentedRow = platform.TraySegmentedRow;
|
||||
pub const TrayMetricRow = platform.TrayMetricRow;
|
||||
pub const TrayChartRow = platform.TrayChartRow;
|
||||
pub const BridgeDispatcher = bridge.Dispatcher;
|
||||
pub const BridgePolicy = bridge.Policy;
|
||||
pub const BridgeCommandPolicy = bridge.CommandPolicy;
|
||||
|
||||
@@ -30,6 +30,8 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type {
|
||||
.windows = self.automation_windows[0..1],
|
||||
.views = &.{},
|
||||
.widgets = &.{},
|
||||
.commands = self.options.commands,
|
||||
.menus = self.options.menus,
|
||||
.diagnostics = automationDiagnostics(self),
|
||||
.frame_profile = automationFrameProfile(self),
|
||||
.trays = automationTrays(self),
|
||||
@@ -64,6 +66,8 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type {
|
||||
.windows = self.automation_windows[0..count],
|
||||
.views = self.automation_views[0..view_count],
|
||||
.widgets = self.automation_widgets[0..widget_count],
|
||||
.commands = self.options.commands,
|
||||
.menus = self.options.menus,
|
||||
.diagnostics = automationDiagnostics(self),
|
||||
.frame_profile = automationFrameProfile(self),
|
||||
.trays = automationTrays(self),
|
||||
@@ -99,6 +103,9 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type {
|
||||
.role = item.role,
|
||||
.key = item.key,
|
||||
.modifiers = item.modifiers,
|
||||
.segmented = item.segmented,
|
||||
.metric = item.metric,
|
||||
.chart = item.chart,
|
||||
};
|
||||
}
|
||||
self.automation_trays[tray_count] = .{
|
||||
|
||||
@@ -2182,6 +2182,8 @@ test "runtime dispatches canvas widget commands from pointer and keyboard activa
|
||||
try std.testing.expectEqual(@as(u32, 6), app_state.command_count);
|
||||
|
||||
harness.runtime.views[0].canvas_widget_focused_id = 6;
|
||||
// Command-string widgets have no typed on-submit channel, so their
|
||||
// combobox Enter behavior intentionally remains trigger activation.
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
|
||||
@@ -1401,6 +1401,57 @@ test "single-line history replay restores exact retained bytes" {
|
||||
try std.testing.expect(harness.runtime.views[0].canvasWidgetTextHistoryAvailability(2).can_redo);
|
||||
}
|
||||
|
||||
test "macOS Command Backspace treats model-provided newlines as single-line presentation bytes" {
|
||||
if (comptime builtin.os.tag != .macos) return error.SkipZigTest;
|
||||
|
||||
const TestApp = struct {
|
||||
fn app(self: *@This()) App {
|
||||
return .{ .context = self, .name = "gpu-widget-input-command-backspace-raw-newline", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
|
||||
}
|
||||
};
|
||||
|
||||
const harness = try TestHarness().create(std.testing.allocator, .{});
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
var app_state: TestApp = .{};
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
_ = try harness.runtime.createView(.{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .gpu_surface,
|
||||
.frame = geometry.RectF.init(0, 0, 240, 100),
|
||||
});
|
||||
|
||||
var nodes: [2]canvas.WidgetLayoutNode = undefined;
|
||||
const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{canvas.Widget{
|
||||
.id = 2,
|
||||
.kind = .input,
|
||||
.frame = geometry.RectF.init(12, 16, 180, 32),
|
||||
.text = "one\ntwo",
|
||||
.text_selection = canvas.TextSelection.collapsed(7),
|
||||
}} }, geometry.RectF.init(0, 0, 240, 100), &nodes);
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
|
||||
harness.runtime.views[0].focused = true;
|
||||
harness.runtime.views[0].canvas_widget_focused_id = 2;
|
||||
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .key_down,
|
||||
.key = "backspace",
|
||||
.modifiers = .{ .command = true, .shift = true },
|
||||
} });
|
||||
|
||||
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqualStrings("", retained.nodes[1].widget.text);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), retained.nodes[1].widget.text_selection.?);
|
||||
try dispatchTextareaHistoryShortcut(harness, app, false);
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqualStrings("one\ntwo", retained.nodes[1].widget.text);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(7), retained.nodes[1].widget.text_selection.?);
|
||||
}
|
||||
|
||||
test "textarea history replays newly completed CRLF atomically" {
|
||||
const TestApp = struct {
|
||||
fn app(self: *@This()) App {
|
||||
@@ -1838,6 +1889,79 @@ test "canvas textareas undo and redo keyboard edits" {
|
||||
try std.testing.expectEqualStrings("external!", retained.nodes[1].widget.text);
|
||||
}
|
||||
|
||||
test "macOS Command Backspace deletes to line start in every editable text kind and undoes as one step" {
|
||||
if (comptime builtin.os.tag != .macos) return error.SkipZigTest;
|
||||
|
||||
const TestApp = struct {
|
||||
fn app(self: *@This()) App {
|
||||
return .{ .context = self, .name = "gpu-widget-command-backspace", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
|
||||
}
|
||||
};
|
||||
|
||||
const harness = try TestHarness().create(std.testing.allocator, .{});
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
var app_state: TestApp = .{};
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
_ = try harness.runtime.createView(.{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .gpu_surface,
|
||||
.frame = geometry.RectF.init(0, 0, 520, 280),
|
||||
});
|
||||
|
||||
const kinds = [_]canvas.WidgetKind{ .input, .text_field, .search_field, .combobox, .textarea };
|
||||
var children: [kinds.len]canvas.Widget = undefined;
|
||||
for (&children, kinds, 0..) |*child, kind, index| {
|
||||
child.* = .{
|
||||
.id = @intCast(index + 2),
|
||||
.kind = kind,
|
||||
.frame = geometry.RectF.init(12, @floatFromInt(12 + index * 46), 260, 36),
|
||||
.text = if (kind == .textarea) "first\nsecond line" else "second line",
|
||||
.text_selection = canvas.TextSelection.collapsed(if (kind == .textarea) 12 else 6),
|
||||
.semantics = .{ .label = "Editor" },
|
||||
};
|
||||
}
|
||||
var nodes: [kinds.len + 1]canvas.WidgetLayoutNode = undefined;
|
||||
const layout = try canvas.layoutWidgetTree(
|
||||
.{ .kind = .stack, .children = &children },
|
||||
geometry.RectF.init(0, 0, 520, 280),
|
||||
&nodes,
|
||||
);
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
|
||||
|
||||
for (kinds, 0..) |kind, index| {
|
||||
const id: canvas.ObjectId = @intCast(index + 2);
|
||||
harness.runtime.views[0].focused = true;
|
||||
harness.runtime.views[0].canvas_widget_focused_id = id;
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .key_down,
|
||||
.key = "backspace",
|
||||
.modifiers = .{ .command = true },
|
||||
} });
|
||||
|
||||
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
const edited = retained.findById(id).?.widget;
|
||||
try std.testing.expectEqualStrings(if (kind == .textarea) "first\n line" else " line", edited.text);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(if (kind == .textarea) 6 else 0), edited.text_selection.?);
|
||||
|
||||
try dispatchTextareaHistoryShortcut(harness, app, false);
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
const undone = retained.findById(id).?.widget;
|
||||
try std.testing.expectEqualStrings(if (kind == .textarea) "first\nsecond line" else "second line", undone.text);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(if (kind == .textarea) 12 else 6), undone.text_selection.?);
|
||||
|
||||
try dispatchTextareaHistoryShortcut(harness, app, true);
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
const redone = retained.findById(id).?.widget;
|
||||
try std.testing.expectEqualStrings(if (kind == .textarea) "first\n line" else " line", redone.text);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(if (kind == .textarea) 6 else 0), redone.text_selection.?);
|
||||
}
|
||||
}
|
||||
|
||||
test "compound editor history reroutes every continuation after controlled rebuilds" {
|
||||
const TestApp = struct {
|
||||
replay_count: usize = 0,
|
||||
|
||||
@@ -1012,7 +1012,7 @@ pub fn RuntimeFlow(comptime Runtime: type) type {
|
||||
// stale or unknown item id is loud driver misuse,
|
||||
// like widget-click on an unmounted widget.
|
||||
const target = try parseAutomationTrayTarget(command.value);
|
||||
if (!SystemServiceMethods().statusItemMenuItemExists(self, target.status_item_id, target.item_id)) return error.InvalidCommand;
|
||||
if (!SystemServiceMethods().statusItemMenuItemActionable(self, target.status_item_id, target.item_id)) return error.InvalidCommand;
|
||||
try dispatchPlatformEvent(self, app, .{ .tray_action = .{
|
||||
.status_item_id = target.status_item_id,
|
||||
.item_id = target.item_id,
|
||||
|
||||
@@ -253,6 +253,35 @@ test "runtime dispatches menu command events" {
|
||||
try std.testing.expectEqual(@as(platform.WindowId, 1), app_state.last_window_id);
|
||||
}
|
||||
|
||||
test "automation snapshot exposes configured app menus" {
|
||||
const items = [_]platform.MenuItem{
|
||||
.{ .label = "Refresh", .command = "app.refresh" },
|
||||
.{ .label = "Disabled", .command = "app.disabled", .enabled = false },
|
||||
.{ .separator = true },
|
||||
};
|
||||
const menus = [_]platform.Menu{
|
||||
.{ .title = "View", .items = &items },
|
||||
};
|
||||
const harness = try TestHarness().create(std.testing.allocator, .{});
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.runtime.options.menus = &menus;
|
||||
const snapshot = harness.runtime.automationSnapshot("Menus");
|
||||
try std.testing.expectEqual(@as(usize, 1), snapshot.menus.len);
|
||||
try std.testing.expectEqualStrings("View", snapshot.menus[0].title);
|
||||
try std.testing.expectEqual(@as(usize, 3), snapshot.menus[0].items.len);
|
||||
try std.testing.expectEqualStrings("app.refresh", snapshot.menus[0].items[0].command);
|
||||
try std.testing.expect(!snapshot.menus[0].items[1].enabled);
|
||||
try std.testing.expect(snapshot.menus[0].items[2].separator);
|
||||
|
||||
var buffer: [2048]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&buffer);
|
||||
try automation.snapshot.writeText(snapshot, &writer);
|
||||
const text = writer.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "app-menu title=\"View\" items=3\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item label=\"Refresh\" command=\"app.refresh\" enabled=true checked=false key=\"\"") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item separator\n") != null);
|
||||
}
|
||||
|
||||
test "runtime dispatches tray item commands" {
|
||||
const TestApp = struct {
|
||||
command_count: u32 = 0,
|
||||
|
||||
@@ -133,8 +133,12 @@ pub const format_fingerprint: u64 = layout_fingerprint.hash(formatLayoutDescript
|
||||
/// out-of-window audio record as journal damage — an older recording
|
||||
/// could journal a wider value the old feed paths accepted, which
|
||||
/// would now be misreported as damage instead of refusing as a
|
||||
/// different generation.
|
||||
pub const format_semantic_epoch: u32 = 5;
|
||||
/// different generation. Epoch 6: macOS Command+Backspace gained its
|
||||
/// platform text-editor meaning (delete to field start in single-line
|
||||
/// editors, or hard line start in textareas). The raw gpu-surface key record
|
||||
/// is byte-identical, but replay now derives a text edit where older builds
|
||||
/// derived none.
|
||||
pub const format_semantic_epoch: u32 = 6;
|
||||
|
||||
/// The canonical description `format_fingerprint` hashes: everything
|
||||
/// that defines the on-disk record layout. Reflection covers the record
|
||||
@@ -1611,6 +1615,21 @@ test "event codec round-trips every payload variant" {
|
||||
try testing.expect(decoded.gpu_surface_input.modifiers.control);
|
||||
try testing.expectEqual(@as(f32, 0), decoded.gpu_surface_input.scale);
|
||||
}
|
||||
{
|
||||
// Command+Backspace journals as the raw platform key chord; replay
|
||||
// re-derives the semantic delete_to_start/delete_to_line_start edit
|
||||
// from the focused widget kind.
|
||||
const decoded = try roundTripEvent(.{ .gpu_surface_input = .{
|
||||
.label = "editor-canvas",
|
||||
.kind = .key_down,
|
||||
.key = "backspace",
|
||||
.modifiers = .{ .primary = true, .command = true },
|
||||
} });
|
||||
try testing.expectEqualStrings("backspace", decoded.gpu_surface_input.key);
|
||||
try testing.expect(decoded.gpu_surface_input.modifiers.primary);
|
||||
try testing.expect(decoded.gpu_surface_input.modifiers.command);
|
||||
try testing.expect(!decoded.gpu_surface_input.modifiers.control);
|
||||
}
|
||||
{
|
||||
// v5: pinch records carry the magnification delta and the
|
||||
// gesture kinds appended at codes 12-14.
|
||||
|
||||
@@ -86,6 +86,20 @@ pub const RuntimeTrayItem = struct {
|
||||
label_storage: [platform.max_tray_item_label_bytes]u8 = undefined,
|
||||
detail_storage: [platform.max_tray_item_detail_bytes]u8 = undefined,
|
||||
key_storage: [platform.max_menu_key_bytes]u8 = undefined,
|
||||
segment_options: [platform.max_tray_segment_options]platform.TraySegmentOption = undefined,
|
||||
segment_option_label_storage: [platform.max_tray_segment_options][platform.max_tray_segment_label_bytes]u8 = undefined,
|
||||
segment_option_command_storage: [platform.max_tray_segment_options][platform.max_tray_item_command_bytes]u8 = undefined,
|
||||
segment_option_count: usize = 0,
|
||||
segmented: ?platform.TraySegmentedRow = null,
|
||||
metric_primary_storage: [platform.max_tray_item_label_bytes]u8 = undefined,
|
||||
metric_secondary_storage: [platform.max_tray_item_detail_bytes]u8 = undefined,
|
||||
metric_accessibility_storage: [platform.max_tray_chart_text_bytes]u8 = undefined,
|
||||
metric: ?platform.TrayMetricRow = null,
|
||||
chart_values: [platform.max_tray_chart_values]f32 = undefined,
|
||||
chart_leading_caption_storage: [platform.max_tray_chart_text_bytes]u8 = undefined,
|
||||
chart_trailing_summary_storage: [platform.max_tray_chart_text_bytes]u8 = undefined,
|
||||
chart_accessibility_label_storage: [platform.max_tray_chart_text_bytes]u8 = undefined,
|
||||
chart: ?platform.TrayChartRow = null,
|
||||
};
|
||||
|
||||
pub const RuntimeStatusItem = struct {
|
||||
|
||||
@@ -229,6 +229,25 @@ pub fn RuntimeSystemServices(comptime Runtime: type) type {
|
||||
const status_item = findStatusItemConst(self, status_item_id) orelse return false;
|
||||
for (status_item.items[0..status_item.item_count]) |item| {
|
||||
if (item.id == item_id) return true;
|
||||
if (item.segmented) |segmented| {
|
||||
for (segmented.options) |option| if (option.id == item_id) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether automation may invoke this row exactly as a user can.
|
||||
/// Disabled native menu items and segmented choices cannot emit a
|
||||
/// platform action, so the automation seam must reject them too.
|
||||
pub fn statusItemMenuItemActionable(self: *const Runtime, status_item_id: platform.StatusItemId, item_id: platform.TrayItemId) bool {
|
||||
const status_item = findStatusItemConst(self, status_item_id) orelse return false;
|
||||
for (status_item.items[0..status_item.item_count]) |item| {
|
||||
if (item.id == item_id) return !item.separator and item.enabled and (item.role == .command or item.role == .agent);
|
||||
if (item.segmented) |segmented| {
|
||||
for (segmented.options) |option| {
|
||||
if (option.id == item_id) return option.enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -241,6 +260,11 @@ pub fn RuntimeSystemServices(comptime Runtime: type) type {
|
||||
const status_item = findStatusItemConst(self, status_item_id) orelse return "tray.action";
|
||||
for (status_item.items[0..status_item.item_count]) |item| {
|
||||
if (item.id == item_id and item.command.len > 0) return item.command;
|
||||
if (item.segmented) |segmented| {
|
||||
for (segmented.options) |option| {
|
||||
if (option.id == item_id) return option.command;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "tray.action";
|
||||
}
|
||||
@@ -480,6 +504,44 @@ fn storeTrayItems(status_item: anytype, items: []const platform.TrayMenuItem) !v
|
||||
status_item.items[index].role = item.role;
|
||||
status_item.items[index].key = try copyInto(&status_item.items[index].key_storage, item.key);
|
||||
status_item.items[index].modifiers = item.modifiers;
|
||||
status_item.items[index].segmented = null;
|
||||
status_item.items[index].segment_option_count = 0;
|
||||
if (item.segmented) |segmented| {
|
||||
for (segmented.options, 0..) |option, option_index| {
|
||||
const stored = &status_item.items[index].segment_options[option_index];
|
||||
stored.id = option.id;
|
||||
stored.label = try copyInto(&status_item.items[index].segment_option_label_storage[option_index], option.label);
|
||||
stored.command = try copyInto(&status_item.items[index].segment_option_command_storage[option_index], option.command);
|
||||
stored.selected = option.selected;
|
||||
stored.enabled = option.enabled;
|
||||
}
|
||||
status_item.items[index].segment_option_count = segmented.options.len;
|
||||
const stored = &status_item.items[index];
|
||||
stored.segmented = .{ .options = stored.segment_options[0..stored.segment_option_count] };
|
||||
}
|
||||
status_item.items[index].metric = null;
|
||||
if (item.metric) |metric| {
|
||||
status_item.items[index].metric = .{
|
||||
.primary_text = try copyInto(&status_item.items[index].metric_primary_storage, metric.primary_text),
|
||||
.secondary_text = try copyInto(&status_item.items[index].metric_secondary_storage, metric.secondary_text),
|
||||
.accessibility_label = try copyInto(&status_item.items[index].metric_accessibility_storage, metric.accessibility_label),
|
||||
};
|
||||
}
|
||||
status_item.items[index].chart = null;
|
||||
if (item.chart) |chart| {
|
||||
@memcpy(status_item.items[index].chart_values[0..chart.values.len], chart.values);
|
||||
const leading_caption = try copyInto(&status_item.items[index].chart_leading_caption_storage, chart.leading_caption);
|
||||
const trailing_summary = try copyInto(&status_item.items[index].chart_trailing_summary_storage, chart.trailing_summary);
|
||||
const accessibility_label = try copyInto(&status_item.items[index].chart_accessibility_label_storage, chart.accessibility_label);
|
||||
status_item.items[index].chart = .{
|
||||
.values = status_item.items[index].chart_values[0..chart.values.len],
|
||||
.min_value = chart.min_value,
|
||||
.max_value = chart.max_value,
|
||||
.leading_caption = leading_caption,
|
||||
.trailing_summary = trailing_summary,
|
||||
.accessibility_label = accessibility_label,
|
||||
};
|
||||
}
|
||||
}
|
||||
status_item.item_count = items.len;
|
||||
}
|
||||
|
||||
+325
-34
@@ -44,7 +44,8 @@
|
||||
//! the committed model. One model-helper convention joins that wiring:
|
||||
//! an exported `themePack(model): "house" | "geist"` helper selects the
|
||||
//! stock pack live through `theme_fn`, without taking ownership of the
|
||||
//! system appearance axes. An exported
|
||||
//! system appearance axes; `themeState(model)` subsumes it with scheme and
|
||||
//! accent axes through `theme_state_fn`. An exported
|
||||
//! `statusItem(model): StatusItemState` helper similarly owns one complete
|
||||
//! menu-bar item through `status_item_fn`; `statusItems(model)` owns a keyed
|
||||
//! collection through `status_items_fn`. Both keep shell, presentation, and
|
||||
@@ -80,6 +81,27 @@ const ts_core_host = @import("ts_core_host.zig");
|
||||
|
||||
const ts_ui_app_log = std.log.scoped(.zero_ts_ui_app);
|
||||
|
||||
/// Quota for a comptime scan of an app-authored type. TS `Msg` unions may
|
||||
/// legally carry 256 arms; include total identifier bytes because
|
||||
/// `std.mem.eql`'s comptime scalar path scales with the compared names.
|
||||
fn typeScanQuota(comptime T: type) u32 {
|
||||
const fields = switch (@typeInfo(T)) {
|
||||
.@"struct" => |info| info.fields,
|
||||
.@"union" => |info| info.fields,
|
||||
.@"enum" => |info| info.fields,
|
||||
else => return 2_000,
|
||||
};
|
||||
var name_bytes: u64 = 0;
|
||||
for (fields) |field| name_bytes += field.name.len;
|
||||
const quota: u64 = 100_000 + @as(u64, fields.len) * 1_024 + name_bytes * 256;
|
||||
return @intCast(@min(quota, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
fn scaledTypeScanQuota(comptime T: type, comptime scans: usize) u32 {
|
||||
const quota = @as(u64, typeScanQuota(T)) * @max(scans, 1);
|
||||
return @intCast(@min(quota, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
pub fn TsUiApp(comptime core: type) type {
|
||||
return struct {
|
||||
/// The effect bridge — shared with any direct `TsCoreHost(core)`
|
||||
@@ -92,6 +114,9 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
pub const Options = App.Options;
|
||||
pub const Effects = App.Effects;
|
||||
pub const Ui = App.Ui;
|
||||
/// Shared with generated launchers that perform their own Msg scans.
|
||||
pub const msg_scan_quota = typeScanQuota(Msg);
|
||||
pub const persist_route_scan_quota = scaledTypeScanQuota(Msg, 3);
|
||||
|
||||
/// Internal keyed-channel namespace for persistence write failures
|
||||
/// ("TSPR"). It never shares an app-authored TS bridge index.
|
||||
@@ -223,12 +248,14 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
/// a typo or payload mismatch fails during `native build`, before a
|
||||
/// first boot can reach the dynamic dispatch path below.
|
||||
pub fn validatePersistRoutes(comptime routes: PersistRoutes) void {
|
||||
@setEvalBranchQuota(persist_route_scan_quota);
|
||||
validatePersistRoute(routes.ok, void, "ok");
|
||||
validatePersistRoute(routes.none, void, "none");
|
||||
validatePersistRoute(routes.err, []const u8, "err");
|
||||
}
|
||||
|
||||
fn validatePersistRoute(comptime route: []const u8, comptime Payload: type, comptime role: []const u8) void {
|
||||
@setEvalBranchQuota(msg_scan_quota);
|
||||
inline for (@typeInfo(Msg).@"union".fields) |arm| {
|
||||
if (comptime std.mem.eql(u8, arm.name, route)) {
|
||||
if (arm.type != Payload) {
|
||||
@@ -379,12 +406,22 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
// external-core mirror. The app owns only the pack; UiApp's
|
||||
// stock-token path keeps following the OS appearance.
|
||||
if (comptime @hasDecl(Model, "themePack")) {
|
||||
if (comptime @hasDecl(Model, "themeState")) {
|
||||
@compileError("TsUiApp: export either themePack or themeState, not both");
|
||||
}
|
||||
if (options.theme_fn != null) {
|
||||
@panic("TsUiApp wires theme_fn from the core's themePack helper - remove the wiring's theme_fn");
|
||||
}
|
||||
comptime validateThemePackHelper();
|
||||
stamped.theme_fn = themePackAdapter;
|
||||
}
|
||||
if (comptime @hasDecl(Model, "themeState")) {
|
||||
if (options.theme_state_fn != null or options.theme_fn != null) {
|
||||
@panic("TsUiApp wires theme_state_fn from the core's themeState helper - remove the wiring's theme_state_fn/theme_fn");
|
||||
}
|
||||
comptime validateThemeStateHelper();
|
||||
stamped.theme_state_fn = themeStateAdapter;
|
||||
}
|
||||
// A statusItem helper is the TS app's model-derived shell
|
||||
// declaration. UiApp installs it on the first frame and
|
||||
// independently patches shell/presentation/menu after each
|
||||
@@ -503,6 +540,82 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
}
|
||||
|
||||
fn themeStateAdapter(model: *const Model) App.ThemeState {
|
||||
const params = @typeInfo(@TypeOf(Model.themeState)).@"fn".params;
|
||||
const raw_state = if (comptime params.len == 1)
|
||||
model.themeState()
|
||||
else
|
||||
model.themeState(core.rt.frameAllocator());
|
||||
const state = if (comptime @typeInfo(@TypeOf(raw_state)) == .pointer) raw_state.* else raw_state;
|
||||
const accent = if (state.accent) |value| parseThemeAccent(value) else null;
|
||||
return .{
|
||||
.pack = if (state.pack) |pack| canvas.ThemePack.fromName(@tagName(pack)).? else null,
|
||||
.color_scheme = if (state.colorScheme) |scheme| themeColorScheme(scheme) else .system,
|
||||
.accent = accent,
|
||||
.invalid_accent = if (state.accent != null and accent == null) state.accent else null,
|
||||
};
|
||||
}
|
||||
|
||||
fn themeColorScheme(value: anytype) App.ThemeColorScheme {
|
||||
const name = @tagName(value);
|
||||
inline for (std.meta.fields(App.ThemeColorScheme)) |field| {
|
||||
if (std.mem.eql(u8, name, field.name)) return @enumFromInt(field.value);
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
|
||||
fn parseThemeAccent(value: []const u8) ?canvas.Color {
|
||||
if (value.len != 7 or value[0] != '#') return null;
|
||||
const r = themeHexByte(value[1], value[2]) orelse return null;
|
||||
const g = themeHexByte(value[3], value[4]) orelse return null;
|
||||
const b = themeHexByte(value[5], value[6]) orelse return null;
|
||||
return canvas.Color.rgb8(r, g, b);
|
||||
}
|
||||
|
||||
fn themeHexByte(hi: u8, lo: u8) ?u8 {
|
||||
const h = themeHexNibble(hi) orelse return null;
|
||||
const l = themeHexNibble(lo) orelse return null;
|
||||
return h * 16 + l;
|
||||
}
|
||||
|
||||
fn themeHexNibble(byte: u8) ?u8 {
|
||||
return switch (byte) {
|
||||
'0'...'9' => byte - '0',
|
||||
'a'...'f' => byte - 'a' + 10,
|
||||
'A'...'F' => byte - 'A' + 10,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn validateThemeStateHelper() void {
|
||||
const teaching = "TsUiApp: themeState must be exported from core.ts as themeState(model: Model): ThemeState; import ThemeState from @native-sdk/core/events";
|
||||
const helper_info = @typeInfo(@TypeOf(Model.themeState));
|
||||
if (helper_info != .@"fn") @compileError(teaching);
|
||||
const function = helper_info.@"fn";
|
||||
if ((function.params.len != 1 and function.params.len != 2) or function.params[0].type == null or function.params[0].type.? != *const Model) {
|
||||
@compileError(teaching);
|
||||
}
|
||||
if (function.params.len == 2) {
|
||||
if (function.params[1].type == null or function.params[1].type.? != std.mem.Allocator or
|
||||
!@hasDecl(core, "rt") or !@hasDecl(core.rt, "frameAllocator"))
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
}
|
||||
const RawState = function.return_type orelse @compileError(teaching);
|
||||
const State = statusItemRecordType(RawState, teaching);
|
||||
const info = @typeInfo(State).@"struct";
|
||||
if (info.fields.len != 3 or !@hasField(State, "pack") or !@hasField(State, "colorScheme") or !@hasField(State, "accent")) {
|
||||
@compileError(teaching);
|
||||
}
|
||||
if (!optionalEnumType(@FieldType(State, "pack"), &.{ "house", "geist" }) or
|
||||
!optionalEnumType(@FieldType(State, "colorScheme"), &.{ "light", "dark", "system" }) or
|
||||
@FieldType(State, "accent") != ?[]const u8)
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the compiled core's canonical status-item records into
|
||||
/// the platform rows UiApp already knows how to validate, copy,
|
||||
/// hash, install, and patch. Interface records cross the core ABI
|
||||
@@ -525,23 +638,13 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
for (state.items, 0..) |raw_item, index| {
|
||||
const item = if (comptime @typeInfo(@TypeOf(raw_item)) == .pointer) raw_item.* else raw_item;
|
||||
scratch.items[index] = .{
|
||||
.id = statusItemId(item.id),
|
||||
.label = item.label,
|
||||
.command = item.command,
|
||||
.separator = item.separator,
|
||||
.enabled = item.enabled,
|
||||
.detail = item.detail,
|
||||
.role = statusItemRole(item.role),
|
||||
.key = item.key,
|
||||
.modifiers = .{
|
||||
.primary = item.modifiers.primary,
|
||||
.command = item.modifiers.command,
|
||||
.control = item.modifiers.control,
|
||||
.option = item.modifiers.option,
|
||||
.shift = item.modifiers.shift,
|
||||
},
|
||||
};
|
||||
const segment_start = index * platform.max_tray_segment_options;
|
||||
const chart_start = index * platform.max_tray_chart_values;
|
||||
scratch.items[index] = statusItemMenuItem(
|
||||
item,
|
||||
scratch.segment_options[segment_start .. segment_start + platform.max_tray_segment_options],
|
||||
scratch.chart_values[chart_start .. chart_start + platform.max_tray_chart_values],
|
||||
);
|
||||
}
|
||||
return statusItemState(state, scratch.items[0..state.items.len]);
|
||||
}
|
||||
@@ -571,7 +674,14 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
for (state.items, 0..) |raw_item, item_index| {
|
||||
const item = if (comptime @typeInfo(@TypeOf(raw_item)) == .pointer) raw_item.* else raw_item;
|
||||
row_storage[item_index] = statusItemMenuItem(item);
|
||||
const flat_row_index = row_start + item_index;
|
||||
const segment_start = flat_row_index * platform.max_tray_segment_options;
|
||||
const chart_start = flat_row_index * platform.max_tray_chart_values;
|
||||
row_storage[item_index] = statusItemMenuItem(
|
||||
item,
|
||||
scratch.segment_options[segment_start .. segment_start + platform.max_tray_segment_options],
|
||||
scratch.chart_values[chart_start .. chart_start + platform.max_tray_chart_values],
|
||||
);
|
||||
}
|
||||
scratch.status_items[status_index] = .{
|
||||
.id = statusItemId(state.id),
|
||||
@@ -668,8 +778,8 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
};
|
||||
}
|
||||
|
||||
fn statusItemMenuItem(item: anytype) platform.TrayMenuItem {
|
||||
return .{
|
||||
fn statusItemMenuItem(item: anytype, segment_storage: []platform.TraySegmentOption, chart_storage: []f32) platform.TrayMenuItem {
|
||||
var result = platform.TrayMenuItem{
|
||||
.id = statusItemId(item.id),
|
||||
.label = item.label,
|
||||
.command = item.command,
|
||||
@@ -686,6 +796,49 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
.shift = item.modifiers.shift,
|
||||
},
|
||||
};
|
||||
if (@hasField(@TypeOf(item), "segmented")) if (item.segmented) |raw_segmented| {
|
||||
const segmented = if (comptime @typeInfo(@TypeOf(raw_segmented)) == .pointer) raw_segmented.* else raw_segmented;
|
||||
if (segmented.options.len <= segment_storage.len) {
|
||||
for (segmented.options, 0..) |raw_option, index| {
|
||||
const option = if (comptime @typeInfo(@TypeOf(raw_option)) == .pointer) raw_option.* else raw_option;
|
||||
segment_storage[index] = .{
|
||||
.id = statusItemId(option.id),
|
||||
.label = option.label,
|
||||
.command = option.command,
|
||||
.selected = option.selected,
|
||||
.enabled = option.enabled,
|
||||
};
|
||||
}
|
||||
result.segmented = .{ .options = segment_storage[0..segmented.options.len] };
|
||||
} else {
|
||||
result.segmented = .{};
|
||||
}
|
||||
};
|
||||
if (@hasField(@TypeOf(item), "metric")) if (item.metric) |raw_metric| {
|
||||
const metric = if (comptime @typeInfo(@TypeOf(raw_metric)) == .pointer) raw_metric.* else raw_metric;
|
||||
result.metric = .{
|
||||
.primary_text = metric.primaryText,
|
||||
.secondary_text = metric.secondaryText,
|
||||
.accessibility_label = metric.accessibilityLabel,
|
||||
};
|
||||
};
|
||||
if (@hasField(@TypeOf(item), "chart")) if (item.chart) |raw_chart| {
|
||||
const chart = if (comptime @typeInfo(@TypeOf(raw_chart)) == .pointer) raw_chart.* else raw_chart;
|
||||
if (chart.values.len <= chart_storage.len) {
|
||||
for (chart.values, 0..) |value, index| chart_storage[index] = statusItemFloat(value);
|
||||
result.chart = .{
|
||||
.values = chart_storage[0..chart.values.len],
|
||||
.min_value = statusItemFloat(chart.minValue),
|
||||
.max_value = statusItemFloat(chart.maxValue),
|
||||
.leading_caption = chart.leadingCaption,
|
||||
.trailing_summary = chart.trailingSummary,
|
||||
.accessibility_label = chart.accessibilityLabel,
|
||||
};
|
||||
} else {
|
||||
result.chart = .{};
|
||||
}
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
fn statusItemState(state: anytype, items: []const platform.TrayMenuItem) App.StatusItemState {
|
||||
@@ -697,6 +850,8 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
.tone = statusItemTone(presentation.tone),
|
||||
.icon_opacity = statusItemFloat(presentation.iconOpacity),
|
||||
.monospaced = presentation.monospaced,
|
||||
.font_size = if (presentation.fontSize) |value| statusItemFloat(value) else 0,
|
||||
.font_weight = if (presentation.fontWeight) |value| statusItemFontWeight(value) else .regular,
|
||||
},
|
||||
.icon_path = state.iconPath,
|
||||
.tooltip = state.tooltip,
|
||||
@@ -737,6 +892,14 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
unreachable;
|
||||
}
|
||||
|
||||
fn statusItemFontWeight(value: anytype) platform.TrayFontWeight {
|
||||
const name = @tagName(value);
|
||||
inline for (std.meta.fields(platform.TrayFontWeight)) |field| {
|
||||
if (std.mem.eql(u8, name, field.name)) return @enumFromInt(field.value);
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
|
||||
fn statusItemRole(value: anytype) platform.TrayItemRole {
|
||||
const name = @tagName(value);
|
||||
inline for (std.meta.fields(platform.TrayItemRole)) |field| {
|
||||
@@ -780,14 +943,17 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
const Presentation = statusItemRecordType(@FieldType(State, "presentation"), teaching);
|
||||
const presentation_info = @typeInfo(Presentation).@"struct";
|
||||
if (presentation_info.fields.len != 5 or !@hasField(Presentation, "title") or !@hasField(Presentation, "width") or
|
||||
!@hasField(Presentation, "tone") or !@hasField(Presentation, "iconOpacity") or !@hasField(Presentation, "monospaced"))
|
||||
if (presentation_info.fields.len != 7 or !@hasField(Presentation, "title") or !@hasField(Presentation, "width") or
|
||||
!@hasField(Presentation, "tone") or !@hasField(Presentation, "iconOpacity") or !@hasField(Presentation, "monospaced") or
|
||||
!@hasField(Presentation, "fontSize") or !@hasField(Presentation, "fontWeight"))
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
if (@FieldType(Presentation, "title") != []const u8 or !statusItemNumericType(@FieldType(Presentation, "width")) or
|
||||
!statusItemEnumType(@FieldType(Presentation, "tone"), &.{ "normal", "warning", "critical" }) or
|
||||
!statusItemNumericType(@FieldType(Presentation, "iconOpacity")) or @FieldType(Presentation, "monospaced") != bool)
|
||||
!statusItemNumericType(@FieldType(Presentation, "iconOpacity")) or @FieldType(Presentation, "monospaced") != bool or
|
||||
!optionalNumericType(@FieldType(Presentation, "fontSize")) or
|
||||
!optionalEnumType(@FieldType(Presentation, "fontWeight"), &.{ "regular", "medium", "semibold", "bold" }))
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
@@ -795,9 +961,10 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
if (items_info != .pointer or items_info.pointer.size != .slice or !items_info.pointer.is_const) @compileError(teaching);
|
||||
const Item = statusItemRecordType(items_info.pointer.child, teaching);
|
||||
const item_info = @typeInfo(Item).@"struct";
|
||||
if (item_info.fields.len != 9 or !@hasField(Item, "id") or !@hasField(Item, "label") or
|
||||
if (item_info.fields.len != 12 or !@hasField(Item, "id") or !@hasField(Item, "label") or
|
||||
!@hasField(Item, "command") or !@hasField(Item, "separator") or !@hasField(Item, "enabled") or
|
||||
!@hasField(Item, "detail") or !@hasField(Item, "role") or !@hasField(Item, "key") or !@hasField(Item, "modifiers"))
|
||||
!@hasField(Item, "detail") or !@hasField(Item, "role") or !@hasField(Item, "key") or !@hasField(Item, "modifiers") or
|
||||
!@hasField(Item, "segmented") or !@hasField(Item, "metric") or !@hasField(Item, "chart"))
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
@@ -805,7 +972,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
if (@FieldType(Item, "label") != []const u8 or @FieldType(Item, "command") != []const u8 or
|
||||
@FieldType(Item, "separator") != bool or @FieldType(Item, "enabled") != bool or
|
||||
@FieldType(Item, "detail") != []const u8 or
|
||||
!statusItemEnumType(@FieldType(Item, "role"), &.{ "command", "info", "header", "hero", "agent", "context" }) or
|
||||
!statusItemEnumType(@FieldType(Item, "role"), &.{ "command", "info", "header", "hero", "agent", "context", "segmented", "chart" }) or
|
||||
@FieldType(Item, "key") != []const u8)
|
||||
{
|
||||
@compileError(teaching);
|
||||
@@ -819,6 +986,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
validateStatusItemRichTypes(Item, teaching);
|
||||
}
|
||||
|
||||
fn validateStatusItemsHelper() void {
|
||||
@@ -854,11 +1022,14 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
const Presentation = statusItemRecordType(@FieldType(State, "presentation"), teaching);
|
||||
const presentation_info = @typeInfo(Presentation).@"struct";
|
||||
if (presentation_info.fields.len != 5 or !@hasField(Presentation, "title") or !@hasField(Presentation, "width") or
|
||||
if (presentation_info.fields.len != 7 or !@hasField(Presentation, "title") or !@hasField(Presentation, "width") or
|
||||
!@hasField(Presentation, "tone") or !@hasField(Presentation, "iconOpacity") or !@hasField(Presentation, "monospaced") or
|
||||
!@hasField(Presentation, "fontSize") or !@hasField(Presentation, "fontWeight") or
|
||||
@FieldType(Presentation, "title") != []const u8 or !statusItemNumericType(@FieldType(Presentation, "width")) or
|
||||
!statusItemEnumType(@FieldType(Presentation, "tone"), &.{ "normal", "warning", "critical" }) or
|
||||
!statusItemNumericType(@FieldType(Presentation, "iconOpacity")) or @FieldType(Presentation, "monospaced") != bool)
|
||||
!statusItemNumericType(@FieldType(Presentation, "iconOpacity")) or @FieldType(Presentation, "monospaced") != bool or
|
||||
!optionalNumericType(@FieldType(Presentation, "fontSize")) or
|
||||
!optionalEnumType(@FieldType(Presentation, "fontWeight"), &.{ "regular", "medium", "semibold", "bold" }))
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
@@ -866,18 +1037,20 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
if (items_info != .pointer or items_info.pointer.size != .slice or !items_info.pointer.is_const) @compileError(teaching);
|
||||
const Item = statusItemRecordType(items_info.pointer.child, teaching);
|
||||
const item_info = @typeInfo(Item).@"struct";
|
||||
if (item_info.fields.len != 9 or !@hasField(Item, "id") or !@hasField(Item, "label") or
|
||||
if (item_info.fields.len != 12 or !@hasField(Item, "id") or !@hasField(Item, "label") or
|
||||
!@hasField(Item, "command") or !@hasField(Item, "separator") or !@hasField(Item, "enabled") or
|
||||
!@hasField(Item, "detail") or !@hasField(Item, "role") or !@hasField(Item, "key") or
|
||||
!@hasField(Item, "modifiers") or !statusItemNumericType(@FieldType(Item, "id")) or
|
||||
!@hasField(Item, "modifiers") or !@hasField(Item, "segmented") or !@hasField(Item, "metric") or !@hasField(Item, "chart") or
|
||||
!statusItemNumericType(@FieldType(Item, "id")) or
|
||||
@FieldType(Item, "label") != []const u8 or @FieldType(Item, "command") != []const u8 or
|
||||
@FieldType(Item, "separator") != bool or @FieldType(Item, "enabled") != bool or
|
||||
@FieldType(Item, "detail") != []const u8 or
|
||||
!statusItemEnumType(@FieldType(Item, "role"), &.{ "command", "info", "header", "hero", "agent", "context" }) or
|
||||
!statusItemEnumType(@FieldType(Item, "role"), &.{ "command", "info", "header", "hero", "agent", "context", "segmented", "chart" }) or
|
||||
@FieldType(Item, "key") != []const u8)
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
validateStatusItemRichTypes(Item, teaching);
|
||||
const Modifiers = statusItemRecordType(@FieldType(Item, "modifiers"), teaching);
|
||||
const modifiers_info = @typeInfo(Modifiers).@"struct";
|
||||
if (modifiers_info.fields.len != 5 or !@hasField(Modifiers, "primary") or !@hasField(Modifiers, "command") or
|
||||
@@ -930,6 +1103,48 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
return info == .optional and statusItemNumericType(info.optional.child);
|
||||
}
|
||||
|
||||
fn optionalEnumType(comptime T: type, comptime expected: []const []const u8) bool {
|
||||
const info = @typeInfo(T);
|
||||
return info == .optional and statusItemEnumType(info.optional.child, expected);
|
||||
}
|
||||
|
||||
fn validateStatusItemRichTypes(comptime Item: type, comptime teaching: []const u8) void {
|
||||
const segmented_info = @typeInfo(@FieldType(Item, "segmented"));
|
||||
const metric_info = @typeInfo(@FieldType(Item, "metric"));
|
||||
const chart_info = @typeInfo(@FieldType(Item, "chart"));
|
||||
if (segmented_info != .optional or metric_info != .optional or chart_info != .optional) @compileError(teaching);
|
||||
|
||||
const Segmented = statusItemRecordType(segmented_info.optional.child, teaching);
|
||||
const segmented_fields = @typeInfo(Segmented).@"struct".fields;
|
||||
if (segmented_fields.len != 1 or !@hasField(Segmented, "options")) @compileError(teaching);
|
||||
const options_info = @typeInfo(@FieldType(Segmented, "options"));
|
||||
if (options_info != .pointer or options_info.pointer.size != .slice or !options_info.pointer.is_const) @compileError(teaching);
|
||||
const Option = statusItemRecordType(options_info.pointer.child, teaching);
|
||||
const option_info = @typeInfo(Option).@"struct";
|
||||
if (option_info.fields.len != 5 or !@hasField(Option, "id") or !@hasField(Option, "label") or
|
||||
!@hasField(Option, "command") or !@hasField(Option, "selected") or !@hasField(Option, "enabled") or
|
||||
!statusItemNumericType(@FieldType(Option, "id")) or @FieldType(Option, "label") != []const u8 or
|
||||
@FieldType(Option, "command") != []const u8 or @FieldType(Option, "selected") != bool or
|
||||
@FieldType(Option, "enabled") != bool) @compileError(teaching);
|
||||
|
||||
const Metric = statusItemRecordType(metric_info.optional.child, teaching);
|
||||
const metric_fields = @typeInfo(Metric).@"struct".fields;
|
||||
if (metric_fields.len != 3 or !@hasField(Metric, "primaryText") or !@hasField(Metric, "secondaryText") or
|
||||
!@hasField(Metric, "accessibilityLabel") or @FieldType(Metric, "primaryText") != []const u8 or
|
||||
@FieldType(Metric, "secondaryText") != []const u8 or @FieldType(Metric, "accessibilityLabel") != []const u8) @compileError(teaching);
|
||||
|
||||
const Chart = statusItemRecordType(chart_info.optional.child, teaching);
|
||||
const chart_fields = @typeInfo(Chart).@"struct".fields;
|
||||
if (chart_fields.len != 6 or !@hasField(Chart, "values") or !@hasField(Chart, "minValue") or
|
||||
!@hasField(Chart, "maxValue") or !@hasField(Chart, "leadingCaption") or
|
||||
!@hasField(Chart, "trailingSummary") or !@hasField(Chart, "accessibilityLabel")) @compileError(teaching);
|
||||
const values_info = @typeInfo(@FieldType(Chart, "values"));
|
||||
if (values_info != .pointer or values_info.pointer.size != .slice or !values_info.pointer.is_const or
|
||||
!statusItemNumericType(values_info.pointer.child) or !statusItemNumericType(@FieldType(Chart, "minValue")) or
|
||||
!statusItemNumericType(@FieldType(Chart, "maxValue")) or @FieldType(Chart, "leadingCaption") != []const u8 or
|
||||
@FieldType(Chart, "trailingSummary") != []const u8 or @FieldType(Chart, "accessibilityLabel") != []const u8) @compileError(teaching);
|
||||
}
|
||||
|
||||
fn statusItemNumericType(comptime T: type) bool {
|
||||
return @typeInfo(T) == .int or @typeInfo(T) == .float;
|
||||
}
|
||||
@@ -1029,6 +1244,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
|
||||
fn persistOutcomeMsg(event: runtime_effects.EffectChannelEvent) Msg {
|
||||
@setEvalBranchQuota(msg_scan_quota);
|
||||
const reason: []const u8 = switch (event.kind) {
|
||||
.data => event.bytes,
|
||||
.rejected => "rejected",
|
||||
@@ -1044,6 +1260,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
|
||||
fn dispatchPersistVoid(fx: *Effects, route: []const u8) void {
|
||||
@setEvalBranchQuota(msg_scan_quota);
|
||||
inline for (@typeInfo(Msg).@"union".fields) |arm| {
|
||||
if (comptime arm.type == void) {
|
||||
if (std.mem.eql(u8, arm.name, route)) {
|
||||
@@ -1056,6 +1273,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
}
|
||||
|
||||
fn dispatchPersistError(fx: *Effects, route: []const u8, reason: []const u8) void {
|
||||
@setEvalBranchQuota(msg_scan_quota);
|
||||
inline for (@typeInfo(Msg).@"union".fields) |arm| {
|
||||
if (comptime arm.type == []const u8) {
|
||||
if (std.mem.eql(u8, arm.name, route)) {
|
||||
@@ -1098,6 +1316,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
/// One env delivery: resolve the arm by name and dispatch the
|
||||
/// value through a full core cycle.
|
||||
fn dispatchOneEnvValue(fx: *Effects, msg: []const u8, value: []const u8) void {
|
||||
@setEvalBranchQuota(msg_scan_quota);
|
||||
inline for (@typeInfo(Msg).@"union".fields) |arm| {
|
||||
if (comptime arm.type == []const u8) {
|
||||
if (std.mem.eql(u8, arm.name, msg)) {
|
||||
@@ -1117,6 +1336,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
/// hand-assembled cores: every `envMsgs` entry must name a Msg
|
||||
/// arm carrying exactly one bytes payload.
|
||||
fn validateEnvMsgs() void {
|
||||
@setEvalBranchQuota(scaledTypeScanQuota(Msg, core.envMsgs.len));
|
||||
for (core.envMsgs) |entry| {
|
||||
var found = false;
|
||||
for (@typeInfo(Msg).@"union".fields) |arm| {
|
||||
@@ -1333,6 +1553,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
/// error the frontend's NS1033 re-derives for hand-written
|
||||
/// cores.
|
||||
fn channelArmIndex(comptime tag: []const u8, comptime channel: []const u8) usize {
|
||||
@setEvalBranchQuota(msg_scan_quota);
|
||||
for (@typeInfo(Msg).@"union".fields, 0..) |arm, index| {
|
||||
if (std.mem.eql(u8, arm.name, tag)) return index;
|
||||
}
|
||||
@@ -1426,7 +1647,7 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
|
||||
const StatusItemsAdapterTestCore = struct {
|
||||
const Tone = enum { normal, warning, critical };
|
||||
const Role = enum { command, info, header, hero, agent, context };
|
||||
const Role = enum { command, info, header, hero, agent, context, segmented, chart };
|
||||
const Modifiers = struct {
|
||||
primary: bool,
|
||||
command: bool,
|
||||
@@ -1440,6 +1661,29 @@ const StatusItemsAdapterTestCore = struct {
|
||||
tone: Tone,
|
||||
iconOpacity: f64,
|
||||
monospaced: bool,
|
||||
fontSize: ?f64,
|
||||
fontWeight: ?enum { regular, medium, semibold, bold },
|
||||
};
|
||||
const SegmentOption = struct {
|
||||
id: f64,
|
||||
label: []const u8,
|
||||
command: []const u8,
|
||||
selected: bool,
|
||||
enabled: bool,
|
||||
};
|
||||
const Segmented = struct { options: []const SegmentOption };
|
||||
const Metric = struct {
|
||||
primaryText: []const u8,
|
||||
secondaryText: []const u8,
|
||||
accessibilityLabel: []const u8,
|
||||
};
|
||||
const Chart = struct {
|
||||
values: []const f64,
|
||||
minValue: f64,
|
||||
maxValue: f64,
|
||||
leadingCaption: []const u8,
|
||||
trailingSummary: []const u8,
|
||||
accessibilityLabel: []const u8,
|
||||
};
|
||||
const Item = struct {
|
||||
id: f64,
|
||||
@@ -1451,6 +1695,9 @@ const StatusItemsAdapterTestCore = struct {
|
||||
role: Role,
|
||||
key: []const u8,
|
||||
modifiers: Modifiers,
|
||||
segmented: ?Segmented,
|
||||
metric: ?Metric,
|
||||
chart: ?Chart,
|
||||
};
|
||||
const Descriptor = struct {
|
||||
id: f64,
|
||||
@@ -1474,6 +1721,9 @@ const StatusItemsAdapterTestCore = struct {
|
||||
.role = .command,
|
||||
.key = "r",
|
||||
.modifiers = .{ .primary = true, .command = false, .control = false, .option = false, .shift = false },
|
||||
.segmented = null,
|
||||
.metric = null,
|
||||
.chart = null,
|
||||
}};
|
||||
const descriptors = [_]Descriptor{.{
|
||||
.id = 7,
|
||||
@@ -1483,7 +1733,7 @@ const StatusItemsAdapterTestCore = struct {
|
||||
.activationCommand = "spend.open",
|
||||
.alternateActivationCommand = "",
|
||||
.openCommand = "spend.refresh",
|
||||
.presentation = .{ .title = "$7", .width = 52, .tone = .warning, .iconOpacity = 0.75, .monospaced = true },
|
||||
.presentation = .{ .title = "$7", .width = 52, .tone = .warning, .iconOpacity = 0.75, .monospaced = true, .fontSize = null, .fontWeight = null },
|
||||
.items = &rows,
|
||||
}};
|
||||
|
||||
@@ -1505,6 +1755,8 @@ test "TypeScript statusItems adapter validates and projects canonical descriptor
|
||||
try std.testing.expectEqual(@as(platform.StatusItemId, 7), descriptors[0].id);
|
||||
try std.testing.expect(!descriptors[0].visible);
|
||||
try std.testing.expectEqualStrings("$7", descriptors[0].state.presentation.title);
|
||||
try std.testing.expectEqual(@as(f32, 0), descriptors[0].state.presentation.font_size);
|
||||
try std.testing.expectEqual(platform.TrayFontWeight.regular, descriptors[0].state.presentation.font_weight);
|
||||
try std.testing.expectEqualStrings("spend.png", descriptors[0].state.icon_path);
|
||||
try std.testing.expectEqual(@as(usize, 1), descriptors[0].state.items.len);
|
||||
try std.testing.expectEqual(@as(platform.TrayItemId, 3), descriptors[0].state.items[0].id);
|
||||
@@ -1551,6 +1803,45 @@ const WindowsAdapterTestCore = struct {
|
||||
};
|
||||
};
|
||||
|
||||
const ThemeStateAdapterTestCore = struct {
|
||||
const Pack = enum { house, geist };
|
||||
const Scheme = enum { light, dark, system };
|
||||
const State = struct {
|
||||
pack: ?Pack,
|
||||
colorScheme: ?Scheme,
|
||||
accent: ?[]const u8,
|
||||
};
|
||||
|
||||
pub const Msg = union(enum) { noop };
|
||||
pub const Model = struct {
|
||||
bad: bool = false,
|
||||
|
||||
pub fn themeState(self: *const Model) State {
|
||||
return .{
|
||||
.pack = .geist,
|
||||
.colorScheme = .dark,
|
||||
.accent = if (self.bad) "hot-pink" else "#Df2670",
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
test "TypeScript themeState adapter validates, parses hex, and preserves malformed accent teaching" {
|
||||
const Adapter = TsUiApp(ThemeStateAdapterTestCore);
|
||||
comptime Adapter.validateThemeStateHelper();
|
||||
var model = ThemeStateAdapterTestCore.Model{};
|
||||
var state = Adapter.themeStateAdapter(&model);
|
||||
try std.testing.expectEqual(canvas.ThemePack.geist, state.pack.?);
|
||||
try std.testing.expectEqual(Adapter.App.ThemeColorScheme.dark, state.color_scheme);
|
||||
try std.testing.expectEqual(canvas.Color.rgb8(0xdf, 0x26, 0x70), state.accent.?);
|
||||
try std.testing.expect(state.invalid_accent == null);
|
||||
|
||||
model.bad = true;
|
||||
state = Adapter.themeStateAdapter(&model);
|
||||
try std.testing.expect(state.accent == null);
|
||||
try std.testing.expectEqualStrings("hot-pink", state.invalid_accent.?);
|
||||
}
|
||||
|
||||
test "TypeScript windows adapter keeps the declared prefix on overflow and projects chromeless" {
|
||||
const Adapter = TsUiApp(WindowsAdapterTestCore);
|
||||
comptime Adapter.validateWindowsHelper();
|
||||
|
||||
+124
-19
@@ -281,6 +281,23 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
items: []const platform.TrayMenuItem = &.{},
|
||||
};
|
||||
|
||||
/// The stock-theme axes a model may own without replacing the full
|
||||
/// DesignTokens register. `system` follows the platform's live color
|
||||
/// scheme; null pack/accent values inherit the manifest-backed
|
||||
/// Options fields. Native chrome and WebViews remain OS-themed — this
|
||||
/// state controls canvas tokens only.
|
||||
pub const ThemeColorScheme = enum { system, light, dark };
|
||||
|
||||
pub const ThemeState = struct {
|
||||
pack: ?canvas.ThemePack = null,
|
||||
color_scheme: ThemeColorScheme = .system,
|
||||
accent: ?canvas.Color = null,
|
||||
/// Adapter-only invalid declaration marker. Zig cores already
|
||||
/// pass a typed Color; the TS adapter retains malformed source
|
||||
/// text here so rebuild rejects it instead of silently inheriting.
|
||||
invalid_accent: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// One entry in the model-declared status-item collection.
|
||||
/// `id` is stable identity; dropping the entry removes that
|
||||
/// native status item, while every other field patches in place.
|
||||
@@ -299,6 +316,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
title_buffer: [platform.max_tray_title_bytes]u8 = undefined,
|
||||
arena_buffer: [2048]u8 = undefined,
|
||||
items: [platform.max_tray_items]platform.TrayMenuItem = undefined,
|
||||
segment_options: [platform.max_tray_items * platform.max_tray_segment_options]platform.TraySegmentOption = undefined,
|
||||
chart_values: [platform.max_tray_items * platform.max_tray_chart_values]f32 = undefined,
|
||||
};
|
||||
|
||||
/// Scratch for `status_items_fn`. The flat row store gives each
|
||||
@@ -309,6 +328,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
title_buffers: [platform.max_status_items][platform.max_tray_title_bytes]u8 = undefined,
|
||||
arena_buffer: [platform.max_status_items * 2048]u8 = undefined,
|
||||
items: [platform.max_status_items * platform.max_tray_items]platform.TrayMenuItem = undefined,
|
||||
segment_options: [platform.max_status_items * platform.max_tray_items * platform.max_tray_segment_options]platform.TraySegmentOption = undefined,
|
||||
chart_values: [platform.max_status_items * platform.max_tray_items * platform.max_tray_chart_values]f32 = undefined,
|
||||
};
|
||||
|
||||
const AppliedStatusItem = struct {
|
||||
@@ -485,6 +506,11 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
/// `tokens_fn` still take precedence because those paths own
|
||||
/// the complete token register.
|
||||
theme_fn: ?*const fn (model: *const ModelT) canvas.ThemePack = null,
|
||||
/// Cohesive model-derived stock-theme state: built-in pack,
|
||||
/// canvas color scheme, and accent. This subsumes `theme_fn` and
|
||||
/// is mutually exclusive with it. Explicit `tokens_fn`/`tokens`
|
||||
/// still own the complete register and take precedence.
|
||||
theme_state_fn: ?*const fn (model: *const ModelT) ThemeState = null,
|
||||
/// The app's ONE-accent brand statement over the stock
|
||||
/// tokens: when set (and the app claims neither `tokens`
|
||||
/// nor `tokens_fn` — apps that own their tokens own their
|
||||
@@ -962,6 +988,10 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
/// light/dark setting live. Test/null platforms never emit it,
|
||||
/// so deterministic runs stay on the default light theme.
|
||||
system_appearance: platform.Appearance = .{},
|
||||
/// Last valid model-derived state. Re-derived before each rebuild;
|
||||
/// equality is a handful of scalar/optional fields.
|
||||
theme_state: ThemeState = .{},
|
||||
theme_state_known: bool = false,
|
||||
pixel_snap_scale: f32 = 1,
|
||||
frame_timestamp_ns: u64 = 0,
|
||||
markup_arenas: [2]std.heap.ArenaAllocator,
|
||||
@@ -1397,6 +1427,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
std.debug.assert((options.update != null) != (options.update_fx != null));
|
||||
// Declared windows need the per-window view to build them.
|
||||
std.debug.assert(options.windows_fn == null or options.window_view != null);
|
||||
std.debug.assert(options.theme_fn == null or options.theme_state_fn == null);
|
||||
if (comptime !features.runtime_markup) std.debug.assert(options.markup == null);
|
||||
}
|
||||
|
||||
@@ -1883,32 +1914,66 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
tokens.pixel_snap.scale = self.pixel_snap_scale;
|
||||
return tokens;
|
||||
}
|
||||
var tokens = canvas.DesignTokens.theme(.{
|
||||
.color_scheme = switch (self.system_appearance.color_scheme) {
|
||||
const state = self.currentThemeState();
|
||||
const color_scheme: canvas.ColorScheme = switch (state.color_scheme) {
|
||||
.system => switch (self.system_appearance.color_scheme) {
|
||||
.light => .light,
|
||||
.dark => .dark,
|
||||
},
|
||||
.light => .light,
|
||||
.dark => .dark,
|
||||
};
|
||||
var tokens = canvas.DesignTokens.theme(.{
|
||||
.color_scheme = color_scheme,
|
||||
.contrast = if (self.system_appearance.high_contrast) .high else .standard,
|
||||
.reduce_motion = self.system_appearance.reduce_motion,
|
||||
.pack = if (self.options.theme_fn) |theme_fn| theme_fn(&self.model) else self.options.theme,
|
||||
.pack = state.pack orelse if (self.options.theme_fn) |theme_fn| theme_fn(&self.model) else self.options.theme,
|
||||
});
|
||||
if (self.options.theme_accent) |accent| {
|
||||
if (state.accent orelse self.options.theme_accent) |accent| {
|
||||
// The manifest accent layers over the resolved pack —
|
||||
// except under high contrast, where the pack's own loud
|
||||
// register wins untouched (accessibility beats brand).
|
||||
// The bundle takes the resolved scheme: the dark ring
|
||||
// derives desaturated (canvas.accentFocusRing).
|
||||
if (!self.system_appearance.high_contrast) {
|
||||
tokens = tokens.withOverrides(canvas.accentOverrides(accent, switch (self.system_appearance.color_scheme) {
|
||||
.light => .light,
|
||||
.dark => .dark,
|
||||
}));
|
||||
tokens = tokens.withOverrides(canvas.accentOverrides(accent, color_scheme));
|
||||
}
|
||||
}
|
||||
tokens.pixel_snap.scale = self.pixel_snap_scale;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
fn currentThemeState(self: *const Self) ThemeState {
|
||||
if (self.theme_state_known) return self.theme_state;
|
||||
return if (self.options.theme_state_fn) |theme_state_fn| theme_state_fn(&self.model) else .{};
|
||||
}
|
||||
|
||||
/// Re-derive once per rebuild and reject malformed adapter input at
|
||||
/// the declaration boundary. A valid retained state contains no
|
||||
/// borrowed accent text, so it remains safe between dispatches.
|
||||
fn refreshThemeState(self: *Self) error{InvalidThemeAccent}!void {
|
||||
// Complete-token paths outrank the stock-theme helper entirely.
|
||||
// Do not even validate an unused model accent: the app has
|
||||
// explicitly claimed the whole DesignTokens register.
|
||||
if (self.options.tokens_fn != null or self.options.tokens != null) {
|
||||
self.theme_state = .{};
|
||||
self.theme_state_known = true;
|
||||
return;
|
||||
}
|
||||
const next = if (self.options.theme_state_fn) |theme_state_fn| theme_state_fn(&self.model) else ThemeState{};
|
||||
if (next.invalid_accent) |accent| {
|
||||
ui_app_log.warn(
|
||||
"themeState(model) returned accent '{s}'; expected exactly #rrggbb (six hexadecimal digits)",
|
||||
.{accent},
|
||||
);
|
||||
return error.InvalidThemeAccent;
|
||||
}
|
||||
if (!self.theme_state_known or !std.meta.eql(self.theme_state, next)) {
|
||||
self.theme_state = next;
|
||||
self.theme_state_known = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The design tokens for a secondary window's rebuild: the same
|
||||
/// app-owned appearance as `effectiveTokens`, restamped with the
|
||||
/// SLOT's device scale. Each window snaps hairlines against its
|
||||
@@ -1924,13 +1989,15 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
/// true only when the app claims neither token override, so an
|
||||
/// appearance flip must re-derive and re-render.
|
||||
fn followsSystemAppearance(self: *const Self) bool {
|
||||
return self.options.tokens_fn == null and self.options.tokens == null;
|
||||
if (self.options.tokens_fn != null or self.options.tokens != null) return false;
|
||||
if (self.options.theme_state_fn == null) return true;
|
||||
return self.currentThemeState().color_scheme == .system;
|
||||
}
|
||||
|
||||
/// Whether tokens are derived per rebuild (model-owned or
|
||||
/// system-followed) rather than a fixed set.
|
||||
fn derivesTokens(self: *const Self) bool {
|
||||
return self.options.tokens_fn != null or self.followsSystemAppearance();
|
||||
return self.options.tokens_fn != null or self.options.theme_state_fn != null or self.followsSystemAppearance();
|
||||
}
|
||||
|
||||
/// Whether a rebuild must push its tokens into the runtime's
|
||||
@@ -1947,8 +2014,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
/// pre-registration metrics). Ordinary rebuilds keep skipping
|
||||
/// the redundant emission.
|
||||
fn rebuildEmitsTokens(self: *const Self, runtime: *Runtime, window_id: platform.WindowId, canvas_label: []const u8, tokens: canvas.DesignTokens) bool {
|
||||
if (self.derivesTokens()) return true;
|
||||
const stored = runtime.canvasWidgetDesignTokens(window_id, canvas_label) catch return true;
|
||||
if (self.derivesTokens()) return !std.meta.eql(stored, tokens);
|
||||
if (!std.meta.eql(stored.text_measure, tokens.text_measure)) return true;
|
||||
return stored.pixel_snap.scale != tokens.pixel_snap.scale;
|
||||
}
|
||||
@@ -1981,6 +2048,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
/// for the next Msg.
|
||||
pub fn rebuild(self: *Self, runtime: *Runtime, window_id: platform.WindowId) anyerror!void {
|
||||
self.syncModel(runtime, window_id);
|
||||
try self.refreshThemeState();
|
||||
if (comptime features.runtime_markup) {
|
||||
// Under automation, drive the interpreter from the first
|
||||
// frame even when a compiled view is present: provenance
|
||||
@@ -2953,6 +3021,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
|
||||
fn rebuildWindowSlot(self: *Self, runtime: *Runtime, slot: *WindowSlot) anyerror!void {
|
||||
if (self.options.window_view == null) return;
|
||||
try self.refreshThemeState();
|
||||
var tokens = runtime.tokensWithTextMeasure(self.slotEffectiveTokens(slot));
|
||||
const next_index = self.contextMenuRebuildIndex(slot.window_id, slot.arena_index);
|
||||
const bounds = geometry.RectF.fromSize(slot.canvas_size).deflate(runtime.viewportInsetsForWindow(slot.window_id));
|
||||
@@ -4249,7 +4318,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
}
|
||||
},
|
||||
.appearance_changed => |appearance| {
|
||||
const changed = !std.meta.eql(self.system_appearance, appearance);
|
||||
const previous = self.system_appearance;
|
||||
self.system_appearance = appearance;
|
||||
if (self.options.on_appearance) |map| {
|
||||
if (map(appearance)) |msg| {
|
||||
@@ -4257,14 +4326,22 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No app mapping consumed the change: when the stock
|
||||
// tokens follow the system, re-derive and re-render
|
||||
// live — flipping the OS appearance re-themes the
|
||||
// running app without a restart. Before install the
|
||||
// stored appearance alone is enough: the first build
|
||||
// No app mapping consumed the change. High contrast and
|
||||
// reduced motion remain live system axes even when the
|
||||
// model forces light/dark; only a scheme-only flip may
|
||||
// skip repaint while themeState is forced. Before install
|
||||
// the stored appearance alone is enough: the first build
|
||||
// reads it.
|
||||
if (changed and self.installed and self.followsSystemAppearance()) {
|
||||
const accessibility_changed =
|
||||
previous.high_contrast != appearance.high_contrast or
|
||||
previous.reduce_motion != appearance.reduce_motion;
|
||||
const followed_scheme_changed =
|
||||
previous.color_scheme != appearance.color_scheme and self.followsSystemAppearance();
|
||||
if (self.installed and self.options.tokens_fn == null and self.options.tokens == null and
|
||||
(accessibility_changed or followed_scheme_changed))
|
||||
{
|
||||
try self.rebuild(runtime, self.canvas_window_id);
|
||||
try self.rebuildWindowSlots(runtime);
|
||||
if (self.options.chrome == null) {
|
||||
_ = try runtime.emitCanvasWidgetDisplayList(self.canvas_window_id, self.options.canvas_label, runtime.tokensWithTextMeasure(self.effectiveTokens()));
|
||||
}
|
||||
@@ -4763,7 +4840,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
hasher.update(presentation.title);
|
||||
hasher.update(std.mem.asBytes(&presentation.width));
|
||||
hasher.update(std.mem.asBytes(&presentation.icon_opacity));
|
||||
hasher.update(&.{ @intFromEnum(presentation.tone), @intFromBool(presentation.monospaced) });
|
||||
hasher.update(std.mem.asBytes(&presentation.font_size));
|
||||
hasher.update(&.{ @intFromEnum(presentation.tone), @intFromBool(presentation.monospaced), @intFromEnum(presentation.font_weight) });
|
||||
return hasher.final();
|
||||
}
|
||||
|
||||
@@ -4780,6 +4858,33 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
hasher.update(item.detail);
|
||||
hasher.update(std.mem.asBytes(&item.key.len));
|
||||
hasher.update(item.key);
|
||||
if (item.segmented) |segmented| {
|
||||
hasher.update(std.mem.asBytes(&segmented.options.len));
|
||||
for (segmented.options) |option| {
|
||||
hasher.update(std.mem.asBytes(&option.id));
|
||||
hasher.update(std.mem.asBytes(&option.label.len));
|
||||
hasher.update(option.label);
|
||||
hasher.update(std.mem.asBytes(&option.command.len));
|
||||
hasher.update(option.command);
|
||||
hasher.update(&.{ @intFromBool(option.selected), @intFromBool(option.enabled) });
|
||||
}
|
||||
}
|
||||
if (item.metric) |metric| {
|
||||
for ([_][]const u8{ metric.primary_text, metric.secondary_text, metric.accessibility_label }) |field| {
|
||||
hasher.update(std.mem.asBytes(&field.len));
|
||||
hasher.update(field);
|
||||
}
|
||||
}
|
||||
if (item.chart) |chart| {
|
||||
hasher.update(std.mem.asBytes(&chart.values.len));
|
||||
hasher.update(std.mem.sliceAsBytes(chart.values));
|
||||
hasher.update(std.mem.asBytes(&chart.min_value));
|
||||
hasher.update(std.mem.asBytes(&chart.max_value));
|
||||
for ([_][]const u8{ chart.leading_caption, chart.trailing_summary, chart.accessibility_label }) |field| {
|
||||
hasher.update(std.mem.asBytes(&field.len));
|
||||
hasher.update(field);
|
||||
}
|
||||
}
|
||||
hasher.update(&.{
|
||||
@intFromBool(item.separator),
|
||||
@intFromBool(item.enabled),
|
||||
|
||||
+295
-13
@@ -47,6 +47,18 @@ fn counterThemePack(model: *const CounterModel) canvas.ThemePack {
|
||||
return if (model.count == 0) .house else .geist;
|
||||
}
|
||||
|
||||
fn counterThemeState(model: *const CounterModel) CounterApp.ThemeState {
|
||||
return switch (model.count) {
|
||||
0 => .{ .color_scheme = .dark },
|
||||
1 => .{ .pack = .geist, .color_scheme = .system, .accent = canvas.Color.rgb8(0, 0x78, 0x6f) },
|
||||
else => .{ .pack = .house, .color_scheme = .light, .accent = canvas.Color.rgb8(0xdf, 0x26, 0x70) },
|
||||
};
|
||||
}
|
||||
|
||||
fn invalidCounterThemeState(_: *const CounterModel) CounterApp.ThemeState {
|
||||
return .{ .invalid_accent = "pink" };
|
||||
}
|
||||
|
||||
const counter_views = [_]app_manifest.ShellView{
|
||||
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
|
||||
};
|
||||
@@ -1920,6 +1932,117 @@ test "model-derived theme packs retain live appearance, accent, and surface scal
|
||||
try std.testing.expectEqual(@as(f32, 2), actual.pixel_snap.scale);
|
||||
}
|
||||
|
||||
test "model-derived theme state composes forced/system schemes and model accent over manifest defaults" {
|
||||
var options = counterOptions();
|
||||
options.theme = .house;
|
||||
options.theme_accent = canvas.Color.rgb8(0x12, 0x34, 0x56);
|
||||
options.theme_state_fn = counterThemeState;
|
||||
|
||||
const app_state = try std.testing.allocator.create(CounterApp);
|
||||
defer std.testing.allocator.destroy(app_state);
|
||||
app_state.* = CounterApp.init(std.heap.page_allocator, .{}, options);
|
||||
defer app_state.deinit();
|
||||
app_state.system_appearance = .{ .color_scheme = .light };
|
||||
app_state.pixel_snap_scale = 2;
|
||||
|
||||
// Model-forced dark wins over an OS-light appearance; omitted pack and
|
||||
// accent inherit the manifest-backed options.
|
||||
var expected = canvas.DesignTokens.theme(.{ .color_scheme = .dark, .pack = .house });
|
||||
expected = expected.withOverrides(canvas.accentOverrides(options.theme_accent.?, .dark));
|
||||
var actual = app_state.effectiveTokens();
|
||||
try std.testing.expectEqualDeep(expected.colors.background, actual.colors.background);
|
||||
try std.testing.expectEqualDeep(expected.colors.accent, actual.colors.accent);
|
||||
try std.testing.expectEqual(@as(f32, 2), actual.pixel_snap.scale);
|
||||
|
||||
// `system` resumes live OS following while model pack/accent override the
|
||||
// manifest values.
|
||||
app_state.model.count = 1;
|
||||
app_state.theme_state_known = false;
|
||||
app_state.system_appearance = .{ .color_scheme = .dark };
|
||||
const teal = canvas.Color.rgb8(0, 0x78, 0x6f);
|
||||
expected = canvas.DesignTokens.theme(.{ .color_scheme = .dark, .pack = .geist });
|
||||
expected = expected.withOverrides(canvas.accentOverrides(teal, .dark));
|
||||
actual = app_state.effectiveTokens();
|
||||
try std.testing.expectEqualDeep(expected.colors.background, actual.colors.background);
|
||||
try std.testing.expectEqualDeep(teal, actual.colors.accent);
|
||||
try std.testing.expectEqualDeep(canvas.accentFocusRing(teal, .dark), actual.colors.focus_ring);
|
||||
|
||||
// High contrast keeps the existing accessibility rule: neither the
|
||||
// model nor manifest accent layers over the pack's loud register.
|
||||
app_state.system_appearance.high_contrast = true;
|
||||
actual = app_state.effectiveTokens();
|
||||
const loud = canvas.DesignTokens.theme(.{ .color_scheme = .dark, .contrast = .high, .pack = .geist });
|
||||
try std.testing.expectEqualDeep(loud.colors.accent, actual.colors.accent);
|
||||
try std.testing.expect(!std.meta.eql(teal, actual.colors.accent));
|
||||
}
|
||||
|
||||
test "malformed adapter theme accents reject the rebuild instead of inheriting silently" {
|
||||
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
var options = counterOptions();
|
||||
options.theme_state_fn = invalidCounterThemeState;
|
||||
const app_state = try std.testing.allocator.create(CounterApp);
|
||||
defer std.testing.allocator.destroy(app_state);
|
||||
app_state.* = CounterApp.init(std.heap.page_allocator, .{}, options);
|
||||
defer app_state.deinit();
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
try std.testing.expectError(error.InvalidThemeAccent, harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
|
||||
.label = canvas_label,
|
||||
.size = geometry.SizeF.init(400, 300),
|
||||
.scale_factor = 1,
|
||||
.frame_index = 1,
|
||||
.timestamp_ns = 1_000_000,
|
||||
.nonblank = true,
|
||||
} }));
|
||||
try std.testing.expect(!app_state.installed);
|
||||
}
|
||||
|
||||
test "forced theme state still follows accessibility axes while scheme-only OS flips do not restyle" {
|
||||
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
|
||||
var options = counterOptions();
|
||||
options.theme_state_fn = counterThemeState; // count 0 forces dark.
|
||||
const app_state = try std.testing.allocator.create(CounterApp);
|
||||
defer std.testing.allocator.destroy(app_state);
|
||||
app_state.* = CounterApp.init(std.heap.page_allocator, .{}, options);
|
||||
defer app_state.deinit();
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .appearance_changed = .{ .color_scheme = .light } });
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
|
||||
.label = canvas_label,
|
||||
.size = geometry.SizeF.init(400, 300),
|
||||
.scale_factor = 1,
|
||||
.frame_index = 1,
|
||||
.timestamp_ns = 1_000_000,
|
||||
.nonblank = true,
|
||||
} });
|
||||
var stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
|
||||
const dark = canvas.DesignTokens.theme(.{ .color_scheme = .dark });
|
||||
try std.testing.expectEqualDeep(dark.colors.background, stored.colors.background);
|
||||
|
||||
// A scheme-only flip cannot override the forced state.
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .appearance_changed = .{ .color_scheme = .dark } });
|
||||
stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
|
||||
try std.testing.expectEqualDeep(dark.colors.background, stored.colors.background);
|
||||
|
||||
// Accessibility axes remain live even under a forced color scheme.
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .appearance_changed = .{
|
||||
.color_scheme = .light,
|
||||
.high_contrast = true,
|
||||
.reduce_motion = true,
|
||||
} });
|
||||
stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
|
||||
const dark_loud = canvas.DesignTokens.theme(.{ .color_scheme = .dark, .contrast = .high, .reduce_motion = true });
|
||||
try std.testing.expectEqualDeep(dark_loud.colors.background, stored.colors.background);
|
||||
try std.testing.expectEqualDeep(dark_loud.colors.focus_ring, stored.colors.focus_ring);
|
||||
try std.testing.expectEqual(@as(u32, 0), stored.motion.normal_ms);
|
||||
}
|
||||
|
||||
test "static tokens carry the surface scale and re-snap on a scale change" {
|
||||
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
@@ -3142,6 +3265,73 @@ test "automation composition and selection verbs keep the model mirror consisten
|
||||
try std.testing.expectEqualStrings("", canceled_layout.findById(field_id).?.widget.text);
|
||||
}
|
||||
|
||||
test "macOS Command Backspace keeps the controlled TextBuffer mirror and history in lockstep" {
|
||||
if (comptime @import("builtin").os.tag != .macos) return error.SkipZigTest;
|
||||
|
||||
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
|
||||
const app_state = try std.testing.allocator.create(SearchMirrorApp);
|
||||
defer std.testing.allocator.destroy(app_state);
|
||||
try startSearchMirror(harness, app_state);
|
||||
defer app_state.deinit();
|
||||
const app = app_state.app();
|
||||
const field_id = findWidgetIdByKind(app_state.tree.?.root, .search_field).?;
|
||||
|
||||
try core.testing.dispatchAutomationWidgetAction(&harness.runtime, app, .{
|
||||
.view_label = search_mirror_canvas_label,
|
||||
.id = field_id,
|
||||
.action = .set_text,
|
||||
.value = "second line",
|
||||
});
|
||||
for (0..5) |_| {
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = search_mirror_canvas_label,
|
||||
.kind = .key_down,
|
||||
.key = "arrowleft",
|
||||
} });
|
||||
}
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(6), app_state.model.query.selection);
|
||||
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = search_mirror_canvas_label,
|
||||
.kind = .key_down,
|
||||
.key = "backspace",
|
||||
.modifiers = .{ .primary = true, .command = true },
|
||||
} });
|
||||
try std.testing.expectEqualStrings(" line", app_state.model.query.text());
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), app_state.model.query.selection);
|
||||
var retained = try harness.runtime.canvasWidgetLayout(1, search_mirror_canvas_label);
|
||||
try std.testing.expectEqualStrings(app_state.model.query.text(), retained.findById(field_id).?.widget.text);
|
||||
try std.testing.expectEqualDeep(app_state.model.query.selection, retained.findById(field_id).?.widget.text_selection.?);
|
||||
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = search_mirror_canvas_label,
|
||||
.kind = .key_down,
|
||||
.key = "z",
|
||||
.modifiers = .{ .primary = true, .command = true },
|
||||
} });
|
||||
try std.testing.expectEqualStrings("second line", app_state.model.query.text());
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(6), app_state.model.query.selection);
|
||||
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = search_mirror_canvas_label,
|
||||
.kind = .key_down,
|
||||
.key = "z",
|
||||
.modifiers = .{ .primary = true, .command = true, .shift = true },
|
||||
} });
|
||||
try std.testing.expectEqualStrings(" line", app_state.model.query.text());
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), app_state.model.query.selection);
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, search_mirror_canvas_label);
|
||||
try std.testing.expectEqualStrings(app_state.model.query.text(), retained.findById(field_id).?.widget.text);
|
||||
try std.testing.expectEqualDeep(app_state.model.query.selection, retained.findById(field_id).?.widget.text_selection.?);
|
||||
}
|
||||
|
||||
// --------------------------------- combobox open-arrow (mirror invariant)
|
||||
|
||||
const combo_mirror_canvas_label = "combo-mirror-canvas";
|
||||
@@ -3151,12 +3341,16 @@ const ComboMirrorModel = struct {
|
||||
note: canvas.TextBuffer(64) = .{},
|
||||
open: bool = false,
|
||||
opens: u32 = 0,
|
||||
submits: u32 = 0,
|
||||
picks: u32 = 0,
|
||||
query_edits: u32 = 0,
|
||||
};
|
||||
|
||||
const ComboMirrorMsg = union(enum) {
|
||||
open_picker,
|
||||
close_picker,
|
||||
submit_query,
|
||||
pick_query: []const u8,
|
||||
query_edit: canvas.TextInputEvent,
|
||||
note_edit: canvas.TextInputEvent,
|
||||
};
|
||||
@@ -3170,6 +3364,16 @@ fn comboMirrorUpdate(model: *ComboMirrorModel, msg: ComboMirrorMsg) void {
|
||||
model.opens += 1;
|
||||
},
|
||||
.close_picker => model.open = false,
|
||||
.submit_query => {
|
||||
model.open = false;
|
||||
model.submits += 1;
|
||||
},
|
||||
.pick_query => |query| {
|
||||
model.query.clear();
|
||||
model.query.apply(.{ .insert_text = query });
|
||||
model.open = false;
|
||||
model.picks += 1;
|
||||
},
|
||||
.query_edit => |edit| {
|
||||
model.query.apply(edit);
|
||||
model.query_edits += 1;
|
||||
@@ -3185,6 +3389,7 @@ fn comboMirrorView(ui: *ComboMirrorApp.Ui, model: *const ComboMirrorModel) Combo
|
||||
.width = 200,
|
||||
.expanded = model.open,
|
||||
.on_press = .open_picker,
|
||||
.on_submit = .submit_query,
|
||||
.on_input = ComboMirrorApp.Ui.inputMsg(.query_edit),
|
||||
}, .{});
|
||||
const picker = if (model.open) ui.stack(.{ .height = 28 }, .{
|
||||
@@ -3196,8 +3401,8 @@ fn comboMirrorView(ui: *ComboMirrorApp.Ui, model: *const ComboMirrorModel) Combo
|
||||
.height = 60,
|
||||
.on_dismiss = .close_picker,
|
||||
}, .{
|
||||
ui.el(.menu_item, .{ .key = .{ .int = 0 }, .text = "glass bead", .height = 26, .on_press = .close_picker }, .{}),
|
||||
ui.el(.menu_item, .{ .key = .{ .int = 1 }, .text = "glass jar", .height = 26, .on_press = .close_picker }, .{}),
|
||||
ui.el(.menu_item, .{ .key = .{ .int = 0 }, .text = "glass bead", .height = 26, .on_press = ComboMirrorMsg{ .pick_query = "glass bead" } }, .{}),
|
||||
ui.el(.menu_item, .{ .key = .{ .int = 1 }, .text = "glass jar", .height = 26, .on_press = ComboMirrorMsg{ .pick_query = "glass jar" } }, .{}),
|
||||
}),
|
||||
}) else ui.stack(.{ .height = 28 }, .{trigger});
|
||||
return ui.column(.{ .gap = 8, .padding = 12 }, .{
|
||||
@@ -3237,7 +3442,7 @@ fn comboMirrorRetainedSelection(harness: *core.TestHarness(), id: canvas.ObjectI
|
||||
return layout.findById(id).?.widget.text_selection;
|
||||
}
|
||||
|
||||
test "a closed combobox's open arrows move neither the retained caret nor the model mirror" {
|
||||
test "combobox submit precedence and open-menu selection keep the text mirror consistent" {
|
||||
// The split-brain escapee: a CLOSED combobox maps ArrowUp/Down to
|
||||
// BOTH its open press (`widgetKeyboardControlIntent`'s menu-open
|
||||
// keys) and — through the single-line caret derivation — a stamped
|
||||
@@ -3292,6 +3497,19 @@ test "a closed combobox's open arrows move neither the retained caret nor the mo
|
||||
try std.testing.expectEqualStrings("glass", app_state.model.query.text());
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection);
|
||||
|
||||
// Enter resolves through on_submit BEFORE the trigger's open press.
|
||||
// The closed picker stays closed and neither side of the text mirror
|
||||
// changes while the submit message commits.
|
||||
const edits_before_submit = app_state.model.query_edits;
|
||||
try comboMirrorKey(harness, app, "enter");
|
||||
try std.testing.expect(!app_state.model.open);
|
||||
try std.testing.expectEqual(@as(u32, 0), app_state.model.opens);
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.model.submits);
|
||||
try std.testing.expectEqual(edits_before_submit, app_state.model.query_edits);
|
||||
try std.testing.expectEqualStrings("glass", app_state.model.query.text());
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection);
|
||||
try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, combo_id));
|
||||
|
||||
// THE pin: ArrowDown on the closed trigger opens the picker and
|
||||
// both carets stay at 3 — no query edit is heard or applied.
|
||||
const edits_before_open = app_state.model.query_edits;
|
||||
@@ -3302,9 +3520,10 @@ test "a closed combobox's open arrows move neither the retained caret nor the mo
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection);
|
||||
try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, combo_id));
|
||||
|
||||
// The OPEN-picker truth, pinned as-is: the next arrow walks the
|
||||
// keyboard INTO the mounted menu (the focus step consumes it before
|
||||
// routing reaches the trigger), so it is no caret edit either.
|
||||
// The OPEN-picker truth: the next arrow walks the keyboard INTO the
|
||||
// mounted menu (the focus step consumes it before routing reaches
|
||||
// the trigger), so Enter selects that menu item instead of reaching
|
||||
// the combobox submit handler again.
|
||||
const first_item_id = findWidgetIdByText(app_state.tree.?, .menu_item, "glass bead").?;
|
||||
try comboMirrorKey(harness, app, "arrowdown");
|
||||
try std.testing.expectEqual(first_item_id, harness.runtime.views[0].canvas_widget_focused_id);
|
||||
@@ -3312,12 +3531,12 @@ test "a closed combobox's open arrows move neither the retained caret nor the mo
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection);
|
||||
try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, combo_id));
|
||||
|
||||
// Escape is consumed by the DISMISSAL pass while the menu floats:
|
||||
// the picker closes through `on_dismiss` and the combobox's
|
||||
// Escape-clear never runs — the query survives.
|
||||
try comboMirrorKey(harness, app, "escape");
|
||||
try comboMirrorKey(harness, app, "enter");
|
||||
try std.testing.expect(!app_state.model.open);
|
||||
try std.testing.expectEqualStrings("glass", app_state.model.query.text());
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.model.submits);
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.model.picks);
|
||||
try std.testing.expectEqualStrings("glass bead", app_state.model.query.text());
|
||||
try std.testing.expectEqualStrings("glass bead", (try harness.runtime.canvasWidgetLayout(1, combo_mirror_canvas_label)).findById(combo_id).?.widget.text);
|
||||
try std.testing.expectEqual(combo_id, harness.runtime.views[0].canvas_widget_focused_id);
|
||||
|
||||
// ArrowUp on the closed trigger is the same open key: opens, and
|
||||
@@ -3326,8 +3545,8 @@ test "a closed combobox's open arrows move neither the retained caret nor the mo
|
||||
try std.testing.expect(app_state.model.open);
|
||||
try std.testing.expectEqual(@as(u32, 2), app_state.model.opens);
|
||||
try std.testing.expectEqual(edits_before_open, app_state.model.query_edits);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(3), app_state.model.query.selection);
|
||||
try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed(3)), try comboMirrorRetainedSelection(harness, combo_id));
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed("glass bead".len), app_state.model.query.selection);
|
||||
try std.testing.expectEqualDeep(@as(?canvas.TextSelection, canvas.TextSelection.collapsed("glass bead".len)), try comboMirrorRetainedSelection(harness, combo_id));
|
||||
try comboMirrorKey(harness, app, "escape");
|
||||
try std.testing.expect(!app_state.model.open);
|
||||
|
||||
@@ -4321,11 +4540,74 @@ test "ui app tray state rides automation snapshots and tray-action drives a row"
|
||||
|
||||
// Unknown or malformed item ids are loud driver misuse, never a
|
||||
// silent no-op or a fallback command dispatch.
|
||||
try std.testing.expectError(error.InvalidCommand, harness.runtime.dispatchAutomationCommand(app, "tray-action 11"));
|
||||
try std.testing.expectError(error.InvalidCommand, harness.runtime.dispatchAutomationCommand(app, "tray-action 99"));
|
||||
try std.testing.expectError(error.InvalidCommand, harness.runtime.dispatchAutomationCommand(app, "tray-action open"));
|
||||
try std.testing.expectEqual(@as(u32, 1), app_state.model.selected_issue);
|
||||
}
|
||||
|
||||
const SegmentedAutomationModel = struct { selected: bool = true };
|
||||
const SegmentedAutomationMsg = union(enum) { enable, disable };
|
||||
const SegmentedAutomationApp = ui_app_model.UiApp(SegmentedAutomationModel, SegmentedAutomationMsg);
|
||||
|
||||
fn segmentedAutomationUpdate(model: *SegmentedAutomationModel, msg: SegmentedAutomationMsg) void {
|
||||
switch (msg) {
|
||||
.enable => model.selected = true,
|
||||
.disable => model.selected = false,
|
||||
}
|
||||
}
|
||||
|
||||
fn segmentedAutomationView(ui: *SegmentedAutomationApp.Ui, _: *const SegmentedAutomationModel) SegmentedAutomationApp.Ui.Node {
|
||||
return ui.text(.{}, "Segments");
|
||||
}
|
||||
|
||||
fn segmentedAutomationCommand(name: []const u8) ?SegmentedAutomationMsg {
|
||||
if (std.mem.eql(u8, name, "segment.enable")) return .enable;
|
||||
if (std.mem.eql(u8, name, "segment.disable")) return .disable;
|
||||
return null;
|
||||
}
|
||||
|
||||
fn segmentedAutomationStatusItem(model: *const SegmentedAutomationModel, scratch: *SegmentedAutomationApp.StatusItemScratch) SegmentedAutomationApp.StatusItemState {
|
||||
scratch.segment_options[0] = .{ .id = 20, .label = "On", .command = "segment.enable", .selected = model.selected, .enabled = false };
|
||||
scratch.segment_options[1] = .{ .id = 21, .label = "Off", .command = "segment.disable", .selected = !model.selected };
|
||||
scratch.items[0] = .{ .role = .segmented, .segmented = .{ .options = scratch.segment_options[0..2] } };
|
||||
return .{ .items = scratch.items[0..1] };
|
||||
}
|
||||
|
||||
test "automation tray-action rejects disabled segmented options" {
|
||||
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
|
||||
const app_state = try std.testing.allocator.create(SegmentedAutomationApp);
|
||||
defer std.testing.allocator.destroy(app_state);
|
||||
app_state.* = SegmentedAutomationApp.init(std.heap.page_allocator, .{}, .{
|
||||
.name = "ui-app-tray-segmented-automation",
|
||||
.scene = counter_scene,
|
||||
.canvas_label = canvas_label,
|
||||
.update = segmentedAutomationUpdate,
|
||||
.view = segmentedAutomationView,
|
||||
.on_command = segmentedAutomationCommand,
|
||||
.status_item_fn = segmentedAutomationStatusItem,
|
||||
});
|
||||
defer app_state.deinit();
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
|
||||
.label = canvas_label,
|
||||
.size = geometry.SizeF.init(400, 300),
|
||||
.scale_factor = 1,
|
||||
.frame_index = 1,
|
||||
.timestamp_ns = 1_000_000,
|
||||
.nonblank = true,
|
||||
} });
|
||||
|
||||
try std.testing.expectError(error.InvalidCommand, harness.runtime.dispatchAutomationCommand(app, "tray-action 20"));
|
||||
try std.testing.expect(app_state.model.selected);
|
||||
try harness.runtime.dispatchAutomationCommand(app, "tray-action 21");
|
||||
try std.testing.expect(!app_state.model.selected);
|
||||
}
|
||||
|
||||
const TaskModel = struct {
|
||||
completed: u32 = 0,
|
||||
deleted: u32 = 0,
|
||||
|
||||
@@ -158,10 +158,12 @@ pub fn validateTrayPresentation(presentation: platform.TrayPresentation) !void {
|
||||
try validateTrayTitle(presentation.title);
|
||||
if (!std.math.isFinite(presentation.width) or presentation.width < 0) return error.InvalidTrayOptions;
|
||||
if (!std.math.isFinite(presentation.icon_opacity) or presentation.icon_opacity < 0 or presentation.icon_opacity > 1) return error.InvalidTrayOptions;
|
||||
if (!std.math.isFinite(presentation.font_size) or presentation.font_size < 0 or presentation.font_size > 64) return error.InvalidTrayOptions;
|
||||
}
|
||||
|
||||
pub fn validateTrayMenuItems(items: []const platform.TrayMenuItem) !void {
|
||||
if (items.len > platform.max_tray_items) return error.InvalidTrayOptions;
|
||||
var fallback_row_count: usize = 0;
|
||||
for (items, 0..) |item, index| {
|
||||
try validateTrayField(item.label, platform.max_tray_item_label_bytes);
|
||||
try validateTrayField(item.command, platform.max_tray_item_command_bytes);
|
||||
@@ -170,6 +172,11 @@ pub fn validateTrayMenuItems(items: []const platform.TrayMenuItem) !void {
|
||||
if (item.role != .command and item.role != .agent and item.command.len > 0) return error.InvalidTrayOptions;
|
||||
if (item.detail.len > 0 and item.role != .info and item.role != .hero and item.role != .agent and item.role != .context) return error.InvalidTrayOptions;
|
||||
if (item.separator and (item.detail.len > 0 or item.role != .command)) return error.InvalidTrayOptions;
|
||||
if ((item.role == .segmented) != (item.segmented != null)) return error.InvalidTrayOptions;
|
||||
if ((item.role == .chart) != (item.chart != null)) return error.InvalidTrayOptions;
|
||||
if (item.metric != null and item.role != .hero) return error.InvalidTrayOptions;
|
||||
if ((item.role == .segmented or item.role == .chart) and item.id != 0) return error.InvalidTrayOptions;
|
||||
if ((item.role == .segmented or item.role == .chart) and item.label.len != 0) return error.InvalidTrayOptions;
|
||||
if (item.id != 0) {
|
||||
for (items[0..index]) |previous| {
|
||||
if (previous.id == item.id) return error.InvalidTrayOptions;
|
||||
@@ -179,11 +186,65 @@ pub fn validateTrayMenuItems(items: []const platform.TrayMenuItem) !void {
|
||||
if (item.separator or item.id == 0) return error.InvalidTrayOptions;
|
||||
try validateCommandName(item.command);
|
||||
}
|
||||
if (!item.separator and item.label.len == 0) return error.InvalidTrayOptions;
|
||||
if (!item.separator and item.label.len == 0 and item.role != .segmented and item.role != .chart and item.metric == null) return error.InvalidTrayOptions;
|
||||
if (item.key.len > 0) {
|
||||
if (item.separator or item.command.len == 0) return error.InvalidTrayOptions;
|
||||
if (!platform.isValidShortcutBinding(item.key, item.modifiers)) return error.InvalidTrayOptions;
|
||||
}
|
||||
if (item.segmented) |segmented| {
|
||||
try validateTraySegmentedRow(items, index, segmented);
|
||||
fallback_row_count += segmented.options.len;
|
||||
} else {
|
||||
fallback_row_count += 1;
|
||||
}
|
||||
if (item.metric) |metric| try validateTrayMetricRow(metric);
|
||||
if (item.chart) |chart| try validateTrayChartRow(chart);
|
||||
}
|
||||
if (fallback_row_count > platform.max_tray_items) return error.InvalidTrayOptions;
|
||||
}
|
||||
|
||||
fn validateTrayMetricRow(metric: platform.TrayMetricRow) !void {
|
||||
try validateTrayField(metric.primary_text, platform.max_tray_item_label_bytes);
|
||||
try validateTrayField(metric.secondary_text, platform.max_tray_item_detail_bytes);
|
||||
try validateTrayField(metric.accessibility_label, platform.max_tray_chart_text_bytes);
|
||||
if (metric.primary_text.len == 0 or metric.accessibility_label.len == 0) return error.InvalidTrayOptions;
|
||||
}
|
||||
|
||||
fn validateTraySegmentedRow(items: []const platform.TrayMenuItem, row_index: usize, row: platform.TraySegmentedRow) !void {
|
||||
if (row.options.len == 0 or row.options.len > platform.max_tray_segment_options) return error.InvalidTrayOptions;
|
||||
var selected_count: usize = 0;
|
||||
for (row.options, 0..) |option, option_index| {
|
||||
if (option.id == 0) return error.InvalidTrayOptions;
|
||||
try validateTrayField(option.label, platform.max_tray_segment_label_bytes);
|
||||
try validateTrayField(option.command, platform.max_tray_item_command_bytes);
|
||||
if (option.label.len == 0 or option.command.len == 0) return error.InvalidTrayOptions;
|
||||
try validateCommandName(option.command);
|
||||
if (option.selected) selected_count += 1;
|
||||
for (row.options[0..option_index]) |previous| {
|
||||
if (previous.id == option.id) return error.InvalidTrayOptions;
|
||||
}
|
||||
for (items, 0..) |other, other_index| {
|
||||
if (other_index == row_index) continue;
|
||||
if (other.id == option.id) return error.InvalidTrayOptions;
|
||||
if (other.segmented) |other_segmented| {
|
||||
for (other_segmented.options) |other_option| {
|
||||
if (other_option.id == option.id) return error.InvalidTrayOptions;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selected_count > 1) return error.InvalidTrayOptions;
|
||||
}
|
||||
|
||||
fn validateTrayChartRow(chart: platform.TrayChartRow) !void {
|
||||
if (chart.values.len == 0 or chart.values.len > platform.max_tray_chart_values) return error.InvalidTrayOptions;
|
||||
if (!std.math.isFinite(chart.min_value) or !std.math.isFinite(chart.max_value) or !(chart.max_value > chart.min_value)) return error.InvalidTrayOptions;
|
||||
try validateTrayField(chart.leading_caption, platform.max_tray_chart_text_bytes);
|
||||
try validateTrayField(chart.trailing_summary, platform.max_tray_chart_text_bytes);
|
||||
try validateTrayField(chart.accessibility_label, platform.max_tray_chart_text_bytes);
|
||||
if (chart.accessibility_label.len == 0) return error.InvalidTrayOptions;
|
||||
for (chart.values) |value| {
|
||||
if (!std.math.isFinite(value) or value < chart.min_value or value > chart.max_value) return error.InvalidTrayOptions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,3 +307,28 @@ pub fn validateViewFrame(frame: geometry.RectF) !void {
|
||||
pub fn isValidWebViewFrame(frame: geometry.RectF) bool {
|
||||
return frame.x >= 0 and frame.y >= 0 and frame.width > 0 and frame.height > 0;
|
||||
}
|
||||
|
||||
test "typed rich tray rows validate bounded data and shared option ids" {
|
||||
const segments = [_]platform.TraySegmentOption{
|
||||
.{ .id = 11, .label = "Day", .command = "range.day", .selected = true },
|
||||
.{ .id = 12, .label = "Week", .command = "range.week" },
|
||||
};
|
||||
const values = [_]f32{ 0.2, 0.5, 1.0 };
|
||||
try validateTrayMenuItems(&.{
|
||||
.{ .role = .hero, .metric = .{ .primary_text = "2,494 requests", .secondary_text = "Today · production", .accessibility_label = "2,494 requests today in production" } },
|
||||
.{ .role = .segmented, .segmented = .{ .options = &segments } },
|
||||
.{ .role = .chart, .chart = .{ .values = &values, .min_value = 0, .max_value = 1, .leading_caption = "CPU", .trailing_summary = "42%", .accessibility_label = "CPU history, 42 percent" } },
|
||||
});
|
||||
|
||||
const duplicate_segments = [_]platform.TraySegmentOption{
|
||||
.{ .id = 11, .label = "Day", .command = "range.day" },
|
||||
.{ .id = 11, .label = "Week", .command = "range.week" },
|
||||
};
|
||||
try std.testing.expectError(error.InvalidTrayOptions, validateTrayMenuItems(&.{
|
||||
.{ .role = .segmented, .segmented = .{ .options = &duplicate_segments } },
|
||||
}));
|
||||
const out_of_range = [_]f32{1.1};
|
||||
try std.testing.expectError(error.InvalidTrayOptions, validateTrayMenuItems(&.{
|
||||
.{ .role = .chart, .chart = .{ .values = &out_of_range, .min_value = 0, .max_value = 1, .accessibility_label = "bad" } },
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
|
||||
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) return .{ .move_caret = .{ .direction = .end, .extend = keyboard.modifiers.shift } };
|
||||
}
|
||||
|
||||
return keyboard.textEditEvent();
|
||||
return canvas.widgetKeyboardTextEditEventForWidget(widget, keyboard);
|
||||
}
|
||||
|
||||
/// Resolve Command/Ctrl+Z against the focused editor's delta
|
||||
|
||||
@@ -254,6 +254,7 @@ fn runZig(io: std.Io, verb: Verb, argv: []const []const u8) !void {
|
||||
// SDK builds with Zig 0.16, where std APIs moved, and those failures
|
||||
// read "no member named 'cwd'/'init'/'io'" on std types.
|
||||
std.debug.print("if the errors above name missing std members, the code may use pre-0.16 Zig idioms - run `native skills get zig` or see https://native-sdk.dev/zig\n", .{});
|
||||
std.debug.print("if generated core wiring exceeds Zig's eval branch quota, do not regenerate the TypeScript contract or add an app-side quota - update or report the SDK-generated scan\n", .{});
|
||||
return error.ZigBuildFailed;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.{
|
||||
.id = "dev.native_sdk.runner_fixture",
|
||||
.name = "runner-fixture",
|
||||
.commands = .{
|
||||
.{ .id = "app.refresh", .title = "Refresh", .enabled = false, .checked = true },
|
||||
.{ .id = "app.defaults" },
|
||||
},
|
||||
.shortcuts = .{
|
||||
.{ .id = "app.refresh", .key = "r", .modifiers = .{ "primary" } },
|
||||
},
|
||||
.menus = .{
|
||||
.{
|
||||
.title = "View",
|
||||
.items = .{
|
||||
.{ .label = "Refresh", .command = "app.refresh", .key = "r", .modifiers = .{ "alt", "shift" }, .enabled = false, .checked = true },
|
||||
.{ .separator = true },
|
||||
.{ .label = "Defaults", .command = "app.defaults" },
|
||||
},
|
||||
},
|
||||
.{ .title = "Help" },
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user