Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/playground.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion layouts/_default/playground.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
59 changes: 51 additions & 8 deletions playground/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 }

Expand Down
37 changes: 37 additions & 0 deletions playground/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion playground/browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 53 additions & 7 deletions playground/browsertest/driver.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand All @@ -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");
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 }));
Expand Down
15 changes: 15 additions & 0 deletions playground/cmd/playground-wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {}
Expand Down
2 changes: 1 addition & 1 deletion playground/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions playground/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading