feat(mobile): canvas-only mobile mode — pinch-zoom/pan/tap gestures + chrome-less editors

On a mobile device (or ?mobile=1) the editors run canvas-only with touch
gestures driving the view:

- touch-gestures.ts: pure recognizer (unit-tested) + DOM shim installed in
  boot preRun — one-finger drag → synthetic middle-drag (pan), pinch →
  synthetic wheel at the centroid (zoom-to-cursor), tap → left click.
  preRun registration order is what lets stopImmediatePropagation suppress
  the wx layer's single-finger→LEFT-drag touch mapping.
- kicadSetChrome(bool) embind: hides all AUI panes except DrawFrame + the
  menubar/status bar via generic wx APIs (kicad fork untouched); boot polls
  it after runtime init. Pairs with the wxwidgets IsShown layout fix.
- mobile-mode.ts: ?mobile=1/0 override or UA-CH/coarse-pointer autodetect;
  shell hides its overlays and the inherent-to-mobile preflight warnings.
- e2e: mobile-chromium project (Pixel 7) + 4 specs (chrome-less, tap,
  pinch, pan) with screenshot-invertibility assertions; also fixes
  tool-switch.spec's stale pre-scope-refactor URLs (was broken on main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-07-06 16:10:52 +02:00
commit dc7c60f723
13 changed files with 1039 additions and 24 deletions

View file

@ -0,0 +1,306 @@
import { test, expect, devices, type Page, type Browser } from '@playwright/test';
/**
* Mobile canvas-only mode e2e (features/mobile) runs under the
* `mobile-chromium` project (Pixel 7 emulation, system Chrome).
*
* Boots pcbnew ONCE on the demo board with `?mobile=1` (the wasm runtime is
* process-global and each boot costs minutes, so the four checks share one
* serial page):
*
* 1. chrome-less: no visible wx toolbars/menubar, GL canvas viewport
* (needs kicadSetChrome the wasm side of features/mobile)
* 2. tap contract: a touch tap synthesizes a LEFT click (selection keeps
* working), and no phantom middle-button events
* 3. pinch: two-finger pinch-out zooms in (and pinch-in restores) rides
* the wheelzoom-to-cursor path, asserted per zoom-cursor.spec.ts logic
* 4. pan: a one-finger drag translates the view (and is NOT the old
* single-finger rubber-band select, which left the view unchanged)
*
* Touches are dispatched as synthetic TouchEvents on #canvas exactly what
* the boot shim (touch-gestures.ts) consumes; Playwright has no pinch API.
*/
const FRONTEND_URL = process.env.WEB_APP_URL ?? 'http://localhost:3048';
const SCOPE = 'default';
// NOT describe.serial: the config is fullyParallel:false + workers:1, so the
// file's tests already run in order in one worker sharing `page` — and a
// failure (e.g. chrome-hide RED before the wasm lands) must not skip the rest.
let page: Page;
interface Pt {
id: number;
x: number;
y: number;
}
/** Dispatch a TouchEvent on #canvas; `active` is the post-event touch list
* (the shape of `event.touches` empty for the final touchend). */
async function touch(pg: Page, type: string, active: Pt[], changed?: Pt[]): Promise<void> {
await pg.evaluate(
({ type, active, changed }) => {
const canvas = document.getElementById('canvas');
if (!canvas) throw new Error('#canvas not found');
const mk = (p: { id: number; x: number; y: number }) =>
new Touch({
identifier: p.id,
target: canvas,
clientX: p.x,
clientY: p.y,
screenX: p.x,
screenY: p.y,
radiusX: 2.5,
radiusY: 2.5,
force: 1,
});
const touches = active.map(mk);
canvas.dispatchEvent(
new TouchEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
touches,
targetTouches: touches,
changedTouches: (changed ?? active).map(mk),
}),
);
},
{ type, active, changed },
);
}
/** One-finger drag as a touch sequence. */
async function fingerDrag(
pg: Page,
from: { x: number; y: number },
to: { x: number; y: number },
steps = 10,
): Promise<void> {
await touch(pg, 'touchstart', [{ id: 1, x: from.x, y: from.y }]);
for (let i = 1; i <= steps; i++) {
const x = from.x + ((to.x - from.x) * i) / steps;
const y = from.y + ((to.y - from.y) * i) / steps;
await touch(pg, 'touchmove', [{ id: 1, x, y }]);
await pg.waitForTimeout(30);
}
await touch(pg, 'touchend', [], [{ id: 1, x: to.x, y: to.y }]);
}
/** Two-finger horizontal pinch around a centre, from ±spreadFrom to ±spreadTo. */
async function pinch(
pg: Page,
centre: { x: number; y: number },
spreadFrom: number,
spreadTo: number,
steps = 8,
): Promise<void> {
const at = (s: number): Pt[] => [
{ id: 1, x: centre.x - s, y: centre.y },
{ id: 2, x: centre.x + s, y: centre.y },
];
await touch(pg, 'touchstart', at(spreadFrom));
for (let i = 1; i <= steps; i++) {
const s = spreadFrom + ((spreadTo - spreadFrom) * i) / steps;
await touch(pg, 'touchmove', at(s));
await pg.waitForTimeout(30);
}
await touch(pg, 'touchend', [], at(spreadTo));
}
/** The visible GAL WebGL canvas box (same lookup as zoom-cursor.spec.ts). */
async function getGlBox(pg: Page): Promise<{ x: number; y: number; width: number; height: number }> {
const id = await pg.evaluate(() => {
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
.map((c) => c as HTMLCanvasElement)
.find((c) => {
const rect = c.getBoundingClientRect();
return window.getComputedStyle(c).display !== 'none' && rect.width > 0 && rect.height > 0;
});
return (visible ?? (document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null))?.id ?? null;
});
if (!id) throw new Error('No visible GL canvas found');
const box = await pg.locator(`#${id}`).boundingBox();
if (!box) throw new Error('GL canvas bounding box unavailable');
return box;
}
/** Fraction of pixels that differ (luma) between two PNGs inside a region
* (ported from zoom-cursor.spec.ts). */
async function diffRatio(
pg: Page,
a: Buffer,
b: Buffer,
box: { x: number; y: number; width: number; height: number },
): Promise<number> {
return pg.evaluate(
async ({ aB64, bB64, box }) => {
const load = async (s: string) => {
const i = new Image();
i.src = `data:image/png;base64,${s}`;
await i.decode();
return i;
};
const [ia, ib] = await Promise.all([load(aB64), load(bB64)]);
const w = Math.min(ia.width, ib.width),
h = Math.min(ia.height, ib.height);
const px = (img: HTMLImageElement) => {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d')!;
x.drawImage(img, 0, 0);
return x.getImageData(0, 0, w, h).data;
};
const da = px(ia),
db = px(ib);
const x0 = Math.max(0, Math.round(box.x)),
x1 = Math.min(w, Math.round(box.x + box.width));
const y0 = Math.max(0, Math.round(box.y)),
y1 = Math.min(h, Math.round(box.y + box.height));
let diff = 0,
total = 0;
for (let y = y0; y < y1; y++)
for (let x = x0; x < x1; x++) {
const i = (y * w + x) * 4;
const la = 0.299 * da[i] + 0.587 * da[i + 1] + 0.114 * da[i + 2];
const lb = 0.299 * db[i] + 0.587 * db[i + 1] + 0.114 * db[i + 2];
if (Math.abs(la - lb) > 24) diff++;
total++;
}
return diff / total;
},
{ aB64: a.toString('base64'), bB64: b.toString('base64'), box },
);
}
const shot = (name: string) =>
page.screenshot({ path: `test-results/mobile-${name}.png`, scale: 'css' });
test.beforeAll(async ({ browser }: { browser: Browser }) => {
test.setTimeout(420_000); // cold load: 136 MB wasm download + compile
page = await browser.newPage({ ...devices['Pixel 7'], baseURL: FRONTEND_URL });
page.on('console', (m) => {
if (/Aborted\(|pageerror/i.test(m.text())) console.log(`[mobile-e2e console] ${m.text()}`);
});
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?mobile=1`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
await expect
.poll(() => page.title(), { timeout: 120000, intervals: [1000] })
.toMatch(/demo — PCB Editor/i);
// The full-screen loading overlays (boot + lib fat-load, both `inset-0 z-30`
// in WasmTool) must be GONE — the gesture tests diff screenshots, and an
// overlay screenshot diffs to zero (learned the hard way on a cold load).
await expect(page.locator('div.inset-0.z-30')).toHaveCount(0, { timeout: 180000 });
// let the first paint + fit-to-view and the chrome-hide poll settle
await page.waitForTimeout(4000);
});
test.afterAll(async () => {
await page?.close();
});
test('tap contract: a touch tap synthesizes a LEFT click, no phantom middle-drag', async () => {
const box = await getGlBox(page);
await page.evaluate(() => {
const c = document.getElementById('canvas')!;
const log: [string, number][] = ((window as unknown as { __mouseLog: [string, number][] }).__mouseLog = []);
for (const type of ['mousedown', 'mouseup'])
c.addEventListener(type, (e) => log.push([e.type, (e as MouseEvent).button]), true);
});
const p = { id: 1, x: box.x + box.width * 0.5, y: box.y + box.height * 0.5 };
await touch(page, 'touchstart', [p]);
await page.waitForTimeout(60);
await touch(page, 'touchend', [], [p]);
await page.waitForTimeout(300);
const log = await page.evaluate(
() => (window as unknown as { __mouseLog: [string, number][] }).__mouseLog,
);
expect(log, 'tap → left mousedown').toContainEqual(['mousedown', 0]);
expect(log, 'tap → left mouseup').toContainEqual(['mouseup', 0]);
expect(
log.filter(([, button]) => button === 1),
'a tap must not press the middle (pan) button',
).toHaveLength(0);
});
test('pinch: two-finger pinch-out zooms in, pinch-in restores', async () => {
// shim marker: mobile mode must have armed the canvas for touch
expect(
await page.evaluate(() => getComputedStyle(document.getElementById('canvas')!).touchAction),
'canvas touch-action none (gesture shim installed)',
).toBe('none');
const box = await getGlBox(page);
const centre = { x: box.x + box.width * 0.5, y: box.y + box.height * 0.45 };
const base = await shot('pinch-00-base');
await pinch(page, centre, 40, 140); // pinch OUT = zoom in
await page.waitForTimeout(500);
const zoomed = await shot('pinch-01-zoomed');
await pinch(page, centre, 140, 40); // pinch IN at the same centre = zoom back out
await page.waitForTimeout(500);
const restored = await shot('pinch-02-restored');
const dIn = await diffRatio(page, base, zoomed, box);
const dBack = await diffRatio(page, base, restored, box);
console.log(`[mobile pinch] dIn=${dIn.toFixed(3)} dBack=${dBack.toFixed(3)}`);
expect(dIn, 'pinch-out visibly zooms').toBeGreaterThan(0.006);
// zoom-to-cursor at a fixed centroid is invertible (same discriminator as
// zoom-cursor.spec.ts)
expect(dBack, 'pinch in+out returns near baseline').toBeLessThan(dIn / 3);
});
test('pan: one-finger drag translates the view (not a rubber-band select)', async () => {
const box = await getGlBox(page);
// start/end in the bottom-left margin: the old single-finger LEFT-drag would
// rubber-band an EMPTY region there (no selection highlight → no pixel change),
// so this fails RED before the shim and can't false-pass on item selection.
const from = { x: box.x + box.width * 0.15, y: box.y + box.height * 0.88 };
const to = { x: box.x + box.width * 0.55, y: box.y + box.height * 0.7 };
const base = await shot('pan-00-base');
await fingerDrag(page, from, to);
await page.waitForTimeout(500);
const panned = await shot('pan-01-panned');
await fingerDrag(page, to, from); // drag back — translation is invertible
await page.waitForTimeout(500);
const restored = await shot('pan-02-restored');
const dPan = await diffRatio(page, base, panned, box);
const dBack = await diffRatio(page, base, restored, box);
console.log(`[mobile pan] dPan=${dPan.toFixed(3)} dBack=${dBack.toFixed(3)}`);
expect(dPan, 'one-finger drag visibly pans the view').toBeGreaterThan(0.006);
expect(dBack, 'panning back returns near baseline').toBeLessThan(dPan / 3);
});
test('editor is canvas-only: no menubar, GL canvas fills the viewport', async () => {
await shot('chrome');
// The menubar is real DOM (.wx-menu-title per menu); AUI toolbars/panels are
// canvas islands, so the GL-canvas-fills-viewport check below is what proves
// THEY are gone (hidden AUI panes release their space to the center pane).
const menus = await page.evaluate(
() =>
Array.from(document.querySelectorAll('.wx-menu-title')).filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && getComputedStyle(el).display !== 'none';
}).length,
);
expect(menus, 'no visible menubar titles').toBe(0);
// the drawing canvas reclaims the whole viewport
const box = await getGlBox(page);
const vp = page.viewportSize()!;
expect(box.width, 'GL canvas width ≈ viewport').toBeGreaterThan(vp.width * 0.95);
expect(box.height, 'GL canvas height ≈ viewport').toBeGreaterThan(vp.height * 0.9);
// the shell's own persistent overlays are gone too
expect(await page.getByText(/console \(/).count(), 'console toggle hidden').toBe(0);
});

View file

@ -48,7 +48,7 @@ test.describe('web app — tool switching', () => {
test('eeschema → Switch to PCB Editor navigates to pcbnew', async ({ page }) => {
test.setTimeout(420000); // two full wasm boots
await page.goto('/p/demo/eeschema/demo.kicad_sch');
await page.goto('/default/projects/demo/demo.kicad_sch');
await waitForToolReady(page, /demo — Schematic Editor/i);
await switchTool(
@ -64,7 +64,7 @@ test.describe('web app — tool switching', () => {
test('pcbnew → Switch to Schematic Editor navigates to eeschema', async ({ page }) => {
test.setTimeout(420000);
await page.goto('/p/demo/pcbnew/demo.kicad_pcb');
await page.goto('/default/projects/demo/demo.kicad_pcb');
await waitForToolReady(page, /demo — PCB Editor/i);
await switchTool(