From 636e33e1af51f4d7a3941074e1628005c54cac6e Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:02:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(desk98):=20word=20wrap=20in=20Notepad?= =?UTF-8?q?=20=E2=80=94=20logical=20lines,=20visual=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Doc keeps LOGICAL lines and the caret/selection stay in logical (row, col) coordinates; wrapDoc() (notepad.ts, pure) projects each line onto visual segments {row, from, to} under a pixel width, and every mapping in both directions goes through that one segment list — the render (NotepadView), hit-testing (clicks, drags), caret movement (Up/Down/Home/End step VISUAL rows), selection highlight (spans intersected per segment), scrolling and the IME caret intent all share one layout truth, so reflow on window resize falls out of the reactive width for free. - Greedy word wrap: break before the overflowing word, trailing spaces hang on the upper row (classic Notepad), words wider than a whole row split at character level. Widths come from the additive-advance measureText, cached per token (bounded). - Wrap affinity: a caret at a soft-wrap boundary belongs to the next row's start unless it carries `end` (End key, clicks past a wrapped row's text) — no visual caret jumps. - Edit > Word Wrap is a real toggle now (checked menu items render a checkmark, gen-icons check-16); wrap off = maxW Infinity, the same code path with one segment per line. - The welcome text's paragraphs become single logical lines — resizing the window reflows them live. Co-Authored-By: Claude Fable 5 --- apps/desk98/app.tsx | 87 ++++++++------- apps/desk98/chrome.tsx | 4 +- apps/desk98/gen-icons.ts | 22 ++++ apps/desk98/icons/check-16.svg | 1 + apps/desk98/notepad.ts | 194 ++++++++++++++++++++++++++++++++- apps/desk98/programs.tsx | 84 +++++++++----- apps/desk98/screenshot.png | Bin 63462 -> 65094 bytes apps/desk98/state.ts | 4 + docs/BACKENDS.md | 13 ++- tests/desk98.test.ts | 84 ++++++++++++++ 10 files changed, 419 insertions(+), 74 deletions(-) create mode 100644 apps/desk98/icons/check-16.svg diff --git a/apps/desk98/app.tsx b/apps/desk98/app.tsx index 1daaaa4a..ab094c57 100644 --- a/apps/desk98/app.tsx +++ b/apps/desk98/app.tsx @@ -63,14 +63,18 @@ 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, hasSel, @@ -78,6 +82,7 @@ import { selectAll, selectedText, wordRangeAt, + type Caret, type CaretMove, type Doc, } from "./notepad.ts"; @@ -88,10 +93,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 +106,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 +173,7 @@ function Window98(props: { win: WinCtl; active: boolean }) { ) : null} {w.kind === "notepad" ? ( - + ) : w.kind === "mines" ? ( ) : w.kind === "folder" ? ( @@ -317,6 +320,7 @@ export default function App() { }), scroll: ref(0), preedit: ref<{ s: string; c: number } | null>(null), + wrap: ref(true), }; const w = createWin({ kind: "notepad", @@ -369,7 +373,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); + }, + }, ], }, { @@ -741,15 +752,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 +871,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 +1095,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"); @@ -1278,7 +1290,7 @@ export default function App() { 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,7 +1313,8 @@ 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; @@ -1405,7 +1418,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 +1464,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..238a4f2a 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 { @@ -197,6 +211,180 @@ 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; +} + +/** 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..530d1cd8 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, wrapDoc, 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,56 @@ 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; } -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 wrapDoc(d.doc.value.lines, padWrapW(w), padWidth); +} + +export function NotepadView(props: { data: PadData; wrapW: number; active: boolean }) { const d = props.data; - const caretRow = () => d.doc.value.caret.row; + const segsAll = () => wrapDoc(d.doc.value.lines, props.wrapW, padWidth); + 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 +85,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 +117,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 725f5a3cb6675a6df0e1c659293f6d32f9415995..d3415a4b494192ed4f34d9da6663d87f878156fc 100644 GIT binary patch literal 65094 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 literal 63462 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 BFvI`= diff --git a/apps/desk98/state.ts b/apps/desk98/state.ts index 37a97f44..88f9b92f 100644 --- a/apps/desk98/state.ts +++ b/apps/desk98/state.ts @@ -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,8 @@ 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; } export interface MinesData { diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 2ca00a64..085796f4 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -125,12 +125,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/tests/desk98.test.ts b/tests/desk98.test.ts index 0debcf5d..490f7bcd 100644 --- a/tests/desk98.test.ts +++ b/tests/desk98.test.ts @@ -24,7 +24,10 @@ import { } from "../apps/desk98/mines.ts"; import { applyMove, + applyMoveWrapped, backspace, + caretAtPoint, + caretXY, colFromX, del, deleteSel, @@ -32,10 +35,14 @@ import { insertText, moveCaret, rowSelSpan, + segSelSpan, selectAll, selectedText, selRange, + vrowOf, wordRangeAt, + wrapDoc, + wrapLine, type Doc, } from "../apps/desk98/notepad.ts"; @@ -337,6 +344,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"], From 563e8107cd0a1e763bb80dbb892294eb16a5b269 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:33:00 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(text):=20the=20wrapText=20host=20op=20?= =?UTF-8?q?=E2=80=94=20platform=20break=20positions,=20Zed's=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-wrap gains its platform half, the same division of labor Zed uses: the platform answers "where does this line break at this width in this font", the application owns the wrapped coordinate space on top. - spec op 43 `wrapText(str, fontSlot, maxW) -> u32[]` (ascending UTF-16 break columns, empty = fits). Optional like every post-16 op: hosts that predate it — apps fall back to the same greedy rules over measureText. - engine/core: Fonts::wrap_text — greedy word wrap (break before the overflowing word, hanging trailing spaces, char-split for over-wide words) over whatever provider measure_run resolves to, so break positions always derive from the metrics that size and paint the text; Ui::set_text_wrap installs a full native wrapper whose positions win. - gpui backend: native_wrap — gpui's own LineWrapper (the machinery Zed's editor WrapMap consumes), resolved through the same TextConfig as the measurer and painter; hosts/macos installs it next to native_measure under --native-text. - wasm + symbian: ui_wrap_text/ui_wrap_text_ptr (FRAMEBUFFER-style staging); hosts/web wasm-ops feature-detects the export. - pocket-ui-surface: op!("wrapText") + set_text_wrap, the QuickJS side. - desk98 consumes the op when present (segsFromBreaks) and keeps the JS greedy as the no-op-host fallback; the visual-segment model, caret affinity and selection mapping are unchanged. - tests: core greedy + native-override unit test; tests/wrap-op.test.ts pins the op column-for-column equal to the JS fallback over desk98's committed W95FA atlas (both reduce to the same additive advances). Co-Authored-By: Claude Fable 5 --- apps/desk98/notepad.ts | 17 +++ apps/desk98/programs.tsx | 27 ++++- contracts/spec/spec.ts | 20 ++++ docs/BACKENDS.md | 9 ++ docs/DESIGN.md | 1 + engine/backends/gpui/src/fonts.rs | 48 ++++++++- engine/backends/gpui/src/lib.rs | 2 +- engine/core/src/lib.rs | 14 +++ engine/core/src/spec.rs | 1 + engine/core/src/tests.rs | 36 +++++++ engine/core/src/text.rs | 102 ++++++++++++++++++ .../crates/pocket-ui-surface/src/surface.rs | 15 +++ engine/symbian/src/lib.rs | 21 ++++ engine/wasm/src/lib.rs | 22 ++++ framework/src/host.ts | 8 ++ hosts/macos/src/main.rs | 6 +- hosts/web/wasm-ops.js | 12 +++ tests/wrap-op.test.ts | 79 ++++++++++++++ tools/test.ts | 1 + 19 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 tests/wrap-op.test.ts diff --git a/apps/desk98/notepad.ts b/apps/desk98/notepad.ts index 238a4f2a..058a4a55 100644 --- a/apps/desk98/notepad.ts +++ b/apps/desk98/notepad.ts @@ -286,6 +286,23 @@ export function wrapDoc(lines: string[], maxW: number, width: (s: string) => num 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). */ diff --git a/apps/desk98/programs.tsx b/apps/desk98/programs.tsx index 530d1cd8..e4db7fe7 100644 --- a/apps/desk98/programs.tsx +++ b/apps/desk98/programs.tsx @@ -11,7 +11,7 @@ import { Image, View } from "@pocketjs/framework/components"; import { getOps } from "@pocketjs/framework/host"; import { T98 } from "./chrome.tsx"; import { FONT, FRAME } from "./theme.ts"; -import { caretXY, segSelSpan, wrapDoc, type VSeg } from "./notepad.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, WinCtl } from "./state.ts"; @@ -52,16 +52,37 @@ export function padWrapW(w: WinCtl): number { 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; +} + /** 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 wrapDoc(d.doc.value.lines, padWrapW(w), padWidth); + return wrapDocHost(d.doc.value.lines, padWrapW(w)); } export function NotepadView(props: { data: PadData; wrapW: number; active: boolean }) { const d = props.data; - const segsAll = () => wrapDoc(d.doc.value.lines, props.wrapW, padWidth); + 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 pre = d.preedit.value; 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 085796f4..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 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/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", From cfa3ed38fae0c526e6d804496829a759b3ac5be8 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:49:39 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(desk98):=20undo/redo=20in=20Notepad=20?= =?UTF-8?q?=E2=80=94=20coalesced=20snapshots,=20macOS=20chords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs are immutable values (every edit returns a fresh one), so history is plain snapshot references — O(1) per step, structure shared (notepad.ts History, pure + unit-tested). Coalescing: consecutive edits of the same continuous kind collapse into one undo unit (typing runs, erase runs); Enter/paste/cut/Time-Date/New always stand alone; continuity is checked against the doc the last recorded edit produced, so a caret move or click between keystrokes breaks the group without recording anything itself. Undo restores caret AND selection (the snapshot carries the anchor). Every mutation routes through one applyEdit(w, kind, next) door (no-op edits record nothing, docEquals); plain caret/selection moves bypass it. Chords: ⌘Z undo, ⌘⇧Z redo; Undo/Redo rows join the Edit menu and the context menu with live disabled states (menus already build their items at open). The desk journey covers the loop end to end: a two-keystroke typing run pulls back out with ONE ⌘Z and replays with ⌘⇧Z. Co-Authored-By: Claude Fable 5 --- apps/desk98/app.tsx | 97 ++++++++++++++++++++++++++++++++-------- apps/desk98/notepad.ts | 67 +++++++++++++++++++++++++++ apps/desk98/state.ts | 5 ++- tests/desk98-sim.test.ts | 14 ++++++ tests/desk98.test.ts | 80 +++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 19 deletions(-) diff --git a/apps/desk98/app.tsx b/apps/desk98/app.tsx index ab094c57..8f8a3da4 100644 --- a/apps/desk98/app.tsx +++ b/apps/desk98/app.tsx @@ -77,14 +77,20 @@ import { 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"; @@ -293,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 { @@ -321,6 +326,7 @@ 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", @@ -337,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: "" }, @@ -354,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 }, @@ -574,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 ---------------------------------------------- @@ -1213,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; @@ -1251,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") { @@ -1273,17 +1304,17 @@ 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": @@ -1320,12 +1351,42 @@ export default function App() { 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[] => @@ -1401,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": { @@ -1409,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; diff --git a/apps/desk98/notepad.ts b/apps/desk98/notepad.ts index 058a4a55..762144f5 100644 --- a/apps/desk98/notepad.ts +++ b/apps/desk98/notepad.ts @@ -29,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 // --------------------------------------------------------------------------- diff --git a/apps/desk98/state.ts b/apps/desk98/state.ts index 88f9b92f..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"; @@ -70,6 +70,9 @@ export interface PadData { 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/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 490f7bcd..f58033c1 100644 --- a/tests/desk98.test.ts +++ b/tests/desk98.test.ts @@ -31,14 +31,19 @@ import { colFromX, del, deleteSel, + docEquals, + emptyHistory, hasSel, insertText, moveCaret, + record, + redoStep, rowSelSpan, segSelSpan, selectAll, selectedText, selRange, + undoStep, vrowOf, wordRangeAt, wrapDoc, @@ -278,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 // ---------------------------------------------------------------------------