From e0c388101f42c5b1b73e338a82184a455d4a801b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:46:22 -0400 Subject: [PATCH] Web mirror: modified click at release point, every app clipboard write notified, NumLock-off numpad navigation --- examples/gallery/web.go | 1 + examples/gallery/web/index.html | 4 ++- examples/gallery/web_input.go | 23 +++++++++++-- examples/gallery/web_test.go | 59 +++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/examples/gallery/web.go b/examples/gallery/web.go index 26c424f..8180d6b 100644 --- a/examples/gallery/web.go +++ b/examples/gallery/web.go @@ -31,6 +31,7 @@ type mirror struct { urls int clip string copied bool + probes int cursor ui.Cursor down *input enc png.Encoder diff --git a/examples/gallery/web/index.html b/examples/gallery/web/index.html index 96ca311..9f5280c 100644 --- a/examples/gallery/web/index.html +++ b/examples/gallery/web/index.html @@ -154,7 +154,9 @@ keys.addEventListener("keydown", (e) => { if (cmd && commands[e.code] && !e.altKey) { send({ type: "command", text: e.code === "KeyZ" && e.shiftKey ? "redo" : commands[e.code] }); } else { - send({ type: "key", code: e.code, text: e.key.length === 1 ? e.key : "", mods: mods(e) }); + // NumLock off: numpad keys act as navigation keys, named by e.key. + const code = e.code.startsWith("Numpad") && e.key.length > 1 && e.key !== "Enter" ? e.key : e.code; + send({ type: "key", code, text: e.key.length === 1 ? e.key : "", mods: mods(e) }); } e.preventDefault(); }); diff --git a/examples/gallery/web_input.go b/examples/gallery/web_input.go index bd0456d..47230d6 100644 --- a/examples/gallery/web_input.go +++ b/examples/gallery/web_input.go @@ -36,7 +36,7 @@ func (m *mirror) serveInput(w http.ResponseWriter, r *http.Request) { return } m.mu.Lock() - err := m.apply(in) + err := m.applyWatchingClipboard(in) if err == nil { m.capture() } @@ -67,7 +67,8 @@ func (m *mirror) apply(in input) error { 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) + // The click lands where it is released, as a real press and release would. + m.tt.ClickAtWith(modifiers(d.Mods), in.X, in.Y) } else { slog.Info("gallery: modified drag ignored; tester clicks only", "mods", modifiers(d.Mods)) } @@ -124,3 +125,21 @@ func modifiers(m []bool) ui.Modifiers { } return out } + +// applyWatchingClipboard applies in and notes any clipboard write it caused, even of equal text. +func (m *mirror) applyWatchingClipboard(in input) error { + if in.Type == "paste" || in.Type == "command" && in.Text == "paste" { + return m.apply(in) + } + before := m.tt.Clipboard() + m.probes++ + probe := fmt.Sprintf("\x00mujica-mirror-probe-%d", m.probes) + m.tt.SetClipboard(probe) + err := m.apply(in) + if m.tt.Clipboard() == probe { + m.tt.SetClipboard(before) + } else { + m.copied = true + } + return err +} diff --git a/examples/gallery/web_test.go b/examples/gallery/web_test.go index 66e5b35..b8798f9 100644 --- a/examples/gallery/web_test.go +++ b/examples/gallery/web_test.go @@ -260,3 +260,62 @@ func TestMirrorForwardsEveryAnnouncement(t *testing.T) { t.Fatalf("announcements forwarded %d times, want 2", heard) } } + +func TestMirrorModifiedClickReleasedJustOutsideDoesNotClick(t *testing.T) { + clicks := 0 + var box ui.Rect + m, srv := mirrorWith(t, func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { + b := mujica.Button(c, "Seal", mujica.ButtonOptions{}) + box = b.Bounds() + if b.Clicked() { + clicks++ + } + }) + }) + m.mu.Lock() + m.tt.Frame() + r := box + m.mu.Unlock() + shift := []bool{true} + y := r.Y + r.H/2 + postInput(t, srv, input{Type: "down", X: r.X + r.W - 1, Y: y, Mods: shift}) + postInput(t, srv, input{Type: "up", X: r.X + r.W + 2, Y: y, Mods: shift}) + if clicks != 0 { + t.Fatalf("clicks %d after releasing 2 DIP outside", clicks) + } +} + +func TestMirrorNotifiesEveryCopyOfEqualText(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, "Copy", mujica.ButtonOptions{}).Clicked() { + c.WriteClipboard("snippet") + } + }) + }) + 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}) + } + copies := 0 + for len(ch) > 0 { + if bytes.Contains(<-ch, []byte(`"clipboard":"snippet"`)) { + copies++ + } + } + if copies != 2 { + t.Fatalf("copies forwarded %d, want 2", copies) + } + m.mu.Lock() + defer m.mu.Unlock() + if got := m.tt.Clipboard(); got != "snippet" { + t.Fatalf("clipboard %q", got) + } +}