Files
8bitMyGoUI/cmd/web/main.go
T
ZacharyZhang-NYandClaude Opus 5.5 03e1f91706 Upgrade MyGo to v0.5.5
Elements are now pass-scoped value handles: tests that read bounds
after a frame bind a ui.Handle. pixel tells frames apart by the build
pass's root, since the Tester clock no longer moves on its own.

Fix the regressions the upgrade brought:
- Clipboard reads in input handlers go through c.Services().
- MyGo notes an overlay's opener after building it, so AutoFocus
  inside lost it; pixel.AutoFocus focuses through a Handle, and
  archtest bans the raw call. Dialogs hand the focus back again.
- Elements around a press stay hovered: Tooltip tells a press-given
  focus by focus arriving under the pointer.
- The web host moves the Tester clock with real time, at most 500 ms
  a call, and gives components the real clock.

The gallery's source index also accepts CRLF checkouts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 01:31:08 -04:00

218 lines
5.8 KiB
Go

//go:build js && wasm
// Command web runs the gallery in a browser canvas.
package main
import (
"fmt"
"log/slog"
"runtime/debug"
"syscall/js"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/gallery"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// dot is DIPs per dot; each dot is one pixel.
const dot = gallery.Dot
// catchUp caps the replay after a hidden tab.
const catchUp = 500 * time.Millisecond
type host struct {
app *gallery.App
tt *ui.Tester
w, h int
frames int
opened int
over bool
dead bool
began time.Time
// wall is when the Tester clock last caught up.
wall time.Time
}
func main() {
h := &host{app: gallery.New("/")}
// Components keep real time while the Tester clock lags.
h.app.SetClock(time.Now)
api := js.Global().Get("Object").New()
export := func(name string, fn func(args []js.Value) any) {
api.Set(name, js.FuncOf(func(_ js.Value, args []js.Value) any { return h.guard(name, args, fn) }))
}
export("start", h.start)
export("resize", func(a []js.Value) any { h.size(a[0].Int(), a[1].Int()); return h.out() })
export("pointer", h.pointer)
export("wheel", func(a []js.Value) any {
h.tt.Scroll(dip(a[0]), dip(a[1]), dip(a[2]), dip(a[3]))
return h.out()
})
export("key", h.key)
export("text", func(a []js.Value) any { h.tt.Type(a[0].String()); return h.out() })
export("compose", func(a []js.Value) any { h.tt.Compose(a[0].String(), a[1].Int()); return h.out() })
export("paste", func(a []js.Value) any {
h.tt.SetClipboard(a[0].String())
h.tt.Command("paste")
h.tt.SetClipboard("")
return h.out()
})
export("focus", func(a []js.Value) any { h.tt.SetFocused(a[0].Bool()); return h.out() })
export("go", func(a []js.Value) any { h.app.Go(a[0].String()); h.tt.Frame(); return h.out() })
export("frame", func([]js.Value) any { h.tt.Frame(); return h.out() })
export("stats", h.stats)
export("texts", func([]js.Value) any { return toJS(h.tt.Texts()) })
export("find", func(a []js.Value) any {
r, ok := h.tt.Find(a[0].String())
if !ok {
return nil
}
return []any{float64(r.X / dot), float64(r.Y / dot), float64(r.W / dot), float64(r.H / dot)}
})
js.Global().Set("__8bitGo", api)
slog.Info("8bit: wasm ready")
select {}
}
// guard runs a JS call; a panic shows GAME OVER.
func (h *host) guard(name string, args []js.Value, fn func([]js.Value) any) (res any) {
if h.dead {
return nil
}
defer func() {
if r := recover(); r != nil {
slog.Error("8bit: crash", "call", name, "panic", r, "stack", string(debug.Stack()))
res = h.crash(fmt.Sprint(r))
}
}()
// An empty clipboard after a call means nothing was copied.
if h.tt != nil {
h.tt.SetClipboard("")
h.sync()
}
pixel.ResetDue()
h.began = time.Now()
return fn(args)
}
// crash shows GAME OVER; a second panic ends the page.
func (h *host) crash(why string) (res any) {
if h.over || h.w == 0 {
h.dead = true
js.Global().Call("__8bitCrash", why)
return nil
}
h.over = true
defer func() {
if r := recover(); r != nil {
res = h.crash(fmt.Sprintf("%s\n\nGAME OVER failed: %v", why, r))
}
}()
h.tt = ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.WinterNight, Dot: dot, Now: time.Now})
ui.Column(c).Fill().Center().Children(func() {
if feedback.ErrorState(c, why, feedback.ErrorStateOptions{}).Continue {
js.Global().Get("location").Call("reload")
}
})
}, h.w*dot, h.h*dot)
h.tt.SetScale(1.0 / dot)
h.wall = time.Now()
return h.out()
}
// start sizes the window and opens any deep link.
func (h *host) start(a []js.Value) any {
h.w, h.h = a[0].Int(), a[1].Int()
if path := a[2].String(); path != "" {
h.app.Go(path)
}
h.tt = ui.NewTester(h.app.View, h.w*dot, h.h*dot)
h.tt.SetScale(1.0 / dot)
h.wall = time.Now()
slog.Info("8bit: start", "dots", fmt.Sprintf("%dx%d", h.w, h.h), "path", h.app.Path())
return h.out()
}
// sync moves the Tester clock on with the real one.
func (h *host) sync() {
now := time.Now()
h.tt.Advance(min(now.Sub(h.wall), catchUp))
h.wall = now
}
func (h *host) size(w, ht int) {
h.w, h.h = w, ht
h.tt.SetSize(w*dot, ht*dot)
slog.Info("8bit: resize", "dots", fmt.Sprintf("%dx%d", w, ht))
}
func (h *host) pointer(a []js.Value) any {
x, y := dip(a[1]), dip(a[2])
switch kind := a[0].String(); kind {
case "move":
h.tt.Move(x, y)
case "down":
h.tt.Press(x, y)
case "up":
h.tt.Release(x, y)
case "right":
h.tt.RightClickAt(x, y)
default:
panic("web: unknown pointer kind " + kind)
}
return h.out()
}
func (h *host) key(a []js.Value) any {
k, ok := keys[a[0].String()]
if !ok {
return nil
}
h.tt.Key(ui.Modifiers(a[1].Int()), k)
return h.out()
}
// out returns the frame and requests for the page.
func (h *host) out() any {
img := h.tt.Image()
h.frames++
pix := js.Global().Get("Uint8ClampedArray").New(len(img.Pix))
js.CopyBytesToJS(pix, img.Pix)
// took is Go's share; the page adds its blit.
took := float64(time.Since(h.began).Microseconds()) / 1000
res := map[string]any{"w": img.Rect.Dx(), "h": img.Rect.Dy(), "pix": pix, "path": h.app.Path(), "cursor": int(h.tt.Cursor()), "took": took}
if due := pixel.Due(); !due.IsZero() {
// Asked on the Tester clock, which may lag.
res["due"] = float64(due.Sub(h.tt.Now()).Milliseconds())
}
if r, ok := h.tt.TextCaret(); ok {
res["caret"] = []any{float64(r.X / dot), float64(r.Y / dot), float64(r.H / dot)}
}
if c := h.tt.Clipboard(); c != "" {
res["copied"] = c
}
if urls := h.tt.OpenedURLs(); len(urls) > h.opened {
res["open"] = toJS(urls[h.opened:])
h.opened = len(urls)
}
return res
}
func (h *host) stats([]js.Value) any {
return map[string]any{"frames": h.frames, "w": h.w, "h": h.h, "path": h.app.Path()}
}
func dip(v js.Value) float32 { return float32(v.Float() * dot) }
func toJS(s []string) any {
out := make([]any, len(s))
for i, v := range s {
out[i] = v
}
return out
}