diff --git a/examples/gallery/main.go b/examples/gallery/main.go index 7dfd576..0b0656a 100644 --- a/examples/gallery/main.go +++ b/examples/gallery/main.go @@ -1,7 +1,4 @@ -// Gallery tours every MujicaUI component in a native MyGo window. -// -// go run ./examples/gallery native window -// go run ./examples/gallery -web :8080 browser mirror +// Gallery tours MujicaUI natively, or mirrored to a browser with -web. package main import ( diff --git a/examples/gallery/web.go b/examples/gallery/web.go index c2e0ea9..26c424f 100644 --- a/examples/gallery/web.go +++ b/examples/gallery/web.go @@ -20,18 +20,20 @@ var indexHTML []byte // mirror runs the gallery view headless and streams its frames. type mirror struct { - mu sync.Mutex - tt *ui.Tester - frame []byte - seq int - hash uint64 - seed maphash.Seed - subs map[chan []byte]struct{} - wake chan struct{} - urls int - told int - clip string - enc png.Encoder + mu sync.Mutex + tt *ui.Tester + frame []byte + seq int + hash uint64 + seed maphash.Seed + subs map[chan []byte]struct{} + wake chan struct{} + urls int + clip string + copied bool + cursor ui.Cursor + down *input + enc png.Encoder } func serveWeb(addr string, view func(c *ui.Context)) error { @@ -54,7 +56,9 @@ func newMirror(view func(c *ui.Context)) (*mirror, http.Handler) { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(indexHTML) + if _, err := w.Write(indexHTML); err != nil { + slog.Warn("gallery: web page write failed", "err", err) + } }) mux.HandleFunc("GET /frame.png", m.serveFrame) mux.HandleFunc("GET /events", m.serveEvents) @@ -62,20 +66,27 @@ func newMirror(view func(c *ui.Context)) (*mirror, http.Handler) { return m, mux } -// loop draws frames while the picture keeps changing, then sleeps. +// loop frames at 33 ms while changing, else polls at 200 ms for timers. func (m *mirror) loop() { - for range m.wake { - for still := 0; still < 6; { - time.Sleep(33 * time.Millisecond) - m.mu.Lock() - m.tt.Frame() - changed := m.capture() - m.mu.Unlock() - if changed { - still = 0 - } else { - still++ - } + still := 0 + for { + wait := 33 * time.Millisecond + if still > 30 { + wait = 200 * time.Millisecond + } + select { + case <-m.wake: + still = 0 + case <-time.After(wait): + } + m.mu.Lock() + m.tt.Frame() + changed := m.capture() + m.mu.Unlock() + if changed { + still = 0 + } else { + still++ } } } @@ -87,12 +98,12 @@ func (m *mirror) kick() { } } -// capture encodes the frame and notifies browsers; the caller holds mu. +// capture encodes a changed frame and notifies browsers; the caller holds mu. func (m *mirror) capture() bool { img := m.tt.Image() h := maphash.Bytes(m.seed, img.Pix) notice := m.notices() - if h == m.hash && notice == nil { + if h == m.hash && len(notice) == 0 { return false } if h != m.hash { @@ -103,11 +114,8 @@ func (m *mirror) capture() bool { m.frame, m.hash = b.Bytes(), h m.seq++ } - if notice == nil { - notice = map[string]any{} - } notice["seq"] = m.seq - notice["cursor"] = cursorCSS(m.tt.Cursor()) + notice["cursor"] = cursorCSS(m.cursor) msg, err := json.Marshal(notice) if err != nil { panic(err) @@ -122,20 +130,21 @@ func (m *mirror) capture() bool { return true } -// notices gathers opened URLs, announcements and clipboard changes. +// notices gathers opened URLs, announcements, clipboard writes and cursor changes. func (m *mirror) notices() map[string]any { n := map[string]any{} if urls := m.tt.OpenedURLs(); len(urls) > m.urls { n["open"], m.urls = urls[m.urls:], len(urls) } - if told := m.tt.Announcements(); len(told) > m.told { - n["announce"], m.told = told[m.told:], len(told) + if told := m.tt.Announcements(); len(told) > 0 { + n["announce"] = told } - if clip := m.tt.Clipboard(); clip != m.clip { - n["clipboard"], m.clip = clip, clip + if clip := m.tt.Clipboard(); clip != m.clip || m.copied { + n["clipboard"], m.clip, m.copied = clip, clip, false } - if len(n) == 0 { - return nil + if c := m.tt.Cursor(); c != m.cursor { + m.cursor = c + n["cursorChanged"] = true } return n } @@ -146,7 +155,9 @@ func (m *mirror) serveFrame(w http.ResponseWriter, r *http.Request) { m.mu.Unlock() w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "no-store") - w.Write(frame) + if _, err := w.Write(frame); err != nil { + slog.Warn("gallery: web frame write failed", "err", err) + } } func (m *mirror) serveEvents(w http.ResponseWriter, r *http.Request) { @@ -169,122 +180,21 @@ func (m *mirror) serveEvents(w http.ResponseWriter, r *http.Request) { }() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") - fmt.Fprintf(w, "data: %s\n\n", first) + if _, err := fmt.Fprintf(w, "data: %s\n\n", first); err != nil { + slog.Warn("gallery: web events write failed", "remote", r.RemoteAddr, "err", err) + return + } flusher.Flush() for { select { case msg := <-ch: - fmt.Fprintf(w, "data: %s\n\n", msg) + if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil { + slog.Warn("gallery: web events write failed", "remote", r.RemoteAddr, "err", err) + return + } flusher.Flush() case <-r.Context().Done(): return } } } - -// input is one browser event. -type input struct { - Type string `json:"type"` - X float32 `json:"x"` - Y float32 `json:"y"` - DX float32 `json:"dx"` - DY float32 `json:"dy"` - Button int `json:"button"` - Code string `json:"code"` - Text string `json:"text"` - Caret int `json:"caret"` - Mods []bool `json:"mods"` - W int `json:"w"` - H int `json:"h"` - Scale float32 `json:"scale"` - Dark bool `json:"dark"` - Motion bool `json:"reduceMotion"` - More bool `json:"moreContrast"` -} - -func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { - var in input - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - m.mu.Lock() - err := m.apply(in) - if err == nil { - m.capture() - } - m.mu.Unlock() - if err != nil { - slog.Warn("gallery: web input rejected", "type", in.Type, "err", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - m.kick() - w.WriteHeader(http.StatusNoContent) -} - -// apply sends one event to the tester; the caller holds mu. -func (m *mirror) apply(in input) error { - mods := modifiers(in.Mods) - switch in.Type { - case "down": - if in.Button == 2 { - m.tt.RightClickAt(in.X, in.Y) - } else if mods != 0 { - m.tt.ClickAtWith(mods, in.X, in.Y) - } else { - m.tt.Press(in.X, in.Y) - } - case "up": - if in.Button == 0 && mods == 0 { - m.tt.Release(in.X, in.Y) - } - case "move": - m.tt.Move(in.X, in.Y) - case "wheel": - m.tt.Scroll(in.X, in.Y, in.DX, in.DY) - case "key": - k, ok := keyCodes[in.Code] - if !ok { - return fmt.Errorf("unmapped key code %q", in.Code) - } - if in.Text != "" && mods&^ui.Shift == 0 { - m.tt.TypeKey(mods, k, in.Text) - } else { - m.tt.Key(mods, k) - } - case "text": - m.tt.Type(in.Text) - case "compose": - m.tt.Compose(in.Text, in.Caret) - case "paste": - m.tt.SetClipboard(in.Text) - m.clip = in.Text - m.tt.Command("paste") - case "command": - m.tt.Command(in.Text) - case "resize": - if in.W < 320 || in.H < 240 || in.Scale <= 0 { - return fmt.Errorf("bad size %dx%d@%v", in.W, in.H, in.Scale) - } - m.tt.SetSize(in.W, in.H) - m.tt.SetScale(in.Scale) - m.tt.SetDark(in.Dark) - m.tt.SetPreferences(ui.Preferences{ReduceMotion: in.Motion, HighContrast: in.More, TextScale: 1}) - case "focus": - m.tt.SetFocused(in.Text == "in") - default: - return fmt.Errorf("unknown input type %q", in.Type) - } - return nil -} - -func modifiers(m []bool) ui.Modifiers { - var out ui.Modifiers - for i, mod := range []ui.Modifiers{ui.Shift, ui.Ctrl, ui.Alt, ui.Super} { - if i < len(m) && m[i] { - out |= mod - } - } - return out -} diff --git a/examples/gallery/web/index.html b/examples/gallery/web/index.html index 98572c4..96ca311 100644 --- a/examples/gallery/web/index.html +++ b/examples/gallery/web/index.html @@ -139,7 +139,7 @@ canvas.addEventListener("pointermove", (e) => { }); canvas.addEventListener("wheel", (e) => { const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? canvas.clientHeight : 1; - send({ type: "wheel", dx: -e.deltaX * k, dy: -e.deltaY * k, ...at(e) }); e.preventDefault(); + send({ type: "wheel", dx: e.deltaX * k, dy: e.deltaY * k, ...at(e) }); e.preventDefault(); }, { passive: false }); canvas.addEventListener("contextmenu", (e) => e.preventDefault()); canvas.addEventListener("focus", () => keys.focus({ preventScroll: true })); diff --git a/examples/gallery/web_input.go b/examples/gallery/web_input.go new file mode 100644 index 0000000..bd0456d --- /dev/null +++ b/examples/gallery/web_input.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/egoist/mygo/ui" +) + +// input is one browser event. +type input struct { + Type string `json:"type"` + X float32 `json:"x"` + Y float32 `json:"y"` + DX float32 `json:"dx"` + DY float32 `json:"dy"` + Button int `json:"button"` + Code string `json:"code"` + Text string `json:"text"` + Caret int `json:"caret"` + Mods []bool `json:"mods"` + W int `json:"w"` + H int `json:"h"` + Scale float32 `json:"scale"` + Dark bool `json:"dark"` + Motion bool `json:"reduceMotion"` + More bool `json:"moreContrast"` +} + +func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { + var in input + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m.mu.Lock() + err := m.apply(in) + if err == nil { + m.capture() + } + m.mu.Unlock() + if err != nil { + slog.Warn("gallery: web input rejected", "type", in.Type, "err", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m.kick() + w.WriteHeader(http.StatusNoContent) +} + +// apply sends one event to the tester; the caller holds mu. +func (m *mirror) apply(in input) error { + mods := modifiers(in.Mods) + switch in.Type { + case "down": + switch { + case in.Button == 2: + m.tt.RightClickAt(in.X, in.Y) + case mods != 0: + m.down = &in + default: + m.tt.Press(in.X, in.Y) + } + case "up": + if d := m.down; d != nil { + m.down = nil + if dx, dy := in.X-d.X, in.Y-d.Y; dx*dx+dy*dy <= 16 { + m.tt.ClickAtWith(modifiers(d.Mods), d.X, d.Y) + } else { + slog.Info("gallery: modified drag ignored; tester clicks only", "mods", modifiers(d.Mods)) + } + } else if in.Button == 0 { + m.tt.Release(in.X, in.Y) + } + case "move": + m.tt.Move(in.X, in.Y) + case "wheel": + m.tt.Scroll(in.X, in.Y, in.DX, in.DY) + case "key": + k, ok := keyCodes[in.Code] + if !ok { + return fmt.Errorf("unmapped key code %q", in.Code) + } + if in.Text != "" && mods&^(ui.Shift|ui.Alt) == 0 { + m.tt.TypeKey(mods, k, in.Text) + } else { + m.tt.Key(mods, k) + } + case "text": + m.tt.Type(in.Text) + case "compose": + m.tt.Compose(in.Text, in.Caret) + case "paste": + m.tt.SetClipboard(in.Text) + m.clip = in.Text + m.tt.Command("paste") + case "command": + m.tt.Command(in.Text) + m.copied = in.Text == "copy" || in.Text == "cut" + case "resize": + if in.W < 320 || in.H < 240 || in.Scale <= 0 { + return fmt.Errorf("bad size %dx%d@%v", in.W, in.H, in.Scale) + } + m.tt.SetSize(in.W, in.H) + m.tt.SetScale(in.Scale) + m.tt.SetDark(in.Dark) + m.tt.SetPreferences(ui.Preferences{ReduceMotion: in.Motion, HighContrast: in.More, TextScale: 1}) + case "focus": + m.tt.SetFocused(in.Text == "in") + default: + return fmt.Errorf("unknown input type %q", in.Type) + } + return nil +} + +func modifiers(m []bool) ui.Modifiers { + var out ui.Modifiers + for i, mod := range []ui.Modifiers{ui.Shift, ui.Ctrl, ui.Alt, ui.Super} { + if i < len(m) && m[i] { + out |= mod + } + } + return out +} diff --git a/examples/gallery/web_keys.go b/examples/gallery/web_keys.go index ae987ad..d57b520 100644 --- a/examples/gallery/web_keys.go +++ b/examples/gallery/web_keys.go @@ -4,6 +4,10 @@ import "github.com/egoist/mygo/ui" // keyCodes maps KeyboardEvent.code to MyGo keys. var keyCodes = map[string]ui.Key{ + "Numpad0": ui.Key0, "Numpad1": ui.Key1, "Numpad2": ui.Key2, "Numpad3": ui.Key3, "Numpad4": ui.Key4, + "Numpad5": ui.Key5, "Numpad6": ui.Key6, "Numpad7": ui.Key7, "Numpad8": ui.Key8, "Numpad9": ui.Key9, + "NumpadDecimal": ui.KeyPeriod, "NumpadSubtract": ui.KeyMinus, "NumpadAdd": ui.KeyEqual, + "NumpadMultiply": ui.Key8, "NumpadDivide": ui.KeySlash, "NumpadEqual": ui.KeyEqual, "NumpadComma": ui.KeyComma, "Enter": ui.KeyEnter, "NumpadEnter": ui.KeyEnter, "Escape": ui.KeyEscape, diff --git a/examples/gallery/web_test.go b/examples/gallery/web_test.go index d3d61c5..66e5b35 100644 --- a/examples/gallery/web_test.go +++ b/examples/gallery/web_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/egoist/mygo/ui" @@ -133,3 +134,129 @@ func TestMirrorRejectsBadInput(t *testing.T) { t.Fatalf("bad json: %d", res.StatusCode) } } + +// mirrorWith serves view through a mirror for one test. +func mirrorWith(t *testing.T, view func(c *ui.Context)) (*mirror, *httptest.Server) { + m, h := newMirror(view) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return m, srv +} + +func postInput(t *testing.T, srv *httptest.Server, in input) { + t.Helper() + body, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + res, err := http.Post(srv.URL+"/input", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusNoContent { + t.Fatalf("%s: %d", in.Type, res.StatusCode) + } +} + +func TestMirrorTimersFireWithoutInput(t *testing.T) { + start := time.Now() + var late bool + m, _ := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + late = c.Now().Sub(start) > 300*time.Millisecond + if !late { + c.After(350 * time.Millisecond) + } + }) + time.Sleep(900 * time.Millisecond) + m.mu.Lock() + defer m.mu.Unlock() + if !late { + t.Fatal("the view was not rebuilt after its timer with no input") + } +} + +func TestMirrorModifiedClickNeedsReleaseInPlace(t *testing.T) { + clicks := 0 + _, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).Children(func() { + if mujica.Button(c, "Seal", mujica.ButtonOptions{}).Clicked() { + clicks++ + } + }) + }) + shift := []bool{true} + postInput(t, srv, input{Type: "down", X: 40, Y: 35, Mods: shift}) + postInput(t, srv, input{Type: "up", X: 400, Y: 400, Mods: shift}) + postInput(t, srv, input{Type: "down", X: 40, Y: 35, Mods: shift}) + postInput(t, srv, input{Type: "up", X: 41, Y: 35, Mods: shift}) + if clicks != 1 { + t.Fatalf("clicks %d, want 1 (released elsewhere must not click)", clicks) + } +} + +func TestMirrorForwardsRepeatedCopy(t *testing.T) { + m, _ := mirrorWith(t, func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) }) + ch := make(chan []byte, 8) + m.mu.Lock() + m.subs[ch] = struct{}{} + notices := 0 + for range 2 { + m.tt.SetClipboard("same") + m.copied = true + if m.capture() { + notices++ + } + } + m.mu.Unlock() + if notices != 2 { + t.Fatalf("repeated copy of the same text notified %d times, want 2", notices) + } + for range 2 { + msg := <-ch + if !bytes.Contains(msg, []byte(`"clipboard":"same"`)) { + t.Fatalf("notice %s", msg) + } + } +} + +func TestMirrorMapsNumpad(t *testing.T) { + r := newMirrorRig(t) + r.post(input{Type: "key", Code: "Tab"}) + if code := r.post(input{Type: "key", Code: "Numpad7", Text: "7"}); code != http.StatusNoContent { + t.Fatalf("numpad: %d", code) + } + if got := r.value(); got != "7" { + t.Fatalf("value %q", got) + } +} + +func TestMirrorForwardsEveryAnnouncement(t *testing.T) { + m, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).Children(func() { + if mujica.Button(c, "Ring", mujica.ButtonOptions{}).Clicked() { + c.Announce("bell rung") + } + }) + }) + ch := make(chan []byte, 64) + m.mu.Lock() + m.subs[ch] = struct{}{} + m.mu.Unlock() + for range 2 { + postInput(t, srv, input{Type: "down", X: 40, Y: 35}) + postInput(t, srv, input{Type: "up", X: 40, Y: 35}) + } + heard := 0 + for len(ch) > 0 { + if bytes.Contains(<-ch, []byte("bell rung")) { + heard++ + } + } + if heard != 2 { + t.Fatalf("announcements forwarded %d times, want 2", heard) + } +}