diff --git a/galleryapp.go b/galleryapp.go index 59692ae..fe61517 100644 --- a/galleryapp.go +++ b/galleryapp.go @@ -4,7 +4,7 @@ package main -import "github.com/go-widgets/gallery/internal/webcanvas" +import "github.com/go-widgets/webcanvas" // galleryApp adapts the gallery's *state to the shared [webcanvas.App] // interface, so the DOM harness in internal/webcanvas can drive it without any diff --git a/galleryapp_test.go b/galleryapp_test.go index bf403ca..61e71a1 100644 --- a/galleryapp_test.go +++ b/galleryapp_test.go @@ -7,7 +7,7 @@ package main import ( "testing" - "github.com/go-widgets/gallery/internal/webcanvas" + "github.com/go-widgets/webcanvas" ) // TestGalleryAppForwards drives every galleryApp adapter method and checks it diff --git a/go.mod b/go.mod index 4bb780d..437c185 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-widgets/mvvm v0.8.0 github.com/go-widgets/painter v0.11.0 github.com/go-widgets/toolkit v0.248.0 + github.com/go-widgets/webcanvas v0.1.0 ) require ( diff --git a/go.sum b/go.sum index e42c7a6..6de5d68 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/go-widgets/painter v0.11.0 h1:xsj4zTz8B43rOZnWrx7ZsaUBMgJyvgaE/Pq2Ffc github.com/go-widgets/painter v0.11.0/go.mod h1:IPRLqdUJuJX8sfuHeYLZCzjoLvA0ApbOlyIAVmguJDQ= github.com/go-widgets/toolkit v0.248.0 h1:ueb/+0dd8wLjKwuKb20Xmo01pBD6GUP0xsc1EY4/fjA= github.com/go-widgets/toolkit v0.248.0/go.mod h1:eBfiAf9RI6uqmW6NoyFIBjNJgVmpjgVtVuJYS/8N5kI= +github.com/go-widgets/webcanvas v0.1.0 h1:fSGllghHlFZSC7CBb1oetj/PKR/S3ds/piDrpr0Kmg0= +github.com/go-widgets/webcanvas v0.1.0/go.mod h1:UAoPu9dO6ZzcFFwQbVeW4Jm4X67XFKVJab9A6qWwsMM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/internal/webcanvas/app.go b/internal/webcanvas/app.go deleted file mode 100644 index 8089351..0000000 --- a/internal/webcanvas/app.go +++ /dev/null @@ -1,198 +0,0 @@ -// Package webcanvas is the gallery's shared browser harness: the generic -// glue that blits a toolkit RGBA framebuffer into a and routes DOM -// pointer / keyboard events into a widget scene. It carries no widget logic -// of its own — every demo in this module (the widget gallery, the isometric -// diagram editor, …) implements the small [App] interface and hands it to -// [Run], so the DOM plumbing lives in exactly one place instead of being -// copy-pasted per demo. -// -// The interface is defined here, in a tag-less file, so a native (non-wasm) -// build and its tests can name the type and assert that a scene satisfies it; -// the DOM implementation of [Run] lives in run_js.go behind a `js && wasm` -// build tag, so it drops out of the native build entirely. -package webcanvas - -import ( - "fmt" - "os" - "runtime/debug" -) - -// App is a self-contained canvas scene. A host (the wasm [Run] loop, or a -// native test) owns the pixel buffer and the event source; the App owns the -// widgets. Every coordinate is in canvas-local pixels (top-left origin), the -// same space [Run] derives from the pointer event's position within the -// canvas' bounding rectangle. -// -// Each event method reports whether the scene changed and therefore needs a -// repaint, so the host can skip a redraw when nothing moved. Draw is expected -// to fully paint the buffer (it is never given a dirty region). -type App interface { - // Size returns the fixed pixel dimensions of the scene's surface. The host - // sizes the canvas and allocates the framebuffer from it; it is read once, - // at startup, so it must not change over the App's life. - Size() (w, h int) - - // Draw paints the whole scene into buf, a width*height*4 RGBA byte slice - // laid out exactly like an image.RGBA's Pix (row-major, 4 bytes/pixel). - Draw(buf []byte) - - // Click delivers a primary (left) button press at (x, y). It begins a - // gesture — a selection, a drag, a placement — that later Move/Release - // calls advance and commit. - Click(x, y int) bool - - // Move delivers a pointer move at (x, y). While a Click gesture is in - // flight it is a drag tick; otherwise it is a hover. - Move(x, y int) bool - - // Release delivers the primary button release at (x, y), committing any - // in-flight gesture Click began. - Release(x, y int) bool - - // Context delivers a secondary (right) button press at (x, y), typically - // opening a context menu. The host suppresses the browser's own menu. - Context(x, y int) bool - - // Char delivers a single printable character typed with no Ctrl/Meta/Alt - // modifier — text input for a focused field. - Char(s string) bool - - // KeyDown delivers a named key (Enter, Backspace, Delete, Arrow*, …) or a - // modified key press, routed to the focused widget. - KeyDown(s string) bool -} - -// Ticker is an optional companion to [App]: a scene that needs a steady -// animation clock (a toast countdown, a blinking caret) implements it, and -// [Run] installs a 60-Hz timer that calls Tick and repaints. A scene with no -// time-varying state omits it, and Run installs no timer, so it never repaints -// except in response to input. -type Ticker interface { - // Tick advances one animation frame. Run repaints after every Tick. - Tick() -} - -// Animator is an optional companion to [App]: a scene with time-varying content -// driven by a REAL wall clock (procedurally animated icons, say) implements it, -// and [Run] installs a requestAnimationFrame loop that hands it the elapsed dt -// between frames — in seconds — through AnimationStep. Unlike [Ticker] (a fixed -// cadence that always repaints), an Animator advances by the true frame delta and -// reports whether the frame changed anything, so Run repaints only when a pixel -// actually moved. A scene that implements neither installs no clock and repaints -// on input alone. The phase-advance logic lives in the scene (natively testable); -// only the rAF wiring is browser-side. -type Animator interface { - // AnimationStep advances the scene's animation by dt seconds of real elapsed - // time and reports whether the scene now needs a repaint. - AnimationStep(dt float64) (repaint bool) -} - -// Resizer is an optional companion to [App]: a scene that can adapt its layout to -// a NEW surface size implements it, and [Run] installs a window "resize" listener -// (and fits the canvas to the viewport once at startup) that re-sizes the canvas -// and framebuffer, calls Resize, and repaints. A scene that omits it keeps the -// fixed [App.Size] forever — the pre-resize behaviour every existing demo relies -// on — so Run never installs the listener and the surface never changes. -// -// Resize is handed the target pixel size (the canvas' laid-out client box) and -// returns the size it will actually render at: a scene may clamp to a sane -// minimum, and the host allocates the framebuffer from the RETURNED size, so the -// scene and the buffer can never disagree. The relayout logic lives in the scene -// (natively testable); only the DOM resize wiring is browser-side. -type Resizer interface { - // Resize relays out the scene to fit w×h device pixels and returns the pixel - // size (rw, rh) it will render at — the size the host sizes the canvas and - // framebuffer to. - Resize(w, h int) (rw, rh int) -} - -// Scroller is an optional companion to [App]: a scene with a scrollable region -// (a docked list, an icon palette, an overflowing panel) implements it, and [Run] -// installs a "wheel" listener that translates the browser's WheelEvent into -// toolkit scroll ROWS and routes them — with the canvas-local pointer position, so -// the scene can hit-test which region the wheel is over — through Scroll. A scene -// that omits it installs no wheel listener, so the page keeps its default wheel -// behaviour and every existing demo (the widget gallery) is unchanged. -// -// dx / dy are the horizontal / vertical scroll amounts in toolkit ROWS (already -// normalised from the event's deltaMode by [scrollRows]): positive dy scrolls -// down / forward, positive dx scrolls right. A scene typically forwards them to -// the widget under (x, y) as a [toolkit.Event] of kind EventScroll (Delta = dy, -// DeltaX = dx), which scrollable widgets clamp at both ends. Scroll reports -// whether the scene changed and therefore needs a repaint. -type Scroller interface { - // Scroll delivers a wheel / trackpad scroll of dy vertical and dx horizontal - // ROWS at canvas-local (x, y), and reports whether the scene needs a repaint. - Scroll(x, y, dx, dy int) (repaint bool) -} - -// A browser WheelEvent reports its delta in one of three units, named by its -// deltaMode: pixels (0, the default a mouse notch and a trackpad use), lines (1, -// some Firefox setups) or pages (2). [scrollRows] normalises each to toolkit rows. -const ( - deltaModeLine = 1 - deltaModePage = 2 - - // wheelLinePixels is the nominal CSS px one scroll ROW spans when a wheel event - // reports its delta in pixels — a typical text-line height. A one-notch wheel - // (~100 px in Chrome) then moves a couple of rows; a slow trackpad glide fewer. - wheelLinePixels = 40 - // wheelPageRows is how many rows one page-mode (deltaMode 2) unit scrolls. - wheelPageRows = 10 -) - -// scrollRows converts one axis of a browser WheelEvent — delta, in the unit named -// by mode — into toolkit scroll ROWS, the unit [toolkit.Event.Delta] carries. A -// pixel delta (mode 0, the default) is divided by a nominal line height; a line -// delta (mode 1) passes straight through; a page delta (mode 2) is multiplied out. -// The sign is preserved and any nonzero delta yields at least one row in its -// direction, so a small trackpad nudge still scrolls rather than rounding away to -// nothing; a zero delta is zero rows. -func scrollRows(delta float64, mode int) int { - if delta == 0 { - return 0 - } - var rows float64 - switch mode { - case deltaModeLine: - rows = delta - case deltaModePage: - rows = delta * wheelPageRows - default: // pixel mode (0) and any unknown mode - rows = delta / wheelLinePixels - } - if n := int(rows); n != 0 { // int() truncates toward zero - return n - } - if delta > 0 { - return 1 - } - return -1 -} - -// PanicReporter logs a handler panic the [guard] net recovered. The wasm host -// swaps in a console.error reporter carrying the JS stack; the default writes to -// standard error so a native run — and the recover test — still surfaces it. It -// is a package var, not a const, precisely so run_js.go can replace it. -var PanicReporter = func(r any, stack []byte) { - fmt.Fprintf(os.Stderr, "webcanvas: recovered panic in a handler: %v\n%s\n", r, stack) -} - -// guard runs fn under a recover net: a panic escaping a single event handler (or -// the paint it triggers) is caught, reported through [PanicReporter] with its -// stack, and swallowed — so ONE bad frame logs an error instead of tearing the -// whole wasm instance off the page, and the next dispatch still runs. It reports -// whether fn panicked so a test can prove the net fired. It is deliberately a -// last-resort net IN ADDITION to the toolkit's own per-widget hardening, never a -// substitute for fixing the upstream bug. -func guard(fn func()) (panicked bool) { - defer func() { - if r := recover(); r != nil { - panicked = true - PanicReporter(r, debug.Stack()) - } - }() - fn() - return false -} diff --git a/internal/webcanvas/app_test.go b/internal/webcanvas/app_test.go deleted file mode 100644 index 5762369..0000000 --- a/internal/webcanvas/app_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2026 the go-widgets/gallery authors. All rights reserved. -// Use of this source code is governed by a BSD-3-Clause license that can be -// found in the LICENSE file at the root of this repository. - -package webcanvas - -import ( - "io" - "os" - "strings" - "testing" -) - -// flakyApp is a minimal [App] whose Click panics on demand while Move keeps a -// live counter, so a test can prove the recover net catches the panic AND that a -// later dispatch still runs against the same instance. -type flakyApp struct { - moves int - panicOnClick bool -} - -func (a *flakyApp) Size() (int, int) { return 4, 4 } -func (a *flakyApp) Draw([]byte) {} -func (a *flakyApp) Click(int, int) bool { //nolint:revive // panics by design - if a.panicOnClick { - panic("boom in Click") - } - return true -} -func (a *flakyApp) Move(int, int) bool { a.moves++; return true } -func (a *flakyApp) Release(int, int) bool { return false } -func (a *flakyApp) Context(int, int) bool { return false } -func (a *flakyApp) Char(string) bool { return false } -func (a *flakyApp) KeyDown(string) bool { return false } - -// flakyApp is a genuine App (the recover net wraps exactly this dispatch surface). -var _ App = (*flakyApp)(nil) - -// captureStderr redirects os.Stderr for the duration of fn and returns everything -// written to it — how the test reads the default reporter's output. -func captureStderr(fn func()) string { - old := os.Stderr - r, w, _ := os.Pipe() - os.Stderr = w - fn() - _ = w.Close() - os.Stderr = old - out, _ := io.ReadAll(r) - return string(out) -} - -// TestGuardRecoversHandlerPanicAndKeepsDispatching is the recover-net proof: a -// handler that panics is caught by the net (not propagated to the caller — the -// wasm Run loop) and reported with its stack, and the VERY NEXT dispatch still -// executes against the same live App. This is the native stand-in for the browser -// callbacks, each of which Run wraps in exactly this guard. -func TestGuardRecoversHandlerPanicAndKeepsDispatching(t *testing.T) { - var got any - var sawStack bool - orig := PanicReporter - PanicReporter = func(r any, stack []byte) { got = r; sawStack = len(stack) > 0 } - defer func() { PanicReporter = orig }() - - app := &flakyApp{panicOnClick: true} - - // A panicking Click is caught by the net, not propagated. - if !guard(func() { app.Click(1, 1) }) { - t.Fatal("guard did not report the handler panic") - } - if got != "boom in Click" { - t.Fatalf("reporter saw %v, want %q", got, "boom in Click") - } - if !sawStack { - t.Fatal("reporter received an empty stack") - } - - // The next dispatch still runs — the instance survived the panic. - if guard(func() { app.Move(2, 2) }) { - t.Fatal("a clean handler must not report a panic") - } - if app.moves != 1 { - t.Fatalf("the post-panic dispatch did not run (moves = %d, want 1)", app.moves) - } -} - -// TestGuardCleanHandlerReportsNoPanic pins the non-panicking path: guard returns -// false and runs fn to completion. -func TestGuardCleanHandlerReportsNoPanic(t *testing.T) { - ran := false - if guard(func() { ran = true }) { - t.Fatal("guard reported a panic for a clean handler") - } - if !ran { - t.Fatal("guard did not run the handler") - } -} - -// TestDefaultPanicReporterWritesMessageAndStack proves the built-in reporter (the -// one a native run uses) writes the recovered value and a stack to standard error. -func TestDefaultPanicReporterWritesMessageAndStack(t *testing.T) { - out := captureStderr(func() { - if !guard(func() { panic("kaboom") }) { - t.Error("expected the panic to be recovered") - } - }) - if !strings.Contains(out, "kaboom") { - t.Errorf("reporter output missing the panic value: %q", out) - } - if !strings.Contains(out, "recovered panic") { - t.Errorf("reporter output missing its prefix: %q", out) - } - if !strings.Contains(out, "webcanvas") { - t.Errorf("reporter output missing a stack frame: %q", out) - } -} diff --git a/internal/webcanvas/run_js.go b/internal/webcanvas/run_js.go deleted file mode 100644 index 7258c2f..0000000 --- a/internal/webcanvas/run_js.go +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) 2026 the go-widgets/gallery authors. All rights reserved. -// Use of this source code is governed by a BSD-3-Clause license that can be -// found in the LICENSE file at the root of this repository. - -//go:build js && wasm - -package webcanvas - -import "syscall/js" - -// Run boots app onto the whose id is screenID and never returns: it -// sizes the canvas from [App.Size], blits [App.Draw] into it, and wires the DOM -// event listeners that drive [App.Click]/Move/Release/Context/Char/KeyDown. -// When app also implements [Ticker], a 60-Hz timer repaints on every tick; when -// it implements [Animator], a requestAnimationFrame loop drives it; when it -// implements [Resizer], the canvas tracks the viewport and the scene relayouts on -// every window resize. -// -// Every DOM callback runs inside [guard], a recover net: a panic escaping one -// handler (or the paint it triggers) is logged through [PanicReporter] and -// swallowed, so a single bad frame never blanks the whole instance and the next -// event still dispatches. It is a last-resort net on top of the toolkit's own -// per-widget hardening, not a substitute for it. -// -// This is the module's ONE canvas host. It carries no widget knowledge, so -// every demo — the widget gallery, the isometric editor — reuses it unchanged -// by supplying an App. A missing canvas is reported and Run returns, leaving -// the page intact (the host shell shows its own boot-error text). -func Run(screenID string, app App) { - doc := js.Global().Get("document") - canvas := doc.Call("getElementById", screenID) - if canvas.IsUndefined() || canvas.IsNull() { - println("webcanvas: no #" + screenID + " canvas in the host page") - return - } - // w, h, and the framebuffer trio are mutable: a [Resizer] scene reallocates - // them on every viewport change, and coords/render read the live values. - w, h := app.Size() - canvas.Set("width", w) - canvas.Set("height", h) - ctx := canvas.Call("getContext", "2d") - - local := make([]byte, 4*w*h) - imageData := ctx.Call("createImageData", w, h) - dst := imageData.Get("data") - - render := func() { - app.Draw(local) - js.CopyBytesToJS(dst, local) - ctx.Call("putImageData", imageData, 0, 0) - } - render() - // Signal the host page that the first frame is painted, so a loading - // placeholder can reveal the canvas. Purely additive — it changes no - // rendering, so every existing demo behaves exactly as before. - js.Global().Set("webcanvasReady", true) - - // clientX/Y → canvas-local pixel coords via the bounding-rect, scaling for - // any CSS resize (the canvas keeps its intrinsic w*h while displayed at an - // arbitrary size), so the mapping stays exact when the page shrinks it. - coords := func(ev js.Value) (int, int) { - rect := canvas.Call("getBoundingClientRect") - sx := rect.Get("width").Float() / float64(w) - sy := rect.Get("height").Float() / float64(h) - x := int((ev.Get("clientX").Float() - rect.Get("left").Float()) / sx) - y := int((ev.Get("clientY").Float() - rect.Get("top").Float()) / sy) - return x, y - } - // listen wires a pointer event whose handler takes canvas-local coords and - // reports whether to repaint. The whole body (coords → handle → render) runs - // under the guard net, so a panic anywhere in it is logged, not fatal. - listen := func(name string, handle func(x, y int) bool) { - cb := js.FuncOf(func(_ js.Value, args []js.Value) any { - guard(func() { - if len(args) == 0 { - return - } - x, y := coords(args[0]) - if handle(x, y) { - render() - } - }) - return nil - }) - canvas.Call("addEventListener", name, cb) - } - // Left-button press only: a right press is the context menu's business. - canvas.Call("addEventListener", "mousedown", js.FuncOf(func(_ js.Value, args []js.Value) any { - guard(func() { - if len(args) == 0 || args[0].Get("button").Int() != 0 { - return - } - x, y := coords(args[0]) - if app.Click(x, y) { - render() - } - }) - return nil - })) - listen("mousemove", app.Move) - listen("mouseup", app.Release) - // Right-click opens the context menu; suppress the browser's own menu. - canvas.Call("addEventListener", "contextmenu", js.FuncOf(func(_ js.Value, args []js.Value) any { - guard(func() { - if len(args) == 0 { - return - } - args[0].Call("preventDefault") - x, y := coords(args[0]) - if app.Context(x, y) { - render() - } - }) - return nil - })) - // Keyboard on the window (a is not focusable). A single printable - // rune with no Ctrl/Meta/Alt is text (Char); every named or modified key is - // a KeyDown. - js.Global().Call("addEventListener", "keydown", js.FuncOf(func(_ js.Value, args []js.Value) any { - guard(func() { - if len(args) == 0 { - return - } - ev := args[0] - key := ev.Get("key").String() - var changed bool - if len([]rune(key)) == 1 && !ev.Get("ctrlKey").Bool() && !ev.Get("metaKey").Bool() && !ev.Get("altKey").Bool() { - changed = app.Char(key) - } else { - changed = app.KeyDown(key) - } - if changed { - ev.Call("preventDefault") - render() - } - }) - return nil - })) - - // Optional wheel scrolling: a [Scroller] scene gets the browser's wheel routed - // to the widget under the pointer as toolkit scroll ROWS (deltaX/deltaY - // normalised for the event's deltaMode). preventDefault stops the page itself - // from scrolling under the canvas, so the wheel reaches the scene's scrollable - // region instead of the document. A scene that does not implement Scroller - // installs no listener, so the page's default wheel handling — and every - // existing demo — is untouched. - if sc, ok := app.(Scroller); ok { - wheel := js.FuncOf(func(_ js.Value, args []js.Value) any { - guard(func() { - if len(args) == 0 { - return - } - ev := args[0] - ev.Call("preventDefault") - x, y := coords(ev) - mode := ev.Get("deltaMode").Int() - dx := scrollRows(ev.Get("deltaX").Float(), mode) - dy := scrollRows(ev.Get("deltaY").Float(), mode) - if sc.Scroll(x, y, dx, dy) { - render() - } - }) - return nil - }) - // passive:false is required for preventDefault to take effect on a wheel - // listener (browsers treat wheel as passive by default). - canvas.Call("addEventListener", "wheel", wheel, map[string]any{"passive": false}) - } - - // Optional viewport tracking: a [Resizer] scene fills the browser window and - // relayouts on every resize. resize reads the canvas' laid-out client box, - // asks the scene to relayout to it (which returns the size it will render at), - // reallocates the framebuffer trio to match, and repaints. A scene that does - // not implement Resizer installs no listener and keeps its fixed Size forever - // (the gallery's behaviour is unchanged). - if rz, ok := app.(Resizer); ok { - resize := func() { - cw := canvas.Get("clientWidth").Int() - ch := canvas.Get("clientHeight").Int() - if cw <= 0 || ch <= 0 { - return - } - w, h = rz.Resize(cw, ch) - canvas.Set("width", w) - canvas.Set("height", h) - local = make([]byte, 4*w*h) - imageData = ctx.Call("createImageData", w, h) - dst = imageData.Get("data") - render() - } - js.Global().Call("addEventListener", "resize", js.FuncOf(func(_ js.Value, _ []js.Value) any { - guard(resize) - return nil - })) - // Fit the canvas to the viewport once, now, so the app opens full-page - // instead of at its intrinsic Size(). - guard(resize) - } - - // Optional fixed-cadence clock: only scenes with time-varying state ask for it. - if t, ok := app.(Ticker); ok { - tick := js.FuncOf(func(_ js.Value, _ []js.Value) any { - guard(func() { - t.Tick() - render() - }) - return nil - }) - js.Global().Call("setInterval", tick, 16) - } - - // Optional real-clock animation via requestAnimationFrame: hand the scene the - // true elapsed dt (seconds) between frames and repaint only when it reports a - // visible change, so an idle scene costs nothing beyond the rAF callback. - if a, ok := app.(Animator); ok { - var prevMS float64 - var raf js.Func - raf = js.FuncOf(func(_ js.Value, args []js.Value) any { - guard(func() { - nowMS := args[0].Float() - if prevMS != 0 && a.AnimationStep((nowMS-prevMS)/1000) { - render() - } - prevMS = nowMS - }) - js.Global().Call("requestAnimationFrame", raf) - return nil - }) - js.Global().Call("requestAnimationFrame", raf) - } - - // Park forever so the callbacks live. - select {} -} diff --git a/internal/webcanvas/scroll_test.go b/internal/webcanvas/scroll_test.go deleted file mode 100644 index 5b79715..0000000 --- a/internal/webcanvas/scroll_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2026 the go-widgets/gallery authors. All rights reserved. -// Use of this source code is governed by a BSD-3-Clause license that can be -// found in the LICENSE file at the root of this repository. - -package webcanvas - -import "testing" - -// scrollApp is a minimal [Scroller] (and [App]) that records the last scroll it -// received, so a test can assert the harness would route a wheel to it. -type scrollApp struct { - flakyApp - lastX, lastY, lastDX, lastDY int - scrolls int -} - -func (a *scrollApp) Scroll(x, y, dx, dy int) bool { - a.lastX, a.lastY, a.lastDX, a.lastDY = x, y, dx, dy - a.scrolls++ - return dx != 0 || dy != 0 -} - -// A scrollApp is both an App and a Scroller — exactly what the wheel wiring keys -// off with a type assertion. -var ( - _ App = (*scrollApp)(nil) - _ Scroller = (*scrollApp)(nil) -) - -// TestScrollRowsPerMode pins the WheelEvent-delta → toolkit-rows conversion across -// every deltaMode, both signs, the sub-row rounding floor and the zero case. -func TestScrollRowsPerMode(t *testing.T) { - cases := []struct { - name string - delta float64 - mode int - want int - }{ - {"zero is no rows", 0, 0, 0}, - {"pixel notch down rounds to rows", 100, 0, 2}, // 100/40 = 2.5 -> 2 - {"pixel notch up rounds to rows", -100, 0, -2}, // -2.5 -> -2 - {"tiny pixel down floors to one row", 8, 0, 1}, // 0.2 -> +1 - {"tiny pixel up floors to one row", -8, 0, -1}, // -0.2 -> -1 - {"line mode passes through", 3, deltaModeLine, 3}, - {"line mode negative", -2, deltaModeLine, -2}, - {"sub-line floors to one row", 0.4, deltaModeLine, 1}, - {"page mode multiplies out", 1, deltaModePage, wheelPageRows}, - {"page mode negative", -2, deltaModePage, -2 * wheelPageRows}, - {"unknown mode treated as pixels", 80, 7, 2}, // 80/40 = 2 - } - for _, c := range cases { - if got := scrollRows(c.delta, c.mode); got != c.want { - t.Errorf("%s: scrollRows(%v, %d) = %d, want %d", c.name, c.delta, c.mode, got, c.want) - } - } -} - -// TestScrollerContract exercises a Scroller through the same call shape the wheel -// listener uses (rows already converted), proving the interface an app opts into -// is satisfiable and reports repaint on a real delta. -func TestScrollerContract(t *testing.T) { - var app App = &scrollApp{} - sc, ok := app.(Scroller) - if !ok { - t.Fatal("scrollApp does not satisfy Scroller") - } - if !sc.Scroll(10, 20, 0, scrollRows(120, 0)) { - t.Fatal("a nonzero scroll should request a repaint") - } - if sc.Scroll(10, 20, 0, 0) { - t.Fatal("a zero scroll should not request a repaint") - } - got := app.(*scrollApp) - if got.lastX != 10 || got.lastY != 20 || got.lastDY != 0 || got.scrolls != 2 { - t.Fatalf("Scroll recorded (x=%d,y=%d,dy=%d,n=%d), want (10,20,0,2)", got.lastX, got.lastY, got.lastDY, got.scrolls) - } -} diff --git a/iso/main.go b/iso/main.go index 429dd07..da30988 100644 --- a/iso/main.go +++ b/iso/main.go @@ -8,7 +8,7 @@ package main -import "github.com/go-widgets/gallery/internal/webcanvas" +import "github.com/go-widgets/webcanvas" func main() { webcanvas.Run("screen", newIsoScene()) diff --git a/main.go b/main.go index ea112fd..268ded2 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ package main -import "github.com/go-widgets/gallery/internal/webcanvas" +import "github.com/go-widgets/webcanvas" func main() { webcanvas.Run("screen", newGalleryApp())