From 6c348d78d4291619a3b24a27a229271d73e62c91 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:39:41 +0800 Subject: [PATCH 1/2] feat(desk98): Windows 98 desktop compositor demo on the gpui backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PocketJS 98 (apps/desk98): a full Win98 desktop as one PocketJS guest on the macos-app target — draggable/resizable/z-ordered windows, taskbar, Start menu with flyouts, desktop icons, right-click context menus, Notepad (typing/IME/caret/scroll), a playable Minesweeper, folder views, About and Shut Down dialogs, and the classic shortcuts (Alt+Tab, Alt+F4, Ctrl+Esc, F2/F5, double-click-to-maximize). Styling follows sheru's win98 theme through the engine's bevel rings; window moves ride paint-only translate props and raises ride zIndex, so drags never relayout and the idle desktop keeps the demand-render governor at a few frames a second. The host grows the DESK companion dialect (input forwarding when the plan's companions name "desk"): right-button mouse lines (b:2), alt/ctl key modifiers, F1..F12, a boot epoch in the hello for the taskbar clock, a {t:"cursor"} pointer-shape intent, and script flags for acceptance runs (--mouse drags/right-clicks, --key chords). Also fixes --press: script buttons were composed but never OR'd into guest.frame(). W95FA (OFL, vendored) bakes per-app into slots 19-21 via pak.json + gen-assets.ts, grid-snapped so density-2 coverage is exactly bi-level (AA 0.00%); the repo slot table 0-18 never moves. bake-font's scanline fill switches from even-odd pairing to nonzero winding — the TrueType/CFF rule — because W95FA's edge-sharing stroke rectangles legitimately cancel under even-odd ('h' baked identical to 'n'); ASCII Inter/JetBrains bakes are byte-identical across the change, and tests/font-bake.test.ts pins the regression. Co-Authored-By: Claude Fable 5 --- .github/workflows/macos.yml | 20 +- apps/desk98/app.tsx | 1215 +++++++++++++++++++++++++++++ apps/desk98/chrome.tsx | 352 +++++++++ apps/desk98/fonts/w95fa-19.bin | Bin 0 -> 15596 bytes apps/desk98/fonts/w95fa-19@2x.bin | Bin 0 -> 60056 bytes apps/desk98/fonts/w95fa-20.bin | Bin 0 -> 16831 bytes apps/desk98/fonts/w95fa-20@2x.bin | Bin 0 -> 64996 bytes apps/desk98/fonts/w95fa-21.bin | Bin 0 -> 53216 bytes apps/desk98/fonts/w95fa-21@2x.bin | Bin 0 -> 210536 bytes apps/desk98/gen-assets.ts | 177 +++++ apps/desk98/gen-icons.ts | 561 +++++++++++++ apps/desk98/icons/cap-close.svg | 1 + apps/desk98/icons/cap-max.svg | 1 + apps/desk98/icons/cap-min.svg | 1 + apps/desk98/icons/cap-restore.svg | 1 + apps/desk98/icons/cdrom-16.svg | 1 + apps/desk98/icons/computer-16.svg | 1 + apps/desk98/icons/computer.svg | 1 + apps/desk98/icons/documents.svg | 1 + apps/desk98/icons/drive-16.svg | 1 + apps/desk98/icons/file-16.svg | 1 + apps/desk98/icons/find-16.svg | 1 + apps/desk98/icons/flag.svg | 1 + apps/desk98/icons/folder-16.svg | 1 + apps/desk98/icons/grip.svg | 1 + apps/desk98/icons/help-16.svg | 1 + apps/desk98/icons/menu-arrow.svg | 1 + apps/desk98/icons/mine.svg | 1 + apps/desk98/icons/mines-16.svg | 1 + apps/desk98/icons/mines.svg | 1 + apps/desk98/icons/notepad-16.svg | 1 + apps/desk98/icons/notepad.svg | 1 + apps/desk98/icons/recycle-16.svg | 1 + apps/desk98/icons/recycle.svg | 1 + apps/desk98/icons/run-16.svg | 1 + apps/desk98/icons/settings-16.svg | 1 + apps/desk98/icons/shutdown-16.svg | 1 + apps/desk98/icons/shutdown.svg | 1 + apps/desk98/icons/smile-cool.svg | 1 + apps/desk98/icons/smile-dead.svg | 1 + apps/desk98/icons/smile-ooh.svg | 1 + apps/desk98/icons/smile.svg | 1 + apps/desk98/icons/start-flag.svg | 1 + apps/desk98/main.tsx | 5 + apps/desk98/mines.ts | 132 ++++ apps/desk98/notepad.ts | 111 +++ apps/desk98/pak.json | 5 + apps/desk98/pocket.config.ts | 20 + apps/desk98/pocket.json | 43 + apps/desk98/programs.tsx | 488 ++++++++++++ apps/desk98/screenshot.png | Bin 0 -> 71281 bytes apps/desk98/state.ts | 121 +++ apps/desk98/svc.ts | 96 +++ apps/desk98/theme.ts | 38 + apps/desk98/wm.ts | 194 +++++ assets/fonts/LICENSE-W95FA.txt | 97 +++ assets/fonts/W95FA.otf | Bin 0 -> 43372 bytes docs/BACKENDS.md | 34 +- framework/compiler/bake-font.ts | 50 +- hosts/macos/src/main.rs | 236 +++++- tests/desk98.test.ts | 274 +++++++ tests/font-bake.test.ts | 41 + tests/platform-contracts.test.ts | 1 + tools/macos.ts | 8 + tools/test.ts | 1 + 65 files changed, 4296 insertions(+), 56 deletions(-) create mode 100644 apps/desk98/app.tsx create mode 100644 apps/desk98/chrome.tsx create mode 100644 apps/desk98/fonts/w95fa-19.bin create mode 100644 apps/desk98/fonts/w95fa-19@2x.bin create mode 100644 apps/desk98/fonts/w95fa-20.bin create mode 100644 apps/desk98/fonts/w95fa-20@2x.bin create mode 100644 apps/desk98/fonts/w95fa-21.bin create mode 100644 apps/desk98/fonts/w95fa-21@2x.bin create mode 100644 apps/desk98/gen-assets.ts create mode 100644 apps/desk98/gen-icons.ts create mode 100644 apps/desk98/icons/cap-close.svg create mode 100644 apps/desk98/icons/cap-max.svg create mode 100644 apps/desk98/icons/cap-min.svg create mode 100644 apps/desk98/icons/cap-restore.svg create mode 100644 apps/desk98/icons/cdrom-16.svg create mode 100644 apps/desk98/icons/computer-16.svg create mode 100644 apps/desk98/icons/computer.svg create mode 100644 apps/desk98/icons/documents.svg create mode 100644 apps/desk98/icons/drive-16.svg create mode 100644 apps/desk98/icons/file-16.svg create mode 100644 apps/desk98/icons/find-16.svg create mode 100644 apps/desk98/icons/flag.svg create mode 100644 apps/desk98/icons/folder-16.svg create mode 100644 apps/desk98/icons/grip.svg create mode 100644 apps/desk98/icons/help-16.svg create mode 100644 apps/desk98/icons/menu-arrow.svg create mode 100644 apps/desk98/icons/mine.svg create mode 100644 apps/desk98/icons/mines-16.svg create mode 100644 apps/desk98/icons/mines.svg create mode 100644 apps/desk98/icons/notepad-16.svg create mode 100644 apps/desk98/icons/notepad.svg create mode 100644 apps/desk98/icons/recycle-16.svg create mode 100644 apps/desk98/icons/recycle.svg create mode 100644 apps/desk98/icons/run-16.svg create mode 100644 apps/desk98/icons/settings-16.svg create mode 100644 apps/desk98/icons/shutdown-16.svg create mode 100644 apps/desk98/icons/shutdown.svg create mode 100644 apps/desk98/icons/smile-cool.svg create mode 100644 apps/desk98/icons/smile-dead.svg create mode 100644 apps/desk98/icons/smile-ooh.svg create mode 100644 apps/desk98/icons/smile.svg create mode 100644 apps/desk98/icons/start-flag.svg create mode 100644 apps/desk98/main.tsx create mode 100644 apps/desk98/mines.ts create mode 100644 apps/desk98/notepad.ts create mode 100644 apps/desk98/pak.json create mode 100644 apps/desk98/pocket.config.ts create mode 100644 apps/desk98/pocket.json create mode 100644 apps/desk98/programs.tsx create mode 100644 apps/desk98/screenshot.png create mode 100644 apps/desk98/state.ts create mode 100644 apps/desk98/svc.ts create mode 100644 apps/desk98/theme.ts create mode 100644 apps/desk98/wm.ts create mode 100644 assets/fonts/LICENSE-W95FA.txt create mode 100644 assets/fonts/W95FA.otf create mode 100644 tests/desk98.test.ts diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 8f8a1cdc..382e8e40 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -19,10 +19,12 @@ 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/font-bake.test.ts" - "tests/npm-package.test.ts" - "package.json" @@ -37,10 +39,12 @@ 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/font-bake.test.ts" - "tests/npm-package.test.ts" - "package.json" @@ -75,8 +79,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 +94,18 @@ 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 (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: Test core (TEXT_RUN + native measure gates) run: cargo test --locked --manifest-path engine/core/Cargo.toml diff --git a/apps/desk98/app.tsx b/apps/desk98/app.tsx new file mode 100644 index 00000000..6c4aeef7 --- /dev/null +++ b/apps/desk98/app.tsx @@ -0,0 +1,1215 @@ +// apps/desk98/app.tsx — PocketJS 98: a Windows 98 desktop compositor as a +// PocketJS app on the gpui macOS host. +// +// The compositor owns ALL input: the host forwards raw mouse/keyboard over +// the desk svc dialect (svc.ts), and this file routes every event itself — +// window drags, resizes, caption buttons, menus, program content — against +// the same geometry the chrome renders (wm.ts + programs.tsx helpers). The +// framework's focus/onPress pipeline is never engaged; a window manager IS +// its own hit tester. Window moves ride paint-only translate props, raises +// ride zIndex, so a drag never relayouts and an idle desktop hashes stable +// for the demand-render governor. +// +// Without the desk companion (sim, goldens, consoles) the app boots a +// static arrangement and just renders it — the unmodified-app base case. + +import { createSignal, For, Show, type Accessor } from "solid-js"; +import { View } from "@pocketjs/framework/components"; +import { onFrame } from "@pocketjs/framework/lifecycle"; +import { virtualNow } from "@pocketjs/framework/clock"; +import { connectSvc, type CursorKind, type HostEvent } from "./svc.ts"; +import { + cascadePos, + clampMove, + cursorForDir, + hitRegion, + maximizedGeo, + resizeGeo, + type CaptionButton, + type Dir, + type Geo, + type Region, +} from "./wm.ts"; +import { createWin, type DeskIcon, type Popup, type PopupItem, type TaskEntry, type WinCtl } from "./state.ts"; +import { DesktopIcons, PopupPanel, StartMenu, Taskbar, Window98 } from "./chrome.tsx"; +import { + ABOUT_GEO, + aboutHit, + AboutView, + folderRowAt, + FolderView, + measure, + MINES_GEO, + minesHit, + MinesView, + NotepadView, + PAD_LINE_H, + shutdownHit, + ShutdownView, + type AboutData, + type FolderData, + type FolderRow, + type MinesData, + type PadData, + type ShutdownData, +} from "./programs.tsx"; +import { backspace, colFromX, del, insertText, moveCaret, type CaretMove, type Doc } from "./notepad.ts"; +import { newMines, reveal, toggleFlag, type Mines } from "./mines.ts"; +import { contentTop } from "./wm.ts"; +import { FRAME, TASK_H } from "./theme.ts"; + +const WELCOME = [ + "Welcome to PocketJS 98.", + "", + "This desktop is one PocketJS guest: the windows,", + "the taskbar, the Start menu and this Notepad are", + "JSX over the same DrawList contract the consoles", + "boot, painted by the gpui backend.", + "", + "Things to try:", + " - drag windows by the title bar", + " - drag any edge or corner to resize", + " - double-click a title bar to maximize", + " - right-click the desktop or Minesweeper", + " - Alt+Tab, Alt+F4, Ctrl+Esc", + "", + "The font is W95FA, baked to the same atlas", + "format every other PocketJS target reads.", +]; + +type Drag = + | { type: "move"; id: number; sx: number; sy: number; orig: Geo } + | { type: "resize"; id: number; dir: Dir; sx: number; sy: number; orig: Geo } + | { type: "capbtn"; id: number; btn: CaptionButton } + | { type: "minehold"; id: number } + | { type: "smiley"; id: number } + | { type: "dialogbtn"; id: number; tag: string } + | null; + +export default function App() { + const svc = connectSvc(); + + const [vp, setVp] = createSignal<{ w: number; h: number }>({ w: 800, h: 600 }); + const [wins, setWins] = createSignal([]); + const [focusId, setFocusId] = createSignal(-1); + const [iconSel, setIconSel] = createSignal(-1); + const [startOpen, setStartOpen] = createSignal(false); + const [startHover, setStartHover] = createSignal(-1); + const [startFly, setStartFly] = createSignal<{ index: number; popup: Popup } | null>(null); + const [flyHover, setFlyHover] = createSignal(-1); + const [popup, setPopup] = createSignal<{ popup: Popup; winId?: number } | null>(null); + const [popupHover, setPopupHover] = createSignal(-1); + const [clock, setClock] = createSignal("--:--"); + + // Non-reactive input state (nothing renders from these directly). + let stack: number[] = []; // window ids, bottom → top + let drag: Drag = null; + let mx = 0; + let my = 0; + let prevDown = false; + let epoch = 0; // wall ms at hello, anchored to virtualNow() then + let epochAt = 0; + let lastCursor: CursorKind = "default"; + let lastClick = { key: "", t: -1, x: 0, y: 0 }; + let lastCaret = { x: -1, y: -1, h: 0 }; + let minesStart = 0; + + const byId = (id: number) => wins().find((w) => w.id === id); + const focused = () => byId(focusId()); + + // ---- window management -------------------------------------------------- + + function applyZ() { + stack.forEach((id, i) => byId(id)?.setZ(i + 1)); + } + + function raise(id: number) { + stack = stack.filter((x) => x !== id).concat(id); + applyZ(); + setFocusId(id); + const w = byId(id); + if (w?.minimized()) w.setMinimized(false); + } + + function addWin(w: WinCtl) { + setWins(wins().concat(w)); + stack = stack.concat(w.id); + applyZ(); + setFocusId(w.id); + } + + function closeWin(id: number) { + setWins(wins().filter((w) => w.id !== id)); + stack = stack.filter((x) => x !== id); + applyZ(); + setFocusId(stack.length > 0 ? stack[stack.length - 1] : -1); + } + + function minimize(id: number) { + byId(id)?.setMinimized(true); + const next = stack.filter((x) => x !== id && !byId(x)?.minimized()); + setFocusId(next.length > 0 ? next[next.length - 1] : -1); + } + + function toggleMax(w: WinCtl) { + if (!w.resizable) return; + if (w.maximized()) { + w.setMaximized(false); + if (w.restoreGeo) w.setGeo(w.restoreGeo); + } else { + w.restoreGeo = w.geo(); + w.setMaximized(true); + w.setGeo(maximizedGeo(vp().w, vp().h)); + } + } + + function cycleWindows() { + const visible = stack.filter((id) => !byId(id)?.minimized()); + if (visible.length < 2) return; + raise(visible[0]); // bottom-most visible comes up — repeated Alt+Tab cycles + } + + // ---- programs ----------------------------------------------------------- + + function openNotepad(title: string, content: string[]) { + const existing = wins().find((w) => w.kind === "notepad" && w.title === title); + if (existing) return raise(existing.id); + const [doc, setDoc] = createSignal({ + lines: content.length > 0 ? content : [""], + caret: { row: 0, col: 0 }, + }); + const [scroll, setScroll] = createSignal(0); + const [preedit, setPreedit] = createSignal<{ s: string; c: number } | null>(null); + const data: PadData = { kind: "notepad", doc, setDoc, scroll, setScroll, preedit, setPreedit }; + const w = createWin({ + kind: "notepad", + title, + icon: "icons/notepad-16.svg", + geo: cascadePos(wins().length, vp().w, vp().h, 400, 300), + minW: 220, + minH: 140, + menus: [ + { + label: "File", + width: measure("File") + 12, + items: [ + { label: "New", act: () => setDoc({ lines: [""], caret: { row: 0, col: 0 } }) }, + { sep: true, label: "" }, + { label: "Exit", act: () => closeWin(w.id) }, + ], + }, + { + label: "Edit", + width: measure("Edit") + 12, + items: [ + { label: "Time/Date", shortcut: "F5", act: () => insertTimeDate(w) }, + { sep: true, label: "" }, + { label: "Word Wrap", disabled: true }, + ], + }, + { + label: "Help", + width: measure("Help") + 12, + items: [{ label: "About PocketJS 98", act: openAbout }], + }, + ], + data, + }); + addWin(w); + } + + function openMines() { + const existing = wins().find((w) => w.kind === "mines"); + if (existing) return raise(existing.id); + const [board, setBoard] = createSignal(newMines((virtualNow() * 1000) | 0), { + equals: false, + }); + const [held, setHeld] = createSignal(-1); + const [smileyHeld, setSmileyHeld] = createSignal(false); + const [elapsed, setElapsed] = createSignal(0); + const data: MinesData = { + kind: "mines", + board, + setBoard, + held, + setHeld, + smileyHeld, + setSmileyHeld, + elapsed, + setElapsed, + }; + const w = createWin({ + kind: "mines", + title: "Minesweeper", + icon: "icons/mines-16.svg", + geo: { ...cascadePos(wins().length, vp().w, vp().h, MINES_GEO.w, MINES_GEO.h) }, + buttons: ["min", "close"], + resizable: false, + menus: [ + { + label: "Game", + width: measure("Game") + 12, + items: [ + { label: "New", shortcut: "F2", act: () => minesNew(w) }, + { sep: true, label: "" }, + { label: "Exit", act: () => closeWin(w.id) }, + ], + }, + { + label: "Help", + width: measure("Help") + 12, + items: [{ label: "About PocketJS 98", act: openAbout }], + }, + ], + data, + }); + addWin(w); + } + + function minesNew(w: WinCtl) { + const d = w.data as MinesData; + d.setBoard(newMines((virtualNow() * 1000) | 0)); + d.setElapsed(0); + minesStart = 0; + } + + function openFolder(title: string, icon: string, rows: FolderRow[], geoW = 420, geoH = 280) { + const existing = wins().find((w) => w.kind === "folder" && w.title === title); + if (existing) return raise(existing.id); + const [selected, setSelected] = createSignal(-1); + const data: FolderData = { kind: "folder", rows, selected, setSelected }; + const w = createWin({ + kind: "folder", + title, + icon, + geo: cascadePos(wins().length, vp().w, vp().h, geoW, geoH), + minW: 260, + minH: 160, + data, + }); + addWin(w); + } + + function openMyComputer() { + openFolder("My Computer", "icons/computer-16.svg", [ + { + icon: "icons/drive-16.svg", + name: "(C:)", + size: "", + type: "Local Disk", + open: () => openDriveC(), + }, + { icon: "icons/cdrom-16.svg", name: "(D:)", size: "", type: "CD-ROM Disc" }, + { icon: "icons/folder-16.svg", name: "Control Panel", size: "", type: "System Folder" }, + { icon: "icons/folder-16.svg", name: "Printers", size: "", type: "System Folder" }, + ]); + } + + function openDriveC() { + openFolder("(C:)", "icons/drive-16.svg", [ + { icon: "icons/folder-16.svg", name: "Program Files", size: "", type: "File Folder" }, + { icon: "icons/folder-16.svg", name: "Windows", size: "", type: "File Folder" }, + { icon: "icons/file-16.svg", name: "AUTOEXEC.BAT", size: "1 KB", type: "MS-DOS Batch File" }, + { icon: "icons/file-16.svg", name: "CONFIG.SYS", size: "1 KB", type: "System file" }, + { + icon: "icons/notepad-16.svg", + name: "README.TXT", + size: "2 KB", + type: "Text Document", + open: () => openNotepad("README.TXT - Notepad", WELCOME), + }, + ]); + } + + function openDocuments() { + openFolder("My Documents", "icons/folder-16.svg", [ + { + icon: "icons/notepad-16.svg", + name: "welcome.txt", + size: "1 KB", + type: "Text Document", + open: () => openNotepad("welcome.txt - Notepad", WELCOME), + }, + ]); + } + + function openRecycle() { + openFolder("Recycle Bin", "icons/recycle-16.svg", []); + } + + function openAbout() { + const existing = wins().find((w) => w.kind === "about"); + if (existing) return raise(existing.id); + const [armed, setArmed] = createSignal(null); + const data: AboutData & { setArmed: (s: string | null) => void } = { + kind: "about", + armed, + setArmed, + }; + const w = createWin({ + kind: "about", + title: "About PocketJS 98", + icon: "icons/computer-16.svg", + geo: centered(ABOUT_GEO.w, ABOUT_GEO.h), + buttons: ["close"], + resizable: false, + data, + }); + addWin(w); + } + + function openShutdown() { + const existing = wins().find((w) => w.kind === "shutdown"); + if (existing) return raise(existing.id); + const [choice, setChoice] = createSignal(0); + const [armed, setArmed] = createSignal(null); + const data: ShutdownData & { setArmed: (s: string | null) => void } = { + kind: "shutdown", + choice, + setChoice, + armed, + setArmed, + }; + const w = createWin({ + kind: "shutdown", + title: "Shut Down Windows", + icon: "icons/shutdown-16.svg", + geo: centered(SHUTDOWN_GEO.w, SHUTDOWN_GEO.h), + buttons: ["close"], + resizable: false, + data, + }); + addWin(w); + } + + function centered(w: number, h: number): Geo { + return { + x: Math.max(0, Math.round((vp().w - w) / 2)), + y: Math.max(0, Math.round((vp().h - TASK_H - h) / 2)), + w, + h, + }; + } + + function restartSession() { + for (const w of wins().slice()) closeWin(w.id); + boot(); + } + + function insertTimeDate(w: WinCtl) { + const d = w.data as PadData; + const t = new Date(epoch + (virtualNow() - epochAt) * 1000); + const stamp = `${pad2(t.getHours())}:${pad2(t.getMinutes())} ${pad2(t.getMonth() + 1)}/${pad2(t.getDate())}/${t.getFullYear()}`; + d.setDoc(insertText(d.doc(), stamp)); + } + + // ---- desktop icons + start menu ----------------------------------------- + + const icons: DeskIcon[] = [ + { icon: "icons/computer.svg", label: "My Computer", open: openMyComputer }, + { icon: "icons/documents.svg", label: "My Documents", open: openDocuments }, + { icon: "icons/recycle.svg", label: "Recycle Bin", open: openRecycle }, + { icon: "icons/notepad.svg", label: "Notepad", open: () => openNotepad("Untitled - Notepad", [""]) }, + { icon: "icons/mines.svg", label: "Minesweeper", open: openMines }, + ]; + const ICON_X = 8; + const ICON_W = 74; + const ICON_STRIDE = 58; + const ICON_CELL_H = 48; + + function iconAt(x: number, y: number): number { + if (x < ICON_X || x >= ICON_X + ICON_W) return -1; + const rel = y - 8; + if (rel < 0 || rel % ICON_STRIDE >= ICON_CELL_H) return -1; + const i = Math.floor(rel / ICON_STRIDE); + return i >= 0 && i < icons.length ? i : -1; + } + + const startItems: () => PopupItem[] = () => [ + { + label: "Programs", + icon: "icons/folder-16.svg", + sub: [ + { label: "Notepad", icon: "icons/notepad-16.svg", act: () => openNotepad("Untitled - Notepad", [""]) }, + { label: "Minesweeper", icon: "icons/mines-16.svg", act: openMines }, + ], + }, + { + label: "Documents", + icon: "icons/folder-16.svg", + sub: [ + { label: "welcome.txt", icon: "icons/notepad-16.svg", act: () => openNotepad("welcome.txt - Notepad", WELCOME) }, + ], + }, + { label: "Settings", icon: "icons/settings-16.svg", disabled: true }, + { label: "Find", icon: "icons/find-16.svg", disabled: true }, + { label: "Help", icon: "icons/help-16.svg", act: openAbout }, + { label: "Run...", icon: "icons/run-16.svg", disabled: true }, + { sep: true, label: "" }, + { label: "Shut Down...", icon: "icons/shutdown-16.svg", act: openShutdown }, + ]; + + const START_ROW = 26; + const START_SEP = 8; + const startH = () => + 2 + startItems().reduce((a, it) => a + (it.sep ? START_SEP : START_ROW), 0); + const startY = () => vp().h - TASK_H - startH(); + + function startItemAt(x: number, y: number): number { + const items = startItems(); + if (x < 2 + 25 || x >= 2 + 182 - 1) return -1; + let oy = startY() + 1; + for (let i = 0; i < items.length; i++) { + const h = items[i].sep ? START_SEP : START_ROW; + if (y >= oy && y < oy + h) return items[i].sep ? -1 : i; + oy += h; + } + return -1; + } + + function buildPopup(x: number, y: number, items: PopupItem[]): Popup { + let w = 0; + for (const it of items) { + if (it.sep) continue; + w = Math.max( + w, + 26 + measure(it.label) + (it.shortcut ? 20 + measure(it.shortcut) : 0) + (it.sub ? 14 : 0) + 14, + ); + } + const h = 2 + items.reduce((a, it) => a + (it.sep ? START_SEP : 18), 0); + return { + x: Math.min(x, vp().w - w - 2), + y: Math.min(y, vp().h - TASK_H - h), + w: Math.max(w, 120), + items, + }; + } + + function popupItemAt(p: Popup, x: number, y: number): number { + if (x < p.x + 1 || x >= p.x + p.w - 1) return -1; + let oy = p.y + 1; + for (let i = 0; i < p.items.length; i++) { + const h = p.items[i].sep ? START_SEP : 18; + if (y >= oy && y < oy + h) return p.items[i].sep ? -1 : i; + oy += h; + } + return -1; + } + + function closeMenus() { + setStartOpen(false); + setStartFly(null); + setPopup(null); + setStartHover(-1); + setFlyHover(-1); + setPopupHover(-1); + for (const w of wins()) w.setOpenMenu(-1); + } + + // ---- helpers ------------------------------------------------------------ + + function chromeOpts(w: WinCtl) { + return { + buttons: w.buttons, + resizable: w.resizable, + maximized: w.maximized(), + menuWidths: (w.menus ?? []).map((m) => m.width), + }; + } + + /** Topmost visible window under the point, with its chrome region. */ + function hitWindows(x: number, y: number): { win: WinCtl; region: Region } | null { + for (let i = stack.length - 1; i >= 0; i--) { + const w = byId(stack[i]); + if (!w || w.minimized()) continue; + const region = hitRegion(w.geo(), chromeOpts(w), x, y); + if (region) return { win: w, region }; + } + return null; + } + + function isDblClick(key: string): boolean { + const now = virtualNow(); + const hit = + lastClick.key === key && + now - lastClick.t < 0.4 && + Math.abs(mx - lastClick.x) < 4 && + Math.abs(my - lastClick.y) < 4; + lastClick = hit ? { key: "", t: -1, x: 0, y: 0 } : { key, t: now, x: mx, y: my }; + return hit; + } + + function pad2(n: number): string { + return String(n).padStart(2, "0"); + } + + function sendCursor(k: CursorKind) { + if (svc && k !== lastCursor) { + lastCursor = k; + svc.send({ t: "cursor", k }); + } + } + + // ---- input routing ------------------------------------------------------ + + function onPrimaryDown(shift: boolean) { + // Open menus swallow the click (classic: outside-click only dismisses). + if (startOpen()) { + const fly = startFly(); + if (fly) { + const i = popupItemAt(fly.popup, mx, my); + if (i >= 0) { + const item = fly.popup.items[i]; + if (!item.disabled && item.act) { + item.act(); + closeMenus(); + } + return; + } + } + const i = startItemAt(mx, my); + if (i >= 0) { + const item = startItems()[i]; + if (item.sub) return; // hover already opened the flyout; stay open + if (!item.disabled && item.act) { + item.act(); + closeMenus(); + } + return; + } + // Outside click (the Start button included) dismisses and is consumed. + closeMenus(); + return; + } + const pop = popup(); + if (pop) { + const i = popupItemAt(pop.popup, mx, my); + closeMenus(); + if (i >= 0) { + const item = pop.popup.items[i]; + if (!item.disabled && item.act) item.act(); + } + return; + } + + // Taskbar. + if (my >= vp().h - TASK_H) { + if (mx >= 2 && mx < 58) { + setStartOpen(!startOpen()); + return; + } + const entry = taskEntryAt(mx, my); + if (entry !== -1) { + const id = wins()[entry].id; + const w = byId(id); + if (!w) return; + if (focusId() === id && !w.minimized()) minimize(id); + else raise(id); + } + return; + } + + // Windows, top-down. + const hit = hitWindows(mx, my); + if (hit) { + const { win: w, region } = hit; + raise(w.id); + if (region.kind === "button") { + drag = { type: "capbtn", id: w.id, btn: region.button }; + w.setPressedBtn(region.button); + return; + } + if (region.kind === "caption") { + if (isDblClick(`cap:${w.id}`)) { + toggleMax(w); + return; + } + if (!w.maximized()) { + drag = { type: "move", id: w.id, sx: mx, sy: my, orig: w.geo() }; + } + return; + } + if (region.kind === "resize") { + drag = { type: "resize", id: w.id, dir: region.dir, sx: mx, sy: my, orig: w.geo() }; + return; + } + if (region.kind === "menu") { + const open = w.openMenu() === region.index ? -1 : region.index; + w.setOpenMenu(open); + if (open >= 0 && w.menus) { + const mxs = w.menus.slice(0, open).reduce((a, m) => a + m.width, 0); + const g = w.geo(); + setPopup({ + popup: buildPopup(g.x + FRAME + mxs, g.y + FRAME + 18 + 1 + 18, w.menus[open].items), + winId: w.id, + }); + } + return; + } + routeContentDown(w, region.cx, region.cy, shift); + return; + } + + // Desktop: select an icon (double-click opens), deactivate windows. + const icon = iconAt(mx, my); + setIconSel(icon); + setFocusId(-1); + if (icon >= 0 && isDblClick(`icon:${icon}`)) icons[icon].open(); + } + + function routeContentDown(w: WinCtl, cx: number, cy: number, _shift: boolean) { + if (w.kind === "notepad") { + const d = w.data as PadData; + const doc = d.doc(); + const row = Math.max( + 0, + Math.min(doc.lines.length - 1, Math.floor((cy - 3 + d.scroll()) / PAD_LINE_H)), + ); + const col = colFromX(doc.lines[row], cx - 3, (s) => measure(s)); + d.setDoc({ lines: doc.lines, caret: { row, col } }); + return; + } + if (w.kind === "mines") { + const d = w.data as MinesData; + const hit = minesHit(cx, cy); + if (hit?.type === "cell") { + drag = { type: "minehold", id: w.id }; + d.setHeld(hit.i); + } else if (hit?.type === "smiley") { + drag = { type: "smiley", id: w.id }; + d.setSmileyHeld(true); + } + return; + } + if (w.kind === "folder") { + const d = w.data as FolderData; + const row = folderRowAt(cy, d.rows.length); + d.setSelected(row); + if (row >= 0 && isDblClick(`row:${w.id}:${row}`)) d.rows[row].open?.(); + return; + } + if (w.kind === "about") { + const g = w.geo(); + const hit = aboutHit(g.w - FRAME * 2, g.h - FRAME - contentTop({ menuWidths: [] }), cx, cy); + if (hit === "ok") { + drag = { type: "dialogbtn", id: w.id, tag: "ok" }; + (w.data as AboutData & { setArmed: (s: string | null) => void }).setArmed("ok"); + } + return; + } + if (w.kind === "shutdown") { + const g = w.geo(); + const d = w.data as ShutdownData & { setArmed: (s: string | null) => void }; + const hit = shutdownHit(g.w - FRAME * 2, g.h - FRAME - contentTop({ menuWidths: [] }), cx, cy); + if (hit === "radio0") d.setChoice(0); + else if (hit === "radio1") d.setChoice(1); + else if (hit === "ok" || hit === "cancel") { + drag = { type: "dialogbtn", id: w.id, tag: hit }; + d.setArmed(hit); + } + } + } + + function onRightDown() { + if (startOpen() || popup()) { + closeMenus(); + return; + } + const hit = hitWindows(mx, my); + if (hit) { + const { win: w, region } = hit; + if (region.kind === "content" && w.kind === "mines") { + raise(w.id); + const d = w.data as MinesData; + const cell = minesHit(region.cx, region.cy); + if (cell?.type === "cell") { + d.setBoard(toggleFlag(d.board(), cell.i)); + } + return; + } + if (region.kind === "caption") { + raise(w.id); + setPopup({ + popup: buildPopup(mx, my, [ + { label: "Minimize", act: () => minimize(w.id) }, + { + label: w.maximized() ? "Restore" : "Maximize", + disabled: !w.resizable, + act: () => toggleMax(w), + }, + { sep: true, label: "" }, + { label: "Close", shortcut: "Alt+F4", act: () => closeWin(w.id) }, + ]), + winId: w.id, + }); + } + return; + } + if (my < vp().h - TASK_H) { + const icon = iconAt(mx, my); + setIconSel(icon); + setPopup({ + popup: buildPopup(mx, my, [ + { label: "Arrange Icons", act: () => {} }, + { label: "Refresh", act: () => {} }, + { sep: true, label: "" }, + { label: "New Text Document", act: () => openNotepad("Untitled - Notepad", [""]) }, + { sep: true, label: "" }, + { label: "Properties", disabled: true }, + ]), + }); + } + } + + function onMove() { + // Menu hover states. + if (startOpen()) { + const i = startItemAt(mx, my); + setStartHover(i); + if (i >= 0) { + const item = startItems()[i]; + if (item.sub) { + if (startFly()?.index !== i) { + let oy = startY() + 1; + for (let k = 0; k < i; k++) oy += startItems()[k].sep ? START_SEP : START_ROW; + setStartFly({ index: i, popup: buildPopup(2 + 182 - 3, oy, item.sub) }); + setFlyHover(-1); + } + } else if (startFly()) { + setStartFly(null); + } + } + const fly = startFly(); + if (fly) setFlyHover(popupItemAt(fly.popup, mx, my)); + } + const pop = popup(); + if (pop) { + setPopupHover(popupItemAt(pop.popup, mx, my)); + // A menu-bar dropdown follows the hovered menu title (classic). + if (pop.winId !== undefined) { + const w = byId(pop.winId); + if (w?.menus) { + const r = hitRegion(w.geo(), chromeOpts(w), mx, my); + if (r?.kind === "menu" && r.index !== w.openMenu()) { + w.setOpenMenu(r.index); + const mxs = w.menus.slice(0, r.index).reduce((a, m) => a + m.width, 0); + const g = w.geo(); + setPopup({ + popup: buildPopup(g.x + FRAME + mxs, g.y + FRAME + 18 + 1 + 18, w.menus[r.index].items), + winId: w.id, + }); + } + } + } + } + + // Drags. + if (drag?.type === "move") { + const w = byId(drag.id); + if (w) { + const g = clampMove( + { ...drag.orig, x: drag.orig.x + (mx - drag.sx), y: drag.orig.y + (my - drag.sy) }, + vp().w, + vp().h, + ); + w.setGeo(g); + } + return; + } + if (drag?.type === "resize") { + const w = byId(drag.id); + if (w) { + w.setGeo( + resizeGeo(drag.orig, drag.dir, mx - drag.sx, my - drag.sy, w.minW, w.minH), + ); + } + sendCursor(cursorForDir(drag.dir)); + return; + } + if (drag?.type === "capbtn") { + const w = byId(drag.id); + if (w) { + const r = hitRegion(w.geo(), chromeOpts(w), mx, my); + w.setPressedBtn(r?.kind === "button" && r.button === drag.btn ? drag.btn : null); + } + return; + } + if (drag?.type === "minehold") { + const w = byId(drag.id); + if (w) { + const d = w.data as MinesData; + const r = hitRegion(w.geo(), chromeOpts(w), mx, my); + const cell = r?.kind === "content" ? minesHit(r.cx, r.cy) : null; + d.setHeld(cell?.type === "cell" ? cell.i : -1); + } + return; + } + if (drag?.type === "dialogbtn") { + const w = byId(drag.id); + if (w) { + const r = hitRegion(w.geo(), chromeOpts(w), mx, my); + const g = w.geo(); + const cw = g.w - FRAME * 2; + const chh = g.h - FRAME - contentTop({ menuWidths: [] }); + let over: string | null = null; + if (r?.kind === "content") { + over = + w.kind === "about" ? aboutHit(cw, chh, r.cx, r.cy) : shutdownHit(cw, chh, r.cx, r.cy); + } + (w.data as { setArmed: (s: string | null) => void }).setArmed( + over === drag.tag ? drag.tag : null, + ); + } + return; + } + + // Hover cursor shape. + let k: CursorKind = "default"; + const hover = hitWindows(mx, my); + if (hover) { + if (hover.region.kind === "resize") k = cursorForDir(hover.region.dir); + else if (hover.region.kind === "content" && hover.win.kind === "notepad") k = "text"; + } + sendCursor(k); + } + + function onPrimaryUp() { + const d = drag; + drag = null; + if (!d) return; + if (d.type === "capbtn") { + const w = byId(d.id); + if (w && w.pressedBtn() === d.btn) { + w.setPressedBtn(null); + if (d.btn === "close") closeWin(d.id); + else if (d.btn === "min") minimize(d.id); + else if (d.btn === "max") toggleMax(w); + } else w?.setPressedBtn(null); + return; + } + if (d.type === "minehold") { + const w = byId(d.id); + if (w) { + const md = w.data as MinesData; + const i = md.held(); + md.setHeld(-1); + if (i >= 0) { + const was = md.board().phase; + md.setBoard(reveal(md.board(), i)); + if (was === "ready" && md.board().phase === "playing") minesStart = virtualNow(); + } + } + return; + } + if (d.type === "smiley") { + const w = byId(d.id); + if (w) { + (w.data as MinesData).setSmileyHeld(false); + const r = hitRegion(w.geo(), chromeOpts(w), mx, my); + if (r?.kind === "content" && minesHit(r.cx, r.cy)?.type === "smiley") minesNew(w); + } + return; + } + if (d.type === "dialogbtn") { + const w = byId(d.id); + if (w) { + const data = w.data as { setArmed: (s: string | null) => void }; + const armed = (w.data as AboutData).armed(); + data.setArmed(null); + if (armed === d.tag) { + if (w.kind === "about") closeWin(w.id); + else if (w.kind === "shutdown") { + const choice = (w.data as ShutdownData).choice(); + if (d.tag === "cancel") closeWin(w.id); + else if (choice === 0) svc?.send({ t: "quit" }); + else restartSession(); + } + } + } + return; + } + if (d.type === "resize") sendCursor("default"); + } + + function onKey(ev: HostEvent) { + const k = ev.k ?? ""; + if (k === "Escape") { + if (ev.ctl) { + // Ctrl+Esc toggles the Start menu (the classic pre-Win-key chord). + setPopup(null); + setStartOpen(!startOpen()); + return; + } + if (startOpen() || popup()) closeMenus(); + return; + } + if (k === "Tab" && (ev.alt || ev.ctl)) { + cycleWindows(); + return; + } + if (k === "F4" && ev.alt) { + const w = focused(); + if (w) closeWin(w.id); + else openShutdown(); + return; + } + const w = focused(); + if (!w) return; + if (w.kind === "mines" && k === "F2") { + minesNew(w); + return; + } + if (w.kind === "notepad") { + if (k === "F5") { + insertTimeDate(w); + return; + } + const d = w.data as PadData; + const doc = d.doc(); + switch (k) { + case "Enter": + d.setDoc(insertText(doc, "\n")); + break; + case "Backspace": + d.setDoc(backspace(doc)); + break; + case "Delete": + d.setDoc(del(doc)); + break; + case "Tab": + d.setDoc(insertText(doc, " ")); + break; + case "Left": + case "Right": + case "Up": + case "Down": + case "Home": + case "End": + d.setDoc({ lines: doc.lines, caret: moveCaret(doc, k as CaretMove) }); + break; + default: + return; + } + scrollCaretIntoView(w); + return; + } + if (w.kind === "shutdown" && k === "Enter") { + const choice = (w.data as ShutdownData).choice(); + if (choice === 0) svc?.send({ t: "quit" }); + else restartSession(); + return; + } + if (w.kind === "about" && k === "Enter") closeWin(w.id); + } + + function padViewH(w: WinCtl): number { + return w.geo().h - FRAME - contentTop(chromeOpts(w)) - 2; + } + + function scrollCaretIntoView(w: WinCtl) { + const d = w.data as PadData; + const y = d.doc().caret.row * PAD_LINE_H; + const viewH = padViewH(w); + if (y - d.scroll() < 0) d.setScroll(Math.max(0, y)); + else if (y - d.scroll() > viewH - PAD_LINE_H) d.setScroll(y - viewH + PAD_LINE_H); + } + + function typeInto(w: WinCtl, s: string) { + const d = w.data as PadData; + d.setDoc(insertText(d.doc(), s)); + scrollCaretIntoView(w); + } + + // ---- taskbar ------------------------------------------------------------ + + const taskEntries = (): TaskEntry[] => + wins() + .filter((w) => w.kind !== "shutdown") + .map((w) => ({ id: w.id, title: w.title, icon: w.icon })); + const taskButtonW = () => { + const n = Math.max(1, taskEntries().length); + return Math.min(160, Math.floor((vp().w - 70 - 60 - n * 3) / n)); + }; + + function taskEntryAt(x: number, y: number): number { + if (y < vp().h - TASK_H + 3) return -1; + const entries = taskEntries(); + const w = taskButtonW(); + const x0 = 2 + 54 + 3 + 1 + 3; + for (let i = 0; i < entries.length; i++) { + const bx = x0 + i * (w + 3); + if (x >= bx && x < bx + w) { + return wins().findIndex((win) => win.id === entries[i].id); + } + } + return -1; + } + + // ---- frame pump --------------------------------------------------------- + + function handleEvent(ev: HostEvent) { + switch (ev.t) { + case "hello": { + setVp({ w: ev.w ?? 800, h: ev.h ?? 600 }); + epoch = ev.epoch ?? 0; + epochAt = virtualNow(); + break; + } + case "resize": { + const w = ev.w ?? vp().w; + const h = ev.h ?? vp().h; + setVp({ w, h }); + for (const win of wins()) { + if (win.maximized()) win.setGeo(maximizedGeo(w, h)); + else win.setGeo(clampMove(win.geo(), w, h)); + } + break; + } + case "mouse": { + if (ev.b === 2) { + if (ev.d) { + mx = ev.x ?? mx; + my = ev.y ?? my; + onRightDown(); + } + break; + } + mx = ev.x ?? mx; + my = ev.y ?? my; + const down = ev.d ?? false; + if (down && !prevDown) onPrimaryDown(ev.sh ?? false); + else if (!down && prevDown) { + onMove(); + onPrimaryUp(); + } else onMove(); + prevDown = down; + break; + } + case "key": + onKey(ev); + break; + case "ch": { + const w = focused(); + if (w?.kind === "notepad" && ev.s) typeInto(w, ev.s); + break; + } + case "paste": { + const w = focused(); + if (w?.kind === "notepad" && ev.text) typeInto(w, ev.text); + break; + } + case "ime": { + const w = focused(); + if (w?.kind === "notepad") { + const d = w.data as PadData; + d.setPreedit(ev.s ? { s: ev.s, c: ev.c ?? ev.s.length } : null); + } + break; + } + case "scroll": { + const hover = hitWindows(mx, my); + if (hover?.win.kind === "notepad") { + const d = hover.win.data as PadData; + const contentH = d.doc().lines.length * PAD_LINE_H + 6; + const maxY = Math.max(0, contentH - padViewH(hover.win)); + d.setScroll(Math.max(0, Math.min(maxY, d.scroll() + (ev.dy ?? 0)))); + } + break; + } + } + } + + function boot() { + openNotepad("welcome.txt - Notepad", WELCOME); + if (!svc) { + // Standalone (sim/goldens): a lively static arrangement. + openMines(); + openMyComputer(); + } + } + + boot(); + + onFrame(() => { + if (svc) for (const ev of svc.poll()) handleEvent(ev); + + // Taskbar clock (minute precision, anchored at the hello epoch). + if (epoch > 0) { + const t = new Date(epoch + (virtualNow() - epochAt) * 1000); + const s = `${pad2(t.getHours())}:${pad2(t.getMinutes())}`; + if (s !== clock()) setClock(s); + } + + // Minesweeper timer. + const mw = wins().find((w) => w.kind === "mines"); + if (mw) { + const d = mw.data as MinesData; + if (d.board().phase === "playing" && minesStart > 0) { + const e = Math.min(999, Math.floor(virtualNow() - minesStart)); + if (e !== d.elapsed()) d.setElapsed(e); + } + } + + // IME candidate window docking: report the focused notepad caret. + const fw = focused(); + if (svc && fw?.kind === "notepad") { + const d = fw.data as PadData; + const g = fw.geo(); + const doc = d.doc(); + const line = doc.lines[doc.caret.row] ?? ""; + const x = g.x + FRAME + 4 + measure(line.slice(0, doc.caret.col)); + const y = g.y + contentTop(chromeOpts(fw)) + 3 + doc.caret.row * PAD_LINE_H - d.scroll(); + if (x !== lastCaret.x || y !== lastCaret.y) { + lastCaret = { x, y, h: PAD_LINE_H }; + svc.send({ t: "caret", x, y, h: PAD_LINE_H }); + } + } + }); + + // ---- render ------------------------------------------------------------- + + return ( + + + + {(w) => ( + + + + )} + + + + + {(fly) => } + + + {(p) => } + + + ); +} + +function WinContent(props: { win: WinCtl; active: boolean }) { + const w = props.win; + switch (w.kind) { + case "notepad": + return ; + case "mines": + return ; + case "folder": + return ( + + ); + case "about": + return ; + case "shutdown": + return ; + } +} diff --git a/apps/desk98/chrome.tsx b/apps/desk98/chrome.tsx new file mode 100644 index 00000000..21e21f99 --- /dev/null +++ b/apps/desk98/chrome.tsx @@ -0,0 +1,352 @@ +// apps/desk98/chrome.tsx — presentational Windows 98 chrome. Every component +// here only paints; the compositor (app.tsx) owns hit testing and routes all +// pointer/keyboard input itself off the svc mouse stream, so nothing in this +// file registers a handler. Geometry mirrors wm.ts via theme.ts constants. +// +// Class strings are FULL literals throughout — the style table compiles at +// build time and template-interpolated fragments are a compile error, so the +// bevel recipes repeat verbatim instead of riding shared constants. + +import { For, Show, type JSX } from "solid-js"; +import { Image, Text, View } from "@pocketjs/framework/components"; +import { CAPTION_ACTIVE, CAPTION_INACTIVE, FONT, FONT_B, FONT_XL } from "./theme.ts"; +import type { CaptionButton } from "./wm.ts"; +import type { DeskIcon, Popup, TaskEntry, WinCtl } from "./state.ts"; + +/** W95FA text. Slots 19/20/21 ride the style prop — the class table never + * sees them (baked per-app via pak.json, docs in gen-assets.ts). */ +export function T98(props: { + class?: string; + bold?: boolean; + xl?: boolean; + children: JSX.Element; +}): JSX.Element { + return ( + + {props.children} + + ); +} + +const BTN_ICON: Record = { + min: "icons/cap-min.svg", + max: "icons/cap-max.svg", + close: "icons/cap-close.svg", +}; + +/** One window: raised frame, caption gradient, controls, menu bar, content. + * Position/size ride the style prop — translate moves are paint-only, and + * zIndex raises without reordering siblings (a reorder would rebuild the + * whole layout tree). */ +export function Window98(props: { + win: WinCtl; + active: boolean; + children: JSX.Element; +}): JSX.Element { + const w = props.win; + return ( + + + + + + {w.title} + + + + + + + + {(menu, i) => ( + + + {menu.label} + + + )} + + + + {props.children} + + ); +} + +function CaptionButtons(props: { win: WinCtl }): JSX.Element { + // Render mirrors wm.ts captionButtonXs: min/zoom adjacent, close 2px apart. + return ( + + + {(btn, i) => { + const pressed = () => props.win.pressedBtn() === btn; + const gapped = btn === "close" && i() > 0; + return ( + + + + ); + }} + + + ); +} + +/** The taskbar: Start, one button per window, the sunken clock tray. */ +export function Taskbar(props: { + entries: TaskEntry[]; + activeId: number; + startOpen: boolean; + clock: string; + buttonW: number; +}): JSX.Element { + return ( + + + + Start + + + + + {(entry) => ( + + + + {entry.title} + + + )} + + + + {props.clock} + + + ); +} + +/** Generic popup menu panel (context menus, dropdowns, start flyouts). */ +export function PopupPanel(props: { popup: Popup; hover: number }): JSX.Element { + return ( + + + {(item, i) => ( + + + + + } + > + + }> + + + + + {item.label} + + + + + {item.shortcut ?? ""} + + + + + + + + )} + + + ); +} + +/** The Start menu: navy banner + 26px rows (flyouts render as PopupPanels). */ +export function StartMenu(props: { + x: number; + y: number; + h: number; + items: Popup["items"]; + hover: number; +}): JSX.Element { + return ( + + + {/* The box is laid out horizontally (wide enough that the label + never wraps) and rotated to vertical at paint time. rotate is + style-only: the class grammar's rotate-N takes no negative + arbitrary value, and a bad token would silently void the whole + literal. */} + + + PocketJS98 + + + + + + {(item, i) => ( + + + + + } + > + + + + + {item.label} + + + + + + + + )} + + + + ); +} + +/** Desktop icons: a left column of 32px art + label, teal behind. */ +export function DesktopIcons(props: { icons: DeskIcon[]; selected: number }): JSX.Element { + return ( + + + {(icon, i) => ( + + + + {icon.label} + + + )} + + + ); +} diff --git a/apps/desk98/fonts/w95fa-19.bin b/apps/desk98/fonts/w95fa-19.bin new file mode 100644 index 0000000000000000000000000000000000000000..1cef0a964b6d5c7c864a1327ce4efced5b578f9b GIT binary patch literal 15596 zcmd^Bg_hh#5R_w#V}~3@Gc&V&W@g40W@h~JymR%8dZt>dkwzRJa^UP@PO$tc=&tkn1_#Ems0iTERX5b5mZvnoD zx~;%PSZ@Qq1l$gM8Mp)Z3UDXzRp2h*Yrx&W*MWP0ZvgiK--P}?;9KasANV%n2Y~MY z4+7r>9s(`~9tJLf<0;@$oahK}8RAEQ%W=|Uz!kvbz?F!f0ImX_1g=K>6mSjjG;l5Y zo&m1Ixy}ODBYqCJ0r}^F8=-#zxC!x#z|Fu*z%9Vbz^%Y5z-_>*!0o8J2HXL>4%~_O z4d5>5+yw5%gl_@&V0|077kCG_4|o^2ALqXZJODrUfd{dE06YY}hrq+odk;8;_4~jh zzz={&fgb{o0Y3sB2Yw7Zf%;E?C$at%cnbI#@HEc-Iq(ed3*cGAzXYDc`YYgh`1u-m z0r78u7lGdbF9E*;UIu;-yaN3nfLG!7N8mNAe*#{|{?EW0z+Zqjq4z8B7V>@r-Y#a( z_#Jo$>py^ZaUcB&yodgO0qoZrx=R! zOjwiSIefajhLX!Xr*`5wI9NCvadVqcT$0Dm&pJ#{N2BSmkh$q1G8w2wMVuEgi)&ev zPLk!!BvC6k@V{z-Eagy@GCGyhnBHv*(=;yPL`0lKi1Vxv4m-?c%wdCMawyj^ eA z4w)co&Nqp;X^?ZUNtj4!=J??p*6DmO7J3{wA0~45c^K@)I6Wd^_kT}hSWoZ}dV1&b z1Ro^aeZq8E#DTgPireqjuJ+Uoe3`o^#-n4=?i26mjSTUu8eEXMRx0N;C+_5qO7pzg zR^%>B)aa0YCtp69F4_Kb3G7GKvTCfjoRk7DC%z~YaxvtR~pEt#M4yhcDbN;{KO?vCA zjt}Ndn=~NzZ%C`%>Qh@Q_?QOioL#7WnC$~VI+e7cXHa>rgM2C;68e$!0kd`}sg>p# zrH?mJB-IqB6rv`ZTXPP_lRRDl|M6D7`+QMze-dr}#m86q2_^;l@@jEK=ht8+8r4`s zU+ORzl7#5Oa5}CN@k#t1wewh1EN4}gnYM`e9zdEwGAEIr=ZLbXnd@JgP%n}>7S|%u zr>lUhX(ve? z>g8t=w75}M9z=48drr;A0=4T;-J?+X*vYZwa@ZDGDX)u{nMj;P6K**r#gj(I*htu@ z4fEWRtx~3Y#7*B?XD-McjJo^<4&jC;h4)?W7)M%{hJkM^dd+4;P0w?ah62c%N&ko*FS)Gcys(>{&I~bFaCn zIE0B>P8&pRjv8ywp_JihWj^Xpu#H!*X~>TwPx{ncNl5c>>Ch_XNxs(P@T7^vhH59a zU6^zF@TMMhd)%vTUt*jy3Es1Butfjqy6daDCQiqZ!ceTqnaFjM=9!i{rO6|uPXqk> zUnq3totkDQM`!R8ocU{orF#js@iKK-pwFTl51fO;sO4cjm1>X=`rJ2*S8CN@5Y)i9 zNanKqpik&cwbDMsD_r)*dBv%vwNpJgK2Gn}8Zw7ov0Y@4hzpw&S`Z~S z?96hL)X;m}E%BqCGI^XdnAVQlzo{u(AWJ9s#e+ Be175tk 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 bn!Vqx<6jA72R^S+d}dulyhTjwJW}8@)-SQr literal 0 HcmV?d00001 diff --git a/apps/desk98/fonts/w95fa-20.bin b/apps/desk98/fonts/w95fa-20.bin new file mode 100644 index 0000000000000000000000000000000000000000..40e22466add7c647a7a47c9823f45e85eb135b0e GIT binary patch literal 16831 zcmeHJg_0dN5ZsH|v4akinVB(WW@fv>%#44Ya(blIs7LR#r-gTMx$<&#J*}DPX^q|` zId=5K;ThG#YT?593l>dGREw*sny6-0OMp*RGpeP)8P%+68E|Gbw^|OIRn4hZ0B2Y8 zs+GVw_-7SxE-F_8=b>^9a6WJ?@M(0c11><_df-BwZvZ|6z7hB=`ZfWdgW_i3^ElrE zd;xu1fiHq@112z5_f6Tnsz}Tmq+uflG0dBfw=iKMGuq8y*9$03HXf#Q6!}D&R@rYMh?}t^u9~ zuEpFlz;(FSS>Sq{p95|{{dwR<=wASC!udtuX5b~@7T{&zR^S!jHsDp@cJy5X?f_l~ z?!@^G;4bLg1n$OWZvpp!-v;gl-U03d-UaT*{qF$}z|Vc)yWkIi2ch>6cnEs$0S|+} z4?F_=0C*JmA@CURBj9o1$G{Wl{{(mv{8Qj5;Ag+*wclgLv_}|sg#1AsVpGZ=# z&);oWV)kXEO{S2@gJ`KCdit0I@ENT#@!zR%3(iPGzn;>&$0-#iM&`ZMDx7d-q)t6X z!3Y>>DIIGYa3!58zN=e<-OHD3_(vU>ta-A zNV6eb)CTThKO9@5W6an=g|{1{pS*OTH&bTRI$hPsJ!Zt*j)?n<;lD@nO=WcYe>l2< zzW?4CRs2s+hIn|RktG8rx^0aAgCSU{_6b!^#37}Tkv38ZAp??0 zvNTRJw#?pu>PtfwFw#0uloFD2Jt7eMcuuJ|u9N<#mRK4vVXw*#N@Mn!Ux>+IXuN{S zS z^1bHoXdIKz#F_yjJG~p@u9CReHZMnnSv*g;ieuYhOqep0X(04M{t^A}Fve-hFLw(c zNY4MnsLr^uphHt@(QqPS($V>2nwc0~Cc&T^nUGlLrD{P#x>yxmy`@CTnn=%{E=K*c z)t$~{a2in8rt)nvT5C3W>fJcDGn29?HX==fRJS}JGl|g98FQm@ zgQ3#fsZAxsE!%I^Z8WS^M#{%&_)v-LF+)&ZXTtC{=vqU+8=fO-0OL>-%RH}Li<$Zx zBWQ^Bv@xi)GPPFrLV-CPjELnRrnB1wuD`WW8fvB&k~Rjl);X=2n-hk0B=HU+0H)e8 zc1M~~D~*Bk8O2Z=1RQ2abkphU( cq?vALu%?Dxqn_3*<)p4D36vm;F;y3b(oYH$ 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 literal 0 HcmV?d00001 diff --git a/apps/desk98/fonts/w95fa-21.bin b/apps/desk98/fonts/w95fa-21.bin new file mode 100644 index 0000000000000000000000000000000000000000..f1aa1d6f343bc2285d3a14289f84d1e67a640798 GIT binary patch literal 53216 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 MAOa#F0&@iZA2A;;cK`qY literal 0 HcmV?d00001 diff --git a/apps/desk98/gen-assets.ts b/apps/desk98/gen-assets.ts new file mode 100644 index 00000000..4d3a5614 --- /dev/null +++ b/apps/desk98/gen-assets.ts @@ -0,0 +1,177 @@ +// 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); + } + } + } + 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..ef81ece5 --- /dev/null +++ b/apps/desk98/gen-icons.ts @@ -0,0 +1,561 @@ +// 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 flag: the four-pane waving flag. + file: "start-flag.svg", + rows: [ + "................", + "....kk..........", + "..kkrrkkkkkk....", + ".krrrrrkGGGGkk..", + ".krrrrrkGGGGGGk.", + ".krrrrrkGGGGGGk.", + ".krrrrrkGGGGGGk.", + "..krrrrkGGGGGk..", + ".kkkkkkkkkkkkk..", + ".kbbbbbksssssk..", + ".kbbbbbkssssssk.", + ".kbbbbbkssssssk.", + ".kbbbbbkssssssk.", + "..kbbbbksssssk..", + "...kkkkkkkkkk...", + "................", + ], + }, +]; + +// 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-flag.svg b/apps/desk98/icons/start-flag.svg new file mode 100644 index 00000000..26ea0c01 --- /dev/null +++ b/apps/desk98/icons/start-flag.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/main.tsx b/apps/desk98/main.tsx new file mode 100644 index 00000000..11c8a4cc --- /dev/null +++ b/apps/desk98/main.tsx @@ -0,0 +1,5 @@ +// apps/desk98/main.tsx — PocketJS 98 entry point. +import { mount } from "@pocketjs/framework"; +import App from "./app.tsx"; + +mount(() => ); 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..dbb1486a --- /dev/null +++ b/apps/desk98/notepad.ts @@ -0,0 +1,111 @@ +// apps/desk98/notepad.ts — pure Notepad line-editing rules (wrap off, like +// the 98 default). No Solid, no framework imports — unit-tested directly. + +export interface Caret { + row: number; + col: number; +} + +export interface Doc { + lines: string[]; + caret: Caret; +} + +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; \n splits the line. */ +export function insertText(doc: Doc, s: string): 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 { + 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 { + 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 }; + } +} + +/** 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..92622eea --- /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.tsx", + "output": "desk98-main", + "framework": "solid", + "companions": [ + "desk" + ], + "viewport": { + "dynamic": { + "default": [ + 800, + 600 + ], + "min": [ + 640, + 480 + ] + } + } + } +} diff --git a/apps/desk98/programs.tsx b/apps/desk98/programs.tsx new file mode 100644 index 00000000..a8daebc8 --- /dev/null +++ b/apps/desk98/programs.tsx @@ -0,0 +1,488 @@ +// apps/desk98/programs.tsx — the window contents: Notepad, Minesweeper, the +// Explorer-style folder view, About and Shut Down dialogs. Presentational +// like chrome.tsx; each program also exports the content-local hit helpers +// app.tsx routes clicks through, so render geometry and hit geometry sit in +// one file. 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 { For, Index, Show, type Accessor, type JSX } from "solid-js"; +import { Image, Text, View } from "@pocketjs/framework/components"; +import { getOps } from "@pocketjs/framework/host"; +import { T98 } from "./chrome.tsx"; +import { FONT } from "./theme.ts"; +import type { Doc } from "./notepad.ts"; +import { MINES_W, type Mines } 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 + +export interface PadData { + kind: "notepad"; + doc: Accessor; + setDoc: (d: Doc) => void; + scroll: Accessor; + setScroll: (y: number) => void; + preedit: Accessor<{ s: string; c: number } | null>; + setPreedit: (p: { s: string; c: number } | null) => void; +} + +export function NotepadView(props: { data: PadData; active: boolean }): JSX.Element { + const d = props.data; + const caretRow = () => d.doc().caret.row; + const caretX = () => { + const doc = d.doc(); + const line = doc.lines[doc.caret.row] ?? ""; + const pre = d.preedit(); + const head = line.slice(0, doc.caret.col); + return measure(pre ? head + pre.s.slice(0, pre.c) : head); + }; + return ( + + + + + {(line, row) => ( + + {line()}} + > + {line().slice(0, d.doc().caret.col)} + + {d.preedit()?.s ?? ""} + + + {line().slice(d.doc().caret.col)} + + + )} + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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 interface MinesData { + kind: "mines"; + board: Accessor; + setBoard: (m: Mines) => void; + /** Cell index held by the primary button, -1 none. */ + held: Accessor; + setHeld: (i: number) => void; + smileyHeld: Accessor; + setSmileyHeld: (h: boolean) => void; + /** Seconds shown by the timer (app.tsx advances it while playing). */ + elapsed: Accessor; + setElapsed: (s: number) => void; +} + +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; +} + +const NUM_COLORS = [ + "", // 0 unused + "text-[#0000ff]", + "text-[#008000]", + "text-[#ff0000]", + "text-[#000080]", + "text-[#800000]", + "text-[#008080]", + "text-[#000000]", + "text-[#808080]", +]; + +/** One 7-seg digit, 13×23, red on black. */ +function Digit(props: { ch: Accessor }): JSX.Element { + // a + // f b segments: bit 0..6 = a b c d e f g + // g + // e c + // d + const SEGS: Record = { + "0": 0b0111111, + "1": 0b0000110, + "2": 0b1011011, + "3": 0b1001111, + "4": 0b1100110, + "5": 0b1101101, + "6": 0b1111101, + "7": 0b0000111, + "8": 0b1111111, + "9": 0b1101111, + "-": 0b1000000, + " ": 0, + }; + const on = (bit: number) => ((SEGS[props.ch()] ?? 0) >> bit) & 1; + const seg = (bit: number, cls: string, off: string) => ( + + ); + return ( + + {seg(0, "absolute left-[2] top-[1] w-[9] h-[2] bg-[#ff0000]", "absolute left-[2] top-[1] w-[9] h-[2] bg-[#3a0000]")} + {seg(1, "absolute left-[10] top-[2] w-[2] h-[9] bg-[#ff0000]", "absolute left-[10] top-[2] w-[2] h-[9] bg-[#3a0000]")} + {seg(2, "absolute left-[10] top-[12] w-[2] h-[9] bg-[#ff0000]", "absolute left-[10] top-[12] w-[2] h-[9] bg-[#3a0000]")} + {seg(3, "absolute left-[2] top-[20] w-[9] h-[2] bg-[#ff0000]", "absolute left-[2] top-[20] w-[9] h-[2] bg-[#3a0000]")} + {seg(4, "absolute left-[1] top-[12] w-[2] h-[9] bg-[#ff0000]", "absolute left-[1] top-[12] w-[2] h-[9] bg-[#3a0000]")} + {seg(5, "absolute left-[1] top-[2] w-[2] h-[9] bg-[#ff0000]", "absolute left-[1] top-[2] w-[2] h-[9] bg-[#3a0000]")} + {seg(6, "absolute left-[2] top-[10] w-[9] h-[3] bg-[#ff0000]", "absolute left-[2] top-[10] w-[9] h-[3] bg-[#3a0000]")} + + ); +} + +function Counter(props: { value: Accessor }): JSX.Element { + const text = () => { + const v = Math.max(-99, Math.min(999, Math.round(props.value()))); + return v < 0 ? "-" + String(-v).padStart(2, "0") : String(v).padStart(3, "0"); + }; + return ( + + text()[0]} /> + text()[1]} /> + text()[2]} /> + + ); +} + +export function MinesView(props: { data: MinesData }): JSX.Element { + const d = props.data; + const smiley = () => { + if (d.smileyHeld()) return "icons/smile.svg"; + const m = d.board(); + if (m.phase === "lost") return "icons/smile-dead.svg"; + if (m.phase === "won") return "icons/smile-cool.svg"; + if (d.held() >= 0) return "icons/smile-ooh.svg"; + return "icons/smile.svg"; + }; + return ( + + + 10 - d.board().flags} /> + + + + + + + + + {(_, ry) => ( + + + {(_, rx) => } + + + )} + + + + ); +} + +const ROWS9 = Array.from({ length: MINES_W }, (_, i) => i); + +function MinesCell(props: { data: MinesData; i: number }): JSX.Element { + const c = () => props.data.board().cells[props.i]; + const bust = () => props.data.board().bust === props.i; + const heldDown = () => props.data.held() === props.i && c().state === "hidden"; + return ( + + + + + + } + > + + 0}> + + {String(c().adj)} + + + }> + + + + + ); +} + +// --------------------------------------------------------------------------- +// Folder (Explorer details view) +// --------------------------------------------------------------------------- + +export const FOLDER_HEADER_H = 17; +export const FOLDER_ROW_H = 17; +export const FOLDER_STATUS_H = 20; + +export interface FolderRow { + icon: string; + name: string; + size: string; + type: string; + open?: () => void; +} + +export interface FolderData { + kind: "folder"; + rows: FolderRow[]; + selected: Accessor; + setSelected: (i: number) => void; +} + +/** 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; +} + +export function FolderView(props: { data: FolderData; active: boolean; resizable: boolean }): JSX.Element { + const d = props.data; + return ( + + + + + Name + + + Size + + + Type + + + + {(row, i) => ( + + + + + {row.name} + + + + + {row.size} + + + + + {row.type} + + + + )} + + + + (empty) + + + + + + {`${d.rows.length} object(s)`} + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// About + Shut Down dialogs +// --------------------------------------------------------------------------- + +export const ABOUT_GEO = { w: 340, h: 216 } as const; +export const SHUTDOWN_GEO = { w: 300, h: 176 } as const; + +function Button98(props: { label: string; armed: boolean; def?: boolean }): JSX.Element { + return ( + + + {props.label} + + + ); +} + +export interface AboutData { + kind: "about"; + armed: Accessor; +} + +/** 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 function AboutView(props: { data: AboutData }): JSX.Element { + return ( + + + + PocketJS 98 + + + + + + A desktop compositor demo on the gpui backend. + Same DrawList contract the consoles boot; the + windows, menus and shortcuts live in the guest. + github.com/pocket-stack/pocketjs + + + + + + ); +} + +export interface ShutdownData { + kind: "shutdown"; + choice: Accessor; + setChoice: (i: number) => void; + armed: Accessor; +} + +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; +} + +export function ShutdownView(props: { data: ShutdownData }): JSX.Element { + const radio = (i: number, label: string) => ( + + + + + + + + + {label} + + ); + return ( + + + + + What do you want the computer to do? + + + + + {radio(0, "Shut down")} + {radio(1, "Restart")} + + + + + + + + ); +} diff --git a/apps/desk98/screenshot.png b/apps/desk98/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..46e2fcfe7e333dc829d0053b361f3db0b11b2393 GIT binary patch literal 71281 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 Tth}ND{`|0Q&(@spy#Don0acz0 literal 0 HcmV?d00001 diff --git a/apps/desk98/state.ts b/apps/desk98/state.ts new file mode 100644 index 00000000..706d2916 --- /dev/null +++ b/apps/desk98/state.ts @@ -0,0 +1,121 @@ +// apps/desk98/state.ts — compositor state: window controls, popups, desktop +// icons. Hot geometry lives in per-window signals 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 { createSignal, type Accessor } from "solid-js"; +import type { CaptionButton, Geo } from "./wm.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; + 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: Accessor; + setGeo: (g: Geo) => void; + z: Accessor; + setZ: (z: number) => void; + minimized: Accessor; + setMinimized: (m: boolean) => void; + maximized: Accessor; + setMaximized: (m: boolean) => void; + /** Geometry to restore on un-maximize. */ + restoreGeo: Geo | null; + pressedBtn: Accessor; + setPressedBtn: (b: CaptionButton | null) => void; + /** Open menu-bar index, -1 closed. */ + openMenu: Accessor; + setOpenMenu: (i: number) => void; + /** Program-specific state bag (notepad lines, the mines board, …). */ + data: unknown; +} + +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 { + const [geo, setGeo] = createSignal(spec.geo); + const [z, setZ] = createSignal(0); + const [minimized, setMinimized] = createSignal(false); + const [maximized, setMaximized] = createSignal(false); + const [pressedBtn, setPressedBtn] = createSignal(null); + const [openMenu, setOpenMenu] = createSignal(-1); + 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, + setGeo, + z, + setZ, + minimized, + setMinimized, + maximized, + setMaximized, + restoreGeo: null, + pressedBtn, + setPressedBtn, + openMenu, + setOpenMenu, + data: spec.data, + }; +} diff --git a/apps/desk98/svc.ts b/apps/desk98/svc.ts new file mode 100644 index 00000000..cc6ea942 --- /dev/null +++ b/apps/desk98/svc.ts @@ -0,0 +1,96 @@ +// 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:"paste", text} system clipboard (⌘V) +// {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:"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; + 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: "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..214e3cd8 --- /dev/null +++ b/apps/desk98/theme.ts @@ -0,0 +1,38 @@ +// 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; +export const CLOSE_GAP = 2; // min/zoom adjacent, close 2px apart (authentic) +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..0484b3a5 --- /dev/null +++ b/apps/desk98/wm.ts @@ -0,0 +1,194 @@ +// 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: [min][zoom] +// 2px [close], each 16×14, vertically 2px under the caption top. + +import { + BTN_H, + BTN_W, + CLOSE_GAP, + 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. */ +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; + // The classic 2px gap sits between close and its neighbors only. + if (buttons[i] === "close" && i > 0) right -= CLOSE_GAP; + } + 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 0000000000000000000000000000000000000000..153077a6baf3bc1f7430b9eb20ae912ca71eb116 GIT binary patch literal 43372 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 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..105dc2e1 100644 --- a/hosts/macos/src/main.rs +++ b/hosts/macos/src/main.rs @@ -23,6 +23,12 @@ //! 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, a boot epoch in the hello +//! (for the guest's wall clock), and a {t:"cursor"} guest intent that sets +//! the window's pointer shape. 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 +38,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 +79,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 alt+/ctl+/sh+ prefixes (svc `key` line). + Key(u64, String, bool, bool, bool), } struct Args { @@ -169,6 +180,51 @@ 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 [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 alt, mut ctl, mut sh) = (false, false, false); + loop { + 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(), alt, ctl, sh)); + } "--quit-after" => args.quit_after_ticks = Some(val("--quit-after")?.parse()?), "--announce-ready" => args.announce_ready = true, "--press" => { @@ -283,7 +339,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 +402,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 +419,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 +477,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 +533,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, alt, ctl, sh) if t == tick => { + self.svc(serde_json::json!( + {"t": "key", "k": k, "sh": sh, "alt": alt, "ctl": ctl} + )); + } _ => {} } } @@ -465,7 +577,7 @@ impl PocketRoot { } if !self.booted { self.booted = true; - if self.args.editor { + if self.forward_input() { self.send_hello(); } } @@ -493,7 +605,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 +636,7 @@ impl PocketRoot { 0 } } else { - self.buttons + self.buttons | self.script_buttons }; self.click_edge = false; if let Err(e) = self.guest.frame(buttons) { @@ -554,6 +666,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,7 +707,7 @@ 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 { match ks.key.as_str() { @@ -623,10 +742,26 @@ 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})); + self.svc(serde_json::json!({ + "t": "key", "k": k, "sh": shift, + "alt": ks.modifiers.alt, "ctl": ks.modifiers.control, + })); } else if self.marked.is_none() { // Plain typing (IME composition delivers through the input // handler instead — no double-input path). @@ -648,7 +783,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 +808,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 +865,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 +1061,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.test.ts b/tests/desk98.test.ts new file mode 100644 index 00000000..89e3546b --- /dev/null +++ b/tests/desk98.test.ts @@ -0,0 +1,274 @@ +// tests/desk98.test.ts — PocketJS 98: window-manager chrome math, the +// Minesweeper rules and Notepad line editing (all pure), plus a sim boot +// smoke test of the standalone desktop. +// +// bun tools/build.ts desk98-main && bun test tests/desk98.test.ts + +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 { backspace, colFromX, del, insertText, moveCaret } from "../apps/desk98/notepad.ts"; +import { runScenario, treeHasText } from "../hosts/sim/sim.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("min/zoom adjacent, close 2px apart, flush right inside the frame", () => { + 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 - 2); // the classic close gap + expect(xs[0]).toBe(xs[1] - 16); // min/zoom adjacent + }); + + 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); + }); +}); + +// --------------------------------------------------------------------------- +// sim smoke: the standalone desktop (no desk companion) boots a static +// arrangement — the unmodified-app base case. +// --------------------------------------------------------------------------- + +describe("desk98-main boots standalone", () => { + test("desktop, taskbar and the boot windows render", async () => { + const trace = await runScenario({ app: "desk98-main", 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); +}); 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..9fc7a4f5 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", From 6215b878eb838c0f669c3d4fbdce777a252a4f20 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:53:09 +0800 Subject: [PATCH 2/2] feat(desk98): vue-vapor rewrite, macOS chords, notepad selection, favicon start logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Business logic ported from Solid JSX to Vue Vapor SFCs: keyed v-for windows, per-window vue refs (state.ts), pure modules unchanged; the manifest declares framework vue-vapor so bun run macos desk98 builds it. - Host fix: plain typing is single-sourced through the IME input handler — gpui hands the unhandled key event to the input context, so insertText always reaches replace_text_in_range and the on_key_down key_char emission delivered every keypress twice (the note dialect shared the latent double path). - Desk dialect: cmd-flagged ⌘ chord key lines (⌘Q quits, ⌘V pastes host-side), {t:"paste-req"} guest intent answered with a paste line, --key cmd+ scripting. - macOS-style shortcuts replace the Win98 chords: ⌘W close, ⌘M minimize, ⌘` cycle, ⌘N new Notepad, ⌘Esc Start menu, ⌘A/C/X in Notepad. - Notepad selection: anchor+caret Doc model (notepad.ts, unit-tested), drag/double-click-word/shift extend, navy segment rendering, Cut/Copy/ Paste/Select All in the Edit menu and a right-click context menu over the system clipboard. - Chrome: caption buttons flush (close gap removed), Start button carries the PocketJS favicon mark (solid color), Start-menu banner wordmark removed. - W95FA: synthetic backtick (the font ships none — the apostrophe mirrored around its ink bounds stays on the pixel grid). - Tests: desk companion journey in the sim (mock svc ops asserting copy/ paste-req payloads on the wire), desk98 sim stage in tools/test.ts and the macos CI lane. Co-Authored-By: Claude Fable 5 --- .github/workflows/macos.yml | 9 +- apps/desk98/AboutView.vue | 31 + apps/desk98/Button98.vue | 21 + apps/desk98/CaptionButtons.vue | 39 + apps/desk98/Counter.vue | 21 + apps/desk98/DesktopIcons.vue | 23 + apps/desk98/Digit.vue | 39 + apps/desk98/FolderView.vue | 57 ++ apps/desk98/MinesCell.vue | 55 ++ apps/desk98/MinesView.vue | 48 ++ apps/desk98/NotepadView.vue | 78 ++ apps/desk98/PopupPanel.vue | 64 ++ apps/desk98/ShutdownView.vue | 43 + apps/desk98/StartMenu.vue | 63 ++ apps/desk98/T98.vue | 17 + apps/desk98/Taskbar.vue | 54 ++ apps/desk98/Window98.vue | 55 ++ apps/desk98/app.tsx | 1215 -------------------------- apps/desk98/app.vue | 1341 +++++++++++++++++++++++++++++ apps/desk98/chrome.tsx | 352 -------- apps/desk98/fonts/w95fa-19.bin | Bin 15596 -> 15760 bytes apps/desk98/fonts/w95fa-19@2x.bin | Bin 60056 -> 60688 bytes apps/desk98/fonts/w95fa-20.bin | Bin 16831 -> 17008 bytes apps/desk98/fonts/w95fa-20@2x.bin | Bin 64996 -> 65680 bytes apps/desk98/fonts/w95fa-21.bin | Bin 53216 -> 53776 bytes apps/desk98/fonts/w95fa-21@2x.bin | Bin 210536 -> 212752 bytes apps/desk98/gen-assets.ts | 38 + apps/desk98/gen-icons.ts | 33 +- apps/desk98/icons/start-flag.svg | 1 - apps/desk98/icons/start-logo.svg | 1 + apps/desk98/main.ts | 8 + apps/desk98/main.tsx | 5 - apps/desk98/notepad.ts | 105 ++- apps/desk98/pocket.json | 4 +- apps/desk98/programs.ts | 97 +++ apps/desk98/programs.tsx | 488 ----------- apps/desk98/screenshot.png | Bin 71281 -> 63444 bytes apps/desk98/state.ts | 99 ++- apps/desk98/svc.ts | 8 +- apps/desk98/theme.ts | 3 +- apps/desk98/wm.ts | 9 +- docs/BACKENDS.md | 33 +- hosts/macos/src/main.rs | 86 +- tests/desk98-sim.test.ts | 170 ++++ tests/desk98.test.ts | 116 ++- tools/test.ts | 6 + 46 files changed, 2757 insertions(+), 2178 deletions(-) create mode 100644 apps/desk98/AboutView.vue create mode 100644 apps/desk98/Button98.vue create mode 100644 apps/desk98/CaptionButtons.vue create mode 100644 apps/desk98/Counter.vue create mode 100644 apps/desk98/DesktopIcons.vue create mode 100644 apps/desk98/Digit.vue create mode 100644 apps/desk98/FolderView.vue create mode 100644 apps/desk98/MinesCell.vue create mode 100644 apps/desk98/MinesView.vue create mode 100644 apps/desk98/NotepadView.vue create mode 100644 apps/desk98/PopupPanel.vue create mode 100644 apps/desk98/ShutdownView.vue create mode 100644 apps/desk98/StartMenu.vue create mode 100644 apps/desk98/T98.vue create mode 100644 apps/desk98/Taskbar.vue create mode 100644 apps/desk98/Window98.vue delete mode 100644 apps/desk98/app.tsx create mode 100644 apps/desk98/app.vue delete mode 100644 apps/desk98/chrome.tsx delete mode 100644 apps/desk98/icons/start-flag.svg create mode 100644 apps/desk98/icons/start-logo.svg create mode 100644 apps/desk98/main.ts delete mode 100644 apps/desk98/main.tsx create mode 100644 apps/desk98/programs.ts delete mode 100644 apps/desk98/programs.tsx create mode 100644 tests/desk98-sim.test.ts diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 382e8e40..5f7064cb 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -25,6 +25,7 @@ on: - "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" @@ -45,6 +46,7 @@ on: - "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" @@ -94,7 +96,7 @@ 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 (W95FA pak.json splice + SVG icons) + - 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"; @@ -106,6 +108,11 @@ jobs: 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.tsx b/apps/desk98/app.tsx deleted file mode 100644 index 6c4aeef7..00000000 --- a/apps/desk98/app.tsx +++ /dev/null @@ -1,1215 +0,0 @@ -// apps/desk98/app.tsx — PocketJS 98: a Windows 98 desktop compositor as a -// PocketJS app on the gpui macOS host. -// -// The compositor owns ALL input: the host forwards raw mouse/keyboard over -// the desk svc dialect (svc.ts), and this file routes every event itself — -// window drags, resizes, caption buttons, menus, program content — against -// the same geometry the chrome renders (wm.ts + programs.tsx helpers). The -// framework's focus/onPress pipeline is never engaged; a window manager IS -// its own hit tester. Window moves ride paint-only translate props, raises -// ride zIndex, so a drag never relayouts and an idle desktop hashes stable -// for the demand-render governor. -// -// Without the desk companion (sim, goldens, consoles) the app boots a -// static arrangement and just renders it — the unmodified-app base case. - -import { createSignal, For, Show, type Accessor } from "solid-js"; -import { View } from "@pocketjs/framework/components"; -import { onFrame } from "@pocketjs/framework/lifecycle"; -import { virtualNow } from "@pocketjs/framework/clock"; -import { connectSvc, type CursorKind, type HostEvent } from "./svc.ts"; -import { - cascadePos, - clampMove, - cursorForDir, - hitRegion, - maximizedGeo, - resizeGeo, - type CaptionButton, - type Dir, - type Geo, - type Region, -} from "./wm.ts"; -import { createWin, type DeskIcon, type Popup, type PopupItem, type TaskEntry, type WinCtl } from "./state.ts"; -import { DesktopIcons, PopupPanel, StartMenu, Taskbar, Window98 } from "./chrome.tsx"; -import { - ABOUT_GEO, - aboutHit, - AboutView, - folderRowAt, - FolderView, - measure, - MINES_GEO, - minesHit, - MinesView, - NotepadView, - PAD_LINE_H, - shutdownHit, - ShutdownView, - type AboutData, - type FolderData, - type FolderRow, - type MinesData, - type PadData, - type ShutdownData, -} from "./programs.tsx"; -import { backspace, colFromX, del, insertText, moveCaret, type CaretMove, type Doc } from "./notepad.ts"; -import { newMines, reveal, toggleFlag, type Mines } from "./mines.ts"; -import { contentTop } from "./wm.ts"; -import { FRAME, TASK_H } from "./theme.ts"; - -const WELCOME = [ - "Welcome to PocketJS 98.", - "", - "This desktop is one PocketJS guest: the windows,", - "the taskbar, the Start menu and this Notepad are", - "JSX over the same DrawList contract the consoles", - "boot, painted by the gpui backend.", - "", - "Things to try:", - " - drag windows by the title bar", - " - drag any edge or corner to resize", - " - double-click a title bar to maximize", - " - right-click the desktop or Minesweeper", - " - Alt+Tab, Alt+F4, Ctrl+Esc", - "", - "The font is W95FA, baked to the same atlas", - "format every other PocketJS target reads.", -]; - -type Drag = - | { type: "move"; id: number; sx: number; sy: number; orig: Geo } - | { type: "resize"; id: number; dir: Dir; sx: number; sy: number; orig: Geo } - | { type: "capbtn"; id: number; btn: CaptionButton } - | { type: "minehold"; id: number } - | { type: "smiley"; id: number } - | { type: "dialogbtn"; id: number; tag: string } - | null; - -export default function App() { - const svc = connectSvc(); - - const [vp, setVp] = createSignal<{ w: number; h: number }>({ w: 800, h: 600 }); - const [wins, setWins] = createSignal([]); - const [focusId, setFocusId] = createSignal(-1); - const [iconSel, setIconSel] = createSignal(-1); - const [startOpen, setStartOpen] = createSignal(false); - const [startHover, setStartHover] = createSignal(-1); - const [startFly, setStartFly] = createSignal<{ index: number; popup: Popup } | null>(null); - const [flyHover, setFlyHover] = createSignal(-1); - const [popup, setPopup] = createSignal<{ popup: Popup; winId?: number } | null>(null); - const [popupHover, setPopupHover] = createSignal(-1); - const [clock, setClock] = createSignal("--:--"); - - // Non-reactive input state (nothing renders from these directly). - let stack: number[] = []; // window ids, bottom → top - let drag: Drag = null; - let mx = 0; - let my = 0; - let prevDown = false; - let epoch = 0; // wall ms at hello, anchored to virtualNow() then - let epochAt = 0; - let lastCursor: CursorKind = "default"; - let lastClick = { key: "", t: -1, x: 0, y: 0 }; - let lastCaret = { x: -1, y: -1, h: 0 }; - let minesStart = 0; - - const byId = (id: number) => wins().find((w) => w.id === id); - const focused = () => byId(focusId()); - - // ---- window management -------------------------------------------------- - - function applyZ() { - stack.forEach((id, i) => byId(id)?.setZ(i + 1)); - } - - function raise(id: number) { - stack = stack.filter((x) => x !== id).concat(id); - applyZ(); - setFocusId(id); - const w = byId(id); - if (w?.minimized()) w.setMinimized(false); - } - - function addWin(w: WinCtl) { - setWins(wins().concat(w)); - stack = stack.concat(w.id); - applyZ(); - setFocusId(w.id); - } - - function closeWin(id: number) { - setWins(wins().filter((w) => w.id !== id)); - stack = stack.filter((x) => x !== id); - applyZ(); - setFocusId(stack.length > 0 ? stack[stack.length - 1] : -1); - } - - function minimize(id: number) { - byId(id)?.setMinimized(true); - const next = stack.filter((x) => x !== id && !byId(x)?.minimized()); - setFocusId(next.length > 0 ? next[next.length - 1] : -1); - } - - function toggleMax(w: WinCtl) { - if (!w.resizable) return; - if (w.maximized()) { - w.setMaximized(false); - if (w.restoreGeo) w.setGeo(w.restoreGeo); - } else { - w.restoreGeo = w.geo(); - w.setMaximized(true); - w.setGeo(maximizedGeo(vp().w, vp().h)); - } - } - - function cycleWindows() { - const visible = stack.filter((id) => !byId(id)?.minimized()); - if (visible.length < 2) return; - raise(visible[0]); // bottom-most visible comes up — repeated Alt+Tab cycles - } - - // ---- programs ----------------------------------------------------------- - - function openNotepad(title: string, content: string[]) { - const existing = wins().find((w) => w.kind === "notepad" && w.title === title); - if (existing) return raise(existing.id); - const [doc, setDoc] = createSignal({ - lines: content.length > 0 ? content : [""], - caret: { row: 0, col: 0 }, - }); - const [scroll, setScroll] = createSignal(0); - const [preedit, setPreedit] = createSignal<{ s: string; c: number } | null>(null); - const data: PadData = { kind: "notepad", doc, setDoc, scroll, setScroll, preedit, setPreedit }; - const w = createWin({ - kind: "notepad", - title, - icon: "icons/notepad-16.svg", - geo: cascadePos(wins().length, vp().w, vp().h, 400, 300), - minW: 220, - minH: 140, - menus: [ - { - label: "File", - width: measure("File") + 12, - items: [ - { label: "New", act: () => setDoc({ lines: [""], caret: { row: 0, col: 0 } }) }, - { sep: true, label: "" }, - { label: "Exit", act: () => closeWin(w.id) }, - ], - }, - { - label: "Edit", - width: measure("Edit") + 12, - items: [ - { label: "Time/Date", shortcut: "F5", act: () => insertTimeDate(w) }, - { sep: true, label: "" }, - { label: "Word Wrap", disabled: true }, - ], - }, - { - label: "Help", - width: measure("Help") + 12, - items: [{ label: "About PocketJS 98", act: openAbout }], - }, - ], - data, - }); - addWin(w); - } - - function openMines() { - const existing = wins().find((w) => w.kind === "mines"); - if (existing) return raise(existing.id); - const [board, setBoard] = createSignal(newMines((virtualNow() * 1000) | 0), { - equals: false, - }); - const [held, setHeld] = createSignal(-1); - const [smileyHeld, setSmileyHeld] = createSignal(false); - const [elapsed, setElapsed] = createSignal(0); - const data: MinesData = { - kind: "mines", - board, - setBoard, - held, - setHeld, - smileyHeld, - setSmileyHeld, - elapsed, - setElapsed, - }; - const w = createWin({ - kind: "mines", - title: "Minesweeper", - icon: "icons/mines-16.svg", - geo: { ...cascadePos(wins().length, vp().w, vp().h, MINES_GEO.w, MINES_GEO.h) }, - buttons: ["min", "close"], - resizable: false, - menus: [ - { - label: "Game", - width: measure("Game") + 12, - items: [ - { label: "New", shortcut: "F2", act: () => minesNew(w) }, - { sep: true, label: "" }, - { label: "Exit", act: () => closeWin(w.id) }, - ], - }, - { - label: "Help", - width: measure("Help") + 12, - items: [{ label: "About PocketJS 98", act: openAbout }], - }, - ], - data, - }); - addWin(w); - } - - function minesNew(w: WinCtl) { - const d = w.data as MinesData; - d.setBoard(newMines((virtualNow() * 1000) | 0)); - d.setElapsed(0); - minesStart = 0; - } - - function openFolder(title: string, icon: string, rows: FolderRow[], geoW = 420, geoH = 280) { - const existing = wins().find((w) => w.kind === "folder" && w.title === title); - if (existing) return raise(existing.id); - const [selected, setSelected] = createSignal(-1); - const data: FolderData = { kind: "folder", rows, selected, setSelected }; - const w = createWin({ - kind: "folder", - title, - icon, - geo: cascadePos(wins().length, vp().w, vp().h, geoW, geoH), - minW: 260, - minH: 160, - data, - }); - addWin(w); - } - - function openMyComputer() { - openFolder("My Computer", "icons/computer-16.svg", [ - { - icon: "icons/drive-16.svg", - name: "(C:)", - size: "", - type: "Local Disk", - open: () => openDriveC(), - }, - { icon: "icons/cdrom-16.svg", name: "(D:)", size: "", type: "CD-ROM Disc" }, - { icon: "icons/folder-16.svg", name: "Control Panel", size: "", type: "System Folder" }, - { icon: "icons/folder-16.svg", name: "Printers", size: "", type: "System Folder" }, - ]); - } - - function openDriveC() { - openFolder("(C:)", "icons/drive-16.svg", [ - { icon: "icons/folder-16.svg", name: "Program Files", size: "", type: "File Folder" }, - { icon: "icons/folder-16.svg", name: "Windows", size: "", type: "File Folder" }, - { icon: "icons/file-16.svg", name: "AUTOEXEC.BAT", size: "1 KB", type: "MS-DOS Batch File" }, - { icon: "icons/file-16.svg", name: "CONFIG.SYS", size: "1 KB", type: "System file" }, - { - icon: "icons/notepad-16.svg", - name: "README.TXT", - size: "2 KB", - type: "Text Document", - open: () => openNotepad("README.TXT - Notepad", WELCOME), - }, - ]); - } - - function openDocuments() { - openFolder("My Documents", "icons/folder-16.svg", [ - { - icon: "icons/notepad-16.svg", - name: "welcome.txt", - size: "1 KB", - type: "Text Document", - open: () => openNotepad("welcome.txt - Notepad", WELCOME), - }, - ]); - } - - function openRecycle() { - openFolder("Recycle Bin", "icons/recycle-16.svg", []); - } - - function openAbout() { - const existing = wins().find((w) => w.kind === "about"); - if (existing) return raise(existing.id); - const [armed, setArmed] = createSignal(null); - const data: AboutData & { setArmed: (s: string | null) => void } = { - kind: "about", - armed, - setArmed, - }; - const w = createWin({ - kind: "about", - title: "About PocketJS 98", - icon: "icons/computer-16.svg", - geo: centered(ABOUT_GEO.w, ABOUT_GEO.h), - buttons: ["close"], - resizable: false, - data, - }); - addWin(w); - } - - function openShutdown() { - const existing = wins().find((w) => w.kind === "shutdown"); - if (existing) return raise(existing.id); - const [choice, setChoice] = createSignal(0); - const [armed, setArmed] = createSignal(null); - const data: ShutdownData & { setArmed: (s: string | null) => void } = { - kind: "shutdown", - choice, - setChoice, - armed, - setArmed, - }; - const w = createWin({ - kind: "shutdown", - title: "Shut Down Windows", - icon: "icons/shutdown-16.svg", - geo: centered(SHUTDOWN_GEO.w, SHUTDOWN_GEO.h), - buttons: ["close"], - resizable: false, - data, - }); - addWin(w); - } - - function centered(w: number, h: number): Geo { - return { - x: Math.max(0, Math.round((vp().w - w) / 2)), - y: Math.max(0, Math.round((vp().h - TASK_H - h) / 2)), - w, - h, - }; - } - - function restartSession() { - for (const w of wins().slice()) closeWin(w.id); - boot(); - } - - function insertTimeDate(w: WinCtl) { - const d = w.data as PadData; - const t = new Date(epoch + (virtualNow() - epochAt) * 1000); - const stamp = `${pad2(t.getHours())}:${pad2(t.getMinutes())} ${pad2(t.getMonth() + 1)}/${pad2(t.getDate())}/${t.getFullYear()}`; - d.setDoc(insertText(d.doc(), stamp)); - } - - // ---- desktop icons + start menu ----------------------------------------- - - const icons: DeskIcon[] = [ - { icon: "icons/computer.svg", label: "My Computer", open: openMyComputer }, - { icon: "icons/documents.svg", label: "My Documents", open: openDocuments }, - { icon: "icons/recycle.svg", label: "Recycle Bin", open: openRecycle }, - { icon: "icons/notepad.svg", label: "Notepad", open: () => openNotepad("Untitled - Notepad", [""]) }, - { icon: "icons/mines.svg", label: "Minesweeper", open: openMines }, - ]; - const ICON_X = 8; - const ICON_W = 74; - const ICON_STRIDE = 58; - const ICON_CELL_H = 48; - - function iconAt(x: number, y: number): number { - if (x < ICON_X || x >= ICON_X + ICON_W) return -1; - const rel = y - 8; - if (rel < 0 || rel % ICON_STRIDE >= ICON_CELL_H) return -1; - const i = Math.floor(rel / ICON_STRIDE); - return i >= 0 && i < icons.length ? i : -1; - } - - const startItems: () => PopupItem[] = () => [ - { - label: "Programs", - icon: "icons/folder-16.svg", - sub: [ - { label: "Notepad", icon: "icons/notepad-16.svg", act: () => openNotepad("Untitled - Notepad", [""]) }, - { label: "Minesweeper", icon: "icons/mines-16.svg", act: openMines }, - ], - }, - { - label: "Documents", - icon: "icons/folder-16.svg", - sub: [ - { label: "welcome.txt", icon: "icons/notepad-16.svg", act: () => openNotepad("welcome.txt - Notepad", WELCOME) }, - ], - }, - { label: "Settings", icon: "icons/settings-16.svg", disabled: true }, - { label: "Find", icon: "icons/find-16.svg", disabled: true }, - { label: "Help", icon: "icons/help-16.svg", act: openAbout }, - { label: "Run...", icon: "icons/run-16.svg", disabled: true }, - { sep: true, label: "" }, - { label: "Shut Down...", icon: "icons/shutdown-16.svg", act: openShutdown }, - ]; - - const START_ROW = 26; - const START_SEP = 8; - const startH = () => - 2 + startItems().reduce((a, it) => a + (it.sep ? START_SEP : START_ROW), 0); - const startY = () => vp().h - TASK_H - startH(); - - function startItemAt(x: number, y: number): number { - const items = startItems(); - if (x < 2 + 25 || x >= 2 + 182 - 1) return -1; - let oy = startY() + 1; - for (let i = 0; i < items.length; i++) { - const h = items[i].sep ? START_SEP : START_ROW; - if (y >= oy && y < oy + h) return items[i].sep ? -1 : i; - oy += h; - } - return -1; - } - - function buildPopup(x: number, y: number, items: PopupItem[]): Popup { - let w = 0; - for (const it of items) { - if (it.sep) continue; - w = Math.max( - w, - 26 + measure(it.label) + (it.shortcut ? 20 + measure(it.shortcut) : 0) + (it.sub ? 14 : 0) + 14, - ); - } - const h = 2 + items.reduce((a, it) => a + (it.sep ? START_SEP : 18), 0); - return { - x: Math.min(x, vp().w - w - 2), - y: Math.min(y, vp().h - TASK_H - h), - w: Math.max(w, 120), - items, - }; - } - - function popupItemAt(p: Popup, x: number, y: number): number { - if (x < p.x + 1 || x >= p.x + p.w - 1) return -1; - let oy = p.y + 1; - for (let i = 0; i < p.items.length; i++) { - const h = p.items[i].sep ? START_SEP : 18; - if (y >= oy && y < oy + h) return p.items[i].sep ? -1 : i; - oy += h; - } - return -1; - } - - function closeMenus() { - setStartOpen(false); - setStartFly(null); - setPopup(null); - setStartHover(-1); - setFlyHover(-1); - setPopupHover(-1); - for (const w of wins()) w.setOpenMenu(-1); - } - - // ---- helpers ------------------------------------------------------------ - - function chromeOpts(w: WinCtl) { - return { - buttons: w.buttons, - resizable: w.resizable, - maximized: w.maximized(), - menuWidths: (w.menus ?? []).map((m) => m.width), - }; - } - - /** Topmost visible window under the point, with its chrome region. */ - function hitWindows(x: number, y: number): { win: WinCtl; region: Region } | null { - for (let i = stack.length - 1; i >= 0; i--) { - const w = byId(stack[i]); - if (!w || w.minimized()) continue; - const region = hitRegion(w.geo(), chromeOpts(w), x, y); - if (region) return { win: w, region }; - } - return null; - } - - function isDblClick(key: string): boolean { - const now = virtualNow(); - const hit = - lastClick.key === key && - now - lastClick.t < 0.4 && - Math.abs(mx - lastClick.x) < 4 && - Math.abs(my - lastClick.y) < 4; - lastClick = hit ? { key: "", t: -1, x: 0, y: 0 } : { key, t: now, x: mx, y: my }; - return hit; - } - - function pad2(n: number): string { - return String(n).padStart(2, "0"); - } - - function sendCursor(k: CursorKind) { - if (svc && k !== lastCursor) { - lastCursor = k; - svc.send({ t: "cursor", k }); - } - } - - // ---- input routing ------------------------------------------------------ - - function onPrimaryDown(shift: boolean) { - // Open menus swallow the click (classic: outside-click only dismisses). - if (startOpen()) { - const fly = startFly(); - if (fly) { - const i = popupItemAt(fly.popup, mx, my); - if (i >= 0) { - const item = fly.popup.items[i]; - if (!item.disabled && item.act) { - item.act(); - closeMenus(); - } - return; - } - } - const i = startItemAt(mx, my); - if (i >= 0) { - const item = startItems()[i]; - if (item.sub) return; // hover already opened the flyout; stay open - if (!item.disabled && item.act) { - item.act(); - closeMenus(); - } - return; - } - // Outside click (the Start button included) dismisses and is consumed. - closeMenus(); - return; - } - const pop = popup(); - if (pop) { - const i = popupItemAt(pop.popup, mx, my); - closeMenus(); - if (i >= 0) { - const item = pop.popup.items[i]; - if (!item.disabled && item.act) item.act(); - } - return; - } - - // Taskbar. - if (my >= vp().h - TASK_H) { - if (mx >= 2 && mx < 58) { - setStartOpen(!startOpen()); - return; - } - const entry = taskEntryAt(mx, my); - if (entry !== -1) { - const id = wins()[entry].id; - const w = byId(id); - if (!w) return; - if (focusId() === id && !w.minimized()) minimize(id); - else raise(id); - } - return; - } - - // Windows, top-down. - const hit = hitWindows(mx, my); - if (hit) { - const { win: w, region } = hit; - raise(w.id); - if (region.kind === "button") { - drag = { type: "capbtn", id: w.id, btn: region.button }; - w.setPressedBtn(region.button); - return; - } - if (region.kind === "caption") { - if (isDblClick(`cap:${w.id}`)) { - toggleMax(w); - return; - } - if (!w.maximized()) { - drag = { type: "move", id: w.id, sx: mx, sy: my, orig: w.geo() }; - } - return; - } - if (region.kind === "resize") { - drag = { type: "resize", id: w.id, dir: region.dir, sx: mx, sy: my, orig: w.geo() }; - return; - } - if (region.kind === "menu") { - const open = w.openMenu() === region.index ? -1 : region.index; - w.setOpenMenu(open); - if (open >= 0 && w.menus) { - const mxs = w.menus.slice(0, open).reduce((a, m) => a + m.width, 0); - const g = w.geo(); - setPopup({ - popup: buildPopup(g.x + FRAME + mxs, g.y + FRAME + 18 + 1 + 18, w.menus[open].items), - winId: w.id, - }); - } - return; - } - routeContentDown(w, region.cx, region.cy, shift); - return; - } - - // Desktop: select an icon (double-click opens), deactivate windows. - const icon = iconAt(mx, my); - setIconSel(icon); - setFocusId(-1); - if (icon >= 0 && isDblClick(`icon:${icon}`)) icons[icon].open(); - } - - function routeContentDown(w: WinCtl, cx: number, cy: number, _shift: boolean) { - if (w.kind === "notepad") { - const d = w.data as PadData; - const doc = d.doc(); - const row = Math.max( - 0, - Math.min(doc.lines.length - 1, Math.floor((cy - 3 + d.scroll()) / PAD_LINE_H)), - ); - const col = colFromX(doc.lines[row], cx - 3, (s) => measure(s)); - d.setDoc({ lines: doc.lines, caret: { row, col } }); - return; - } - if (w.kind === "mines") { - const d = w.data as MinesData; - const hit = minesHit(cx, cy); - if (hit?.type === "cell") { - drag = { type: "minehold", id: w.id }; - d.setHeld(hit.i); - } else if (hit?.type === "smiley") { - drag = { type: "smiley", id: w.id }; - d.setSmileyHeld(true); - } - return; - } - if (w.kind === "folder") { - const d = w.data as FolderData; - const row = folderRowAt(cy, d.rows.length); - d.setSelected(row); - if (row >= 0 && isDblClick(`row:${w.id}:${row}`)) d.rows[row].open?.(); - return; - } - if (w.kind === "about") { - const g = w.geo(); - const hit = aboutHit(g.w - FRAME * 2, g.h - FRAME - contentTop({ menuWidths: [] }), cx, cy); - if (hit === "ok") { - drag = { type: "dialogbtn", id: w.id, tag: "ok" }; - (w.data as AboutData & { setArmed: (s: string | null) => void }).setArmed("ok"); - } - return; - } - if (w.kind === "shutdown") { - const g = w.geo(); - const d = w.data as ShutdownData & { setArmed: (s: string | null) => void }; - const hit = shutdownHit(g.w - FRAME * 2, g.h - FRAME - contentTop({ menuWidths: [] }), cx, cy); - if (hit === "radio0") d.setChoice(0); - else if (hit === "radio1") d.setChoice(1); - else if (hit === "ok" || hit === "cancel") { - drag = { type: "dialogbtn", id: w.id, tag: hit }; - d.setArmed(hit); - } - } - } - - function onRightDown() { - if (startOpen() || popup()) { - closeMenus(); - return; - } - const hit = hitWindows(mx, my); - if (hit) { - const { win: w, region } = hit; - if (region.kind === "content" && w.kind === "mines") { - raise(w.id); - const d = w.data as MinesData; - const cell = minesHit(region.cx, region.cy); - if (cell?.type === "cell") { - d.setBoard(toggleFlag(d.board(), cell.i)); - } - return; - } - if (region.kind === "caption") { - raise(w.id); - setPopup({ - popup: buildPopup(mx, my, [ - { label: "Minimize", act: () => minimize(w.id) }, - { - label: w.maximized() ? "Restore" : "Maximize", - disabled: !w.resizable, - act: () => toggleMax(w), - }, - { sep: true, label: "" }, - { label: "Close", shortcut: "Alt+F4", act: () => closeWin(w.id) }, - ]), - winId: w.id, - }); - } - return; - } - if (my < vp().h - TASK_H) { - const icon = iconAt(mx, my); - setIconSel(icon); - setPopup({ - popup: buildPopup(mx, my, [ - { label: "Arrange Icons", act: () => {} }, - { label: "Refresh", act: () => {} }, - { sep: true, label: "" }, - { label: "New Text Document", act: () => openNotepad("Untitled - Notepad", [""]) }, - { sep: true, label: "" }, - { label: "Properties", disabled: true }, - ]), - }); - } - } - - function onMove() { - // Menu hover states. - if (startOpen()) { - const i = startItemAt(mx, my); - setStartHover(i); - if (i >= 0) { - const item = startItems()[i]; - if (item.sub) { - if (startFly()?.index !== i) { - let oy = startY() + 1; - for (let k = 0; k < i; k++) oy += startItems()[k].sep ? START_SEP : START_ROW; - setStartFly({ index: i, popup: buildPopup(2 + 182 - 3, oy, item.sub) }); - setFlyHover(-1); - } - } else if (startFly()) { - setStartFly(null); - } - } - const fly = startFly(); - if (fly) setFlyHover(popupItemAt(fly.popup, mx, my)); - } - const pop = popup(); - if (pop) { - setPopupHover(popupItemAt(pop.popup, mx, my)); - // A menu-bar dropdown follows the hovered menu title (classic). - if (pop.winId !== undefined) { - const w = byId(pop.winId); - if (w?.menus) { - const r = hitRegion(w.geo(), chromeOpts(w), mx, my); - if (r?.kind === "menu" && r.index !== w.openMenu()) { - w.setOpenMenu(r.index); - const mxs = w.menus.slice(0, r.index).reduce((a, m) => a + m.width, 0); - const g = w.geo(); - setPopup({ - popup: buildPopup(g.x + FRAME + mxs, g.y + FRAME + 18 + 1 + 18, w.menus[r.index].items), - winId: w.id, - }); - } - } - } - } - - // Drags. - if (drag?.type === "move") { - const w = byId(drag.id); - if (w) { - const g = clampMove( - { ...drag.orig, x: drag.orig.x + (mx - drag.sx), y: drag.orig.y + (my - drag.sy) }, - vp().w, - vp().h, - ); - w.setGeo(g); - } - return; - } - if (drag?.type === "resize") { - const w = byId(drag.id); - if (w) { - w.setGeo( - resizeGeo(drag.orig, drag.dir, mx - drag.sx, my - drag.sy, w.minW, w.minH), - ); - } - sendCursor(cursorForDir(drag.dir)); - return; - } - if (drag?.type === "capbtn") { - const w = byId(drag.id); - if (w) { - const r = hitRegion(w.geo(), chromeOpts(w), mx, my); - w.setPressedBtn(r?.kind === "button" && r.button === drag.btn ? drag.btn : null); - } - return; - } - if (drag?.type === "minehold") { - const w = byId(drag.id); - if (w) { - const d = w.data as MinesData; - const r = hitRegion(w.geo(), chromeOpts(w), mx, my); - const cell = r?.kind === "content" ? minesHit(r.cx, r.cy) : null; - d.setHeld(cell?.type === "cell" ? cell.i : -1); - } - return; - } - if (drag?.type === "dialogbtn") { - const w = byId(drag.id); - if (w) { - const r = hitRegion(w.geo(), chromeOpts(w), mx, my); - const g = w.geo(); - const cw = g.w - FRAME * 2; - const chh = g.h - FRAME - contentTop({ menuWidths: [] }); - let over: string | null = null; - if (r?.kind === "content") { - over = - w.kind === "about" ? aboutHit(cw, chh, r.cx, r.cy) : shutdownHit(cw, chh, r.cx, r.cy); - } - (w.data as { setArmed: (s: string | null) => void }).setArmed( - over === drag.tag ? drag.tag : null, - ); - } - return; - } - - // Hover cursor shape. - let k: CursorKind = "default"; - const hover = hitWindows(mx, my); - if (hover) { - if (hover.region.kind === "resize") k = cursorForDir(hover.region.dir); - else if (hover.region.kind === "content" && hover.win.kind === "notepad") k = "text"; - } - sendCursor(k); - } - - function onPrimaryUp() { - const d = drag; - drag = null; - if (!d) return; - if (d.type === "capbtn") { - const w = byId(d.id); - if (w && w.pressedBtn() === d.btn) { - w.setPressedBtn(null); - if (d.btn === "close") closeWin(d.id); - else if (d.btn === "min") minimize(d.id); - else if (d.btn === "max") toggleMax(w); - } else w?.setPressedBtn(null); - return; - } - if (d.type === "minehold") { - const w = byId(d.id); - if (w) { - const md = w.data as MinesData; - const i = md.held(); - md.setHeld(-1); - if (i >= 0) { - const was = md.board().phase; - md.setBoard(reveal(md.board(), i)); - if (was === "ready" && md.board().phase === "playing") minesStart = virtualNow(); - } - } - return; - } - if (d.type === "smiley") { - const w = byId(d.id); - if (w) { - (w.data as MinesData).setSmileyHeld(false); - const r = hitRegion(w.geo(), chromeOpts(w), mx, my); - if (r?.kind === "content" && minesHit(r.cx, r.cy)?.type === "smiley") minesNew(w); - } - return; - } - if (d.type === "dialogbtn") { - const w = byId(d.id); - if (w) { - const data = w.data as { setArmed: (s: string | null) => void }; - const armed = (w.data as AboutData).armed(); - data.setArmed(null); - if (armed === d.tag) { - if (w.kind === "about") closeWin(w.id); - else if (w.kind === "shutdown") { - const choice = (w.data as ShutdownData).choice(); - if (d.tag === "cancel") closeWin(w.id); - else if (choice === 0) svc?.send({ t: "quit" }); - else restartSession(); - } - } - } - return; - } - if (d.type === "resize") sendCursor("default"); - } - - function onKey(ev: HostEvent) { - const k = ev.k ?? ""; - if (k === "Escape") { - if (ev.ctl) { - // Ctrl+Esc toggles the Start menu (the classic pre-Win-key chord). - setPopup(null); - setStartOpen(!startOpen()); - return; - } - if (startOpen() || popup()) closeMenus(); - return; - } - if (k === "Tab" && (ev.alt || ev.ctl)) { - cycleWindows(); - return; - } - if (k === "F4" && ev.alt) { - const w = focused(); - if (w) closeWin(w.id); - else openShutdown(); - return; - } - const w = focused(); - if (!w) return; - if (w.kind === "mines" && k === "F2") { - minesNew(w); - return; - } - if (w.kind === "notepad") { - if (k === "F5") { - insertTimeDate(w); - return; - } - const d = w.data as PadData; - const doc = d.doc(); - switch (k) { - case "Enter": - d.setDoc(insertText(doc, "\n")); - break; - case "Backspace": - d.setDoc(backspace(doc)); - break; - case "Delete": - d.setDoc(del(doc)); - break; - case "Tab": - d.setDoc(insertText(doc, " ")); - break; - case "Left": - case "Right": - case "Up": - case "Down": - case "Home": - case "End": - d.setDoc({ lines: doc.lines, caret: moveCaret(doc, k as CaretMove) }); - break; - default: - return; - } - scrollCaretIntoView(w); - return; - } - if (w.kind === "shutdown" && k === "Enter") { - const choice = (w.data as ShutdownData).choice(); - if (choice === 0) svc?.send({ t: "quit" }); - else restartSession(); - return; - } - if (w.kind === "about" && k === "Enter") closeWin(w.id); - } - - function padViewH(w: WinCtl): number { - return w.geo().h - FRAME - contentTop(chromeOpts(w)) - 2; - } - - function scrollCaretIntoView(w: WinCtl) { - const d = w.data as PadData; - const y = d.doc().caret.row * PAD_LINE_H; - const viewH = padViewH(w); - if (y - d.scroll() < 0) d.setScroll(Math.max(0, y)); - else if (y - d.scroll() > viewH - PAD_LINE_H) d.setScroll(y - viewH + PAD_LINE_H); - } - - function typeInto(w: WinCtl, s: string) { - const d = w.data as PadData; - d.setDoc(insertText(d.doc(), s)); - scrollCaretIntoView(w); - } - - // ---- taskbar ------------------------------------------------------------ - - const taskEntries = (): TaskEntry[] => - wins() - .filter((w) => w.kind !== "shutdown") - .map((w) => ({ id: w.id, title: w.title, icon: w.icon })); - const taskButtonW = () => { - const n = Math.max(1, taskEntries().length); - return Math.min(160, Math.floor((vp().w - 70 - 60 - n * 3) / n)); - }; - - function taskEntryAt(x: number, y: number): number { - if (y < vp().h - TASK_H + 3) return -1; - const entries = taskEntries(); - const w = taskButtonW(); - const x0 = 2 + 54 + 3 + 1 + 3; - for (let i = 0; i < entries.length; i++) { - const bx = x0 + i * (w + 3); - if (x >= bx && x < bx + w) { - return wins().findIndex((win) => win.id === entries[i].id); - } - } - return -1; - } - - // ---- frame pump --------------------------------------------------------- - - function handleEvent(ev: HostEvent) { - switch (ev.t) { - case "hello": { - setVp({ w: ev.w ?? 800, h: ev.h ?? 600 }); - epoch = ev.epoch ?? 0; - epochAt = virtualNow(); - break; - } - case "resize": { - const w = ev.w ?? vp().w; - const h = ev.h ?? vp().h; - setVp({ w, h }); - for (const win of wins()) { - if (win.maximized()) win.setGeo(maximizedGeo(w, h)); - else win.setGeo(clampMove(win.geo(), w, h)); - } - break; - } - case "mouse": { - if (ev.b === 2) { - if (ev.d) { - mx = ev.x ?? mx; - my = ev.y ?? my; - onRightDown(); - } - break; - } - mx = ev.x ?? mx; - my = ev.y ?? my; - const down = ev.d ?? false; - if (down && !prevDown) onPrimaryDown(ev.sh ?? false); - else if (!down && prevDown) { - onMove(); - onPrimaryUp(); - } else onMove(); - prevDown = down; - break; - } - case "key": - onKey(ev); - break; - case "ch": { - const w = focused(); - if (w?.kind === "notepad" && ev.s) typeInto(w, ev.s); - break; - } - case "paste": { - const w = focused(); - if (w?.kind === "notepad" && ev.text) typeInto(w, ev.text); - break; - } - case "ime": { - const w = focused(); - if (w?.kind === "notepad") { - const d = w.data as PadData; - d.setPreedit(ev.s ? { s: ev.s, c: ev.c ?? ev.s.length } : null); - } - break; - } - case "scroll": { - const hover = hitWindows(mx, my); - if (hover?.win.kind === "notepad") { - const d = hover.win.data as PadData; - const contentH = d.doc().lines.length * PAD_LINE_H + 6; - const maxY = Math.max(0, contentH - padViewH(hover.win)); - d.setScroll(Math.max(0, Math.min(maxY, d.scroll() + (ev.dy ?? 0)))); - } - break; - } - } - } - - function boot() { - openNotepad("welcome.txt - Notepad", WELCOME); - if (!svc) { - // Standalone (sim/goldens): a lively static arrangement. - openMines(); - openMyComputer(); - } - } - - boot(); - - onFrame(() => { - if (svc) for (const ev of svc.poll()) handleEvent(ev); - - // Taskbar clock (minute precision, anchored at the hello epoch). - if (epoch > 0) { - const t = new Date(epoch + (virtualNow() - epochAt) * 1000); - const s = `${pad2(t.getHours())}:${pad2(t.getMinutes())}`; - if (s !== clock()) setClock(s); - } - - // Minesweeper timer. - const mw = wins().find((w) => w.kind === "mines"); - if (mw) { - const d = mw.data as MinesData; - if (d.board().phase === "playing" && minesStart > 0) { - const e = Math.min(999, Math.floor(virtualNow() - minesStart)); - if (e !== d.elapsed()) d.setElapsed(e); - } - } - - // IME candidate window docking: report the focused notepad caret. - const fw = focused(); - if (svc && fw?.kind === "notepad") { - const d = fw.data as PadData; - const g = fw.geo(); - const doc = d.doc(); - const line = doc.lines[doc.caret.row] ?? ""; - const x = g.x + FRAME + 4 + measure(line.slice(0, doc.caret.col)); - const y = g.y + contentTop(chromeOpts(fw)) + 3 + doc.caret.row * PAD_LINE_H - d.scroll(); - if (x !== lastCaret.x || y !== lastCaret.y) { - lastCaret = { x, y, h: PAD_LINE_H }; - svc.send({ t: "caret", x, y, h: PAD_LINE_H }); - } - } - }); - - // ---- render ------------------------------------------------------------- - - return ( - - - - {(w) => ( - - - - )} - - - - - {(fly) => } - - - {(p) => } - - - ); -} - -function WinContent(props: { win: WinCtl; active: boolean }) { - const w = props.win; - switch (w.kind) { - case "notepad": - return ; - case "mines": - return ; - case "folder": - return ( - - ); - case "about": - return ; - case "shutdown": - return ; - } -} 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/chrome.tsx b/apps/desk98/chrome.tsx deleted file mode 100644 index 21e21f99..00000000 --- a/apps/desk98/chrome.tsx +++ /dev/null @@ -1,352 +0,0 @@ -// apps/desk98/chrome.tsx — presentational Windows 98 chrome. Every component -// here only paints; the compositor (app.tsx) owns hit testing and routes all -// pointer/keyboard input itself off the svc mouse stream, so nothing in this -// file registers a handler. Geometry mirrors wm.ts via theme.ts constants. -// -// Class strings are FULL literals throughout — the style table compiles at -// build time and template-interpolated fragments are a compile error, so the -// bevel recipes repeat verbatim instead of riding shared constants. - -import { For, Show, type JSX } from "solid-js"; -import { Image, Text, View } from "@pocketjs/framework/components"; -import { CAPTION_ACTIVE, CAPTION_INACTIVE, FONT, FONT_B, FONT_XL } from "./theme.ts"; -import type { CaptionButton } from "./wm.ts"; -import type { DeskIcon, Popup, TaskEntry, WinCtl } from "./state.ts"; - -/** W95FA text. Slots 19/20/21 ride the style prop — the class table never - * sees them (baked per-app via pak.json, docs in gen-assets.ts). */ -export function T98(props: { - class?: string; - bold?: boolean; - xl?: boolean; - children: JSX.Element; -}): JSX.Element { - return ( - - {props.children} - - ); -} - -const BTN_ICON: Record = { - min: "icons/cap-min.svg", - max: "icons/cap-max.svg", - close: "icons/cap-close.svg", -}; - -/** One window: raised frame, caption gradient, controls, menu bar, content. - * Position/size ride the style prop — translate moves are paint-only, and - * zIndex raises without reordering siblings (a reorder would rebuild the - * whole layout tree). */ -export function Window98(props: { - win: WinCtl; - active: boolean; - children: JSX.Element; -}): JSX.Element { - const w = props.win; - return ( - - - - - - {w.title} - - - - - - - - {(menu, i) => ( - - - {menu.label} - - - )} - - - - {props.children} - - ); -} - -function CaptionButtons(props: { win: WinCtl }): JSX.Element { - // Render mirrors wm.ts captionButtonXs: min/zoom adjacent, close 2px apart. - return ( - - - {(btn, i) => { - const pressed = () => props.win.pressedBtn() === btn; - const gapped = btn === "close" && i() > 0; - return ( - - - - ); - }} - - - ); -} - -/** The taskbar: Start, one button per window, the sunken clock tray. */ -export function Taskbar(props: { - entries: TaskEntry[]; - activeId: number; - startOpen: boolean; - clock: string; - buttonW: number; -}): JSX.Element { - return ( - - - - Start - - - - - {(entry) => ( - - - - {entry.title} - - - )} - - - - {props.clock} - - - ); -} - -/** Generic popup menu panel (context menus, dropdowns, start flyouts). */ -export function PopupPanel(props: { popup: Popup; hover: number }): JSX.Element { - return ( - - - {(item, i) => ( - - - - - } - > - - }> - - - - - {item.label} - - - - - {item.shortcut ?? ""} - - - - - - - - )} - - - ); -} - -/** The Start menu: navy banner + 26px rows (flyouts render as PopupPanels). */ -export function StartMenu(props: { - x: number; - y: number; - h: number; - items: Popup["items"]; - hover: number; -}): JSX.Element { - return ( - - - {/* The box is laid out horizontally (wide enough that the label - never wraps) and rotated to vertical at paint time. rotate is - style-only: the class grammar's rotate-N takes no negative - arbitrary value, and a bad token would silently void the whole - literal. */} - - - PocketJS98 - - - - - - {(item, i) => ( - - - - - } - > - - - - - {item.label} - - - - - - - - )} - - - - ); -} - -/** Desktop icons: a left column of 32px art + label, teal behind. */ -export function DesktopIcons(props: { icons: DeskIcon[]; selected: number }): JSX.Element { - return ( - - - {(icon, i) => ( - - - - {icon.label} - - - )} - - - ); -} diff --git a/apps/desk98/fonts/w95fa-19.bin b/apps/desk98/fonts/w95fa-19.bin index 1cef0a964b6d5c7c864a1327ce4efced5b578f9b..65f505eb878fdacfd1a49bac8459a6aaeaca8f38 100644 GIT binary patch delta 298 zcmWO0Nm9a46ougffkaD3j2t<&ikV9YU=*+egVKXND)xZHsBsBYWf#mSHsTUkfGokn zfArmR-mClGuQ&3oOxm#xwI|kcG-yk*71b%GWa7@A3ml|oHUL##z7j_Akx;RLfUCVN`J@Kmm)FFYf6@mx;z@PZHY@sj+FMcJCoDdb&jtjVf14w;b^G_!!;BsbQ~(C0MQ Okrgvt4o{}*Bi}z-5Gx`8 diff --git a/apps/desk98/fonts/w95fa-19@2x.bin b/apps/desk98/fonts/w95fa-19@2x.bin index 1e1c4f85865c81cc48593f08d02a51987e7e53d8..66ad9c426530f7edd38daa957c978299564cd756 100644 GIT binary patch delta 402 zcmY+;yD|e|7{>8;v$7kbFr!epfH0Bl9hkKd5{HC11(AqTA|cLa(ov8Ze2t1)X|`W8 zu0W$vxe>$Pp;7Go-sk$<+ zqvDrs93$`GIOn@KfqOVf;XY=#cYssk*A8)-e1tR7Ajg>H2TpL7e2O_S<>QktU4 f&~f(H5&S*xD5^+3NJrqO*Ycj#edW@vKQ!52hjddF delta 332 zcmXZWxlTes7{&1;BlmJGEi5bzm62#?%M}E75KzQ5;)WZzfIIRCkW53Rm5m+YL9A$e z7o&d?rjfJ7l6*NT;tU>O3=c7mM>vben2^y4Cg~-d z!&97BKxeps=eWrH0+;BQxU7)MxWc@GDZIk8oQAFms|;>1!w+sTtDvfwqt|ea^E+I} zd(88}2i)M^BNh}Kc*0HkGj3^6FSyME>bOIH#a+4j`6bBfZ!BB##=B8dc(YSupEhb7 fTbUgGcf_VfC&(e&%!ciIq-dMdL;c+S`tXx~AnG#> diff --git a/apps/desk98/fonts/w95fa-20.bin b/apps/desk98/fonts/w95fa-20.bin index 40e22466add7c647a7a47c9823f45e85eb135b0e..f57ee7b1f6bf32a625e1bda1d5b09c4658277e00 100644 GIT binary patch delta 304 zcmWO0J5mBc6ouh4j?6H@R9R(aY-C`SJK!xUhzcqSBEAKcmk6ln1R9(423Ud=3(zGQ znppBj-#w?hZui%l_497IiFhh%t2%bQnV>2uDqANQldDsV;~6IK9Fur~DZIp0&FTu% zSWj3YKVw-T4e^K%ykLd=ipL7;@1H>v$NpV1pWcG`UMZWAT{3ea N-+V0klgM%~KLsMZDmVZD delta 359 zcmWm8NlrpR5XSL((vJryY6ua+MmM-{1u-s+D+F<#XK}!JLdAJPABk>_iFFV$9Kf6C z6}W)WzrE;Rebtrju030yPnMHOXHy$CTX1E6c3!^w0PwLXULa0 z%lQ?~;Wf^4;SDY@cZ-YSweE0kCS~Fc* z2n4khZqTnpOy9${mZ>YTgjRy>n(s0C9rbCs)97m`uEoZtx9)mvOXjpSVyl~s=u14R L6=SOTM04m5Ac{K_ diff --git a/apps/desk98/fonts/w95fa-20@2x.bin b/apps/desk98/fonts/w95fa-20@2x.bin index 85b68f2507913d13322759ef56c0a252e0b13216..2ed23cf60a45a313699926e672cebf2128389cdc 100644 GIT binary patch delta 337 zcmWm8xlTes7{&1;gZIiSpf5lp#?ndzQE|b2!v*)NqJaB;V`pVYH#eV zYY<;Q(%9jy*d#h`TsMU&3MX9*!uceazFBaa69a3d0yh702-aCwSlxC*|k}r^v@R z&G`w=;3>|s@C@g;caHONnl7+FzQhGJ>k1e7z%?$BZ*W;H`fV^Mbj;h6&9^>;LE<%H ollNVOcKh;KvyHnp>w9qPgH=I-@<>ab{0q$WQ_pyKnIE9B8%GME1laH~8 zCpaTZr#OpeI7j~+=gAis$hHXnG#9r6oSG^*bLg~t9jmMLbkrXP%_v6Z}=IP+=x UU%r{}vZmDa&D(1&olL9IALB?b8vpSWZ+ql7=OU&XG=IA@PN$z4^%euw_{S9s@Jkc#fk>U*w z+`|XlXWC{GWxH@$SWf LQ@8A|cwejk0J19W delta 308 zcmWm8J5GW@6o%nX|KKknM2U@+H4Wen2!bdA3Wy3`@h&KLQ2~|2)`mC>u+Rly*nrER zbR8x}U#FPod}mH(rvGkzy;^o891U4I_^_Shy+cVMxpjmKc#L5@!3dt>BA#JXw$5>h ze1XeY#F!i^;R;@2ocWse9bz0}r@Ie#CuE>F1Y1)BhXGjAlHh@H9INTHnuR WF!yHqz6tXg@SFKb@38PP2>t<8*)7%p diff --git a/apps/desk98/fonts/w95fa-21@2x.bin b/apps/desk98/fonts/w95fa-21@2x.bin index 338504d3afe64568ba8596ff4e648b0224b25b4e..e819a62d1e1d5f8ec994fa9dee57ea28941be1cd 100644 GIT binary patch delta 326 zcmXBOxlTes6vpvyX7pakg&5z$@>cc;;w~UAxZ}Qqs35MGm}p^R#|v248jIWdD3%t! zg5W=~ncw-&OwO6_pZH5V?x$0kq*LkLkJ)BPQc~So$3Z2ufhpX?G;ZM#Zes>_a9F-} zafG~wqqvV_%sIeuJj4n5M>t77#wqowj9K~>%qh{-2}Pbp6{qnO3oJOp83j7WS#k~M zxUb_pUSN@d4P4;eB`zv3SGYvJ#%0BHgDZUC7FWr4xTaqHb}01VxhKz=$aD5JjKZ?% ex6yrI+V21S2%o!5>#JxVTe-UJi9QGHTjviQS2AY+ delta 603 zcmZ|JIZFdU6bJA(vq%&ZJP>RYQc8*t`~(}17_S(Q#9QNiYt(pO3xdKHA`E0jEW|>@ zN)ur%EEEM(_zeV2B`HKKtOSpLJkUZHc7F3_hW~r(ug2KD(NK`@uuIf>Xh`BqZA4@z z6Sc!U*a7olCoF(nun=}b2N|db7NPEiPS^*FDS`T72^@f>I3I*%sE1%VrO_~S;d}&E zKtFVoX^BR$sl>q;tU`lvSWU?kfHkNmpa=Jp&Oq+#Nh)S&>#1iRp^Ob8Y&g%2!x?Q)%buC0Jn@IWl6QY4p9tTX pD*PiDA>-oz20_ey6qcO9haO>SX^7a5~A|k#{P@x)}ff diff --git a/apps/desk98/gen-assets.ts b/apps/desk98/gen-assets.ts index 4d3a5614..32806bb7 100644 --- a/apps/desk98/gen-assets.ts +++ b/apps/desk98/gen-assets.ts @@ -70,6 +70,44 @@ async function loadSnapped(): Promise { } } } + + // 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; } diff --git a/apps/desk98/gen-icons.ts b/apps/desk98/gen-icons.ts index ef81ece5..ce930638 100644 --- a/apps/desk98/gen-icons.ts +++ b/apps/desk98/gen-icons.ts @@ -363,24 +363,25 @@ const ICONS: Icon[] = [ }, { // Recycle 16 shares the desktop art; folder-16 doubles as Documents. - // Start flag: the four-pane waving flag. - file: "start-flag.svg", + // 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: [ "................", - "....kk..........", - "..kkrrkkkkkk....", - ".krrrrrkGGGGkk..", - ".krrrrrkGGGGGGk.", - ".krrrrrkGGGGGGk.", - ".krrrrrkGGGGGGk.", - "..krrrrkGGGGGk..", - ".kkkkkkkkkkkkk..", - ".kbbbbbksssssk..", - ".kbbbbbkssssssk.", - ".kbbbbbkssssssk.", - ".kbbbbbkssssssk.", - "..kbbbbksssssk..", - "...kkkkkkkkkk...", + "................", + "................", + "..kkkkkkkkkkkk..", + ".k............k.", + ".k............k.", + ".k..kk..kkkkk.k.", + ".k.kkkk.......k.", + ".k.kkkk.kkk...k.", + ".k..kk........k.", + ".k............k.", + ".k............k.", + "..kkkkkkkkkkkk..", + "................", + "................", "................", ], }, diff --git a/apps/desk98/icons/start-flag.svg b/apps/desk98/icons/start-flag.svg deleted file mode 100644 index 26ea0c01..00000000 --- a/apps/desk98/icons/start-flag.svg +++ /dev/null @@ -1 +0,0 @@ - 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/main.tsx b/apps/desk98/main.tsx deleted file mode 100644 index 11c8a4cc..00000000 --- a/apps/desk98/main.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// apps/desk98/main.tsx — PocketJS 98 entry point. -import { mount } from "@pocketjs/framework"; -import App from "./app.tsx"; - -mount(() => ); diff --git a/apps/desk98/notepad.ts b/apps/desk98/notepad.ts index dbb1486a..fc1212dc 100644 --- a/apps/desk98/notepad.ts +++ b/apps/desk98/notepad.ts @@ -1,5 +1,6 @@ // apps/desk98/notepad.ts — pure Notepad line-editing rules (wrap off, like -// the 98 default). No Solid, no framework imports — unit-tested directly. +// the 98 default): insertion, deletion, caret movement and the selection +// model (anchor + caret). No framework imports — unit-tested directly. export interface Caret { row: number; @@ -9,16 +10,97 @@ export interface Caret { 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; \n splits the line. */ +/** 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); @@ -39,6 +121,7 @@ export function insertText(doc: Doc, s: string): Doc { } export function backspace(doc: Doc): Doc { + if (hasSel(doc)) return deleteSel(doc); let { lines, caret } = doc; lines = lines.slice(); caret = clampCaret(lines, caret); @@ -55,6 +138,7 @@ export function backspace(doc: Doc): Doc { } export function del(doc: Doc): Doc { + if (hasSel(doc)) return deleteSel(doc); let { lines, caret } = doc; lines = lines.slice(); caret = clampCaret(lines, caret); @@ -96,6 +180,23 @@ export function moveCaret(doc: Doc, key: CaretMove): Caret { } } +/** 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. */ diff --git a/apps/desk98/pocket.json b/apps/desk98/pocket.json index 92622eea..aba01973 100644 --- a/apps/desk98/pocket.json +++ b/apps/desk98/pocket.json @@ -21,9 +21,9 @@ } }, "app": { - "entry": "apps/desk98/main.tsx", + "entry": "apps/desk98/main.ts", "output": "desk98-main", - "framework": "solid", + "framework": "vue-vapor", "companions": [ "desk" ], 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/programs.tsx b/apps/desk98/programs.tsx deleted file mode 100644 index a8daebc8..00000000 --- a/apps/desk98/programs.tsx +++ /dev/null @@ -1,488 +0,0 @@ -// apps/desk98/programs.tsx — the window contents: Notepad, Minesweeper, the -// Explorer-style folder view, About and Shut Down dialogs. Presentational -// like chrome.tsx; each program also exports the content-local hit helpers -// app.tsx routes clicks through, so render geometry and hit geometry sit in -// one file. 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 { For, Index, Show, type Accessor, type JSX } from "solid-js"; -import { Image, Text, View } from "@pocketjs/framework/components"; -import { getOps } from "@pocketjs/framework/host"; -import { T98 } from "./chrome.tsx"; -import { FONT } from "./theme.ts"; -import type { Doc } from "./notepad.ts"; -import { MINES_W, type Mines } 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 - -export interface PadData { - kind: "notepad"; - doc: Accessor; - setDoc: (d: Doc) => void; - scroll: Accessor; - setScroll: (y: number) => void; - preedit: Accessor<{ s: string; c: number } | null>; - setPreedit: (p: { s: string; c: number } | null) => void; -} - -export function NotepadView(props: { data: PadData; active: boolean }): JSX.Element { - const d = props.data; - const caretRow = () => d.doc().caret.row; - const caretX = () => { - const doc = d.doc(); - const line = doc.lines[doc.caret.row] ?? ""; - const pre = d.preedit(); - const head = line.slice(0, doc.caret.col); - return measure(pre ? head + pre.s.slice(0, pre.c) : head); - }; - return ( - - - - - {(line, row) => ( - - {line()}} - > - {line().slice(0, d.doc().caret.col)} - - {d.preedit()?.s ?? ""} - - - {line().slice(d.doc().caret.col)} - - - )} - - - - - - - - ); -} - -// --------------------------------------------------------------------------- -// 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 interface MinesData { - kind: "mines"; - board: Accessor; - setBoard: (m: Mines) => void; - /** Cell index held by the primary button, -1 none. */ - held: Accessor; - setHeld: (i: number) => void; - smileyHeld: Accessor; - setSmileyHeld: (h: boolean) => void; - /** Seconds shown by the timer (app.tsx advances it while playing). */ - elapsed: Accessor; - setElapsed: (s: number) => void; -} - -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; -} - -const NUM_COLORS = [ - "", // 0 unused - "text-[#0000ff]", - "text-[#008000]", - "text-[#ff0000]", - "text-[#000080]", - "text-[#800000]", - "text-[#008080]", - "text-[#000000]", - "text-[#808080]", -]; - -/** One 7-seg digit, 13×23, red on black. */ -function Digit(props: { ch: Accessor }): JSX.Element { - // a - // f b segments: bit 0..6 = a b c d e f g - // g - // e c - // d - const SEGS: Record = { - "0": 0b0111111, - "1": 0b0000110, - "2": 0b1011011, - "3": 0b1001111, - "4": 0b1100110, - "5": 0b1101101, - "6": 0b1111101, - "7": 0b0000111, - "8": 0b1111111, - "9": 0b1101111, - "-": 0b1000000, - " ": 0, - }; - const on = (bit: number) => ((SEGS[props.ch()] ?? 0) >> bit) & 1; - const seg = (bit: number, cls: string, off: string) => ( - - ); - return ( - - {seg(0, "absolute left-[2] top-[1] w-[9] h-[2] bg-[#ff0000]", "absolute left-[2] top-[1] w-[9] h-[2] bg-[#3a0000]")} - {seg(1, "absolute left-[10] top-[2] w-[2] h-[9] bg-[#ff0000]", "absolute left-[10] top-[2] w-[2] h-[9] bg-[#3a0000]")} - {seg(2, "absolute left-[10] top-[12] w-[2] h-[9] bg-[#ff0000]", "absolute left-[10] top-[12] w-[2] h-[9] bg-[#3a0000]")} - {seg(3, "absolute left-[2] top-[20] w-[9] h-[2] bg-[#ff0000]", "absolute left-[2] top-[20] w-[9] h-[2] bg-[#3a0000]")} - {seg(4, "absolute left-[1] top-[12] w-[2] h-[9] bg-[#ff0000]", "absolute left-[1] top-[12] w-[2] h-[9] bg-[#3a0000]")} - {seg(5, "absolute left-[1] top-[2] w-[2] h-[9] bg-[#ff0000]", "absolute left-[1] top-[2] w-[2] h-[9] bg-[#3a0000]")} - {seg(6, "absolute left-[2] top-[10] w-[9] h-[3] bg-[#ff0000]", "absolute left-[2] top-[10] w-[9] h-[3] bg-[#3a0000]")} - - ); -} - -function Counter(props: { value: Accessor }): JSX.Element { - const text = () => { - const v = Math.max(-99, Math.min(999, Math.round(props.value()))); - return v < 0 ? "-" + String(-v).padStart(2, "0") : String(v).padStart(3, "0"); - }; - return ( - - text()[0]} /> - text()[1]} /> - text()[2]} /> - - ); -} - -export function MinesView(props: { data: MinesData }): JSX.Element { - const d = props.data; - const smiley = () => { - if (d.smileyHeld()) return "icons/smile.svg"; - const m = d.board(); - if (m.phase === "lost") return "icons/smile-dead.svg"; - if (m.phase === "won") return "icons/smile-cool.svg"; - if (d.held() >= 0) return "icons/smile-ooh.svg"; - return "icons/smile.svg"; - }; - return ( - - - 10 - d.board().flags} /> - - - - - - - - - {(_, ry) => ( - - - {(_, rx) => } - - - )} - - - - ); -} - -const ROWS9 = Array.from({ length: MINES_W }, (_, i) => i); - -function MinesCell(props: { data: MinesData; i: number }): JSX.Element { - const c = () => props.data.board().cells[props.i]; - const bust = () => props.data.board().bust === props.i; - const heldDown = () => props.data.held() === props.i && c().state === "hidden"; - return ( - - - - - - } - > - - 0}> - - {String(c().adj)} - - - }> - - - - - ); -} - -// --------------------------------------------------------------------------- -// Folder (Explorer details view) -// --------------------------------------------------------------------------- - -export const FOLDER_HEADER_H = 17; -export const FOLDER_ROW_H = 17; -export const FOLDER_STATUS_H = 20; - -export interface FolderRow { - icon: string; - name: string; - size: string; - type: string; - open?: () => void; -} - -export interface FolderData { - kind: "folder"; - rows: FolderRow[]; - selected: Accessor; - setSelected: (i: number) => void; -} - -/** 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; -} - -export function FolderView(props: { data: FolderData; active: boolean; resizable: boolean }): JSX.Element { - const d = props.data; - return ( - - - - - Name - - - Size - - - Type - - - - {(row, i) => ( - - - - - {row.name} - - - - - {row.size} - - - - - {row.type} - - - - )} - - - - (empty) - - - - - - {`${d.rows.length} object(s)`} - - - - - - - ); -} - -// --------------------------------------------------------------------------- -// About + Shut Down dialogs -// --------------------------------------------------------------------------- - -export const ABOUT_GEO = { w: 340, h: 216 } as const; -export const SHUTDOWN_GEO = { w: 300, h: 176 } as const; - -function Button98(props: { label: string; armed: boolean; def?: boolean }): JSX.Element { - return ( - - - {props.label} - - - ); -} - -export interface AboutData { - kind: "about"; - armed: Accessor; -} - -/** 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 function AboutView(props: { data: AboutData }): JSX.Element { - return ( - - - - PocketJS 98 - - - - - - A desktop compositor demo on the gpui backend. - Same DrawList contract the consoles boot; the - windows, menus and shortcuts live in the guest. - github.com/pocket-stack/pocketjs - - - - - - ); -} - -export interface ShutdownData { - kind: "shutdown"; - choice: Accessor; - setChoice: (i: number) => void; - armed: Accessor; -} - -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; -} - -export function ShutdownView(props: { data: ShutdownData }): JSX.Element { - const radio = (i: number, label: string) => ( - - - - - - - - - {label} - - ); - return ( - - - - - What do you want the computer to do? - - - - - {radio(0, "Shut down")} - {radio(1, "Restart")} - - - - - - - - ); -} diff --git a/apps/desk98/screenshot.png b/apps/desk98/screenshot.png index 46e2fcfe7e333dc829d0053b361f3db0b11b2393..93522dd2b65568390b91ad14f6a5328960db5320 100644 GIT binary patch literal 63444 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 bCY?T(#>>~wUjvt$AS;%yUY7Z#|Dpd6!j1;e literal 71281 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 Tth}ND{`|0Q&(@spy#Don0acz0 diff --git a/apps/desk98/state.ts b/apps/desk98/state.ts index 706d2916..37a97f44 100644 --- a/apps/desk98/state.ts +++ b/apps/desk98/state.ts @@ -1,10 +1,12 @@ // apps/desk98/state.ts — compositor state: window controls, popups, desktop -// icons. Hot geometry lives in per-window signals so a drag re-evaluates one +// 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 { createSignal, type Accessor } from "solid-js"; +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"; @@ -12,7 +14,8 @@ export interface MenuDef { label: string; /** Hit width in px (measured at open — mirrors the render's px-[6] pads). */ width: number; - items: PopupItem[]; + /** Built at open — disabled states follow live state (selection, …). */ + items: () => PopupItem[]; } export interface PopupItem { @@ -42,25 +45,65 @@ export interface WinCtl { minW: number; minH: number; menus: MenuDef[] | null; - geo: Accessor; - setGeo: (g: Geo) => void; - z: Accessor; - setZ: (z: number) => void; - minimized: Accessor; - setMinimized: (m: boolean) => void; - maximized: Accessor; - setMaximized: (m: boolean) => void; + geo: ShallowRef; + z: Ref; + minimized: Ref; + maximized: Ref; /** Geometry to restore on un-maximize. */ restoreGeo: Geo | null; - pressedBtn: Accessor; - setPressedBtn: (b: CaptionButton | null) => void; + pressedBtn: Ref; /** Open menu-bar index, -1 closed. */ - openMenu: Accessor; - setOpenMenu: (i: number) => void; - /** Program-specific state bag (notepad lines, the mines board, …). */ + 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; @@ -87,12 +130,6 @@ export function createWin(spec: { menus?: MenuDef[] | null; data?: unknown; }): WinCtl { - const [geo, setGeo] = createSignal(spec.geo); - const [z, setZ] = createSignal(0); - const [minimized, setMinimized] = createSignal(false); - const [maximized, setMaximized] = createSignal(false); - const [pressedBtn, setPressedBtn] = createSignal(null); - const [openMenu, setOpenMenu] = createSignal(-1); return { id: nextId++, kind: spec.kind, @@ -103,19 +140,13 @@ export function createWin(spec: { minW: spec.minW ?? 200, minH: spec.minH ?? 120, menus: spec.menus ?? null, - geo, - setGeo, - z, - setZ, - minimized, - setMinimized, - maximized, - setMaximized, + geo: shallowRef(spec.geo), + z: ref(0), + minimized: ref(false), + maximized: ref(false), restoreGeo: null, - pressedBtn, - setPressedBtn, - openMenu, - setOpenMenu, + pressedBtn: ref(null), + openMenu: ref(-1), data: spec.data, }; } diff --git a/apps/desk98/svc.ts b/apps/desk98/svc.ts index cc6ea942..89817a22 100644 --- a/apps/desk98/svc.ts +++ b/apps/desk98/svc.ts @@ -8,7 +8,9 @@ // {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:"paste", text} system clipboard (⌘V) +// {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) @@ -17,6 +19,7 @@ // 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 @@ -39,6 +42,8 @@ export interface HostEvent { 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. */ @@ -63,6 +68,7 @@ export interface Svc { line: | { t: "quit" } | { t: "copy"; text: string } + | { t: "paste-req" } | { t: "caret"; x: number; y: number; h: number } | { t: "cursor"; k: CursorKind }, ): void; diff --git a/apps/desk98/theme.ts b/apps/desk98/theme.ts index 214e3cd8..48c7a3db 100644 --- a/apps/desk98/theme.ts +++ b/apps/desk98/theme.ts @@ -30,8 +30,7 @@ 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; -export const CLOSE_GAP = 2; // min/zoom adjacent, close 2px apart (authentic) +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; diff --git a/apps/desk98/wm.ts b/apps/desk98/wm.ts index 0484b3a5..8e68d0d8 100644 --- a/apps/desk98/wm.ts +++ b/apps/desk98/wm.ts @@ -5,13 +5,12 @@ // // 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: [min][zoom] -// 2px [close], each 16×14, vertically 2px under the caption top. +// 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, - CLOSE_GAP, FRAME, MENU_H, RESIZE_BAND, @@ -47,15 +46,13 @@ export interface ChromeOpts { menuWidths: readonly number[]; } -/** Left x of each caption button, right-aligned inside the frame. */ +/** 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; - // The classic 2px gap sits between close and its neighbors only. - if (buttons[i] === "close" && i > 0) right -= CLOSE_GAP; } return xs; } diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index d2d68533..37bd234a 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -114,24 +114,31 @@ rendered`); a settled app shows M ≪ N. 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, a boot -epoch in the hello**, and a `{t:"cursor"}` guest intent that sets the -window's pointer shape. `apps/desk98` — a Windows 98 desktop compositor — -is the reference consumer: the guest owns every window (drag, resize, -z-order, menus, 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. +**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, Alt+Tab, Ctrl+Esc, Alt+F4 +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 [alt+][ctl+]NAME@TICK`, -`--type TEXT@TICK`. +X,Y[,d|u|r]@TICK` (drags, right clicks), `--key +[cmd+][alt+][ctl+]NAME@TICK`, `--type TEXT@TICK`. ## Choosing a backend diff --git a/hosts/macos/src/main.rs b/hosts/macos/src/main.rs index 105dc2e1..48fd98f5 100644 --- a/hosts/macos/src/main.rs +++ b/hosts/macos/src/main.rs @@ -25,10 +25,13 @@ //! 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, a boot epoch in the hello -//! (for the guest's wall clock), and a {t:"cursor"} guest intent that sets -//! the window's pointer shape. Wired when the plan's companion list names -//! "desk"; the note dialect stays byte-compatible (extra fields ignored). +//! 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; @@ -82,8 +85,8 @@ enum ScriptEvent { /// 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 alt+/ctl+/sh+ prefixes (svc `key` line). - Key(u64, String, bool, bool, bool), + /// Named key with optional cmd+/alt+/ctl+/sh+ prefixes (svc `key` line). + Key(u64, String, bool, bool, bool, bool), } struct Args { @@ -202,14 +205,17 @@ fn parse_args() -> Result { )); } "--key" => { - // --key [alt+][ctl+][sh+]NAME@TICK — scripted svc key line. + // --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 alt, mut ctl, mut sh) = (false, false, false); + let (mut cmd, mut alt, mut ctl, mut sh) = (false, false, false, false); loop { - if let Some(rest) = name.strip_prefix("alt+") { + 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+") { @@ -222,8 +228,14 @@ fn parse_args() -> Result { break; } } - args.script - .push(ScriptEvent::Key(t.parse()?, name.to_string(), alt, ctl, sh)); + 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, @@ -559,9 +571,9 @@ impl PocketRoot { )); } } - ScriptEvent::Key(t, ref k, alt, ctl, sh) if t == tick => { + ScriptEvent::Key(t, ref k, cmd, alt, ctl, sh) if t == tick => { self.svc(serde_json::json!( - {"t": "key", "k": k, "sh": sh, "alt": alt, "ctl": ctl} + {"t": "key", "k": k, "cmd": cmd, "sh": sh, "alt": alt, "ctl": ctl} )); } _ => {} @@ -658,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, @@ -710,6 +731,32 @@ impl PocketRoot { 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( @@ -762,15 +809,12 @@ impl PocketRoot { "t": "key", "k": k, "sh": shift, "alt": ks.modifiers.alt, "ctl": ks.modifiers.control, })); - } 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})); - } } + // 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; 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 index 89e3546b..0debcf5d 100644 --- a/tests/desk98.test.ts +++ b/tests/desk98.test.ts @@ -1,8 +1,7 @@ // tests/desk98.test.ts — PocketJS 98: window-manager chrome math, the -// Minesweeper rules and Notepad line editing (all pure), plus a sim boot -// smoke test of the standalone desktop. -// -// bun tools/build.ts desk98-main && bun test tests/desk98.test.ts +// 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 { @@ -23,8 +22,22 @@ import { reveal, toggleFlag, } from "../apps/desk98/mines.ts"; -import { backspace, colFromX, del, insertText, moveCaret } from "../apps/desk98/notepad.ts"; -import { runScenario, treeHasText } from "../hosts/sim/sim.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 @@ -39,12 +52,12 @@ const OPTS: ChromeOpts = { }; describe("caption buttons", () => { - test("min/zoom adjacent, close 2px apart, flush right inside the frame", () => { + 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 - 2); // the classic close gap - expect(xs[0]).toBe(xs[1] - 16); // min/zoom adjacent + 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", () => { @@ -259,16 +272,81 @@ describe("notepad editing", () => { }); // --------------------------------------------------------------------------- -// sim smoke: the standalone desktop (no desk companion) boots a static -// arrangement — the unmodified-app base case. +// notepad.ts — selection model // --------------------------------------------------------------------------- -describe("desk98-main boots standalone", () => { - test("desktop, taskbar and the boot windows render", async () => { - const trace = await runScenario({ app: "desk98-main", 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); +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/tools/test.ts b/tools/test.ts index 9fc7a4f5..d25eaaab 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -116,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"]],