fix(tray): harden rich row contracts
This commit is contained in:
@@ -188,7 +188,7 @@ 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.
|
||||
|
||||
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 `fontSize` (`0` keeps the platform default), `fontWeight` (`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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -92,8 +92,6 @@ export function statusItem(model: Model): StatusItemState {
|
||||
tone: "normal",
|
||||
iconOpacity: 1,
|
||||
monospaced: true,
|
||||
fontSize: 0,
|
||||
fontWeight: "regular",
|
||||
},
|
||||
items: [
|
||||
{ id: 1, label: utf8Bytes("Open Player"), 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 } },
|
||||
|
||||
Vendored
+4
-4
@@ -16,15 +16,15 @@ 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;
|
||||
}
|
||||
readonly fontSize?: number;
|
||||
readonly fontWeight?: StatusItemFontWeight;
|
||||
};
|
||||
export interface StatusItemSegmentOption {
|
||||
readonly id: number;
|
||||
readonly label: Uint8Array;
|
||||
|
||||
@@ -63,8 +63,9 @@ 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";
|
||||
@@ -79,15 +80,17 @@ 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;
|
||||
readonly fontSize: number;
|
||||
readonly fontWeight: StatusItemFontWeight;
|
||||
}
|
||||
/// 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;
|
||||
|
||||
@@ -1039,7 +1039,13 @@ 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 numericFontSize = fontSize !== undefined && ["number", "i64", "f64", "numAlias"].includes(fontSize.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 &&
|
||||
@@ -1049,8 +1055,8 @@ export class SubsetChecker {
|
||||
enumMembersAre(tone, ["normal", "warning", "critical"]) &&
|
||||
numericOpacity &&
|
||||
monospaced?.type.k === "bool" &&
|
||||
numericFontSize &&
|
||||
enumMembersAre(fontWeight, ["regular", "medium", "semibold", "bold"]) &&
|
||||
optionalNumeric(fontSize) &&
|
||||
optionalEnumMembersAre(fontWeight, ["regular", "medium", "semibold", "bold"]) &&
|
||||
item !== undefined &&
|
||||
itemNames.join(",") === "chart,command,detail,enabled,id,key,label,metric,modifiers,role,segmented,separator" &&
|
||||
numericId &&
|
||||
@@ -1435,12 +1441,18 @@ export class SubsetChecker {
|
||||
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 numericFontSize = fontSize !== undefined && ["number", "i64", "f64", "numAlias"].includes(fontSize.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" &&
|
||||
@@ -1453,8 +1465,8 @@ export class SubsetChecker {
|
||||
enumMembersAre(tone, ["normal", "warning", "critical"]) &&
|
||||
numericOpacity &&
|
||||
monospaced?.type.k === "bool" &&
|
||||
numericFontSize &&
|
||||
enumMembersAre(fontWeight, ["regular", "medium", "semibold", "bold"]) &&
|
||||
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" &&
|
||||
|
||||
@@ -344,7 +344,8 @@ export class TypedAst {
|
||||
/// a shape this walk cannot carry whole refuses as an unsupported
|
||||
/// alias instead of registering a struct with silently missing fields.
|
||||
/// A tiny closed set of SDK-owned shell records carries omission
|
||||
/// intentionally (ThemeState inheritance and optional rich tray payloads);
|
||||
/// 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 {
|
||||
|
||||
@@ -333,7 +333,7 @@ export class TypeTable {
|
||||
|
||||
private isCanonicalOptionalSdkRecord(decl: ts.TypeAliasDeclaration): boolean {
|
||||
const events = sdkLibraryModules.get("@native-sdk/core/events");
|
||||
return (decl.name.text === "ThemeState" || decl.name.text === "StatusItemMenuItem") && 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1240,7 +1240,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
activationCommand: asciiBytes("refresh"),
|
||||
alternateActivationCommand: asciiBytes("toggle"),
|
||||
openCommand: asciiBytes("refresh"),
|
||||
presentation: { title: asciiBytes(model.playing ? "MB on" : "MB"), width: 52, tone: "normal", iconOpacity: 1, monospaced: true, fontSize: 13, fontWeight: "semibold" },
|
||||
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 }] } },
|
||||
|
||||
@@ -185,7 +185,7 @@ export function statusItem(model: Model): StatusItemState {
|
||||
activationCommand: asciiBytes("refresh"),
|
||||
alternateActivationCommand: asciiBytes("toggle"),
|
||||
openCommand: asciiBytes("refresh"),
|
||||
presentation: { title: asciiBytes(model.playing ? "MB on" : "MB"), width: 52, tone: "normal", iconOpacity: 1, monospaced: true, fontSize: 13, fontWeight: "semibold" },
|
||||
presentation: { title: asciiBytes(model.playing ? "MB on" : "MB"), width: 52, tone: "normal", iconOpacity: 1, monospaced: true },
|
||||
items: [{ id: 1, label: asciiBytes("Toggle"), command: asciiBytes("toggle"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } }],
|
||||
};
|
||||
}
|
||||
@@ -203,6 +203,13 @@ export function statusItem(model: Model): StatusItemState {
|
||||
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", () => {
|
||||
|
||||
@@ -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`, `iconOpacity`, `monospaced`, `fontSize` (0 = platform default), and `fontWeight` (`regular | medium | semibold | bold`). 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -324,10 +324,15 @@ const NullStatusItem = struct {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1802,7 +1807,16 @@ pub const NullPlatform = struct {
|
||||
status_item.items[index] = item;
|
||||
if (item.segmented) |segmented| {
|
||||
const start = index * max_tray_segment_options;
|
||||
@memcpy(status_item.segment_options[start .. start + segmented.options.len], segmented.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| {
|
||||
@@ -1815,7 +1829,14 @@ pub const NullPlatform = struct {
|
||||
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];
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -1583,10 +1611,9 @@ fn updateTrayMenu(context: ?*anyopaque, status_item_id: platform_mod.StatusItemI
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const raw_label = if (item.metric) |metric| metric.primary_text else if (item.chart) |chart| chart.leading_caption else item.label;
|
||||
const raw_detail = if (item.metric) |metric| metric.secondary_text else if (item.chart) |chart| chart.trailing_summary else item.detail;
|
||||
const label = escapeMenuLabelAmpersands(raw_label, &label_pool, &pool_used);
|
||||
const detail = escapeMenuLabelAmpersands(raw_detail, &label_pool, &pool_used);
|
||||
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;
|
||||
@@ -2205,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -236,6 +236,22 @@ pub fn RuntimeSystemServices(comptime Runtime: type) type {
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn trayItemExists(self: *const Runtime, item_id: platform.TrayItemId) bool {
|
||||
return self.statusItemMenuItemExists(platform.primary_status_item_id, item_id);
|
||||
}
|
||||
|
||||
@@ -850,8 +850,8 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
.tone = statusItemTone(presentation.tone),
|
||||
.icon_opacity = statusItemFloat(presentation.iconOpacity),
|
||||
.monospaced = presentation.monospaced,
|
||||
.font_size = statusItemFloat(presentation.fontSize),
|
||||
.font_weight = statusItemFontWeight(presentation.fontWeight),
|
||||
.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,
|
||||
@@ -952,8 +952,8 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
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 or
|
||||
!statusItemNumericType(@FieldType(Presentation, "fontSize")) or
|
||||
!statusItemEnumType(@FieldType(Presentation, "fontWeight"), &.{ "regular", "medium", "semibold", "bold" }))
|
||||
!optionalNumericType(@FieldType(Presentation, "fontSize")) or
|
||||
!optionalEnumType(@FieldType(Presentation, "fontWeight"), &.{ "regular", "medium", "semibold", "bold" }))
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
@@ -1028,8 +1028,8 @@ pub fn TsUiApp(comptime core: type) type {
|
||||
@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 or
|
||||
!statusItemNumericType(@FieldType(Presentation, "fontSize")) or
|
||||
!statusItemEnumType(@FieldType(Presentation, "fontWeight"), &.{ "regular", "medium", "semibold", "bold" }))
|
||||
!optionalNumericType(@FieldType(Presentation, "fontSize")) or
|
||||
!optionalEnumType(@FieldType(Presentation, "fontWeight"), &.{ "regular", "medium", "semibold", "bold" }))
|
||||
{
|
||||
@compileError(teaching);
|
||||
}
|
||||
@@ -1661,8 +1661,8 @@ const StatusItemsAdapterTestCore = struct {
|
||||
tone: Tone,
|
||||
iconOpacity: f64,
|
||||
monospaced: bool,
|
||||
fontSize: f64,
|
||||
fontWeight: enum { regular, medium, semibold, bold },
|
||||
fontSize: ?f64,
|
||||
fontWeight: ?enum { regular, medium, semibold, bold },
|
||||
};
|
||||
const SegmentOption = struct {
|
||||
id: f64,
|
||||
@@ -1733,7 +1733,7 @@ const StatusItemsAdapterTestCore = struct {
|
||||
.activationCommand = "spend.open",
|
||||
.alternateActivationCommand = "",
|
||||
.openCommand = "spend.refresh",
|
||||
.presentation = .{ .title = "$7", .width = 52, .tone = .warning, .iconOpacity = 0.75, .monospaced = true, .fontSize = 13, .fontWeight = .semibold },
|
||||
.presentation = .{ .title = "$7", .width = 52, .tone = .warning, .iconOpacity = 0.75, .monospaced = true, .fontSize = null, .fontWeight = null },
|
||||
.items = &rows,
|
||||
}};
|
||||
|
||||
@@ -1755,6 +1755,8 @@ test "TypeScript statusItems adapter validates and projects canonical descriptor
|
||||
try std.testing.expectEqual(@as(platform.StatusItemId, 7), descriptors[0].id);
|
||||
try std.testing.expect(!descriptors[0].visible);
|
||||
try std.testing.expectEqualStrings("$7", descriptors[0].state.presentation.title);
|
||||
try std.testing.expectEqual(@as(f32, 0), descriptors[0].state.presentation.font_size);
|
||||
try std.testing.expectEqual(platform.TrayFontWeight.regular, descriptors[0].state.presentation.font_weight);
|
||||
try std.testing.expectEqualStrings("spend.png", descriptors[0].state.icon_path);
|
||||
try std.testing.expectEqual(@as(usize, 1), descriptors[0].state.items.len);
|
||||
try std.testing.expectEqual(@as(platform.TrayItemId, 3), descriptors[0].state.items[0].id);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user