diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 8f8a1cdc..5f7064cb 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -19,10 +19,13 @@ on: - "hosts/macos/**" - "tools/macos.ts" - "apps/note/**" + - "apps/desk98/**" - "framework/compiler/**" - "assets/fonts/**" - "tests/platform-contracts.test.ts" - "tests/note.test.ts" + - "tests/desk98.test.ts" + - "tests/desk98-sim.test.ts" - "tests/font-bake.test.ts" - "tests/npm-package.test.ts" - "package.json" @@ -37,10 +40,13 @@ on: - "hosts/macos/**" - "tools/macos.ts" - "apps/note/**" + - "apps/desk98/**" - "framework/compiler/**" - "assets/fonts/**" - "tests/platform-contracts.test.ts" - "tests/note.test.ts" + - "tests/desk98.test.ts" + - "tests/desk98-sim.test.ts" - "tests/font-bake.test.ts" - "tests/npm-package.test.ts" - "package.json" @@ -75,8 +81,8 @@ jobs: - name: Install JS dependencies run: bun install --frozen-lockfile - - name: Contract + note + platform + npm package tests - run: bun test tests/platform-contracts.test.ts tests/note.test.ts tests/font-bake.test.ts tests/npm-package.test.ts && bun tests/contract.ts + - name: Contract + note + desk98 + platform + npm package tests + run: bun test tests/platform-contracts.test.ts tests/note.test.ts tests/desk98.test.ts tests/font-bake.test.ts tests/npm-package.test.ts && bun tests/contract.ts - name: Compile the note against macos-app (plan + bundle + pak) run: | @@ -90,6 +96,23 @@ jobs: await Bun.write(".pocket/macos-app/note-main.plan.json", JSON.stringify(r.plan, null, 2) + "\n");' bun tools/build.ts --plan=.pocket/macos-app/note-main.plan.json + - name: Compile desk98 against macos-app (vue-vapor, W95FA pak.json splice + SVG icons) + run: | + bun -e ' + import { validateAndResolveBuildPlan } from "./framework/src/manifest/resolve.ts"; + import { mkdirSync } from "node:fs"; + const m = await Bun.file("apps/desk98/pocket.json").json(); + const r = validateAndResolveBuildPlan(m, { target: "macos-app" }); + if (!r.ok) throw new Error(JSON.stringify(r.diagnostics)); + mkdirSync(".pocket/macos-app", { recursive: true }); + await Bun.write(".pocket/macos-app/desk98-main.plan.json", JSON.stringify(r.plan, null, 2) + "\n");' + bun tools/build.ts --plan=.pocket/macos-app/desk98-main.plan.json + + - name: desk98 sim smoke (vue-vapor bundle) + run: | + bun tools/build.ts desk98-main --framework=vue-vapor + bun test --conditions=browser tests/desk98-sim.test.ts + - name: Test core (TEXT_RUN + native measure gates) run: cargo test --locked --manifest-path engine/core/Cargo.toml diff --git a/apps/desk98/AboutView.vue b/apps/desk98/AboutView.vue new file mode 100644 index 00000000..35c83bcc --- /dev/null +++ b/apps/desk98/AboutView.vue @@ -0,0 +1,31 @@ + + + diff --git a/apps/desk98/Button98.vue b/apps/desk98/Button98.vue new file mode 100644 index 00000000..d677555f --- /dev/null +++ b/apps/desk98/Button98.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/desk98/CaptionButtons.vue b/apps/desk98/CaptionButtons.vue new file mode 100644 index 00000000..20118a41 --- /dev/null +++ b/apps/desk98/CaptionButtons.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/desk98/Counter.vue b/apps/desk98/Counter.vue new file mode 100644 index 00000000..ca37cec8 --- /dev/null +++ b/apps/desk98/Counter.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/desk98/DesktopIcons.vue b/apps/desk98/DesktopIcons.vue new file mode 100644 index 00000000..2d0eb6db --- /dev/null +++ b/apps/desk98/DesktopIcons.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/desk98/Digit.vue b/apps/desk98/Digit.vue new file mode 100644 index 00000000..5de1f583 --- /dev/null +++ b/apps/desk98/Digit.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/desk98/FolderView.vue b/apps/desk98/FolderView.vue new file mode 100644 index 00000000..ed95df73 --- /dev/null +++ b/apps/desk98/FolderView.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/desk98/MinesCell.vue b/apps/desk98/MinesCell.vue new file mode 100644 index 00000000..32f832ac --- /dev/null +++ b/apps/desk98/MinesCell.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/desk98/MinesView.vue b/apps/desk98/MinesView.vue new file mode 100644 index 00000000..3672ac2f --- /dev/null +++ b/apps/desk98/MinesView.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/desk98/NotepadView.vue b/apps/desk98/NotepadView.vue new file mode 100644 index 00000000..e74386cc --- /dev/null +++ b/apps/desk98/NotepadView.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/desk98/PopupPanel.vue b/apps/desk98/PopupPanel.vue new file mode 100644 index 00000000..707a2d9e --- /dev/null +++ b/apps/desk98/PopupPanel.vue @@ -0,0 +1,64 @@ + + + diff --git a/apps/desk98/ShutdownView.vue b/apps/desk98/ShutdownView.vue new file mode 100644 index 00000000..85242e8b --- /dev/null +++ b/apps/desk98/ShutdownView.vue @@ -0,0 +1,43 @@ + + + diff --git a/apps/desk98/StartMenu.vue b/apps/desk98/StartMenu.vue new file mode 100644 index 00000000..4eb83282 --- /dev/null +++ b/apps/desk98/StartMenu.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/desk98/T98.vue b/apps/desk98/T98.vue new file mode 100644 index 00000000..1a828722 --- /dev/null +++ b/apps/desk98/T98.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/desk98/Taskbar.vue b/apps/desk98/Taskbar.vue new file mode 100644 index 00000000..23a263ea --- /dev/null +++ b/apps/desk98/Taskbar.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/desk98/Window98.vue b/apps/desk98/Window98.vue new file mode 100644 index 00000000..587be263 --- /dev/null +++ b/apps/desk98/Window98.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/desk98/app.vue b/apps/desk98/app.vue new file mode 100644 index 00000000..267fe5dd --- /dev/null +++ b/apps/desk98/app.vue @@ -0,0 +1,1341 @@ + + + diff --git a/apps/desk98/fonts/w95fa-19.bin b/apps/desk98/fonts/w95fa-19.bin new file mode 100644 index 00000000..65f505eb Binary files /dev/null and b/apps/desk98/fonts/w95fa-19.bin differ diff --git a/apps/desk98/fonts/w95fa-19@2x.bin b/apps/desk98/fonts/w95fa-19@2x.bin new file mode 100644 index 00000000..66ad9c42 Binary files /dev/null and b/apps/desk98/fonts/w95fa-19@2x.bin differ diff --git a/apps/desk98/fonts/w95fa-20.bin b/apps/desk98/fonts/w95fa-20.bin new file mode 100644 index 00000000..f57ee7b1 Binary files /dev/null and b/apps/desk98/fonts/w95fa-20.bin differ diff --git a/apps/desk98/fonts/w95fa-20@2x.bin b/apps/desk98/fonts/w95fa-20@2x.bin new file mode 100644 index 00000000..2ed23cf6 Binary files /dev/null and b/apps/desk98/fonts/w95fa-20@2x.bin differ diff --git a/apps/desk98/fonts/w95fa-21.bin b/apps/desk98/fonts/w95fa-21.bin new file mode 100644 index 00000000..fda2c497 Binary files /dev/null and b/apps/desk98/fonts/w95fa-21.bin differ diff --git a/apps/desk98/fonts/w95fa-21@2x.bin b/apps/desk98/fonts/w95fa-21@2x.bin new file mode 100644 index 00000000..e819a62d Binary files /dev/null and b/apps/desk98/fonts/w95fa-21@2x.bin differ diff --git a/apps/desk98/gen-assets.ts b/apps/desk98/gen-assets.ts new file mode 100644 index 00000000..32806bb7 --- /dev/null +++ b/apps/desk98/gen-assets.ts @@ -0,0 +1,215 @@ +// apps/desk98/gen-assets.ts — bakes W95FA (assets/fonts/W95FA.otf, OFL; the +// FontsArena Win95 MS Sans Serif recreation) into per-app FONT ATLAS blobs, +// committed under apps/desk98/fonts/ and spliced by pak.json: +// +// bun apps/desk98/gen-assets.ts +// +// Slots (repo slots 0..18 are Inter/JetBrains Mono; these live app-side): +// 19 W95FA 12.5px regular — the whole desktop +// 20 W95FA 12.5px synthetic bold (GDI smear: 1px max-blend + advance+1) +// 21 W95FA 25px regular — the Start-menu banner +// +// W95FA is a bitmap-font conversion on an 80-units/px grid at its native +// 12.5px, with sloppy CFF floats (…129.92 for 130) and a 10-unit x phase. +// Coordinates snap to the 40-unit half-pixel grid minus that phase before +// baking, so at rasterDensity 2 every stroke edge lands on a device pixel; +// the bake then hard-thresholds coverage to 0/255 and asserts the AA +// fraction was already negligible (the snap really aligned) and that +// edge-sharing strokes survived the fill ('h' keeps its ascender — the +// nonzero-winding guarantee this font's outlines depend on). + +import { parse as parseFont, type Font } from "opentype.js"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { bakeSlot, type BakedAtlas } from "../../framework/compiler/bake-font.ts"; +import { + FONT_CMAP_ENTRY_SIZE, + FONT_FLAG_BOLD, + FONT_HEADER_SIZE, +} from "../../contracts/spec/spec.ts"; + +const ROOT = new URL("../..", import.meta.url).pathname; +const OUT = join(ROOT, "apps/desk98/fonts"); +mkdirSync(OUT, { recursive: true }); + +const PHASE = 10; // measured x offset of the outline grid, font units +const STEP = 40; // half-pixel grid at 12.5px (80 units/px) + +function snapCoord(v: number, phase: number): number { + return Math.round((v - phase) / STEP) * STEP; +} + +async function loadSnapped(): Promise { + const font = parseFont(await Bun.file(join(ROOT, "assets/fonts/W95FA.otf")).arrayBuffer()); + // Snap only the glyphs the bake reads — .notdef and unmapped decorations + // are off-grid and never rasterized (gid 0 is the drawn tofu box). + const gids = new Set(); + for (const cp of CHARS) { + const gi = font.charToGlyphIndex(String.fromCodePoint(cp)); + if (gi > 0) gids.add(gi); + } + for (const gi of gids) { + const glyph = font.glyphs.get(gi); + const path = glyph.path as { commands?: Array> }; + for (const cmd of path?.commands ?? []) { + for (const key of ["x", "x1", "x2"]) { + if (typeof cmd[key] === "number") { + const s = snapCoord(cmd[key] as number, PHASE); + const err = Math.abs((cmd[key] as number) - PHASE - s); + // Two conversion strays ('"' and 'y') sit up to 16 units off the + // grid — snap them too (≤0.2px drift); anything worse is a new + // font revision and needs a fresh look. + if (err > 20) { + throw new Error(`gen-assets: gid ${gi} x=${cmd[key]} is off the ${STEP}-unit grid`); + } + cmd[key] = s; + } + } + for (const key of ["y", "y1", "y2"]) { + if (typeof cmd[key] === "number") cmd[key] = snapCoord(cmd[key] as number, 0); + } + } + } + + // W95FA ships no backtick (cp 96 → .notdef, the drawn tofu box), but the + // desktop's ⌘` shortcut copy needs one. Synthesize it: mirror the + // apostrophe's ink around its own x bounds (the mark leans the other way; + // bounds are snapped 40-multiples, so the mirror stays on the pixel grid) + // into a donor glyph outside the ASCII set, and remap cp 96 onto it. + const apoGi = font.charToGlyphIndex("'"); + const apo = font.glyphs.get(apoGi); + const apoCmds = (apo.path as { commands: Array> }).commands; + const xs: number[] = []; + for (const cmd of apoCmds) { + for (const key of ["x", "x1", "x2"]) { + if (typeof cmd[key] === "number") xs.push(cmd[key] as number); + } + } + const pivot = Math.min(...xs) + Math.max(...xs); + let donorGid = -1; + for (let gi = font.glyphs.length - 1; gi > 0; gi--) { + if (!gids.has(gi)) { + donorGid = gi; + break; + } + } + if (donorGid < 0) throw new Error("gen-assets: no donor gid for the synthetic backtick"); + const donor = font.glyphs.get(donorGid); + const donorPath = donor.path as { commands: Array> }; + donorPath.commands.length = 0; + for (const cmd of apoCmds) { + const m: Record = { ...cmd }; + for (const key of ["x", "x1", "x2"]) { + if (typeof m[key] === "number") m[key] = pivot - (m[key] as number); + } + donorPath.commands.push(m); + } + donor.advanceWidth = apo.advanceWidth; + const origIndex = font.charToGlyphIndex.bind(font); + font.charToGlyphIndex = (s: string) => (s === "`" ? donorGid : origIndex(s)); + + return font; +} + +const CHARS: number[] = []; +for (let c = 32; c <= 126; c++) CHARS.push(c); + +/** Hard-threshold the coverage plane to 0/255 and report the AA fraction. */ +function threshold(atlas: BakedAtlas): number { + const cmapEnd = FONT_HEADER_SIZE + atlas.glyphCount * FONT_CMAP_ENTRY_SIZE; + let aa = 0; + let ink = 0; + for (let i = cmapEnd; i < atlas.bytes.length; i++) { + const b = atlas.bytes[i]; + if (b > 0) ink++; + if (b > 0 && b < 255) { + aa++; + atlas.bytes[i] = b >= 128 ? 255 : 0; + } + } + return ink > 0 ? aa / ink : 0; +} + +/** Coverage cell for a codepoint (gid-indexed cells after header + cmap). */ +function cellFor(atlas: BakedAtlas, cp: number): Uint8Array { + const view = new DataView(atlas.bytes.buffer, atlas.bytes.byteOffset, atlas.bytes.byteLength); + const cellBytes = atlas.coverageW * atlas.coverageH; + const cellsOff = FONT_HEADER_SIZE + atlas.glyphCount * FONT_CMAP_ENTRY_SIZE; + for (let i = 0; i < atlas.glyphCount; i++) { + const e = FONT_HEADER_SIZE + i * FONT_CMAP_ENTRY_SIZE; + if (view.getUint32(e, true) === cp) { + const gid = view.getUint16(e + 4, true); + return atlas.bytes.subarray(cellsOff + gid * cellBytes, cellsOff + (gid + 1) * cellBytes); + } + } + throw new Error(`gen-assets: codepoint ${cp} missing from the bake`); +} + +/** Synthetic bold: widen cells by one logical px, max-blend a copy shifted + * one logical px right (the classic GDI smear), advance +1. Rebuilds the + * blob because cellW changes. */ +function embolden(src: BakedAtlas, slot: number): Uint8Array { + const d = src.rasterDensity; + const srcW = src.coverageW; + const dstCellW = src.cellW + 1; + const dstW = dstCellW * d; + const h = src.coverageH; + const cellBytesSrc = srcW * h; + const cellBytesDst = dstW * h; + const cmapBytes = src.glyphCount * FONT_CMAP_ENTRY_SIZE; + const out = new Uint8Array(FONT_HEADER_SIZE + cmapBytes + src.glyphCount * cellBytesDst); + out.set(src.bytes.subarray(0, FONT_HEADER_SIZE + cmapBytes)); + out[8] = dstCellW; + out[12] = slot; + out[13] = FONT_FLAG_BOLD; + // Advances grow one logical px (clamped to the u8 the cmap carries). + for (let i = 0; i < src.glyphCount; i++) { + const a = FONT_HEADER_SIZE + i * FONT_CMAP_ENTRY_SIZE + 6; + out[a] = Math.min(255, out[a] + 1); + } + const srcCells = FONT_HEADER_SIZE + cmapBytes; + for (let g = 0; g < src.glyphCount; g++) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < dstW; x++) { + const at = (dx: number) => { + const sx = x - dx; + return sx >= 0 && sx < srcW + ? src.bytes[srcCells + g * cellBytesSrc + y * srcW + sx] + : 0; + }; + out[srcCells + g * cellBytesDst + y * dstW + x] = Math.max(at(0), at(d)); + } + } + } + return out; +} + +const font = await loadSnapped(); + +for (const density of [1, 2]) { + const suffix = density === 2 ? "@2x" : ""; + const a19 = bakeSlot(font, 19, 12.5, false, CHARS, density); + const aa19 = threshold(a19); + const a21 = bakeSlot(font, 21, 25, false, CHARS, density); + const aa21 = threshold(a21); + // The snap guarantee: at density 2 every edge is a device pixel, so the + // rasterizer produced (near-)bi-level coverage BEFORE thresholding. + if (density === 2 && (aa19 > 0.02 || aa21 > 0.02)) { + throw new Error(`gen-assets: AA fraction too high (${aa19}, ${aa21}) — grid snap regressed?`); + } + // Fill-rule guarantee: W95FA strokes share edges; even-odd cancels them. + const hCell = cellFor(a19, "h".codePointAt(0)!); + const nCell = cellFor(a19, "n".codePointAt(0)!); + if (Buffer.from(hCell).equals(Buffer.from(nCell))) { + throw new Error("gen-assets: 'h' baked identical to 'n' — fill rule regressed to even-odd?"); + } + if (!hCell.some((b) => b > 0)) throw new Error("gen-assets: 'h' baked empty"); + const a20 = embolden(a19, 20); + await Bun.write(join(OUT, `w95fa-19${suffix}.bin`), a19.bytes); + await Bun.write(join(OUT, `w95fa-20${suffix}.bin`), a20); + await Bun.write(join(OUT, `w95fa-21${suffix}.bin`), a21.bytes); + console.log( + `gen-assets: density ${density} — slot19 ${a19.bytes.length}B (AA ${(aa19 * 100).toFixed(2)}%), ` + + `slot20 ${a20.length}B, slot21 ${a21.bytes.length}B (AA ${(aa21 * 100).toFixed(2)}%)`, + ); +} diff --git a/apps/desk98/gen-icons.ts b/apps/desk98/gen-icons.ts new file mode 100644 index 00000000..ce930638 --- /dev/null +++ b/apps/desk98/gen-icons.ts @@ -0,0 +1,562 @@ +// apps/desk98/gen-icons.ts — pixel-art icon source. ASCII grids compile to +// crispEdges SVGs in apps/desk98/icons/ (committed; re-run on art changes): +// +// bun apps/desk98/gen-icons.ts +// +// One 16×16 grid per subject; desktop icons emit a second 32px file scaled +// 2× so both stay one art. Caption glyphs and tiny hud art carry their own +// grids at native size. The build then rasterizes each SVG at the plan's +// density like any other asset — no hand-baked PNGs. + +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +const PAL: Record = { + k: "#000000", + w: "#ffffff", + g: "#c0c0c0", + d: "#808080", + e: "#dfdfdf", + y: "#fcd116", + Y: "#fcf080", + b: "#000080", + B: "#1084d0", + r: "#ff0000", + R: "#800000", + t: "#008080", + G: "#008000", + o: "#ff8000", + s: "#ffd800", // smiley yellow +}; + +interface Icon { + file: string; + rows: string[]; + /** Also emit `${file}` at 2× under this name (desktop icons). */ + big?: string; +} + +function svgFor(rows: string[], scale: number): string { + const h = rows.length; + const w = Math.max(...rows.map((r) => r.length)); + for (const r of rows) { + if (r.length !== w) throw new Error(`gen-icons: ragged grid (row "${r}" vs width ${w})`); + } + const byColor = new Map(); + for (let y = 0; y < h; y++) { + const row = rows[y]; + let x = 0; + while (x < row.length) { + const ch = row[x]; + if (ch === "." || ch === " ") { + x++; + continue; + } + let run = 1; + while (x + run < row.length && row[x + run] === ch) run++; + const color = PAL[ch]; + if (!color) throw new Error(`gen-icons: unknown palette char '${ch}'`); + const d = byColor.get(color) ?? []; + d.push(`M${x * scale} ${y * scale}h${run * scale}v${scale}h${-run * scale}z`); + byColor.set(color, d); + x += run; + } + } + const paths = [...byColor.entries()] + .map(([color, ds]) => ``) + .join(""); + return `${paths}\n`; +} + +const ICONS: Icon[] = [ + { + // My Computer: a CRT over a slim base, navy screen with a sky glint. + file: "computer-16.svg", + big: "computer.svg", + rows: [ + "................", + ".kkkkkkkkkkkkk..", + ".kggggggggggkk..", + ".kgkkkkkkkkgkk..", + ".kgkbbbbbbkgkk..", + ".kgkbBBbbbkgkk..", + ".kgkbBbbbbkgkk..", + ".kgkbbbbbbkgkk..", + ".kgkkkkkkkkgkk..", + ".kggggggggggkk..", + ".kkkkkkkkkkkkk..", + "......kggk......", + "....kkggggkk....", + "..kggggggggggk..", + "..kggkkkkkkggk..", + "..kkkkkkkkkkkk..", + ], + }, + { + // My Documents: the two-tone yellow folder with a paper peeking out. + file: "folder-16.svg", + big: "documents.svg", + rows: [ + "................", + "................", + ".kkkkk..........", + "kYYYYYkkkkkkkk..", + "kYYYYYYYYYYYYk..", + "kYwwwwwwwwwwYk..", + "kYwkkkwwkkwwYk..", + "kkkkkkkkkkkkkkk.", + "kyyyyyyyyyyyyyk.", + "kyYYYYYYYYYYYyk.", + "kyyyyyyyyyyyyyk.", + "kyyyyyyyyyyyyyk.", + "kyyyyyyyyyyyyyk.", + "kkkkkkkkkkkkkkk.", + "................", + "................", + ], + }, + { + // Recycle Bin: gray basket, dark lid, recycle chevrons. + file: "recycle-16.svg", + big: "recycle.svg", + rows: [ + "................", + "......kkkk......", + "....kkggggkk....", + "..kkggggggggkk..", + ".kddddddddddddk.", + ".kddddddddddddk.", + "..kggggggggggk..", + "..kgGGggggGGgk..", + "..kgGgggggGggk..", + "..kggGGgGGgggk..", + "..kgggGGGggggk..", + "..kggggGgggggk..", + "..kggggggggggk..", + "...kggggggggk...", + "...kkkkkkkkkk...", + "................", + ], + }, + { + // Notepad: spiral pad, ruled lines. + file: "notepad-16.svg", + big: "notepad.svg", + rows: [ + "................", + "..kdkdkdkdkdk...", + ".kwwwwwwwwwwwk..", + ".kwwwwwwwwwwwk..", + ".kwkkkkkkkkwwk..", + ".kwwwwwwwwwwwk..", + ".kwkkkkkkwwwwk..", + ".kwwwwwwwwwwwk..", + ".kwkkkkkkkwwwk..", + ".kwwwwwwwwwwwk..", + ".kwkkkkwwwwwwk..", + ".kwwwwwwwwwwwk..", + ".kwwwwwwwwwwwk..", + ".kkkkkkkkkkkkk..", + "................", + "................", + ], + }, + { + // Minesweeper: a raised cell with a mine. + file: "mines-16.svg", + big: "mines.svg", + rows: [ + "wwwwwwwwwwwwwwwd", + "wggggggggggggggd", + "wgggggggkggggggd", + "wgggkggkkkggkggd", + "wggggkkkkkkkgggd", + "wgggkkkwwkkkkggd", + "wgggkkwwkkkkkggd", + "wgkkkkwwkkkkkkgd", + "wgggkkkkkkkkkggd", + "wgggkkkkkkkkkggd", + "wggggkkkkkkkgggd", + "wgggkggkkkggkggd", + "wgggggggkggggggd", + "wggggggggggggggd", + "wggggggggggggggd", + "dddddddddddddddd", + ], + }, + { + // Local disk. + file: "drive-16.svg", + rows: [ + "................", + "................", + "................", + "................", + ".kkkkkkkkkkkkkk.", + ".kggggggggggggk.", + ".keeeeeeeeeeegk.", + ".kggggggggggggk.", + ".kgggggggggkgGk.", + ".kkkkkkkkkkkkkk.", + "................", + "................", + "................", + "................", + "................", + "................", + ], + }, + { + // CD-ROM drive: a disc. + file: "cdrom-16.svg", + rows: [ + "................", + "................", + ".....kkkkkk.....", + "...kkeeeeeekk...", + "..keeeeeeeeeek..", + "..keeewwweeeek..", + ".keeewgggweeeek.", + ".keewggkggweeek.", + ".keewgkwkgweeek.", + ".keewggkggweeek.", + ".keeewgggweeeek.", + "..keeewwweeeek..", + "..keeeeeeeeeek..", + "...kkeeeeeekk...", + ".....kkkkkk.....", + "................", + ], + }, + { + // Plain document. + file: "file-16.svg", + rows: [ + "................", + "..kkkkkkkkk.....", + "..kwwwwwwwkk....", + "..kwwwwwwwkgk...", + "..kwwwwwwwkkkk..", + "..kwkkkkkwwwwk..", + "..kwwwwwwwwwwk..", + "..kwkkkkkkkwwk..", + "..kwwwwwwwwwwk..", + "..kwkkkkkkwwwk..", + "..kwwwwwwwwwwk..", + "..kwkkkkkkkkwk..", + "..kwwwwwwwwwwk..", + "..kwwwwwwwwwwk..", + "..kkkkkkkkkkkk..", + "................", + ], + }, + { + // Shut Down: a power key on a gray keycap. + file: "shutdown-16.svg", + big: "shutdown.svg", + rows: [ + "................", + ".kkkkkkkkkkkkkk.", + ".kwwwwwwwwwwwgk.", + ".kwggggggggggdk.", + ".kwgggkkkgggggk.", + ".kwggkgkgkggggk.", + ".kwgkggkggkgggk.", + ".kwgkggkggkgggk.", + ".kwgkggggggkggk.", + ".kwgkggggggkggk.", + ".kwggkggggkgggk.", + ".kwgggkkkkggggk.", + ".kwggggggggggdk.", + ".kgddddddddddgk.", + ".kkkkkkkkkkkkkk.", + "................", + ], + }, + { + // Settings: a gear. + file: "settings-16.svg", + rows: [ + "................", + "......kk........", + "..kk.kggk.kk....", + "..kgkkggkkgk....", + "...kggggggk.....", + "..kkggkkggkk....", + ".kgggkwwkgggk...", + ".kgggkwwkgggk...", + "..kkggkkggkk....", + "...kggggggk.....", + "..kgkkggkkgk....", + "..kk.kggk.kk....", + "......kk........", + "................", + "................", + "................", + ], + }, + { + // Find: a magnifier. + file: "find-16.svg", + rows: [ + "................", + "...kkkk.........", + "..kwwwwk........", + ".kwggggwk.......", + ".kwgggggk.......", + ".kwgggggk.......", + ".kwggggwk.......", + "..kwwwwk........", + "...kkkkkk.......", + "......kkdk......", + ".......kddk.....", + "........kddk....", + ".........kddk...", + "..........kk....", + "................", + "................", + ], + }, + { + // Help: a question mark on a page. + file: "help-16.svg", + rows: [ + "................", + "....kkkkkk......", + "...kbbbbbbk.....", + "..kbbkkkbbbk....", + "..kbbk.kbbbk....", + "...kk..kbbbk....", + "......kbbbk.....", + ".....kbbbk......", + ".....kbbk.......", + ".....kbbk.......", + "......kk........", + ".....kbbk.......", + ".....kbbk.......", + "......kk........", + "................", + "................", + ], + }, + { + // Run…: a command window. + file: "run-16.svg", + rows: [ + "................", + ".kkkkkkkkkkkkk..", + ".kbbbbbbbbbbbk..", + ".kkkkkkkkkkkkk..", + ".kwwwwwwwwwwwk..", + ".kwkwwwwwwwwwk..", + ".kwkkwwwwwwwwk..", + ".kwkkkwwwwwwwk..", + ".kwkkwwkkkkwwk..", + ".kwkwwwwwwwwwk..", + ".kwwwwwwwwwwwk..", + ".kkkkkkkkkkkkk..", + "................", + "................", + "................", + "................", + ], + }, + { + // Recycle 16 shares the desktop art; folder-16 doubles as Documents. + // Start logo: the PocketJS favicon motif (site/assets/favicon.svg) as a + // single-color pixel mark — rounded pocket frame, lens, two bars. + file: "start-logo.svg", + rows: [ + "................", + "................", + "................", + "..kkkkkkkkkkkk..", + ".k............k.", + ".k............k.", + ".k..kk..kkkkk.k.", + ".k.kkkk.......k.", + ".k.kkkk.kkk...k.", + ".k..kk........k.", + ".k............k.", + ".k............k.", + "..kkkkkkkkkkkk..", + "................", + "................", + "................", + ], + }, +]; + +// Native-size art (caption glyphs, hud bits) — no 2× variant. Pak textures +// must be pow2, so every canvas is 8×8 or 16×16 with the art inside. +const NATIVE: Icon[] = [ + { + file: "cap-min.svg", + rows: ["........", "........", "........", "........", "........", ".kkkkkk.", ".kkkkkk.", "........"], + }, + { + file: "cap-max.svg", + rows: ["kkkkkkkk", "kkkkkkkk", "k......k", "k......k", "k......k", "k......k", "k......k", "kkkkkkkk"], + }, + { + file: "cap-restore.svg", + rows: [ + "...kkkkk", + "...kkkkk", + "...k...k", + "kkkkk..k", + "kkkkkkkk", + "k...k...", + "k...k...", + "kkkkk...", + ], + }, + { + file: "cap-close.svg", + rows: ["........", "kk....kk", ".kk..kk.", "..kkkk..", "...kk...", "..kkkk..", ".kk..kk.", "kk....kk"], + }, + { + file: "menu-arrow.svg", + rows: ["........", "..k.....", "..kk....", "..kkk...", "..kkkk..", "..kkk...", "..kk....", "..k....."], + }, + { file: "grip.svg", rows: grip16() }, + { + file: "mine.svg", + rows: [ + "...k....", + "..kkk.k.", + ".kkkkkk.", + "kkwkkkkk", + ".kkkkkk.", + "..kkk.k.", + "...k....", + "........", + ], + }, + { + file: "flag.svg", + rows: [ + "..rr....", + "rrrr....", + "..rr....", + "...k....", + "...k....", + "..kk....", + ".kkkkk..", + "kkkkkkk.", + ], + }, + { file: "smile.svg", rows: face("smile") }, + { file: "smile-ooh.svg", rows: face("ooh") }, + { file: "smile-dead.svg", rows: face("dead") }, + { file: "smile-cool.svg", rows: face("cool") }, +]; + +/** Status-bar size grip: diagonal white/gray ridge pairs in the lower-right + * triangle of a 16×16 canvas. */ +function grip16(): string[] { + const N = 16; + const grid: string[][] = Array.from({ length: N }, () => Array(N).fill(".")); + for (const k of [17, 21, 25, 29]) { + for (let x = 0; x < N; x++) { + const yw = k - x; + if (yw >= 4 && yw < N && x >= 4) grid[yw][x] = "w"; + const yd = k + 1 - x; + if (yd >= 4 && yd < N && x >= 4) grid[yd][x] = "d"; + } + } + return grid.map((r) => r.join("")); +} + +/** Smiley faces built procedurally: a yellow disc with a black ring, then + * per-state eyes and mouth pixels — hand grids kept coming out ragged. */ +function face(kind: "smile" | "ooh" | "dead" | "cool"): string[] { + const N = 16; + const grid: string[][] = Array.from({ length: N }, () => Array(N).fill(".")); + const c = (N - 1) / 2; + const inside = (x: number, y: number) => (x - c) ** 2 + (y - c) ** 2 <= 7.2 ** 2; + for (let y = 0; y < N; y++) { + for (let x = 0; x < N; x++) if (inside(x, y)) grid[y][x] = "s"; + } + for (let y = 0; y < N; y++) { + for (let x = 0; x < N; x++) { + if (grid[y][x] !== "s") continue; + const edge = + !inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1); + if (edge) grid[y][x] = "k"; + } + } + const px = (x: number, y: number) => { + grid[y][x] = "k"; + }; + if (kind === "dead") { + for (const ex of [4, 9]) { + px(ex, 4); + px(ex + 2, 4); + px(ex + 1, 5); + px(ex, 6); + px(ex + 2, 6); + } + // Frown. + for (let x = 6; x <= 9; x++) px(x, 10); + px(5, 11); + px(10, 11); + } else if (kind === "cool") { + // Sunglasses: one bar with two lenses. + for (let x = 2; x <= 13; x++) px(x, 5); + for (const lx of [3, 9]) { + for (let x = lx; x <= lx + 3; x++) { + px(x, 6); + px(x, 7); + } + } + for (let x = 6; x <= 9; x++) px(x, 12); + px(5, 11); + px(10, 11); + } else { + // Eyes. + for (const ex of [5, 10]) { + px(ex, 5); + px(ex, 6); + } + if (kind === "smile") { + for (let x = 6; x <= 9; x++) px(x, 12); + px(5, 11); + px(10, 11); + px(4, 10); + px(11, 10); + } else { + // "ooh": a small round mouth. + for (const [x, y] of [ + [7, 9], + [8, 9], + [6, 10], + [9, 10], + [6, 11], + [9, 11], + [7, 12], + [8, 12], + ]) { + px(x, y); + } + } + } + return grid.map((r) => r.join("")); +} + +const outDir = join(import.meta.dir, "icons"); +mkdirSync(outDir, { recursive: true }); +let count = 0; +for (const icon of ICONS) { + await Bun.write(join(outDir, icon.file), svgFor(icon.rows, 1)); + count++; + if (icon.big) { + await Bun.write(join(outDir, icon.big), svgFor(icon.rows, 2)); + count++; + } +} +for (const icon of NATIVE) { + await Bun.write(join(outDir, icon.file), svgFor(icon.rows, 1)); + count++; +} +console.log(`gen-icons: wrote ${count} SVGs to apps/desk98/icons/`); diff --git a/apps/desk98/icons/cap-close.svg b/apps/desk98/icons/cap-close.svg new file mode 100644 index 00000000..fc2e0652 --- /dev/null +++ b/apps/desk98/icons/cap-close.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/cap-max.svg b/apps/desk98/icons/cap-max.svg new file mode 100644 index 00000000..dcb63473 --- /dev/null +++ b/apps/desk98/icons/cap-max.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/cap-min.svg b/apps/desk98/icons/cap-min.svg new file mode 100644 index 00000000..1b164133 --- /dev/null +++ b/apps/desk98/icons/cap-min.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/cap-restore.svg b/apps/desk98/icons/cap-restore.svg new file mode 100644 index 00000000..ea5e2deb --- /dev/null +++ b/apps/desk98/icons/cap-restore.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/cdrom-16.svg b/apps/desk98/icons/cdrom-16.svg new file mode 100644 index 00000000..cefb23ff --- /dev/null +++ b/apps/desk98/icons/cdrom-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/computer-16.svg b/apps/desk98/icons/computer-16.svg new file mode 100644 index 00000000..cae26311 --- /dev/null +++ b/apps/desk98/icons/computer-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/computer.svg b/apps/desk98/icons/computer.svg new file mode 100644 index 00000000..e7ee4ed5 --- /dev/null +++ b/apps/desk98/icons/computer.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/documents.svg b/apps/desk98/icons/documents.svg new file mode 100644 index 00000000..e109acec --- /dev/null +++ b/apps/desk98/icons/documents.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/drive-16.svg b/apps/desk98/icons/drive-16.svg new file mode 100644 index 00000000..50b35abd --- /dev/null +++ b/apps/desk98/icons/drive-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/file-16.svg b/apps/desk98/icons/file-16.svg new file mode 100644 index 00000000..c1ab39eb --- /dev/null +++ b/apps/desk98/icons/file-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/find-16.svg b/apps/desk98/icons/find-16.svg new file mode 100644 index 00000000..04aeda89 --- /dev/null +++ b/apps/desk98/icons/find-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/flag.svg b/apps/desk98/icons/flag.svg new file mode 100644 index 00000000..608636e0 --- /dev/null +++ b/apps/desk98/icons/flag.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/folder-16.svg b/apps/desk98/icons/folder-16.svg new file mode 100644 index 00000000..4b84d6c9 --- /dev/null +++ b/apps/desk98/icons/folder-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/grip.svg b/apps/desk98/icons/grip.svg new file mode 100644 index 00000000..3025ddce --- /dev/null +++ b/apps/desk98/icons/grip.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/help-16.svg b/apps/desk98/icons/help-16.svg new file mode 100644 index 00000000..6af52a61 --- /dev/null +++ b/apps/desk98/icons/help-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/menu-arrow.svg b/apps/desk98/icons/menu-arrow.svg new file mode 100644 index 00000000..aae25464 --- /dev/null +++ b/apps/desk98/icons/menu-arrow.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/mine.svg b/apps/desk98/icons/mine.svg new file mode 100644 index 00000000..3ec34a9f --- /dev/null +++ b/apps/desk98/icons/mine.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/mines-16.svg b/apps/desk98/icons/mines-16.svg new file mode 100644 index 00000000..764c774c --- /dev/null +++ b/apps/desk98/icons/mines-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/mines.svg b/apps/desk98/icons/mines.svg new file mode 100644 index 00000000..1864289f --- /dev/null +++ b/apps/desk98/icons/mines.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/notepad-16.svg b/apps/desk98/icons/notepad-16.svg new file mode 100644 index 00000000..ef12913a --- /dev/null +++ b/apps/desk98/icons/notepad-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/notepad.svg b/apps/desk98/icons/notepad.svg new file mode 100644 index 00000000..84896053 --- /dev/null +++ b/apps/desk98/icons/notepad.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/recycle-16.svg b/apps/desk98/icons/recycle-16.svg new file mode 100644 index 00000000..54241f3b --- /dev/null +++ b/apps/desk98/icons/recycle-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/recycle.svg b/apps/desk98/icons/recycle.svg new file mode 100644 index 00000000..99470652 --- /dev/null +++ b/apps/desk98/icons/recycle.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/run-16.svg b/apps/desk98/icons/run-16.svg new file mode 100644 index 00000000..4667bd4f --- /dev/null +++ b/apps/desk98/icons/run-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/settings-16.svg b/apps/desk98/icons/settings-16.svg new file mode 100644 index 00000000..cd74e753 --- /dev/null +++ b/apps/desk98/icons/settings-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/shutdown-16.svg b/apps/desk98/icons/shutdown-16.svg new file mode 100644 index 00000000..8895d607 --- /dev/null +++ b/apps/desk98/icons/shutdown-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/shutdown.svg b/apps/desk98/icons/shutdown.svg new file mode 100644 index 00000000..eed64d4e --- /dev/null +++ b/apps/desk98/icons/shutdown.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/smile-cool.svg b/apps/desk98/icons/smile-cool.svg new file mode 100644 index 00000000..8ca8c41d --- /dev/null +++ b/apps/desk98/icons/smile-cool.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/smile-dead.svg b/apps/desk98/icons/smile-dead.svg new file mode 100644 index 00000000..101a9526 --- /dev/null +++ b/apps/desk98/icons/smile-dead.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/smile-ooh.svg b/apps/desk98/icons/smile-ooh.svg new file mode 100644 index 00000000..b0708216 --- /dev/null +++ b/apps/desk98/icons/smile-ooh.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/smile.svg b/apps/desk98/icons/smile.svg new file mode 100644 index 00000000..9959b262 --- /dev/null +++ b/apps/desk98/icons/smile.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/icons/start-logo.svg b/apps/desk98/icons/start-logo.svg new file mode 100644 index 00000000..f02fec14 --- /dev/null +++ b/apps/desk98/icons/start-logo.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/main.ts b/apps/desk98/main.ts new file mode 100644 index 00000000..a127ab9a --- /dev/null +++ b/apps/desk98/main.ts @@ -0,0 +1,8 @@ +// apps/desk98/main.ts — PocketJS 98 entry point. Only the mount lives here: +// the framework-runtime import stays confined to this file (app-check +// typechecks the entry's whole graph; the prelude's globalThis reshaping +// belongs to the runtime, not to app modules). +import { mount } from "@pocketjs/framework/vue-vapor"; +import App from "./app.vue"; + +mount(App); diff --git a/apps/desk98/mines.ts b/apps/desk98/mines.ts new file mode 100644 index 00000000..eef57865 --- /dev/null +++ b/apps/desk98/mines.ts @@ -0,0 +1,132 @@ +// apps/desk98/mines.ts — pure Minesweeper rules (beginner 9×9, 10 mines). +// No Solid, no framework imports — unit-tested directly. First reveal is +// always safe: mines place after it, excluding the clicked cell. + +export const MINES_W = 9; +export const MINES_H = 9; +export const MINES_N = 10; + +export type CellState = "hidden" | "flag" | "revealed"; + +export interface Cell { + mine: boolean; + adj: number; + state: CellState; +} + +export type Phase = "ready" | "playing" | "won" | "lost"; + +export interface Mines { + cells: Cell[]; // row-major MINES_W × MINES_H + phase: Phase; + flags: number; + revealed: number; + /** Index of the mine that ended the game (drawn red), -1 otherwise. */ + bust: number; + seed: number; +} + +/** mulberry32 — deterministic placement from a seed. */ +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function newMines(seed: number): Mines { + return { + cells: Array.from({ length: MINES_W * MINES_H }, () => ({ + mine: false, + adj: 0, + state: "hidden" as CellState, + })), + phase: "ready", + flags: 0, + revealed: 0, + bust: -1, + seed, + }; +} + +function neighbors(i: number): number[] { + const x = i % MINES_W; + const y = (i / MINES_W) | 0; + const out: number[] = []; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && nx < MINES_W && ny >= 0 && ny < MINES_H) out.push(ny * MINES_W + nx); + } + } + return out; +} + +function place(m: Mines, safe: number): void { + const rand = rng(m.seed); + let planted = 0; + while (planted < MINES_N) { + const i = Math.floor(rand() * MINES_W * MINES_H); + if (i === safe || m.cells[i].mine) continue; + m.cells[i].mine = true; + planted++; + } + for (let i = 0; i < m.cells.length; i++) { + m.cells[i].adj = neighbors(i).filter((n) => m.cells[n].mine).length; + } +} + +/** Reveal a cell (mutates and returns m). Floods zero-adjacency regions. */ +export function reveal(m: Mines, i: number): Mines { + if (m.phase === "won" || m.phase === "lost") return m; + const c = m.cells[i]; + if (c.state !== "hidden") return m; + if (m.phase === "ready") { + place(m, i); + m.phase = "playing"; + } + if (c.mine) { + m.phase = "lost"; + m.bust = i; + for (const cell of m.cells) { + if (cell.mine && cell.state !== "flag") cell.state = "revealed"; + } + return m; + } + const stack = [i]; + while (stack.length > 0) { + const j = stack.pop()!; + const cj = m.cells[j]; + if (cj.state === "revealed" || cj.state === "flag" || cj.mine) continue; + cj.state = "revealed"; + m.revealed++; + if (cj.adj === 0) for (const n of neighbors(j)) stack.push(n); + } + if (m.revealed === MINES_W * MINES_H - MINES_N) { + m.phase = "won"; + // Convention: flag every remaining mine on a win. + for (const cell of m.cells) if (cell.mine) cell.state = "flag"; + m.flags = MINES_N; + } + return m; +} + +/** Toggle a flag (right-click). */ +export function toggleFlag(m: Mines, i: number): Mines { + if (m.phase === "won" || m.phase === "lost") return m; + const c = m.cells[i]; + if (c.state === "revealed") return m; + if (c.state === "flag") { + c.state = "hidden"; + m.flags--; + } else { + c.state = "flag"; + m.flags++; + } + return m; +} diff --git a/apps/desk98/notepad.ts b/apps/desk98/notepad.ts new file mode 100644 index 00000000..fc1212dc --- /dev/null +++ b/apps/desk98/notepad.ts @@ -0,0 +1,212 @@ +// apps/desk98/notepad.ts — pure Notepad line-editing rules (wrap off, like +// the 98 default): insertion, deletion, caret movement and the selection +// model (anchor + caret). No framework imports — unit-tested directly. + +export interface Caret { + row: number; + col: number; +} + +export interface Doc { + lines: string[]; + caret: Caret; + /** Selection anchor (the other end of the range); null/absent = none. + * Every edit collapses it — edited Docs never carry a stale anchor. */ + anchor?: Caret | null; +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +function cmpCaret(a: Caret, b: Caret): number { + return a.row !== b.row ? a.row - b.row : a.col - b.col; +} + +/** The ordered selection range, null when collapsed or absent. */ +export function selRange(doc: Doc): { from: Caret; to: Caret } | null { + if (!doc.anchor) return null; + const c = cmpCaret(doc.anchor, doc.caret); + if (c === 0) return null; + return c < 0 ? { from: doc.anchor, to: doc.caret } : { from: doc.caret, to: doc.anchor }; +} + +export function hasSel(doc: Doc): boolean { + return selRange(doc) !== null; +} + +export function selectedText(doc: Doc): string { + const r = selRange(doc); + if (!r) return ""; + if (r.from.row === r.to.row) return doc.lines[r.from.row].slice(r.from.col, r.to.col); + const parts = [doc.lines[r.from.row].slice(r.from.col)]; + for (let row = r.from.row + 1; row < r.to.row; row++) parts.push(doc.lines[row]); + parts.push(doc.lines[r.to.row].slice(0, r.to.col)); + return parts.join("\n"); +} + +/** Delete the selected range; caret lands at its start. No-op when none. */ +export function deleteSel(doc: Doc): Doc { + const r = selRange(doc); + if (!r) return { lines: doc.lines, caret: doc.caret }; + const lines = doc.lines.slice(); + const merged = lines[r.from.row].slice(0, r.from.col) + lines[r.to.row].slice(r.to.col); + lines.splice(r.from.row, r.to.row - r.from.row + 1, merged); + return { lines, caret: { row: r.from.row, col: r.from.col } }; +} + +export function selectAll(doc: Doc): Doc { + const last = doc.lines.length - 1; + return { + lines: doc.lines, + caret: { row: last, col: doc.lines[last].length }, + anchor: { row: 0, col: 0 }, + }; +} + +/** Column range of the word (or whitespace run) around col — double-click. */ +export function wordRangeAt(line: string, col: number): { from: number; to: number } { + if (line.length === 0) return { from: 0, to: 0 }; + const i = Math.max(0, Math.min(col, line.length - 1)); + const wordish = (ch: string) => /[\w]/.test(ch); + const cls = wordish(line[i]) ? 1 : line[i] === " " ? 0 : 2; + const same = (ch: string) => (cls === 1 ? wordish(ch) : cls === 0 ? ch === " " : !wordish(ch) && ch !== " "); + let from = i; + let to = i + 1; + while (from > 0 && same(line[from - 1])) from--; + while (to < line.length && same(line[to])) to++; + return { from, to }; +} + +/** The selected column span on one row, null when the row has none. Rows + * strictly inside a multi-row selection select their full length. */ +export function rowSelSpan(doc: Doc, row: number): { from: number; to: number } | null { + const r = selRange(doc); + if (!r || row < r.from.row || row > r.to.row) return null; + const from = row === r.from.row ? r.from.col : 0; + const to = row === r.to.row ? r.to.col : doc.lines[row].length; + return from === to ? null : { from, to }; +} + +// --------------------------------------------------------------------------- +// Editing +// --------------------------------------------------------------------------- + +function clampCaret(lines: string[], caret: Caret): Caret { + const row = Math.max(0, Math.min(caret.row, lines.length - 1)); + const col = Math.max(0, Math.min(caret.col, lines[row].length)); + return { row, col }; +} + +/** Insert typed text at the caret (replacing any selection); \n splits. */ +export function insertText(doc: Doc, s: string): Doc { + if (hasSel(doc)) doc = deleteSel(doc); + let { lines, caret } = doc; + lines = lines.slice(); + caret = clampCaret(lines, caret); + const parts = s.replace(/\r\n?/g, "\n").split("\n"); + const line = lines[caret.row]; + const before = line.slice(0, caret.col); + const after = line.slice(caret.col); + if (parts.length === 1) { + lines[caret.row] = before + parts[0] + after; + return { lines, caret: { row: caret.row, col: caret.col + parts[0].length } }; + } + const inserted = [before + parts[0], ...parts.slice(1, -1), parts[parts.length - 1] + after]; + lines.splice(caret.row, 1, ...inserted); + return { + lines, + caret: { row: caret.row + parts.length - 1, col: parts[parts.length - 1].length }, + }; +} + +export function backspace(doc: Doc): Doc { + if (hasSel(doc)) return deleteSel(doc); + let { lines, caret } = doc; + lines = lines.slice(); + caret = clampCaret(lines, caret); + if (caret.col > 0) { + const line = lines[caret.row]; + lines[caret.row] = line.slice(0, caret.col - 1) + line.slice(caret.col); + return { lines, caret: { row: caret.row, col: caret.col - 1 } }; + } + if (caret.row === 0) return { lines, caret }; + const col = lines[caret.row - 1].length; + lines[caret.row - 1] += lines[caret.row]; + lines.splice(caret.row, 1); + return { lines, caret: { row: caret.row - 1, col } }; +} + +export function del(doc: Doc): Doc { + if (hasSel(doc)) return deleteSel(doc); + let { lines, caret } = doc; + lines = lines.slice(); + caret = clampCaret(lines, caret); + const line = lines[caret.row]; + if (caret.col < line.length) { + lines[caret.row] = line.slice(0, caret.col) + line.slice(caret.col + 1); + return { lines, caret }; + } + if (caret.row === lines.length - 1) return { lines, caret }; + lines[caret.row] += lines[caret.row + 1]; + lines.splice(caret.row + 1, 1); + return { lines, caret }; +} + +export type CaretMove = "Left" | "Right" | "Up" | "Down" | "Home" | "End"; + +export function moveCaret(doc: Doc, key: CaretMove): Caret { + const { lines } = doc; + const caret = clampCaret(lines, doc.caret); + switch (key) { + case "Left": + if (caret.col > 0) return { row: caret.row, col: caret.col - 1 }; + if (caret.row > 0) return { row: caret.row - 1, col: lines[caret.row - 1].length }; + return caret; + case "Right": + if (caret.col < lines[caret.row].length) return { row: caret.row, col: caret.col + 1 }; + if (caret.row < lines.length - 1) return { row: caret.row + 1, col: 0 }; + return caret; + case "Up": + return caret.row > 0 ? clampCaret(lines, { row: caret.row - 1, col: caret.col }) : caret; + case "Down": + return caret.row < lines.length - 1 + ? clampCaret(lines, { row: caret.row + 1, col: caret.col }) + : caret; + case "Home": + return { row: caret.row, col: 0 }; + case "End": + return { row: caret.row, col: lines[caret.row].length }; + } +} + +/** One caret move, selection-aware. `extend` (shift held) keeps or plants + * the anchor; a plain horizontal move over a selection collapses to the + * matching edge (the standard behavior), vertical moves step from it. */ +export function applyMove(doc: Doc, key: CaretMove, extend: boolean): Doc { + if (extend) { + const anchor = doc.anchor ?? doc.caret; + return { lines: doc.lines, caret: moveCaret(doc, key), anchor }; + } + const r = selRange(doc); + if (r) { + const edge = key === "Left" || key === "Up" || key === "Home" ? r.from : r.to; + if (key === "Left" || key === "Right") return { lines: doc.lines, caret: edge }; + return { lines: doc.lines, caret: moveCaret({ lines: doc.lines, caret: edge }, key) }; + } + return { lines: doc.lines, caret: moveCaret(doc, key) }; +} + +/** Caret column from a click x, given per-prefix pixel widths. `measure` + * returns the width of a string prefix; binary-search-free linear scan is + * fine at Notepad line lengths. */ +export function colFromX(line: string, x: number, measure: (s: string) => number): number { + if (x <= 0) return 0; + let prev = 0; + for (let i = 1; i <= line.length; i++) { + const w = measure(line.slice(0, i)); + if (x < (prev + w) / 2) return i - 1; + prev = w; + } + return x >= prev ? line.length : line.length; +} diff --git a/apps/desk98/pak.json b/apps/desk98/pak.json new file mode 100644 index 00000000..8b5da2a4 --- /dev/null +++ b/apps/desk98/pak.json @@ -0,0 +1,5 @@ +[ + { "key": "ui:font.19", "file": "fonts/w95fa-19.bin" }, + { "key": "ui:font.20", "file": "fonts/w95fa-20.bin" }, + { "key": "ui:font.21", "file": "fonts/w95fa-21.bin" } +] diff --git a/apps/desk98/pocket.config.ts b/apps/desk98/pocket.config.ts new file mode 100644 index 00000000..c3de12b3 --- /dev/null +++ b/apps/desk98/pocket.config.ts @@ -0,0 +1,20 @@ +// apps/desk98/pocket.config.ts — the Notepad caret reuses the note app's +// square-wave blink: 500 ms on, 500 ms off, hard edges. Long constant +// segments keep the DrawList byte-stable between edges so the demand- +// rendering host paints ~2 frames a second while the caret rests +// (docs/BACKENDS.md governor discipline). +import { definePocketConfig } from "@pocketjs/framework/config"; + +export default definePocketConfig({ + theme: { + keyframes: { + caret: { + "0%,49.99%": { opacity: 1 }, + "50%,100%": { opacity: 0 }, + }, + }, + animation: { + caret: "caret 1s linear infinite", + }, + }, +}); diff --git a/apps/desk98/pocket.json b/apps/desk98/pocket.json new file mode 100644 index 00000000..aba01973 --- /dev/null +++ b/apps/desk98/pocket.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.desk98", + "name": "pocket-desk98", + "title": "PocketJS 98", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": [ + "text.glyphs.baked", + "input.buttons" + ], + "enhances": [ + "input.text", + "input.pointer", + "input.ime", + "host.clipboard", + "display.viewport.live" + ] + } + }, + "app": { + "entry": "apps/desk98/main.ts", + "output": "desk98-main", + "framework": "vue-vapor", + "companions": [ + "desk" + ], + "viewport": { + "dynamic": { + "default": [ + 800, + 600 + ], + "min": [ + 640, + 480 + ] + } + } + } +} diff --git a/apps/desk98/programs.ts b/apps/desk98/programs.ts new file mode 100644 index 00000000..3a8fca25 --- /dev/null +++ b/apps/desk98/programs.ts @@ -0,0 +1,97 @@ +// apps/desk98/programs.ts — pure program geometry: content-local hit helpers +// and metrics for Notepad, Minesweeper, the folder view and the dialogs. The +// matching views live in the *.vue components; render geometry and hit +// geometry stay in one place by sharing these constants. Content coordinates +// are (cx, cy) from wm.ts hitRegion — origin at the frame's inner top-left, +// below caption (and menu bar if present). + +import { getOps } from "@pocketjs/framework/host"; +import { FONT } from "./theme.ts"; +import { MINES_W } from "./mines.ts"; + +export function measure(s: string): number { + const ops = getOps(); + return ops.measureText ? ops.measureText(s, FONT) : s.length * 7; +} + +// --------------------------------------------------------------------------- +// Notepad +// --------------------------------------------------------------------------- + +export const PAD_LINE_H = 16; +export const PAD_PAD = 3; // inset of the text from the white well + +// --------------------------------------------------------------------------- +// Minesweeper — fixed-size window; all metrics in content-local px. +// --------------------------------------------------------------------------- + +export const MINES_GEO = { w: 166, h: 227 } as const; +const M_PAD = 5; // content padding +const M_HEADER_H = 36; +const M_FIELD_TOP = M_PAD + M_HEADER_H + 6; // header + gap +const M_CELL = 16; +const M_CELLS_X = M_PAD + 3; // field bevel-w-[3] ring +const M_CELLS_Y = M_FIELD_TOP + 3; + +export type MinesHit = { type: "cell"; i: number } | { type: "smiley" } | null; + +export function minesHit(cx: number, cy: number): MinesHit { + const sx = 160 / 2 - 13; + if (cx >= sx && cx < sx + 26 && cy >= M_PAD + 5 && cy < M_PAD + 5 + 26) { + return { type: "smiley" }; + } + const x = Math.floor((cx - M_CELLS_X) / M_CELL); + const y = Math.floor((cy - M_CELLS_Y) / M_CELL); + if (x >= 0 && x < 9 && y >= 0 && y < 9) return { type: "cell", i: y * MINES_W + x }; + return null; +} + +// --------------------------------------------------------------------------- +// Folder (Explorer details view) +// --------------------------------------------------------------------------- + +export const FOLDER_HEADER_H = 17; +export const FOLDER_ROW_H = 17; +export const FOLDER_STATUS_H = 20; + +/** Row index for a content-local click inside the list, -1 none. */ +export function folderRowAt(cy: number, rowCount: number): number { + const i = Math.floor((cy - 1 - FOLDER_HEADER_H) / FOLDER_ROW_H); + return i >= 0 && i < rowCount ? i : -1; +} + +// --------------------------------------------------------------------------- +// About + Shut Down dialogs +// --------------------------------------------------------------------------- + +export const ABOUT_GEO = { w: 340, h: 216 } as const; +export const SHUTDOWN_GEO = { w: 300, h: 176 } as const; + +/** Content-local button hits for the About dialog. */ +export function aboutHit(contentW: number, contentH: number, cx: number, cy: number): "ok" | null { + const x = contentW - 10 - 75; + const y = contentH - 10 - 23; + return cx >= x && cx < x + 75 && cy >= y && cy < y + 23 ? "ok" : null; +} + +export type ShutdownHit = "ok" | "cancel" | "radio0" | "radio1" | null; + +export function shutdownHit( + contentW: number, + contentH: number, + cx: number, + cy: number, +): ShutdownHit { + const by = contentH - 10 - 23; + const cancelX = contentW - 10 - 75; + const okX = cancelX - 6 - 75; + if (cy >= by && cy < by + 23) { + if (cx >= okX && cx < okX + 75) return "ok"; + if (cx >= cancelX && cx < cancelX + 75) return "cancel"; + } + for (const i of [0, 1]) { + const ry = 46 + i * 20; + if (cx >= 56 && cx < 220 && cy >= ry && cy < ry + 18) return i === 0 ? "radio0" : "radio1"; + } + return null; +} diff --git a/apps/desk98/screenshot.png b/apps/desk98/screenshot.png new file mode 100644 index 00000000..93522dd2 Binary files /dev/null and b/apps/desk98/screenshot.png differ diff --git a/apps/desk98/state.ts b/apps/desk98/state.ts new file mode 100644 index 00000000..37a97f44 --- /dev/null +++ b/apps/desk98/state.ts @@ -0,0 +1,152 @@ +// apps/desk98/state.ts — compositor state: window controls, popups, desktop +// icons. Hot geometry lives in per-window refs so a drag re-evaluates one +// window's style binding, not the world; the window LIST only changes on +// open/close (a reorder would rebuild the layout tree — z rides zIndex). + +import { ref, shallowRef, type Ref, type ShallowRef } from "vue"; +import type { CaptionButton, Geo } from "./wm.ts"; +import type { Doc } from "./notepad.ts"; +import type { Mines } from "./mines.ts"; + +export type WinKind = "notepad" | "mines" | "folder" | "about" | "shutdown"; + +export interface MenuDef { + label: string; + /** Hit width in px (measured at open — mirrors the render's px-[6] pads). */ + width: number; + /** Built at open — disabled states follow live state (selection, …). */ + items: () => PopupItem[]; +} + +export interface PopupItem { + label: string; + icon?: string; + shortcut?: string; + disabled?: boolean; + sep?: boolean; + sub?: PopupItem[]; + act?: () => void; +} + +export interface Popup { + x: number; + y: number; + w: number; + items: PopupItem[]; +} + +export interface WinCtl { + id: number; + kind: WinKind; + title: string; + icon: string; + buttons: readonly CaptionButton[]; + resizable: boolean; + minW: number; + minH: number; + menus: MenuDef[] | null; + geo: ShallowRef; + z: Ref; + minimized: Ref; + maximized: Ref; + /** Geometry to restore on un-maximize. */ + restoreGeo: Geo | null; + pressedBtn: Ref; + /** Open menu-bar index, -1 closed. */ + openMenu: Ref; + /** Program-specific state bag (PadData, MinesData, …). */ + data: unknown; +} + +// Program state bags. Vue refs instead of accessor/setter pairs — templates +// read `.value` explicitly (refs nested in objects never auto-unwrap). + +export interface PadData { + kind: "notepad"; + doc: ShallowRef; + scroll: Ref; + preedit: Ref<{ s: string; c: number } | null>; +} + +export interface MinesData { + kind: "mines"; + /** Mutated in place by mines.ts rules — re-assign + triggerRef to paint. */ + board: ShallowRef; + /** Cell index held by the primary button, -1 none. */ + held: Ref; + smileyHeld: Ref; + /** Seconds shown by the timer (app.vue advances it while playing). */ + elapsed: Ref; +} + +export interface FolderRow { + icon: string; + name: string; + size: string; + type: string; + open?: () => void; +} + +export interface FolderData { + kind: "folder"; + rows: FolderRow[]; + selected: Ref; +} + +export interface AboutData { + kind: "about"; + armed: Ref; +} + +export interface ShutdownData { + kind: "shutdown"; + choice: Ref; + armed: Ref; +} + +export interface TaskEntry { + id: number; + title: string; + icon: string; +} + +export interface DeskIcon { + icon: string; + label: string; + open: () => void; +} + +let nextId = 1; + +export function createWin(spec: { + kind: WinKind; + title: string; + icon: string; + geo: Geo; + buttons?: readonly CaptionButton[]; + resizable?: boolean; + minW?: number; + minH?: number; + menus?: MenuDef[] | null; + data?: unknown; +}): WinCtl { + return { + id: nextId++, + kind: spec.kind, + title: spec.title, + icon: spec.icon, + buttons: spec.buttons ?? ["min", "max", "close"], + resizable: spec.resizable ?? true, + minW: spec.minW ?? 200, + minH: spec.minH ?? 120, + menus: spec.menus ?? null, + geo: shallowRef(spec.geo), + z: ref(0), + minimized: ref(false), + maximized: ref(false), + restoreGeo: null, + pressedBtn: ref(null), + openMenu: ref(-1), + data: spec.data, + }; +} diff --git a/apps/desk98/svc.ts b/apps/desk98/svc.ts new file mode 100644 index 00000000..89817a22 --- /dev/null +++ b/apps/desk98/svc.ts @@ -0,0 +1,102 @@ +// apps/desk98/svc.ts — the desk companion protocol over the spec svc channel +// (HostOps svcOpen/svcPoll/svcSend), the note dialect's input lines extended +// for a desktop compositor. hosts/macos speaks it when the plan's companion +// list names "desk". +// +// host → guest lines (superset of apps/note/svc.ts): +// {t:"hello", w, h, epoch} viewport at boot + wall-clock ms (taskbar clock) +// {t:"resize", w, h} live window resize +// {t:"ch", s} typed characters +// {t:"key", k, sh, alt, ctl} named key + modifiers (adds F1..F12) +// {t:"key", k, cmd:true} ⌘ chord — k is the raw lowercase key ("w", +// "m", "`", "c", …); ⌘Q/⌘V stay host-side +// {t:"paste", text} system clipboard (⌘V or a paste-req reply) +// {t:"ime", s, c} IME preedit + caret char index (null clears) +// {t:"mouse", x, y, d, sh} primary-button pointer stream +// {t:"mouse", x, y, d, b:2} right-button press/release (desk-only lines) +// {t:"scroll", dy} wheel delta in logical px +// +// guest → host intents: +// {t:"quit"} Shut Down +// {t:"copy", text} put text on the system clipboard +// {t:"paste-req"} ask for the clipboard (host answers {t:"paste"}) +// {t:"caret", x, y, h} caret rect — docks the IME candidate window +// {t:"cursor", k} pointer shape: default|text|pointer|move| +// grabbing|ew|ns|nwse|nesw + +import { getOps } from "@pocketjs/framework"; + +export interface HostEvent { + t: "hello" | "resize" | "ch" | "key" | "mouse" | "scroll" | "paste" | "ime"; + w?: number; + h?: number; + epoch?: number; + s?: string; + k?: string; + x?: number; + y?: number; + /** Button state for "mouse" lines (the b-button's state, primary if no b). */ + d?: boolean; + /** Mouse button: undefined/1 = primary, 2 = right. */ + b?: number; + sh?: boolean; + alt?: boolean; + ctl?: boolean; + /** ⌘ held — k is then the raw lowercase key name ("w", "m", "`", …). */ + cmd?: boolean; + dy?: number; + text?: string; + /** IME preedit caret (char index into s), null when composition ends. */ + c?: number | null; +} + +export type CursorKind = + | "default" + | "text" + | "pointer" + | "move" + | "grabbing" + | "ew" + | "ns" + | "nwse" + | "nesw"; + +export interface Svc { + /** Drain and parse this frame's host lines (call once per frame). */ + poll(): HostEvent[]; + send( + line: + | { t: "quit" } + | { t: "copy"; text: string } + | { t: "paste-req" } + | { t: "caret"; x: number; y: number; h: number } + | { t: "cursor"; k: CursorKind }, + ): void; +} + +/** Probe the channel; null = standalone (sim, goldens — static desktop). */ +export function connectSvc(): Svc | null { + const ops = getOps(); + if (!ops.svcOpen || !ops.svcPoll || !ops.svcSend || !ops.svcOpen("desk")) return null; + const poll = ops.svcPoll.bind(ops); + const send = ops.svcSend.bind(ops); + return { + poll() { + const batch = poll(); + if (!batch) return []; + const events: HostEvent[] = []; + for (const line of batch.split("\n")) { + if (line === "") continue; + try { + events.push(JSON.parse(line) as HostEvent); + } catch { + // A malformed line is a host bug; skip it rather than wedge. + } + } + return events; + }, + send(line) { + send(JSON.stringify(line)); + }, + }; +} diff --git a/apps/desk98/theme.ts b/apps/desk98/theme.ts new file mode 100644 index 00000000..48c7a3db --- /dev/null +++ b/apps/desk98/theme.ts @@ -0,0 +1,37 @@ +// apps/desk98/theme.ts — the Windows 98 classic look as PocketJS class +// literals, translated from sheru's win98 theme (packages/themes/src/win98) +// the way pocket-shell's cooked skin does it: one bevel system over +// button-face gray. Raised chrome is a two-ring outset bevel, pressing +// inverts it, content wells are sunken; nothing is rounded, nothing +// animates. bevel-[outerTL,outerBR,innerTL,innerBR] per the compiler +// grammar (engine bevel rings, spec PROP.bevelOuter*). + +/** Baked W95FA slots (apps/desk98/gen-assets.ts → pak.json). */ +export const FONT = 19; // 12.5px regular +export const FONT_B = 20; // 12.5px synthetic bold +export const FONT_XL = 21; // 25px regular (start-menu banner) + +// The bevel recipes (they repeat as FULL literals in the components — the +// class table compiles at build time and template-interpolated fragments +// are a compile error): +// raised control bevel-[#ffffff,#000000,#dfdfdf,#808080] +// pressed bevel-[#000000,#ffffff,#808080,#dfdfdf] +// window frame bevel-[#dfdfdf,#000000,#ffffff,#808080] +// sunken well bevel-[#808080,#ffffff,#000000,#dfdfdf] +// thin raised bevel-[#ffffff,#808080] thin sunken bevel-[#808080,#ffffff] + +export const CAPTION_ACTIVE = + "flex-row items-center h-[18] pl-[3] pr-[2] bg-gradient-to-r from-[#000080] to-[#1084d0] mb-[1]"; +export const CAPTION_INACTIVE = + "flex-row items-center h-[18] pl-[3] pr-[2] bg-gradient-to-r from-[#808080] to-[#b5b5b5] mb-[1]"; + +// Chrome metrics (mirrored by wm.ts hit testing — change both together). +export const FRAME = 3; +export const TITLE_H = 18; +export const TITLE_GAP = 1; // face-gray hairline under the caption +export const BTN_W = 16; +export const BTN_H = 14; // all caption buttons sit flush, no close gap +export const MENU_H = 18; // menu bar row +export const TASK_H = 28; // taskbar +export const RESIZE_BAND = 4; +export const RESIZE_CORNER = 14; diff --git a/apps/desk98/wm.ts b/apps/desk98/wm.ts new file mode 100644 index 00000000..8e68d0d8 --- /dev/null +++ b/apps/desk98/wm.ts @@ -0,0 +1,191 @@ +// apps/desk98/wm.ts — pure window-manager math: chrome hit regions, resize +// arithmetic, movement clamps. No Solid, no framework imports — unit-tested +// directly (tests/desk98.test.ts). The compositor (app.tsx) owns the state; +// this module owns the geometry rules. +// +// Chrome anatomy (theme.ts metrics): a window is a face-gray box with a 3px +// raised frame (padding), an 18px caption (+1px hairline), an optional 18px +// menu bar, then content. Caption controls sit flush right and flush against +// each other — [min][zoom][close], each 16×14, 2px under the caption top. + +import { + BTN_H, + BTN_W, + FRAME, + MENU_H, + RESIZE_BAND, + RESIZE_CORNER, + TASK_H, + TITLE_GAP, + TITLE_H, +} from "./theme.ts"; + +export interface Geo { + x: number; + y: number; + w: number; + h: number; +} + +export type CaptionButton = "min" | "max" | "close"; +export type Dir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; + +export type Region = + | { kind: "caption" } + | { kind: "button"; button: CaptionButton } + | { kind: "menu"; index: number } + | { kind: "content"; cx: number; cy: number } + | { kind: "resize"; dir: Dir }; + +export interface ChromeOpts { + /** Caption controls present, left to right (close is always last). */ + buttons: readonly CaptionButton[]; + resizable: boolean; + maximized: boolean; + /** Menu-bar item widths in px (empty = no menu bar). */ + menuWidths: readonly number[]; +} + +/** Left x of each caption button, right-aligned inside the frame, flush. */ +export function captionButtonXs(w: number, buttons: readonly CaptionButton[]): number[] { + const xs: number[] = []; + let right = w - FRAME - 2; + for (let i = buttons.length - 1; i >= 0; i--) { + xs.unshift(right - BTN_W); + right -= BTN_W; + } + return xs; +} + +/** Content-area top inside the window (frame + caption + menu bar). */ +export function contentTop(opts: Pick): number { + return FRAME + TITLE_H + TITLE_GAP + (opts.menuWidths.length > 0 ? MENU_H : 0); +} + +/** Hit-test a point in window-local coordinates against the chrome. */ +export function hitRegion(geo: Geo, opts: ChromeOpts, px: number, py: number): Region | null { + const x = px - geo.x; + const y = py - geo.y; + if (x < 0 || y < 0 || x >= geo.w || y >= geo.h) return null; + + // Resize bands claim the outer edge before anything else. + if (opts.resizable && !opts.maximized) { + const corner = RESIZE_CORNER; + const n = y < RESIZE_BAND; + const s = y >= geo.h - RESIZE_BAND; + const w = x < RESIZE_BAND; + const e = x >= geo.w - RESIZE_BAND; + if (n || s || w || e) { + const nearL = x < corner; + const nearR = x >= geo.w - corner; + const nearT = y < corner; + const nearB = y >= geo.h - corner; + let dir: Dir; + if ((n && nearL) || (w && nearT)) dir = "nw"; + else if ((n && nearR) || (e && nearT)) dir = "ne"; + else if ((s && nearL) || (w && nearB)) dir = "sw"; + else if ((s && nearR) || (e && nearB)) dir = "se"; + else if (n) dir = "n"; + else if (s) dir = "s"; + else if (w) dir = "w"; + else dir = "e"; + return { kind: "resize", dir }; + } + } + + // Caption strip. + if (y >= FRAME && y < FRAME + TITLE_H) { + const xs = captionButtonXs(geo.w, opts.buttons); + const btnTop = FRAME + 2; + if (y >= btnTop && y < btnTop + BTN_H) { + for (let i = 0; i < xs.length; i++) { + if (x >= xs[i] && x < xs[i] + BTN_W) { + return { kind: "button", button: opts.buttons[i] }; + } + } + } + if (x >= FRAME && x < geo.w - FRAME) return { kind: "caption" }; + } + + // Menu bar. + const menuTop = FRAME + TITLE_H + TITLE_GAP; + if (opts.menuWidths.length > 0 && y >= menuTop && y < menuTop + MENU_H) { + let mx = FRAME; + for (let i = 0; i < opts.menuWidths.length; i++) { + if (x >= mx && x < mx + opts.menuWidths[i]) return { kind: "menu", index: i }; + mx += opts.menuWidths[i]; + } + } + + const top = contentTop(opts); + if (x >= FRAME && x < geo.w - FRAME && y >= top && y < geo.h - FRAME) { + return { kind: "content", cx: x - FRAME, cy: y - top }; + } + return { kind: "caption" }; // frame padding drags like the caption did in 98 +} + +/** Apply a resize drag: dir edge follows the pointer, mins hold, the + * anchored edge never moves. */ +export function resizeGeo( + orig: Geo, + dir: Dir, + dx: number, + dy: number, + minW: number, + minH: number, +): Geo { + let { x, y, w, h } = orig; + if (dir.includes("e")) w = Math.max(minW, orig.w + dx); + if (dir.includes("s")) h = Math.max(minH, orig.h + dy); + if (dir.includes("w")) { + w = Math.max(minW, orig.w - dx); + x = orig.x + orig.w - w; + } + if (dir.includes("n")) { + h = Math.max(minH, orig.h - dy); + y = orig.y + orig.h - h; + } + return { x, y, w, h }; +} + +/** Clamp a moved window so its caption stays reachable: some strip of the + * title bar remains on screen and above the taskbar. */ +export function clampMove(geo: Geo, vpW: number, vpH: number): Geo { + const grip = 48; // px of caption that must stay visible + const x = Math.min(Math.max(geo.x, grip - geo.w), vpW - grip); + const y = Math.min(Math.max(geo.y, 0), vpH - TASK_H - TITLE_H); + return { ...geo, x, y }; +} + +/** Maximized geometry: the desktop minus the taskbar. */ +export function maximizedGeo(vpW: number, vpH: number): Geo { + return { x: 0, y: 0, w: vpW, h: vpH - TASK_H }; +} + +/** Cascade position for the i-th opened window. */ +export function cascadePos(i: number, vpW: number, vpH: number, w: number, h: number): Geo { + const step = 24; + const cols = Math.max(1, Math.floor((vpH - TASK_H - h - 8) / step) + 1); + const k = i % Math.max(1, cols); + const x = Math.min(64 + i * step, Math.max(8, vpW - w - 8)); + const y = 28 + k * step; + return { x, y, w, h }; +} + +/** The resize cursor for a band direction ({t:"cursor"} intent keys). */ +export function cursorForDir(dir: Dir): "ew" | "ns" | "nwse" | "nesw" { + switch (dir) { + case "e": + case "w": + return "ew"; + case "n": + case "s": + return "ns"; + case "nw": + case "se": + return "nwse"; + case "ne": + case "sw": + return "nesw"; + } +} diff --git a/assets/fonts/LICENSE-W95FA.txt b/assets/fonts/LICENSE-W95FA.txt new file mode 100644 index 00000000..f1a20ac1 --- /dev/null +++ b/assets/fonts/LICENSE-W95FA.txt @@ -0,0 +1,97 @@ +Copyright (c) , (), +with Reserved Font Name . +Copyright (c) , (), +with Reserved Font Name . +Copyright (c) , (). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/W95FA.otf b/assets/fonts/W95FA.otf new file mode 100644 index 00000000..153077a6 Binary files /dev/null and b/assets/fonts/W95FA.otf differ diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index a2187505..37bd234a 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -103,11 +103,42 @@ bun run macos note --proof `tools/macos.ts` resolves the manifest against `macos-app`, writes the plan, builds the bundle + pak, and derives the capability-shaped host flags -(`--fixed`, `--native-text`) from the resolved plan. `--editor` is NOT a -capability: it enables the note's companion svc adapter (an app protocol — -the profile deliberately registers no pointer/text/IME/clipboard ids, see -contracts/spec/platforms.ts). On exit the host prints its governor receipt -(`pocket-macos: N ticks, M frames rendered`); a settled app shows M ≪ N. +(`--fixed`, `--native-text`, `--companions`) from the resolved plan. +`--editor` is NOT a capability: it enables the note's companion svc adapter +(an app protocol — the profile deliberately registers no +pointer/text/IME/clipboard ids, see contracts/spec/platforms.ts). On exit +the host prints its governor receipt (`pocket-macos: N ticks, M frames +rendered`); a settled app shows M ≪ N. + +## The desk companion + +The host speaks a second svc dialect when the plan's companion list names +`desk` (apps/desk98/svc.ts): the note dialect's input lines extended with +**right-button mouse lines (`b:2`), alt/ctl key modifiers, F1–F12, +cmd-flagged ⌘ chords, a boot epoch in the hello**, a `{t:"cursor"}` guest +intent that sets the window's pointer shape, and a `{t:"paste-req"}` guest +intent the host answers with a paste line (menu-driven Paste). ⌘Q quits +and ⌘V pastes host-side; every other ⌘ chord reaches the guest, so the +compositor owns its shortcuts (⌘W close, ⌘M minimize, ⌘` cycle windows, +⌘Esc Start menu, ⌘A/C/X editing). Plain typing arrives only through the +IME input handler (`insertText:` → one `ch` line per keypress). + +`apps/desk98` — a Windows 98 desktop compositor written in Vue Vapor SFCs +— is the reference consumer: the guest owns every window (drag, resize, +z-order, menus, text selection, Minesweeper) by hit-testing the raw +pointer stream itself, window moves ride paint-only translate props, and +raises ride zIndex, so a drag never relayouts and an idle desktop keeps +the demand-render governor at a few frames per second. Its W95FA pixel +font is baked per-app into slots 19–21 through `apps/desk98/pak.json` +(`gen-assets.ts`) — the repo slot table (0–18) never moves. + +``` +bun run macos desk98 # the full desktop; drag-select, Cmd+`, Cmd+W, Cmd+Esc +``` + +Scripted acceptance drives the same dialect from flags: `--mouse +X,Y[,d|u|r]@TICK` (drags, right clicks), `--key +[cmd+][alt+][ctl+]NAME@TICK`, `--type TEXT@TICK`. ## Choosing a backend diff --git a/framework/compiler/bake-font.ts b/framework/compiler/bake-font.ts index 1e51e426..bdd56544 100644 --- a/framework/compiler/bake-font.ts +++ b/framework/compiler/bake-font.ts @@ -8,8 +8,9 @@ // out — the core resolves cmap misses to gid 0 (tofu) at runtime. // // Rasterization: opentype.js outlines, flattened to polylines, scanline -// even-odd fill with horizontally-biased supersampling into 8-bit coverage -// cells. Atlas v3 keeps cell/line/cmap metrics in LOGICAL px while baking the +// nonzero-winding fill (the TrueType/CFF rule — even-odd cancels the shared +// edges and overlaps some outlines are built from) with horizontally-biased +// supersampling into 8-bit coverage cells. Atlas v3 keeps cell/line/cmap metrics in LOGICAL px while baking the // bitmap at `rasterDensity` samples per logical px. A Vita density-2 build can // therefore draw sharp 2x coverage without changing PSP-compatible layout. // Cells are tight in logical space: cellW = max inked logical width over the @@ -165,32 +166,47 @@ function rasterize(contours: Contour[], cellW: number, cellH: number): Uint8Arra const sw = cellW * SS_X; const samplesPerPixel = SS_X * SS_Y; const counts = new Uint16Array(cellW); // covered subsamples per pixel column, one row at a time - const xs: number[] = []; + // Crossings for one scanline: x position + winding direction (+1 downward, + // -1 upward in y-down space). Nonzero winding — pairing sorted crossings + // (even-odd) erases the interior wherever two contours share an edge or + // overlap, which pixel-font outlines (stroke rectangles sharing stem edges) + // and composite glyphs (accent over base) legitimately do. + const edges: { x: number; w: number }[] = []; for (let row = 0; row < cellH; row++) { counts.fill(0); for (let sub = 0; sub < SS_Y; sub++) { const y = row + (sub + 0.5) / SS_Y; - xs.length = 0; + edges.length = 0; for (const c of contours) { for (let i = 0; i < c.length - 1; i++) { const p0 = c[i]; const p1 = c[i + 1]; - if ((p0.y <= y && p1.y > y) || (p1.y <= y && p0.y > y)) { - xs.push(p0.x + ((y - p0.y) * (p1.x - p0.x)) / (p1.y - p0.y)); + if (p0.y <= y && p1.y > y) { + edges.push({ x: p0.x + ((y - p0.y) * (p1.x - p0.x)) / (p1.y - p0.y), w: 1 }); + } else if (p1.y <= y && p0.y > y) { + edges.push({ x: p0.x + ((y - p0.y) * (p1.x - p0.x)) / (p1.y - p0.y), w: -1 }); } } } - if (xs.length < 2) continue; - xs.sort((a, b) => a - b); - for (let k = 0; k + 1 < xs.length; k += 2) { - // subcolumn centers inside [xs[k], xs[k+1]) - let s0 = Math.ceil(xs[k] * SS_X - 0.5); - let s1 = Math.floor(xs[k + 1] * SS_X - 0.5); - if (s0 < 0) s0 = 0; - if (s1 >= sw) s1 = sw - 1; - for (let s = s0; s <= s1; s++) { - const center = (s + 0.5) / SS_X; - if (center >= xs[k] && center < xs[k + 1]) counts[(s / SS_X) | 0]++; + if (edges.length < 2) continue; + edges.sort((a, b) => a.x - b.x); + let wind = 0; + let spanX = 0; + for (const e of edges) { + const prev = wind; + wind += e.w; + if (prev === 0 && wind !== 0) { + spanX = e.x; + } else if (prev !== 0 && wind === 0) { + // subcolumn centers inside [spanX, e.x) + let s0 = Math.ceil(spanX * SS_X - 0.5); + let s1 = Math.floor(e.x * SS_X - 0.5); + if (s0 < 0) s0 = 0; + if (s1 >= sw) s1 = sw - 1; + for (let s = s0; s <= s1; s++) { + const center = (s + 0.5) / SS_X; + if (center >= spanX && center < e.x) counts[(s / SS_X) | 0]++; + } } } } diff --git a/hosts/macos/src/main.rs b/hosts/macos/src/main.rs index 4f261f82..48fd98f5 100644 --- a/hosts/macos/src/main.rs +++ b/hosts/macos/src/main.rs @@ -23,6 +23,15 @@ //! forwarded as lines, guest intents (save/quit/copy/caret) handled here. //! An app protocol, not a capability: the live-viewport hook and button //! map work for every app regardless of this flag. +//! - desk — the DESK COMPANION adapter (apps/desk98/svc.ts): the same +//! input-line dialect as the editor, extended with right-button mouse +//! lines (b:2), alt/ctl key modifiers, F1–F12, cmd-flagged ⌘ chords +//! (except ⌘Q = host quit and ⌘V = host-side paste), a boot epoch in the +//! hello (for the guest's wall clock), a {t:"cursor"} guest intent that +//! sets the window's pointer shape, and a {t:"paste-req"} guest intent +//! answered with a paste line (menu-driven Paste). Wired when the plan's +//! companion list names "desk"; the note dialect stays byte-compatible +//! (extra fields ignored). use std::cell::{Cell, RefCell}; use std::ops::Range; @@ -32,11 +41,11 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result, anyhow}; use gpui::{ - App, AppContext as _, Application, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, - FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent, KeyUpEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, - ScrollWheelEvent, SharedString, Styled, TitlebarOptions, UTF16Selection, Window, WindowBounds, - WindowOptions, canvas, div, point, px, size, + App, AppContext as _, Application, Bounds, ClipboardItem, Context, CursorStyle, Entity, + EntityInputHandler, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent, + KeyUpEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, + Point, Render, ScrollWheelEvent, SharedString, Styled, TitlebarOptions, UTF16Selection, Window, + WindowBounds, WindowOptions, canvas, div, point, px, size, }; use pocket_mod::Guest; use pocket_ui_gpui::{GpuiRenderer, TextConfig, native_measure}; @@ -73,6 +82,11 @@ enum ScriptEvent { Click(u64, f32, f32), /// Hold a console button for ~6 ticks starting at a tick. Press(u64, u32), + /// Raw pointer line at a tick: kind 'd' presses, 'u' releases, 'm' moves + /// with the current scripted button state (drag scripting). + Mouse(u64, f32, f32, char), + /// Named key with optional cmd+/alt+/ctl+/sh+ prefixes (svc `key` line). + Key(u64, String, bool, bool, bool, bool), } struct Args { @@ -169,6 +183,60 @@ fn parse_args() -> Result { args.script .push(ScriptEvent::Click(t.parse()?, x.parse()?, y.parse()?)); } + "--mouse" => { + // --mouse X,Y[,d|u|r]@TICK — scripted pointer line (m = move, + // r = right press+release). + let v = val("--mouse")?; + let (spec, t) = v + .rsplit_once('@') + .ok_or_else(|| anyhow!("--mouse X,Y[,d|u]@TICK"))?; + let parts: Vec<&str> = spec.split(',').collect(); + if parts.len() < 2 || parts.len() > 3 { + return Err(anyhow!("--mouse X,Y[,d|u]@TICK")); + } + let kind = parts + .get(2) + .map_or('m', |s| s.chars().next().unwrap_or('m')); + args.script.push(ScriptEvent::Mouse( + t.parse()?, + parts[0].parse()?, + parts[1].parse()?, + kind, + )); + } + "--key" => { + // --key [cmd+][alt+][ctl+][sh+]NAME@TICK — scripted svc key line. + let v = val("--key")?; + let (mut name, t) = v + .rsplit_once('@') + .ok_or_else(|| anyhow!("--key NAME@TICK"))?; + let (mut cmd, mut alt, mut ctl, mut sh) = (false, false, false, false); + loop { + if let Some(rest) = name.strip_prefix("cmd+") { + cmd = true; + name = rest; + } else if let Some(rest) = name.strip_prefix("alt+") { + alt = true; + name = rest; + } else if let Some(rest) = name.strip_prefix("ctl+") { + ctl = true; + name = rest; + } else if let Some(rest) = name.strip_prefix("sh+") { + sh = true; + name = rest; + } else { + break; + } + } + args.script.push(ScriptEvent::Key( + t.parse()?, + name.to_string(), + cmd, + alt, + ctl, + sh, + )); + } "--quit-after" => args.quit_after_ticks = Some(val("--quit-after")?.parse()?), "--announce-ready" => args.announce_ready = true, "--press" => { @@ -283,7 +351,13 @@ struct PocketRoot { buttons: u32, /// Buttons currently held by the --press script. script_buttons: u32, - // editor input + /// Primary-button state of the --mouse script. + script_mouse: bool, + // editor/desk input + /// The desk companion is in the plan — the extended input dialect. + desk: bool, + /// Pointer shape requested by the guest ({t:"cursor"} intent). + cursor_style: CursorStyle, mouse_down: bool, click_edge: bool, last_mouse: Option<(f32, f32, bool)>, @@ -340,6 +414,7 @@ impl PocketRoot { let focus = cx.focus_handle(); let viewport = args.viewport; let script = args.script.clone(); + let desk = args.companions.iter().any(|c| c == "desk"); let root = PocketRoot { surface, guest, @@ -356,6 +431,9 @@ impl PocketRoot { canvas_origin: Rc::new(Cell::new(point(px(0.0), px(0.0)))), buttons: 0, script_buttons: 0, + script_mouse: false, + desk, + cursor_style: CursorStyle::Arrow, mouse_down: false, click_edge: false, last_mouse: None, @@ -411,10 +489,25 @@ impl PocketRoot { self.surface.svc_push(value.to_string()); } + /// Input lines flow when either JSON-line dialect is wired. + fn forward_input(&self) -> bool { + self.args.editor || self.desk + } + /// The svc hello: viewport first, then the document (order matters — the - /// app lays text out against the viewport it was just told about). + /// app lays text out against the viewport it was just told about). The + /// epoch anchors the guest's wall clock (desk taskbar); the note dialect + /// ignores it. fn send_hello(&mut self) { - self.svc(serde_json::json!({"t": "hello", "w": self.viewport.0, "h": self.viewport.1})); + log::debug!( + "pocket-macos: svc hello (desk={}, editor={})", + self.desk, + self.args.editor + ); + self.svc(serde_json::json!({ + "t": "hello", "w": self.viewport.0, "h": self.viewport.1, + "epoch": epoch_ms() as u64, + })); if let Some(file) = &self.args.file { let text = std::fs::read_to_string(file).unwrap_or_default(); if !text.is_empty() { @@ -452,6 +545,37 @@ impl PocketRoot { ScriptEvent::Press(t, bit) if tick == t + 6 => { self.script_buttons &= !bit; } + ScriptEvent::Mouse(t, x, y, kind) if t == tick => { + if kind == 'r' { + // Right click: press + release in one tick (b:2 lines). + self.svc(serde_json::json!( + {"t": "mouse", "x": x, "y": y, "d": true, "b": 2, "sh": false} + )); + self.svc(serde_json::json!( + {"t": "mouse", "x": x, "y": y, "d": false, "b": 2, "sh": false} + )); + } else { + let down = match kind { + 'd' => { + self.script_mouse = true; + true + } + 'u' => { + self.script_mouse = false; + false + } + _ => self.script_mouse, + }; + self.svc(serde_json::json!( + {"t": "mouse", "x": x, "y": y, "d": down, "sh": false} + )); + } + } + ScriptEvent::Key(t, ref k, cmd, alt, ctl, sh) if t == tick => { + self.svc(serde_json::json!( + {"t": "key", "k": k, "cmd": cmd, "sh": sh, "alt": alt, "ctl": ctl} + )); + } _ => {} } } @@ -465,7 +589,7 @@ impl PocketRoot { } if !self.booted { self.booted = true; - if self.args.editor { + if self.forward_input() { self.send_hello(); } } @@ -493,7 +617,7 @@ impl PocketRoot { ) { log::warn!("pocket-macos: resize hook failed: {e}"); } - if self.args.editor { + if self.forward_input() { self.svc(serde_json::json!({"t": "resize", "w": vp.0, "h": vp.1})); } } @@ -524,7 +648,7 @@ impl PocketRoot { 0 } } else { - self.buttons + self.buttons | self.script_buttons }; self.click_edge = false; if let Err(e) = self.guest.frame(buttons) { @@ -546,6 +670,15 @@ impl PocketRoot { v["text"].as_str().unwrap_or_default().to_string(), )); } + Some("paste-req") => { + // The guest can't read the system clipboard; answer a + // menu-driven Paste with the dialect's paste line. + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) + && !text.is_empty() + { + self.svc(serde_json::json!({"t": "paste", "text": text})); + } + } Some("caret") => { self.caret_rect = Some(( v["x"].as_f64().unwrap_or(0.0) as f32, @@ -554,6 +687,13 @@ impl PocketRoot { v["h"].as_f64().unwrap_or(20.0) as f32, )); } + Some("cursor") => { + let style = cursor_for(v["k"].as_str().unwrap_or("default")); + if style != self.cursor_style { + self.cursor_style = style; + cx.notify(); + } + } other => log::warn!("pocket-macos: unknown intent {other:?}"), }, Err(e) => log::warn!("pocket-macos: bad svc line from guest: {e}"), @@ -588,9 +728,35 @@ impl PocketRoot { fn on_key_down(&mut self, e: &KeyDownEvent, _w: &mut Window, cx: &mut Context) { let ks = &e.keystroke; - if self.args.editor { + if self.forward_input() { let shift = ks.modifiers.shift; if ks.modifiers.platform { + if self.desk { + // Desk dialect: ⌘Q quits the host (macOS convention) and + // ⌘V pastes here (the guest can't read the clipboard); + // every other chord goes to the guest as a cmd-flagged + // key line — the compositor owns its own shortcuts + // (⌘W close, ⌘M minimize, ⌘` cycle, ⌘C/X/A editing). + match ks.key.as_str() { + "q" => self.exit = true, + "v" => { + if let Some(text) = + cx.read_from_clipboard().and_then(|item| item.text()) + && !text.is_empty() + { + self.svc(serde_json::json!({"t": "paste", "text": text})); + } + } + k => self.svc(serde_json::json!({ + "t": "key", "k": k, "cmd": true, "sh": shift, + "alt": ks.modifiers.alt, "ctl": ks.modifiers.control, + })), + } + // Consumed: don't let AppKit hunt the (empty) menu bar + // for a key equivalent and beep. + cx.stop_propagation(); + return; + } match ks.key.as_str() { "q" | "w" => self.exit = true, "z" => self.svc( @@ -623,19 +789,32 @@ impl PocketRoot { "pageup" => Some("PageUp"), "pagedown" => Some("PageDown"), "escape" => Some("Escape"), + // Desk-dialect chord keys (Alt+F4, Ctrl+Esc, Alt+Tab). + "f1" => Some("F1"), + "f2" => Some("F2"), + "f3" => Some("F3"), + "f4" => Some("F4"), + "f5" => Some("F5"), + "f6" => Some("F6"), + "f7" => Some("F7"), + "f8" => Some("F8"), + "f9" => Some("F9"), + "f10" => Some("F10"), + "f11" => Some("F11"), + "f12" => Some("F12"), _ => None, }; if let Some(k) = named { - self.svc(serde_json::json!({"t": "key", "k": k, "sh": shift})); - } else if self.marked.is_none() { - // Plain typing (IME composition delivers through the input - // handler instead — no double-input path). - if let Some(s) = &ks.key_char - && !s.is_empty() - { - self.svc(serde_json::json!({"t": "ch", "s": s})); - } + self.svc(serde_json::json!({ + "t": "key", "k": k, "sh": shift, + "alt": ks.modifiers.alt, "ctl": ks.modifiers.control, + })); } + // Plain typing is NOT emitted here. gpui hands the unhandled key + // event to the input context after this listener, and insertText: + // always reaches the registered input handler — so the character + // arrives once through replace_text_in_range. Emitting key_char + // here as well delivered every keypress twice. } else { if ks.modifiers.platform && (ks.key == "q" || ks.key == "w") { self.exit = true; @@ -648,7 +827,7 @@ impl PocketRoot { } fn on_key_up(&mut self, e: &KeyUpEvent, _w: &mut Window, _cx: &mut Context) { - if !self.args.editor + if !self.forward_input() && let Some(bit) = button_for(&e.keystroke.key) { self.buttons &= !bit; @@ -673,37 +852,55 @@ impl PocketRoot { fn on_mouse_down(&mut self, e: &MouseDownEvent, w: &mut Window, _cx: &mut Context) { self.focus.focus(w); - if e.button != MouseButton::Left { - return; - } - self.mouse_down = true; - self.click_edge = true; - if self.args.editor { - let (x, y) = self.logical_pos(e.position); - self.push_mouse(x, y, true, e.modifiers.shift); + match e.button { + MouseButton::Left => { + self.mouse_down = true; + self.click_edge = true; + if self.forward_input() { + let (x, y) = self.logical_pos(e.position); + self.push_mouse(x, y, true, e.modifiers.shift); + } + } + // Right button is desk-dialect only (b:2 lines would read as + // primary presses to the note protocol). + MouseButton::Right if self.desk => { + let (x, y) = self.logical_pos(e.position); + self.svc(serde_json::json!( + {"t": "mouse", "x": x, "y": y, "d": true, "b": 2, "sh": e.modifiers.shift} + )); + } + _ => {} } } fn on_mouse_up(&mut self, e: &MouseUpEvent, _w: &mut Window, _cx: &mut Context) { - if e.button != MouseButton::Left { - return; - } - self.mouse_down = false; - if self.args.editor { - let (x, y) = self.logical_pos(e.position); - self.push_mouse(x, y, false, e.modifiers.shift); + match e.button { + MouseButton::Left => { + self.mouse_down = false; + if self.forward_input() { + let (x, y) = self.logical_pos(e.position); + self.push_mouse(x, y, false, e.modifiers.shift); + } + } + MouseButton::Right if self.desk => { + let (x, y) = self.logical_pos(e.position); + self.svc(serde_json::json!( + {"t": "mouse", "x": x, "y": y, "d": false, "b": 2, "sh": e.modifiers.shift} + )); + } + _ => {} } } fn on_mouse_move(&mut self, e: &MouseMoveEvent, _w: &mut Window, _cx: &mut Context) { - if self.args.editor { + if self.forward_input() { let (x, y) = self.logical_pos(e.position); self.push_mouse(x, y, self.mouse_down, e.modifiers.shift); } } fn on_scroll(&mut self, e: &ScrollWheelEvent, w: &mut Window, _cx: &mut Context) { - if self.args.editor { + if self.forward_input() { let dy = f32::from(e.delta.pixel_delta(w.line_height()).y); if dy != 0.0 { self.svc(serde_json::json!({"t": "scroll", "dy": dy})); @@ -712,6 +909,22 @@ impl PocketRoot { } } +/// {t:"cursor"} intent → pointer shape. Keys mirror CSS cursor names the +/// desk dialect uses; unknown keys fall back to the arrow. +fn cursor_for(k: &str) -> CursorStyle { + match k { + "text" => CursorStyle::IBeam, + "pointer" => CursorStyle::PointingHand, + "move" => CursorStyle::OpenHand, + "grabbing" => CursorStyle::ClosedHand, + "ew" => CursorStyle::ResizeLeftRight, + "ns" => CursorStyle::ResizeUpDown, + "nwse" => CursorStyle::ResizeUpLeftDownRight, + "nesw" => CursorStyle::ResizeUpRightDownLeft, + _ => CursorStyle::Arrow, + } +} + fn button_for(key: &str) -> Option { Some(match key { "up" => BTN_UP, @@ -892,16 +1105,19 @@ impl Render for PocketRoot { .then_some((self.args.viewport.0 as f32, self.args.viewport.1 as f32)); let entity: Entity = cx.entity(); let focus = self.focus.clone(); - let ime = self.args.editor; + let ime = self.forward_input(); div() .size_full() .bg(gpui::black()) + .cursor(self.cursor_style) .track_focus(&self.focus) .on_key_down(cx.listener(Self::on_key_down)) .on_key_up(cx.listener(Self::on_key_up)) .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_mouse_down(MouseButton::Right, cx.listener(Self::on_mouse_down)) + .on_mouse_up(MouseButton::Right, cx.listener(Self::on_mouse_up)) .on_mouse_move(cx.listener(Self::on_mouse_move)) .on_scroll_wheel(cx.listener(Self::on_scroll)) .child( diff --git a/tests/desk98-sim.test.ts b/tests/desk98-sim.test.ts new file mode 100644 index 00000000..9e575370 --- /dev/null +++ b/tests/desk98-sim.test.ts @@ -0,0 +1,170 @@ +// tests/desk98-sim.test.ts — desk98 in the sim. Two worlds: +// +// 1. standalone (no desk companion): the app boots a static arrangement — +// the unmodified-app base case. +// 2. a mock desk companion: svcOpen/svcPoll/svcSend installed before eval +// (bootWorld mutateOps), so the whole desk dialect journey runs +// headless — typing, drag selection, ⌘ chords, the notepad context +// menu, paste-req — with guest intents (copy payloads!) asserted on +// the wire. +// +// The vue-vapor bundle must be prebuilt (the sim's fallback build cannot +// resolve the framework-suffixed name): +// +// bun tools/build.ts desk98-main --framework=vue-vapor +// bun test --conditions=browser tests/desk98-sim.test.ts + +import { describe, expect, test } from "bun:test"; +import { bootWorld, runScenario, treeHasText, type SimWorld } from "../hosts/sim/sim.ts"; + +const APP = "desk98-main.vue-vapor"; + +describe("desk98-main boots standalone", () => { + test("desktop, taskbar and the boot windows render", async () => { + const trace = await runScenario({ app: APP, seconds: 2 }); + expect(treeHasText(trace.tree, "Start")).toBe(true); + expect(treeHasText(trace.tree, "Minesweeper")).toBe(true); + expect(treeHasText(trace.tree, "My Computer")).toBe(true); + expect(treeHasText(trace.tree, "Welcome to PocketJS 98.")).toBe(true); + }, 30000); +}); + +// --------------------------------------------------------------------------- +// The desk companion journey +// --------------------------------------------------------------------------- + +interface MockSvc { + push: (line: Record) => void; + sent: () => Record[]; + mutateOps: (ops: Record) => void; +} + +function mockSvc(): MockSvc { + const toGuest: string[] = []; + const fromGuest: Record[] = []; + return { + push: (line) => toGuest.push(JSON.stringify(line)), + sent: () => fromGuest, + mutateOps: (ops) => { + ops.svcOpen = (name: string) => name === "desk"; + ops.svcPoll = () => { + if (toGuest.length === 0) return null; + const batch = toGuest.join("\n"); + toGuest.length = 0; + return batch; + }; + ops.svcSend = (line: string) => { + fromGuest.push(JSON.parse(line) as Record); + }; + }, + }; +} + +/** One frame + core catch-up + a microtask turn (Vue Vapor flushes + * dependent render effects in a microtask). */ +async function step(world: SimWorld, frames = 1): Promise { + for (let f = 0; f < frames; f++) { + world.frame(0); + for (let t = 0; t < world.ticksPerFrame; t++) world.tick(); + await Promise.resolve(); + } +} + +function mouse(svc: MockSvc, x: number, y: number, d: boolean, b?: number) { + svc.push(b === 2 ? { t: "mouse", x, y, d, b: 2, sh: false } : { t: "mouse", x, y, d, sh: false }); +} + +describe("desk98 desk companion journey", () => { + test("typing, selection, ⌘ chords, context menu and paste-req", async () => { + const svc = mockSvc(); + const world = await bootWorld(APP, 60, undefined, svc.mutateOps); + const EPOCH = 1755650000000; + svc.push({ t: "hello", w: 800, h: 600, epoch: EPOCH }); + await step(world, 3); + + // With the companion connected only the welcome notepad boots (the + // standalone extras — the My Computer folder with its status bar — stay + // closed); the taskbar clock ticks from the hello epoch. + let tree = world.getTree(); + expect(treeHasText(tree, "welcome.txt - Notepad")).toBe(true); + expect(treeHasText(tree, "object(s)")).toBe(false); + const d = new Date(EPOCH); + const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + expect(treeHasText(tree, hhmm)).toBe(true); + + // Typing: ch lines land at the caret (doc origin), one char per line. + for (const ch of ["H", "i"]) svc.push({ t: "ch", s: ch }); + await step(world, 2); + tree = world.getTree(); + expect(treeHasText(tree, "HiWelcome to PocketJS 98.")).toBe(true); + + // Double-click selects the word under the pointer; ⌘C ships it as a + // copy intent (the welcome window sits at 64,28; content text origin + // 70,71; row 0 centers at y≈79). + mouse(svc, 75, 79, true); + mouse(svc, 75, 79, false); + await step(world); + mouse(svc, 75, 79, true); + mouse(svc, 75, 79, false); + await step(world, 2); + svc.push({ t: "key", k: "c", cmd: true }); + await step(world, 2); + const copies = svc.sent().filter((l) => l.t === "copy"); + expect(copies.length).toBe(1); + expect(copies[0].text).toBe("HiWelcome"); + + // Drag selection: down at the line start, drag right, release; ⌘C + // copies a prefix of the row and the selected run renders as its own + // navy segment (its text splits out of the full-line node). + mouse(svc, 70, 79, true); + await step(world); + mouse(svc, 140, 79, false); + await step(world); + mouse(svc, 140, 79, false); + await step(world); + svc.push({ t: "key", k: "c", cmd: true }); + await step(world, 2); + const copy2 = svc.sent().filter((l) => l.t === "copy")[1]; + expect(typeof copy2.text).toBe("string"); + const dragged = copy2.text as string; + expect(dragged.length).toBeGreaterThan(0); + expect("HiWelcome to PocketJS 98.".startsWith(dragged)).toBe(true); + tree = world.getTree(); + expect(treeHasText(tree, dragged)).toBe(true); + + // Right-click in the content opens the edit context menu; the Paste row + // sends paste-req; the host's paste line lands at the caret. + mouse(svc, 75, 100, true, 2); + mouse(svc, 75, 100, false, 2); + await step(world, 2); + tree = world.getTree(); + expect(treeHasText(tree, "Select All")).toBe(true); + expect(treeHasText(tree, "Paste")).toBe(true); + // Popup at (75,100): 1px border, 18px rows — Cut, Copy, Paste. + mouse(svc, 100, 101 + 18 + 18 + 9, true); + mouse(svc, 100, 101 + 18 + 18 + 9, false); + await step(world, 2); + expect(svc.sent().some((l) => l.t === "paste-req")).toBe(true); + svc.push({ t: "paste", text: "[PASTED]" }); + await step(world, 2); + expect(treeHasText(world.getTree(), "[PASTED]")).toBe(true); + + // ⌘Esc toggles the Start menu. + svc.push({ t: "key", k: "escape", cmd: true }); + await step(world, 2); + tree = world.getTree(); + expect(treeHasText(tree, "Programs")).toBe(true); + expect(treeHasText(tree, "Shut Down...")).toBe(true); + svc.push({ t: "key", k: "Escape" }); + await step(world, 2); + expect(treeHasText(world.getTree(), "Shut Down...")).toBe(false); + + // ⌘N opens a fresh Notepad, ⌘W closes it again. + svc.push({ t: "key", k: "n", cmd: true }); + await step(world, 2); + expect(treeHasText(world.getTree(), "Untitled - Notepad")).toBe(true); + svc.push({ t: "key", k: "w", cmd: true }); + await step(world, 2); + expect(treeHasText(world.getTree(), "Untitled - Notepad")).toBe(false); + }, 30000); +}); diff --git a/tests/desk98.test.ts b/tests/desk98.test.ts new file mode 100644 index 00000000..0debcf5d --- /dev/null +++ b/tests/desk98.test.ts @@ -0,0 +1,352 @@ +// tests/desk98.test.ts — PocketJS 98: window-manager chrome math, the +// Minesweeper rules, Notepad line editing and the selection model (all +// pure). The sim boot smoke lives in tests/desk98-sim.test.ts (needs the +// vue-vapor bundle prebuilt). + +import { describe, expect, test } from "bun:test"; +import { + captionButtonXs, + clampMove, + contentTop, + cursorForDir, + hitRegion, + maximizedGeo, + resizeGeo, + type ChromeOpts, + type Geo, +} from "../apps/desk98/wm.ts"; +import { + MINES_N, + MINES_W, + newMines, + reveal, + toggleFlag, +} from "../apps/desk98/mines.ts"; +import { + applyMove, + backspace, + colFromX, + del, + deleteSel, + hasSel, + insertText, + moveCaret, + rowSelSpan, + selectAll, + selectedText, + selRange, + wordRangeAt, + type Doc, +} from "../apps/desk98/notepad.ts"; + +// --------------------------------------------------------------------------- +// wm.ts — chrome hit regions +// --------------------------------------------------------------------------- + +const GEO: Geo = { x: 100, y: 50, w: 400, h: 300 }; +const OPTS: ChromeOpts = { + buttons: ["min", "max", "close"], + resizable: true, + maximized: false, + menuWidths: [34, 34], +}; + +describe("caption buttons", () => { + test("all three buttons sit flush against each other, flush right", () => { + const xs = captionButtonXs(400, ["min", "max", "close"]); + // close right edge at w - FRAME(3) - 2. + expect(xs[2] + 16).toBe(400 - 3 - 2); + expect(xs[1]).toBe(xs[2] - 16); // no close gap + expect(xs[0]).toBe(xs[1] - 16); + }); + + test("close-only dialogs place the single button flush right", () => { + const xs = captionButtonXs(300, ["close"]); + expect(xs).toEqual([300 - 3 - 2 - 16]); + }); +}); + +describe("hitRegion", () => { + test("caption bar drags, buttons claim their cells", () => { + // Caption strip, left of the buttons. + expect(hitRegion(GEO, OPTS, 100 + 200, 50 + 10)).toEqual({ kind: "caption" }); + const xs = captionButtonXs(GEO.w, OPTS.buttons); + for (const [i, name] of (["min", "max", "close"] as const).entries()) { + const r = hitRegion(GEO, OPTS, 100 + xs[i] + 8, 50 + 3 + 2 + 7); + expect(r).toEqual({ kind: "button", button: name }); + } + }); + + test("resize bands claim edges and corners with the right directions", () => { + expect(hitRegion(GEO, OPTS, 100 + 200, 50 + 1)).toEqual({ kind: "resize", dir: "n" }); + expect(hitRegion(GEO, OPTS, 100 + 1, 50 + 150)).toEqual({ kind: "resize", dir: "w" }); + expect(hitRegion(GEO, OPTS, 100 + 399, 50 + 299)).toEqual({ kind: "resize", dir: "se" }); + expect(hitRegion(GEO, OPTS, 100 + 1, 50 + 299)).toEqual({ kind: "resize", dir: "sw" }); + expect(hitRegion(GEO, OPTS, 100 + 399, 50 + 1)).toEqual({ kind: "resize", dir: "ne" }); + }); + + test("maximized and fixed windows expose no resize bands", () => { + const max = { ...OPTS, maximized: true }; + expect(hitRegion(GEO, max, 100 + 200, 50 + 1)).toEqual({ kind: "caption" }); + const fixed = { ...OPTS, resizable: false }; + expect(hitRegion(GEO, fixed, 100 + 399, 50 + 299)).not.toEqual({ + kind: "resize", + dir: "se", + }); + }); + + test("menu bar items hit by accumulated widths, content below them", () => { + const menuY = 50 + 3 + 18 + 1 + 9; + expect(hitRegion(GEO, OPTS, 100 + 3 + 10, menuY)).toEqual({ kind: "menu", index: 0 }); + expect(hitRegion(GEO, OPTS, 100 + 3 + 34 + 10, menuY)).toEqual({ kind: "menu", index: 1 }); + const r = hitRegion(GEO, OPTS, 100 + 50, 50 + contentTop(OPTS) + 20); + expect(r).toEqual({ kind: "content", cx: 47, cy: 20 }); + }); + + test("outside the window misses", () => { + expect(hitRegion(GEO, OPTS, 99, 60)).toBeNull(); + expect(hitRegion(GEO, OPTS, 100 + 400, 60)).toBeNull(); + }); +}); + +describe("resizeGeo", () => { + const orig: Geo = { x: 100, y: 50, w: 400, h: 300 }; + test("east/south follow the pointer, west/north anchor the far edge", () => { + expect(resizeGeo(orig, "se", 40, 30, 200, 120)).toEqual({ x: 100, y: 50, w: 440, h: 330 }); + const west = resizeGeo(orig, "w", 60, 0, 200, 120); + expect(west.w).toBe(340); + expect(west.x + west.w).toBe(orig.x + orig.w); // right edge pinned + const north = resizeGeo(orig, "n", 0, -20, 200, 120); + expect(north.h).toBe(320); + expect(north.y + north.h).toBe(orig.y + orig.h); + }); + + test("minimums hold on every edge", () => { + const tiny = resizeGeo(orig, "se", -1000, -1000, 200, 120); + expect(tiny.w).toBe(200); + expect(tiny.h).toBe(120); + const wTiny = resizeGeo(orig, "nw", 1000, 1000, 200, 120); + expect(wTiny.w).toBe(200); + expect(wTiny.h).toBe(120); + expect(wTiny.x + wTiny.w).toBe(orig.x + orig.w); + expect(wTiny.y + wTiny.h).toBe(orig.y + orig.h); + }); +}); + +describe("clampMove / maximizedGeo", () => { + test("the caption always stays reachable", () => { + const g = clampMove({ x: -1000, y: -50, w: 400, h: 300 }, 800, 600); + expect(g.x).toBe(48 - 400); + expect(g.y).toBe(0); + const low = clampMove({ x: 790, y: 590, w: 400, h: 300 }, 800, 600); + expect(low.x).toBe(800 - 48); + expect(low.y).toBe(600 - 28 - 18); + }); + + test("maximized fills the desktop above the taskbar", () => { + expect(maximizedGeo(800, 600)).toEqual({ x: 0, y: 0, w: 800, h: 572 }); + }); + + test("resize cursor kinds", () => { + expect(cursorForDir("e")).toBe("ew"); + expect(cursorForDir("n")).toBe("ns"); + expect(cursorForDir("se")).toBe("nwse"); + expect(cursorForDir("sw")).toBe("nesw"); + }); +}); + +// --------------------------------------------------------------------------- +// mines.ts +// --------------------------------------------------------------------------- + +describe("minesweeper", () => { + test("first reveal is always safe and plants exactly ten mines", () => { + for (const seed of [1, 42, 1234, 987654]) { + const m = reveal(newMines(seed), 40); + expect(m.phase === "lost").toBe(false); + expect(m.cells.filter((c) => c.mine).length).toBe(MINES_N); + expect(m.cells[40].mine).toBe(false); + expect(m.cells[40].state).toBe("revealed"); + } + }); + + test("zero-adjacency regions flood open", () => { + // Find a seed/cell whose reveal floods more than one cell. + const m = newMines(7); + reveal(m, 0); + if (m.cells[0].adj === 0) { + expect(m.revealed).toBeGreaterThan(1); + } + // Whatever the layout, revealed count matches cells marked revealed. + expect(m.cells.filter((c) => c.state === "revealed").length).toBe(m.revealed); + }); + + test("flags toggle and never reveal", () => { + const m = reveal(newMines(3), 0); + const hidden = m.cells.findIndex((c) => c.state === "hidden"); + toggleFlag(m, hidden); + expect(m.cells[hidden].state).toBe("flag"); + expect(m.flags).toBe(1); + reveal(m, hidden); // flagged cells refuse reveal + expect(m.cells[hidden].state).toBe("flag"); + toggleFlag(m, hidden); + expect(m.flags).toBe(0); + }); + + test("revealing a mine loses and exposes the field; clearing all safe cells wins", () => { + const m = reveal(newMines(11), 22); + const mine = m.cells.findIndex((c) => c.mine); + reveal(m, mine); + expect(m.phase).toBe("lost"); + expect(m.bust).toBe(mine); + expect(m.cells.filter((c) => c.mine && c.state === "revealed").length).toBe(MINES_N); + + const w = newMines(5); + reveal(w, 0); + for (let i = 0; i < w.cells.length; i++) { + if (!w.cells[i].mine) reveal(w, i); + } + expect(w.phase).toBe("won"); + expect(w.flags).toBe(MINES_N); // win convention: mines auto-flag + }); + + test("placement is deterministic per seed", () => { + const a = reveal(newMines(99), 0); + const b = reveal(newMines(99), 0); + expect(a.cells.map((c) => c.mine)).toEqual(b.cells.map((c) => c.mine)); + expect(MINES_W).toBe(9); + }); +}); + +// --------------------------------------------------------------------------- +// notepad.ts +// --------------------------------------------------------------------------- + +describe("notepad editing", () => { + const doc = { lines: ["hello", "world"], caret: { row: 0, col: 5 } }; + + test("insert with newlines splits lines and lands the caret", () => { + const d = insertText(doc, "!\nnew"); + expect(d.lines).toEqual(["hello!", "new", "world"]); + expect(d.caret).toEqual({ row: 1, col: 3 }); + }); + + test("backspace joins lines at col 0", () => { + const d = backspace({ lines: ["ab", "cd"], caret: { row: 1, col: 0 } }); + expect(d.lines).toEqual(["abcd"]); + expect(d.caret).toEqual({ row: 0, col: 2 }); + }); + + test("delete joins the next line at the end", () => { + const d = del({ lines: ["ab", "cd"], caret: { row: 0, col: 2 } }); + expect(d.lines).toEqual(["abcd"]); + expect(d.caret).toEqual({ row: 0, col: 2 }); + }); + + test("caret movement clamps and wraps", () => { + expect(moveCaret({ lines: ["ab", "c"], caret: { row: 0, col: 2 } }, "Right")).toEqual({ + row: 1, + col: 0, + }); + expect(moveCaret({ lines: ["ab", "c"], caret: { row: 1, col: 0 } }, "Left")).toEqual({ + row: 0, + col: 2, + }); + expect(moveCaret({ lines: ["ab", "c"], caret: { row: 0, col: 2 } }, "Down")).toEqual({ + row: 1, + col: 1, + }); + expect(moveCaret({ lines: ["ab", "c"], caret: { row: 1, col: 1 } }, "End")).toEqual({ + row: 1, + col: 1, + }); + }); + + test("colFromX picks the nearest gap by prefix midpoints", () => { + const measure = (s: string) => s.length * 6; + expect(colFromX("abcd", 0, measure)).toBe(0); + expect(colFromX("abcd", 2, measure)).toBe(0); // < half of the first char + expect(colFromX("abcd", 4, measure)).toBe(1); + expect(colFromX("abcd", 100, measure)).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// notepad.ts — selection model +// --------------------------------------------------------------------------- + +describe("notepad selection", () => { + const sel: Doc = { + lines: ["hello world", "second line", "third"], + caret: { row: 1, col: 4 }, + anchor: { row: 0, col: 6 }, + }; + + test("selRange orders anchor/caret either way; collapsed = none", () => { + expect(selRange(sel)).toEqual({ from: { row: 0, col: 6 }, to: { row: 1, col: 4 } }); + const flipped: Doc = { ...sel, caret: sel.anchor as { row: number; col: number }, anchor: sel.caret }; + expect(selRange(flipped)).toEqual(selRange(sel)); + expect(hasSel({ lines: ["a"], caret: { row: 0, col: 1 }, anchor: { row: 0, col: 1 } })).toBe(false); + expect(hasSel({ lines: ["a"], caret: { row: 0, col: 1 } })).toBe(false); + }); + + test("selectedText joins the range with newlines", () => { + expect(selectedText(sel)).toBe("world\nseco"); + const one: Doc = { lines: ["hello"], caret: { row: 0, col: 4 }, anchor: { row: 0, col: 1 } }; + expect(selectedText(one)).toBe("ell"); + }); + + test("deleteSel merges the edge lines and lands the caret at the start", () => { + const d = deleteSel(sel); + expect(d.lines).toEqual(["hello nd line", "third"]); + expect(d.caret).toEqual({ row: 0, col: 6 }); + expect(hasSel(d)).toBe(false); + }); + + test("typing replaces the selection; backspace/delete just remove it", () => { + const typed = insertText(sel, "X"); + expect(typed.lines).toEqual(["hello Xnd line", "third"]); + expect(typed.caret).toEqual({ row: 0, col: 7 }); + expect(backspace(sel).lines).toEqual(["hello nd line", "third"]); + expect(del(sel).lines).toEqual(["hello nd line", "third"]); + }); + + test("shift extends from the caret; a plain move collapses to the edge", () => { + const start: Doc = { lines: ["abc def"], caret: { row: 0, col: 4 } }; + const ext = applyMove(start, "Right", true); + expect(ext.anchor).toEqual({ row: 0, col: 4 }); + expect(ext.caret).toEqual({ row: 0, col: 5 }); + const left = applyMove(sel, "Left", false); + expect(left.caret).toEqual({ row: 0, col: 6 }); // collapse to from + expect(hasSel(left)).toBe(false); + const right = applyMove(sel, "Right", false); + expect(right.caret).toEqual({ row: 1, col: 4 }); // collapse to to + }); + + test("selectAll spans the whole document", () => { + const all = selectAll({ lines: ["ab", "cde"], caret: { row: 0, col: 0 } }); + expect(all.anchor).toEqual({ row: 0, col: 0 }); + expect(all.caret).toEqual({ row: 1, col: 3 }); + expect(selectedText(all)).toBe("ab\ncde"); + }); + + test("wordRangeAt picks word, whitespace and punctuation runs", () => { + expect(wordRangeAt("foo bar_baz!", 5)).toEqual({ from: 4, to: 11 }); + expect(wordRangeAt("foo bar", 3)).toEqual({ from: 3, to: 4 }); // the space run + expect(wordRangeAt("a==b", 1)).toEqual({ from: 1, to: 3 }); // punct run + expect(wordRangeAt("", 0)).toEqual({ from: 0, to: 0 }); + }); + + test("rowSelSpan covers edge rows partially and middle rows fully", () => { + const tall: Doc = { + lines: ["aaaa", "bbbb", "cccc"], + caret: { row: 2, col: 2 }, + anchor: { row: 0, col: 1 }, + }; + expect(rowSelSpan(tall, 0)).toEqual({ from: 1, to: 4 }); + expect(rowSelSpan(tall, 1)).toEqual({ from: 0, to: 4 }); + expect(rowSelSpan(tall, 2)).toEqual({ from: 0, to: 2 }); + expect(rowSelSpan(tall, 3)).toBeNull(); + expect(rowSelSpan({ lines: ["x"], caret: { row: 0, col: 0 } }, 0)).toBeNull(); + }); +}); diff --git a/tests/font-bake.test.ts b/tests/font-bake.test.ts index f2520405..34c9b999 100644 --- a/tests/font-bake.test.ts +++ b/tests/font-bake.test.ts @@ -136,3 +136,44 @@ describe("monospace slots", () => { expect(glyphAdvances(bakeSlot(font, 1, 14, false, chars, 1)).size).toBeGreaterThan(1); }); }); + +describe("fill rule", () => { + // W95FA (a bitmap-font conversion) builds glyphs from stroke rectangles + // that SHARE EDGES — under the old even-odd pairing the shared spans + // cancelled and 'h' lost its ascender (rendered identical to 'n'). + // Nonzero winding is the TrueType/CFF rule; this pins it. + test("edge-sharing strokes survive (nonzero winding, not even-odd)", async () => { + const w95 = parseFont( + await Bun.file(new URL("../assets/fonts/W95FA.otf", import.meta.url)).arrayBuffer(), + ); + const chars = ["h", "n"].map((c) => c.codePointAt(0)!); + const atlas = bakeSlot(w95, 0, 12.5, false, chars, 2); + const view = new DataView(atlas.bytes.buffer, atlas.bytes.byteOffset, atlas.bytes.byteLength); + const cellBytes = atlas.coverageW * atlas.coverageH; + const cellsOff = FONT_HEADER_SIZE + atlas.glyphCount * FONT_CMAP_ENTRY_SIZE; + const cellOf = (cp: number): Uint8Array => { + for (let i = 0; i < atlas.glyphCount; i++) { + const e = FONT_HEADER_SIZE + i * FONT_CMAP_ENTRY_SIZE; + if (view.getUint32(e, true) === cp) { + const gid = view.getUint16(e + 4, true); + return atlas.bytes.subarray(cellsOff + gid * cellBytes, cellsOff + (gid + 1) * cellBytes); + } + } + throw new Error(`codepoint ${cp} missing`); + }; + const h = cellOf(chars[0]); + const n = cellOf(chars[1]); + expect(h.some((b) => b > 0)).toBe(true); + expect(Buffer.from(h).equals(Buffer.from(n))).toBe(false); + // The ascender: 'h' must have ink strictly above 'n''s topmost row. + const topInk = (cell: Uint8Array): number => { + for (let y = 0; y < atlas.coverageH; y++) { + for (let x = 0; x < atlas.coverageW; x++) { + if (cell[y * atlas.coverageW + x] > 0) return y; + } + } + return atlas.coverageH; + }; + expect(topInk(h)).toBeLessThan(topInk(n)); + }); +}); diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 583775e7..1e21acdf 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -403,6 +403,7 @@ describe("semantic resolution", () => { cards: [true, true, false, true], chrome: [true, true, false, true], cursor: [true, true, false, true], + desk98: [false, false, true, true], // dynamic-only desktop compositor; the desk companion is macos-app's gallery: [true, true, false, true], hero: [true, true, true, true], "hero-vue-sfc": [true, true, false, true], diff --git a/tools/macos.ts b/tools/macos.ts index a4f3218d..f71d7a82 100644 --- a/tools/macos.ts +++ b/tools/macos.ts @@ -85,6 +85,14 @@ if (plan.features["text.layout.native"]) flags.push("--native-text"); if (plan.companions.length > 0) flags.push("--companions", plan.companions.join(",")); if (editor) flags.push("--editor"); +if (proof && !editor) { + console.error( + "bun run macos --proof drives the NOTE companion protocol (scripted typing + autosave); " + + `${appDir} declares [${plan.companions.join(", ")}]. Script other apps with the host's ` + + "--mouse/--key/--type/--click flags directly.", + ); + process.exit(1); +} if (proof) { const file = `${root}dist/macos-proof.md`; await $`rm -f ${file}`; diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..d25eaaab 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -44,6 +44,7 @@ const SUITE: readonly Stage[] = [ "tests/widget-args.test.ts", "tests/ipod-nano.test.ts", "tests/note.test.ts", + "tests/desk98.test.ts", "tests/site-stage.test.ts", "tests/host-build-inputs.test.ts", "tests/iphone2g-profile.test.ts", @@ -115,6 +116,12 @@ const SUITE: readonly Stage[] = [ browser: true, tests: ["tests/octane-smoke.test.ts"], }, + { + name: "desk98 sim", + prep: [["bun", "tools/build.ts", "desk98-main", "--framework=vue-vapor"]], + browser: true, + tests: ["tests/desk98-sim.test.ts"], + }, { name: "cafe sim (determinism)", prep: [["bun", "tools/build.ts", "cafe-main"]],