Files
2026-07-13 12:29:49 +08:00

169 lines
6.2 KiB
TypeScript

// The markup-view fixture core: a small task board whose entire view is
// tests/ts-core/markup_view.native binding this model — a record-array
// list (for each + key), an optional scalar gate (<if>), an enum filter
// (string-literal union), bytes text, and camelCase fields that markup
// binds by their own names (the emitted struct keeps the TS spellings,
// so `{nextId}` binds `nextId`). Effects stay minimal (one
// Cmd.now) so the markup e2e suite pins the view/automation/pixel
// guarantees, not the effect vocabulary (host_e2e covers that).
// Transpiled at build time by the repo's own transpiler and driven by
// tests/ts-core/markup_e2e_tests.zig.
import { Cmd, Sub, asciiBytes } from "@native-sdk/core";
// The SDK-provided event records: the same structural shapes a core may
// declare in-file, imported instead (markup's on-input and the wiring
// channels match structurally either way — this fixture pins the import
// path end-to-end through the real markup suite).
import {
type TextInputEvent,
type FrameEvent,
type KeyEvent,
type ColorScheme,
type ChromeInsets,
type ChromeButtons,
} from "@native-sdk/core/events";
export type Filter = "all" | "open" | "done";
export interface Task {
readonly id: number;
readonly title: Uint8Array;
readonly done: boolean;
}
export interface Model {
readonly filter: Filter;
readonly nextId: number;
readonly doneCount: number;
readonly banner: Uint8Array;
readonly selected: number | null;
readonly tasks: readonly Task[];
readonly stampMs: number;
readonly draft: Uint8Array;
/// The frame channel's width mirror (the album-grid derivation shape).
readonly canvasWidth: number;
/// The appearance channel's scheme mirror.
readonly dark: boolean;
/// The chrome channel's titlebar band mirror.
readonly chromeTop: number;
}
export type Msg =
| { readonly kind: "add" }
| { readonly kind: "toggle"; readonly id: number }
| { readonly kind: "pick"; readonly id: number }
| { readonly kind: "cycle" }
| { readonly kind: "clear" }
| { readonly kind: "stamp" }
| { readonly kind: "stamped"; readonly at: number }
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
| { readonly kind: "canvas_resized"; readonly width: number }
| { readonly kind: "appearance_changed"; readonly colorScheme: ColorScheme; readonly reduceMotion: boolean; readonly highContrast: boolean }
| { readonly kind: "chrome_changed"; readonly insets: ChromeInsets; readonly buttons: ChromeButtons; readonly tabsProjected: boolean }
| { readonly kind: "banner_set"; readonly value: Uint8Array };
/// Presented frames dispatch ONLY on a width change (the idle law: a
/// frame that changes nothing dispatches nothing, so the channel starves
/// when the app is idle).
export function frameMsg(model: Model, frame: FrameEvent): Msg | null {
if (frame.width !== model.canvasWidth) return { kind: "canvas_resized", width: frame.width };
return null;
}
/// The desktop key conventions on the app-level fallback: space cycles
/// the filter, plain "c" clears — modifier chords stay the chrome's.
export function keyMsg(key: KeyEvent): Msg | null {
if (key.control || key.alt || key.super || key.shift) return null;
if (key.key === "space") return { kind: "cycle" };
if (key.key === "c") return { kind: "clear" };
return null;
}
export const appearanceMsg = "appearance_changed";
export const chromeMsg = "chrome_changed";
export const envMsgs = [{ env: "TS_BOARD_BANNER", msg: "banner_set" }] as const;
export function initialModel(): Model {
return {
filter: "all",
nextId: 1,
doneCount: 0,
banner: asciiBytes("ready"),
selected: null,
tasks: [],
stampMs: -1,
draft: new Uint8Array(0),
canvasWidth: 0,
dark: false,
chromeTop: 0,
};
}
// A deliberately small draft reducer: append, backspace, clear — enough to
// prove the markup -> core -> re-render loop; caret/selection fidelity is
// the full text engine's job (see the inbox gate fixture).
function applyDraftEdit(draft: Uint8Array, edit: TextInputEvent): Uint8Array {
switch (edit.kind) {
case "insert_text": {
const out = new Uint8Array(draft.length + edit.text.length);
out.set(draft, 0);
out.set(edit.text, draft.length);
return out;
}
case "delete_backward":
return draft.length === 0 ? draft : draft.subarray(0, draft.length - 1);
case "clear":
return new Uint8Array(0);
case "delete_forward":
case "delete_word_backward":
case "delete_word_forward":
case "move_caret":
case "set_selection":
case "set_composition":
case "commit_composition":
case "cancel_composition":
return draft;
}
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "add": {
const slot = model.nextId % 3;
const title = slot === 0 ? asciiBytes("alpha") : slot === 1 ? asciiBytes("beta") : asciiBytes("gamma");
const added: Task = { id: model.nextId, title: title, done: false };
return { ...model, tasks: [...model.tasks, added], nextId: model.nextId + 1 };
}
case "toggle": {
const next = model.tasks.map((t) => (t.id === msg.id ? { ...t, done: !t.done } : t));
let done = 0;
for (let i = 0; i < next.length; i++) {
if (next[i].done) {
done = done + 1;
}
}
return { ...model, tasks: next, doneCount: done };
}
case "pick":
return { ...model, selected: msg.id };
case "cycle":
return { ...model, filter: model.filter === "all" ? "open" : model.filter === "open" ? "done" : "all" };
case "clear":
return { ...model, tasks: [], doneCount: 0, selected: null, banner: asciiBytes("cleared") };
case "stamp":
return [model, Cmd.now("stamped")];
case "stamped":
return { ...model, stampMs: msg.at };
case "draft_edit":
return { ...model, draft: applyDraftEdit(model.draft, msg.edit) };
case "canvas_resized":
return { ...model, canvasWidth: msg.width };
case "appearance_changed":
return { ...model, dark: msg.colorScheme === "dark" };
case "chrome_changed":
return { ...model, chromeTop: msg.insets.top };
case "banner_set":
return { ...model, banner: msg.value };
}
}