feat(tray): add typed rich rows (#383)

* feat(tray): add typed rich rows

- Add typed metric, segmented-choice, and bounded chart rows across Zig and TypeScript.
- Render native macOS controls with command routing and accessible platform fallbacks.
- Support independently styled persistent menu-bar titles with configurable size, weight, and number style.

* fix(tray): keep rich rows model-owned

* fix(tray): harden rich row contracts
This commit is contained in:
Chris Tate
2026-08-17 23:26:25 -05:00
committed by GitHub
parent fe41864a15
commit cb6a417965
33 changed files with 1726 additions and 126 deletions
+8
View File
@@ -1573,6 +1573,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\"" },
+92 -2
View File
@@ -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 132 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 menus 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.
+2 -2
View File
@@ -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.
+33 -4
View File
@@ -7,7 +7,8 @@ export type ThemeState = {
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;
@@ -15,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;
@@ -32,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;
+45 -6
View File
@@ -63,12 +63,14 @@ export type ThemeState = {
/// 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;
@@ -78,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;
@@ -96,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
+103 -8
View File
@@ -992,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() ?? [];
@@ -1004,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",
);
@@ -1019,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",
@@ -1352,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);
@@ -1366,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",
+4 -2
View File
@@ -343,8 +343,10 @@ export class TypedAst {
/// 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.
/// The one omission-carrying value record is the SDK-owned ThemeState;
/// its caller opts in explicitly so authored/service records keep the
/// 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[] = [];
+8 -8
View File
@@ -266,7 +266,7 @@ export class TypeTable {
this.declOrder.push(name);
continue;
}
const projectOptional = this.isCanonicalThemeState(stmt);
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
@@ -275,8 +275,8 @@ export class TypeTable {
// 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. The
// canonical SDK ThemeState is the sole optional-property record:
// omission is its manifest/system inheritance signal.
// 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,
@@ -323,7 +323,7 @@ export class TypeTable {
}
const structInfo = this.structs.get(stmt.name.text);
if (structInfo && structInfo.decl === stmt && ts.isTypeLiteralNode(stmt.type)) {
const projectOptional = this.isCanonicalThemeState(stmt);
const projectOptional = this.isCanonicalOptionalSdkRecord(stmt);
const props = this.tast.propsOfTypeLiteral(stmt.type, projectOptional);
if (props) structInfo.fields = props.map((p) => this.fieldOf(p, projectOptional));
}
@@ -331,9 +331,9 @@ export class TypeTable {
}
}
private isCanonicalThemeState(decl: ts.TypeAliasDeclaration): boolean {
private isCanonicalOptionalSdkRecord(decl: ts.TypeAliasDeclaration): boolean {
const events = sdkLibraryModules.get("@native-sdk/core/events");
return decl.name.text === "ThemeState" && events !== undefined &&
return (decl.name.text === "ThemeState" || decl.name.text === "StatusItemPresentation" || decl.name.text === "StatusItemMenuItem") && events !== undefined &&
path.resolve(decl.getSourceFile().fileName) === path.resolve(events);
}
@@ -342,8 +342,8 @@ export class TypeTable {
return {
tsName: p.name,
zigName: zigDeclName(p.name),
// Only the canonical ThemeState projects JS `undefined` omission onto
// the contract's ordinary optional slot. Applying this globally would
// 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"
+3
View File
@@ -1242,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 } },
],
+11
View File
@@ -199,6 +199,17 @@ 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", () => {
+2 -2
View File
@@ -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 132 bounded values with captions and accessibility. These are generic composable row payloads, not text conventions.
## App wiring (Zig cores and extensions only)
+3 -3
View File
@@ -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.
@@ -396,7 +396,7 @@ 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.
+91 -3
View File
@@ -228,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.
@@ -643,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,
@@ -982,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;
@@ -1008,6 +1059,43 @@ 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);
+41 -2
View File
@@ -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);
+198 -9
View File
@@ -774,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;
@@ -784,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;
@@ -1095,6 +1101,7 @@ 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;
@@ -7480,6 +7487,9 @@ static BOOL NativeSdkScrollDriverCanConsumeHorizontally(NativeSdkScrollDriverVie
@implementation NativeSdkShortcut
@end
@implementation NativeSdkTraySegmentedControl
@end
@implementation NativeSdkStatusItemEntry
@end
@@ -12197,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)];
}
@@ -13344,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;
@@ -13398,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);
@@ -13415,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);
}
}
@@ -13425,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;
}
@@ -13438,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] : @"";
@@ -13667,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;
@@ -13740,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 {
@@ -13753,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);
}
}
+191 -9
View File
@@ -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);
}
}
+110 -3
View File
@@ -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;
@@ -2690,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,
@@ -2739,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 {
@@ -2748,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 {
@@ -2821,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);
+50 -1
View File
@@ -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;
}
+53
View File
@@ -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();
+9
View File
@@ -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;
+58
View File
@@ -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 {
+88 -15
View File
@@ -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
+4
View File
@@ -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;
+3
View File
@@ -103,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] = .{
+1 -1
View File
@@ -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,
+14
View File
@@ -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 {
+62
View File
@@ -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;
}
+161 -33
View File
@@ -638,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]);
}
@@ -684,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),
@@ -790,8 +787,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,
@@ -808,6 +805,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 {
@@ -819,6 +859,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,
@@ -859,6 +901,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| {
@@ -902,14 +952,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);
}
@@ -917,9 +970,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);
}
@@ -927,7 +981,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);
@@ -941,6 +995,7 @@ pub fn TsUiApp(comptime core: type) type {
{
@compileError(teaching);
}
validateStatusItemRichTypes(Item, teaching);
}
fn validateStatusItemsHelper() void {
@@ -976,11 +1031,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);
}
@@ -988,18 +1046,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
@@ -1059,6 +1119,43 @@ pub fn TsUiApp(comptime core: type) type {
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;
}
@@ -1561,7 +1658,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,
@@ -1575,6 +1672,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,
@@ -1586,6 +1706,9 @@ const StatusItemsAdapterTestCore = struct {
role: Role,
key: []const u8,
modifiers: Modifiers,
segmented: ?Segmented,
metric: ?Metric,
chart: ?Chart,
};
const Descriptor = struct {
id: f64,
@@ -1609,6 +1732,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,
@@ -1618,7 +1744,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,
}};
@@ -1640,6 +1766,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);
+33 -1
View File
@@ -316,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
@@ -326,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 {
@@ -4843,7 +4847,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();
}
@@ -4860,6 +4865,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),
+63
View File
@@ -4540,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,
+87 -1
View File
@@ -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" } },
}));
}
+62
View File
@@ -102,6 +102,8 @@ export interface Model {
export type Msg =
| { readonly kind: "toggle" }
| { readonly kind: "enable" }
| { readonly kind: "disable" }
| { readonly kind: "refresh" }
| { readonly kind: "abort" }
| { readonly kind: "stamp" }
@@ -245,6 +247,10 @@ export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
switch (msg.kind) {
case "toggle":
return [{ ...model, polling: !model.polling }, Cmd.none];
case "enable":
return [{ ...model, polling: true }, Cmd.none];
case "disable":
return [{ ...model, polling: false }, Cmd.none];
case "refresh":
return [model, Cmd.request("status.read", model.status, { key: "status", ok: "loaded", err: "failed" })];
case "abort":
@@ -580,8 +586,62 @@ export function statusItem(model: Model): StatusItemState {
tone: model.polling ? "normal" : "warning",
iconOpacity: model.polling ? 1 : 0.5,
monospaced: true,
fontSize: model.polling ? 12 : 13,
fontWeight: model.polling ? "medium" : "semibold",
},
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(model.polling ? "2,494 requests" : "1,240 requests"),
secondaryText: utf8Bytes("Today · production"),
accessibilityLabel: asciiBytes(model.polling ? "2,494 requests today" : "1,240 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("core.enable"), selected: model.polling, enabled: true },
{ id: 12, label: asciiBytes("Off"), command: asciiBytes("core.disable"), selected: !model.polling, 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: model.polling ? [0.25, 0.5, 0.75, 1] : [1, 0.75, 0.5, 0.25],
minValue: 0,
maxValue: 1,
leadingCaption: asciiBytes("Load"),
trailingSummary: asciiBytes(model.polling ? "rising" : "falling"),
accessibilityLabel: asciiBytes(model.polling ? "Load rising" : "Load falling"),
},
},
{
id: 1,
label: model.polling ? utf8Bytes("Pause polling…") : utf8Bytes("Resume polling…"),
@@ -610,6 +670,8 @@ export function statusItem(model: Model): StatusItemState {
}
export function commandMsg(name: string): Msg | null {
if (name === "core.enable") return { kind: "enable" };
if (name === "core.disable") return { kind: "disable" };
if (name === "core.toggle") return { kind: "toggle" };
if (name === "core.refresh") return { kind: "refresh" };
if (name === "core.open-settings") return { kind: "open_settings" };
+33 -11
View File
@@ -64,6 +64,8 @@ fn e2eWindowView(ui: *App.Ui, model: *const fixture.Model, label: []const u8) Ap
fn e2eCommand(name: []const u8) ?fixture.Msg {
if (std.mem.eql(u8, name, "core.toggle")) return .toggle;
if (std.mem.eql(u8, name, "core.enable")) return .enable;
if (std.mem.eql(u8, name, "core.disable")) return .disable;
if (std.mem.eql(u8, name, "core.refresh")) return .refresh;
if (std.mem.eql(u8, name, "core.abort")) return .abort;
if (std.mem.eql(u8, name, "core.stamp")) return .stamp;
@@ -445,14 +447,24 @@ test "the compiled core's statusItem helper installs and updates title and menu"
try std.testing.expectEqual(native_sdk.platform.TrayTone.normal, installed_presentation.tone);
try std.testing.expectEqual(@as(f32, 1), installed_presentation.icon_opacity);
try std.testing.expect(installed_presentation.monospaced);
try std.testing.expectEqual(@as(usize, 3), h.harness.null_platform.trayItems().len);
try std.testing.expectEqualStrings("Pause polling…", h.harness.null_platform.trayItems()[0].label);
try std.testing.expectEqualStrings("configured ✓", h.harness.null_platform.trayItems()[0].detail);
try std.testing.expectEqual(native_sdk.platform.TrayItemRole.agent, h.harness.null_platform.trayItems()[0].role);
try std.testing.expect(h.harness.null_platform.trayItems()[1].separator);
try std.testing.expect(h.harness.null_platform.trayItems()[2].enabled);
try std.testing.expectEqualStrings("r", h.harness.null_platform.trayItems()[2].key);
try std.testing.expect(h.harness.null_platform.trayItems()[2].modifiers.primary);
try std.testing.expectEqual(@as(f32, 12), installed_presentation.font_size);
try std.testing.expectEqual(native_sdk.platform.TrayFontWeight.medium, installed_presentation.font_weight);
try std.testing.expectEqual(@as(usize, 6), h.harness.null_platform.trayItems().len);
try std.testing.expectEqualStrings("2,494 requests", h.harness.null_platform.trayItems()[0].metric.?.primary_text);
try std.testing.expectEqualStrings("Today · production", h.harness.null_platform.trayItems()[0].metric.?.secondary_text);
const segmented = h.harness.null_platform.trayItems()[1].segmented.?;
try std.testing.expectEqual(@as(usize, 2), segmented.options.len);
try std.testing.expectEqualStrings("core.enable", segmented.options[0].command);
try std.testing.expect(segmented.options[0].selected);
try std.testing.expectEqualStrings("Load rising", h.harness.null_platform.trayItems()[2].chart.?.accessibility_label);
try std.testing.expectEqual(@as(f32, 0.75), h.harness.null_platform.trayItems()[2].chart.?.values[2]);
try std.testing.expectEqualStrings("Pause polling…", h.harness.null_platform.trayItems()[3].label);
try std.testing.expectEqualStrings("configured ✓", h.harness.null_platform.trayItems()[3].detail);
try std.testing.expectEqual(native_sdk.platform.TrayItemRole.agent, h.harness.null_platform.trayItems()[3].role);
try std.testing.expect(h.harness.null_platform.trayItems()[4].separator);
try std.testing.expect(h.harness.null_platform.trayItems()[5].enabled);
try std.testing.expectEqualStrings("r", h.harness.null_platform.trayItems()[5].key);
try std.testing.expect(h.harness.null_platform.trayItems()[5].modifiers.primary);
const menu_updates = h.harness.null_platform.trayUpdateCount();
const title_updates = h.harness.null_platform.trayTitleUpdateCount();
@@ -465,12 +477,22 @@ test "the compiled core's statusItem helper installs and updates title and menu"
try std.testing.expectEqual(@as(f32, 72), updated_presentation.width);
try std.testing.expectEqual(native_sdk.platform.TrayTone.warning, updated_presentation.tone);
try std.testing.expectEqual(@as(f32, 0.5), updated_presentation.icon_opacity);
try std.testing.expectEqualStrings("Resume polling…", h.harness.null_platform.trayItems()[0].label);
try std.testing.expectEqualStrings("warning ⚠", h.harness.null_platform.trayItems()[0].detail);
try std.testing.expect(!h.harness.null_platform.trayItems()[2].enabled);
try std.testing.expectEqual(@as(f32, 13), updated_presentation.font_size);
try std.testing.expectEqual(native_sdk.platform.TrayFontWeight.semibold, updated_presentation.font_weight);
try std.testing.expectEqualStrings("1,240 requests", h.harness.null_platform.trayItems()[0].metric.?.primary_text);
try std.testing.expect(!h.harness.null_platform.trayItems()[1].segmented.?.options[0].selected);
try std.testing.expect(h.harness.null_platform.trayItems()[1].segmented.?.options[1].selected);
try std.testing.expectEqualStrings("Load falling", h.harness.null_platform.trayItems()[2].chart.?.accessibility_label);
try std.testing.expectEqualStrings("Resume polling…", h.harness.null_platform.trayItems()[3].label);
try std.testing.expectEqualStrings("warning ⚠", h.harness.null_platform.trayItems()[3].detail);
try std.testing.expect(!h.harness.null_platform.trayItems()[5].enabled);
try std.testing.expectEqual(title_updates + 1, h.harness.null_platform.trayTitleUpdateCount());
try std.testing.expectEqual(menu_updates + 1, h.harness.null_platform.trayUpdateCount());
try std.testing.expectEqual(@as(usize, 1), h.harness.null_platform.trayCreateCount());
// A segment's stable id uses the exact ordinary tray action path.
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .tray_action = .{ .item_id = 11 } });
try std.testing.expect(Bridge.model().polling);
}
test "requests round-trip, replace, and cancel through the real dispatch path" {