diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 0a16854..6e9f2b2 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -9,8 +9,10 @@ # ui-guards mvvmlint (no direct widget-state writes) and bricolint (no # hand-drawn UI), each with a negative control proving it bites. # browser-proof the BUILT page (Hugo + the wasm) in headless Chrome, light -# and dark forced, 1x and 2x: real clicks and keys, asserted on -# the app's state AND on canvas pixels. A missing browser is a +# and dark forced, 1x and 2x, plus a phone (390x844 at 3x, +# mobile, touch): real clicks and keys, asserted on the app's +# state AND on canvas pixels, and the canvas must fit the +# viewport width. A missing browser is a # failure here, never a skip. name: playground diff --git a/layouts/_default/playground.html b/layouts/_default/playground.html index de242c3..16b95d3 100644 --- a/layouts/_default/playground.html +++ b/layouts/_default/playground.html @@ -14,7 +14,10 @@ html,body{height:100%} body.pg{display:flex;flex-direction:column;overflow:hidden} .pg .nav{position:relative} - .stage{position:relative;flex:1;min-height:0;background:var(--bg)} + /* The stage scrolls when the app needs more height than the window has: + stacked on a phone, the wasm host makes the canvas as tall as + State.MinHeight says. */ + .stage{position:relative;flex:1;min-height:0;background:var(--bg);overflow-x:hidden;overflow-y:auto} #nd-canvas{display:block;width:100%;height:100%;opacity:0;transition:opacity .2s} .stage.ready #nd-canvas{opacity:1} #pg-load{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--slate);font-size:14px} diff --git a/playground/app.go b/playground/app.go index 5c79b9b..24629ce 100644 --- a/playground/app.go +++ b/playground/app.go @@ -89,9 +89,10 @@ type State struct { // The box tree. The boxes are the toolkit's own types, not wrappers: the // focus system walks them to find the widget that receives the keyboard. - root, left, right *toolkit.VBox - body toolkit.Widget // an HBox, or a VBox when narrow - formBtns, codeBar, top *toolkit.HBox + root, left, right *toolkit.VBox + body toolkit.Widget // an HBox, or a VBox when narrow + formBtns, codeBar *toolkit.HBox + top toolkit.Widget // an HBox, or a VBox (title above the presets) when narrow dropdowns []*toolkit.DropDown @@ -207,10 +208,24 @@ func (s *State) layout() { sc := toolkit.Scaled narrow := s.w < sc(narrowW) - s.top = hbox(sc(gap)) - s.top.AddFlex(s.title, 1) - s.top.AddFixed(s.presets, sc(260)) - s.top.AddFixed(s.reset, sc(80)) + // On a narrow screen the presets and reset take the whole width, so the + // title gets a row of its own: beside them a 390 px phone left it 10 px. + topH := sc(barH) + if narrow { + pick := hbox(sc(gap)) + pick.AddFlex(s.presets, 1) + pick.AddFixed(s.reset, sc(80)) + top := vbox(sc(gap)) + top.AddFixed(s.title, sc(barH)) + top.AddFixed(pick, sc(barH)) + s.top, topH = top, sc(2*barH+gap) + } else { + top := hbox(sc(gap)) + top.AddFlex(s.title, 1) + top.AddFixed(s.presets, sc(260)) + top.AddFixed(s.reset, sc(80)) + s.top = top + } s.formBtns = hbox(sc(gap)) s.formBtns.AddFlex(s.apply, 1) @@ -256,7 +271,7 @@ func (s *State) layout() { s.body = b } s.root = vbox(sc(gap)) - s.root.AddFixed(s.top, sc(barH)) + s.root.AddFixed(s.top, topH) s.root.AddFlex(s.body, 1) s.root.AddFixed(s.status, sc(labelH)) m := sc(margin) @@ -270,6 +285,34 @@ func (s *State) Resize(w, h int) { s.layout() } +// minTableH is the least height, in logical pixels, the stacked layout leaves +// the table: a header and a few rows. +const minTableH = 6 * rowH + +// MinHeight is the least height, in device pixels, the scene needs at width +// w. Side by side it is 0: every widget fits whatever the window gives. Stacked, +// the form, the summary and the code panel have fixed heights and the table +// takes what is left, so a short window left it nothing: 0 px on a 390×844 +// phone. The host makes the canvas at least this tall and lets the page +// scroll. +// +// It is measured, not computed: in the stacked layout the table is the only +// part that grows, so the layout is laid out once at a tall height and the +// table's share is replaced by minTableH. Formula and layout cannot drift. +func (s *State) MinHeight(w int) int { + if w >= toolkit.Scaled(narrowW) { + return 0 + } + ow, oh := s.w, s.h + const tall = 1 << 15 + s.w, s.h = max(w, 1), tall + s.layout() + need := tall - s.table.Bounds().H + toolkit.Scaled(minTableH) + s.w, s.h = ow, oh + s.layout() + return need +} + // Size is the scene size in device pixels. func (s *State) Size() (int, int) { return s.w, s.h } diff --git a/playground/app_test.go b/playground/app_test.go index 7ef6da5..df8ac2b 100644 --- a/playground/app_test.go +++ b/playground/app_test.go @@ -187,6 +187,43 @@ func TestResizeStacksWhenNarrow(t *testing.T) { } } +// TestMinHeightOnAPhone is the layout the browser proof's phone case found +// broken: 390×844 CSS pixels at 3× gave the table 0 px, ran the code panel +// under the status line and clipped the title to "gc". +func TestMinHeightOnAPhone(t *testing.T) { + s := newTestState(t, false) + if got := s.MinHeight(testW); got != 0 { + t.Errorf("side by side, MinHeight = %d, want 0", got) + } + SetupText(3) + defer SetupText(1) + w, h := 390*3, 779*3 // the canvas under the nav bar, in device pixels + s.Resize(w, h) + before := s.Rects() + need := s.MinHeight(w) + if need <= h { + t.Fatalf("MinHeight(%d) = %d: the 779 px a phone leaves were enough, yet they gave the table %d px", + w, need, before["table"].H) + } + if gw, gh := s.Size(); gw != w || gh != h || s.table.Bounds() != before["table"] { + t.Error("MinHeight changed the scene it measured") + } + s.Resize(w, need) + if got, want := s.table.Bounds().H, toolkit.Scaled(minTableH); got != want { + t.Errorf("at MinHeight the table is %d px, want %d", got, want) + } + code, status := s.code.Bounds(), s.status.Bounds() + if code.Y+code.H > status.Y { + t.Errorf("the code panel (to %d) runs under the status line (from %d)", code.Y+code.H, status.Y) + } + if status.Y+status.H > need { + t.Errorf("the status line (to %d) runs off the canvas (%d)", status.Y+status.H, need) + } + if tw, pw := s.title.Bounds().W, s.presets.Bounds().W; tw < w/2 || pw < w/2 { + t.Errorf("narrow top bar: title %d px, presets %d px, of %d", tw, pw, w) + } +} + func TestSetTheme(t *testing.T) { s := newTestState(t, false) s.SetTheme(true) diff --git a/playground/browser_test.go b/playground/browser_test.go index 414487c..19256c3 100644 --- a/playground/browser_test.go +++ b/playground/browser_test.go @@ -21,7 +21,8 @@ import ( // TestBrowserProof builds the page exactly as the deploy does — the wasm from // this module, wasm_exec.js from the same toolchain, then Hugo — serves it, // and runs browsertest/driver.cjs against it in headless Chrome, light and -// dark with the scheme forced, at 1x and 2x. See the driver for what it +// dark with the scheme forced, at 1x and 2x (both schemes), and on a phone +// viewport (390x844 CSS px at 3x, mobile, touch). See the driver for what it // checks. It skips when Chrome, node, puppeteer-core or Hugo is missing, // unless PLAYGROUND_REQUIRE_BROWSER=1 (CI), where a skip would be a pass that // proved nothing. diff --git a/playground/browsertest/driver.cjs b/playground/browsertest/driver.cjs index a1d03e5..f3cea65 100644 --- a/playground/browsertest/driver.cjs +++ b/playground/browsertest/driver.cjs @@ -26,7 +26,13 @@ function check(name, ok, detail) { const BG = { light: [0xff, 0xff, 0xff], dark: [0x0b, 0x0e, 0x14] }; const VIRIDIS_ENDS = [[0x44, 0x01, 0x54], [0xfd, 0xe7, 0x25]]; -async function run(scheme, dpr) { +// The cases: the forced scheme, the device pixel ratio and the viewport. A +// phone is set with page.setViewport (isMobile, hasTouch), because headless +// Chrome's --window-size cannot go that narrow. +const DESKTOP = { width: 1400, height: 900 }; +const PHONE = { width: 390, height: 844, isMobile: true, hasTouch: true, name: "phone" }; + +async function run(scheme, dpr, vp = DESKTOP) { const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ["--no-sandbox", "--disable-gpu", "--blink-settings=preferredColorScheme=" + (scheme === "dark" ? 0 : 1)], @@ -36,20 +42,32 @@ async function run(scheme, dpr) { const errors = []; page.on("pageerror", (e) => errors.push(String(e))); page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); - await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: dpr }); + await page.setViewport({ width: vp.width, height: vp.height, deviceScaleFactor: dpr, isMobile: !!vp.isMobile, hasTouch: !!vp.hasTouch }); await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: scheme }]); await page.evaluateOnNewDocument(() => { try { localStorage.removeItem("theme"); } catch (e) {} }); await page.goto(url, { waitUntil: "load" }); await page.waitForFunction(() => globalThis.ndarrayPlaygroundReady === true && document.querySelector(".stage").classList.contains("ready"), { timeout: 60000 }); - const tag = scheme + "@" + dpr + "x: "; + const tag = scheme + "@" + dpr + "x" + (vp.name ? " " + vp.name : "") + ": "; + const shot = (what) => path.join(captureDir, what + "-" + scheme + "-" + dpr + "x" + (vp.name ? "-" + vp.name : "") + ".png"); const debug = async () => JSON.parse(await page.evaluate(() => ndarrayDebug())); - const canvasBox = await page.evaluate(() => { const r = document.getElementById("nd-canvas").getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }); + const boxOf = async () => page.evaluate(() => { const r = document.getElementById("nd-canvas").getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }); + let canvasBox = await boxOf(); + // On a phone the canvas is taller than the window and the stage scrolls + // (State.MinHeight): scroll a widget into view before clicking it, as a + // visitor would, then re-read where the canvas now is. + const reveal = async (r) => { + await page.evaluate((y, h) => { + const s = document.querySelector(".stage"); + if (y < s.scrollTop || y + h > s.scrollTop + s.clientHeight) s.scrollTop = Math.max(0, y + h / 2 - s.clientHeight / 2); + }, r.y, r.h); + canvasBox = await boxOf(); + }; const rects = async () => JSON.parse(await page.evaluate(() => ndarrayRects())); const center = (r) => [canvasBox.x + r.x + r.w / 2, canvasBox.y + r.y + r.h / 2]; const clickAt = async (x, y) => { await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); }; - const click = async (name) => { const r = (await rects())[name]; const [x, y] = center(r); await clickAt(x, y); }; + const click = async (name) => { const r = (await rects())[name]; await reveal(r); const [x, y] = center(r); await clickAt(x, y); }; // Pixels of the canvas inside a CSS rectangle (relative to the canvas). const pixels = async (r) => page.evaluate((r, dpr) => { const c = document.getElementById("nd-canvas"); @@ -67,6 +85,7 @@ async function run(scheme, dpr) { check(tag + "option " + option + " exists in " + dd, idx >= 0, list.join(",")); await click(dd); const pop = (await rects())[dd + "Popover"]; + await reveal(pop); const visible = Math.min(list.length, 12); const rowH = pop.h / visible; // The popover keeps its scroll between openings: wheel it back to the @@ -90,6 +109,29 @@ async function run(scheme, dpr) { await page.keyboard.type(text); }; + // 0. A capture of the opened page first, so a failure below still leaves + // the picture of what was on screen. + await page.screenshot({ path: shot("loaded") }); + // 0b. The viewport is the one asked for, and the canvas fits it: no + // horizontal overflow, and something other than the background is + // painted across it. + const fit = await page.evaluate(() => { + const c = document.getElementById("nd-canvas"); + const r = c.getBoundingClientRect(); + return { iw: innerWidth, dpr: devicePixelRatio, left: r.left, right: r.right, cw: c.width, + scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }; + }); + // Measured against the width ASKED for: on a mobile viewport Chrome widens + // the layout viewport to fit overflowing content, so innerWidth alone + // would follow the overflow it is meant to catch. + check(tag + "the canvas fits the viewport width", fit.left >= 0 && fit.right <= vp.width + 0.5, fit.left + ".." + fit.right + " of " + vp.width); + check(tag + "the page does not scroll horizontally", fit.scroll <= vp.width && fit.client <= vp.width, "scrollWidth " + fit.scroll + ", clientWidth " + fit.client + " of " + vp.width); + check(tag + "the viewport is " + vp.width + " CSS px at " + dpr + "x", fit.iw === vp.width && fit.dpr === dpr, fit.iw + " @ " + fit.dpr); + check(tag + "the canvas backing store matches the device pixels", Math.abs(fit.cw - Math.round((fit.right - fit.left) * dpr)) <= dpr, fit.cw); + const all = await pixels({ x: 0, y: 0, w: canvasBox.w, h: Math.min(canvasBox.h, 400) }); + const painted = all.length / 4 - count(all, BG[scheme], 6); + check(tag + "the app rendered something (non-background pixels)", painted > 1000, painted + " px"); + // 1. The page opened on the README example, in the forced theme. let d = await debug(); check(tag + "opens on the README example (7 steps)", d.steps.length === 7, d.steps.length); @@ -103,10 +145,11 @@ async function run(scheme, dpr) { const heat = count(tp, VIRIDIS_ENDS[0], 12) + count(tp, VIRIDIS_ENDS[1], 12); check(tag + "the table is painted as a heatmap (viridis end colours on screen)", heat > 200, heat + " px"); check(tag + "the memory line names the layout", /strides \(3, 1\) elements/.test(d.memory), d.memory); - await page.screenshot({ path: path.join(captureDir, "open-" + scheme + "-" + dpr + "x.png") }); + await page.screenshot({ path: shot("open") }); // 2. Click a step of the pipeline: b, the reshaped view. const st = (await rects()).steps; + await reveal(st); // Probe down the list until the click lands on row b (index 1): the test // does not assume the list's row height. for (let y = st.y + 4; y < st.y + st.h; y += 4) { @@ -141,11 +184,12 @@ async function run(scheme, dpr) { const ep = await pixels((await rects()).error); const red = scheme === "dark" ? [0xf7, 0x8b, 0x8b] : [0xb4, 0x23, 0x18]; check(tag + "the error is on screen in red", count(ep, red, 40) > 20, count(ep, red, 40) + " px"); - await page.screenshot({ path: path.join(captureDir, "error-" + scheme + "-" + dpr + "x.png") }); + await page.screenshot({ path: shot("error") }); // 5. The code panel: Go program, then the NumPy equivalent. check(tag + "the Go program is shown", d.code.startsWith("package main") && d.code.includes("ndarray.Arange(0, 6, 1)"), d.code.slice(0, 40)); const lang = (await rects()).lang; + await reveal(lang); await clickAt(canvasBox.x + lang.x + lang.w * 0.75, canvasBox.y + lang.y + lang.h / 2); d = await debug(); check(tag + "the NumPy tab shows NumPy", d.code.startsWith("import numpy as np") && d.code.includes("b.T"), d.code.slice(0, 40)); @@ -203,6 +247,8 @@ async function run(scheme, dpr) { await run("light", 1); await run("dark", 1); await run("light", 2); + await run("dark", 2); + await run("light", 3, PHONE); console.log("RESULT " + JSON.stringify({ ok: true, results })); } catch (e) { console.log("RESULT " + JSON.stringify({ ok: false, error: String(e.message || e), results })); diff --git a/playground/cmd/playground-wasm/main.go b/playground/cmd/playground-wasm/main.go index 6912d30..854bdf9 100644 --- a/playground/cmd/playground-wasm/main.go +++ b/playground/cmd/playground-wasm/main.go @@ -145,7 +145,21 @@ func main() { return changed }) + // grow makes the canvas at least as tall as the scene needs at its width + // (State.MinHeight, non-zero only when the columns stack) and lets the + // stage scroll; otherwise the canvas fills the stage, as before. + stage := canvas.Get("parentElement") + grow := func() { + d := dpr() + need := int(float64(state.MinHeight(int(canvas.Get("clientWidth").Float()*d+0.5)))/d + 0.999) + if need > stage.Get("clientHeight").Int() { + canvas.Get("style").Set("height", strconv.Itoa(need)+"px") + } else { + canvas.Get("style").Set("height", "") + } + } refit := func() { + grow() nw, nh, nd := deviceSize() ow, oh := state.Size() if nw == ow && nh == oh && nd == curDPR { @@ -186,6 +200,7 @@ func main() { return string(b) })) + refit() // a narrow window may need a taller canvas than the stage render() js.Global().Set("ndarrayPlaygroundReady", true) select {} diff --git a/playground/go.mod b/playground/go.mod index fdfcb70..20a37a9 100644 --- a/playground/go.mod +++ b/playground/go.mod @@ -3,7 +3,7 @@ module github.com/go-ndarray/go-ndarray.github.io/playground go 1.27.2 require ( - github.com/go-ndarray/ndarray v0.9.1 + github.com/go-ndarray/ndarray v0.11.0 github.com/go-widgets/mvvm v0.13.0 github.com/go-widgets/mvvmtk v0.14.1 github.com/go-widgets/painter v0.15.0 diff --git a/playground/go.sum b/playground/go.sum index 89efcf2..e0b1364 100644 --- a/playground/go.sum +++ b/playground/go.sum @@ -22,8 +22,8 @@ github.com/go-images/jpeg2000 v0.1.0 h1:SiFAZ6NGvGLU+VmxKji74RiSQDeK6TyjUZr1wjlx github.com/go-images/jpeg2000 v0.1.0/go.mod h1:MwSmrbiP6RK7eLphoNyzY60BAXWOD+8LVdBYDPIBrN4= github.com/go-images/png v0.1.0 h1:OI4yHotS0lA75sLnG2FTL2Vooh4PWpVWEidre1msSe0= github.com/go-images/png v0.1.0/go.mod h1:V9vaEbr2nrZGorUWn4xv9F24Yuu1/qLVtmailM9GBVo= -github.com/go-ndarray/ndarray v0.9.1 h1:1115kGKWYoqmZArPVXGgsUK9T8NvLoWMhQfjIwNUEvc= -github.com/go-ndarray/ndarray v0.9.1/go.mod h1:URXtNNKKw05MdWCqSCOmiw4zE38HQl95PztFSwgeVdk= +github.com/go-ndarray/ndarray v0.11.0 h1:HMW7rAI25wSNNgVxOFTYCz48vW/wIBRT8IN17DNZHQ8= +github.com/go-ndarray/ndarray v0.11.0/go.mod h1:ZcgNCBVLXt1FxeZjQWI5Z+k0SSWAT0/wR8slfat/gRA= github.com/go-opentype/fonts v0.10.0 h1:9f7ik795SlBEJjz6iqBE/ZV2PaTauAj537MMWw9t4OQ= github.com/go-opentype/fonts v0.10.0/go.mod h1:b+o8v1zDZ6B3GixA398jGILAh09+WwfyoYz52zKe7DA= github.com/go-opentype/opentype v0.13.0 h1:q9K7IGDfwMP/ik51ZkwiYf6jbaqPfocvAIuojetd0UQ=