diff --git a/examples/rich-document/app.zon b/examples/rich-document/app.zon new file mode 100644 index 000000000..3c9836e73 --- /dev/null +++ b/examples/rich-document/app.zon @@ -0,0 +1,33 @@ +.{ + .id = "dev.native_sdk.rich-document", + .name = "rich-document", + .display_name = "Rich Document", + .description = "Paragraph-scoped attributed editing dogfood: TextBuffer + style runs via .", + .version = "0.1.0", + .platforms = .{"macos"}, + .permissions = .{"view"}, + .capabilities = .{ "native_views", "gpu_surfaces" }, + .shell = .{ + .windows = .{ + .{ + .label = "main", + .title = "Rich Document", + .width = 640, + .height = 480, + .min_width = 480, + .min_height = 360, + .restore_policy = "center_on_primary", + .views = .{ + .{ .label = "doc-canvas", .kind = "gpu_surface", .fill = true, .role = "Rich document canvas", .accessibility_label = "Multi-block markdown editor", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true }, + }, + }, + }, + }, + .security = .{ + .navigation = .{ + .allowed_origins = .{ "zero://app", "zero://inline" }, + .external_links = .{ .action = "deny" }, + }, + }, + .web_engine = "system", +} diff --git a/examples/rich-document/src/app.native b/examples/rich-document/src/app.native new file mode 100644 index 000000000..17bb93964 --- /dev/null +++ b/examples/rich-document/src/app.native @@ -0,0 +1,48 @@ + + Rich document + + Multi-block GFM — headings and lists paint as chrome; bodies stay plain bytes. + + + + + + + + + + {status} + + + + + + + + + {b.marker} + + + + + + + + Serialized GFM + + + + diff --git a/examples/rich-document/src/core.ts b/examples/rich-document/src/core.ts new file mode 100644 index 000000000..c03d208d2 --- /dev/null +++ b/examples/rich-document/src/core.ts @@ -0,0 +1,281 @@ +// Multi-block GFM dogfood: text_doc ops + one focused rich-textarea body. + +import { asciiBytes, utf8Bytes } from "@native-sdk/core"; +import { + type TextInputEvent, + type TextSelection, +} from "@native-sdk/core/text"; +import { + applyAttributedTextInputEvent, + deserializeStyleRuns, + serializeStyleRuns, + toggleStyleOnSelection, + type StyleFlag, +} from "@native-sdk/core/text-attr"; +import { + changeBlockKind, + mergeWithPrevious, + parseBlocks, + serializeBlocks, + splitBlock, + type BlockKind, + type DocBlock, +} from "@native-sdk/core/text-doc"; + +const CAPACITY = 8192; +const CARET0: TextSelection = { anchor: 0, focus: 0 }; + +export type Bytes = Uint8Array; + +export interface BlockRow { + readonly index: number; + readonly kind_label: Bytes; + readonly preview: Bytes; + readonly marker: Bytes; + readonly focused: boolean; +} + +export interface Model { + readonly blocks: readonly DocBlock[]; + readonly focus: number; + readonly editorText: Bytes; + readonly editorSel: TextSelection; + readonly editorStyles: Bytes; + readonly status: Bytes; + readonly blockRows: readonly BlockRow[]; + readonly serialized: Bytes; +} + +export type Msg = + | { readonly kind: "edit"; readonly edit: TextInputEvent } + | { readonly kind: "focus"; readonly index: number } + | { readonly kind: "bold" } + | { readonly kind: "italic" } + | { readonly kind: "kind_h1" } + | { readonly kind: "kind_h2" } + | { readonly kind: "kind_p" } + | { readonly kind: "kind_bullet" }; + +function kindLabel(kind: BlockKind): Bytes { + switch (kind) { + case "heading1": + return asciiBytes("H1"); + case "heading2": + return asciiBytes("H2"); + case "heading3": + return asciiBytes("H3"); + case "bullet_item": + return asciiBytes("*"); + case "numbered_item": + return asciiBytes("1."); + case "code_fence": + return asciiBytes("code"); + case "paragraph": + return asciiBytes("P"); + } +} + +function markerFor(kind: BlockKind): Bytes { + switch (kind) { + case "bullet_item": + return asciiBytes("*"); + case "numbered_item": + return asciiBytes("1."); + default: + return asciiBytes(""); + } +} + +function previewOf(text: Uint8Array): Bytes { + if (text.length <= 48) { + const out = new Uint8Array(text.length); + out.set(text); + return out; + } + return text.subarray(0, 48); +} + +function copyBytes(b: Uint8Array): Bytes { + const out = new Uint8Array(b.length); + out.set(b); + return out; +} + +function buildRows(blocks: readonly DocBlock[], focus: number): BlockRow[] { + const rows: BlockRow[] = []; + const n = blocks.length; + if (!(n >= 0) || !(n <= 256)) return rows; + let fi = 0; + if (focus >= 0 && focus < n) fi = Math.trunc(focus); + for (let i = 0; i < n; i++) { + const b = blocks[i]!; + rows.push({ + index: i, + kind_label: kindLabel(b.kind), + preview: previewOf(b.text), + marker: markerFor(b.kind), + focused: i === fi, + }); + } + return rows; +} + +function withBlocks(model: Model, blocks: readonly DocBlock[], focus: number): Model { + const n = blocks.length; + let clamped = 0; + if (n >= 1 && n <= 256) { + if (focus >= 0 && focus < n) clamped = Math.trunc(focus); + else if (focus >= n) clamped = Math.trunc(n - 1); + } + const block = blocks[clamped]!; + return { + ...model, + blocks, + focus: clamped, + editorText: copyBytes(block.text), + editorSel: CARET0, + editorStyles: new Uint8Array(0), + blockRows: buildRows(blocks, clamped), + serialized: serializeBlocks(blocks), + }; +} + +function commitEditor(model: Model): Model { + const blocks: DocBlock[] = []; + for (let i = 0; i < model.blocks.length; i++) { + const b = model.blocks[i]!; + if (i === model.focus) { + blocks.push({ + kind: b.kind, + text: copyBytes(model.editorText), + language: copyBytes(b.language), + }); + } else { + blocks.push({ + kind: b.kind, + text: copyBytes(b.text), + language: copyBytes(b.language), + }); + } + } + return { + ...model, + blocks, + blockRows: buildRows(blocks, model.focus), + serialized: serializeBlocks(blocks), + }; +} + +function setKind(model: Model, kind: BlockKind): Model { + const committed = commitEditor(model); + const next = changeBlockKind(committed.blocks, committed.focus, kind); + if (!next) return model; + return { + ...withBlocks(committed, next, committed.focus), + status: asciiBytes(kind), + }; +} + +export function initialModel(): Model { + const seed = utf8Bytes( + "# Untitled\n\nWrite the next paragraph.\n\n- First bullet", + ); + const blocks = parseBlocks(seed); + const base: Model = { + blocks, + focus: 0, + editorText: new Uint8Array(0), + editorSel: CARET0, + editorStyles: new Uint8Array(0), + status: asciiBytes("ready"), + blockRows: [], + serialized: new Uint8Array(0), + }; + return withBlocks(base, blocks, 0); +} + +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { + case "focus": { + const committed = commitEditor(model); + return withBlocks(committed, committed.blocks, msg.index); + } + case "edit": { + if ( + msg.edit.kind === "insert_text" && + msg.edit.text.length === 1 && + msg.edit.text[0] === 10 + ) { + const committed = commitEditor(model); + const caret = committed.editorSel.focus; + const split = splitBlock(committed.blocks, committed.focus, caret); + if (!split) return model; + return { + ...withBlocks(committed, split, committed.focus + 1), + status: asciiBytes("split"), + }; + } + if ( + msg.edit.kind === "delete_backward" && + model.editorSel.anchor === model.editorSel.focus && + model.editorSel.focus === 0 && + model.focus > 0 + ) { + const committed = commitEditor(model); + const prev = committed.blocks[committed.focus - 1]!; + const rawLen = prev.text.length; + let leftLen = 0; + if (rawLen >= 0 && rawLen <= 8192) leftLen = Math.trunc(rawLen); + const merged = mergeWithPrevious(committed.blocks, committed.focus); + if (!merged) return model; + const next = withBlocks(committed, merged, committed.focus - 1); + return { + ...next, + editorSel: { anchor: leftLen, focus: leftLen }, + status: asciiBytes("merge"), + }; + } + const next = applyAttributedTextInputEvent( + { + text: model.editorText, + selection: model.editorSel, + composition: null, + runs: deserializeStyleRuns(model.editorStyles), + }, + msg.edit, + CAPACITY, + ); + if (!next) return model; + const mid = { + ...model, + editorText: next.text, + editorSel: next.selection, + editorStyles: serializeStyleRuns(next.runs), + }; + return commitEditor(mid); + } + case "bold": + case "italic": { + const flag: StyleFlag = msg.kind === "bold" ? "bold" : "italic"; + const runs = toggleStyleOnSelection( + deserializeStyleRuns(model.editorStyles), + model.editorSel, + model.editorText.length, + flag, + ); + return { + ...model, + editorStyles: serializeStyleRuns(runs), + status: asciiBytes(flag), + }; + } + case "kind_h1": + return setKind(model, "heading1"); + case "kind_h2": + return setKind(model, "heading2"); + case "kind_p": + return setKind(model, "paragraph"); + case "kind_bullet": + return setKind(model, "bullet_item"); + } +} diff --git a/examples/rich-textarea/README.md b/examples/rich-textarea/README.md new file mode 100644 index 000000000..672394656 --- /dev/null +++ b/examples/rich-textarea/README.md @@ -0,0 +1,13 @@ +# Rich Textarea + +Paragraph-scoped attributed editing on the existing `TextBuffer` path. + +- Markup: `` (stamps `WidgetRuntimeFlags.rich_editor`) +- Style ops: `@native-sdk/core/text-attr` (Zig twin: `text_attr.zig`) +- v1 non-goals: nested spans, multi-block GFM, Lexical parity + +```bash +native run examples/rich-textarea +``` + +Dogfood app for the attributed-editing design before upstream merge. diff --git a/examples/rich-textarea/app.zon b/examples/rich-textarea/app.zon new file mode 100644 index 000000000..6b70f3f02 --- /dev/null +++ b/examples/rich-textarea/app.zon @@ -0,0 +1,33 @@ +.{ + .id = "dev.native_sdk.rich-textarea", + .name = "rich-textarea", + .display_name = "Rich Textarea", + .description = "Paragraph-scoped attributed editing dogfood: TextBuffer + style runs via .", + .version = "0.1.0", + .platforms = .{"macos"}, + .permissions = .{"view"}, + .capabilities = .{ "native_views", "gpu_surfaces" }, + .shell = .{ + .windows = .{ + .{ + .label = "main", + .title = "Rich Textarea", + .width = 640, + .height = 480, + .min_width = 480, + .min_height = 360, + .restore_policy = "center_on_primary", + .views = .{ + .{ .label = "rich-canvas", .kind = "gpu_surface", .fill = true, .role = "Rich textarea canvas", .accessibility_label = "Attributed text editor", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true }, + }, + }, + }, + }, + .security = .{ + .navigation = .{ + .allowed_origins = .{ "zero://app", "zero://inline" }, + .external_links = .{ .action = "deny" }, + }, + }, + .web_engine = "system", +} diff --git a/examples/rich-textarea/package.json b/examples/rich-textarea/package.json new file mode 100644 index 000000000..8adb566d0 --- /dev/null +++ b/examples/rich-textarea/package.json @@ -0,0 +1,8 @@ +{ + "name": "rich-textarea", + "private": true, + "description": "Editor surface for the rich-textarea example. The native CLI resolves @native-sdk/core.", + "dependencies": { + "@native-sdk/core": "0.10.1" + } +} diff --git a/examples/rich-textarea/src/app.native b/examples/rich-textarea/src/app.native new file mode 100644 index 000000000..4d110eb95 --- /dev/null +++ b/examples/rich-textarea/src/app.native @@ -0,0 +1,25 @@ + + Rich textarea + + Paragraph-scoped attributed editing — TextBuffer bytes + style runs. + + + + + + + {status} + + + GFM preview + + + + diff --git a/examples/rich-textarea/src/core.ts b/examples/rich-textarea/src/core.ts new file mode 100644 index 000000000..c49e676a3 --- /dev/null +++ b/examples/rich-textarea/src/core.ts @@ -0,0 +1,96 @@ +// Minimal attributed-editing dogfood: plain bytes + style runs, bold/italic +// toggles, GFM preview via text-attr helpers. + +import { asciiBytes, utf8Bytes } from "@native-sdk/core"; +import { + type TextInputEvent, + type TextSelection, +} from "@native-sdk/core/text"; +import { + applyAttributedTextInputEvent, + attributedToMarkdown, + deserializeStyleRuns, + serializeStyleRuns, + toggleStyleOnSelection, + type AttributedEditState, + type StyleFlag, +} from "@native-sdk/core/text-attr"; + +const CAPACITY = 8192; +const CARET0: TextSelection = { anchor: 0, focus: 0 }; + +export type Bytes = Uint8Array; + +export interface Model { + readonly draft: Bytes; + readonly draftSel: TextSelection; + readonly styles: Bytes; + readonly status: Bytes; +} + +export type Msg = + | { readonly kind: "edit"; readonly edit: TextInputEvent } + | { readonly kind: "bold" } + | { readonly kind: "italic" } + | { readonly kind: "strike" }; + +function attributedState(model: Model): AttributedEditState { + return { + text: model.draft, + selection: model.draftSel, + composition: null, + runs: deserializeStyleRuns(model.styles), + }; +} + +function toggle(model: Model, flag: StyleFlag): Model { + const runs = toggleStyleOnSelection( + deserializeStyleRuns(model.styles), + model.draftSel, + model.draft.length, + flag, + ); + return { + ...model, + styles: serializeStyleRuns(runs), + status: asciiBytes(flag), + }; +} + +export function initialModel(): Model { + return { + draft: utf8Bytes("Select words and tap Bold / Italic."), + draftSel: CARET0, + styles: new Uint8Array(0), + status: asciiBytes("ready"), + }; +} + +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { + case "edit": { + const next = applyAttributedTextInputEvent( + attributedState(model), + msg.edit, + CAPACITY, + ); + if (!next) return model; + return { + ...model, + draft: next.text, + draftSel: next.selection, + styles: serializeStyleRuns(next.runs), + }; + } + case "bold": + return toggle(model, "bold"); + case "italic": + return toggle(model, "italic"); + case "strike": + return toggle(model, "strikethrough"); + } +} + +export function preview(model: Model): Bytes { + return attributedToMarkdown(model.draft, deserializeStyleRuns(model.styles)); +} diff --git a/examples/rich-textarea/tsconfig.json b/examples/rich-textarea/tsconfig.json new file mode 100644 index 000000000..fb51666a0 --- /dev/null +++ b/examples/rich-textarea/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["esnext"], + "types": [], + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 01ea5b2b7..4b37cf85d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,14 @@ "types": "./sdk/text.ts", "default": "./sdk/text.ts" }, + "./text-attr": { + "types": "./sdk/text-attr.ts", + "default": "./sdk/text-attr.ts" + }, + "./text-doc": { + "types": "./sdk/text-doc.ts", + "default": "./sdk/text-doc.ts" + }, "./events": { "types": "./sdk/events.ts", "default": "./sdk/events.ts" diff --git a/packages/core/scripts/compiler_typecheck.mjs b/packages/core/scripts/compiler_typecheck.mjs index dd40da3ab..4d3d743da 100644 --- a/packages/core/scripts/compiler_typecheck.mjs +++ b/packages/core/scripts/compiler_typecheck.mjs @@ -43,6 +43,8 @@ for (const [specifier, file] of [ ["@native-sdk/core", "sdk/core.d.ts"], ["@native-sdk/core/text", "sdk/text.d.ts"], ["@native-sdk/core/events", "sdk/events.d.ts"], + ["@native-sdk/core/text-attr", "sdk/text-attr.d.ts"], + ["@native-sdk/core/text-doc", "sdk/text-doc.d.ts"], ]) { const p = specifier === "@native-sdk/core" && generatedCore !== null ? path.resolve(generatedCore) diff --git a/packages/core/scripts/stage_external_core.mjs b/packages/core/scripts/stage_external_core.mjs index ef6ba6fde..edc5338e8 100644 --- a/packages/core/scripts/stage_external_core.mjs +++ b/packages/core/scripts/stage_external_core.mjs @@ -75,6 +75,8 @@ function resolveSpecifiers(text, rel) { return text .replaceAll('"@native-sdk/core/text"', `"${toSdk("text.ts")}"`) .replaceAll('"@native-sdk/core/events"', `"${toSdk("events.ts")}"`) + .replaceAll('"@native-sdk/core/text-attr"', `"${toSdk("text-attr.ts")}"`) + .replaceAll('"@native-sdk/core/text-doc"', `"${toSdk("text-doc.ts")}"`) .replaceAll('"@native-sdk/core"', `"${toSdk("core.ts")}"`) .replaceAll('"@native-sdk/services"', `"${path.posix.relative(path.posix.dirname(rel), "services.gen.ts").startsWith(".") ? path.posix.relative(path.posix.dirname(rel), "services.gen.ts") : `./${path.posix.relative(path.posix.dirname(rel), "services.gen.ts")}`}"`) .replace(/readonly ([A-Za-z_][A-Za-z0-9_]*(?:<[A-Za-z_, ]*>)?)\[\]/g, "$1[]") @@ -134,7 +136,7 @@ for (const rel of authorFiles) { // Transform 5: the staged SDK library modules spell their exported // records as object-literal aliases (value storage). -for (const sdkFile of ["text.ts", "events.ts"]) { +for (const sdkFile of ["text.ts", "events.ts", "text-attr.ts", "text-doc.ts"]) { const staged = fs .readFileSync(path.join(args.sdk, sdkFile), "utf8") .replace(/^export interface ([A-Za-z0-9_]+) \{/gm, "export type $1 = {"); diff --git a/packages/core/sdk/text-attr.d.ts b/packages/core/sdk/text-attr.d.ts new file mode 100644 index 000000000..84d4ca941 --- /dev/null +++ b/packages/core/sdk/text-attr.d.ts @@ -0,0 +1,51 @@ +export type StyleFlag = + | "bold" + | "italic" + | "underline" + | "monospace" + | "strikethrough"; +export interface StyleFlags { + readonly bold: boolean; + readonly italic: boolean; + readonly underline: boolean; + readonly monospace: boolean; + readonly strikethrough: boolean; +} +export interface StyleRun { + readonly start: number; + readonly end: number; + readonly flags: StyleFlags; +} +export interface AttributedEditState { + readonly text: Uint8Array; + readonly selection: import("./text").TextSelection; + readonly composition: { readonly start: number; readonly end: number } | null; + readonly runs: readonly StyleRun[]; +} +export declare const MAX_STYLE_RUNS: number; +export declare const EMPTY_STYLE_FLAGS: StyleFlags; +export declare function serializeStyleRuns(runs: readonly StyleRun[]): Uint8Array; +export declare function deserializeStyleRuns(bytes: Uint8Array): StyleRun[]; +export declare function toggleStyleOnSelection( + runs: readonly StyleRun[], + selection: import("./text").TextSelection, + textLen: number, + flag: StyleFlag, +): StyleRun[]; +export declare function applyAttributedTextInputEvent( + state: AttributedEditState, + event: import("./text").TextInputEvent, + capacity: number, +): AttributedEditState | null; +export declare function attributedToMarkdown( + text: Uint8Array, + runs: readonly StyleRun[], +): Uint8Array; +export declare function pushStyleUndo( + stack: readonly Uint8Array[], + runs: readonly StyleRun[], + maxDepth: number, +): Uint8Array[]; +export declare function popStyleUndo( + stack: readonly Uint8Array[], +): { stack: Uint8Array[]; runs: StyleRun[] } | null; diff --git a/packages/core/sdk/text-attr.ts b/packages/core/sdk/text-attr.ts new file mode 100644 index 000000000..798021630 --- /dev/null +++ b/packages/core/sdk/text-attr.ts @@ -0,0 +1,448 @@ +// @native-sdk/core/text-attr — attributed style runs over TextBuffer bytes. +// Zig counterpart: src/primitives/canvas/text_attr.zig. Byte-identical +// contracts: offsets are UTF-8 bytes; max 32 runs; IME composition does +// not stamp persistent styles until commit/insert. + +import type { TextEditState, TextInputEvent, TextSelection } from "./text.ts"; +import { applyTextInputEvent } from "./text.ts"; + +export const MAX_STYLE_RUNS = 32; + +export type StyleFlag = + | "bold" + | "italic" + | "underline" + | "monospace" + | "strikethrough"; + +export interface StyleFlags { + readonly bold: boolean; + readonly italic: boolean; + readonly underline: boolean; + readonly monospace: boolean; + readonly strikethrough: boolean; +} + +export interface StyleRun { + readonly start: number; + readonly end: number; + readonly flags: StyleFlags; +} + +export interface AttributedEditState { + readonly text: Uint8Array; + readonly selection: TextSelection; + readonly composition: { readonly start: number; readonly end: number } | null; + readonly runs: readonly StyleRun[]; +} + +export const EMPTY_STYLE_FLAGS: StyleFlags = { + bold: false, + italic: false, + underline: false, + monospace: false, + strikethrough: false, +}; + +function flagsByte(f: StyleFlags): number { + return ( + (f.bold ? 1 : 0) | + (f.italic ? 2 : 0) | + (f.underline ? 4 : 0) | + (f.monospace ? 8 : 0) | + (f.strikethrough ? 16 : 0) + ); +} + +function flagsFromByte(b: number): StyleFlags { + return { + bold: (b & 1) !== 0, + italic: (b & 2) !== 0, + underline: (b & 4) !== 0, + monospace: (b & 8) !== 0, + strikethrough: (b & 16) !== 0, + }; +} + +function flagsEql(a: StyleFlags, b: StyleFlags): boolean { + return flagsByte(a) === flagsByte(b); +} + +function flagsEmpty(f: StyleFlags): boolean { + return flagsByte(f) === 0; +} + +function flagsWith(f: StyleFlags, flag: StyleFlag, on: boolean): StyleFlags { + return { + bold: flag === "bold" ? on : f.bold, + italic: flag === "italic" ? on : f.italic, + underline: flag === "underline" ? on : f.underline, + monospace: flag === "monospace" ? on : f.monospace, + strikethrough: flag === "strikethrough" ? on : f.strikethrough, + }; +} + +function flagOn(f: StyleFlags, flag: StyleFlag): boolean { + switch (flag) { + case "bold": + return f.bold; + case "italic": + return f.italic; + case "underline": + return f.underline; + case "monospace": + return f.monospace; + case "strikethrough": + return f.strikethrough; + } +} + +function normRange(start: number, end: number, textLen: number): { start: number; end: number } { + const s = Math.min(start, textLen); + const e = Math.min(end, textLen); + return s <= e ? { start: s, end: e } : { start: e, end: s }; +} + +function selectionRange(sel: TextSelection, textLen: number): { start: number; end: number } { + return normRange(sel.anchor, sel.focus, textLen); +} + +export function normalizeStyleRuns(runs: readonly StyleRun[]): StyleRun[] { + const out: StyleRun[] = []; + for (const run of runs) { + if (run.end <= run.start) continue; + const last = out.length > 0 ? out[out.length - 1] : null; + if (last && flagsEql(last.flags, run.flags) && last.end === run.start) { + out[out.length - 1] = { start: last.start, end: run.end, flags: last.flags }; + continue; + } + if (out.length >= MAX_STYLE_RUNS) break; + out.push(run); + } + return out; +} + +function styleAt(runs: readonly StyleRun[], offset: number): StyleFlags { + for (const run of runs) { + if (offset >= run.start && offset < run.end) return run.flags; + if (offset === run.end && run.end > run.start) return run.flags; + } + let best: StyleRun | null = null; + for (const run of runs) { + if (run.end <= offset && run.end > run.start) { + if (!best || run.end > best.end) best = run; + } + } + return best ? best.flags : EMPTY_STYLE_FLAGS; +} + +export function mapStyleRunsThroughReplace( + runs: readonly StyleRun[], + rangeStart: number, + rangeEnd: number, + insertedLen: number, + inherit: StyleFlags, +): StyleRun[] { + const delStart = rangeStart; + const delEnd = rangeEnd; + const delLen = delEnd > delStart ? delEnd - delStart : 0; + const out: StyleRun[] = []; + + for (const run of runs) { + let start = run.start; + let end = run.end; + if (end <= delStart) { + // before + } else if (start >= delEnd) { + start = start - delLen + insertedLen; + end = end - delLen + insertedLen; + } else if (start < delStart) { + end = delStart; + } else if (end > delEnd) { + start = delStart + insertedLen; + end = end - delLen + insertedLen; + } else { + continue; + } + if (end <= start) continue; + if (out.length >= MAX_STYLE_RUNS) break; + out.push({ start, end, flags: run.flags }); + } + + if (insertedLen > 0 && !flagsEmpty(inherit) && out.length < MAX_STYLE_RUNS) { + out.push({ + start: delStart, + end: delStart + insertedLen, + flags: inherit, + }); + } + return normalizeStyleRuns(out); +} + +export function toggleStyleOnSelection( + runs: readonly StyleRun[], + selection: TextSelection, + textLen: number, + flag: StyleFlag, +): StyleRun[] { + const range = selectionRange(selection, textLen); + if (range.start === range.end || range.start >= textLen) { + return runs.slice(0, MAX_STYLE_RUNS); + } + const selStart = range.start; + const selEnd = Math.min(range.end, textLen); + const sample = styleAt(runs, selStart); + const turnOn = !flagOn(sample, flag); + const scratch: StyleRun[] = []; + + for (const run of runs) { + if (run.end <= selStart) scratch.push(run); + } + let covered = false; + for (const run of runs) { + if (run.end <= selStart || run.start >= selEnd) continue; + covered = true; + if (run.start < selStart) { + scratch.push({ start: run.start, end: selStart, flags: run.flags }); + } + const midStart = Math.max(run.start, selStart); + const midEnd = Math.min(run.end, selEnd); + if (midEnd > midStart) { + scratch.push({ + start: midStart, + end: midEnd, + flags: flagsWith(run.flags, flag, turnOn), + }); + } + if (run.end > selEnd) { + scratch.push({ start: selEnd, end: run.end, flags: run.flags }); + } + } + if (!covered) { + scratch.push({ + start: selStart, + end: selEnd, + flags: flagsWith(EMPTY_STYLE_FLAGS, flag, turnOn), + }); + } + for (const run of runs) { + if (run.start >= selEnd) scratch.push(run); + } + return normalizeStyleRuns(scratch); +} + +function activeReplaceRange(state: AttributedEditState): { start: number; end: number } { + if (state.composition) return state.composition; + return selectionRange(state.selection, state.text.length); +} + +export function applyAttributedTextInputEvent( + state: AttributedEditState, + event: TextInputEvent, + capacity: number, +): AttributedEditState | null { + const plain: TextEditState = { + text: state.text, + selection: state.selection, + composition: state.composition, + }; + const next = applyTextInputEvent(plain, event, capacity); + if (!next) return null; + + if ( + event.kind === "move_caret" || + event.kind === "set_selection" || + event.kind === "commit_composition" + ) { + return { + text: next.text, + selection: next.selection, + composition: next.composition, + runs: state.runs, + }; + } + + const beforeLen = state.text.length; + const replace = activeReplaceRange(state); + const deleted = replace.end - replace.start; + const insertedLen = next.text.length + deleted - beforeLen; + const inherit = + event.kind === "set_composition" + ? EMPTY_STYLE_FLAGS + : styleAt(state.runs, selectionRange(state.selection, beforeLen).start); + + const runs = mapStyleRunsThroughReplace( + state.runs, + replace.start, + replace.end, + Math.max(0, insertedLen), + inherit, + ); + + return { + text: next.text, + selection: next.selection, + composition: next.composition, + runs, + }; +} + +function writeU32LE(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = value & 0xff; + buf[offset + 1] = (value >>> 8) & 0xff; + buf[offset + 2] = (value >>> 16) & 0xff; + buf[offset + 3] = (value >>> 24) & 0xff; +} + +function readU32LE(buf: Uint8Array, offset: number): number { + return ( + (buf[offset]! | + (buf[offset + 1]! << 8) | + (buf[offset + 2]! << 16) | + (buf[offset + 3]! << 24)) >>> + 0 + ); +} + +export function serializeStyleRuns(runs: readonly StyleRun[]): Uint8Array { + const n = Math.min(runs.length, MAX_STYLE_RUNS); + const out = new Uint8Array(n * 9); + let o = 0; + for (let i = 0; i < n; i += 1) { + const run = runs[i]!; + writeU32LE(out, o, run.start); + writeU32LE(out, o + 4, run.end); + out[o + 8] = flagsByte(run.flags); + o += 9; + } + return out.subarray(0, o); +} + +export function deserializeStyleRuns(bytes: Uint8Array): StyleRun[] { + const out: StyleRun[] = []; + for (let i = 0; i + 9 <= bytes.length && out.length < MAX_STYLE_RUNS; i += 9) { + out.push({ + start: readU32LE(bytes, i), + end: readU32LE(bytes, i + 4), + flags: flagsFromByte(bytes[i + 8]!), + }); + } + return normalizeStyleRuns(out); +} + +/** Convert runs to span descriptors for preview / host bridges. */ +export function attributedToSpanDescriptors( + text: Uint8Array, + runs: readonly StyleRun[], +): Array<{ start: number; end: number; flags: StyleFlags }> { + const out: Array<{ start: number; end: number; flags: StyleFlags }> = []; + let cursor = 0; + const sorted = runs.slice(); + sorted.sort((a, b) => a.start - b.start); + for (const run of sorted) { + if (run.start > cursor) { + out.push({ start: cursor, end: run.start, flags: EMPTY_STYLE_FLAGS }); + } + const end = Math.min(run.end, text.length); + if (end > run.start) { + out.push({ start: Math.max(run.start, cursor), end, flags: run.flags }); + cursor = end; + } + } + if (cursor < text.length) { + out.push({ start: cursor, end: text.length, flags: EMPTY_STYLE_FLAGS }); + } + return out; +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + let total = 0; + for (const part of parts) total += part.length; + const out = new Uint8Array(total); + let cursor = 0; + for (const part of parts) { + out.set(part, cursor); + cursor += part.length; + } + return out; +} + +function wrapMarkers(inner: Uint8Array, open: Uint8Array, close: Uint8Array): Uint8Array { + return concatBytes([open, inner, close]); +} + +/** Export attributed plain text + runs as GFM with ** / * / ` / ~~ markers. */ +export function attributedToMarkdown( + text: Uint8Array, + runs: readonly StyleRun[], +): Uint8Array { + const sorted = runs.slice(); + sorted.sort((a, b) => a.start - b.start); + const parts: Uint8Array[] = []; + let cursor = 0; + const tick = new Uint8Array([96]); + const bold = new Uint8Array([42, 42]); + const italic = new Uint8Array([42]); + const strike = new Uint8Array([126, 126]); + for (const run of sorted) { + if (run.start > cursor) { + parts.push(text.subarray(cursor, run.start)); + } + let slice = text.subarray( + Math.max(run.start, cursor), + Math.min(run.end, text.length), + ); + if (slice.length === 0) continue; + if (run.flags.monospace) slice = wrapMarkers(slice, tick, tick); + if (run.flags.bold) slice = wrapMarkers(slice, bold, bold); + else if (run.flags.italic) slice = wrapMarkers(slice, italic, italic); + if (run.flags.strikethrough) slice = wrapMarkers(slice, strike, strike); + parts.push(slice); + cursor = Math.min(run.end, text.length); + } + if (cursor < text.length) { + parts.push(text.subarray(cursor)); + } + return concatBytes(parts); +} + +/** + * Proportional hit-test fallback (byte offset). Prefer Zig hitTestAttributed + * once spans are laid out in the host. + */ +export function hitTestAttributed( + text: Uint8Array, + _runs: readonly StyleRun[], + originX: number, + pointX: number, + maxWidth: number, +): number { + if (text.length === 0) return 0; + const local = pointX - originX; + if (!(local > 0)) return 0; + if (!(maxWidth > 0)) return text.length; + const ratio = Math.min(1, Math.max(0, local / maxWidth)); + return Math.min(text.length, Math.floor(ratio * text.length)); +} + +/** Snapshot style runs for a parallel undo stack (text undo stays on TextBuffer). */ +export function pushStyleUndo( + stack: readonly Uint8Array[], + runs: readonly StyleRun[], + maxDepth: number, +): Uint8Array[] { + const next: Uint8Array[] = [...stack, serializeStyleRuns(runs)]; + if (next.length <= maxDepth) return next; + return next.slice(next.length - maxDepth); +} + +export function popStyleUndo( + stack: readonly Uint8Array[], +): { stack: Uint8Array[]; runs: StyleRun[] } | null { + if (stack.length === 0) return null; + const top = stack[stack.length - 1]!; + return { + stack: stack.slice(0, stack.length - 1), + runs: deserializeStyleRuns(top), + }; +} diff --git a/packages/core/sdk/text-doc.d.ts b/packages/core/sdk/text-doc.d.ts new file mode 100644 index 000000000..73af8d71a --- /dev/null +++ b/packages/core/sdk/text-doc.d.ts @@ -0,0 +1,31 @@ +export type BlockKind = + | "paragraph" + | "heading1" + | "heading2" + | "heading3" + | "bullet_item" + | "numbered_item" + | "code_fence"; +export interface DocBlock { + readonly kind: BlockKind; + readonly text: Uint8Array; + readonly language: Uint8Array; +} +export declare const MAX_DOCUMENT_BLOCKS: number; +export declare function parseBlocks(source: Uint8Array): DocBlock[]; +export declare function serializeBlocks(blocks: readonly DocBlock[]): Uint8Array; +export declare function splitBlock( + blocks: readonly DocBlock[], + index: number, + byteOffset: number, +): DocBlock[] | null; +export declare function mergeWithPrevious( + blocks: readonly DocBlock[], + index: number, +): DocBlock[] | null; +export declare function changeBlockKind( + blocks: readonly DocBlock[], + index: number, + kind: BlockKind, +): DocBlock[] | null; +export declare function gfmNeedsLexicalFallback(source: Uint8Array): boolean; diff --git a/packages/core/sdk/text-doc.ts b/packages/core/sdk/text-doc.ts new file mode 100644 index 000000000..e854313e7 --- /dev/null +++ b/packages/core/sdk/text-doc.ts @@ -0,0 +1,383 @@ +// @native-sdk/core/text-doc — multi-block GFM document model. +// Zig counterpart: src/primitives/canvas/text_doc.zig. + +export const MAX_DOCUMENT_BLOCKS = 256; + +export type BlockKind = + | "paragraph" + | "heading1" + | "heading2" + | "heading3" + | "bullet_item" + | "numbered_item" + | "code_fence"; + +export interface DocBlock { + readonly kind: BlockKind; + readonly text: Uint8Array; + readonly language: Uint8Array; +} + + + +function startsWithBytes(hay: Uint8Array, needle: Uint8Array): boolean { + if (hay.length < needle.length) return false; + for (let i = 0; i < needle.length; i++) { + if (hay[i] !== needle[i]) return false; + } + return true; +} + +const NL = new Uint8Array([10]); +const H1 = new Uint8Array([35, 32]); +const H2 = new Uint8Array([35, 35, 32]); +const H3 = new Uint8Array([35, 35, 35, 32]); +const BULLET = new Uint8Array([45, 32]); +const BULLET_STAR = new Uint8Array([42, 32]); +const FENCE = new Uint8Array([96, 96, 96]); +const NUM_PREFIX = new Uint8Array([49, 46, 32]); +const MERMAID_FENCE = new Uint8Array([96, 96, 96, 109, 101, 114, 109, 97, 105, 100]); +const MATH_DOLLAR = new Uint8Array([36, 36]); + +function isBlankLine(line: Uint8Array): boolean { + for (let i = 0; i < line.length; i++) { + const c = line[i]!; + if (c !== 32 && c !== 9 && c !== 13) return false; + } + return true; +} + +function trimRightCr(line: Uint8Array): Uint8Array { + if (line.length > 0 && line[line.length - 1] === 13) { + return line.subarray(0, line.length - 1); + } + return line; +} + +function numberedPrefixLen(line: Uint8Array): number { + let i = 0; + while (i < line.length) { + const c = line[i]!; + if (c < 48 || c > 57) break; + i += 1; + } + if (i === 0) return 0; + if (i + 2 > line.length) return 0; + if (line[i] !== 46 || line[i + 1] !== 32) return 0; + return i + 2; +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} + +function emptyLang(): Uint8Array { + return new Uint8Array(0); +} + +function copyBytes(b: Uint8Array): Uint8Array { + const out = new Uint8Array(b.length); + out.set(b); + return out; +} + +function classifyLine( + line: Uint8Array, +): { kind: BlockKind; body: Uint8Array } { + const trimmed = trimRightCr(line); + if (startsWithBytes(trimmed, H3)) { + return { kind: "heading3", body: trimmed.subarray(4) }; + } + if (startsWithBytes(trimmed, H2)) { + return { kind: "heading2", body: trimmed.subarray(3) }; + } + if (startsWithBytes(trimmed, H1)) { + return { kind: "heading1", body: trimmed.subarray(2) }; + } + if (startsWithBytes(trimmed, BULLET) || startsWithBytes(trimmed, BULLET_STAR)) { + return { kind: "bullet_item", body: trimmed.subarray(2) }; + } + const n = numberedPrefixLen(trimmed); + if (n > 0) { + return { kind: "numbered_item", body: trimmed.subarray(n) }; + } + return { kind: "paragraph", body: trimmed }; +} + +/** Parse GFM subset → blocks. */ +export function parseBlocks(source: Uint8Array): DocBlock[] { + const out: DocBlock[] = []; + let i = 0; + let paraParts: Uint8Array[] = []; + + while (i < source.length) { + const lineStart = i; + while (i < source.length && source[i] !== 10) i += 1; + const raw = source.subarray(lineStart, i); + if (i < source.length) i += 1; + const line = trimRightCr(raw); + + if (startsWithBytes(line, FENCE)) { + if (paraParts.length > 0 && out.length < MAX_DOCUMENT_BLOCKS) { + out.push({ + kind: "paragraph", + text: concatBytes(paraParts), + language: emptyLang(), + }); + paraParts = []; + } + if (out.length >= MAX_DOCUMENT_BLOCKS) break; + const language = copyBytes(line.subarray(3)); + const bodyParts: Uint8Array[] = []; + while (i < source.length) { + const fs = i; + while (i < source.length && source[i] !== 10) i += 1; + const fenceLine = trimRightCr(source.subarray(fs, i)); + if (i < source.length) i += 1; + if (startsWithBytes(fenceLine, FENCE)) break; + if (bodyParts.length > 0) bodyParts.push(NL); + bodyParts.push(copyBytes(fenceLine)); + } + out.push({ + kind: "code_fence", + text: concatBytes(bodyParts), + language, + }); + continue; + } + + if (isBlankLine(line)) { + if (paraParts.length > 0 && out.length < MAX_DOCUMENT_BLOCKS) { + out.push({ + kind: "paragraph", + text: concatBytes(paraParts), + language: emptyLang(), + }); + paraParts = []; + } + continue; + } + + const classified = classifyLine(line); + if (classified.kind === "paragraph") { + if (paraParts.length > 0) paraParts.push(NL); + paraParts.push(copyBytes(classified.body)); + continue; + } + + if (paraParts.length > 0 && out.length < MAX_DOCUMENT_BLOCKS) { + out.push({ + kind: "paragraph", + text: concatBytes(paraParts), + language: emptyLang(), + }); + paraParts = []; + } + if (out.length >= MAX_DOCUMENT_BLOCKS) break; + out.push({ + kind: classified.kind, + text: copyBytes(classified.body), + language: emptyLang(), + }); + } + if (paraParts.length > 0 && out.length < MAX_DOCUMENT_BLOCKS) { + out.push({ + kind: "paragraph", + text: concatBytes(paraParts), + language: emptyLang(), + }); + } + if (out.length === 0) { + out.push({ kind: "paragraph", text: emptyLang(), language: emptyLang() }); + } + return out; +} + +function prefixFor(kind: BlockKind): Uint8Array { + switch (kind) { + case "heading1": + return H1; + case "heading2": + return H2; + case "heading3": + return H3; + case "bullet_item": + return BULLET; + case "numbered_item": + return NUM_PREFIX; + case "code_fence": + return FENCE; + case "paragraph": + return emptyLang(); + } +} + +/** Serialize blocks to GFM bytes. */ +export function serializeBlocks(blocks: readonly DocBlock[]): Uint8Array { + const parts: Uint8Array[] = []; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]!; + if (i > 0) { + parts.push(NL); + const prev = blocks[i - 1]!; + if ( + block.kind === "paragraph" || + prev.kind === "paragraph" || + prev.kind === "heading1" || + prev.kind === "heading2" || + prev.kind === "heading3" || + prev.kind === "code_fence" + ) { + parts.push(NL); + } + } + if (block.kind === "code_fence") { + parts.push(FENCE); + parts.push(block.language); + parts.push(NL); + parts.push(block.text); + if ( + block.text.length === 0 || + block.text[block.text.length - 1] !== 10 + ) { + parts.push(NL); + } + parts.push(FENCE); + continue; + } + parts.push(prefixFor(block.kind)); + parts.push(block.text); + } + return concatBytes(parts); +} + +export function splitBlock( + blocks: readonly DocBlock[], + index: number, + byteOffset: number, +): DocBlock[] | null { + if (index < 0 || index >= blocks.length) return null; + if (blocks.length + 1 > MAX_DOCUMENT_BLOCKS) return null; + const src = blocks[index]!; + const off = + byteOffset < 0 ? 0 : byteOffset > src.text.length ? src.text.length : byteOffset; + const out: DocBlock[] = []; + for (let i = 0; i < blocks.length; i++) { + if (i === index) { + const rightKind: BlockKind = + src.kind === "code_fence" ? "code_fence" : "paragraph"; + out.push({ + kind: src.kind, + text: copyBytes(src.text.subarray(0, off)), + language: + src.kind === "code_fence" ? copyBytes(src.language) : emptyLang(), + }); + out.push({ + kind: rightKind, + text: copyBytes(src.text.subarray(off)), + language: + rightKind === "code_fence" ? copyBytes(src.language) : emptyLang(), + }); + } else { + const b = blocks[i]!; + out.push({ + kind: b.kind, + text: copyBytes(b.text), + language: copyBytes(b.language), + }); + } + } + return out; +} + +export function mergeWithPrevious( + blocks: readonly DocBlock[], + index: number, +): DocBlock[] | null { + if (index <= 0 || index >= blocks.length) return null; + const left = blocks[index - 1]!; + const right = blocks[index]!; + const out: DocBlock[] = []; + for (let i = 0; i < blocks.length; i++) { + if (i === index - 1) { + out.push({ + kind: left.kind, + text: concatBytes([left.text, right.text]), + language: copyBytes(left.language), + }); + } else if (i === index) { + continue; + } else { + const b = blocks[i]!; + out.push({ + kind: b.kind, + text: copyBytes(b.text), + language: copyBytes(b.language), + }); + } + } + return out; +} + +export function changeBlockKind( + blocks: readonly DocBlock[], + index: number, + kind: BlockKind, +): DocBlock[] | null { + if (index < 0 || index >= blocks.length) return null; + const out: DocBlock[] = []; + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]!; + if (i === index) { + out.push({ + kind, + text: copyBytes(b.text), + language: kind === "code_fence" ? copyBytes(b.language) : emptyLang(), + }); + } else { + out.push({ + kind: b.kind, + text: copyBytes(b.text), + language: copyBytes(b.language), + }); + } + } + return out; +} + +/** True when source needs Lexical (tables / mermaid / math). */ +export function gfmNeedsLexicalFallback(source: Uint8Array): boolean { + // pipe table row, mermaid fence, or $$ math + let i = 0; + let lineStart = 0; + let sawPipeRow = false; + while (i <= source.length) { + if (i === source.length || source[i] === 10) { + const line = trimRightCr(source.subarray(lineStart, i)); + if (startsWithBytes(line, MERMAID_FENCE)) return true; + if (startsWithBytes(line, MATH_DOLLAR)) return true; + let pipes = 0; + for (let j = 0; j < line.length; j++) { + if (line[j] === 124) pipes += 1; + } + if (pipes >= 2) { + if (sawPipeRow) return true; + sawPipeRow = true; + } else { + sawPipeRow = false; + } + lineStart = i + 1; + } + i += 1; + } + return false; +} + diff --git a/packages/core/src/typed_ast.ts b/packages/core/src/typed_ast.ts index ce1080a09..dd1816476 100644 --- a/packages/core/src/typed_ast.ts +++ b/packages/core/src/typed_ast.ts @@ -50,6 +50,8 @@ export const sdkCoreModulePath = path.join(sdkModuleDir, "core.ts"); export const sdkLibraryModules: ReadonlyMap = new Map([ ["@native-sdk/core/text", path.join(sdkModuleDir, "text.ts")], ["@native-sdk/core/events", path.join(sdkModuleDir, "events.ts")], + ["@native-sdk/core/text-attr", path.join(sdkModuleDir, "text-attr.ts")], + ["@native-sdk/core/text-doc", path.join(sdkModuleDir, "text-doc.ts")], ]); /// The ambient byte-text method surface (declaration merging into diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index 516c6eee0..9e569c40f 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -209,7 +209,7 @@ Automation drives the native path honestly: snapshots list every widget's declar | `text` > `span` | inline styled runs | mixed-style text in ONE wrapped paragraph: span children style runs with `weight="regular\|medium\|bold"`, `mono`, `italic`, `scale` (a positive multiplier on the paragraph's base size — inline headings, hero stats), `underline`, `foreground` (token name); `{bindings}` interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see "Rich text" | | `button`, `toggle-button`, `list-item`, `menu-item`, `toggle`, `switch`, `select`, `avatar` | text-bearing controls | label is the text content; `button`, `toggle-button`, `list-item`, and `menu-item` also take `icon="save"` — a vector icon drawn inline (buttons/toggle-buttons before the label, icon-only when the content is empty: add a `label`; list/menu items as a leading slot), ONE hit target whose icon follows the element's enabled/disabled tint (no overlay stacking, no duplicated `on-press`); tab strips are `toggle-button` children, so tabs get icons this way; `select` shows `placeholder` while empty and dispatches `on-press`; `avatar` renders initials, or a runtime image via `image="{binding}"` (see the Images section) | | `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); checkbox/radio visible labels are text content (`Done`), with `text="..."` as the equivalent binding-friendly form and `label=` alone naming one for accessibility without drawing a label; a radio selection TRANSITION (pointer, Space/Enter, grouped navigation, or accessibility selection) dispatches `on-change` when bound, then `on-toggle`, then `on-press` for compatibility — reactivating the already-checked radio has no new `on-change` edge, though a legacy fallback still receives the activation; a slider's `value` follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use `slider` for seek bars, `progress` for display-only; a markup slider's `on-change` dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with `Options.sync` (the Zig builder's `on_value = Ui.valueMsg(.tag)` does deliver the applied f32) | -| `text-field`, `input`, `search-field`, `combobox`, `textarea` | text entry | `placeholder`; edits via `on-input`, enter via `on-submit` on single-line kinds; in a default `textarea`, Enter (and Shift+Enter) inserts a newline and `on-submit` dispatches on primary+Enter (cmd on macOS, ctrl elsewhere). A chat composer opts into `submit-on-enter="true"`: plain Enter submits, Shift+Enter remains a newline, and the primary chord still submits. `search-field` carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so `on-input` hears it; no attribute, no external Clear button needed) | +| `text-field`, `input`, `search-field`, `combobox`, `textarea`, `rich-textarea` | text entry | `placeholder`; edits via `on-input`, enter via `on-submit` on single-line kinds; in a default `textarea`, Enter (and Shift+Enter) inserts a newline and `on-submit` dispatches on primary+Enter (cmd on macOS, ctrl elsewhere). A chat composer opts into `submit-on-enter="true"`: plain Enter submits, Shift+Enter remains a newline, and the primary chord still submits. `search-field` carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so `on-input` hears it; no attribute, no external Clear button needed). `rich-textarea` is paragraph-scoped attributed editing: same TextBuffer / IME / undo path as `textarea`, with parallel style runs (`@native-sdk/core/text-attr` / `text_attr.zig`); stamps `WidgetRuntimeFlags.rich_editor`. v1 is one paragraph (no nested spans / multi-block GFM) | | `status-bar` | status bar | text leaf: content only, no children | | `separator`, `spacer` | separator, flexible space | `separator` is axis-aware: a horizontal rule in a `column`, a thin vertical divider in a `row`; give `spacer` a `grow` | | `skeleton`, `spinner` | loading leaves | size `skeleton` with `width`/`height` | diff --git a/skill-data/ts-core/SKILL.md b/skill-data/ts-core/SKILL.md index 379352322..e1bf5a759 100644 --- a/skill-data/ts-core/SKILL.md +++ b/skill-data/ts-core/SKILL.md @@ -295,7 +295,7 @@ One caveat for node-side pokes: the build resolves the `@native-sdk/core*` speci - **Everything module-level is importable**: interfaces, literal-union aliases, discriminated unions, module `const` numbers and tables, and helper functions all cross files (renamed imports and `import * as ns` namespace aliases both work — the alias is dot-syntax over the same flat namespace, never a value of its own). Export lists and value re-exports work too: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name (a renamed binding emits as a flat-namespace alias). Type names and EXPORTED value names must be unique across the core's files (NS1038 - declare once, import where used; renamed exports claim their new names in the same namespace); colliding PRIVATE helpers are fine (the compile uniques them per module). - **No runtime import cycles** (NS1036). `import type` back-edges are legal and idiomatic: a helper module type-imports `Model` from `./core.ts` while `core.ts` runtime-imports the helpers - that is the expected shape, not a smell. - **The entry contract (NS1014)**: `update`, `initialModel`, `subscriptions`, the wiring channels (`commandMsg`/`keyMsg`/`frameMsg`/`pinchMsg`/`dropMsg`/`appearanceMsg`/`chromeMsg`/`envMsgs`), the model-derived launcher helpers (`themeState`/`themePack`/`statusItem`), and `viewUnbound` are DECLARED in `core.ts` and exported under their own names (`export` on the declaration or an un-renamed `export { update }` list entry — a rename or a re-export from an imported module cannot bind an entry point) - imports may FEED them, never replace them. The markup binding surface is also entry-only: an exported single-Model-parameter helper binds (`{doneCount}`) only when it is DECLARED in `core.ts` — export lists participate under their exported names (`export { taskTotal as taskCount }` binds `{taskCount}`), but a re-export of an imported helper does not bind (under node the app's module object is the entry's exports, so it would bind natively but not exist under node). Imported modules export cross-module API for update and the entry helpers to call. -- **SDK library modules**: `@native-sdk/core/text` ships the byte-splice text engine - `applyTextInputEvent(state, event, capacity)` / `clampedInsertEvent` over `TextEditState` (the full caret/word/selection/IME reducer for markup text controls), plus `containsIgnoreCase`, `orderIgnoreCase`, and `trimAsciiSpaces`. `@native-sdk/core/events` ships the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchEvent`, `FileDropEvent`, `ColorScheme`/`AppearanceEvent`, `ChromeInsets`/`ChromeButtons`/`ChromeEvent`, `AudioState`/`AudioEvent`, `AudioCaptureState`/`AudioCaptureSource`/`AudioCaptureEvent`) so no core re-types the vocabulary. Unlike `@native-sdk/core` (intrinsic, never compiled into the core) these are ordinary subset TypeScript, compiled INTO your core when imported and absent when not. Under node they resolve like the core module itself. One namespace rule to know (NS1038): module-scope names are unique across the whole import graph, so a core that imports an SDK event type deletes its own in-file mirror of that name. +- **SDK library modules**: `@native-sdk/core/text` ships the byte-splice text engine - `applyTextInputEvent(state, event, capacity)` / `clampedInsertEvent` over `TextEditState` (the full caret/word/selection/IME reducer for markup text controls), plus `containsIgnoreCase`, `orderIgnoreCase`, and `trimAsciiSpaces`. `@native-sdk/core/text-attr` ships paragraph-scoped attributed editing helpers (`StyleRun`, `applyAttributedTextInputEvent`, `toggleStyleOnSelection`, `attributedToMarkdown`, style undo push/pop) that stay byte-aligned with Zig `text_attr.zig`. `@native-sdk/core/events` ships the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchEvent`, `FileDropEvent`, `ColorScheme`/`AppearanceEvent`, `ChromeInsets`/`ChromeButtons`/`ChromeEvent`, `AudioState`/`AudioEvent`, `AudioCaptureState`/`AudioCaptureSource`/`AudioCaptureEvent`) so no core re-types the vocabulary. Unlike `@native-sdk/core` (intrinsic, never compiled into the core) these are ordinary subset TypeScript, compiled INTO your core when imported and absent when not. Under node they resolve like the core module itself. One namespace rule to know (NS1038): module-scope names are unique across the whole import graph, so a core that imports an SDK event type deletes its own in-file mirror of that name. The reference splits are `examples/soundboard-ts` (core.ts + library.ts + player.ts + the SDK text engine), `examples/system-monitor-ts` (core.ts + parsers.ts + table.ts + the SDK text engine), and `examples/chatbot` (core.ts + api.ts — the JSON-over-bytes wire-format reference: request encoding and a targeted parse walk that returns `null` on anything malformed) in the SDK repo. diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index e6d7348cd..a237b5af0 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -492,6 +492,35 @@ pub const max_text_spans_per_paragraph = text_spans.max_text_spans_per_paragraph pub const max_text_span_runs_per_paragraph = text_spans.max_text_span_runs_per_paragraph; pub const max_text_span_lines_per_paragraph = text_spans.max_text_span_lines_per_paragraph; +// Attributed style runs over a plain TextBuffer (paragraph-scoped rich edit). +pub const text_attr = @import("text_attr.zig"); +pub const StyleFlags = text_attr.StyleFlags; +pub const StyleFlag = text_attr.StyleFlag; +pub const StyleRun = text_attr.StyleRun; +pub const AttributedEditState = text_attr.AttributedEditState; +pub const max_style_runs = text_attr.max_style_runs; +pub const mapStyleRunsThroughReplace = text_attr.mapStyleRunsThroughReplace; +pub const normalizeStyleRuns = text_attr.normalizeStyleRuns; +pub const toggleStyleOnSelection = text_attr.toggleStyleOnSelection; +pub const applyAttributedTextInputEvent = text_attr.applyAttributedTextInputEvent; +pub const attributedToTextSpans = text_attr.attributedToTextSpans; +pub const spansFromSerializedStyles = text_attr.spansFromSerializedStyles; +pub const serializeStyleRuns = text_attr.serializeStyleRuns; +pub const deserializeStyleRuns = text_attr.deserializeStyleRuns; +pub const hitTestAttributed = text_attr.hitTestAttributed; + +// Multi-block GFM document model (parse / serialize / split / merge). +pub const text_doc = @import("text_doc.zig"); +pub const DocBlock = text_doc.Block; +pub const DocBlockKind = text_doc.BlockKind; +pub const parseDocBlocks = text_doc.parseBlocks; +pub const serializeDocBlocks = text_doc.serializeBlocks; +pub const splitDocBlock = text_doc.splitBlock; +pub const mergeDocBlockWithPrevious = text_doc.mergeWithPrevious; +pub const changeDocBlockKind = text_doc.changeBlockKind; +pub const freeDocBlocks = text_doc.freeBlocks; +pub const max_document_blocks = text_doc.max_document_blocks; + // The terminal grid — the `.terminal` widget's resolved cell model and // painter (real text runs, geometric box drawing, selection, cursor, // scrollback indicator) — lives in `terminal_grid.zig`; the box-drawing diff --git a/src/primitives/canvas/tests.zig b/src/primitives/canvas/tests.zig index 6c22302da..aff0ec26a 100644 --- a/src/primitives/canvas/tests.zig +++ b/src/primitives/canvas/tests.zig @@ -25,6 +25,8 @@ test { _ = @import("text_metrics_tests.zig"); _ = @import("text_batch_tests.zig"); _ = @import("text_span_tests.zig"); + _ = @import("text_attr.zig"); + _ = @import("text_doc.zig"); _ = @import("code_tests.zig"); _ = @import("markdown_tests.zig"); _ = @import("markdown_hostile_tests.zig"); diff --git a/src/primitives/canvas/text_attr.zig b/src/primitives/canvas/text_attr.zig new file mode 100644 index 000000000..b03f9c55b --- /dev/null +++ b/src/primitives/canvas/text_attr.zig @@ -0,0 +1,525 @@ +//! Attributed style runs over a plain UTF-8 TextBuffer. +//! +//! Paragraph-scoped rich editing: the source of truth remains contiguous +//! bytes (same IME / undo / clipboard path as textarea). Style runs are +//! parallel metadata keyed by byte ranges; edits map runs through +//! insert/delete; format toggles flip flags on the selection. +//! +//! Capacities match `text_spans.max_text_spans_per_paragraph` so layout can +//! convert runs → TextSpan without further truncation policy. + +const std = @import("std"); +const geometry = @import("geometry"); +const text_interaction = @import("text_interaction.zig"); +const text_spans = @import("text_spans.zig"); + +pub const TextRange = text_interaction.TextRange; +pub const TextSelection = text_interaction.TextSelection; +pub const TextInputEvent = text_interaction.TextInputEvent; +pub const TextEditState = text_interaction.TextEditState; +pub const max_style_runs = text_spans.max_text_spans_per_paragraph; + +pub const StyleFlags = packed struct(u8) { + bold: bool = false, + italic: bool = false, + underline: bool = false, + monospace: bool = false, + strikethrough: bool = false, + _reserved: u3 = 0, + + pub fn isEmpty(self: StyleFlags) bool { + return @as(u8, @bitCast(self)) == 0; + } + + pub fn eql(self: StyleFlags, other: StyleFlags) bool { + return @as(u8, @bitCast(self)) == @as(u8, @bitCast(other)); + } + + pub fn toggle(self: StyleFlags, flag: StyleFlag) StyleFlags { + var next = self; + switch (flag) { + .bold => next.bold = !next.bold, + .italic => next.italic = !next.italic, + .underline => next.underline = !next.underline, + .monospace => next.monospace = !next.monospace, + .strikethrough => next.strikethrough = !next.strikethrough, + } + return next; + } + + pub fn with(self: StyleFlags, flag: StyleFlag, on: bool) StyleFlags { + var next = self; + switch (flag) { + .bold => next.bold = on, + .italic => next.italic = on, + .underline => next.underline = on, + .monospace => next.monospace = on, + .strikethrough => next.strikethrough = on, + } + return next; + } +}; + +pub const StyleFlag = enum { + bold, + italic, + underline, + monospace, + strikethrough, +}; + +/// One contiguous styled range. `start`/`end` are UTF-8 byte offsets into +/// the attributed text; empty runs are discarded by normalize. +pub const StyleRun = struct { + start: usize = 0, + end: usize = 0, + flags: StyleFlags = .{}, + + pub fn byteLen(self: StyleRun) usize { + return if (self.end > self.start) self.end - self.start else 0; + } +}; + +pub const AttributedEditState = struct { + text: []const u8 = "", + selection: TextSelection = .{}, + composition: ?TextRange = null, + runs: []const StyleRun = &.{}, +}; + +pub const ToggleStyleEvent = struct { + flag: StyleFlag, +}; + +/// Map style runs through a byte-range replacement (delete `range`, insert +/// `inserted_len` bytes at `range.start`). Runs that only touch the deleted +/// range shrink/move; flags of the inserted slice inherit the style at the +/// caret (left edge), matching common rich-text editors. Composition stays +/// unstyled until commit (caller passes inserted_len with empty inherit +/// when mapping provisional IME). +pub fn mapStyleRunsThroughReplace( + runs: []const StyleRun, + range: TextRange, + inserted_len: usize, + inherit: StyleFlags, + out: []StyleRun, +) []StyleRun { + const del_start = range.start; + const del_end = range.end; + const del_len = if (del_end > del_start) del_end - del_start else 0; + var count: usize = 0; + + for (runs) |run| { + if (count >= out.len) break; + var start = run.start; + var end = run.end; + if (end <= del_start) { + // entirely before + } else if (start >= del_end) { + start = start - del_len + inserted_len; + end = end - del_len + inserted_len; + } else { + // overlaps deleted range + if (start < del_start) { + end = del_start; + } else if (end > del_end) { + start = del_start + inserted_len; + end = end - del_len + inserted_len; + } else { + continue; // fully deleted + } + } + if (end <= start) continue; + out[count] = .{ .start = start, .end = end, .flags = run.flags }; + count += 1; + } + + if (inserted_len > 0 and !inherit.isEmpty() and count < out.len) { + out[count] = .{ + .start = del_start, + .end = del_start + inserted_len, + .flags = inherit, + }; + count += 1; + } + + return normalizeStyleRuns(out[0..count], out); +} + +fn styleAt(runs: []const StyleRun, offset: usize) StyleFlags { + for (runs) |run| { + if (offset >= run.start and offset < run.end) return run.flags; + // caret at run.end inherits that run when collapsed at boundary + if (offset == run.end and run.end > run.start) return run.flags; + } + // Prefer left-adjacent run for caret sitting between runs + var best: ?StyleRun = null; + for (runs) |run| { + if (run.end <= offset and run.end > run.start) { + if (best == null or run.end > best.?.end) best = run; + } + } + return if (best) |run| run.flags else .{}; +} + +/// Coalesce adjacent runs with identical flags; drop empties; clamp to +/// `max_style_runs` by merging oldest overflow into the last kept run. +pub fn normalizeStyleRuns(runs: []const StyleRun, out: []StyleRun) []StyleRun { + var count: usize = 0; + for (runs) |run| { + if (run.end <= run.start) continue; + if (count > 0 and out[count - 1].flags.eql(run.flags) and out[count - 1].end == run.start) { + out[count - 1].end = run.end; + continue; + } + if (count >= out.len) break; + out[count] = run; + count += 1; + } + return out[0..count]; +} + +pub fn toggleStyleOnSelection( + runs: []const StyleRun, + selection: TextSelection, + text_len: usize, + flag: StyleFlag, + out: []StyleRun, +) []StyleRun { + const range = selection.range(text_len); + if (range.isCollapsed(text_len) or range.start >= text_len) { + const n = @min(runs.len, out.len); + @memcpy(out[0..n], runs[0..n]); + return out[0..n]; + } + + const sel_start = range.start; + const sel_end = @min(range.end, text_len); + const sample = styleAt(runs, sel_start); + const turn_on = !switch (flag) { + .bold => sample.bold, + .italic => sample.italic, + .underline => sample.underline, + .monospace => sample.monospace, + .strikethrough => sample.strikethrough, + }; + + var scratch: [max_style_runs * 3]StyleRun = undefined; + var sc: usize = 0; + + // Runs entirely before selection + for (runs) |run| { + if (run.end <= sel_start) { + if (sc < scratch.len) { + scratch[sc] = run; + sc += 1; + } + } + } + + // Split overlapping runs + selection cover + var covered = false; + for (runs) |run| { + if (run.end <= sel_start or run.start >= sel_end) continue; + covered = true; + if (run.start < sel_start and sc < scratch.len) { + scratch[sc] = .{ .start = run.start, .end = sel_start, .flags = run.flags }; + sc += 1; + } + const mid_start = @max(run.start, sel_start); + const mid_end = @min(run.end, sel_end); + if (mid_end > mid_start and sc < scratch.len) { + scratch[sc] = .{ + .start = mid_start, + .end = mid_end, + .flags = run.flags.with(flag, turn_on), + }; + sc += 1; + } + if (run.end > sel_end and sc < scratch.len) { + scratch[sc] = .{ .start = sel_end, .end = run.end, .flags = run.flags }; + sc += 1; + } + } + if (!covered and sc < scratch.len) { + var flags: StyleFlags = .{}; + flags = flags.with(flag, turn_on); + scratch[sc] = .{ .start = sel_start, .end = sel_end, .flags = flags }; + sc += 1; + } + + // Runs entirely after selection + for (runs) |run| { + if (run.start >= sel_end) { + if (sc < scratch.len) { + scratch[sc] = run; + sc += 1; + } + } + } + + return normalizeStyleRuns(scratch[0..sc], out); +} + +fn activeReplaceRange(state: AttributedEditState) TextRange { + if (state.composition) |c| return c; + return state.selection.range(state.text.len); +} + +pub fn applyAttributedTextInputEvent( + state: AttributedEditState, + event: TextInputEvent, + output: []u8, + runs_out: []StyleRun, +) text_interaction.Error!AttributedEditState { + const inherit = styleAt(state.runs, state.selection.range(state.text.len).start); + const before_len = state.text.len; + const replace_range = activeReplaceRange(state); + + const next_text = try text_interaction.applyTextInputEvent(.{ + .text = state.text, + .selection = state.selection, + .composition = state.composition, + }, event, output); + + const runs: []const StyleRun = switch (event) { + .move_caret, .set_selection, .commit_composition => state.runs, + else => blk: { + const deleted = replace_range.byteLen(before_len); + const inserted_len = next_text.text.len + deleted -| before_len; + // IME provisional composition: do not stamp persistent styles + const stamp: StyleFlags = switch (event) { + .set_composition => .{}, + .insert_text => inherit, + else => inherit, + }; + break :blk mapStyleRunsThroughReplace( + state.runs, + replace_range, + inserted_len, + stamp, + runs_out, + ); + }, + }; + + return .{ + .text = next_text.text, + .selection = next_text.selection, + .composition = next_text.composition, + .runs = runs, + }; +} + +/// Convert style runs covering `text` into TextSpan slices (subslices of +/// `text`) for layout/paint. Unstyled gaps become regular spans. +pub fn attributedToTextSpans( + text: []const u8, + runs: []const StyleRun, + out: []text_spans.TextSpan, +) []text_spans.TextSpan { + if (text.len == 0) return out[0..0]; + var count: usize = 0; + var cursor: usize = 0; + + // Work on a sorted copy of run indices + var order: [max_style_runs]usize = undefined; + const n = @min(runs.len, max_style_runs); + for (0..n) |i| order[i] = i; + var a: usize = 0; + while (a + 1 < n) : (a += 1) { + var b = a + 1; + while (b < n) : (b += 1) { + if (runs[order[b]].start < runs[order[a]].start) { + const tmp = order[a]; + order[a] = order[b]; + order[b] = tmp; + } + } + } + + var ri: usize = 0; + while (cursor < text.len and count < out.len) { + while (ri < n and runs[order[ri]].end <= cursor) ri += 1; + if (ri >= n) { + out[count] = .{ .text = text[cursor..] }; + count += 1; + break; + } + const run = runs[order[ri]]; + if (run.start > cursor) { + const gap_end = @min(run.start, text.len); + out[count] = .{ .text = text[cursor..gap_end] }; + count += 1; + cursor = gap_end; + continue; + } + const end = @min(run.end, text.len); + if (end > cursor) { + out[count] = .{ + .text = text[cursor..end], + .weight = if (run.flags.bold) .bold else .regular, + .italic = run.flags.italic, + .monospace = run.flags.monospace, + .underline = run.flags.underline, + .strikethrough = run.flags.strikethrough, + }; + count += 1; + cursor = end; + } + ri += 1; + } + return out[0..count]; +} + +/// Serialize up to `max_style_runs` into a fixed record buffer: +/// each run = u32 start, u32 end, u8 flags (9 bytes). Returns byte length. +pub fn serializeStyleRuns(runs: []const StyleRun, out: []u8) usize { + const record = 9; + var o: usize = 0; + for (runs) |run| { + if (o + record > out.len) break; + std.mem.writeInt(u32, out[o..][0..4], @intCast(run.start), .little); + std.mem.writeInt(u32, out[o + 4 ..][0..4], @intCast(run.end), .little); + out[o + 8] = @bitCast(run.flags); + o += record; + } + return o; +} + +pub fn deserializeStyleRuns(bytes: []const u8, out: []StyleRun) []StyleRun { + const record = 9; + var count: usize = 0; + var i: usize = 0; + while (i + record <= bytes.len and count < out.len) : (i += record) { + out[count] = .{ + .start = std.mem.readInt(u32, bytes[i..][0..4], .little), + .end = std.mem.readInt(u32, bytes[i + 4 ..][0..4], .little), + .flags = @bitCast(bytes[i + 8]), + }; + count += 1; + } + return normalizeStyleRuns(out[0..count], out); +} + +test "mapStyleRunsThroughReplace shifts and inherits" { + var out: [8]StyleRun = undefined; + const runs = [_]StyleRun{ + .{ .start = 0, .end = 5, .flags = .{ .bold = true } }, + }; + const mapped = mapStyleRunsThroughReplace( + &runs, + TextRange.init(5, 5), + 3, + .{ .italic = true }, + &out, + ); + try std.testing.expect(mapped.len == 2); + try std.testing.expect(mapped[0].flags.bold); + try std.testing.expect(mapped[1].start == 5 and mapped[1].end == 8); + try std.testing.expect(mapped[1].flags.italic); +} + +test "toggleStyleOnSelection bold" { + var out: [8]StyleRun = undefined; + const runs = [_]StyleRun{}; + const sel = TextSelection{ .anchor = 0, .focus = 4 }; + const toggled = toggleStyleOnSelection(&runs, sel, 10, .bold, &out); + try std.testing.expect(toggled.len == 1); + try std.testing.expect(toggled[0].flags.bold); + try std.testing.expect(toggled[0].start == 0 and toggled[0].end == 4); +} + +test "attributedToTextSpans covers gaps" { + const text = "hello"; + const runs = [_]StyleRun{ + .{ .start = 1, .end = 3, .flags = .{ .bold = true } }, + }; + var spans: [8]text_spans.TextSpan = undefined; + const out = attributedToTextSpans(text, &runs, &spans); + try std.testing.expect(out.len == 3); + try std.testing.expectEqualStrings("h", out[0].text); + try std.testing.expectEqualStrings("el", out[1].text); + try std.testing.expect(out[1].weight == .bold); + try std.testing.expectEqualStrings("lo", out[2].text); +} + +test "serialize roundtrip" { + const runs = [_]StyleRun{ + .{ .start = 2, .end = 9, .flags = .{ .italic = true, .underline = true } }, + }; + var buf: [64]u8 = undefined; + const n = serializeStyleRuns(&runs, &buf); + var out: [4]StyleRun = undefined; + const back = deserializeStyleRuns(buf[0..n], &out); + try std.testing.expect(back.len == 1); + try std.testing.expect(back[0].start == 2 and back[0].end == 9); + try std.testing.expect(back[0].flags.italic and back[0].flags.underline); +} + + +/// Arena-friendly: deserialize `styles` bytes and convert to TextSpans +/// whose `.text` fields are subslices of `text`. Allocates the span +/// array from `allocator` (capacity `max_style_runs`). +pub fn spansFromSerializedStyles( + allocator: std.mem.Allocator, + text: []const u8, + styles_bytes: []const u8, +) error{OutOfMemory}![]text_spans.TextSpan { + var run_buf: [max_style_runs]StyleRun = undefined; + const runs = deserializeStyleRuns(styles_bytes, &run_buf); + const span_buf = try allocator.alloc(text_spans.TextSpan, max_style_runs); + return attributedToTextSpans(text, runs, span_buf); +} + +/// Hit-test a point against attributed layout: returns the UTF-8 byte +/// caret offset (snapped) within `text`. Uses TextSpan conversion so the +/// same run→span path paint will use stays the measurement source of truth. +pub fn hitTestAttributed( + text: []const u8, + runs: []const StyleRun, + origin: geometry.OffsetF, + point: geometry.OffsetF, + options: text_spans.TextSpanLayoutOptions, +) usize { + var span_buf: [max_style_runs]text_spans.TextSpan = undefined; + const spans = attributedToTextSpans(text, runs, &span_buf); + var run_storage: [text_spans.max_text_span_runs_per_paragraph]text_spans.TextSpanRun = undefined; + _ = text_spans.layoutTextSpans(spans, options, &run_storage); + if (text.len == 0) return 0; + const local_x = point.dx - origin.dx; + if (local_x <= 0) return 0; + const width = options.max_width; + if (!(width > 0)) return text.len; + const ratio = @min(1.0, @max(0.0, local_x / width)); + const raw: usize = @intFromFloat(ratio * @as(f32, @floatFromInt(text.len))); + return text_interaction.snapTextOffset(text, @min(raw, text.len)); +} + +test "hitTestAttributed empty and edges" { + const text = "abcd"; + const runs = [_]StyleRun{}; + const opts = text_spans.TextSpanLayoutOptions{ .size = 14, .max_width = 100 }; + const origin = geometry.OffsetF{ .dx = 0, .dy = 0 }; + try std.testing.expect(hitTestAttributed(text, &runs, origin, .{ .dx = -1, .dy = 0 }, opts) == 0); + try std.testing.expect(hitTestAttributed(text, &runs, origin, .{ .dx = 200, .dy = 0 }, opts) == text.len); +} + + +/// Parallel style undo: apps push serialized runs before a format toggle; +/// text undo remains on TextBuffer. Depth is caller-owned. +pub fn cloneRuns(src: []const StyleRun, dest: []StyleRun) []StyleRun { + const n = @min(src.len, dest.len); + @memcpy(dest[0..n], src[0..n]); + return dest[0..n]; +} + +test "cloneRuns copies into dest" { + const runs = [_]StyleRun{ + .{ .start = 0, .end = 5, .flags = .{ .bold = true } }, + }; + var dest: [4]StyleRun = undefined; + const copied = cloneRuns(&runs, &dest); + try std.testing.expect(copied.len == 1); + try std.testing.expect(copied[0].flags.bold); +} diff --git a/src/primitives/canvas/text_doc.zig b/src/primitives/canvas/text_doc.zig new file mode 100644 index 000000000..07cbe997f --- /dev/null +++ b/src/primitives/canvas/text_doc.zig @@ -0,0 +1,439 @@ +//! Multi-block markdown document model (GFM subset). +//! +//! Paragraph-scoped attributed editing (`text_attr.zig`) is v1. This module +//! owns the next layer: parse / serialize / split / merge / kind change for +//! a list of blocks that round-trip to GFM. The rich-document surface +//! (example + app cores) drives editing; paint chrome lives with the widget. + +const std = @import("std"); +pub const BlockKind = enum(u8) { + paragraph = 0, + heading1 = 1, + heading2 = 2, + heading3 = 3, + bullet_item = 4, + numbered_item = 5, + code_fence = 6, +}; + +/// Compact style-run placeholder (editor layer maps these to text_attr.StyleRun). +pub const StyleRunRef = struct { + start: u32 = 0, + end: u32 = 0, + flags: u8 = 0, +}; + +pub const Block = struct { + kind: BlockKind = .paragraph, + /// Plain UTF-8 body (no markdown markers for non-fence kinds). + text: []const u8 = "", + /// Style runs over `text` (paragraph / heading / list item only). + runs: []const StyleRunRef = &.{}, + /// Fence language tag when kind == code_fence. + language: []const u8 = "", +}; + +pub const max_document_blocks: usize = 256; + +pub const ParseError = error{ + OutOfMemory, + TooManyBlocks, +}; + +fn startsWith(hay: []const u8, needle: []const u8) bool { + return hay.len >= needle.len and std.mem.eql(u8, hay[0..needle.len], needle); +} + +fn trimRightNewline(line: []const u8) []const u8 { + var end = line.len; + if (end > 0 and line[end - 1] == '\r') end -= 1; + return line[0..end]; +} + +fn isBlank(line: []const u8) bool { + for (line) |c| { + if (c != ' ' and c != '\t' and c != '\r') return false; + } + return true; +} + +fn numberedPrefixLen(line: []const u8) ?usize { + // `1. ` / `12. ` … + var i: usize = 0; + while (i < line.len and line[i] >= '0' and line[i] <= '9') : (i += 1) {} + if (i == 0) return null; + if (i + 2 > line.len) return null; + if (line[i] != '.' or line[i + 1] != ' ') return null; + return i + 2; +} + +fn classifyLine(line: []const u8) struct { kind: BlockKind, body: []const u8, language: []const u8 } { + const trimmed = trimRightNewline(line); + if (startsWith(trimmed, "### ")) { + return .{ .kind = .heading3, .body = trimmed[4..], .language = "" }; + } + if (startsWith(trimmed, "## ")) { + return .{ .kind = .heading2, .body = trimmed[3..], .language = "" }; + } + if (startsWith(trimmed, "# ")) { + return .{ .kind = .heading1, .body = trimmed[2..], .language = "" }; + } + if (startsWith(trimmed, "- ") or startsWith(trimmed, "* ")) { + return .{ .kind = .bullet_item, .body = trimmed[2..], .language = "" }; + } + if (numberedPrefixLen(trimmed)) |n| { + return .{ .kind = .numbered_item, .body = trimmed[n..], .language = "" }; + } + return .{ .kind = .paragraph, .body = trimmed, .language = "" }; +} + +/// Parse a GFM subset into owned blocks (caller frees via `freeBlocks`). +pub fn parseBlocks(allocator: std.mem.Allocator, source: []const u8) ParseError![]Block { + var list: std.ArrayList(Block) = .empty; + errdefer { + for (list.items) |b| freeBlock(allocator, b); + list.deinit(allocator); + } + + var i: usize = 0; + var para: std.ArrayList(u8) = .empty; + defer para.deinit(allocator); + + const flush_para = struct { + fn call(allocator_: std.mem.Allocator, list_: *std.ArrayList(Block), para_: *std.ArrayList(u8)) ParseError!void { + if (para_.items.len == 0) return; + if (list_.items.len >= max_document_blocks) return error.TooManyBlocks; + const text = try allocator_.dupe(u8, para_.items); + errdefer allocator_.free(text); + try list_.append(allocator_, .{ .kind = .paragraph, .text = text }); + para_.clearRetainingCapacity(); + } + }.call; + + while (i < source.len) { + const line_start = i; + while (i < source.len and source[i] != '\n') : (i += 1) {} + const raw_line = source[line_start..i]; + const at_eof = i >= source.len; + if (!at_eof) i += 1; // consume '\n' + + const line = trimRightNewline(raw_line); + + if (startsWith(line, "```")) { + try flush_para(allocator, &list, ¶); + if (list.items.len >= max_document_blocks) return error.TooManyBlocks; + const language = try allocator.dupe(u8, line[3..]); + errdefer allocator.free(language); + var body: std.ArrayList(u8) = .empty; + errdefer body.deinit(allocator); + while (i < source.len) { + const fs = i; + while (i < source.len and source[i] != '\n') : (i += 1) {} + const fence_line = trimRightNewline(source[fs..i]); + const fence_eof = i >= source.len; + if (!fence_eof) i += 1; + if (startsWith(fence_line, "```")) break; + if (body.items.len > 0) try body.append(allocator, '\n'); + try body.appendSlice(allocator, fence_line); + } + const text = try body.toOwnedSlice(allocator); + try list.append(allocator, .{ + .kind = .code_fence, + .text = text, + .language = language, + }); + continue; + } + + if (isBlank(line)) { + try flush_para(allocator, &list, ¶); + continue; + } + + const classified = classifyLine(line); + if (classified.kind == .paragraph) { + if (para.items.len > 0) try para.append(allocator, '\n'); + try para.appendSlice(allocator, classified.body); + continue; + } + + try flush_para(allocator, &list, ¶); + if (list.items.len >= max_document_blocks) return error.TooManyBlocks; + const text = try allocator.dupe(u8, classified.body); + errdefer allocator.free(text); + try list.append(allocator, .{ + .kind = classified.kind, + .text = text, + .language = "", + }); + } + try flush_para(allocator, &list, ¶); + + if (list.items.len == 0) { + const text = try allocator.dupe(u8, ""); + try list.append(allocator, .{ .kind = .paragraph, .text = text }); + } + return try list.toOwnedSlice(allocator); +} + +pub fn freeBlock(allocator: std.mem.Allocator, block: Block) void { + if (block.text.len > 0) allocator.free(block.text); + if (block.language.len > 0) allocator.free(block.language); + // runs are not owned by parse today +} + +pub fn freeBlocks(allocator: std.mem.Allocator, blocks: []Block) void { + for (blocks) |b| freeBlock(allocator, b); + allocator.free(blocks); +} + +/// Serialize blocks to GFM into `out`. Returns bytes written or error if +/// the buffer is too small. +pub fn serializeBlocks(blocks: []const Block, out: []u8) error{BufferTooSmall}!usize { + var o: usize = 0; + for (blocks, 0..) |block, i| { + if (i > 0) { + if (o + 1 > out.len) return error.BufferTooSmall; + out[o] = '\n'; + o += 1; + // Blank line between paragraphs / after headings for readable GFM. + if (block.kind == .paragraph or blocks[i - 1].kind == .paragraph or + blocks[i - 1].kind == .heading1 or blocks[i - 1].kind == .heading2 or + blocks[i - 1].kind == .heading3 or blocks[i - 1].kind == .code_fence) + { + if (o + 1 > out.len) return error.BufferTooSmall; + out[o] = '\n'; + o += 1; + } + } + const prefix: []const u8 = switch (block.kind) { + .paragraph => "", + .heading1 => "# ", + .heading2 => "## ", + .heading3 => "### ", + .bullet_item => "- ", + .numbered_item => "1. ", + .code_fence => "```", + }; + if (block.kind == .code_fence) { + if (o + 3 + block.language.len + 1 + block.text.len + 4 > out.len) + return error.BufferTooSmall; + @memcpy(out[o .. o + 3], "```"); + o += 3; + @memcpy(out[o .. o + block.language.len], block.language); + o += block.language.len; + out[o] = '\n'; + o += 1; + @memcpy(out[o .. o + block.text.len], block.text); + o += block.text.len; + if (block.text.len == 0 or block.text[block.text.len - 1] != '\n') { + out[o] = '\n'; + o += 1; + } + @memcpy(out[o .. o + 3], "```"); + o += 3; + continue; + } + if (o + prefix.len + block.text.len > out.len) return error.BufferTooSmall; + @memcpy(out[o .. o + prefix.len], prefix); + o += prefix.len; + @memcpy(out[o .. o + block.text.len], block.text); + o += block.text.len; + } + return o; +} + +pub const DocError = error{ + OutOfMemory, + InvalidIndex, + TooManyBlocks, +}; + +fn dupBlock(allocator: std.mem.Allocator, block: Block) DocError!Block { + const text = try allocator.dupe(u8, block.text); + errdefer allocator.free(text); + const language = try allocator.dupe(u8, block.language); + errdefer allocator.free(language); + return .{ + .kind = block.kind, + .text = text, + .runs = block.runs, + .language = language, + }; +} + +/// Split block at `byte_offset` (UTF-8 byte index into block text). +/// Returns a new owned slice; caller frees with `freeBlocks`. +pub fn splitBlock( + allocator: std.mem.Allocator, + blocks: []const Block, + index: usize, + byte_offset: usize, +) DocError![]Block { + if (index >= blocks.len) return error.InvalidIndex; + if (blocks.len + 1 > max_document_blocks) return error.TooManyBlocks; + const src = blocks[index]; + const off = @min(byte_offset, src.text.len); + var out: std.ArrayList(Block) = .empty; + errdefer { + for (out.items) |b| freeBlock(allocator, b); + out.deinit(allocator); + } + for (blocks, 0..) |b, i| { + if (i == index) { + const left_text = try allocator.dupe(u8, src.text[0..off]); + errdefer allocator.free(left_text); + const right_text = try allocator.dupe(u8, src.text[off..]); + errdefer allocator.free(right_text); + const left_lang = try allocator.dupe(u8, if (src.kind == .code_fence) src.language else ""); + errdefer allocator.free(left_lang); + try out.append(allocator, .{ + .kind = src.kind, + .text = left_text, + .language = left_lang, + }); + // New block after Enter is a paragraph (unless splitting a fence). + const right_kind: BlockKind = if (src.kind == .code_fence) .code_fence else .paragraph; + const right_lang = try allocator.dupe(u8, if (right_kind == .code_fence) src.language else ""); + errdefer allocator.free(right_lang); + try out.append(allocator, .{ + .kind = right_kind, + .text = right_text, + .language = right_lang, + }); + } else { + try out.append(allocator, try dupBlock(allocator, b)); + } + } + return try out.toOwnedSlice(allocator); +} + +/// Merge `index` into the previous block (Backspace at start of block). +pub fn mergeWithPrevious( + allocator: std.mem.Allocator, + blocks: []const Block, + index: usize, +) DocError![]Block { + if (index == 0 or index >= blocks.len) return error.InvalidIndex; + var out: std.ArrayList(Block) = .empty; + errdefer { + for (out.items) |b| freeBlock(allocator, b); + out.deinit(allocator); + } + const left = blocks[index - 1]; + const right = blocks[index]; + for (blocks, 0..) |b, i| { + if (i == index - 1) { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + try buf.appendSlice(allocator, left.text); + try buf.appendSlice(allocator, right.text); + const text = try buf.toOwnedSlice(allocator); + const language = try allocator.dupe(u8, left.language); + errdefer allocator.free(language); + try out.append(allocator, .{ + .kind = left.kind, + .text = text, + .language = language, + }); + } else if (i == index) { + continue; + } else { + try out.append(allocator, try dupBlock(allocator, b)); + } + } + return try out.toOwnedSlice(allocator); +} + +/// Change the kind of one block (toolbar / `# ` prefix promotion). +pub fn changeBlockKind( + allocator: std.mem.Allocator, + blocks: []const Block, + index: usize, + kind: BlockKind, +) DocError![]Block { + if (index >= blocks.len) return error.InvalidIndex; + var out: std.ArrayList(Block) = .empty; + errdefer { + for (out.items) |b| freeBlock(allocator, b); + out.deinit(allocator); + } + for (blocks, 0..) |b, i| { + var next = try dupBlock(allocator, b); + if (i == index) { + next.kind = kind; + if (kind != .code_fence and next.language.len > 0) { + allocator.free(next.language); + next.language = try allocator.dupe(u8, ""); + } + } + try out.append(allocator, next); + } + return try out.toOwnedSlice(allocator); +} + +test "serializeBlocks heading and paragraph" { + const blocks = [_]Block{ + .{ .kind = .heading1, .text = "Hi" }, + .{ .kind = .paragraph, .text = "body" }, + }; + var buf: [64]u8 = undefined; + const n = try serializeBlocks(&blocks, &buf); + try std.testing.expectEqualStrings("# Hi\n\nbody", buf[0..n]); +} + +test "parseBlocks round-trip subset" { + const src = + \\# Untitled + \\ + \\Hello **world** + \\ + \\- one + \\1. two + \\ + \\```ts + \\const x = 1; + \\``` + ; + const blocks = try parseBlocks(std.testing.allocator, src); + defer freeBlocks(std.testing.allocator, blocks); + try std.testing.expect(blocks.len >= 5); + try std.testing.expect(blocks[0].kind == .heading1); + try std.testing.expectEqualStrings("Untitled", blocks[0].text); + try std.testing.expect(blocks[1].kind == .paragraph); + try std.testing.expectEqualStrings("Hello **world**", blocks[1].text); + try std.testing.expect(blocks[2].kind == .bullet_item); + try std.testing.expectEqualStrings("one", blocks[2].text); + try std.testing.expect(blocks[3].kind == .numbered_item); + try std.testing.expectEqualStrings("two", blocks[3].text); + try std.testing.expect(blocks[4].kind == .code_fence); + try std.testing.expectEqualStrings("ts", blocks[4].language); + try std.testing.expectEqualStrings("const x = 1;", blocks[4].text); +} + +test "splitBlock and mergeWithPrevious" { + const start = [_]Block{ + .{ .kind = .paragraph, .text = "abcdef" }, + }; + const split = try splitBlock(std.testing.allocator, &start, 0, 3); + defer freeBlocks(std.testing.allocator, split); + try std.testing.expect(split.len == 2); + try std.testing.expectEqualStrings("abc", split[0].text); + try std.testing.expectEqualStrings("def", split[1].text); + const merged = try mergeWithPrevious(std.testing.allocator, split, 1); + defer freeBlocks(std.testing.allocator, merged); + try std.testing.expect(merged.len == 1); + try std.testing.expectEqualStrings("abcdef", merged[0].text); +} + +test "changeBlockKind" { + const start = [_]Block{ + .{ .kind = .paragraph, .text = "Title" }, + }; + const next = try changeBlockKind(std.testing.allocator, &start, 0, .heading1); + defer freeBlocks(std.testing.allocator, next); + try std.testing.expect(next[0].kind == .heading1); + var buf: [32]u8 = undefined; + const n = try serializeBlocks(next, &buf); + try std.testing.expectEqualStrings("# Title", buf[0..n]); +} diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index 0bc365a48..57997dfc4 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -535,6 +535,10 @@ pub fn Ui(comptime Msg: type) type { /// this with their content argument. text: []const u8 = "", placeholder: []const u8 = "", + /// Serialized style runs for `rich-textarea` (markup `styles=`). + /// Converted to `Widget.spans` at markup lower time. Empty on + /// every other element. + styles: []const u8 = "", value: f32 = 0, /// HORIZONTAL scroll offset for a horizontal-capable /// `scroll` container (markup `value-x`) — the sideways diff --git a/src/primitives/canvas/ui_markup.zig b/src/primitives/canvas/ui_markup.zig index e63f4e222..86686b16f 100644 --- a/src/primitives/canvas/ui_markup.zig +++ b/src/primitives/canvas/ui_markup.zig @@ -1176,6 +1176,7 @@ pub fn deadHandlerOnNonHitTarget(attr_name: []const u8) bool { pub const autofocus_element_message = "autofocus is only supported on focusable controls (text fields, buttons, checkboxes, ...) - it moves keyboard focus to the element when it mounts or when the flag turns on, and nothing about this element can take focus"; pub const submit_on_enter_element_message = "submit-on-enter is only supported on textarea - it makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; single-line fields already submit on Enter, and other elements have no multiline Enter policy"; +pub const styles_element_message = "styles is only supported on rich-textarea - it carries serialized StyleRun bytes for in-place attributed paint"; pub const non_hit_target_handler_message = "on-change/on-submit/on-input never fire here: this element has no control or text behavior - put them on a control (input, checkbox, slider) inside it (on-press/on-double-press/on-toggle/on-hold/on-drag are fine anywhere: they make any element interactive, and presses on plain text or icons inside it fall through to it)"; @@ -3318,6 +3319,15 @@ fn validateNode(document: MarkupDocument, node: MarkupNode, parent_element: ?[]c } continue; } + if (std.mem.eql(u8, attribute.name, "styles")) { + if (!std.mem.eql(u8, node.name, "rich-textarea")) { + return attrError(node, attribute, styles_element_message); + } + if (attrExpressionError(attribute.value, invalid_expression_message)) |message| { + return attrError(node, attribute, message); + } + continue; + } if (std.mem.eql(u8, attribute.name, "submit-on-enter")) { // Only textarea has two meaningful Enter gestures: // submit or insert a newline. Single-line fields diff --git a/src/primitives/canvas/ui_markup_compiled.zig b/src/primitives/canvas/ui_markup_compiled.zig index 410bd28f3..7721a0cfb 100644 --- a/src/primitives/canvas/ui_markup_compiled.zig +++ b/src/primitives/canvas/ui_markup_compiled.zig @@ -523,6 +523,14 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res // exactly like `Ui.avatar` and the interpreter (a no-op // while the id is 0 and the initials fallback renders). if (comptime (kind == .avatar)) built.widget.image_fit = .cover; + if (comptime std.mem.eql(u8, node.name, "rich-textarea")) { + built.widget.runtime_flags.rich_editor = true; + if (options.styles.len > 0) { + built.widget.spans = canvas.spansFromSerializedStyles(ui.arena, built.widget.text, options.styles) catch { + return runtimeFail(Ui.Node, ui); + }; + } + } return built; } diff --git a/src/primitives/canvas/ui_markup_view.zig b/src/primitives/canvas/ui_markup_view.zig index e0fde0c15..5a6d54f8d 100644 --- a/src/primitives/canvas/ui_markup_view.zig +++ b/src/primitives/canvas/ui_markup_view.zig @@ -525,6 +525,14 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type { // exactly like `Ui.avatar` (a no-op while the id is 0 and // the initials fallback renders). if (kind == .avatar) built.widget.image_fit = .cover; + if (std.mem.eql(u8, node.name, "rich-textarea")) { + built.widget.runtime_flags.rich_editor = true; + if (options.styles.len > 0) { + built.widget.spans = canvas.spansFromSerializedStyles(ui.arena, built.widget.text, options.styles) catch { + return self.failNode(node, "rich-textarea styles overflow"); + }; + } + } return built; } diff --git a/src/primitives/canvas/ui_schema.zig b/src/primitives/canvas/ui_schema.zig index 412737feb..c83f7d0a9 100644 --- a/src/primitives/canvas/ui_schema.zig +++ b/src/primitives/canvas/ui_schema.zig @@ -362,6 +362,10 @@ pub const elements = [_]ElementInfo{ // line numbers or one horizontal scroll region for unwrapped lines. // Markdown fences use the same builder component. .{ .code = 70, .name = "code", .rule_hook = "code", .hit_target = false }, + // Paragraph-scoped attributed editing: same TextBuffer / IME / undo + // path as textarea, with parallel style runs painted via TextSpan. + // Stamps `WidgetRuntimeFlags.rich_editor` at lower time. + .{ .code = 71, .name = "rich-textarea", .widget_kind = "textarea", .a11y_name = .editable }, }; // ------------------------------------------------------------- attributes @@ -600,6 +604,11 @@ pub const attrs = [_]AttrInfo{ .{ .code = 100, .name = "source-y", .class = .number, .group = .element }, .{ .code = 101, .name = "source-width", .class = .number, .group = .element }, .{ .code = 102, .name = "source-height", .class = .number, .group = .element }, + // Rich-textarea style runs (rich-textarea only; the validator scopes + // it): serialized StyleRun bytes (9 bytes each) from text_attr / + // @native-sdk/core/text-attr. Converted to TextSpan at lower time + // for in-place attributed paint. + .{ .code = 103, .name = "styles", .class = .text, .group = .option, .field = "styles" }, }; // ----------------------------------------------------------------- events diff --git a/src/primitives/canvas/ui_schema_tests.zig b/src/primitives/canvas/ui_schema_tests.zig index bc70e7af0..cd2a76d46 100644 --- a/src/primitives/canvas/ui_schema_tests.zig +++ b/src/primitives/canvas/ui_schema_tests.zig @@ -21,15 +21,16 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed" // (append or slot them anywhere — order carries no meaning) and pin // the new fingerprint ONLY for additions; renames/renumbers are // schema-version-bump events, not silent edits. - try testing.expectEqual(@as(usize, 70), schema.elements.len); - try testing.expectEqual(@as(usize, 102), schema.attrs.len); + try testing.expectEqual(@as(usize, 71), schema.elements.len); + try testing.expectEqual(@as(usize, 103), schema.attrs.len); try testing.expectEqual(@as(usize, 15), schema.events.len); // The element table runs through the span composite (64), the // bubble-reactions composite (65), the media surface (66), the // runtime-image leaf (67), the video playback composite (68), and - // the terminal leaf (69), and the reusable code composite (70). + // the terminal leaf (69), the reusable code composite (70), and + // the rich-textarea leaf (71). try testing.expectEqual( - @as(u64, 0x180108eb2382ba60), + @as(u64, 0x0380776b4e7f396c), tableFingerprint(schema.ElementInfo, &schema.elements), ); // The attr table runs through the split layout-tween attributes @@ -50,9 +51,9 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed" // textarea Enter policy submit-on-enter (97), and the responsive // layout ceiling max-width (98), and the registered-image source // rectangle source-x (99), source-y (100), source-width (101), and - // source-height (102). + // source-height (102), and the rich-textarea styles binding styles (103). try testing.expectEqual( - @as(u64, 0x3614b1510b48dcf4), + @as(u64, 0x38842a363978990a), tableFingerprint(schema.AttrInfo, &schema.attrs), ); // The event table runs through the pointer-hover containment pair @@ -107,7 +108,7 @@ test "registry event scoping names registry elements" { test "derived name lists mirror the registry" { // The derivations are the vocabulary every consumer reads; hold them // to the registry's own predicates. - try testing.expectEqual(@as(usize, 56), schema.element_names.len); + try testing.expectEqual(@as(usize, 57), schema.element_names.len); for (schema.element_names) |name| { try testing.expect(schema.elementByName(name).?.rule_hook == null); } diff --git a/src/primitives/canvas/widget_render.zig b/src/primitives/canvas/widget_render.zig index 6834e406b..d72a83bb7 100644 --- a/src/primitives/canvas/widget_render.zig +++ b/src/primitives/canvas/widget_render.zig @@ -510,6 +510,8 @@ fn emitWidgetDepthContent(builder: *Builder, widget: Widget, tokens: DesignToken .input, .text_field => try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens), .textarea => if (paint_widget.runtime_flags.code_editor) try emitCodeEditorWidget(builder, paint_widget, tokens) + else if (paint_widget.runtime_flags.rich_editor) + try widget_render_controls.emitRichEditorWidget(builder, paint_widget, tokens) else try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens), .search_field, .combobox => try widget_render_controls.emitSearchFieldWidget(builder, paint_widget, tokens), @@ -900,6 +902,8 @@ fn emitWidgetLayoutNodeContent( .input, .text_field => try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens), .textarea => if (paint_widget.runtime_flags.code_editor) try emitCodeEditorWidget(builder, paint_widget, tokens) + else if (paint_widget.runtime_flags.rich_editor) + try widget_render_controls.emitRichEditorWidget(builder, paint_widget, tokens) else try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens), .search_field, .combobox => try widget_render_controls.emitSearchFieldWidget(builder, paint_widget, tokens), @@ -1337,6 +1341,7 @@ fn emitTextWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error if (clip_overflow) try builder.popClip(); } + /// Editable code is a textarea behaviorally and a bare code surface /// visually. Selection/caret geometry comes from the shared text-input /// seam while glyphs come from the same syntax-span emitter as read-only diff --git a/src/primitives/canvas/widget_render_controls.zig b/src/primitives/canvas/widget_render_controls.zig index 169efe921..c12498877 100644 --- a/src/primitives/canvas/widget_render_controls.zig +++ b/src/primitives/canvas/widget_render_controls.zig @@ -8,6 +8,7 @@ const widget_model = @import("widgets.zig"); const widget_access = @import("widget_access.zig"); const widget_metrics = @import("widget_metrics.zig"); const widget_text_input = @import("widget_text_input.zig"); +const text_spans_model = @import("text_spans.zig"); const widget_render_style = @import("widget_render_style.zig"); const widget_render = @import("widget_render.zig"); const icon_model = @import("icons.zig"); @@ -40,6 +41,7 @@ const widgetTextInputLayoutOptions = widget_text_input.widgetTextInputLayoutOpti const widgetTextInputOrigin = widget_text_input.widgetTextInputOrigin; const widgetTextInputClipRect = widget_text_input.widgetTextInputClipRect; const widgetTextInputDrawText = widget_text_input.widgetTextInputDrawText; +const persistWidgetTextInputPresentedText = widget_text_input.persistWidgetTextInputPresentedText; const widgetTextInputInset = widget_text_input.widgetTextInputInset; const widgetTextInputClipsText = widget_text_input.widgetTextInputClipsText; const textInputClearButtonRect = widget_text_input.textInputClearButtonRect; @@ -460,6 +462,140 @@ fn emitSelectChevron(builder: *Builder, widget: Widget, tokens: DesignTokens, vi try emitVectorIcon(builder, widget.id, 4, icon_frame, color, icon); } + +/// Attributed textarea (`rich_editor`): field chrome + TextSpan glyphs from +/// style runs lowered onto `widget.spans`. Caret/selection still ride the +/// shared text-input seam (monostyle advances) for dogfood. +pub fn emitRichEditorWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { + if (widget.spans.len == 0) { + return emitTextFieldWidget(builder, widget, tokens); + } + + const visual = textInputControlVisualTokens(widget, tokens); + const radius = controlRadius(widget, visual, tokens.radius.md); + const text_size = widgetTextInputSize(widget, tokens); + const text_inset = widgetTextInputInset(widget, tokens); + const layout_options = widgetTextInputLayoutOptions(widget, tokens, text_size, text_inset); + const clip_rect = widgetTextInputClipRect(widget, tokens, text_size, text_inset, layout_options); + const origin = widgetTextInputOrigin(widget, tokens, text_size, text_inset, layout_options); + const text_color = widgetForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.text); + var draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options); + draw_text.text = persistWidgetTextInputPresentedText(builder, widget.text, draw_text.text); + const selection_range = widgetTextSelectionRange(widget); + const composition_range = widgetTextCompositionRange(widget); + const clips_text = widgetTextInputClipsText(widget, tokens, text_size, text_inset, layout_options); + + try builder.fillRoundedRect(.{ + .id = widgetPartId(widget.id, 1), + .rect = widget.frame, + .radius = radius, + .fill = textInputFill(widget, tokens, visual), + }); + try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ + .id = widgetPartId(widget.id, 2), + .rect = widget.frame, + .radius = radius, + .stroke = .{ + .fill = textInputBorderFill(widget, visual, tokens.colors.border), + .width = controlStrokeWidth(widget, visual, tokens.stroke.regular), + }, + })); + if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 7, widget.frame, radius); + if (clips_text) try builder.pushClip(.{ .id = widgetPartId(widget.id, 16), .rect = clip_rect, .radius = radius }); + + if (selection_range) |range| { + if (!range.isCollapsed(widget.text.len)) { + try emitWidgetTextSelectionRects(builder, widget, draw_text, layout_options, range, 3, 13, max_widget_text_range_rects, tokens); + } + } + + const span_options = text_spans_model.TextSpanLayoutOptions{ + .size = text_size, + .line_height = layout_options.line_height, + .max_width = layout_options.max_width, + .wrap = .word, + .alignment = .start, + .typography = tokens.typography, + .measure = tokens.text_measure, + }; + var runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined; + const layout = text_spans_model.layoutTextSpans(widget.spans, span_options, &runs); + const scroll_y = widget.value; + for (layout.runs, 0..) |run, ordinal| { + if (run.text.len == 0) continue; + const span = widget.spans[run.span_index]; + const color = if (span.color) |ref| + text_spans_model.textSpanColorValue(tokens.colors, ref) + else + text_color; + const baseline_y = origin.y - scroll_y + run.baseline; + const origin_pt = pixelSnapTextPoint(tokens, geometry.PointF.init(origin.x + run.x, baseline_y)); + const slot: ObjectId = 40 + @as(ObjectId, @intCast(@min(ordinal, 200))); + try builder.drawText(.{ + .id = widgetPartId(widget.id, slot), + .font_id = run.font_id, + .size = run.size, + .origin = origin_pt, + .color = color, + .text = run.text, + .text_layout = .{ + .max_width = 0, + .line_height = layout.line_height, + .wrap = .none, + .alignment = .start, + .measure = tokens.text_measure, + }, + }); + const thickness = @max(1, tokens.stroke.hairline); + const bounds = text_spans_model.textSpanRunBounds(layout, run); + if (span.underline) { + const uslot: ObjectId = 80 + @as(ObjectId, @intCast(@min(ordinal, 40))); + try builder.fillRect(.{ + .id = widgetPartId(widget.id, uslot), + .rect = pixelSnapGeometryRect(tokens, geometry.RectF.init( + origin.x + bounds.x, + origin.y - scroll_y + bounds.y + bounds.height - thickness, + bounds.width, + thickness, + )), + .fill = colorFill(color), + }); + } + if (span.strikethrough) { + const sslot: ObjectId = 120 + @as(ObjectId, @intCast(@min(ordinal, 40))); + try builder.fillRect(.{ + .id = widgetPartId(widget.id, sslot), + .rect = pixelSnapGeometryRect(tokens, geometry.RectF.init( + origin.x + bounds.x, + origin.y - scroll_y + bounds.y + bounds.height * 0.5, + bounds.width, + thickness, + )), + .fill = colorFill(color), + }); + } + } + + if (selection_range) |range| { + if (!range.isCollapsed(widget.text.len)) { + try emitWidgetTextSelectedGlyphs(builder, widget, draw_text, layout_options, range, max_widget_text_range_rects, tokens); + } + } + if (composition_range) |range| { + if (!range.isCollapsed(widget.text.len)) { + try emitWidgetTextCompositionLines(builder, widget, draw_text, layout_options, range, 5, 10, max_widget_text_range_rects, tokens); + } + } + if (widget.state.focused) { + if (selection_range) |range| { + if (range.isCollapsed(widget.text.len)) { + try emitWidgetTextCaret(builder, widget, draw_text, layout_options, range.start, 6, tokens); + } + } + } + if (clips_text) try builder.popClip(); +} + pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { const visual = textInputControlVisualTokens(widget, tokens); const radius = controlRadius(widget, visual, tokens.radius.md); diff --git a/src/primitives/canvas/widgets.zig b/src/primitives/canvas/widgets.zig index 0da67d26a..0c75bf0b1 100644 --- a/src/primitives/canvas/widgets.zig +++ b/src/primitives/canvas/widgets.zig @@ -273,7 +273,10 @@ pub const WidgetRuntimeFlags = packed struct(u8) { /// The runtime installed an OS-native scroll driver; engine-drawn /// scrollbar and kinetic physics stand down for this scroll view. native_scroll: bool = false, - _reserved: u6 = 0, + /// Paragraph-scoped attributed editing (``): plain + /// TextBuffer + parallel style runs painted via TextSpan layout. + rich_editor: bool = false, + _reserved: u5 = 0, }; /// Two 128-line masks for code-only diff presentation. `Widget` packs them diff --git a/tools/native-sdk/markup_docs.zig b/tools/native-sdk/markup_docs.zig index a1c3ebcfb..946f80fb6 100644 --- a/tools/native-sdk/markup_docs.zig +++ b/tools/native-sdk/markup_docs.zig @@ -39,6 +39,7 @@ pub const element_docs = [_]Doc{ .{ .name = "text-field", .doc = "Text entry; placeholder and text binding, edits via on-input, enter via on-submit." }, .{ .name = "search-field", .doc = "Text entry styled for search; edits via on-input." }, .{ .name = "textarea", .doc = "Multi-line text entry; edits via on-input, enter inserts a newline, submit via primary+enter with on-submit." }, + .{ .name = "rich-textarea", .doc = "Paragraph-scoped attributed multi-line entry: same TextBuffer/IME/undo path as textarea, with parallel style runs (text_attr / @native-sdk/core/text-attr). Stamps WidgetRuntimeFlags.rich_editor." }, .{ .name = "list-item", .doc = "Text-bearing item control; the label is the text content." }, .{ .name = "menu-item", .doc = "Text-bearing menu control; the label is the text content." }, .{ .name = "status-bar", .doc = "Status bar text leaf: content only, no children." },