import assert from "node:assert/strict"; function tags(frame) { // This fixture has workspace and column bars in its first two rows. return frame.styleRows.flatMap((cells, y) => y > 1 && /^\s{3}\S/.test(frame.lines[y]) && cells.some((cell) => cell[2] & 128) ? [y] : []); } function pane(frame, name) { const rows = tags(frame); const top = rows.find((row) => frame.lines[row].includes(name)); assert.notEqual(top, undefined, `missing pane ${name}`); const bottom = rows.find((row) => row > top) ?? frame.rows; return { top, bottom, lines: frame.lines.slice(top + 1, bottom) }; } function styled(frame) { const source = pane(frame, "touch-fixture.zig"); for (const [line, word, fg, bold] of [ ["pub fn choose", "fn", 0xcf87e8, false], ["pub fn choose", "choose", 0xbdbdbd, false], ["if (value", "if", 0xcf87e8, false], ["if (value", "42", 0xe3c78a, false], ["if (value", '"touch fixture"', 0xe3c78a, false], ]) { const row = source.lines.findIndex((text) => text.includes(line)); assert(row >= 0, `missing source line ${line}`); const col = source.lines[row].indexOf(word); for (let x = col; x < col + word.length; x++) { const style = frame.styleRows[source.top + 1 + row][x]; assert.equal(style[0], fg, `${word} cell ${x - col} foreground`); assert.equal(Boolean(style[2] & 1), bold, `${word} cell ${x - col} bold`); } } for (const [row, line] of source.lines.entries()) { const number = /\b(\d+),/.exec(line); if (!number) continue; for (let x = number.index; x < number.index + number[1].length; x++) { assert.equal(frame.styleRows[source.top + 1 + row][x][0], 0xe3c78a, `row ${row} number foreground`); } } } export default async function ({ grid, touch, wait, stable, delay, evaluate, mouse }) { await stable(100, 5000); const compactGeometry = await evaluate(`(() => { const root = document.createElement("div"); root.className = "pardes"; root.style.cssText = "position:fixed;left:0;top:0;width:20px;height:40px"; document.body.append(root); const memory = new WebAssembly.Memory({initial:1}); const bytes = new Uint8Array(memory.buffer), data = new DataView(memory.buffer); for (const [base,count] of [[0,4],[1024,10]]) for(let i=0;i50,pardes_frame:()=>4, pardes_pointer_shape:()=>0,pardes_frame_cols:()=>2,pardes_frame_rows:()=>2, pardes_frame_ptr:()=>0,pardes_cursor_x:()=>65535,pardes_cursor_y:()=>65535,pardes_cursor_bar:()=>0, pardes_body_layer_limit:()=>1,pardes_body_layer_value:(_,field)=>values[field],pardes_body_layer_cells:()=>1024}; const renderer=new pardes.runtime.renderer.constructor(root,wasm); Object.assign(renderer,{cellWidth:10,cellHeight:20,taglineWidth:5,taglineHeight:10}); renderer.render(); const layer=renderer.layerRoot.firstChild, rect=layer.getBoundingClientRect(); const bands=[...layer.children].slice(0,3).map(node=>{ const box=node.getBoundingClientRect();return [box.y-rect.y,box.height]; }); const cursor=layer.querySelector('.is-cursor'); renderer.render(); const result={bands,height:rect.height,clipped:getComputedStyle(layer).overflow, cursor:cursor?.textContent,cached:renderer.layerRoot.firstChild===layer}; values[0]=5;values[2]=3;values[6]=4;values[11]=0;values[12]=1;values[13]=1; renderer.render(); const borders=()=>[...renderer.layerRoot.querySelectorAll('[data-context-border]')].map(node=>parseFloat(node.style.top)); result.gapBorders=borders(); const withGap=renderer.layerRoot.firstChild; values[12]=0; renderer.render(); result.closedGapBorders=borders(); result.borderChangeInvalidates=renderer.layerRoot.firstChild!==withGap; const tagValues=[2,2,0,42,1,0,1,1,1,1,0x334455]; wasm.pardes_tag_layer_limit=()=>1; wasm.pardes_tag_layer_value=(_,field)=>tagValues[field]; wasm.pardes_tag_layer_cells=()=>1024; wasm.pardes_tagline_band_offset=()=>2560; renderer.render(); const tag=renderer.tagRoot.firstChild; result.tag={left:parseFloat(tag.style.left),top:parseFloat(tag.style.top),width:parseFloat(tag.style.width), text:tag.textContent,cursor:tag.querySelector('.is-cursor')?.textContent, gripVisible:renderer.cells[0].style.visibility,covered:renderer.cells[1].style.visibility, background:tag.style.backgroundColor}; renderer.render(); result.tag.cached=renderer.tagRoot.firstChild===tag; tagValues[10]=0x556677; renderer.render(); result.tag.bgInvalidates=renderer.tagRoot.firstChild!==tag; wasm.pardes_tag_text_inset=()=>3; tagValues[1]=1;tagValues[4]=3;tagValues[5]=0; bytes[0]=32;data.setUint32(8,0x778899,true); renderer.render(); const columnGrip=renderer.tagRoot.querySelector('[data-tag-grip]'); const marks=node=>[...node.children].map(mark=>[parseFloat(mark.style.left),parseFloat(mark.style.top),parseFloat(mark.style.width)]); result.columnMarks=columnGrip ? marks(columnGrip) : []; tagValues[1]=2;tagValues[5]=1;bytes[40]=32;data.setUint32(48,0x778899,true); renderer.render(); result.paneMarks=marks(renderer.tagRoot.querySelector('[data-tag-grip]')); const inkCenter=canvas=>{ const pixels=canvas.getContext('2d').getImageData(0,0,canvas.width,canvas.height).data; let minX=canvas.width,minY=canvas.height,maxX=-1,maxY=-1; for(let y=0;y8) { minX=Math.min(minX,x);maxX=Math.max(maxX,x);minY=Math.min(minY,y);maxY=Math.max(maxY,y); } return {x:(minX+maxX+1-canvas.width)/2,y:(minY+maxY+1-canvas.height)/2,visible:maxX>=0}; }; Object.assign(renderer,{cellWidth:20,cellHeight:40,taglineWidth:10,taglineHeight:28}); renderer.cells[2].style.font='18px serif'; renderer.resetGripMetrics(); result.modeGlyphs=[]; for(const glyph of ['^','$']) { bytes[40]=glyph.charCodeAt(0); renderer.render(); const canvas=renderer.tagRoot.querySelector('canvas[data-tag-grip]'); const center=inkCenter(canvas),size=renderer.gripMetrics.size; renderer.render(); result.modeGlyphs.push({...center,suppressed:renderer.cells[2].textContent===' ', reused:renderer.tagRoot.querySelector('canvas[data-tag-grip]')===canvas, measuredOnce:renderer.gripMetrics.size===size}); } const glyphCell=renderer.cells[2]; glyphCell.style.font='italic 24px serif'; renderer.resetGripMetrics(); const italic=renderer.drawGripGlyph(glyphCell,{text:'j',attrs:2},40,40); result.italic=inkCenter(italic); const saved=renderer.gripMetrics.values().next().value; renderer.drawGripGlyph(glyphCell,{text:'j',attrs:2},40,40); result.italic.cached=renderer.gripMetrics.size===1 && renderer.gripMetrics.values().next().value===saved; document.fonts.dispatchEvent(new Event('loadingdone')); result.fontInvalidation=renderer.gripMetrics.size===0 && renderer.gripFonts.size===0 && renderer.tagNodes.size===0; glyphCell.style.font='italic 30px serif'; renderer.drawGripGlyph(glyphCell,{text:'j',attrs:2},40,40); result.fontRemeasured=renderer.gripMetrics.values().next().value!==saved && [...renderer.gripFonts.values()][0].includes('30px'); root.remove();return result; })()`); assert.deepEqual(compactGeometry.bands, [[0,10],[10,20],[30,20]], "compact headers release real height to additional body rows"); assert.equal(compactGeometry.height,40); assert.equal(compactGeometry.clipped,"hidden"); assert.equal(compactGeometry.cursor,"E"); assert(compactGeometry.cached,"unchanged compact layers reuse their DOM nodes"); assert.deepEqual(compactGeometry.gapBorders,[20,30],"source gaps and final header have separators, consecutive headers do not"); assert.deepEqual(compactGeometry.closedGapBorders,[30]); assert(compactGeometry.borderChangeInvalidates,"separator changes invalidate an otherwise unchanged layer"); assert.deepEqual(compactGeometry.tag, {left:10,top:10,width:10,text:"AB",cursor:"B", gripVisible:"",covered:"hidden",background:"rgb(51, 68, 85)",cached:true,bgInvalidates:true}, "tag text uses its own columns after a fixed body-width grip, with cursor and cached base background"); assert.deepEqual(compactGeometry.columnMarks,[],"column handles have no mark"); assert.deepEqual(compactGeometry.paneMarks,[[7,33,6],[7,36,6]],"pane handles keep their physical mark geometry"); for (const glyph of [...compactGeometry.modeGlyphs,compactGeometry.italic]) { assert(glyph.visible,"mode indicator has visible rasterized ink"); assert(Math.abs(glyph.x)<=1 && Math.abs(glyph.y)<=1,`ink center offset ${glyph.x},${glyph.y}`); } for (const glyph of compactGeometry.modeGlyphs) assert(glyph.suppressed && glyph.reused && glyph.measuredOnce,"mode ink is drawn once and cached"); assert(compactGeometry.italic.cached,"asymmetric italic glyph reuses cached ink bounds"); assert(compactGeometry.fontInvalidation && compactGeometry.fontRemeasured,"font loading clears cached faces and bounds before remeasurement"); let frame = await grid(); assert.equal(tags(frame).length, 1); styled(frame); const link = "/virtual/pane/1/body"; const row = frame.lines.findIndex((text) => text.includes(link)); const col = frame.lines[row].indexOf(link) + 5; let finger = await touch("down", 1, col, row, null); finger = await touch("move", 1, col + 0.2, row + 0.1, finger); await touch("up", 1, col + 0.2, row + 0.1, finger); await wait(async () => tags(await grid()).length === 2, 5000, "Look opening the reflected body"); await stable(100, 5000); frame = await grid(); styled(frame); const reflected = pane(frame, link); assert(reflected.lines.some((text) => text.includes("pub fn choose"))); const original = pane(frame, "touch-fixture.zig"); const sharedRows = Math.min(reflected.lines.length, original.lines.length); assert(sharedRows >= 6); assert.deepEqual(reflected.lines.slice(0, sharedRows), original.lines.slice(0, sharedRows), "Look must read the source pane body"); const center = Math.floor(frame.cols / 2); const separator = Math.max(original.top, reflected.top) - 1; const distance = original.top < reflected.top ? 4 : -4; finger = await touch("down", 2, center, separator, null); for (let step = 1; step <= 8; step++) { finger = await touch("move", 2, center, separator + distance * step / 8, finger); await delay(20); } await touch("up", 2, center, separator + distance, finger); await stable(100, 5000); frame = await grid(); assert.equal(tags(frame).length, 2); const resized = pane(frame, "touch-fixture.zig"); assert(resized.lines.length >= original.lines.length + 3, "separator drag must resize, not scroll the body"); assert.equal(resized.lines[0], original.lines[0], "resizing must preserve the source viewport"); styled(frame); const beforeScroll = resized.lines[0]; const reflectedBeforeScroll = pane(frame, link).lines; const from = resized.bottom - 3; const to = Math.max(resized.top + 2, from - 7); finger = await touch("down", 3, center, from, null); for (let step = 1; step <= 12; step++) { finger = await touch("move", 3, center, from + (to - from) * step / 12, finger); await delay(20); } await touch("up", 3, center, to, finger); await stable(100, 5000); frame = await grid(); assert.equal(tags(frame).length, 2, "scroll must not also Look"); const scrolled = pane(frame, "touch-fixture.zig"); assert.equal(scrolled.top, resized.top, "body scrolling must not resize"); assert.notEqual(scrolled.lines[0], beforeScroll, "source must scroll"); assert(Number(scrolled.lines[0].trim().split(/\s+/)[0]) > 1, "scroll advances source line numbers"); assert.deepEqual(pane(frame, link).lines, reflectedBeforeScroll, "scrolling the source must leave its neighbor alone"); const sourceRow = scrolled.top + 2; const reflectedRow = pane(frame, link).top + 2; const point = await evaluate(`(() => { const runtime = pardes.runtime; const root = runtime.root; const wasm = runtime.wasm; const events = []; const canceled = []; const pointerDownCanceled = []; const click = (row) => { wasm.pardes_mouse(0, 0, 10, row, 0); wasm.pardes_mouse(0, 1, 10, row, 0); wasm.pardes_tick(); runtime.renderer.render(); }; click(${sourceRow}); click(${reflectedRow}); runtime.wasm = { ...wasm, pardes_mouse: (...args) => { events.push(args.slice(0, 2)); wasm.pardes_mouse(...args); }, pardes_mouse_pixel: (...args) => { events.push(args.slice(0, 2)); wasm.pardes_mouse_pixel(...args); } }; const observe = (event) => { if (event.type === "pointerdown") pointerDownCanceled.push(event.defaultPrevented); else if (event.button === 3 || event.button === 4) canceled.push(event.defaultPrevented); }; for (const type of ["pointerdown", "mousedown", "auxclick"]) root.addEventListener(type, observe); pardes.thumbTest = { wasm, observe, events, canceled, pointerDownCanceled }; return runtime.cellPoint(10, ${sourceRow}); })()`); const thumb = async (button, held = false) => { const mask = button === "back" ? 8 : 16; await mouse({ type: "mousePressed", button, buttons: mask | Number(held), ...point, clickCount: 1 }); await mouse({ type: "mouseReleased", button, buttons: Number(held), ...point, clickCount: 1 }); await stable(100, 5000); return (await grid()).cursorY; }; try { const backRow = await thumb("back"); const forwardRow = await thumb("forward"); assert(backRow > scrolled.top && backRow < scrolled.bottom, "Back returns to the source pane"); const reflectedAfter = pane(await grid(), link); assert(forwardRow > reflectedAfter.top && forwardRow < reflectedAfter.bottom, "Forward returns to the reflected pane"); await mouse({ type: "mousePressed", button: "left", buttons: 1, ...point, clickCount: 1 }); await evaluate("pardes.thumbTest.heldPointer = pardes.runtime.pointer"); await thumb("back", true); await thumb("forward", true); const navigation = await evaluate(`({ events: pardes.thumbTest.events, canceled: pardes.thumbTest.canceled, pointerDownCanceled: pardes.thumbTest.pointerDownCanceled, keptPointer: pardes.thumbTest.heldPointer !== null && pardes.runtime.pointer === pardes.thumbTest.heldPointer, })`); const presses = navigation.events.filter((event) => event[1] === 0 && (event[0] === 8 || event[0] === 9)); assert.deepEqual(presses, [[8, 0], [9, 0], [8, 0], [9, 0]], "each physical thumb press navigates once"); assert(!navigation.events.some((event) => event[0] === 1 || (event[0] === 0 && event[1] === 3)), "thumb buttons must not become selection drags or middle clicks"); assert(navigation.canceled.length >= 4 && navigation.canceled.every(Boolean), "thumb mouse and auxclick events must cancel browser defaults"); assert(navigation.pointerDownCanceled.every((value) => !value), "mouse pointerdown must permit subsequent mousedown events"); assert(navigation.keptPointer, "thumb navigation must retain the held selection pointer"); } finally { await mouse({ type: "mouseReleased", button: "left", buttons: 0, ...point, clickCount: 1 }); await evaluate(`(() => { for (const type of ["pointerdown", "mousedown", "auxclick"]) pardes.runtime.root.removeEventListener(type, pardes.thumbTest.observe); pardes.runtime.wasm = pardes.thumbTest.wasm; delete pardes.thumbTest; })()`); } }