diff --git a/apps/desk98/app.tsx b/apps/desk98/app.tsx index 1daaaa4a..8f8a3da4 100644 --- a/apps/desk98/app.tsx +++ b/apps/desk98/app.tsx @@ -63,23 +63,34 @@ import { minesHit, NotepadView, PAD_LINE_H, + padSegs, + padWidth, + padWrapW, SHUTDOWN_GEO, ShutdownView, shutdownHit, } from "./programs.tsx"; import { - applyMove, + applyMoveWrapped, backspace, - colFromX, + caretAtPoint, + caretXY, del, deleteSel, + docEquals, + emptyHistory, hasSel, insertText, + record, + redoStep, selectAll, selectedText, + undoStep, wordRangeAt, + type Caret, type CaretMove, type Doc, + type EditKind, } from "./notepad.ts"; import { newMines, reveal, toggleFlag } from "./mines.ts"; import { CAPTION_ACTIVE, CAPTION_INACTIVE, FRAME, TASK_H } from "./theme.ts"; @@ -88,10 +99,9 @@ import { CaptionButtons, DesktopIcons, PopupPanel, StartMenu, T98, Taskbar } fro const WELCOME = [ "Welcome to PocketJS 98.", "", - "This desktop is one PocketJS guest: the windows,", - "the taskbar, the Start menu and this Notepad are", - "Vue Vapor JSX over the same DrawList contract", - "the consoles boot, painted by the gpui backend.", + "This desktop is one PocketJS guest: the windows, the taskbar, the Start menu and this Notepad are Vue Vapor JSX over the same DrawList contract the consoles boot, painted by the gpui backend.", + "", + "Word wrap is on (Edit > Word Wrap) - these paragraphs are single logical lines; resize the window and they reflow live.", "", "Things to try:", " - drag windows by the title bar", @@ -102,8 +112,7 @@ const WELCOME = [ " - Cmd+` cycles windows, Cmd+W closes them", " - Cmd+Esc opens the Start menu", "", - "The font is W95FA, baked to the same atlas", - "format every other PocketJS target reads.", + "The font is W95FA, baked to the same atlas format every other PocketJS target reads.", ]; type Drag = @@ -170,7 +179,7 @@ function Window98(props: { win: WinCtl; active: boolean }) { ) : null} {w.kind === "notepad" ? ( - + ) : w.kind === "mines" ? ( ) : w.kind === "folder" ? ( @@ -290,8 +299,7 @@ export default function App() { const p = focusedPad(); if (!p || !hasSel(p.d.doc.value)) return; copySel(); - p.d.doc.value = deleteSel(p.d.doc.value); - scrollCaretIntoView(p.w); + applyEdit(p.w, "other", deleteSel(p.d.doc.value)); } function pasteReq(): void { @@ -317,6 +325,8 @@ export default function App() { }), scroll: ref(0), preedit: ref<{ s: string; c: number } | null>(null), + wrap: ref(true), + hist: emptyHistory(), }; const w = createWin({ kind: "notepad", @@ -333,7 +343,7 @@ export default function App() { { label: "New", act: () => { - data.doc.value = { lines: [""], caret: { row: 0, col: 0 } }; + applyEdit(w, "other", { lines: [""], caret: { row: 0, col: 0 } }); }, }, { sep: true, label: "" }, @@ -350,6 +360,23 @@ export default function App() { label: "Edit", width: measure("Edit") + 12, items: () => [ + { + label: "Undo", + shortcut: "Cmd+Z", + disabled: data.hist.undo.length === 0, + act: () => { + undoIn(w); + }, + }, + { + label: "Redo", + shortcut: "Cmd+Shift+Z", + disabled: data.hist.redo.length === 0, + act: () => { + redoIn(w); + }, + }, + { sep: true, label: "" }, { label: "Cut", shortcut: "Cmd+X", disabled: !hasSel(data.doc.value), act: cutSel }, { label: "Copy", shortcut: "Cmd+C", disabled: !hasSel(data.doc.value), act: copySel }, { label: "Paste", shortcut: "Cmd+V", act: pasteReq }, @@ -369,7 +396,14 @@ export default function App() { }, }, { sep: true, label: "" }, - { label: "Word Wrap", disabled: true }, + { + label: "Word Wrap", + checked: data.wrap.value, + act: () => { + data.wrap.value = !data.wrap.value; + scrollCaretIntoView(w); + }, + }, ], }, { @@ -563,7 +597,7 @@ export default function App() { const d = padOf(w); 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.doc.value = insertText(d.doc.value, stamp); + applyEdit(w, "other", insertText(d.doc.value, stamp)); } // ---- desktop icons + start menu ---------------------------------------------- @@ -741,15 +775,12 @@ export default function App() { } } - /** Notepad caret position for a content-local point (row clamped). */ - function padCaretAt(d: PadData, cx: number, cy: number): { row: number; col: number } { - const doc = d.doc.value; - const row = Math.max( - 0, - Math.min(doc.lines.length - 1, Math.floor((cy - 3 + d.scroll.value) / PAD_LINE_H)), - ); - const col = colFromX(doc.lines[row], cx - 3, (s) => measure(s)); - return { row, col }; + /** Notepad caret for a content-local point, over the wrapped layout + * (visual row from y, column from x inside that segment). */ + function padCaretAt(w: WinCtl, cx: number, cy: number): Caret { + const d = padOf(w); + const vrow = Math.floor((cy - 3 + d.scroll.value) / PAD_LINE_H); + return caretAtPoint(padSegs(w), d.doc.value.lines, vrow, cx - 3, padWidth); } // ---- input routing -------------------------------------------------------------- @@ -863,16 +894,20 @@ export default function App() { if (w.kind === "notepad") { const d = padOf(w); const doc = d.doc.value; - const { row, col } = padCaretAt(d, cx, cy); + const caret = padCaretAt(w, cx, cy); if (isDblClick(`pad:${w.id}`)) { // Double-click: select the word under the pointer. - const r = wordRangeAt(doc.lines[row], col); - d.doc.value = { lines: doc.lines, caret: { row, col: r.to }, anchor: { row, col: r.from } }; + const r = wordRangeAt(doc.lines[caret.row], caret.col); + d.doc.value = { + lines: doc.lines, + caret: { row: caret.row, col: r.to }, + anchor: { row: caret.row, col: r.from }, + }; return; } // Click places the caret; shift-click extends; dragging selects. - const anchor = shift ? (doc.anchor ?? doc.caret) : { row, col }; - d.doc.value = { lines: doc.lines, caret: { row, col }, anchor }; + const anchor = shift ? (doc.anchor ?? doc.caret) : { row: caret.row, col: caret.col }; + d.doc.value = { lines: doc.lines, caret, anchor }; drag = { type: "textsel", id: w.id }; return; } @@ -1083,13 +1118,13 @@ export default function App() { const cx = mx - g.x - FRAME; const cy = my - g.y - contentTop(chromeOpts(w)); const doc = d.doc.value; - const { row, col } = padCaretAt(d, Math.max(0, cx), cy); - if (row !== doc.caret.row || col !== doc.caret.col) { - d.doc.value = { - lines: doc.lines, - caret: { row, col }, - anchor: doc.anchor ?? doc.caret, - }; + const caret = padCaretAt(w, Math.max(0, cx), cy); + if ( + caret.row !== doc.caret.row || + caret.col !== doc.caret.col || + (caret.end ?? false) !== (doc.caret.end ?? false) + ) { + d.doc.value = { lines: doc.lines, caret, anchor: doc.anchor ?? doc.caret }; } } sendCursor("text"); @@ -1201,8 +1236,16 @@ export default function App() { } /** macOS-style ⌘ chords (host forwards them cmd-flagged, raw lowercase k). */ - function onCmd(k: string) { + function onCmd(k: string, shift: boolean) { switch (k) { + case "z": { + const p = focusedPad(); + if (p) { + if (shift) redoIn(p.w); + else undoIn(p.w); + } + return; + } case "escape": toggleStart(); return; @@ -1239,7 +1282,7 @@ export default function App() { function onKey(ev: HostEvent) { const k = ev.k ?? ""; if (ev.cmd) { - onCmd(k); + onCmd(k, ev.sh ?? false); return; } if (k === "Escape") { @@ -1261,24 +1304,24 @@ export default function App() { const doc = d.doc.value; switch (k) { case "Enter": - d.doc.value = insertText(doc, "\n"); - break; + applyEdit(w, "other", insertText(doc, "\n")); + return; case "Backspace": - d.doc.value = backspace(doc); - break; + applyEdit(w, "erase", backspace(doc)); + return; case "Delete": - d.doc.value = del(doc); - break; + applyEdit(w, "erase", del(doc)); + return; case "Tab": - d.doc.value = insertText(doc, " "); - break; + applyEdit(w, "type", insertText(doc, " ")); + return; case "Left": case "Right": case "Up": case "Down": case "Home": case "End": - d.doc.value = applyMove(doc, k as CaretMove, ev.sh ?? false); + d.doc.value = applyMoveWrapped(doc, k as CaretMove, ev.sh ?? false, padSegs(w), padWidth); break; default: return; @@ -1301,18 +1344,49 @@ export default function App() { function scrollCaretIntoView(w: WinCtl) { const d = padOf(w); - const y = d.doc.value.caret.row * PAD_LINE_H; + const vrow = caretXY(padSegs(w), d.doc.value.lines, d.doc.value.caret, padWidth).vrow; + const y = vrow * PAD_LINE_H; const viewH = padViewH(w); if (y - d.scroll.value < 0) d.scroll.value = Math.max(0, y); else if (y - d.scroll.value > viewH - PAD_LINE_H) d.scroll.value = y - viewH + PAD_LINE_H; } - function typeInto(w: WinCtl, s: string) { + /** Apply an EDIT (never a plain caret/selection move) with an undo + * snapshot. Coalescing lives in notepad.ts record(); no-op edits record + * nothing. */ + function applyEdit(w: WinCtl, kind: EditKind, next: Doc) { const d = padOf(w); - d.doc.value = insertText(d.doc.value, s); + const prev = d.doc.value; + if (docEquals(prev, next)) return; + d.hist = record(d.hist, prev, next, kind); + d.doc.value = next; scrollCaretIntoView(w); } + function undoIn(w: WinCtl) { + const d = padOf(w); + const r = undoStep(d.hist, d.doc.value); + if (!r) return; + d.hist = r.h; + d.doc.value = r.doc; + d.preedit.value = null; + scrollCaretIntoView(w); + } + + function redoIn(w: WinCtl) { + const d = padOf(w); + const r = redoStep(d.hist, d.doc.value); + if (!r) return; + d.hist = r.h; + d.doc.value = r.doc; + d.preedit.value = null; + scrollCaretIntoView(w); + } + + function typeInto(w: WinCtl, s: string, kind: EditKind = "type") { + applyEdit(w, kind, insertText(padOf(w).doc.value, s)); + } + // ---- taskbar -------------------------------------------------------------------- const taskEntries = (): TaskEntry[] => @@ -1388,7 +1462,7 @@ export default function App() { } case "paste": { const w = focused(); - if (w?.kind === "notepad" && ev.text) typeInto(w, ev.text); + if (w?.kind === "notepad" && ev.text) typeInto(w, ev.text, "other"); break; } case "ime": { @@ -1396,7 +1470,7 @@ export default function App() { if (w?.kind === "notepad") { const d = padOf(w); // Composition replaces the selection the moment it starts. - if (ev.s && hasSel(d.doc.value)) d.doc.value = deleteSel(d.doc.value); + if (ev.s && hasSel(d.doc.value)) applyEdit(w, "other", deleteSel(d.doc.value)); d.preedit.value = ev.s ? { s: ev.s, c: ev.c ?? ev.s.length } : null; } break; @@ -1405,7 +1479,7 @@ export default function App() { const hover = hitWindows(mx, my); if (hover?.win.kind === "notepad") { const d = padOf(hover.win); - const contentH = d.doc.value.lines.length * PAD_LINE_H + 6; + const contentH = padSegs(hover.win).length * PAD_LINE_H + 6; const maxY = Math.max(0, contentH - padViewH(hover.win)); d.scroll.value = Math.max(0, Math.min(maxY, d.scroll.value + (ev.dy ?? 0))); } @@ -1451,9 +1525,9 @@ export default function App() { const d = padOf(fw); const g = fw.geo.value; const doc = d.doc.value; - 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.value; + const pos = caretXY(padSegs(fw), doc.lines, doc.caret, padWidth); + const x = g.x + FRAME + 4 + pos.x; + const y = g.y + contentTop(chromeOpts(fw)) + 3 + pos.vrow * PAD_LINE_H - d.scroll.value; if (x !== lastCaret.x || y !== lastCaret.y) { lastCaret = { x, y, h: PAD_LINE_H }; svc.send({ t: "caret", x, y, h: PAD_LINE_H }); diff --git a/apps/desk98/chrome.tsx b/apps/desk98/chrome.tsx index 54e6e034..af615b74 100644 --- a/apps/desk98/chrome.tsx +++ b/apps/desk98/chrome.tsx @@ -138,7 +138,9 @@ export function PopupPanel(props: { popup: Popup; hover: number }) { : "h-[18] flex-row items-center gap-[5] pl-[4] pr-[8]" } > - {item.icon ? ( + {item.checked ? ( + + ) : item.icon ? ( ) : ( diff --git a/apps/desk98/gen-icons.ts b/apps/desk98/gen-icons.ts index ce930638..d62e59ee 100644 --- a/apps/desk98/gen-icons.ts +++ b/apps/desk98/gen-icons.ts @@ -420,6 +420,28 @@ const NATIVE: Icon[] = [ rows: ["........", "..k.....", "..kk....", "..kkk...", "..kkkk..", "..kkk...", "..kk....", "..k....."], }, { file: "grip.svg", rows: grip16() }, + { + // Menu checkmark (checked toggle items, e.g. Edit > Word Wrap). + file: "check-16.svg", + rows: [ + "................", + "................", + "................", + "..........kk....", + ".........kkk....", + "........kkk.....", + "..kk...kkk......", + "..kkk.kkk.......", + "...kkkkk........", + "....kkk.........", + ".....k..........", + "................", + "................", + "................", + "................", + "................", + ], + }, { file: "mine.svg", rows: [ diff --git a/apps/desk98/icons/check-16.svg b/apps/desk98/icons/check-16.svg new file mode 100644 index 00000000..e1abc350 --- /dev/null +++ b/apps/desk98/icons/check-16.svg @@ -0,0 +1 @@ + diff --git a/apps/desk98/notepad.ts b/apps/desk98/notepad.ts index fc1212dc..762144f5 100644 --- a/apps/desk98/notepad.ts +++ b/apps/desk98/notepad.ts @@ -1,10 +1,24 @@ -// apps/desk98/notepad.ts — pure Notepad line-editing rules (wrap off, like -// the 98 default): insertion, deletion, caret movement and the selection -// model (anchor + caret). No framework imports — unit-tested directly. +// apps/desk98/notepad.ts — pure Notepad text rules: insertion, deletion, +// caret movement, the selection model (anchor + caret) and the word-wrap +// layout (logical lines → visual segments). No framework imports — +// unit-tested directly. +// +// The wrap model: the Doc stores LOGICAL lines and the caret/selection live +// in logical (row, col) coordinates; wrapDoc() projects each line onto 1..N +// VISUAL segments {row, from, to} under a pixel width, and every mapping in +// both directions (caret → x/vrow, click → caret, Up/Down/Home/End) goes +// through the same segment list — render geometry and hit geometry share one +// source of truth. Widths come from a caller-supplied measure function; +// glyph advances are additive in this engine (no kerning pairs), so a +// slice's width is exact. export interface Caret { row: number; col: number; + /** Wrap affinity: at a soft-wrap boundary column this caret belongs to + * the END of the earlier visual row (End key, clicks past a row's text), + * not the start of the next. Edits and plain moves never set it. */ + end?: boolean; } export interface Doc { @@ -15,6 +29,73 @@ export interface Doc { anchor?: Caret | null; } +// --------------------------------------------------------------------------- +// Undo/redo history +// --------------------------------------------------------------------------- +// Docs are immutable values (every edit returns a fresh one), so history is +// plain snapshots — O(1) per step, structure shared. Coalescing: consecutive +// edits of the same CONTINUOUS kind ("type" runs, "erase" runs) collapse +// into one undo unit; "other" edits (Enter, paste, cut, Time/Date, New) +// always stand alone. Continuity is checked against `tip` — the doc the +// last recorded edit produced — so a caret move or click between keystrokes +// breaks the group without recording anything itself. + +export type EditKind = "type" | "erase" | "other"; + +export interface History { + undo: readonly Doc[]; + redo: readonly Doc[]; + kind: EditKind | null; + tip: Doc | null; +} + +const HISTORY_DEPTH = 200; + +export function emptyHistory(): History { + return { undo: [], redo: [], kind: null, tip: null }; +} + +/** Two docs hold the same text + caret + selection (cheap: shared strings). */ +export function docEquals(a: Doc, b: Doc): boolean { + if (a.lines.length !== b.lines.length) return false; + for (let i = 0; i < a.lines.length; i++) if (a.lines[i] !== b.lines[i]) return false; + const sameCaret = a.caret.row === b.caret.row && a.caret.col === b.caret.col; + const an = a.anchor ?? null; + const bn = b.anchor ?? null; + const sameAnchor = + an === bn || (an !== null && bn !== null && an.row === bn.row && an.col === bn.col); + return sameCaret && sameAnchor; +} + +/** Record an edit prev → next. Clears the redo stack; coalesces per the + * rules above; caps the depth. */ +export function record(h: History, prev: Doc, next: Doc, kind: EditKind): History { + const cont = kind !== "other" && kind === h.kind && h.tip === prev; + let undo = cont ? h.undo : [...h.undo, prev]; + if (undo.length > HISTORY_DEPTH) undo = undo.slice(undo.length - HISTORY_DEPTH); + return { undo, redo: [], kind, tip: next }; +} + +/** Pop one undo unit; null when empty. The current doc moves to redo. */ +export function undoStep(h: History, current: Doc): { h: History; doc: Doc } | null { + if (h.undo.length === 0) return null; + const doc = h.undo[h.undo.length - 1]; + return { + h: { undo: h.undo.slice(0, -1), redo: [...h.redo, current], kind: null, tip: null }, + doc, + }; +} + +/** Pop one redo unit; null when empty. The current doc moves back to undo. */ +export function redoStep(h: History, current: Doc): { h: History; doc: Doc } | null { + if (h.redo.length === 0) return null; + const doc = h.redo[h.redo.length - 1]; + return { + h: { undo: [...h.undo, current], redo: h.redo.slice(0, -1), kind: null, tip: null }, + doc, + }; +} + // --------------------------------------------------------------------------- // Selection // --------------------------------------------------------------------------- @@ -197,6 +278,197 @@ export function applyMove(doc: Doc, key: CaretMove, extend: boolean): Doc { return { lines: doc.lines, caret: moveCaret(doc, key) }; } +// --------------------------------------------------------------------------- +// Word wrap: logical lines → visual segments +// --------------------------------------------------------------------------- + +/** One visual row: lines[row].slice(from, to). Consecutive segments of a row + * tile it exactly (to === next.from); trailing spaces at a soft break stay + * on the upper row (they hang past the wrap width, like classic Notepad). */ +export interface VSeg { + row: number; + from: number; + to: number; +} + +/** Greedy word wrap of one line under maxW px: break before the word that + * overflows, splitting words wider than a whole row at character level. + * Space runs never trigger a break — they hang on the row they follow. */ +export function wrapLine( + line: string, + maxW: number, + width: (s: string) => number, +): { from: number; to: number }[] { + if (line.length === 0) return [{ from: 0, to: 0 }]; + if (!Number.isFinite(maxW) || width(line) <= maxW) return [{ from: 0, to: line.length }]; + const segs: { from: number; to: number }[] = []; + let segFrom = 0; // current visual row start + let x = 0; // committed row width (hanging spaces included) + let i = 0; + while (i < line.length) { + if (line[i] === " ") { + let j = i; + while (j < line.length && line[j] === " ") j++; + x += width(line.slice(i, j)); + i = j; + continue; + } + let j = i; + while (j < line.length && line[j] !== " ") j++; + const w = width(line.slice(i, j)); + if (i > segFrom && x + w > maxW) { + segs.push({ from: segFrom, to: i }); + segFrom = i; + x = 0; + } + if (w > maxW) { + // A word wider than a whole row: hard character chunks. + let cw = 0; + for (let k = i; k < j; k++) { + const chW = width(line[k]); + if (k > segFrom && cw + chW > maxW) { + segs.push({ from: segFrom, to: k }); + segFrom = k; + cw = 0; + } + cw += chW; + } + x = cw; + i = j; + continue; + } + x += w; + i = j; + } + segs.push({ from: segFrom, to: line.length }); + return segs; +} + +/** The whole document as visual segments, in reading order. */ +export function wrapDoc(lines: string[], maxW: number, width: (s: string) => number): VSeg[] { + const out: VSeg[] = []; + for (let row = 0; row < lines.length; row++) { + for (const s of wrapLine(lines[row], maxW, width)) out.push({ row, from: s.from, to: s.to }); + } + return out; +} + +/** Segments of one line from host-computed break columns (the wrapText op: + * ascending UTF-16 indices, empty = fits). */ +export function segsFromBreaks( + len: number, + breaks: readonly number[], +): { from: number; to: number }[] { + if (breaks.length === 0) return [{ from: 0, to: len }]; + const segs: { from: number; to: number }[] = []; + let from = 0; + for (const b of breaks) { + segs.push({ from, to: b }); + from = b; + } + segs.push({ from, to: len }); + return segs; +} + +/** Index of the visual segment a caret sits on. A caret at a soft-wrap + * boundary column belongs to the next row's start unless it carries end + * affinity (End key, clicks past a wrapped row's text). */ +export function vrowOf(segs: VSeg[], caret: Caret): number { + let first = -1; + let last = -1; + for (let i = 0; i < segs.length; i++) { + if (segs[i].row !== caret.row) { + if (last >= 0) break; + continue; + } + if (first < 0) first = i; + last = i; + } + if (first < 0) return -1; + for (let i = first; i <= last; i++) { + if (caret.col < segs[i].to) { + return caret.end && caret.col === segs[i].from && i > first ? i - 1 : i; + } + } + return last; +} + +/** Caret → (visual row, x px inside it). */ +export function caretXY( + segs: VSeg[], + lines: string[], + caret: Caret, + width: (s: string) => number, +): { vrow: number; x: number } { + const vrow = vrowOf(segs, caret); + if (vrow < 0) return { vrow: 0, x: 0 }; + const s = segs[vrow]; + return { vrow, x: width(lines[s.row].slice(s.from, caret.col)) }; +} + +/** (visual row, x px) → caret, with end affinity when the hit lands past a + * soft-wrapped row's last column (the caret stays visibly on that row). */ +export function caretAtPoint( + segs: VSeg[], + lines: string[], + vrow: number, + x: number, + width: (s: string) => number, +): Caret { + if (segs.length === 0) return { row: 0, col: 0 }; + const s = segs[Math.max(0, Math.min(segs.length - 1, vrow))]; + const col = s.from + colFromX(lines[s.row].slice(s.from, s.to), x, width); + if (col === s.to && s.to < lines[s.row].length) return { row: s.row, col, end: true }; + return { row: s.row, col }; +} + +/** Selection span intersected with one visual segment (absolute cols). */ +export function segSelSpan(doc: Doc, seg: VSeg): { from: number; to: number } | null { + const span = rowSelSpan(doc, seg.row); + if (!span) return null; + const from = Math.max(span.from, seg.from); + const to = Math.min(span.to, seg.to); + return from >= to ? null : { from, to }; +} + +/** One caret move over the WRAPPED layout: Left/Right stay logical + * (applyMove), Up/Down step visual rows keeping the x offset, Home/End go + * to the visual row's bounds (End takes wrap affinity on soft-wrapped + * rows). Selection semantics mirror applyMove: shift extends from the + * anchor, a plain move over a selection collapses to the matching edge + * first. With one segment per line (wrap off) this IS the logical move. */ +export function applyMoveWrapped( + doc: Doc, + key: CaretMove, + extend: boolean, + segs: VSeg[], + width: (s: string) => number, +): Doc { + if (key === "Left" || key === "Right") return applyMove(doc, key, extend); + const lines = doc.lines; + const r = selRange(doc); + const base = !extend && r ? (key === "Up" || key === "Home" ? r.from : r.to) : doc.caret; + const anchor = extend ? (doc.anchor ?? doc.caret) : null; + const vi = vrowOf(segs, base); + let caret: Caret; + if (vi < 0) caret = base; + else if (key === "Home") caret = { row: segs[vi].row, col: segs[vi].from }; + else if (key === "End") { + const s = segs[vi]; + caret = + s.to < lines[s.row].length ? { row: s.row, col: s.to, end: true } : { row: s.row, col: s.to }; + } else { + const target = key === "Up" ? vi - 1 : vi + 1; + if (target < 0 || target >= segs.length) caret = base; + else { + const s0 = segs[vi]; + const x = width(lines[base.row].slice(s0.from, base.col)); + caret = caretAtPoint(segs, lines, target, x, width); + } + } + return extend ? { lines, caret, anchor } : { lines, caret }; +} + /** 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/programs.tsx b/apps/desk98/programs.tsx index fe347fb1..e4db7fe7 100644 --- a/apps/desk98/programs.tsx +++ b/apps/desk98/programs.tsx @@ -10,10 +10,10 @@ import { computed } from "vue"; import { Image, View } from "@pocketjs/framework/components"; import { getOps } from "@pocketjs/framework/host"; import { T98 } from "./chrome.tsx"; -import { FONT } from "./theme.ts"; -import { rowSelSpan } from "./notepad.ts"; +import { FONT, FRAME } from "./theme.ts"; +import { caretXY, segSelSpan, segsFromBreaks, wrapLine, type VSeg } from "./notepad.ts"; import { MINES_W, type Cell } from "./mines.ts"; -import type { AboutData, FolderData, MinesData, PadData, ShutdownData } from "./state.ts"; +import type { AboutData, FolderData, MinesData, PadData, ShutdownData, WinCtl } from "./state.ts"; export function measure(s: string): number { const ops = getOps(); @@ -27,26 +27,77 @@ export function measure(s: string): number { export const PAD_LINE_H = 16; export const PAD_PAD = 3; // inset of the text from the white well -/** Row text split at the selection edges; one segment when unselected. */ -function segs(d: PadData, line: string, row: number): { t: string; sel: boolean }[] { - const span = rowSelSpan(d.doc.value, row); - if (!span) return [{ t: line, sel: false }]; - return [ - { t: line.slice(0, span.from), sel: false }, - { t: line.slice(span.from, span.to), sel: true }, - { t: line.slice(span.to), sel: false }, - ]; +// Wrap math runs on every render, keystroke and pointer move, so word/prefix +// widths ride a bounded cache (advances are additive — a cached width is +// exact forever; the atlas never changes at runtime). +const widthCache = new Map(); + +/** Cached slot-19 width — the `width` function every wrap helper takes. */ +export function padWidth(s: string): number { + if (s === "") return 0; + let w = widthCache.get(s); + if (w === undefined) { + if (widthCache.size > 4096) widthCache.clear(); + w = measure(s); + widthCache.set(s, w); + } + return w; +} + +/** Wrap width for a notepad window: the content well minus the 3px text + * insets (mirrors NotepadView's left-[3] + right margin). Infinity when + * Word Wrap is off — every line becomes one visual segment. */ +export function padWrapW(w: WinCtl): number { + const d = w.data as PadData; + return d.wrap.value ? Math.max(40, w.geo.value.w - FRAME * 2 - PAD_PAD * 2) : Infinity; +} + +/** One line's visual segments: the host wrapText op when present (spec op + * 43 — the platform half: core greedy over the slot's measure provider, + * gpui's LineWrapper for native-text apps), else the same greedy rules in + * JS over measureText. A parity test pins the two equal on baked hosts. */ +function wrapLineHost(line: string, maxW: number): { from: number; to: number }[] { + const ops = getOps(); + if (Number.isFinite(maxW) && ops.wrapText) { + return segsFromBreaks(line.length, ops.wrapText(line, FONT, maxW)); + } + return wrapLine(line, maxW, padWidth); +} + +/** The whole document as visual segments through the host/fallback path. */ +export function wrapDocHost(lines: string[], maxW: number): VSeg[] { + const out: VSeg[] = []; + for (let row = 0; row < lines.length; row++) { + for (const s of wrapLineHost(lines[row], maxW)) out.push({ row, from: s.from, to: s.to }); + } + return out; } -export function NotepadView(props: { data: PadData; active: boolean }) { +/** The window's visual segments — the ONE layout both the render below and + * app.tsx hit-testing/caret movement read. */ +export function padSegs(w: WinCtl): VSeg[] { + const d = w.data as PadData; + return wrapDocHost(d.doc.value.lines, padWrapW(w)); +} + +export function NotepadView(props: { data: PadData; wrapW: number; active: boolean }) { const d = props.data; - const caretRow = () => d.doc.value.caret.row; + const segsAll = () => wrapDocHost(d.doc.value.lines, props.wrapW); + const caretPos = () => caretXY(segsAll(), d.doc.value.lines, d.doc.value.caret, padWidth); const caretX = () => { - const doc = d.doc.value; - const line = doc.lines[doc.caret.row] ?? ""; const pre = d.preedit.value; - const head = line.slice(0, doc.caret.col); - return measure(pre ? head + pre.s.slice(0, pre.c) : head); + return caretPos().x + (pre ? padWidth(pre.s.slice(0, pre.c)) : 0); + }; + /** Visual-segment text split at the selection edges. */ + const parts = (seg: VSeg): { t: string; sel: boolean }[] => { + const line = d.doc.value.lines[seg.row]; + const span = segSelSpan(d.doc.value, seg); + if (!span) return [{ t: line.slice(seg.from, seg.to), sel: false }]; + return [ + { t: line.slice(seg.from, span.from), sel: false }, + { t: line.slice(span.from, span.to), sel: true }, + { t: line.slice(span.to, seg.to), sel: false }, + ]; }; return ( @@ -55,25 +106,25 @@ export function NotepadView(props: { data: PadData; active: boolean }) { class="absolute left-[3] top-[3] right-0 flex-col" style={{ translateY: -d.scroll.value }} > - {d.doc.value.lines.map((line, row) => ( + {segsAll().map((seg, vi) => ( - {row === caretRow() && d.preedit.value ? ( + {vi === caretPos().vrow && d.preedit.value ? ( [ - , + , , - , + , ] ) : ( - segs(d, line, row).map((seg) => - seg.sel ? ( + parts(seg).map((p) => + p.sel ? ( - + ) : ( - + ), ) )} @@ -87,7 +138,7 @@ export function NotepadView(props: { data: PadData; active: boolean }) { insetL: 0, insetT: 0, translateX: 3 + caretX(), - translateY: 3 + caretRow() * PAD_LINE_H - d.scroll.value + 1, + translateY: 3 + caretPos().vrow * PAD_LINE_H - d.scroll.value + 1, }} /> ) : null} diff --git a/apps/desk98/screenshot.png b/apps/desk98/screenshot.png index 725f5a3c..d3415a4b 100644 Binary files a/apps/desk98/screenshot.png and b/apps/desk98/screenshot.png differ diff --git a/apps/desk98/state.ts b/apps/desk98/state.ts index 37a97f44..d5e94ced 100644 --- a/apps/desk98/state.ts +++ b/apps/desk98/state.ts @@ -5,7 +5,7 @@ import { ref, shallowRef, type Ref, type ShallowRef } from "vue"; import type { CaptionButton, Geo } from "./wm.ts"; -import type { Doc } from "./notepad.ts"; +import type { Doc, History } from "./notepad.ts"; import type { Mines } from "./mines.ts"; export type WinKind = "notepad" | "mines" | "folder" | "about" | "shutdown"; @@ -23,6 +23,8 @@ export interface PopupItem { icon?: string; shortcut?: string; disabled?: boolean; + /** Toggle state — renders a checkmark in the icon slot. */ + checked?: boolean; sep?: boolean; sub?: PopupItem[]; act?: () => void; @@ -66,6 +68,11 @@ export interface PadData { doc: ShallowRef; scroll: Ref; preedit: Ref<{ s: string; c: number } | null>; + /** Word wrap (Edit menu toggle): reflow to the window width. */ + wrap: Ref; + /** Undo/redo snapshots (notepad.ts History). Plain field: nothing renders + * from it — the Edit/context menus read it when they build their items. */ + hist: History; } export interface MinesData { diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index 4a0da325..b520be4c 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -241,6 +241,26 @@ export const OP = { // contract note on that argument). The guest only // issues this op when no fact channel exists (devtools // replay, injected test hosts, older wasm builds). + // -- text wrap (the platform half of soft-wrap layout; docs/BACKENDS.md) -- + wrapText: 43, // (str: string, fontSlot: i32, maxW: f32) -> u32[]. + // Soft-wrap break columns for ONE line of text under + // maxW px, ascending UTF-16 code-unit indices (empty + // = the line fits). The engine computes + // greedy word wrap over the SAME provider that + // measures and paints the slot (atlas advances, or + // the native measurer for native-text apps): break + // BEFORE the word that overflows, space runs hang + // past maxW on the row they follow, a word wider than + // a whole row splits at character level. Native-text + // backends may install a host wrapper next to the + // measurer (Ui::set_text_wrap — gpui's LineWrapper) + // and its break positions win. Wrapped-coordinate + // bookkeeping (visual rows, caret/selection mapping) + // stays app-side — this op is the "where may it + // break" half only. Hosts without it: the framework + // falls back to the same greedy rules over + // measureText (apps/desk98/notepad.ts wrapLine is the + // pinned reference; a parity test holds them equal). } as const; // --------------------------------------------------------------------------- diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 2ca00a64..4dd9e1f3 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -65,6 +65,15 @@ text can come from the host text system. caret positions as prefix widths through `measureText` (`apps/note/layout.ts`, `apps/im/wrap.ts`) and prefix sums only equal shaped positions when advances are additive. +- **Soft-wrap breaks are a host op.** `wrapText` (spec op 43) returns the + break columns for one line under a pixel width. The core computes greedy + word wrap over the slot's measure provider; a native-text app gets gpui's + own `LineWrapper` instead (`native_wrap`, installed next to the measurer + through the same `TextConfig` — Zed's editor WrapMap consumes the same + machinery). The wrapped COORDINATE SPACE — visual rows, caret/selection + mapping, hit testing — stays app-side (apps/desk98/notepad.ts is the + reference): the op is only the "where does this line break" half, exactly + the platform/editor split Zed uses. - **Two ops keep a pixel-exact escape hatch.** Gouraud `TRI` and `TEX_TRI` batches (rotated gradients and images, 3D subtrees) have no gpui vector equivalent, so consecutive batches raster through @@ -125,12 +134,13 @@ IME input handler (`insertText:` → one `ch` line per keypress). `apps/desk98` — a Windows 98 desktop compositor written in Vue Vapor JSX — 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. +z-order, menus, text selection, word-wrap layout, Minesweeper) by +hit-testing the raw pointer stream itself, window moves ride paint-only +translate props, and raises ride zIndex, so a drag never relayouts and an +idle desktop keeps the demand-render governor at a few frames per second. +Its W95FA pixel font is baked per-app into slots 19–21 through +`apps/desk98/pak.json` (`gen-assets.ts`) — the repo slot table (0–18) +never moves. ``` bun run macos desk98 # the full desktop; drag-select, Cmd+`, Cmd+W, Cmd+Esc diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6bbae072..3cdec34e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -247,6 +247,7 @@ children[], …}`) so reconciler *reads* never cross the FFI. Handles are `i32` | setFocus | `(idOr0)` | applies `focus:` variant natively | | loadStyles / loadFontAtlas | `(buf …)` | **web/test hosts only** — on PSP, hosts/psp/src/pak.rs feeds core directly from include_bytes! **[R]** | | measureText | `(str, fontSlot) → width` | JS convenience; layout measures natively | +| wrapText | `(str, fontSlot, maxW) → u32[]` | soft-wrap break columns for one line (ascending UTF-16 indices, empty = fits): greedy word wrap over the slot's measure provider; native-text hosts install the host text system's wrapper (gpui LineWrapper) and its positions win. Optional — apps fall back to the same greedy rules over measureText | | loadTileTexture | `(pakKey, tileIndex) → handle` | decode ONE tile of a TILESET pak entry (spec.ts) into a CLUT8 texture, host-side — on PSP straight from `.rodata`, zero JS-heap transit. Hosts without it: the runtime falls back to `__pak` + uploadTexture (framework/src/tiles.ts) | | freeTexture | `(handle)` | releases a texture slot. Texture handles are **generation-tagged** like node ids (spec `TEX_SLOT_BITS`): a stale handle resolves to nothing and draws nothing — tile churn cannot sample a stranger's texture | | uploadImgEntry | `(blob) → handle` | self-contained IMG entry upload (v2: PSM_T8 palette, PackBits-RLE + linear-filter flags parsed core-side) | diff --git a/engine/backends/gpui/src/fonts.rs b/engine/backends/gpui/src/fonts.rs index 6ae09627..53ee171d 100644 --- a/engine/backends/gpui/src/fonts.rs +++ b/engine/backends/gpui/src/fonts.rs @@ -11,9 +11,10 @@ use std::sync::Arc; use gpui::{ - Font, FontFeatures, FontStyle, FontWeight, SharedString, TextRun, WindowTextSystem, px, + Font, FontFeatures, FontStyle, FontWeight, LineFragment, SharedString, TextRun, + WindowTextSystem, px, }; -use pocketjs_core::text::MeasureFn; +use pocketjs_core::text::{MeasureFn, WrapFn}; /// Slot -> px mirror of the compiler's slot registry /// (framework/compiler/tailwind.ts FONT_PX: slots 0..6 regular, 7..13 bold, @@ -110,6 +111,49 @@ pub fn slot_line_height(ts: &WindowTextSystem, cfg: &TextConfig, slot: u8) -> f3 /// re-measures every text leaf — but only the edited line's width actually /// changed. The cache makes the other N-1 lookups free instead of N-1 /// CoreText shapes per tick. +/// Build the core line wrapper (installed via `UiSurface::set_text_wrap` +/// next to the measurer): gpui's own `LineWrapper` — the exact machinery +/// Zed's editor WrapMap consumes — decides the soft-break positions with +/// the same font the measurer sizes and the painter shapes, so break +/// columns, measured advances and painted glyphs always agree (the additive +/// shaping configuration above is what makes them provably consistent). +/// Returns ascending UTF-16 columns per the wrapText op contract. +pub fn native_wrap(ts: Arc, cfg: TextConfig) -> WrapFn { + Box::new(move |text: &str, slot: u8, max_w: f32| { + if text.is_empty() || max_w.is_nan() || max_w <= 0.0 { + return Vec::new(); + } + let (size, bold, mono) = slot_px(slot); + let mut wrapper = ts.line_wrapper(cfg.font(bold, mono), px(size)); + let fragments = [LineFragment::text(text)]; + // Boundary.ix is the UTF-8 byte index where the next visual row + // starts (Zed slices `line[prev.ix..boundary.ix]`); convert to the + // UTF-16 columns the guest slices JS strings with. + let byte_breaks: Vec = wrapper + .wrap_line(&fragments, px(max_w)) + .map(|b| b.ix) + .collect(); + drop(wrapper); + if byte_breaks.is_empty() { + return Vec::new(); + } + let mut breaks = Vec::with_capacity(byte_breaks.len()); + let mut next = byte_breaks.iter().copied().peekable(); + let mut col = 0u32; + for (byte_ix, ch) in text.char_indices() { + while next.peek() == Some(&byte_ix) { + breaks.push(col); + next.next(); + } + if next.peek().is_none() { + break; + } + col += ch.len_utf16() as u32; + } + breaks + }) +} + pub fn native_measure(ts: Arc, cfg: TextConfig) -> MeasureFn { let widths: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); diff --git a/engine/backends/gpui/src/lib.rs b/engine/backends/gpui/src/lib.rs index c066e35c..e88e0bad 100644 --- a/engine/backends/gpui/src/lib.rs +++ b/engine/backends/gpui/src/lib.rs @@ -22,5 +22,5 @@ mod fonts; mod render; -pub use fonts::{TextConfig, native_measure, slot_line_height, slot_px}; +pub use fonts::{TextConfig, native_measure, native_wrap, slot_line_height, slot_px}; pub use render::GpuiRenderer; diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index 5cf08364..cb39a968 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -518,6 +518,13 @@ impl Ui { self.fonts.measure_run(text, font_slot, 0.0, f32::NAN).0 } + /// Soft-wrap break columns for one line of `text` at `font_slot` under + /// `max_w` px (OP wrapText): ascending UTF-16 columns, empty when the + /// line fits. Provider rules in [`text::Fonts::wrap_text`]. + pub fn wrap_text(&self, text: &str, font_slot: u8, max_w: f32) -> Vec { + self.fonts.wrap_text(text, font_slot, max_w) + } + // ---- assets ---------------------------------------------------------- /// Upload a texture (raw pixels in `psm` format — spec::psm::*, pow2 @@ -1240,6 +1247,13 @@ impl Ui { self.layout.dirty = true; } + /// Install (or clear) a native line wrapper (text::WrapFn) next to the + /// measurer — OP wrapText then returns the host's break positions. + /// A pure query: no layout state depends on it. + pub fn set_text_wrap(&mut self, f: Option) { + self.fonts.set_native_wrap(f); + } + /// Resize the logical viewport (root node + layout bounds + draw clip). /// Defaults to the PSP's 480x272; desktop hosts call this with their /// surface size. Values are clamped to the DrawList's i16 coordinate diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index a1420e62..08d623dc 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -95,6 +95,7 @@ pub mod op { pub const APP_LAUNCH: u8 = 40; pub const APP_SHOT: u8 = 41; pub const HIT_TEST_BOUNDS: u8 = 42; + pub const WRAP_TEXT: u8 = 43; } /// Property ids (u8, stable, append-only). Groups: diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index f68e44a9..dbf36719 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -981,6 +981,42 @@ fn overflow_hidden_emits_balanced_intersected_scissors() { } } +#[test] +fn wrap_text_greedy_breaks_and_native_override() { + let mut ui = Ui::new(); + // A=6, B=5, space=4 (synthetic atlas, slot 2). + let blob = encode_atlas( + 2, + 8, + 8, + 7, + 10, + 4, + &[ + (' ' as u32, 3, 4), + ('A' as u32, 1, 6), + ('B' as u32, 2, 5), + (0xfffd, 0, 8), + ], + ); + assert!(ui.load_font_atlas(&blob)); + // Fits (or empty): no breaks. + assert!(ui.wrap_text("AA AA", 2, 100.0).is_empty()); + assert!(ui.wrap_text("", 2, 30.0).is_empty()); + // Greedy: "AA AA " = 28px + hanging space, third word overflows 30px → + // break BEFORE it (col 6); the trailing space stays on the upper row. + assert_eq!(ui.wrap_text("AA AA AA", 2, 30.0), alloc::vec![6]); + // A word wider than a whole row splits at character level. + assert_eq!(ui.wrap_text("AAAAA", 2, 13.0), alloc::vec![2, 4]); + // A native wrapper overrides the greedy path; clearing restores it. + ui.set_text_wrap(Some(alloc::boxed::Box::new(|_t: &str, _s: u8, _w: f32| { + alloc::vec![7, 9] + }))); + assert_eq!(ui.wrap_text("AA AA AA", 2, 30.0), alloc::vec![7, 9]); + ui.set_text_wrap(None); + assert_eq!(ui.wrap_text("AA AA AA", 2, 30.0), alloc::vec![6]); +} + #[test] fn text_measurement_against_synthetic_atlas() { let mut ui = Ui::new(); diff --git a/engine/core/src/text.rs b/engine/core/src/text.rs index d779915e..2fed174f 100644 --- a/engine/core/src/text.rs +++ b/engine/core/src/text.rs @@ -215,6 +215,14 @@ pub struct GlyphPos { /// before the guest mounts; fixed-function hosts never install one. pub type MeasureFn = alloc::boxed::Box (f32, f32)>; +/// A host-installed native line wrapper: `(text, slot, max_w) -> soft-break +/// columns` (ascending, in UTF-16 code units — the guest slices JS strings +/// with them). Installed by native-text backends through `Ui::set_text_wrap` +/// next to the measurer, so break positions and measured advances always +/// come from the same provider; without one, `wrap_text` computes greedy +/// breaks over whatever provider `measure_run` resolves to. +pub type WrapFn = alloc::boxed::Box alloc::vec::Vec>; + /// The per-core atlas registry. pub struct Fonts { slots: [Option; spec::MAX_FONT_SLOTS], @@ -226,6 +234,8 @@ pub struct Fonts { /// runs keep the baked path on BOTH sides so a node's measured metrics /// always match its painted glyphs. native: Option, + /// Native line wrapper (OP wrapText); absent = greedy over `measure_run`. + wrap_native: Option, } impl Default for Fonts { @@ -240,6 +250,7 @@ impl Fonts { slots: Default::default(), misses: Cell::new(0), native: None, + wrap_native: None, } } @@ -249,6 +260,11 @@ impl Fonts { self.native = f; } + /// Install (or clear) the native line wrapper (OP wrapText). + pub fn set_native_wrap(&mut self, f: Option) { + self.wrap_native = f; + } + /// True when a native measurer is installed. #[inline] pub fn native_active(&self) -> bool { @@ -342,6 +358,92 @@ impl Fonts { (max_w, lines as f32 * lh) } + /// Soft-wrap break columns for ONE line of text under `max_w` px + /// (ascending, UTF-16 code units; empty = the line fits). With a native + /// wrapper installed it decides; otherwise greedy word wrap over + /// whatever provider `measure_run` resolves to (atlas advances, or the + /// native measurer for tracking-0 native apps) — break positions always + /// derive from the metrics that size and paint the text. + /// + /// Greedy rules (pinned by the JS-fallback parity test): break BEFORE + /// the word that overflows; space runs never break — they hang past + /// `max_w` on the row they follow (classic Notepad); a word wider than a + /// whole row splits at character level. + pub fn wrap_text(&self, text: &str, slot: u8, max_w: f32) -> Vec { + if let Some(f) = &self.wrap_native { + return f(text, slot, max_w); + } + self.wrap_greedy(text, slot, max_w) + } + + fn wrap_greedy(&self, text: &str, slot: u8, max_w: f32) -> Vec { + let mut breaks = Vec::new(); + if text.is_empty() || max_w.is_nan() || max_w <= 0.0 { + return breaks; + } + let width = |a: usize, b: usize| self.measure_run(&text[a..b], slot, 0.0, f32::NAN).0; + if width(0, text.len()) <= max_w { + return breaks; + } + // Per-char byte offsets + running UTF-16 columns (the guest slices + // JS strings, whose indices are UTF-16 code units). + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let n = chars.len(); + let mut u16_at = Vec::with_capacity(n + 1); + let mut acc = 0u32; + for &(_, c) in &chars { + u16_at.push(acc); + acc += c.len_utf16() as u32; + } + u16_at.push(acc); + let byte_at = |ci: usize| if ci < n { chars[ci].0 } else { text.len() }; + let w = |a: usize, b: usize| width(byte_at(a), byte_at(b)); + + let mut seg_from = 0usize; // char index of the current visual row start + let mut x = 0f32; // committed row width (hanging spaces included) + let mut i = 0usize; + while i < n { + if chars[i].1 == ' ' { + let mut j = i; + while j < n && chars[j].1 == ' ' { + j += 1; + } + x += w(i, j); + i = j; + continue; + } + let mut j = i; + while j < n && chars[j].1 != ' ' { + j += 1; + } + let word_w = w(i, j); + if i > seg_from && x + word_w > max_w { + breaks.push(u16_at[i]); + seg_from = i; + x = 0.0; + } + if word_w > max_w { + // A word wider than a whole row: hard character chunks. + let mut cw = 0f32; + for k in i..j { + let ch_w = w(k, k + 1); + if k > seg_from && cw + ch_w > max_w { + breaks.push(u16_at[k]); + seg_from = k; + cw = 0.0; + } + cw += ch_w; + } + x = cw; + i = j; + continue; + } + x += word_w; + i = j; + } + breaks + } + /// Inline-run layout: place every glyph (cell top-left, relative to the /// box origin) honoring text-align within `box_w` and per-line vertical /// centering of the glyph cell inside the line box. diff --git a/engine/crates/pocket-ui-surface/src/surface.rs b/engine/crates/pocket-ui-surface/src/surface.rs index 6b044aa1..10d2ef6f 100644 --- a/engine/crates/pocket-ui-surface/src/surface.rs +++ b/engine/crates/pocket-ui-surface/src/surface.rs @@ -258,6 +258,13 @@ impl UiSurface { self.inner.borrow_mut().ui.set_text_measure(Some(f)); } + /// Install a native line wrapper next to the measurer (OP wrapText — + /// break positions then come from the host text system). Same call- + /// before-`mount` rule so the guest never observes a provider swap. + pub fn set_text_wrap(&self, f: pocketjs_core::text::WrapFn) { + self.inner.borrow_mut().ui.set_text_wrap(Some(f)); + } + /// Borrow the core (the renderer reads the DrawList/textures/atlases /// through this; hosts can use it for `set_viewport` on resize). pub fn with_ui(&self, f: impl FnOnce(&mut Ui) -> R) -> R { @@ -430,6 +437,14 @@ impl UiSurface { ui.borrow_mut().ui.measure_text(&s.0, slot as u8) as f64 }); + let ui = self.inner.clone(); + op!( + "wrapText", + move |s: LossyString, slot: i32, max_w: f64| -> Vec { + ui.borrow_mut().ui.wrap_text(&s.0, slot as u8, max_w as f32) + } + ); + // ---- streamed textures (spec ops 23..25) --------------------- let ui = self.inner.clone(); op!("loadTileTexture", move |key: LossyString, index: i32| { diff --git a/engine/symbian/src/lib.rs b/engine/symbian/src/lib.rs index c6ac4412..a2e9f4af 100644 --- a/engine/symbian/src/lib.rs +++ b/engine/symbian/src/lib.rs @@ -138,6 +138,9 @@ fn allocation_error(_layout: Layout) -> ! { static mut UI: Option = None; static mut FRAMEBUFFER: Vec = Vec::new(); static mut DAMAGE_TRACKER: DamageTracker = DamageTracker::new(); +/// wrapText result staging (FRAMEBUFFER lifetime contract: valid until the +/// next wrapText or init call). +static mut WRAP_BREAKS: Vec = Vec::new(); /* * Damage statistics for the incremental raster path. * @@ -462,6 +465,24 @@ pub extern "C" fn ui_measure_text(ptr: *const u8, len: usize, font_slot: u32) -> ui().measure_text(unsafe { text(ptr, len) }, font_slot as u8) } +/// OP wrapText: stage the soft-wrap break columns (ascending UTF-16 code +/// units) for one line under `max_w` px and return their count; the host +/// reads them from `ui_wrap_text_ptr` before its next call. +#[no_mangle] +pub extern "C" fn ui_wrap_text(ptr: *const u8, len: usize, font_slot: u32, max_w: f32) -> u32 { + let breaks = ui().wrap_text(unsafe { text(ptr, len) }, font_slot as u8, max_w); + unsafe { + WRAP_BREAKS = breaks; + WRAP_BREAKS.len() as u32 + } +} + +/// The staged wrapText columns (valid until the next wrapText/init call). +#[no_mangle] +pub extern "C" fn ui_wrap_text_ptr() -> *const u32 { + unsafe { WRAP_BREAKS.as_ptr() } +} + // ---- fixed-step frame and DevTools ---------------------------------------- #[no_mangle] diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index 4292d595..92a0c94d 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -31,6 +31,10 @@ use pocketjs_core::raster; static mut UI: Option = None; static mut FRAMEBUFFER: Vec = Vec::new(); static mut DAMAGE_TRACKER: DamageTracker = DamageTracker::new(); +/// wrapText result staging (same lifetime contract as FRAMEBUFFER: the +/// pointer from `ui_wrap_text_ptr` stays valid until the next wrapText or +/// init call on this instance). +static mut WRAP_BREAKS: Vec = Vec::new(); #[inline] fn ui() -> &'static mut Ui { @@ -231,6 +235,24 @@ pub extern "C" fn ui_measure_text(ptr: *const u8, len: usize, font_slot: u32) -> ui().measure_text(unsafe { text(ptr, len) }, font_slot as u8) } +/// OP wrapText: stage the soft-wrap break columns (ascending UTF-16 code +/// units) for one line under `max_w` px and return their count; the host +/// reads them from `ui_wrap_text_ptr` before its next wasm call. +#[no_mangle] +pub extern "C" fn ui_wrap_text(ptr: *const u8, len: usize, font_slot: u32, max_w: f32) -> u32 { + let breaks = ui().wrap_text(unsafe { text(ptr, len) }, font_slot as u8, max_w); + unsafe { + WRAP_BREAKS = breaks; + WRAP_BREAKS.len() as u32 + } +} + +/// The staged wrapText columns (valid until the next wrapText/init call). +#[no_mangle] +pub extern "C" fn ui_wrap_text_ptr() -> *const u32 { + unsafe { WRAP_BREAKS.as_ptr() } +} + // ---- frame ------------------------------------------------------------------ /// Advance one fixed-dt (1/60 s) frame: animations, then layout if dirty. diff --git a/framework/src/host.ts b/framework/src/host.ts index 26b29188..68462eb6 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -121,6 +121,14 @@ export interface HostOps { loadFontAtlas?(buf: Uint8Array): void; /** JS-side convenience; layout measures natively. → width in px. */ measureText(str: string, fontSlot: number): number; + /** Soft-wrap break columns for ONE line under maxW px (spec op 43): + * ascending UTF-16 code-unit indices, empty = the line fits. The engine + * computes greedy word wrap over the same provider that measures and + * paints the slot; native-text backends may install the host text + * system's wrapper (gpui LineWrapper) whose positions win. Optional: + * hosts that predate it — apps fall back to the same greedy rules over + * measureText (apps/desk98/notepad.ts wrapLine, parity-tested). */ + wrapText?(str: string, fontSlot: number, maxW: number): number[]; // -- streamed textures (spec ops 23..25) — deep-zoom tile canvases. Native // hosts (PSP, uihost) implement loadTileTexture so tile bytes never diff --git a/hosts/macos/src/main.rs b/hosts/macos/src/main.rs index 48fd98f5..e11ae499 100644 --- a/hosts/macos/src/main.rs +++ b/hosts/macos/src/main.rs @@ -48,7 +48,7 @@ use gpui::{ WindowBounds, WindowOptions, canvas, div, point, px, size, }; use pocket_mod::Guest; -use pocket_ui_gpui::{GpuiRenderer, TextConfig, native_measure}; +use pocket_ui_gpui::{GpuiRenderer, TextConfig, native_measure, native_wrap}; use pocket_ui_surface::UiSurface; const HOST_ID: &str = "macos-app"; @@ -393,6 +393,10 @@ impl PocketRoot { // BEFORE mount: measurement feeds layout, and the guest must // never observe a provider swap (engine/core/src/lib.rs). surface.set_text_measure(native_measure(window.text_system().clone(), cfg.clone())); + // The wrapText op's break positions then come from gpui's + // LineWrapper through the SAME TextConfig — measurement, wrap + // and paint stay one provider. + surface.set_text_wrap(native_wrap(window.text_system().clone(), cfg.clone())); } let guest = Guest::new()?; surface.mount(&guest)?; diff --git a/hosts/web/wasm-ops.js b/hosts/web/wasm-ops.js index 40d3a89a..9e430cca 100644 --- a/hosts/web/wasm-ops.js +++ b/hosts/web/wasm-ops.js @@ -125,6 +125,18 @@ export async function createWasmUi(wasm, options = {}) { } if (ex.ui_set_cursor_pos) ops.setCursorPos = (x, y) => ex.ui_set_cursor_pos(x, y); + // Text wrap (spec op 43) — feature-detected; apps fall back to the same + // greedy rules over measureText when absent. The staged columns must be + // copied INSIDE the closure: the view over linear memory dies on the next + // alloc/grow (and withStr frees its scratch right after fn returns). + if (ex.ui_wrap_text) { + ops.wrapText = (str, fontSlot, maxW) => + withStr(str, (p, l) => { + const n = ex.ui_wrap_text(p, l, fontSlot, maxW); + return Array.from(new Uint32Array(ex.memory.buffer, ex.ui_wrap_text_ptr(), n)); + }); + } + function framebufferView(ptr, scale) { if (!ptr) throw new Error(`pocketjs.wasm rejected render scale ${scale}`); return new Uint8Array( diff --git a/tests/desk98-sim.test.ts b/tests/desk98-sim.test.ts index 9e575370..0bdb4c1b 100644 --- a/tests/desk98-sim.test.ts +++ b/tests/desk98-sim.test.ts @@ -166,5 +166,19 @@ describe("desk98 desk companion journey", () => { svc.push({ t: "key", k: "w", cmd: true }); await step(world, 2); expect(treeHasText(world.getTree(), "Untitled - Notepad")).toBe(false); + + // Undo/redo: a typing run coalesces into ONE unit — ⌘Z pulls both + // characters back out at once, ⌘⇧Z replays them. + for (const ch of ["Q", "Q"]) svc.push({ t: "ch", s: ch }); + await step(world, 2); + expect(treeHasText(world.getTree(), "[PASTED]QQ")).toBe(true); + svc.push({ t: "key", k: "z", cmd: true }); + await step(world, 2); + const afterUndo = world.getTree(); + expect(treeHasText(afterUndo, "[PASTED]QQ")).toBe(false); + expect(treeHasText(afterUndo, "[PASTED]")).toBe(true); + svc.push({ t: "key", k: "z", cmd: true, sh: true }); + await step(world, 2); + expect(treeHasText(world.getTree(), "[PASTED]QQ")).toBe(true); }, 30000); }); diff --git a/tests/desk98.test.ts b/tests/desk98.test.ts index 0debcf5d..f58033c1 100644 --- a/tests/desk98.test.ts +++ b/tests/desk98.test.ts @@ -24,18 +24,30 @@ import { } from "../apps/desk98/mines.ts"; import { applyMove, + applyMoveWrapped, backspace, + caretAtPoint, + caretXY, colFromX, del, deleteSel, + docEquals, + emptyHistory, hasSel, insertText, moveCaret, + record, + redoStep, rowSelSpan, + segSelSpan, selectAll, selectedText, selRange, + undoStep, + vrowOf, wordRangeAt, + wrapDoc, + wrapLine, type Doc, } from "../apps/desk98/notepad.ts"; @@ -271,6 +283,81 @@ describe("notepad editing", () => { }); }); +// --------------------------------------------------------------------------- +// notepad.ts — undo/redo history +// --------------------------------------------------------------------------- + +describe("notepad history", () => { + const D = (s: string, col: number): Doc => ({ lines: [s], caret: { row: 0, col } }); + + test("a typing run coalesces into one undo unit; redo replays it whole", () => { + let h = emptyHistory(); + let doc = D("", 0); + for (const ch of ["a", "b", "c"]) { + const next = insertText(doc, ch); + h = record(h, doc, next, "type"); + doc = next; + } + expect(doc.lines).toEqual(["abc"]); + expect(h.undo.length).toBe(1); + const u = undoStep(h, doc)!; + expect(u.doc.lines).toEqual([""]); + const r = redoStep(u.h, u.doc)!; + expect(r.doc.lines).toEqual(["abc"]); + expect(undoStep(emptyHistory(), doc)).toBeNull(); + }); + + test("a caret move between keystrokes breaks the group (tip mismatch)", () => { + let h = emptyHistory(); + const d0 = D("xy", 2); + const d1 = insertText(d0, "a"); + h = record(h, d0, d1, "type"); + // A plain caret move produces a doc record() never saw as its tip. + const moved: Doc = { lines: d1.lines, caret: { row: 0, col: 0 } }; + const d2 = insertText(moved, "b"); + h = record(h, moved, d2, "type"); + expect(h.undo.length).toBe(2); + }); + + test("erase runs coalesce separately; other edits never coalesce", () => { + let h = emptyHistory(); + let doc = D("abc", 3); + for (let i = 0; i < 2; i++) { + const next = backspace(doc); + h = record(h, doc, next, "erase"); + doc = next; + } + expect(h.undo.length).toBe(1); + for (let i = 0; i < 2; i++) { + const next = insertText(doc, "\n"); + h = record(h, doc, next, "other"); + doc = next; + } + expect(h.undo.length).toBe(3); + }); + + test("a new edit clears redo; undo restores the selection", () => { + let h = emptyHistory(); + const sel: Doc = { lines: ["hello"], caret: { row: 0, col: 5 }, anchor: { row: 0, col: 0 } }; + const cut = deleteSel(sel); + h = record(h, sel, cut, "other"); + const u = undoStep(h, cut)!; + expect(u.doc.anchor).toEqual({ row: 0, col: 0 }); + expect(u.h.redo.length).toBe(1); + const again = record(u.h, u.doc, insertText(u.doc, "!"), "type"); + expect(again.redo.length).toBe(0); + }); + + test("docEquals sees text/caret/anchor, not object identity", () => { + expect(docEquals(D("a", 1), { lines: ["a"], caret: { row: 0, col: 1 } })).toBe(true); + expect(docEquals(D("a", 1), D("a", 0))).toBe(false); + expect(docEquals(D("a", 1), { lines: ["b"], caret: { row: 0, col: 1 } })).toBe(false); + expect( + docEquals(D("a", 1), { lines: ["a"], caret: { row: 0, col: 1 }, anchor: { row: 0, col: 0 } }), + ).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // notepad.ts — selection model // --------------------------------------------------------------------------- @@ -337,6 +424,83 @@ describe("notepad selection", () => { expect(wordRangeAt("", 0)).toEqual({ from: 0, to: 0 }); }); + test("word wrap: greedy word breaks, hanging spaces, char fallback", () => { + const w6 = (s: string) => s.length * 6; + // Row capacity 7.5 chars: "aaa bbb" fits (42px), the trailing space + // hangs, "ccc" opens the next visual row. + expect(wrapLine("aaa bbb ccc", 45, w6)).toEqual([ + { from: 0, to: 8 }, + { from: 8, to: 11 }, + ]); + // Exact fit and no-wrap widths pass through as one segment. + expect(wrapLine("aaa", 18, w6)).toEqual([{ from: 0, to: 3 }]); + expect(wrapLine("aaa bbb ccc", Infinity, w6)).toEqual([{ from: 0, to: 11 }]); + expect(wrapLine("", 45, w6)).toEqual([{ from: 0, to: 0 }]); + // A word wider than a whole row breaks at character level. + expect(wrapLine("abcdefgh", 18, w6)).toEqual([ + { from: 0, to: 3 }, + { from: 3, to: 6 }, + { from: 6, to: 8 }, + ]); + // Segments tile the document in reading order. + expect(wrapDoc(["aaa bbb ccc", "", "dd"], 45, w6)).toEqual([ + { row: 0, from: 0, to: 8 }, + { row: 0, from: 8, to: 11 }, + { row: 1, from: 0, to: 0 }, + { row: 2, from: 0, to: 2 }, + ]); + }); + + test("word wrap: caret ↔ visual row mapping with end affinity", () => { + const w6 = (s: string) => s.length * 6; + const lines = ["aaa bbb ccc"]; + const segs = wrapDoc(lines, 45, w6); + // The wrap boundary column belongs to the next row's start by default, + // to the earlier row's end under end affinity. + expect(vrowOf(segs, { row: 0, col: 8 })).toBe(1); + expect(vrowOf(segs, { row: 0, col: 8, end: true })).toBe(0); + expect(vrowOf(segs, { row: 0, col: 3 })).toBe(0); + expect(vrowOf(segs, { row: 0, col: 11 })).toBe(1); + expect(caretXY(segs, lines, { row: 0, col: 9 }, w6)).toEqual({ vrow: 1, x: 6 }); + expect(caretXY(segs, lines, { row: 0, col: 8, end: true }, w6)).toEqual({ vrow: 0, x: 48 }); + // Clicking past a wrapped row's text keeps the caret on that row. + expect(caretAtPoint(segs, lines, 0, 100, w6)).toEqual({ row: 0, col: 8, end: true }); + // At the last row of the line no affinity is needed. + expect(caretAtPoint(segs, lines, 1, 100, w6)).toEqual({ row: 0, col: 11 }); + }); + + test("word wrap: Up/Down step visual rows, Home/End take the row bounds", () => { + const w6 = (s: string) => s.length * 6; + const lines = ["aaa bbb ccc"]; + const segs = wrapDoc(lines, 45, w6); + const doc: Doc = { lines, caret: { row: 0, col: 1 } }; + const down = applyMoveWrapped(doc, "Down", false, segs, w6); + expect(down.caret).toEqual({ row: 0, col: 9 }); // same x, next visual row + const up = applyMoveWrapped({ lines, caret: { row: 0, col: 9 } }, "Up", false, segs, w6); + expect(up.caret).toEqual({ row: 0, col: 1 }); + const end = applyMoveWrapped(doc, "End", false, segs, w6); + expect(end.caret).toEqual({ row: 0, col: 8, end: true }); // visual row end + const home = applyMoveWrapped({ lines, caret: { row: 0, col: 9 } }, "Home", false, segs, w6); + expect(home.caret).toEqual({ row: 0, col: 8 }); // visual row start + const ext = applyMoveWrapped(doc, "Down", true, segs, w6); + expect(ext.anchor).toEqual({ row: 0, col: 1 }); + expect(ext.caret).toEqual({ row: 0, col: 9 }); + // With one segment per line (wrap off) the move is the logical one. + const flat = wrapDoc(["ab", "c"], Infinity, w6); + const d2 = applyMoveWrapped({ lines: ["ab", "c"], caret: { row: 0, col: 2 } }, "Down", false, flat, w6); + expect(d2.caret).toEqual({ row: 1, col: 1 }); + }); + + test("word wrap: selection spans intersect visual segments", () => { + const w6 = (s: string) => s.length * 6; + const lines = ["aaa bbb ccc"]; + const segs = wrapDoc(lines, 45, w6); + const doc: Doc = { lines, caret: { row: 0, col: 10 }, anchor: { row: 0, col: 2 } }; + expect(segSelSpan(doc, segs[0])).toEqual({ from: 2, to: 8 }); + expect(segSelSpan(doc, segs[1])).toEqual({ from: 8, to: 10 }); + expect(segSelSpan({ lines, caret: { row: 0, col: 3 }, anchor: { row: 0, col: 1 } }, segs[1])).toBeNull(); + }); + test("rowSelSpan covers edge rows partially and middle rows fully", () => { const tall: Doc = { lines: ["aaaa", "bbbb", "cccc"], diff --git a/tests/wrap-op.test.ts b/tests/wrap-op.test.ts new file mode 100644 index 00000000..071de8e2 --- /dev/null +++ b/tests/wrap-op.test.ts @@ -0,0 +1,79 @@ +// tests/wrap-op.test.ts — the wrapText op (spec op 43) against the JS greedy +// fallback (apps/desk98/notepad.ts wrapLine). On a baked host both reduce to +// the same additive atlas advances, so their break columns must agree +// column-for-column — this is the parity that lets apps use the op when +// present and the JS rules when not, without the layout ever moving. +// +// Runs on the wasm core with desk98's committed W95FA atlas (slot 19) — the +// real consumer's font, spaces and CJK-free ASCII plus over-wide tokens. + +import { beforeAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createWasmUi } from "../hosts/web/wasm-ops.js"; +import { segsFromBreaks, wrapLine } from "../apps/desk98/notepad.ts"; + +const ROOT = new URL("..", import.meta.url).pathname; +const WASM_PATH = join(ROOT, "hosts/web/pocketjs.wasm"); + +function ensureBuilt(path: string, cmd: string[]): void { + if (existsSync(path)) return; + const p = Bun.spawnSync(cmd, { cwd: ROOT, stdout: "inherit", stderr: "inherit" }); + if (p.exitCode !== 0 || !existsSync(path)) throw new Error(`wrap-op: failed to produce ${path}`); +} + +const SLOT = 19; +let ops: { + loadFontAtlas(buf: Uint8Array): void; + measureText(s: string, slot: number): number; + wrapText?(s: string, slot: number, maxW: number): number[]; +}; + +beforeAll(async () => { + ensureBuilt(WASM_PATH, [process.execPath, "tools/wasm.ts"]); + const wasm = await createWasmUi(await Bun.file(WASM_PATH).arrayBuffer()); + ops = wasm.ops; + const atlas = await Bun.file(join(ROOT, "apps/desk98/fonts/w95fa-19.bin")).arrayBuffer(); + ops.loadFontAtlas(new Uint8Array(atlas)); +}); + +const SAMPLES = [ + "", + "Welcome to PocketJS 98.", + "This desktop is one PocketJS guest: the windows, the taskbar, the Start menu and this Notepad are Vue Vapor JSX over the same DrawList contract the consoles boot, painted by the gpui backend.", + " - drag-select this text; Cmd+C/X/V, right-click", + "word", + "spaces hang at soft breaks ", + "averyveryverylongunbreakabletokenthatmustcharsplitacrossrows plus a tail", + "a b c d e f g h i j k l m n o p q r s t u v w x y z", +]; +const WIDTHS = [24, 60, 120, 200, 388]; + +describe("wrapText op ↔ JS fallback parity", () => { + test("the op exists on the wasm host", () => { + expect(typeof ops.wrapText).toBe("function"); + }); + + test("break columns agree with the greedy JS rules for every sample", () => { + const width = (s: string) => ops.measureText(s, SLOT); + for (const line of SAMPLES) { + for (const maxW of WIDTHS) { + const opBreaks = ops.wrapText!(line, SLOT, maxW); + const jsBreaks = wrapLine(line, maxW, width) + .slice(1) + .map((s) => s.from); + expect({ line, maxW, breaks: opBreaks }).toEqual({ line, maxW, breaks: jsBreaks }); + // Segments rebuilt from the op tile the line exactly. + const segs = segsFromBreaks(line.length, opBreaks); + expect(segs[0].from).toBe(0); + expect(segs[segs.length - 1].to).toBe(line.length); + for (let i = 1; i < segs.length; i++) expect(segs[i].from).toBe(segs[i - 1].to); + } + } + }); + + test("fitting lines and empty text produce no breaks", () => { + expect(ops.wrapText!("", SLOT, 100)).toEqual([]); + expect(ops.wrapText!("short", SLOT, 10000)).toEqual([]); + }); +}); diff --git a/tools/test.ts b/tools/test.ts index 260cc9b1..9d67c714 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -97,6 +97,7 @@ const SUITE: readonly Stage[] = [ "tests/vue-vapor-dom.test.ts", "tests/vue-vapor-pak.test.ts", "tests/svg-bake.test.ts", + "tests/wrap-op.test.ts", "tests/devtools.test.ts", "tests/hot.test.ts", "tests/clock.test.ts",