diff --git a/DESIGN.md b/DESIGN.md index fcee117..0e02e84 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -651,10 +651,13 @@ and no new endpoint. ## 15. Copy as Image The code view keeps its selection as a model range (line/column), so Select All, copy and image export cover lines -that are not in the DOM and never include decorations (gutter, "more characters"). **Copy as Image** (right-click -menu with a selection, palette command `selection.copyImage`, Cmd/Ctrl+Shift+C; not in the terminal) lays the range -out as canvas draw ops (`imagelayout.ts`, pure) in the theme colours and writes a PNG; a Markdown preview selection -is serialised into an SVG `foreignObject` snapshot (`mdsnap.ts`). The ClipboardItem is created synchronously inside -the gesture with a promise of the blob. Fallbacks: macOS app posts `copyimage` to the native bridge, no image -clipboard downloads the PNG, a render failure copies plain text; selections over 20,000 rows or 4,000 columns, or whose canvas would exceed 16M pixels even at scale 1, are refused (the scale drops from 2 to fit). Toasts reuse the -app toast. +that are not in the DOM; line numbers never join a mouse selection and never appear in the image. **Copy as Image** +(right-click menu with a selection, palette command `selection.copyImage`, Cmd/Ctrl+Shift+C; not in the terminal) +lays the range out as canvas draw ops (`imagelayout.ts`, pure) in the theme colours. `imageframe.ts` wraps it in a +snippet card: a backdrop, a rounded window with window dots, the file name as the title and a shadow, always at 2x +(the scale drops only to fit the pixel cap). A Markdown preview selection is serialised into an SVG +`foreignObject` snapshot (`mdsnap.ts`) and gets the same frame; so does the macOS native snapshot, which posts +`copyimage` to the bridge. The result goes to the clipboard only: the ClipboardItem is created synchronously inside +the gesture with a promise of the blob, and nothing else is tried. Any failure shows a clear error +toast. Selections over 20,000 rows or 4,000 columns, or whose framed canvas would exceed 16M pixels even at scale 1, +are refused ("Selection too large for an image"). diff --git a/README.md b/README.md index e72b818..50f5bb6 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,12 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/ ## Copy as Image Select code (or text in a Markdown preview) and choose **Copy as Image** from the right-click menu, the command -palette (*Copy Selection as Image*), or press ⌘⇧C / Ctrl+Shift+C. Code is drawn with the current theme's syntax -colours (no line numbers); a preview selection is rendered as it looks on screen. If the clipboard does not accept -images the PNG is downloaded instead; if rendering fails the plain text is copied and a message says so. Very large -selections are refused with "Selection too large for an image". +palette (*Copy Selection as Image*), or press ⌘⇧C / Ctrl+Shift+C. The image goes to the clipboard only and is +never written to disk. It is a share-ready snippet card: a backdrop, a rounded window with three window dots, the +file name as the title, a soft shadow, rendered at 2x. Code uses the current theme's syntax colours; line numbers +never join a selection, so they are never in the image. Markdown selections and the macOS app's native snapshot get +the same frame. If the image cannot be put on the clipboard, a clear error says so. Very large selections are +refused with "Selection too large for an image". ## Markdown diff --git a/packaging/macos/main.swift b/packaging/macos/main.swift index f740c35..0f24bc1 100644 --- a/packaging/macos/main.swift +++ b/packaging/macos/main.swift @@ -242,7 +242,7 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI // {t: "theme", dark, bg} sync the window appearance // {t: "drag"} mouse-down on empty title bar: move the window // {t: "copyimage", png} PNG (base64) to put on the general pasteboard - // {t: "snapshotrect", id, x, y, w, h} snapshot a viewport rect (CSS px) to the pasteboard; replies window.__cvSnap(id, ok) + // {t: "snapshotrect", id, x, y, w, h} snapshot a viewport rect (CSS px) and return it; replies window.__cvSnap(id, ok, pngBase64); the pasteboard is written by copyimage // {t: "snapshotcancel", id} timed out: a late snapshot for id is dropped // {t: "dblclick"} double-click on the title bar: system zoom/minimize action func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) { @@ -270,19 +270,17 @@ final class ViewerWindow: NSObject, NSWindowDelegate, WKNavigationDelegate, WKUI if t == "snapshotrect" { guard let id = (body["id"] as? NSNumber)?.intValue else { return } func num(_ k: String) -> Double? { (body[k] as? NSNumber)?.doubleValue } - func reply(_ ok: Bool) { webView.evaluateJavaScript("window.__cvSnap && window.__cvSnap(\(id), \(ok))", completionHandler: nil) } + func reply(_ ok: Bool, _ b64: String = "") { webView.evaluateJavaScript("window.__cvSnap && window.__cvSnap(\(id), \(ok), \"\(b64)\")", completionHandler: nil) } guard let x = num("x"), let y = num("y"), let w = num("w"), let h = num("h"), x.isFinite, y.isFinite, w.isFinite, h.isFinite, w > 0, h > 0 else { return reply(false) } let cfg = WKSnapshotConfiguration() cfg.rect = CGRect(x: x, y: y, width: w, height: h) webView.takeSnapshot(with: cfg) { [weak self] image, _ in - // A cancelled (timed out) request must not touch the pasteboard or reply. + // A cancelled (timed out) request must not reply. if self?.cancelledSnaps.remove(id) != nil { return } guard let image = image, let tiff = image.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) else { return reply(false) } - let pb = NSPasteboard.general - pb.clearContents() - reply(pb.setData(png, forType: .png)) + reply(true, png.base64EncodedString()) } return } diff --git a/src/client/app.ts b/src/client/app.ts index d8b9867..5e5575a 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -12,8 +12,8 @@ import { Updates } from "./updates.ts"; import { debounce, dragSplitter, esc, formatBytes, h, IS_MAC, MOD, store } from "./util.ts"; import { isContextMenuOpen, showContextMenu } from "./menu.ts"; import { bindEditorSelectionMenu, readSelection, snapshotText, copyImageCommand, selectionSnapshot, type SelectionHost } from "./copyentry.ts"; -import { copyCodeImage, copyPngOrSnapshot, MSG_DOWNLOADED, MSG_FAILED, MSG_NO_SELECTION, MSG_OK } from "./copyimage.ts"; -import { browserDeps, fragmentFromRange, markdownSnapDeps, snapshotDeps } from "./imagedom.ts"; +import { copyCodeImage, copyPngOrSnapshot, messageFor, MSG_NO_SELECTION } from "./copyimage.ts"; +import { browserDeps, fragmentFromRange, framePng, markdownSnapDeps, snapshotDeps } from "./imagedom.ts"; import { snapshotMarkdown } from "./mdsnap.ts"; import type { ImageTheme } from "./imagelayout.ts"; import type { SelRange } from "./selection.ts"; @@ -552,6 +552,8 @@ class App { return { bg: cssVar("--bg-editor"), fg: cssVar("--fg"), + muted: cssVar("--fg-muted"), + accent: cssVar("--accent"), tokenColors, tokenStyles: { c: { italic: true }, h: { bold: true }, b: { bold: true }, i: { italic: true } }, font, @@ -569,27 +571,21 @@ class App { if (tab?.md) { const range = snap?.md ?? tab.md.selectionRange(); if (!range) return notify(MSG_NO_SELECTION); - const text = range.toString(); - const png = fragmentFromRange(range, tab.md.el, cssVar("--bg-editor")) - .then((f) => snapshotMarkdown(markdownSnapDeps, f)) - .then((r) => { - if (!(r instanceof Blob)) throw new Error("snapshot failed"); - return r; - }); + const title = tab.path ?? ""; + const measure = deps.measure!; + const png = fragmentFromRange(range, tab.md.el, cssVar("--bg-editor")).then(async (f) => { + const r = await snapshotMarkdown(markdownSnapDeps, f); + if (!(r instanceof Blob)) throw new Error("snapshot failed"); + return framePng(r, f.width, f.height, title, theme, measure); + }); const rects = Array.from(range.getClientRects()); const vp = { width: window.innerWidth, height: window.innerHeight }; - void copyPngOrSnapshot({ ...deps, ...snapshotDeps(nativeBridge()) }, png, rects, vp).then(async (res) => { - if (res === "failed") { - await Promise.resolve(deps.copyText(text)).catch(() => {}); - return notify(MSG_FAILED); - } - notify(res === "downloaded" ? MSG_DOWNLOADED : MSG_OK); - }); + void copyPngOrSnapshot({ ...deps, ...snapshotDeps(nativeBridge()), compose: (p, r) => framePng(p, r.w, r.h, title, theme, measure) }, png, rects, vp).then((res) => notify(messageFor(res))); return; } const input = tab?.code?.imageInput(snap?.code); if (!input) return notify(MSG_NO_SELECTION); - void copyCodeImage(deps, { ...input, theme, dpr: window.devicePixelRatio || 1 }); + void copyCodeImage(deps, { ...input, theme, title: tab?.path, dpr: window.devicePixelRatio || 1 }); } private notify(msg: string) { diff --git a/src/client/codeview.ts b/src/client/codeview.ts index 3fda44a..76a9fbc 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -79,6 +79,10 @@ export class CodeView { private hunks: Hunk[] = []; private gitMarks: Uint8Array = new Uint8Array(0); private blame: { line: number; html: string } | null = null; + /** Primary button held on a code line (drag-selection in progress); the blame label waits for mouseup. */ + private pressed = false; + /** Line whose row currently carries the .blame span (-1 = none); render() rebuilds it with the markup. */ + private shownBlame = -1; private peek: HTMLElement | null = null; // find state @@ -142,6 +146,7 @@ export class CodeView { } }); this.linesEl.addEventListener("mousedown", (e) => { + this.pressed = false; // a new press supersedes a hold whose mouseup was lost const ln = (e.target as HTMLElement).closest(".ln"); if (!ln) return; const n = Number(ln.dataset.n); @@ -153,8 +158,19 @@ export class CodeView { this.togglePeek(n, ln); return; } + this.pressed = true; this.setActiveLine(n); }); + const release = () => { + if (!this.pressed) return; + this.pressed = false; + this.syncBlame(); + }; + document.addEventListener("mouseup", release); + window.addEventListener("blur", release); + document.addEventListener("mousemove", (e) => { + if (this.pressed && e.buttons === 0) release(); // the button was released outside the page + }); this.scroller.addEventListener("scroll", () => this.closePeek(), { passive: true }); this.scroller.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "a") { @@ -167,7 +183,10 @@ export class CodeView { e.preventDefault(); e.clipboardData?.setData("text/plain", this.selectedText()); }); - document.addEventListener("selectionchange", () => this.readDomSelection()); + document.addEventListener("selectionchange", () => { + this.readDomSelection(); + this.syncBlame(); // a selection hides the label only while it exists + }); // A primary press outside the lines (not on the context menu) makes the model range stale. document.addEventListener("mousedown", (e) => { const t = e.target as HTMLElement | null; @@ -370,7 +389,26 @@ export class CodeView { /** Inline blame annotation shown at the end of the active line. */ setBlame(line: number, html: string | null) { this.blame = html === null ? null : { line, html }; - this.schedule(); + this.syncBlame(); + } + + private blameVisible(i: number): boolean { + return !!this.blame && this.blame.line === i && !this.pressed && window.getSelection?.()?.isCollapsed !== false; + } + + private rowEl(i: number): HTMLElement | null { + return this.linesEl.querySelector?.(`.ln[data-n="${i}"]`) ?? null; + } + + /** Patches the single .blame span in place; rows are never rebuilt (that would break a drag-selection). */ + private syncBlame() { + if (this.shownBlame >= 0) this.rowEl(this.shownBlame)?.querySelector?.(".blame")?.remove(); + this.shownBlame = -1; + if (!this.blame || !this.blameVisible(this.blame.line)) return; + const row = this.rowEl(this.blame.line); + if (!row) return; + row.insertAdjacentHTML("beforeend", `${this.blame.html}`); + this.shownBlame = this.blame.line; } private hunkAt(line: number): Hunk | undefined { @@ -481,11 +519,13 @@ export class CodeView { cls += " flash"; style = ` style="animation-delay:${-(now - this.flash.at) | 0}ms"`; } - const blame = this.blame && this.blame.line === i ? `${this.blame.html}` : ""; + const blame = this.blameVisible(i) ? `${this.blame.html}` : ""; html += `
${i + 1}${this.lineHtml(i)}${blame}
`; } this.linesEl.style.transform = `translateY(${first * this.lh}px)`; this.linesEl.innerHTML = html; + const bl = this.blame?.line ?? -1; + this.shownBlame = bl >= first && bl < last && this.blameVisible(bl) ? bl : -1; this.restoreSelection(); } @@ -539,9 +579,13 @@ export class CodeView { } setActiveLine(n: number) { + const old = this.activeLine; this.activeLine = n; this.onActiveLine(n); - this.schedule(); + if (old !== n) { + if (old >= 0) this.rowEl(old)?.classList?.remove?.("active"); + this.rowEl(n)?.classList?.add?.("active"); + } } /** Scrolls so that 0-based `line` is visible (centered when far away). */ diff --git a/src/client/copyimage.ts b/src/client/copyimage.ts index 86444a7..ec5ae71 100644 --- a/src/client/copyimage.ts +++ b/src/client/copyimage.ts @@ -1,22 +1,34 @@ -// Image copy orchestration: layout -> render -> clipboard / native / download. Deps are injected. +// Image copy orchestration: layout -> render -> clipboard / native. Deps are injected. import type { Span } from "./highlight.ts"; -import { fitScale, layoutImage, type ImageLayout, type ImageTheme, type LayoutOptions } from "./imagelayout.ts"; -import { textOf, type SelRange } from "./selection.ts"; +import { frameImage } from "./imageframe.ts"; +import { layoutImage, type ImageLayout, type ImageTheme, type LayoutOptions } from "./imagelayout.ts"; +import type { SelRange } from "./selection.ts"; export const MSG_OK = "Copied as image"; export const MSG_TOO_LARGE = "Selection too large for an image"; -export const MSG_DOWNLOADED = "Clipboard unavailable: image downloaded"; -export const MSG_FAILED = "Could not create image; copied text instead"; +export const MSG_CLIPBOARD_FAILED = "Could not copy image to the clipboard"; export const MSG_NO_SELECTION = "Nothing selected"; -export type CopyResult = "native" | "clipboard" | "downloaded" | "failed"; +export type CopyResult = "native" | "clipboard" | "failed" | "tooLarge"; + +/** Thrown when a framed image would exceed the pixel cap. */ +export class FrameTooLargeError extends Error { + constructor() { + super("framed image too large"); + } +} + +/** Toast message for a copy result. */ +export function messageFor(res: CopyResult | "snapshot"): string { + return res === "tooLarge" ? MSG_TOO_LARGE : res === "failed" ? MSG_CLIPBOARD_FAILED : MSG_OK; +} export interface CopyImageDeps { renderPng(layout: ImageLayout, scale: number): Promise; clipboard?: { write?(items: unknown[]): Promise; ClipboardItem?: new (data: Record>) => unknown }; - download(blob: Blob, name: string): void; native?: (png: Blob) => Promise; - copyText(text: string): Promise | void; + /** Text width in layout px, for ellipsising the frame title. */ + measure?: (text: string) => number; notify(msg: string): void; } export interface CopyImageInput { @@ -25,11 +37,11 @@ export interface CopyImageInput { theme: ImageTheme; range: SelRange; dpr: number; + /** Active file path or name; its basename titles the frame. */ + title?: string; opts?: LayoutOptions; } -const FILE_NAME = "selection.png"; - /** Writes a PNG (as a promise, so the ClipboardItem can be built inside the user gesture). */ export async function copyPng(deps: CopyImageDeps, png: Promise): Promise { const { clipboard } = deps; @@ -49,25 +61,25 @@ export async function copyPng(deps: CopyImageDeps, png: Promise): Promise< await png; // a render failure must not read as success even if write() ignored it return "clipboard"; } catch { - /* fall through to download */ + // write() may wrap the render error: look at the PNG promise itself + return (await png.then(() => undefined, (e: unknown) => e)) instanceof FrameTooLargeError ? "tooLarge" : "failed"; } } let blob: Blob; try { blob = await png; - } catch { - return "failed"; + } catch (e) { + return e instanceof FrameTooLargeError ? "tooLarge" : "failed"; } if (deps.native) { try { await deps.native(blob); return "native"; } catch { - /* fall through to download */ + return "failed"; } } - deps.download(blob, FILE_NAME); - return "downloaded"; + return "failed"; } /** Copies the range as an image; returns (and notifies) the user-facing message. */ @@ -78,18 +90,12 @@ export async function copyCodeImage(deps: CopyImageDeps, input: CopyImageInput): }; const layout = layoutImage(input.lines, input.tokens, input.theme, input.range, input.opts); if ("tooLarge" in layout) return done(MSG_TOO_LARGE); - const fit = fitScale(layout.width, layout.height, input.dpr); - if ("tooLarge" in fit) return done(MSG_TOO_LARGE); - const result = await copyPng(deps, deps.renderPng(layout, fit.scale)); - if (result === "failed") { - try { - await deps.copyText(textOf(input.lines, input.range)); - } catch { - /* nothing more to try */ - } - return done(MSG_FAILED); - } - return done(result === "downloaded" ? MSG_DOWNLOADED : MSG_OK); + const size = input.theme.size; + const measure = deps.measure ?? ((t: string) => t.length * size * 0.6); + const framed = frameImage(layout, input.title ?? "", input.theme, measure, input.dpr); + if ("tooLarge" in framed) return done(MSG_TOO_LARGE); + const result = await copyPng(deps, deps.renderPng(framed, framed.scale)); + return done(messageFor(result)); } export interface RectLike { @@ -132,26 +138,47 @@ export function clampSnapRect(rects: RectLike[], vp: Viewport): SnapRect | null export interface SnapshotDeps { /** Native WKWebView snapshot of a viewport rect; resolves true when the PNG reached the pasteboard. */ - snapshot?: (r: SnapRect, id?: number) => Promise; + snapshot?: (r: SnapRect, id?: number) => Promise; + /** Frames the decoded snapshot PNG (CSS-px rect it covers) into the snippet card. */ + compose?: (png: Blob, rect: SnapRect) => Promise; + /** Writes a PNG to the native pasteboard. */ + native?: (png: Blob) => Promise; /** Starts a request: returns its id (supersedes any pending one) and makes it the owner of the highlight hide. */ begin?: () => number; /** Hides (true) / restores (false) the selection highlight; a restore by a request that no longer owns the hide is ignored. */ hideSelection(on: boolean, id?: number): void; } -/** Tries the native snapshot of the selection rects; true only when it succeeded. Always restores the highlight. */ -export async function copyViaSnapshot(deps: SnapshotDeps, rects: RectLike[], vp: Viewport): Promise { +/** Decodes base64 PNG data into an image/png blob. */ +export function pngFromBase64(b64: string): Blob { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new Blob([bytes], { type: "image/png" }); +} + +/** Tries the native snapshot of the selection rects (base64 PNG), frames it and writes it through the native pasteboard; true only when it all succeeded. Always restores the highlight. */ +export async function copyViaSnapshot(deps: SnapshotDeps, rects: RectLike[], vp: Viewport): Promise { const rect = deps.snapshot ? clampSnapRect(rects, vp) : null; if (!deps.snapshot || !rect) return false; + const { compose, native } = deps; const id = deps.begin?.(); deps.hideSelection(true, id); + let b64: string | null; try { - return (await deps.snapshot(rect, id)) === true; + b64 = await deps.snapshot(rect, id); } catch { - return false; + b64 = null; } finally { deps.hideSelection(false, id); } + if (typeof b64 !== "string" || !b64 || !compose || !native) return false; + try { + await native(await compose(pngFromBase64(b64), rect)); + return true; + } catch (e) { + return e instanceof FrameTooLargeError ? "tooLarge" : false; + } } /** copyPng, then (only when the PNG could not be produced) the native snapshot fallback. */ @@ -162,8 +189,9 @@ export async function copyPngOrSnapshot( vp: Viewport, ): Promise { const res = await copyPng(deps, png); - if (res === "failed" && (await copyViaSnapshot(deps, rects, vp))) return "snapshot"; - return res; + if (res !== "failed") return res; // tooLarge would not fit through the snapshot either + const snap = await copyViaSnapshot(deps, rects, vp); + return snap === true ? "snapshot" : snap === "tooLarge" ? "tooLarge" : res; } export interface SnapTracker { diff --git a/src/client/imagedom.ts b/src/client/imagedom.ts index 4157e0b..9fd92a9 100644 --- a/src/client/imagedom.ts +++ b/src/client/imagedom.ts @@ -1,7 +1,8 @@ -// Thin browser glue for image copy (canvas, clipboard, download, native bridge). Verified manually. -import { snapTracker, type CopyImageDeps, type SnapRect, type SnapshotDeps } from "./copyimage.ts"; +// Thin browser glue for image copy (canvas, clipboard, native bridge). Verified manually. +import { FrameTooLargeError, snapTracker, type CopyImageDeps, type SnapRect, type SnapshotDeps } from "./copyimage.ts"; import { isInlinableImage, pruneToRange, snapFaces, snapSize, type PNode, type PrunedNode, type SnapDeps, type SnapFragment } from "./mdsnap.ts"; -import type { ImageLayout } from "./imagelayout.ts"; +import { canvasFont, fontLoadSpecs, frameSnapshot, glyphCells, scaledShadow } from "./imageframe.ts"; +import type { DrawOp, ImageLayout, ImageTheme } from "./imagelayout.ts"; function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath(); @@ -13,9 +14,11 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe ctx.closePath(); } -export async function renderLayoutPng(layout: ImageLayout, scale: number, font: string, size: number): Promise { +export async function renderLayoutPng(layout: ImageLayout, scale: number, font: string, size: number, image?: { bitmap: CanvasImageSource; x: number; y: number; w: number; h: number; clip?: { x: number; y: number; w: number; h: number; r: number } }): Promise { + font = layout.font ?? font; + size = layout.size ?? size; try { - await document.fonts.load(`${size}px ${font}`); + await Promise.all(fontLoadSpecs(layout.ops, font, size).map((f) => document.fonts.load(f))); } catch { /* draw with whatever font is available */ } @@ -27,25 +30,69 @@ export async function renderLayoutPng(layout: ImageLayout, scale: number, font: ctx.scale(scale, scale); ctx.textBaseline = "top"; for (const op of layout.ops) { - if (op.t === "rect") { + if (op.t === "gradient") { + const g = ctx.createLinearGradient(op.x0, op.y0, op.x1, op.y1); + for (const st of op.stops) g.addColorStop(st.at, st.color); + ctx.fillStyle = g; + ctx.fillRect(op.x, op.y, op.w, op.h); + } else if (op.t === "rect") { + ctx.save(); + if (op.shadow) { + ctx.shadowColor = op.shadow.color; + const sh = scaledShadow(op.shadow, scale); + ctx.shadowBlur = sh.blur; + ctx.shadowOffsetY = sh.offsetY; + } ctx.fillStyle = op.fill; roundRect(ctx, op.x, op.y, op.w, op.h, op.r); ctx.fill(); + ctx.restore(); + } else if (op.t === "dot") { + ctx.fillStyle = op.fill; + ctx.beginPath(); + ctx.arc(op.cx, op.cy, op.r, 0, Math.PI * 2); + ctx.fill(); } else { - ctx.font = `${op.italic ? "italic " : ""}${op.bold ? "bold " : ""}${size}px ${font}`; + ctx.font = canvasFont(font, size, op.bold, op.italic); ctx.fillStyle = op.fill; - ctx.fillText(op.text, op.x, op.y); + // One glyph per grid cell: the editor disables ligatures, so the image must never form them. + const charW = layout.charW ?? size * 0.6; + if (op.whole) ctx.fillText(op.text, op.x, op.y); + else for (const g of glyphCells(op.text, op.x, charW)) ctx.fillText(g.ch, g.x, op.y); } } + if (image) { + ctx.save(); + // Keep the card's rounded corners where the image reaches the card edge. + if (image.clip) { + roundRect(ctx, image.clip.x, image.clip.y, image.clip.w, image.clip.h, image.clip.r); + ctx.clip(); + } + ctx.drawImage(image.bitmap, image.x, image.y, image.w, image.h); + ctx.restore(); + } return new Promise((res, rej) => canvas.toBlob((b) => (b ? res(b) : rej(new Error("toBlob failed"))), "image/png")); } -export function downloadBlob(blob: Blob, name: string): void { - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = name; - a.click(); - setTimeout(() => URL.revokeObjectURL(a.href), 1000); +/** Decodes a PNG blob (a data-URI image never taints the canvas). */ +async function decodePng(png: Blob): Promise { + const uri = await blobToDataUri(png); + return new Promise((res, rej) => { + const img = new Image(); + img.onload = () => res(img); + img.onerror = () => rej(new Error("png decode failed")); + img.src = uri; + }); +} + +/** Draws a snapshot PNG (cssW x cssH CSS px) inside the snippet card: backdrop, title bar, then the image. */ +export async function framePng(png: Blob, cssW: number, cssH: number, title: string, theme: ImageTheme, measure: (t: string) => number): Promise { + const f = frameSnapshot(cssW, cssH, title, theme, measure); + if ("tooLarge" in f) throw new FrameTooLargeError(); + const bitmap = await decodePng(png); + const layout: ImageLayout = { width: f.width, height: f.height, cols: 0, rows: 0, ops: f.ops as DrawOp[], font: theme.font, size: theme.size }; + const card = f.ops.find((o): o is Extract => o.t === "rect"); + return renderLayoutPng(layout, f.scale, theme.font, theme.size, { bitmap, ...f.image, clip: card && { x: card.x, y: card.y, w: card.w, h: card.h, r: card.r } }); } function toBase64(blob: Blob): Promise { @@ -65,20 +112,24 @@ export function browserDeps(opts: { font: string; size: number; bridge?: Bridge; const bridge = opts.bridge; return { renderPng: (layout, scale) => renderLayoutPng(layout, scale, opts.font, opts.size), + measure: (t) => { + const c = document.createElement("canvas").getContext("2d"); + if (!c) return t.length * opts.size * 0.6; + c.font = `${opts.size}px ${opts.font}`; + return c.measureText(t).width; + }, clipboard: cb?.write && w.ClipboardItem ? { write: (items) => cb.write!(items), ClipboardItem: w.ClipboardItem } : undefined, - download: downloadBlob, native: bridge ? async (png) => bridge.postMessage({ t: "copyimage", png: await toBase64(png) }) : undefined, - copyText: (t) => navigator.clipboard.writeText(t), notify: opts.notify, }; } const SNAP_TIMEOUT = 3000; const tracker = snapTracker(); -let snapDone: ((ok: boolean) => void) | undefined; +let snapDone: ((png: string | null) => void) | undefined; if (typeof window !== "undefined") { - (window as unknown as { __cvSnap?: (id: unknown, ok: unknown) => void }).__cvSnap = (id, ok) => { - if (tracker.accept(id)) snapDone?.(ok === true); + (window as unknown as { __cvSnap?: (id: unknown, ok: unknown, png?: unknown) => void }).__cvSnap = (id, ok, png) => { + if (tracker.accept(id)) snapDone?.(ok === true && typeof png === "string" && png ? png : null); }; } @@ -91,7 +142,7 @@ export function snapshotDeps(bridge: Bridge): SnapshotDeps { const old = tracker.cancel(); if (old !== undefined) { bridge?.postMessage({ t: "snapshotcancel", id: old }); - snapDone?.(false); + snapDone?.(null); } const id = tracker.begin(); tracker.claimHide(id); @@ -104,23 +155,24 @@ export function snapshotDeps(bridge: Bridge): SnapshotDeps { }, snapshot: bridge ? (r: SnapRect, id?: number) => - new Promise((res) => { + new Promise((res) => { if (id === undefined) id = deps.begin!(); const rid = id; - const finish = (ok: boolean) => { + const finish = (png: string | null) => { clearTimeout(timer); snapDone = undefined; - res(ok); + res(png); }; const timer = setTimeout(() => { if (tracker.cancel(rid) !== undefined) bridge.postMessage({ t: "snapshotcancel", id: rid }); - finish(false); + finish(null); }, SNAP_TIMEOUT); snapDone = finish; // Give the highlight removal one frame to paint before the native side captures. requestAnimationFrame(() => requestAnimationFrame(() => bridge.postMessage({ t: "snapshotrect", id: rid, ...r }))); }) : undefined, + native: bridge ? async (png) => bridge.postMessage({ t: "copyimage", png: await toBase64(png) }) : undefined, }; return deps; } diff --git a/src/client/imageframe.ts b/src/client/imageframe.ts new file mode 100644 index 0000000..5f483c8 --- /dev/null +++ b/src/client/imageframe.ts @@ -0,0 +1,172 @@ +// Pure geometry of the snippet frame (backdrop, window card, title bar). No DOM access. +import { charWidth } from "./term/width.ts"; +import { fitScale, MAX_PIXELS, type DrawOp, type GradStop, type ImageLayout, type ImageTheme, type Shadow } from "./imagelayout.ts"; + +export const BACKDROP_PAD = 56; +export const TITLE_BAR_H = 36; +export const CARD_RADIUS = 11; +export const MIN_CONTENT_W = 360; +export const DOT_COLORS = ["#ff5f57", "#febc2e", "#28c840"] as const; + +export type Measure = (text: string) => number; +export interface FrameTheme { + bg: string; + fg: string; + muted: string; + accent: string; +} +export interface Frame { + width: number; + height: number; + card: { x: number; y: number; w: number; h: number; r: number; fill: string }; + /** Where the code content's top-left lands on the canvas. */ + inset: { x: number; y: number }; + shadow: Shadow; + dots: { cx: number; cy: number; r: number; fill: string }[]; + backdrop: { x0: number; y0: number; x1: number; y1: number; stops: GradStop[] }; + title?: { text: string; x: number; y: number; fill: string }; +} +export type FrameResult = Frame | { tooLarge: true }; +export type FramedImage = ImageLayout & { scale: number }; +export type FramedResult = FramedImage | { tooLarge: true }; + +const TITLE_SIDE = 76; +const DOT_R = 6; +const DOT_GAP = 20; +const DOT_X0 = 20; +const TITLE_Y = 12; +const SHADOW: Shadow = { blur: 40, offsetY: 18, color: "rgba(0,0,0,0.45)" }; + +export function basename(path: string): string { + return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1); +} + +/** Shortens `text` with a trailing "…" until it fits `maxW`. */ +export function ellipsize(text: string, maxW: number, measure: Measure): string { + if (measure(text) <= maxW) return text; + let n = text.length - 1; + while (n > 0 && measure(text.slice(0, n) + "…") > maxW) n--; + return text.slice(0, n) + "…"; +} + +/** Darkens a #rgb/#rrggbb colour toward black; other formats are returned unchanged. */ +function darken(color: string, k: number): string { + const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color); + if (!m) return color; + const h = m[1]!.length === 3 ? m[1]!.replace(/./g, (c) => c + c) : m[1]!; + const ch = [0, 2, 4].map((i) => Math.round(parseInt(h.slice(i, i + 2), 16) * k)); + return "#" + ch.map((v) => v.toString(16).padStart(2, "0")).join(""); +} + +export function frameGeometry(contentW: number, contentH: number, title: string, theme: FrameTheme, measure: Measure): FrameResult { + const cw = Math.max(contentW, MIN_CONTENT_W); + const width = cw + 2 * BACKDROP_PAD; + const height = contentH + TITLE_BAR_H + 2 * BACKDROP_PAD; + if (width * height > MAX_PIXELS) return { tooLarge: true }; + const name = basename(title); + const text = name ? ellipsize(name, cw - 2 * TITLE_SIDE, measure) : ""; + const frame: Frame = { + width, + height, + card: { x: BACKDROP_PAD, y: BACKDROP_PAD, w: cw, h: contentH + TITLE_BAR_H, r: CARD_RADIUS, fill: theme.bg }, + inset: { x: BACKDROP_PAD, y: BACKDROP_PAD + TITLE_BAR_H }, + shadow: SHADOW, + dots: DOT_COLORS.map((fill, i) => ({ cx: BACKDROP_PAD + DOT_X0 + i * DOT_GAP, cy: BACKDROP_PAD + TITLE_BAR_H / 2, r: DOT_R, fill })), + backdrop: { + x0: 0, + y0: 0, + x1: width, + y1: height, + stops: [ + { at: 0, color: theme.accent }, + { at: 1, color: darken(theme.accent, 0.35) }, + ], + }, + }; + if (text) frame.title = { text, x: BACKDROP_PAD + cw / 2 - measure(text) / 2, y: BACKDROP_PAD + TITLE_Y, fill: theme.muted }; + return frame; +} + +export function frameOps(frame: Frame): DrawOp[] { + const c = frame.card; + const ops: DrawOp[] = [ + { t: "gradient", x: 0, y: 0, w: frame.width, h: frame.height, ...frame.backdrop }, + { t: "rect", x: c.x, y: c.y, w: c.w, h: c.h, r: c.r, fill: c.fill, shadow: frame.shadow }, + ...frame.dots.map((d) => ({ t: "dot" as const, ...d })), + ]; + if (frame.title) ops.push({ t: "text", x: frame.title.x, y: frame.title.y, text: frame.title.text, fill: frame.title.fill, bold: false, italic: false, whole: true }); + return ops; +} + +/** Wraps a laid-out code image (always 2x, reduced only to fit the pixel cap; dpr is not consulted) in the frame: offsets its text into the card and fits the scale to the outer size. */ +export function frameImage(layout: ImageLayout, title: string, theme: ImageTheme, measure: Measure, _dpr: number): FramedResult { + const ft: FrameTheme = { bg: theme.bg, fg: theme.fg, muted: theme.muted ?? theme.fg, accent: theme.accent ?? theme.fg }; + const frame = frameGeometry(layout.width, layout.height, title, ft, measure); + if ("tooLarge" in frame) return frame; + const fit = fitScale(frame.width, frame.height, 2); + if ("tooLarge" in fit) return fit; + const code = layout.ops + .filter((o) => o.t === "text") + .map((o) => ({ ...o, x: o.x + frame.inset.x, y: o.y + frame.inset.y })); + return { width: frame.width, height: frame.height, cols: layout.cols, rows: layout.rows, ops: [...frameOps(frame), ...code], font: theme.font, size: theme.size, ...(layout.charW ? { charW: layout.charW } : {}), scale: fit.scale }; +} + +export interface FramedSnapshot { + width: number; + height: number; + scale: number; + /** Backdrop, card and title-bar ops (the same as the code frame). */ + ops: DrawOp[]; + /** Where the snapshot image is drawn, in outer CSS px. */ + image: { x: number; y: number; w: number; h: number }; +} +/** Frames an image of imgW x imgH CSS px (Markdown or native snapshot) in the card. */ +export function frameSnapshot(imgW: number, imgH: number, title: string, theme: ImageTheme, measure: Measure): FramedSnapshot | { tooLarge: true } { + const ft: FrameTheme = { bg: theme.bg, fg: theme.fg, muted: theme.muted ?? theme.fg, accent: theme.accent ?? theme.fg }; + const frame = frameGeometry(imgW, imgH, title, ft, measure); + if ("tooLarge" in frame) return frame; + const fit = fitScale(frame.width, frame.height, 2); + if ("tooLarge" in fit) return fit; + const cw = frame.card.w; + return { width: frame.width, height: frame.height, scale: fit.scale, ops: frameOps(frame), image: { x: frame.inset.x + (cw - imgW) / 2, y: frame.inset.y, w: imgW, h: imgH } }; +} + +/** Shadow blur/offset in device pixels for a canvas drawn at `scale` (ctx.shadow* ignores ctx.scale). */ +export function scaledShadow(shadow: Shadow, scale: number): Shadow { + return { blur: shadow.blur * scale, offsetY: shadow.offsetY * scale, color: shadow.color }; +} + +const FALLBACK = "ui-monospace, Menlo, monospace"; + +/** Canvas font shorthand with a monospace fallback chain. */ +export function canvasFont(font: string, size: number, bold: boolean, italic: boolean): string { + const family = /[,"']/.test(font) ? font : `"${font}", ${FALLBACK}`; + return `${italic ? "italic " : ""}${bold ? "bold " : ""}${size}px ${family}`; +} + +/** Distinct canvas font strings the ops need loaded (regular first, then bold, italic, bold-italic when used). */ +export function fontLoadSpecs(ops: DrawOp[], font: string, size: number): string[] { + const used = new Set(["00"]); + for (const o of ops) if (o.t === "text") used.add(`${o.bold ? 1 : 0}${o.italic ? 1 : 0}`); + return ["00", "10", "01", "11"].filter((k) => used.has(k)).map((k) => canvasFont(font, size, k[0] === "1", k[1] === "1")); +} + +/** One entry per drawn glyph cluster at x + cell * charW (spaces occupy a cell but are not drawn). A zero-width mark (accent, variation selector, ZWJ) is drawn with its base glyph, and a ZWJ also joins the glyph after it. */ +export function glyphCells(text: string, x: number, charW: number): { ch: string; x: number }[] { + const out: { ch: string; x: number }[] = []; + let i = 0; + let open = false; // the last entry can still take a combining mark + let join = false; // the previous code point was a ZWJ + for (const ch of text) { + const w = charWidth(ch.codePointAt(0)!); + if (ch === " ") open = false; + else if (out.length && open && (w === 0 || join)) out[out.length - 1]!.ch += ch; + else { + out.push({ ch, x: x + i * charW }); + open = true; + } + join = ch === "\u200d"; + i += w; + } + return out; +} diff --git a/src/client/imagelayout.ts b/src/client/imagelayout.ts index cf322b4..1a690f8 100644 --- a/src/client/imagelayout.ts +++ b/src/client/imagelayout.ts @@ -1,6 +1,7 @@ // Pure layout of a code selection into canvas draw ops. No DOM access. import type { Span } from "./highlight.ts"; import { normalize, type SelRange } from "./selection.ts"; +import { charWidth } from "./term/width.ts"; export interface TokenStyle { bold?: boolean; @@ -9,6 +10,10 @@ export interface TokenStyle { export interface ImageTheme { bg: string; fg: string; + /** Muted foreground for the frame title; falls back to fg. */ + muted?: string; + /** Theme accent the frame backdrop gradient derives from; falls back to fg. */ + accent?: string; tokenColors: Record; tokenStyles?: Record; font: string; @@ -21,15 +26,31 @@ export interface LayoutOptions { charW?: number; lineH?: number; } +export interface Shadow { + blur: number; + offsetY: number; + color: string; +} +export interface GradStop { + at: number; + color: string; +} export type DrawOp = - | { t: "rect"; x: number; y: number; w: number; h: number; r: number; fill: string } - | { t: "text"; x: number; y: number; text: string; fill: string; bold: boolean; italic: boolean }; + | { t: "rect"; x: number; y: number; w: number; h: number; r: number; fill: string; shadow?: Shadow } + | { t: "gradient"; x: number; y: number; w: number; h: number; x0: number; y0: number; x1: number; y1: number; stops: GradStop[] } + | { t: "dot"; cx: number; cy: number; r: number; fill: string } + | { t: "text"; x: number; y: number; text: string; fill: string; bold: boolean; italic: boolean; /** Draw as one run (titles), not one glyph per grid cell. */ whole?: boolean }; export interface ImageLayout { width: number; height: number; cols: number; rows: number; ops: DrawOp[]; + /** Set on framed layouts so the renderer draws with the theme font. */ + font?: string; + size?: number; + /** Grid cell width, so the renderer can draw each glyph in its own cell. */ + charW?: number; } export type LayoutResult = ImageLayout | { tooLarge: true }; export type ScaleResult = { scale: number } | { tooLarge: true }; @@ -47,6 +68,16 @@ export function fitScale(w: number, h: number, dpr: number): ScaleResult { return { scale: Math.floor(Math.sqrt(MAX_PIXELS / area) * 1000) / 1000 }; } +/** Columns the UTF-16 unit at `i` advances (tab is handled by the caller): wcwidth of its code point; a low surrogate belongs to the pair before it. Shared column model with glyphCells. */ +function unitCols(line: string, i: number): number { + const c = line.charCodeAt(i); + if (c >= 0xdc00 && c <= 0xdfff && i > 0) { + const p = line.charCodeAt(i - 1); + if (p >= 0xd800 && p <= 0xdbff) return 0; + } + return charWidth(line.codePointAt(i)!); +} + /** Lays out the range of `lines` (with per-line token spans) as draw ops. Deterministic. */ export function layoutImage(lines: string[], tokens: Span[][], theme: ImageTheme, range: SelRange, opts: LayoutOptions = {}): LayoutResult { const { start, end } = normalize(range); @@ -68,7 +99,7 @@ export function layoutImage(lines: string[], tokens: Span[][], theme: ImageTheme const c1 = Math.min(row === rows - 1 ? end.col : line.length, line.length); const y = padding + row * lineH; let col = 0; - for (let i = 0; i < c0; i++) col += line[i] === "\t" ? tabSize - (col % tabSize) : 1; + for (let i = 0; i < c0; i++) col += line[i] === "\t" ? tabSize - (col % tabSize) : unitCols(line, i); const base = col; let run = ""; let runCol = 0; @@ -85,7 +116,7 @@ export function layoutImage(lines: string[], tokens: Span[][], theme: ImageTheme runCol = col; runTok = toks[i]; } - const w = line[i] === "\t" ? tabSize - (col % tabSize) : 1; + const w = line[i] === "\t" ? tabSize - (col % tabSize) : unitCols(line, i); run += " ".repeat(line[i] === "\t" ? w : 0) + (line[i] === "\t" ? "" : line[i]); col += w; } @@ -95,5 +126,5 @@ export function layoutImage(lines: string[], tokens: Span[][], theme: ImageTheme if (cols > MAX_COLS) return { tooLarge: true }; const width = 2 * padding + cols * charW; const height = 2 * padding + rows * lineH; - return { width, height, cols, rows, ops: [{ t: "rect", x: 0, y: 0, w: width, h: height, r: radius, fill: theme.bg }, ...text] }; + return { width, height, cols, rows, charW, ops: [{ t: "rect", x: 0, y: 0, w: width, h: height, r: radius, fill: theme.bg }, ...text] }; } diff --git a/src/client/styles.css b/src/client/styles.css index 6033127..07d5917 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -288,11 +288,13 @@ svg { .resizing-x, .resizing-x * { cursor: col-resize !important; + -webkit-user-select: none !important; user-select: none !important; } .resizing-y, .resizing-y * { cursor: row-resize !important; + -webkit-user-select: none !important; user-select: none !important; } @@ -554,6 +556,7 @@ svg { cursor: pointer; white-space: nowrap; color: var(--fg); + -webkit-user-select: none; user-select: none; } .row::before { @@ -713,6 +716,7 @@ svg { color: var(--fg-muted); border-right: 1px solid var(--border); cursor: pointer; + -webkit-user-select: none; user-select: none; transition: color 0.12s, background 0.12s; } @@ -1064,6 +1068,7 @@ svg { text-align: right; color: var(--fg-faint); background: var(--bg-editor); + -webkit-user-select: none; user-select: none; font-variant-numeric: tabular-nums; } @@ -1134,6 +1139,7 @@ svg { font-size: 0.88em; font-style: italic; white-space: nowrap; + -webkit-user-select: none; user-select: none; pointer-events: none; } @@ -1189,6 +1195,7 @@ svg { padding-right: 12px; text-align: right; color: var(--fg-faint); + -webkit-user-select: none; user-select: none; } .peek-t { @@ -1207,6 +1214,7 @@ svg { } } .trunc { + -webkit-user-select: none; user-select: none; color: var(--fg-faint); font-style: italic; @@ -1517,6 +1525,7 @@ mark.cur { border-radius: 6px; color: var(--fg-muted); cursor: pointer; + -webkit-user-select: none; user-select: none; font-size: 12px; } @@ -2083,3 +2092,10 @@ li[data-state="done"] .upd-mark { .sv-refresh { cursor: pointer; } html.snap-hide-sel ::selection { background: transparent !important; } + +.ln .g::selection, +.ln .g *::selection, +.blame::selection, +.blame *::selection, +.trunc::selection, +.trunc *::selection { background: transparent; } diff --git a/test/client.codeview.blame.test.ts b/test/client.codeview.blame.test.ts new file mode 100644 index 0000000..f406e38 --- /dev/null +++ b/test/client.codeview.blame.test.ts @@ -0,0 +1,257 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, uninstallDom, type Dom, type FakeElement } from "./helpers/fakedom.ts"; + +// Row nodes with just enough DOM (classList, querySelector, append, remove, insertAdjacentHTML) for in-place patching. +class R { + parent: R | null = null; + childNodes: any[] = []; + data = ""; + dataset: Record = {}; + html = ""; + constructor(public nodeType: 1 | 3, public cls = "") {} + get className() { + return this.cls; + } + set className(v: string) { + this.cls = v; + } + get parentElement() { + return this.parent; + } + get children() { + return this.childNodes.filter((c) => c.nodeType === 1); + } + get textContent(): string { + return this.nodeType === 3 ? this.data : this.childNodes.map((c) => c.textContent).join("") + (this.html ? this.html.replace(/<[^>]*>/g, "") : ""); + } + set textContent(v: string) { + this.html = v; + } + get innerHTML() { + return this.html; + } + set innerHTML(v: string) { + this.html = v; + } + get classList() { + const get = () => this.cls.split(" ").filter(Boolean); + return { + contains: (c: string) => get().includes(c), + add: (...cs: string[]) => void (this.cls = [...new Set([...get(), ...cs])].join(" ")), + remove: (...cs: string[]) => void (this.cls = get().filter((c) => !cs.includes(c)).join(" ")), + toggle: (c: string, force?: boolean) => { + const on = force ?? !get().includes(c); + if (on) this.cls = [...new Set([...get(), c])].join(" "); + else this.cls = get().filter((x) => x !== c).join(" "); + return on; + }, + }; + } + add(...cs: R[]) { + for (const c of cs) { + c.parent = this; + this.childNodes.push(c); + } + return this; + } + append(...cs: any[]) { + for (const c of cs) if (c && typeof c === "object") this.add(c); + } + appendChild(c: any) { + this.append(c); + return c; + } + insertAdjacentHTML(_pos: string, html: string) { + const m = /([\s\S]*)<\/span>/.exec(html); + if (!m) return; + const s = new R(1, m[1]); + s.html = m[2]!; + this.add(s); + } + remove() { + if (this.parent) this.parent.childNodes = this.parent.childNodes.filter((c) => c !== this); + this.parent = null; + } + contains(n: any): boolean { + for (let x = n; x; x = x.parent) if (x === this) return true; + return false; + } + closest(c: string): R | null { + c = c.replace(/^\./, ""); + for (let x: R | null = this; x; x = x.parent) if (x.nodeType === 1 && x.classList.contains(c)) return x; + return null; + } + querySelector(sel: string): any { + const cls = /^\.([\w-]+)/.exec(sel)?.[1]; + const n = /data-n="?(\d+)"?/.exec(sel)?.[1]; + for (const k of this.childNodes) { + if (k.nodeType !== 1) continue; + const kc = (k.className ?? "") as string; + if ((!cls || kc.split(" ").includes(cls)) && (n === undefined || k.dataset?.n === n)) return k; + const r = k.querySelector?.(sel); + if (r) return r; + } + return null; + } +} +const text = (s: string) => Object.assign(new R(3), { data: s }); +const el = (cls: string, ...cs: R[]) => new R(1, cls).add(...cs); + +let C: typeof import("../src/client/codeview.ts"); +let dom: Dom; +let raf: (() => void)[] = []; +beforeAll(async () => { + dom = installDom(); + const g = globalThis as Record; + g.ResizeObserver = class { + observe() {} + }; + g.requestAnimationFrame = (cb: () => void) => (raf.push(cb), raf.length); + g.getComputedStyle = () => ({ getPropertyValue: () => "monospace" }); + const make = dom.document.createElement.bind(dom.document); + dom.document.createElement = (tag: string) => { + const e = make(tag); + (e.style as Record).setProperty = (k: string, v: string) => (e.style[k] = v); + if (tag === "canvas") (e as unknown as Record).getContext = () => ({ measureText: () => ({ width: 80 }) }); + return e; + }; + C = await import("../src/client/codeview.ts"); +}); +afterAll(() => uninstallDom()); +const flush = () => { + const q = raf; + raf = []; + for (const f of q) f(); +}; +const setWinSel = (s: unknown) => ((dom.window as unknown as Record).getSelection = () => s); +const collapsed = { isCollapsed: true, rangeCount: 0, toString: () => "" }; +const ranged = { isCollapsed: false, rangeCount: 1, toString: () => "abc", anchorNode: null, focusNode: null }; + +function mount(src: string) { + const view = new C.CodeView(src, "text", 10) as unknown as Record; + const linesEl = view.linesEl as FakeElement & Record; + let rows: R[] = []; + let renders = 0; + Object.defineProperty(linesEl, "innerHTML", { + configurable: true, + get: () => "", + set: () => { + renders++; + rows = []; + const lines = view.lines as string[]; + for (let i = view.window.first; i < lines.length; i++) { + const row = el(`ln${i === view.activeLine ? " active" : ""}`, el("g", text(String(i + 1))), el("tx", text(lines[i]!))); + row.dataset.n = String(i); + if (view.blame && view.blame.line === i) row.add(Object.assign(el("blame"), { html: view.blame.html })); + rows.push(row); + } + }, + }); + linesEl.querySelectorAll = () => rows; + linesEl.querySelector = (sel: string) => { + const n = /data-n="?(\d+)"?/.exec(sel)?.[1]; + return rows.find((r) => n === undefined || r.dataset.n === n) ?? null; + }; + Object.defineProperty(linesEl, "children", { configurable: true, get: () => rows }); + Object.defineProperty(linesEl, "firstElementChild", { configurable: true, get: () => rows[0] ?? null }); + linesEl.contains = (n: R) => rows.some((r) => r.contains(n)); + dom.document.body.append(view.el as FakeElement); + flush(); + return { view, linesEl, rows: () => rows, renders: () => renders }; +} +const src = ["abcdefgh", "ijklmnop", "qrstuvwx"].join("\n"); +const blameOf = (r: R) => r.querySelector(".blame") as R | null; +const press = (linesEl: FakeElement, row: R, init: Record = {}) => + linesEl.dispatchEvent(new FakeEvent("mousedown", { button: 0, target: row.childNodes[0], ...init })); +const release = () => { + for (const t of [dom.document, dom.window] as { dispatchEvent: (e: FakeEvent) => unknown }[]) t.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); +}; + +describe("AC-18: active line and blame patch rows in place", () => { + test("setActiveLine keeps every row element and its .tx text node; only the active class moves", () => { + setWinSel(collapsed); + const { view, rows, renders } = mount(src); + const before = [...rows()]; + const txs = before.map((r) => r.querySelector(".tx")); + const texts = txs.map((t) => t.childNodes[0]); + const n = renders(); + view.setActiveLine(0); + flush(); + expect(before[0]!.classList.contains("active")).toBe(true); + view.setActiveLine(2); + flush(); + expect(renders()).toBe(n); + expect(rows().length).toBe(before.length); + rows().forEach((r, i) => { + expect(r).toBe(before[i]!); + expect(r.querySelector(".tx")).toBe(txs[i]); + expect(r.querySelector(".tx").childNodes[0]).toBe(texts[i]); + }); + expect(before[0]!.classList.contains("active")).toBe(false); + expect(before[1]!.classList.contains("active")).toBe(false); + expect(before[2]!.classList.contains("active")).toBe(true); + }); + + test("setBlame adds one .blame span to the active row and removes the old one, keeping row elements", () => { + setWinSel(collapsed); + const { view, rows, renders } = mount(src); + view.setActiveLine(1); + flush(); + const before = [...rows()]; + const txs = before.map((r) => r.querySelector(".tx")); + const n = renders(); + view.setBlame(1, "Ann fix"); + flush(); + expect(blameOf(before[1]!)?.innerHTML).toBe("Ann fix"); + view.setBlame(2, "Bob"); + flush(); + expect(blameOf(before[1]!)).toBeNull(); + expect(blameOf(before[2]!)?.innerHTML).toBe("Bob"); + expect(before.filter((r) => blameOf(r)).length).toBe(1); + view.setBlame(2, null); + flush(); + expect(before.filter((r) => blameOf(r)).length).toBe(0); + expect(renders()).toBe(n); + rows().forEach((r, i) => { + expect(r).toBe(before[i]!); + expect(r.querySelector(".tx")).toBe(txs[i]); + }); + }); +}); + +describe("AC-19: no blame label while the primary button is held", () => { + test("blame stays hidden during a press and appears for the active line after mouseup", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + flush(); + view.setBlame(1, "Ann"); + flush(); + expect(rows().filter((r) => blameOf(r)).length).toBe(0); + release(); + flush(); + expect(blameOf(rows()[1]!)?.innerHTML).toBe("Ann"); + }); + + test("a non-collapsed selection after mouseup suppresses the label", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + flush(); + view.setBlame(1, "Ann"); + setWinSel(ranged); + release(); + flush(); + expect(rows().filter((r) => blameOf(r)).length).toBe(0); + }); + + test("a non-primary press does not hold the label back", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!, { button: 2 }); + flush(); + view.setBlame(1, "Ann"); + flush(); + expect(blameOf(rows()[1]!)?.innerHTML).toBe("Ann"); + }); +}); diff --git a/test/client.codeview.blame2.test.ts b/test/client.codeview.blame2.test.ts new file mode 100644 index 0000000..4c9d640 --- /dev/null +++ b/test/client.codeview.blame2.test.ts @@ -0,0 +1,257 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, uninstallDom, type Dom, type FakeElement } from "./helpers/fakedom.ts"; + +// Row nodes with just enough DOM (classList, querySelector, append, remove, insertAdjacentHTML) for in-place patching. +class R { + parent: R | null = null; + childNodes: any[] = []; + data = ""; + dataset: Record = {}; + html = ""; + constructor(public nodeType: 1 | 3, public cls = "") {} + get className() { + return this.cls; + } + set className(v: string) { + this.cls = v; + } + get parentElement() { + return this.parent; + } + get children() { + return this.childNodes.filter((c) => c.nodeType === 1); + } + get textContent(): string { + return this.nodeType === 3 ? this.data : this.childNodes.map((c) => c.textContent).join("") + (this.html ? this.html.replace(/<[^>]*>/g, "") : ""); + } + set textContent(v: string) { + this.html = v; + } + get innerHTML() { + return this.html; + } + set innerHTML(v: string) { + this.html = v; + } + get classList() { + const get = () => this.cls.split(" ").filter(Boolean); + return { + contains: (c: string) => get().includes(c), + add: (...cs: string[]) => void (this.cls = [...new Set([...get(), ...cs])].join(" ")), + remove: (...cs: string[]) => void (this.cls = get().filter((c) => !cs.includes(c)).join(" ")), + toggle: (c: string, force?: boolean) => { + const on = force ?? !get().includes(c); + if (on) this.cls = [...new Set([...get(), c])].join(" "); + else this.cls = get().filter((x) => x !== c).join(" "); + return on; + }, + }; + } + add(...cs: R[]) { + for (const c of cs) { + c.parent = this; + this.childNodes.push(c); + } + return this; + } + append(...cs: any[]) { + for (const c of cs) if (c && typeof c === "object") this.add(c); + } + appendChild(c: any) { + this.append(c); + return c; + } + insertAdjacentHTML(_pos: string, html: string) { + const m = /([\s\S]*)<\/span>/.exec(html); + if (!m) return; + const s = new R(1, m[1]); + s.html = m[2]!; + this.add(s); + } + remove() { + if (this.parent) this.parent.childNodes = this.parent.childNodes.filter((c) => c !== this); + this.parent = null; + } + contains(n: any): boolean { + for (let x = n; x; x = x.parent) if (x === this) return true; + return false; + } + closest(c: string): R | null { + c = c.replace(/^\./, ""); + for (let x: R | null = this; x; x = x.parent) if (x.nodeType === 1 && x.classList.contains(c)) return x; + return null; + } + querySelector(sel: string): any { + const cls = /^\.([\w-]+)/.exec(sel)?.[1]; + const n = /data-n="?(\d+)"?/.exec(sel)?.[1]; + for (const k of this.childNodes) { + if (k.nodeType !== 1) continue; + const kc = (k.className ?? "") as string; + if ((!cls || kc.split(" ").includes(cls)) && (n === undefined || k.dataset?.n === n)) return k; + const r = k.querySelector?.(sel); + if (r) return r; + } + return null; + } +} +const text = (s: string) => Object.assign(new R(3), { data: s }); +const el = (cls: string, ...cs: R[]) => new R(1, cls).add(...cs); + +let C: typeof import("../src/client/codeview.ts"); +let dom: Dom; +let raf: (() => void)[] = []; +beforeAll(async () => { + dom = installDom(); + const g = globalThis as Record; + g.ResizeObserver = class { + observe() {} + }; + g.requestAnimationFrame = (cb: () => void) => (raf.push(cb), raf.length); + g.getComputedStyle = () => ({ getPropertyValue: () => "monospace" }); + const make = dom.document.createElement.bind(dom.document); + dom.document.createElement = (tag: string) => { + const e = make(tag); + (e.style as Record).setProperty = (k: string, v: string) => (e.style[k] = v); + if (tag === "canvas") (e as unknown as Record).getContext = () => ({ measureText: () => ({ width: 80 }) }); + return e; + }; + C = await import("../src/client/codeview.ts"); +}); +afterAll(() => uninstallDom()); +const flush = () => { + const q = raf; + raf = []; + for (const f of q) f(); +}; +const setWinSel = (s: unknown) => ((dom.window as unknown as Record).getSelection = () => s); +const collapsed = { isCollapsed: true, rangeCount: 0, toString: () => "" }; +const ranged = { isCollapsed: false, rangeCount: 1, toString: () => "abc", anchorNode: null, focusNode: null }; + +function mount(src: string) { + const view = new C.CodeView(src, "text", 10) as unknown as Record; + const linesEl = view.linesEl as FakeElement & Record; + let rows: R[] = []; + let renders = 0; + Object.defineProperty(linesEl, "innerHTML", { + configurable: true, + get: () => "", + set: () => { + renders++; + rows = []; + const lines = view.lines as string[]; + for (let i = view.window.first; i < lines.length; i++) { + const row = el(`ln${i === view.activeLine ? " active" : ""}`, el("g", text(String(i + 1))), el("tx", text(lines[i]!))); + row.dataset.n = String(i); + if (view.blame && view.blame.line === i) row.add(Object.assign(el("blame"), { html: view.blame.html })); + rows.push(row); + } + }, + }); + linesEl.querySelectorAll = () => rows; + linesEl.querySelector = (sel: string) => { + const n = /data-n="?(\d+)"?/.exec(sel)?.[1]; + return rows.find((r) => n === undefined || r.dataset.n === n) ?? null; + }; + Object.defineProperty(linesEl, "children", { configurable: true, get: () => rows }); + Object.defineProperty(linesEl, "firstElementChild", { configurable: true, get: () => rows[0] ?? null }); + linesEl.contains = (n: R) => rows.some((r) => r.contains(n)); + dom.document.body.append(view.el as FakeElement); + flush(); + return { view, linesEl, rows: () => rows, renders: () => renders }; +} +const src = ["abcdefgh", "ijklmnop", "qrstuvwx"].join("\n"); +const blameOf = (r: R) => r.querySelector(".blame") as R | null; +const press = (linesEl: FakeElement, row: R, init: Record = {}) => + linesEl.dispatchEvent(new FakeEvent("mousedown", { button: 0, target: row.childNodes[0], ...init })); +const release = () => { + for (const t of [dom.document, dom.window] as { dispatchEvent: (e: FakeEvent) => unknown }[]) t.dispatchEvent(new FakeEvent("mouseup", { button: 0 })); +}; + +const selectionChange = () => dom.document.dispatchEvent(new FakeEvent("selectionchange")); +const shown = (rows: () => R[]) => rows().filter((r) => blameOf(r)).length; + +describe("R-01: a selection hides the blame only while it exists", () => { + test("the label returns when the selection collapses after the drag", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + view.setBlame(1, "Ann"); + setWinSel(ranged); + release(); + flush(); + expect(shown(rows)).toBe(0); + setWinSel(collapsed); + selectionChange(); + flush(); + expect(blameOf(rows()[1]!)?.innerHTML).toBe("Ann"); + }); + + test("a keyboard active-line change after the selection was cleared shows its blame", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + setWinSel(ranged); + release(); + setWinSel(collapsed); + selectionChange(); + view.setActiveLine(2); + view.setBlame(2, "Kay"); + flush(); + expect(blameOf(rows()[2]!)?.innerHTML).toBe("Kay"); + }); + + test("a selection made while the label is shown hides it", () => { + setWinSel(collapsed); + const { view, rows } = mount(src); + view.setActiveLine(1); + view.setBlame(1, "Ann"); + flush(); + expect(shown(rows)).toBe(1); + setWinSel(ranged); + selectionChange(); + flush(); + expect(shown(rows)).toBe(0); + }); +}); + +describe("R-02: the held-button state never sticks", () => { + test("window blur clears it", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + dom.window.dispatchEvent(new FakeEvent("blur")); + view.setBlame(1, "Ann"); + flush(); + expect(blameOf(rows()[1]!)?.innerHTML).toBe("Ann"); + }); + + test("a mousemove with no buttons down clears it", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + dom.document.dispatchEvent(new FakeEvent("mousemove", { buttons: 0 })); + view.setBlame(1, "Ann"); + flush(); + expect(blameOf(rows()[1]!)?.innerHTML).toBe("Ann"); + }); + + test("a mousemove with the primary button still down keeps it held", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + dom.document.dispatchEvent(new FakeEvent("mousemove", { buttons: 1 })); + view.setBlame(1, "Ann"); + flush(); + expect(shown(rows)).toBe(0); + }); + + test("the next mousedown (even a right-click) clears a stale hold", () => { + setWinSel(collapsed); + const { view, linesEl, rows } = mount(src); + press(linesEl, rows()[1]!); + press(linesEl, rows()[2]!, { button: 2 }); + view.setBlame(2, "Bob"); + flush(); + expect(blameOf(rows()[2]!)?.innerHTML).toBe("Bob"); + }); +}); diff --git a/test/client.codeview.copytext.test.ts b/test/client.codeview.copytext.test.ts new file mode 100644 index 0000000..6e5f835 --- /dev/null +++ b/test/client.codeview.copytext.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeElement, FakeEvent, installDom, uninstallDom, type Dom } from "./helpers/fakedom.ts"; + +let C: typeof import("../src/client/codeview.ts"); +let dom: Dom; +let rafQueue: (() => void)[] = []; + +beforeAll(async () => { + dom = installDom(); + const g = globalThis as Record; + g.ResizeObserver = class { + observe() {} + disconnect() {} + }; + g.requestAnimationFrame = (cb: () => void) => { + rafQueue.push(cb); + return rafQueue.length; + }; + g.getComputedStyle = () => ({ getPropertyValue: () => "monospace" }); + const make = dom.document.createElement.bind(dom.document); + dom.document.createElement = (tag: string) => { + const el = make(tag); + (el.style as Record).setProperty = (k: string, v: string) => (el.style[k] = v); + if (tag === "canvas") (el as unknown as Record).getContext = () => ({ font: "", measureText: () => ({ width: 80 }) }); + return el; + }; + try { + C = await import("../src/client/codeview.ts"); + } catch {} +}); +afterAll(() => uninstallDom()); + +describe("AC-15 text copy never includes gutter line numbers", () => { + test("the copy event over multiple plain lines writes exactly the model text", () => { + expect(typeof C?.CodeView).toBe("function"); + const text = "alpha\nbeta\ngamma\ndelta"; + const view = new C.CodeView(text, "text", 10) as unknown as Record; + dom.document.body.append(view.el as FakeElement); + const q = rafQueue; + rafQueue = []; + for (const f of q) f(); + view.setRange({ start: { line: 0, col: 0 }, end: { line: 2, col: 5 } }); + const set: [string, string][] = []; + const e = new FakeEvent("copy", { clipboardData: { setData: (t: string, d: string) => set.push([t, d]) } }); + (view.scroller as FakeElement).dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(set).toEqual([["text/plain", "alpha\nbeta\ngamma"]]); + expect(view.selectedText()).toBe("alpha\nbeta\ngamma"); + }); +}); diff --git a/test/client.copyimage.entry2.test.ts b/test/client.copyimage.entry2.test.ts index 81360c0..af5b424 100644 --- a/test/client.copyimage.entry2.test.ts +++ b/test/client.copyimage.entry2.test.ts @@ -191,8 +191,6 @@ describe("R-06 copyPng does not leave the png promise unhandled", () => { const deps: CopyImageDeps = { renderPng: async () => new Blob(), clipboard: { ClipboardItem: Item, write: () => new Promise((_, rej) => setTimeout(() => rej(new Error("w")), 30)) }, - download: () => {}, - copyText: () => {}, notify: () => {}, }; const res = await copyPng(deps, Promise.reject(new Error("render"))); diff --git a/test/client.copyimage.gesture.test.ts b/test/client.copyimage.gesture.test.ts index 4ade2a6..b6ab665 100644 --- a/test/client.copyimage.gesture.test.ts +++ b/test/client.copyimage.gesture.test.ts @@ -20,8 +20,7 @@ function mk(log: string[], over: Partial = {}): CopyImageDeps { await (items[0] as Item).data["image/png"]; }, }, - download: () => void log.push("download"), - copyText: (t) => void log.push(`text:${t}`), + ...({ download: () => void log.push("download") } as object), // must never be called notify: (m) => void log.push(`notify:${m}`), ...over, }; @@ -52,7 +51,7 @@ describe("clipboard.write is called inside the gesture (R-01)", () => { expect(await copyPng(mk(log), Promise.reject(new Error("render")))).toBe("failed"); expect(log).toEqual(["write"]); }); - test("write rejecting while the png is fine falls back to download", async () => { + test("write rejecting while the png is fine fails without download", async () => { const log: string[] = []; const deps = mk(log, { clipboard: { @@ -63,10 +62,10 @@ describe("clipboard.write is called inside the gesture (R-01)", () => { }, }, }); - expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); - expect(log).toEqual(["write", "download"]); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("failed"); + expect(log).toEqual(["write"]); }); - test("render failure copies plain text, reports the failed message, never downloads", async () => { + test("render failure reports the clipboard-failed message, copies no text, never downloads", async () => { const log: string[] = []; const deps = mk(log, { renderPng: async () => { @@ -74,8 +73,7 @@ describe("clipboard.write is called inside the gesture (R-01)", () => { }, }); const msg = await copyCodeImage(deps, { lines: ["ab", "cd"], tokens: [[], []], theme, range, dpr: 1 }); - expect(msg).toBe("Could not create image; copied text instead"); - expect(log).toContain("text:ab\ncd"); + expect(msg).toBe("Could not copy image to the clipboard"); expect(log).not.toContain("download"); }); test("too large is decided before write is called", async () => { diff --git a/test/client.copyimage.test.ts b/test/client.copyimage.test.ts index fb4c574..03b69a0 100644 --- a/test/client.copyimage.test.ts +++ b/test/client.copyimage.test.ts @@ -1,5 +1,7 @@ +import { BACKDROP_PAD, MIN_CONTENT_W, frameGeometry } from "../src/client/imageframe.ts"; import { describe, expect, test } from "bun:test"; -import { copyCodeImage, copyPng, MSG_DOWNLOADED, MSG_FAILED, MSG_NO_SELECTION, MSG_OK, MSG_TOO_LARGE, type CopyImageDeps, type CopyImageInput } from "../src/client/copyimage.ts"; +import * as copyMod from "../src/client/copyimage.ts"; +import { copyCodeImage, copyPng, MSG_CLIPBOARD_FAILED, MSG_NO_SELECTION, MSG_OK, MSG_TOO_LARGE, type CopyImageDeps, type CopyImageInput } from "../src/client/copyimage.ts"; import type { ImageTheme } from "../src/client/imagelayout.ts"; const theme: ImageTheme = { bg: "#000", fg: "#fff", tokenColors: {}, font: "monospace", size: 10 }; @@ -23,8 +25,7 @@ function mk(over: Partial = {}, log: string[] = []) { log.push(`write:${items.length}`); }, }, - download: (_b, name) => void log.push(`download:${name}`), - copyText: (t) => void log.push(`text:${t}`), + ...({ download: (_b: unknown, name: string) => void log.push(`download:${name}`) } as object), // must never be called notify: (m) => void log.push(`notify:${m}`), ...over, }; @@ -36,8 +37,9 @@ describe("messages (AC-7b)", () => { test("exact strings", () => { expect(MSG_OK).toBe("Copied as image"); expect(MSG_TOO_LARGE).toBe("Selection too large for an image"); - expect(MSG_DOWNLOADED).toBe("Clipboard unavailable: image downloaded"); - expect(MSG_FAILED).toBe("Could not create image; copied text instead"); + expect(MSG_CLIPBOARD_FAILED).toBe("Could not copy image to the clipboard"); + expect((copyMod as Record).MSG_DOWNLOADED).toBeUndefined(); + expect((copyMod as Record).MSG_FAILED).toBeUndefined(); expect(MSG_NO_SELECTION).toBe("Nothing selected"); }); }); @@ -53,18 +55,19 @@ describe("copyPng (AC-8)", () => { expect(await copyPng(deps, Promise.resolve(blob))).toBe("clipboard"); expect(log).toEqual(["item", "write:1"]); }); - test("missing ClipboardItem downloads", async () => { + test("missing ClipboardItem fails without downloading", async () => { const { deps, log } = mk({ clipboard: { write: async () => {} } }); - expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); - expect(log.filter((l) => l.startsWith("download"))).toHaveLength(1); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("failed"); + expect(log.filter((l) => l.startsWith("download"))).toHaveLength(0); }); - test("missing clipboard or write downloads", async () => { + test("missing clipboard or write fails without downloading", async () => { const a = mk({ clipboard: undefined }); - expect(await copyPng(a.deps, Promise.resolve(blob))).toBe("downloaded"); + expect(await copyPng(a.deps, Promise.resolve(blob))).toBe("failed"); const b = mk({ clipboard: { ClipboardItem: class {} as never } }); - expect(await copyPng(b.deps, Promise.resolve(blob))).toBe("downloaded"); + expect(await copyPng(b.deps, Promise.resolve(blob))).toBe("failed"); + expect([...a.log, ...b.log].filter((l) => l.startsWith("download"))).toEqual([]); }); - test("write rejection falls back to download", async () => { + test("write rejection fails without downloading", async () => { const { deps, log } = mk({ clipboard: { ClipboardItem: class {} as never, @@ -73,9 +76,8 @@ describe("copyPng (AC-8)", () => { }, }, }); - expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); - expect(log).toHaveLength(1); - expect(log[0]).toStartWith("download:"); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("failed"); + expect(log.some((l) => l.startsWith("download"))).toBe(false); }); test("native handler is preferred over the clipboard", async () => { const got: Blob[] = []; @@ -84,14 +86,14 @@ describe("copyPng (AC-8)", () => { expect(got).toEqual([blob]); expect(log.some((l) => l.startsWith("write"))).toBe(false); }); - test("native failure falls back to download", async () => { + test("native failure fails without downloading", async () => { const { deps, log } = mk({ native: async () => { throw new Error("x"); }, }); - expect(await copyPng(deps, Promise.resolve(blob))).toBe("downloaded"); - expect(log.filter((l) => l.startsWith("download"))).toHaveLength(1); + expect(await copyPng(deps, Promise.resolve(blob))).toBe("failed"); + expect(log.filter((l) => l.startsWith("download"))).toHaveLength(0); }); test("a rejected render promise reports failed without download", async () => { const { deps, log } = mk(); @@ -128,7 +130,10 @@ describe("copyCodeImage (AC-4, AC-7)", () => { }, }); await copyCodeImage(deps, input({ opts: { charW: 6, padding: 10, lineH: 15 } })); - expect(w).toBe(2 * 10 + 2 * 6); + const f = frameGeometry(2 * 10 + 2 * 6, 2 * 10 + 15, "", { bg: "", fg: "", muted: "", accent: "" }, () => 0); + if ("tooLarge" in f) throw new Error("unexpected tooLarge"); + expect(w).toBe(f.width); + expect(w).toBe(MIN_CONTENT_W + 2 * BACKDROP_PAD); }); test("too many rows reports too large and never touches the clipboard", async () => { const lines = Array.from({ length: 20001 }, () => "x"); @@ -144,18 +149,18 @@ describe("copyCodeImage (AC-4, AC-7)", () => { expect(msg).toBe("Selection too large for an image"); expect(log.some((l) => l === "item" || l.startsWith("write") || l === "render")).toBe(false); }); - test("clipboard unavailable reports the download message", async () => { - const { deps } = mk({ clipboard: undefined }); - expect(await copyCodeImage(deps, input())).toBe("Clipboard unavailable: image downloaded"); + test("clipboard unavailable reports the clipboard-failed message, no text, no download", async () => { + const { deps, log } = mk({ clipboard: undefined }); + expect(await copyCodeImage(deps, input())).toBe("Could not copy image to the clipboard"); + expect(log).toEqual(["render", "notify:Could not copy image to the clipboard"]); }); - test("render failure copies plain text and reports MSG_FAILED", async () => { + test("render failure reports MSG_CLIPBOARD_FAILED and copies no text", async () => { const { deps, log } = mk({ renderPng: async () => { throw new Error("canvas"); }, }); - expect(await copyCodeImage(deps, input({ range: range(0, 1, 1, 1) }))).toBe("Could not create image; copied text instead"); - expect(log).toContain("text:b\nc"); - expect(log).toContain("notify:Could not create image; copied text instead"); + expect(await copyCodeImage(deps, input({ range: range(0, 1, 1, 1) }))).toBe("Could not copy image to the clipboard"); + expect(log).toContain("notify:Could not copy image to the clipboard"); }); }); diff --git a/test/client.docs.test.ts b/test/client.docs.test.ts new file mode 100644 index 0000000..5d39f2b --- /dev/null +++ b/test/client.docs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +function section(file: string, heading: RegExp): string { + const t = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); + const m = heading.exec(t); + if (!m) throw new Error(`${file}: heading not found`); + const rest = t.slice(m.index + m[0].length); + const next = rest.search(/^## /m); + return next < 0 ? rest : rest.slice(0, next); +} + +const docs: [string, RegExp][] = [ + ["README.md", /^## Copy as Image$/m], + ["DESIGN.md", /^## 15\. Copy as Image$/m], +]; + +describe("AC-13 Copy as Image docs", () => { + for (const [file, heading] of docs) { + describe(file, () => { + const s = () => section(file, heading); + test("describes clipboard-only, never a download", () => { + const t = s().toLowerCase(); + expect(t).toContain("clipboard"); + expect(t).not.toContain("download"); + expect(t).not.toContain("plain text is copied"); + expect(t).not.toContain("copied text instead"); + }); + test("describes the snippet frame", () => { + const t = s().toLowerCase(); + for (const w of ["backdrop", "window dots", "file name", "shadow", "2x"]) expect(t).toContain(w); + }); + test("covers Markdown, the macOS native snapshot and line numbers", () => { + const t = s().toLowerCase(); + expect(t).toContain("markdown"); + expect(t).toContain("macos"); + expect(t).toContain("line numbers"); + expect(t).toContain("never"); + }); + }); + } +}); diff --git a/test/client.imageframe.copy.test.ts b/test/client.imageframe.copy.test.ts new file mode 100644 index 0000000..8145a70 --- /dev/null +++ b/test/client.imageframe.copy.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { copyCodeImage, MSG_OK, type CopyImageDeps } from "../src/client/copyimage.ts"; +import { BACKDROP_PAD, MIN_CONTENT_W, TITLE_BAR_H } from "../src/client/imageframe.ts"; +import type { DrawOp, ImageLayout, ImageTheme } from "../src/client/imagelayout.ts"; + +const theme: ImageTheme = { bg: "#101010", fg: "#eeeeee", muted: "#888888", accent: "#4f8cff", tokenColors: { k: "#ff0000" }, font: "Menlo, monospace", size: 10 }; +const opts = { charW: 6, lineH: 15, padding: 10, radius: 8, tabSize: 4 }; + +class Item { + constructor(public data: Record>) {} +} + +function run(title: string | undefined, lines: string[], tokens = lines.map(() => [] as [number, number, string][]), dpr = 2) { + const seen: { layout?: ImageLayout; scale?: number } = {}; + const deps: CopyImageDeps = { + renderPng: async (layout, scale) => { + seen.layout = layout; + seen.scale = scale; + return new Blob(["x"]); + }, + clipboard: { ClipboardItem: Item, write: async () => {} }, + notify: () => {}, + }; + const last = lines.length - 1; + const p = copyCodeImage(deps, { lines, tokens, theme, range: { start: { line: 0, col: 0 }, end: { line: last, col: lines[last]!.length } }, dpr, opts, title }); + return { p, seen }; +} +const texts = (l: ImageLayout) => l.ops.filter((o): o is Extract => o.t === "text"); + +describe("copyCodeImage renders the framed snippet (AC-6, AC-10, AC-16)", () => { + test("renderPng gets the framed layout at the outer size and scale 2", async () => { + const { p, seen } = run("src/app.ts", ["const a = 1;"]); + expect(await p).toBe(MSG_OK); + const l = seen.layout!; + expect(l.width).toBe(MIN_CONTENT_W + 2 * BACKDROP_PAD); + expect(l.height).toBe(2 * 10 + 15 + TITLE_BAR_H + 2 * BACKDROP_PAD); + expect(seen.scale).toBe(2); + expect(l.font).toBe("Menlo, monospace"); + expect(l.size).toBe(10); + }); + test("the file basename reaches the title and the dots are drawn", async () => { + const { p, seen } = run("src/client/app.ts", ["a"]); + await p; + expect(texts(seen.layout!).some((o) => o.text === "app.ts")).toBe(true); + expect(seen.layout!.ops.filter((o) => o.t === "dot")).toHaveLength(3); + }); + test("without a title there is no title text", async () => { + const { p, seen } = run(undefined, ["a"]); + await p; + expect(texts(seen.layout!).map((o) => o.text)).toEqual(["a"]); + }); + test("tabs expand to tab stops and offsets include the card inset; no line numbers", async () => { + const { p, seen } = run("a.ts", ["\treturn 1"], [[[1, 7, "k"]]]); + await p; + const ret = texts(seen.layout!).find((o) => o.text === "return")!; + expect(ret.fill).toBe("#ff0000"); + expect(ret.x).toBe(BACKDROP_PAD + 10 + 4 * 6); + expect(ret.y).toBe(BACKDROP_PAD + TITLE_BAR_H + 10); + expect(texts(seen.layout!).some((o) => /^\d+$/.test(o.text))).toBe(false); + }); + test("default text uses theme fg and the card uses theme bg", async () => { + const { p, seen } = run("a.ts", ["plain"]); + await p; + expect(texts(seen.layout!).find((o) => o.text === "plain")!.fill).toBe("#eeeeee"); + expect(seen.layout!.ops.find((o) => o.t === "rect")).toMatchObject({ fill: "#101010" }); + }); + test("a measure dep is used to ellipsise the title", async () => { + const seen: { layout?: ImageLayout } = {}; + const deps: CopyImageDeps = { + renderPng: async (l) => ((seen.layout = l), new Blob(["x"])), + clipboard: { ClipboardItem: Item, write: async () => {} }, + measure: (s: string) => s.length * 50, + notify: () => {}, + }; + await copyCodeImage(deps, { lines: ["a"], tokens: [[]], theme, range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }, dpr: 2, opts, title: "long-name.ts" }); + const t = texts(seen.layout!).find((o) => o.text.endsWith("…")); + expect(t).toBeDefined(); + }); +}); diff --git a/test/client.imageframe.fix.test.ts b/test/client.imageframe.fix.test.ts new file mode 100644 index 0000000..8015f70 --- /dev/null +++ b/test/client.imageframe.fix.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { canvasFont, fontLoadSpecs, frameImage, glyphCells, scaledShadow } from "../src/client/imageframe.ts"; +import type { DrawOp, ImageLayout, ImageTheme } from "../src/client/imagelayout.ts"; + +const theme: ImageTheme = { bg: "#111", fg: "#eee", muted: "#888", accent: "#4f8cff", tokenColors: {}, font: "JetBrains Mono", size: 13 }; +const lay = (w: number, h: number): ImageLayout => ({ width: w, height: h, cols: 1, rows: 1, ops: [{ t: "rect", x: 0, y: 0, w, h, r: 8, fill: "#111" }] }); +const txt = (text: string, bold = false, italic = false): DrawOp => ({ t: "text", x: 0, y: 0, text, fill: "#fff", bold, italic }); + +describe("always 2x within the pixel cap (R-01)", () => { + test("dpr 1 and 1.5 still render at 2", () => { + for (const dpr of [1, 1.5, 2, 3]) { + const r = frameImage(lay(500, 200), "a.ts", theme, (s) => s.length * 7, dpr); + if ("tooLarge" in r) throw new Error("unexpected"); + expect(r.scale).toBe(2); + } + }); + test("a large frame is still reduced to fit, never below 1", () => { + const r = frameImage(lay(3000, 3000), "a.ts", theme, (s) => s.length, 1); + if ("tooLarge" in r) throw new Error("unexpected"); + expect(r.scale).toBeLessThan(2); + expect(r.scale).toBeGreaterThanOrEqual(1); + }); +}); + +describe("scaledShadow (R-02)", () => { + test("blur and offset scale with the canvas scale; colour is kept", () => { + const s = { blur: 40, offsetY: 18, color: "rgba(0,0,0,0.45)" }; + expect(scaledShadow(s, 2)).toEqual({ blur: 80, offsetY: 36, color: "rgba(0,0,0,0.45)" }); + expect(scaledShadow(s, 1.5)).toEqual({ blur: 60, offsetY: 27, color: "rgba(0,0,0,0.45)" }); + }); +}); + +describe("canvasFont (V-1)", () => { + const tail = ', ui-monospace, Menlo, monospace'; + test("regular, bold, italic and bold-italic strings with a monospace fallback", () => { + expect(canvasFont("JetBrains Mono", 13, false, false)).toBe(`13px "JetBrains Mono"${tail}`); + expect(canvasFont("JetBrains Mono", 13, true, false)).toBe(`bold 13px "JetBrains Mono"${tail}`); + expect(canvasFont("JetBrains Mono", 13, false, true)).toBe(`italic 13px "JetBrains Mono"${tail}`); + expect(canvasFont("JetBrains Mono", 13, true, true)).toBe(`italic bold 13px "JetBrains Mono"${tail}`); + }); + test("an already-listed family chain is kept without duplicate fallbacks", () => { + expect(canvasFont("Menlo, monospace", 10, false, false)).toBe("10px Menlo, monospace"); + }); +}); + +describe("fontLoadSpecs (V-1)", () => { + test("loads every variant used, regular always first, each once", () => { + const ops = [txt("a"), txt("b", false, true), txt("c", true, true), txt("d", false, true), txt("e", true)]; + expect(fontLoadSpecs(ops, "JetBrains Mono", 13)).toEqual([ + canvasFont("JetBrains Mono", 13, false, false), + canvasFont("JetBrains Mono", 13, true, false), + canvasFont("JetBrains Mono", 13, false, true), + canvasFont("JetBrains Mono", 13, true, true), + ]); + }); + test("only the regular face when nothing is bold or italic; non-text ops ignored", () => { + expect(fontLoadSpecs([{ t: "dot", cx: 0, cy: 0, r: 1, fill: "#f00" }, txt("a")], "JetBrains Mono", 13)).toEqual([canvasFont("JetBrains Mono", 13, false, false)]); + }); +}); + +describe("glyphCells (V-2)", () => { + test("every non-space character gets its own grid cell", () => { + expect(glyphCells("a===b", 100, 8)).toEqual([ + { ch: "a", x: 100 }, + { ch: "=", x: 108 }, + { ch: "=", x: 116 }, + { ch: "=", x: 124 }, + { ch: "b", x: 132 }, + ]); + }); + test("spaces advance a cell but are not emitted", () => { + expect(glyphCells("<= x", 0, 10)).toEqual([ + { ch: "<", x: 0 }, + { ch: "=", x: 10 }, + { ch: "x", x: 30 }, + ]); + }); + test("a wide astral character takes two cells (shared column model)", () => { + expect(glyphCells("a😀b", 0, 5)).toEqual([ + { ch: "a", x: 0 }, + { ch: "😀", x: 5 }, + { ch: "b", x: 15 }, + ]); + }); + test("empty text yields nothing", () => { + expect(glyphCells("", 5, 8)).toEqual([]); + }); +}); diff --git a/test/client.imageframe.snap.test.ts b/test/client.imageframe.snap.test.ts new file mode 100644 index 0000000..913e156 --- /dev/null +++ b/test/client.imageframe.snap.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { BACKDROP_PAD, MIN_CONTENT_W, TITLE_BAR_H, frameGeometry, frameOps, frameSnapshot, glyphCells } from "../src/client/imageframe.ts"; +import { layoutImage, type DrawOp, type ImageTheme } from "../src/client/imagelayout.ts"; + +const theme: ImageTheme = { bg: "#101010", fg: "#eeeeee", muted: "#8a8a8a", accent: "#4f8cff", tokenColors: { k: "#f00", s: "#0f0" }, font: "JetBrains Mono", size: 10 }; +const measure = (s: string) => s.length * 7; +const ft = { bg: theme.bg, fg: theme.fg, muted: theme.muted!, accent: theme.accent! }; + +function snap(w: number, h: number, title = "/x/doc.md", dpr = 1) { + const r = frameSnapshot(w, h, title, theme, measure, dpr); + expect("tooLarge" in r).toBe(false); + return r as Exclude; +} + +describe("frameSnapshot (AC-11)", () => { + test("wraps the PNG with the same geometry and ops as the code frame", () => { + const r = snap(500, 200); + const g = frameGeometry(500, 200, "/x/doc.md", ft, measure); + if ("tooLarge" in g) throw new Error("tooLarge"); + expect(r.width).toBe(500 + 2 * BACKDROP_PAD); + expect(r.height).toBe(200 + TITLE_BAR_H + 2 * BACKDROP_PAD); + expect(r.ops).toEqual(frameOps(g)); + expect(r.image).toEqual({ x: BACKDROP_PAD, y: BACKDROP_PAD + TITLE_BAR_H, w: 500, h: 200 }); + expect((r.ops as DrawOp[]).some((o) => o.t === "text" && o.text === "doc.md")).toBe(true); + }); + test("an image narrower than the minimum card is centred in the clamped card", () => { + const r = snap(200, 100); + expect(r.width).toBe(MIN_CONTENT_W + 2 * BACKDROP_PAD); + expect(r.image.x).toBe(BACKDROP_PAD + (MIN_CONTENT_W - 200) / 2); + expect(r.image.w).toBe(200); + }); + test("always 2x unless the pixel cap forces a reduction; tooLarge past the cap", () => { + expect(snap(500, 200, "a.md", 1).scale).toBe(2); + const big = snap(3000, 3000); + expect(big.scale).toBeLessThan(2); + expect(big.scale).toBeGreaterThanOrEqual(1); + expect("tooLarge" in frameSnapshot(9000, 9000, "a.md", theme, measure, 2)).toBe(true); + }); +}); + +describe("one column model for layout and drawing (R-05)", () => { + test("glyphCells: a wide astral character advances two cells", () => { + expect(glyphCells("😀ab", 0, 10)).toEqual([ + { ch: "😀", x: 0 }, + { ch: "a", x: 20 }, + { ch: "b", x: 30 }, + ]); + }); + test("glyphCells: wide CJK advances two cells, a combining mark joins its base glyph", () => { + expect(glyphCells("中b", 0, 10)).toEqual([ + { ch: "中", x: 0 }, + { ch: "b", x: 20 }, + ]); + expect(glyphCells("éx", 0, 10)).toEqual([ + { ch: "e\u0301", x: 0 }, + { ch: "x", x: 10 }, + ]); + }); + const tops = (line: string, tokens: [number, number, string][]) => { + const l = layoutImage([line], [tokens], theme, { start: { line: 0, col: 0 }, end: { line: 0, col: line.length } }, { charW: 10, padding: 0 }); + if ("tooLarge" in l) throw new Error("tooLarge"); + return { l, texts: l.ops.filter((o): o is Extract => o.t === "text") }; + }; + test("layoutImage: a narrow astral character (2 UTF-16 units) is one column", () => { + const { l, texts } = tops("\u{1d4b3}b", [[0, 2, "k"], [2, 3, "s"]]); + expect(texts.map((t) => [t.text, t.x])).toEqual([["\u{1d4b3}", 0], ["b", 10]]); + expect(l.cols).toBe(2); + }); + test("layoutImage: an emoji and CJK take two columns, so the next run lines up with glyphCells", () => { + const e = tops("😀ab", [[0, 2, "k"], [2, 4, "s"]]); + expect(e.texts.map((t) => [t.text, t.x])).toEqual([["😀", 0], ["ab", 20]]); + expect(e.l.cols).toBe(4); + const run = e.texts[1]!; + expect(glyphCells("😀ab", 0, 10).filter((g) => g.ch === "a")[0]!.x).toBe(run.x); + const c = tops("中b", [[0, 1, "k"], [1, 2, "s"]]); + expect(c.texts.map((t) => [t.text, t.x])).toEqual([["中", 0], ["b", 20]]); + expect(c.l.cols).toBe(3); + }); + test("layoutImage: a selection starting after an emoji offsets by its columns", () => { + const line = "😀ab"; + const l = layoutImage([line], [[[2, 4, "s"]]], theme, { start: { line: 0, col: 2 }, end: { line: 0, col: 4 } }, { charW: 10, padding: 0 }); + if ("tooLarge" in l) throw new Error("tooLarge"); + expect(l.cols).toBe(2); + }); +}); diff --git a/test/client.imageframe.snap2.test.ts b/test/client.imageframe.snap2.test.ts new file mode 100644 index 0000000..1e309c4 --- /dev/null +++ b/test/client.imageframe.snap2.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { + FrameTooLargeError, + MSG_CLIPBOARD_FAILED, + MSG_OK, + MSG_TOO_LARGE, + copyPng, + copyPngOrSnapshot, + copyViaSnapshot, + messageFor, + type CopyImageDeps, + type SnapshotDeps, +} from "../src/client/copyimage.ts"; +import { frameGeometry, frameOps, frameSnapshot, glyphCells } from "../src/client/imageframe.ts"; +import type { ImageTheme } from "../src/client/imagelayout.ts"; + +const theme: ImageTheme = { bg: "#101010", fg: "#eeeeee", muted: "#8a8a8a", accent: "#4f8cff", tokenColors: {}, font: "JetBrains Mono", size: 13 }; +const measure = (s: string) => s.length * 7; + +describe("R-01 combining marks share their base cell", () => { + test("a combining accent is drawn with its base character in one op", () => { + expect(glyphCells("éx", 0, 10)).toEqual([ + { ch: "é", x: 0 }, + { ch: "x", x: 10 }, + ]); + }); + test("a variation selector and a ZWJ sequence stay with their base glyph", () => { + expect(glyphCells("❤️a", 0, 10)).toEqual([ + { ch: "❤️", x: 0 }, + { ch: "a", x: 10 }, + ]); + expect(glyphCells("\u{1f468}‍\u{1f469}b", 0, 10)).toEqual([ + { ch: "\u{1f468}‍\u{1f469}", x: 0 }, + { ch: "b", x: 40 }, + ]); + }); + test("a leading zero-width mark with no base still gets its own op at the start", () => { + expect(glyphCells("́a", 5, 10)).toEqual([ + { ch: "́", x: 5 }, + { ch: "a", x: 5 }, + ]); + }); +}); + +describe("R-02 frameSnapshot takes no dpr", () => { + test("five parameters", () => { + expect(frameSnapshot.length).toBe(5); + }); +}); + +describe("R-03 a too-large frame is reported as too large", () => { + const blob = new Blob(["png"], { type: "image/png" }); + const mkDeps = (over: Partial = {}): CopyImageDeps & SnapshotDeps => ({ + renderPng: async () => blob, + native: async () => {}, + notify: () => {}, + hideSelection: () => {}, + ...over, + }); + const rects = [{ left: 5, top: 5, width: 100, height: 20 }]; + const vp = { width: 800, height: 600 }; + const tooLarge = () => Promise.reject(new FrameTooLargeError()); + + test("copyPng maps a FrameTooLargeError rejection to tooLarge, other errors to failed", async () => { + expect(await copyPng(mkDeps(), tooLarge())).toBe("tooLarge"); + expect(await copyPng(mkDeps(), Promise.reject(new Error("x")))).toBe("failed"); + }); + test("copyPngOrSnapshot reports tooLarge without trying the snapshot", async () => { + let asked = false; + const deps = mkDeps({ snapshot: async () => ((asked = true), btoa("PNG")), compose: async (p) => p }); + expect(await copyPngOrSnapshot(deps, tooLarge(), rects, vp)).toBe("tooLarge"); + expect(asked).toBe(false); + }); + test("a native snapshot whose frame is too large reports tooLarge", async () => { + const deps = mkDeps({ snapshot: async () => btoa("PNG"), compose: tooLarge as never }); + expect(await copyViaSnapshot(deps, rects, vp)).toBe("tooLarge"); + expect(await copyPngOrSnapshot(deps, Promise.reject(new Error("x")), rects, vp)).toBe("tooLarge"); + }); + test("messageFor", () => { + expect(messageFor("tooLarge")).toBe(MSG_TOO_LARGE); + expect(messageFor("failed")).toBe(MSG_CLIPBOARD_FAILED); + expect(messageFor("clipboard")).toBe(MSG_OK); + expect(messageFor("native")).toBe(MSG_OK); + expect(messageFor("snapshot")).toBe(MSG_OK); + }); +}); + +describe("R-04 the title is drawn as one measured run", () => { + test("frameOps marks the title text op whole", () => { + const g = frameGeometry(500, 200, "/a/app.ts", { bg: "#101010", fg: "#eee", muted: "#888", accent: "#4f8cff" }, measure); + if ("tooLarge" in g) throw new Error("unexpected"); + const title = frameOps(g).filter((o) => o.t === "text"); + expect(title).toHaveLength(1); + expect((title[0] as { whole?: boolean }).whole).toBe(true); + }); + test("frameSnapshot's title op is whole too", () => { + const r = frameSnapshot(500, 200, "doc.md", theme, measure); + expect("tooLarge" in r).toBe(false); + const ops = (r as { ops: { t: string; whole?: boolean }[] }).ops.filter((o) => o.t === "text"); + expect(ops.map((o) => o.whole)).toEqual([true]); + }); +}); diff --git a/test/client.imageframe.test.ts b/test/client.imageframe.test.ts new file mode 100644 index 0000000..e5836de --- /dev/null +++ b/test/client.imageframe.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "bun:test"; +import { + BACKDROP_PAD, + CARD_RADIUS, + DOT_COLORS, + MIN_CONTENT_W, + TITLE_BAR_H, + basename, + ellipsize, + frameGeometry, + frameImage, + frameOps, + type Frame, + type FrameTheme, +} from "../src/client/imageframe.ts"; +import type { DrawOp, ImageLayout, ImageTheme } from "../src/client/imagelayout.ts"; + +const ft: FrameTheme = { bg: "#101010", fg: "#eeeeee", muted: "#8a8a8a", accent: "#4f8cff" }; +const measure = (s: string) => s.length * 7; +const geo = (w: number, h: number, title = "app.ts", t = ft, m = measure): Frame => { + const f = frameGeometry(w, h, title, t, m); + if ("tooLarge" in f) throw new Error("unexpected tooLarge"); + return f; +}; + +describe("frameGeometry (AC-6)", () => { + test("outer size is content plus backdrop padding and title bar", () => { + const f = geo(500, 200); + expect(f.width).toBe(500 + 2 * BACKDROP_PAD); + expect(f.height).toBe(200 + TITLE_BAR_H + 2 * BACKDROP_PAD); + }); + test("design constants stay in the requested ranges", () => { + expect(BACKDROP_PAD).toBeGreaterThanOrEqual(48); + expect(BACKDROP_PAD).toBeLessThanOrEqual(64); + expect(CARD_RADIUS).toBeGreaterThanOrEqual(10); + expect(CARD_RADIUS).toBeLessThanOrEqual(12); + }); + test("card sits inside the backdrop with theme bg, radius and a soft shadow", () => { + const f = geo(500, 200); + expect(f.card).toEqual({ x: BACKDROP_PAD, y: BACKDROP_PAD, w: 500, h: 200 + TITLE_BAR_H, r: CARD_RADIUS, fill: "#101010" }); + expect(f.shadow.blur).toBeGreaterThan(0); + expect(f.shadow.offsetY).toBeGreaterThan(0); + expect(typeof f.shadow.color).toBe("string"); + }); + test("code inset is below the title bar inside the card", () => { + const f = geo(500, 200); + expect(f.inset).toEqual({ x: BACKDROP_PAD, y: BACKDROP_PAD + TITLE_BAR_H }); + }); + test("three dots red/yellow/green, left to right, centred in the title bar", () => { + const f = geo(500, 200); + expect(f.dots.map((d) => d.fill)).toEqual([...DOT_COLORS]); + expect(DOT_COLORS).toEqual(["#ff5f57", "#febc2e", "#28c840"]); + const cy = BACKDROP_PAD + TITLE_BAR_H / 2; + for (const d of f.dots) { + expect(d.cy).toBe(cy); + expect(d.r).toBeGreaterThan(0); + expect(d.cx).toBeGreaterThan(BACKDROP_PAD); + } + expect(f.dots[0]!.cx).toBeLessThan(f.dots[1]!.cx); + expect(f.dots[1]!.cx).toBeLessThan(f.dots[2]!.cx); + }); + test("backdrop gradient derives from the accent and spans the canvas", () => { + const f = geo(500, 200); + const s = f.backdrop.stops; + expect(s.length).toBeGreaterThanOrEqual(2); + expect(s[0]!.at).toBe(0); + expect(s[s.length - 1]!.at).toBe(1); + expect(s[0]!.color).toBe("#4f8cff"); + expect(s[s.length - 1]!.color).not.toBe("#4f8cff"); + expect(s[s.length - 1]!.color).toMatch(/^#[0-9a-f]{6}$/i); + expect(geo(500, 200, "a", { ...ft, accent: "#ff0000" }).backdrop.stops).not.toEqual(s); + const b = f.backdrop; + expect(Math.max(b.x1, b.y1)).toBeGreaterThan(0); + expect(b.x0).toBe(0); + expect(b.y0).toBe(0); + }); + test("title is centred in the card in the muted foreground", () => { + const f = geo(500, 200, "app.ts"); + expect(f.title!.text).toBe("app.ts"); + expect(f.title!.fill).toBe("#8a8a8a"); + expect(f.title!.x).toBe(BACKDROP_PAD + 250 - measure("app.ts") / 2); + expect(f.title!.y).toBeGreaterThanOrEqual(BACKDROP_PAD); + expect(f.title!.y).toBeLessThan(BACKDROP_PAD + TITLE_BAR_H); + }); + test("is deterministic", () => { + expect(geo(500, 200)).toEqual(geo(500, 200)); + }); +}); + +describe("minimum content width (AC-7)", () => { + test("narrow content is clamped up to MIN_CONTENT_W", () => { + const f = geo(50, 100); + expect(f.card.w).toBe(MIN_CONTENT_W); + expect(f.width).toBe(MIN_CONTENT_W + 2 * BACKDROP_PAD); + }); + test("wider content is kept", () => { + expect(geo(MIN_CONTENT_W + 40, 100).card.w).toBe(MIN_CONTENT_W + 40); + }); +}); + +describe("title (AC-8)", () => { + test("basename strips directories of both separators", () => { + expect(basename("src/client/app.ts")).toBe("app.ts"); + expect(basename("C:\\w\\x.py")).toBe("x.py"); + expect(basename("plain.md")).toBe("plain.md"); + expect(basename("")).toBe(""); + }); + test("frameGeometry uses the basename, not the path", () => { + expect(geo(500, 100, "/a/b/c/readme.md").title!.text).toBe("readme.md"); + }); + test("ellipsize keeps text that fits and cuts with an ellipsis otherwise", () => { + expect(ellipsize("abc", 100, measure)).toBe("abc"); + const out = ellipsize("abcdefghij", 50, measure); + expect(out.endsWith("…")).toBe(true); + expect(measure(out)).toBeLessThanOrEqual(50); + expect(out.length).toBeGreaterThan(1); + expect("abcdefghij".startsWith(out.slice(0, -1))).toBe(true); + }); + test("a long basename is ellipsised to fit the title bar", () => { + const name = "a-very-long-file-name-that-cannot-possibly-fit-in-the-bar.component.test.ts"; + const f = geo(MIN_CONTENT_W, 100, `dir/${name}`); + expect(f.title!.text.endsWith("…")).toBe(true); + expect(f.title!.text.length).toBeLessThan(name.length); + expect(measure(f.title!.text)).toBeLessThan(MIN_CONTENT_W); + const left = f.dots[2]!.cx; + expect(f.title!.x).toBeGreaterThan(left); + expect(f.title!.x + measure(f.title!.text)).toBeLessThanOrEqual(BACKDROP_PAD + MIN_CONTENT_W); + }); + test("an empty title yields no title and no text op", () => { + const f = geo(500, 100, ""); + expect(f.title).toBeUndefined(); + expect(frameOps(f).some((o) => o.t === "text")).toBe(false); + expect(frameOps(f).filter((o) => o.t === "dot")).toHaveLength(3); + }); +}); + +describe("frameOps", () => { + test("backdrop gradient first, then the card with shadow, dots and title", () => { + const f = geo(500, 200); + const ops = frameOps(f); + expect(ops[0]).toEqual({ t: "gradient", x: 0, y: 0, w: f.width, h: f.height, ...f.backdrop }); + expect(ops[1]).toEqual({ t: "rect", x: f.card.x, y: f.card.y, w: f.card.w, h: f.card.h, r: f.card.r, fill: "#101010", shadow: f.shadow }); + expect(ops.filter((o) => o.t === "dot")).toEqual(f.dots.map((d) => ({ t: "dot", ...d }))); + expect(ops.find((o) => o.t === "text")).toEqual({ t: "text", x: f.title!.x, y: f.title!.y, text: "app.ts", fill: "#8a8a8a", bold: false, italic: false, whole: true }); + }); +}); + +const it: ImageTheme = { bg: "#101010", fg: "#eeeeee", muted: "#8a8a8a", accent: "#4f8cff", tokenColors: { k: "#ff0000" }, font: "Menlo, monospace", size: 10 }; +const layout = (w: number, h: number): ImageLayout => ({ + width: w, + height: h, + cols: 5, + rows: 2, + ops: [ + { t: "rect", x: 0, y: 0, w, h, r: 8, fill: "#101010" }, + { t: "text", x: 10, y: 10, text: "const", fill: "#ff0000", bold: false, italic: false }, + { t: "text", x: 10, y: 25, text: "x", fill: "#eeeeee", bold: false, italic: false }, + ], +}); +const framed = (l: ImageLayout, dpr = 2, title = "a/app.ts") => { + const r = frameImage(l, title, it, measure, dpr); + if ("tooLarge" in r) throw new Error("unexpected tooLarge"); + return r; +}; + +describe("frameImage (AC-9, AC-10, AC-16)", () => { + test("outer size and scale 2 at dpr 2", () => { + const r = framed(layout(500, 200)); + expect(r.width).toBe(500 + 2 * BACKDROP_PAD); + expect(r.height).toBe(200 + TITLE_BAR_H + 2 * BACKDROP_PAD); + expect(r.scale).toBe(2); + expect(r.cols).toBe(5); + expect(r.rows).toBe(2); + }); + test("scale is always 2 when it fits the pixel cap, regardless of dpr (R-01)", () => { + expect(framed(layout(500, 200), 1).scale).toBe(2); + expect(framed(layout(500, 200), 3).scale).toBe(2); + }); + test("min width clamp applies to the outer size", () => { + expect(framed(layout(50, 40)).width).toBe(MIN_CONTENT_W + 2 * BACKDROP_PAD); + }); + test("fitScale applies to the OUTER size", () => { + // 4000x3000 content is 12M px alone; framed it is larger and still fits at scale 1 but not 2. + const r = framed(layout(4000, 3000)); + expect(r.width * r.height).toBeGreaterThan(12_000_000); + expect(r.scale).toBeLessThan(2); + expect(r.scale).toBeGreaterThanOrEqual(1); + expect(r.width * r.height * r.scale * r.scale).toBeLessThanOrEqual(16_000_000); + }); + test("a frame whose outer size exceeds MAX_PIXELS at scale 1 is tooLarge even if content fits", () => { + // content 3900x3900 = 15.2M fits; outer (4012 x 4048) = 16.2M does not. + expect(frameImage(layout(3900, 3900), "a.ts", it, measure, 2)).toEqual({ tooLarge: true }); + expect(frameImage(layout(5000, 4000), "a.ts", it, measure, 1)).toEqual({ tooLarge: true }); + }); + test("code ops are offset by the card inset; the layout's own backdrop rect is dropped", () => { + const r = framed(layout(500, 200)); + const texts = r.ops.filter((o): o is Extract => o.t === "text" && o.text !== "app.ts"); + expect(texts.map((o) => [o.text, o.x, o.y])).toEqual([ + ["const", 10 + BACKDROP_PAD, 10 + BACKDROP_PAD + TITLE_BAR_H], + ["x", 10 + BACKDROP_PAD, 25 + BACKDROP_PAD + TITLE_BAR_H], + ]); + const rects = r.ops.filter((o) => o.t === "rect"); + expect(rects).toHaveLength(1); + expect(rects[0]).toMatchObject({ fill: "#101010", r: CARD_RADIUS, x: BACKDROP_PAD, y: BACKDROP_PAD }); + }); + test("token fills and default fg are preserved; font and size travel with the result", () => { + const r = framed(layout(500, 200)); + expect(r.ops.find((o) => o.t === "text" && o.text === "const")).toMatchObject({ fill: "#ff0000" }); + expect(r.ops.find((o) => o.t === "text" && o.text === "x")).toMatchObject({ fill: "#eeeeee" }); + expect(r.font).toBe("Menlo, monospace"); + expect(r.size).toBe(10); + }); + test("the frame ops come first, the title is the basename", () => { + const r = framed(layout(500, 200)); + expect(r.ops[0]!.t).toBe("gradient"); + expect(r.ops.some((o) => o.t === "text" && o.text === "app.ts")).toBe(true); + }); + test("accent and muted fall back to fg when the theme has none", () => { + const bare: ImageTheme = { ...it, accent: undefined, muted: undefined }; + const r = frameImage(layout(500, 200), "a.ts", bare, measure, 2); + if ("tooLarge" in r) throw new Error("unexpected"); + const g = r.ops[0] as Extract; + expect(g.stops[0]!.color).toBe("#eeeeee"); + expect(r.ops.find((o) => o.t === "text" && o.text === "a.ts")).toMatchObject({ fill: "#eeeeee" }); + }); +}); diff --git a/test/client.snapfallback.test.ts b/test/client.snapfallback.test.ts index 8e98d37..51f7414 100644 --- a/test/client.snapfallback.test.ts +++ b/test/client.snapfallback.test.ts @@ -3,6 +3,7 @@ import { clampSnapRect, copyPngOrSnapshot, copyViaSnapshot, type CopyImageDeps, const vp = { width: 800, height: 600 }; const blob = new Blob(["png"], { type: "image/png" }); +const B64 = btoa("PNGDATA"); const rej = () => Promise.reject(new Error("SecurityError")); describe("clampSnapRect", () => { @@ -22,10 +23,9 @@ describe("clampSnapRect", () => { function mk(log: string[], snap?: SnapshotDeps["snapshot"], over: Partial = {}): CopyImageDeps & SnapshotDeps { return { renderPng: async () => blob, - download: () => void log.push("download"), native: async () => void log.push("native"), - copyText: () => {}, notify: () => {}, + compose: async (p) => p, snapshot: snap, hideSelection: (on) => void log.push(on ? "hide" : "show"), ...over, @@ -37,21 +37,21 @@ describe("copyViaSnapshot", () => { test("hides, snapshots the clamped rect, restores", async () => { const log: string[] = []; let got: unknown; - const ok = await copyViaSnapshot(mk(log, async (r) => ((got = r), true)), rects, vp); + const ok = await copyViaSnapshot(mk(log, async (r) => ((got = r), B64)), rects, vp); expect(ok).toBe(true); expect(got).toEqual({ x: 5, y: 5, w: 100, h: 20 }); - expect(log).toEqual(["hide", "show"]); + expect(log).toEqual(["hide", "show", "native"]); }); test("false and restored when the snapshot fails or throws", async () => { const log: string[] = []; - expect(await copyViaSnapshot(mk(log, async () => false), rects, vp)).toBe(false); + expect(await copyViaSnapshot(mk(log, async () => null), rects, vp)).toBe(false); expect(await copyViaSnapshot(mk(log, async () => { throw new Error("x"); }), rects, vp)).toBe(false); expect(log).toEqual(["hide", "show", "hide", "show"]); }); test("false without a snapshot bridge or visible rect, never hiding", async () => { const log: string[] = []; expect(await copyViaSnapshot(mk(log), rects, vp)).toBe(false); - expect(await copyViaSnapshot(mk(log, async () => true), [], vp)).toBe(false); + expect(await copyViaSnapshot(mk(log, async () => B64), [], vp)).toBe(false); expect(log).toEqual([]); }); }); @@ -60,14 +60,14 @@ describe("copyPngOrSnapshot", () => { test("PNG ok: no snapshot", async () => { const log: string[] = []; let called = false; - expect(await copyPngOrSnapshot(mk(log, async () => ((called = true), true)), Promise.resolve(blob), rects, vp)).toBe("native"); + expect(await copyPngOrSnapshot(mk(log, async () => ((called = true), B64)), Promise.resolve(blob), rects, vp)).toBe("native"); expect(called).toBe(false); }); test("PNG fails + snapshot ok -> snapshot", async () => { - expect(await copyPngOrSnapshot(mk([], async () => true), rej(), rects, vp)).toBe("snapshot"); + expect(await copyPngOrSnapshot(mk([], async () => B64), rej(), rects, vp)).toBe("snapshot"); }); test("PNG fails + snapshot fails or unavailable -> failed", async () => { - expect(await copyPngOrSnapshot(mk([], async () => false), rej(), rects, vp)).toBe("failed"); + expect(await copyPngOrSnapshot(mk([], async () => null), rej(), rects, vp)).toBe("failed"); expect(await copyPngOrSnapshot(mk([]), rej(), rects, vp)).toBe("failed"); }); }); diff --git a/test/client.snapfallback4.test.ts b/test/client.snapfallback4.test.ts index 0524bee..6bc6f7f 100644 --- a/test/client.snapfallback4.test.ts +++ b/test/client.snapfallback4.test.ts @@ -30,8 +30,8 @@ describe("snapTracker hide ownership", () => { // End to end through the real deps with a fake DOM and bridge. let on = false; const log: string[] = []; -const win: any = {}; -(globalThis as any).window = win; +// imagedom is a cached module: share one window object across test files so its __cvSnap hook lands on it. +const win: any = ((globalThis as any).window ??= {}); (globalThis as any).document = { documentElement: { classList: { toggle: (_c: string, v: boolean) => { on = v; log.push(v ? "hide" : "show"); } } }, }; @@ -47,7 +47,7 @@ describe("overlapping Copy as Image", () => { on = false; log.length = 0; const msgs: { t: string; id: number; hideAtPost: boolean }[] = []; - const deps = snapshotDeps({ postMessage: (m: any) => msgs.push({ ...m, hideAtPost: on }) }); + const deps = { ...snapshotDeps({ postMessage: (m: any) => msgs.push({ ...m, hideAtPost: on }) }), compose: async (p: Blob) => p, native: async () => {} }; const pA = copyViaSnapshot(deps, rects, vp); await sleep(5); const pB = copyViaSnapshot(deps, rects, vp); @@ -56,7 +56,7 @@ describe("overlapping Copy as Image", () => { expect(shotB).toBeDefined(); expect(shotB.hideAtPost).toBe(true); expect(on).toBe(true); - win.__cvSnap(2, true); + win.__cvSnap(2, true, btoa("PNGDATA")); expect(await pB).toBe(true); expect(await pA).toBe(false); expect(on).toBe(false); diff --git a/test/client.snapfallback5.test.ts b/test/client.snapfallback5.test.ts new file mode 100644 index 0000000..991159b --- /dev/null +++ b/test/client.snapfallback5.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { copyViaSnapshot, pngFromBase64, type SnapRect, type SnapshotDeps } from "../src/client/copyimage.ts"; + +// AC-12: the native snapshot replies with base64 PNG; JS decodes, composes the frame, then writes via the native pasteboard handler. +const vp = { width: 800, height: 600 }; +const rects = [{ left: 5, top: 5, width: 100, height: 20 }]; +const B64 = btoa("PNGDATA"); + +interface Probe { + log: string[]; + composed?: { text: string; type: string; rect: SnapRect }; + written?: Blob; +} +function mk(p: Probe, over: Partial = {}): SnapshotDeps { + return { + hideSelection: (on) => void p.log.push(on ? "hide" : "show"), + snapshot: async () => B64, + compose: async (png, rect) => { + p.composed = { text: await png.text(), type: png.type, rect }; + p.log.push("compose"); + return new Blob(["FRAMED"], { type: "image/png" }); + }, + native: async (b) => { + p.written = b; + p.log.push("native"); + }, + ...over, + }; +} + +describe("pngFromBase64", () => { + test("decodes to an image/png blob", async () => { + const b = pngFromBase64(B64); + expect(b.type).toBe("image/png"); + expect(await b.text()).toBe("PNGDATA"); + }); +}); + +describe("copyViaSnapshot base64 flow (AC-12)", () => { + test("decodes, composes with the clamped rect, writes the composed blob natively", async () => { + const p: Probe = { log: [] }; + expect(await copyViaSnapshot(mk(p), rects, vp)).toBe(true); + expect(p.composed).toEqual({ text: "PNGDATA", type: "image/png", rect: { x: 5, y: 5, w: 100, h: 20 } }); + expect(await p.written!.text()).toBe("FRAMED"); + expect(p.log[0]).toBe("hide"); + expect(p.log.filter((l) => l === "show")).toHaveLength(1); + expect(p.log.indexOf("compose")).toBeLessThan(p.log.indexOf("native")); + }); + test("a compose failure returns false, writes nothing and restores the highlight", async () => { + const p: Probe = { log: [] }; + const ok = await copyViaSnapshot(mk(p, { compose: async () => { throw new Error("canvas"); } }), rects, vp); + expect(ok).toBe(false); + expect(p.written).toBeUndefined(); + expect(p.log.filter((l) => l === "show")).toHaveLength(1); + }); + test("a native write failure returns false", async () => { + const p: Probe = { log: [] }; + expect(await copyViaSnapshot(mk(p, { native: async () => { throw new Error("pb"); } }), rects, vp)).toBe(false); + expect(p.log.filter((l) => l === "show")).toHaveLength(1); + }); + test("no PNG data, or no compose/native writer, is a failure", async () => { + const p: Probe = { log: [] }; + expect(await copyViaSnapshot(mk(p, { snapshot: async () => null }), rects, vp)).toBe(false); + expect(await copyViaSnapshot(mk(p, { compose: undefined }), rects, vp)).toBe(false); + expect(await copyViaSnapshot(mk(p, { native: undefined }), rects, vp)).toBe(false); + expect(p.written).toBeUndefined(); + }); + test("the snapshot is asked for the request id from begin()", async () => { + const p: Probe = { log: [] }; + let got: number | undefined; + const deps = mk(p, { begin: () => 7, snapshot: async (_r, id) => ((got = id), B64) }); + expect(await copyViaSnapshot(deps, rects, vp)).toBe(true); + expect(got).toBe(7); + }); +}); + +describe("snapshotDeps reply (AC-12, real deps with a fake bridge)", () => { + test("__cvSnap(id, ok, base64) resolves the base64 once; ok=false or missing data resolves null", async () => { + const win: any = {}; + const prevWin = (globalThis as any).window; + (globalThis as any).window = win; + (globalThis as any).document = { documentElement: { classList: { toggle() {} } } }; + (globalThis as any).requestAnimationFrame = (cb: () => void) => setTimeout(cb, 0); + const { snapshotDeps } = await import("../src/client/imagedom.ts?own-tracker"); + expect(typeof win.__cvSnap).toBe("function"); + const sent: any[] = []; + const deps = snapshotDeps({ postMessage: (m: any) => sent.push(m) }); + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const rect = { x: 1, y: 2, w: 30, h: 40 }; + + const a = deps.snapshot!(rect, deps.begin!()); + await sleep(15); + const idA = sent.find((m) => m.t === "snapshotrect").id; + win.__cvSnap(idA, true, B64); + expect(await a).toBe(B64); + + const b = deps.snapshot!(rect, deps.begin!()); + await sleep(15); + win.__cvSnap(sent.filter((m) => m.t === "snapshotrect").pop().id, false); + expect(await b).toBeNull(); + + const c = deps.snapshot!(rect, deps.begin!()); + await sleep(15); + win.__cvSnap(sent.filter((m) => m.t === "snapshotrect").pop().id, true); // ok without data + expect(await c).toBeNull(); + (globalThis as any).window = prevWin; + }); +}); diff --git a/test/client.swift.snapshot.test.ts b/test/client.swift.snapshot.test.ts new file mode 100644 index 0000000..9cd685d --- /dev/null +++ b/test/client.swift.snapshot.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +// AC-17: the snapshotrect handler replies with base64 PNG and never touches the pasteboard; copyimage keeps writing it. +const path = new URL("../packaging/macos/main.swift", import.meta.url).pathname; +const src = readFileSync(path, "utf8"); + +function block(from: string): string { + const start = src.indexOf(`if t == "${from}" {`); + if (start < 0) throw new Error(`no ${from} handler`); + let depth = 0; + for (let i = src.indexOf("{", start); i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(start, i + 1); + } + throw new Error("unbalanced"); +} + +describe("macOS snapshot handlers (AC-17)", () => { + test("snapshotrect replies with base64 PNG and does not use the pasteboard", () => { + const h = block("snapshotrect"); + expect(h).toContain("reply("); + expect(h).toContain("base64EncodedString"); + expect(h).not.toContain("pb.setData"); + expect(h).not.toContain("clearContents"); + expect(h).not.toContain("NSPasteboard"); + }); + test("copyimage still writes the pasteboard", () => { + const h = block("copyimage"); + expect(h).toContain("clearContents"); + expect(h).toContain("pb.setData"); + }); + // A cold swiftc start on CI runners takes well over bun's 5 s default. + test("main.swift parses", async () => { + const swiftc = Bun.which("swiftc"); + if (!swiftc) return; + const p = Bun.spawn([swiftc, "-parse", path], { stdout: "ignore", stderr: "pipe" }); + expect(await p.exited).toBe(0); + }, 60_000); +}); diff --git a/test/client.userselect.css.test.ts b/test/client.userselect.css.test.ts new file mode 100644 index 0000000..6f0a78f --- /dev/null +++ b/test/client.userselect.css.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { textOf } from "../src/client/selection.ts"; + +const css = readFileSync(new URL("../src/client/styles.css", import.meta.url), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + +/** Innermost `selector { body }` blocks (at-rule wrappers are skipped). */ +function rules(src: string): { sel: string; body: string }[] { + const out: { sel: string; body: string }[] = []; + const re = /([^{}]+)\{([^{}]*)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src))) out.push({ sel: m[1]!.trim(), body: m[2]! }); + return out; +} +const all = rules(css); +/** Value of declaration `prop` in a rule body, matched by exact property name. */ +const decl = (body: string, prop: string) => { + for (const d of body.split(";")) { + const i = d.indexOf(":"); + if (i > 0 && d.slice(0, i).trim() === prop) return d.slice(i + 1).trim(); + } +}; + +describe("user-select prefix (AC-1)", () => { + const nones = all.filter((r) => /(^|[;\s])user-select\s*:\s*none/.test(r.body)); + test("the stylesheet declares user-select: none in several rules", () => { + expect(nones.length).toBeGreaterThanOrEqual(9); + }); + test("every user-select: none rule also declares -webkit-user-select: none, !important matching", () => { + const missing = nones + .filter((r) => { + const plain = decl(r.body, "user-select")!; + const pre = decl(r.body, "-webkit-user-select"); + return pre !== plain; + }) + .map((r) => r.sel); + expect(missing).toEqual([]); + }); +}); + +describe("gutter and labels (AC-2)", () => { + test(".ln .g::selection is transparent", () => { + const r = all.find((x) => x.sel.split(",").map((s) => s.trim()).includes(".ln .g::selection")); + expect(r).toBeDefined(); + expect(decl(r!.body, "background")).toBe("transparent"); + }); + test("blame and truncation labels carry -webkit-user-select: none", () => { + for (const sel of [".blame", ".trunc", ".ln .g"]) { + const rs = all.filter((x) => x.sel.split(",").map((s) => s.trim()).includes(sel)); + expect(rs.some((x) => decl(x.body, "-webkit-user-select") === "none")).toBe(true); + } + }); +}); + +describe("text copy excludes line numbers (AC-15)", () => { + test("textOf over numbered lines returns only the code", () => { + const lines = ["15 const a = 1;", "16 const b = 2;", "17 const c = 3;"]; + const out = textOf(lines, { start: { line: 0, col: 3 }, end: { line: 2, col: 15 } }); + expect(out).toBe("const a = 1;\n16 const b = 2;\n17 const c = 3;"); + expect(out.split("\n")[0]).toBe("const a = 1;"); + }); +});