From d0082ba04774edfa8c2f61101324bb80843b7a25 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:05:37 -0400 Subject: [PATCH 1/5] Gallery test: wait for the visible overlay; no password in failure output --- examples/gallery/composite_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/gallery/composite_test.go b/examples/gallery/composite_test.go index 1526d8e..101805c 100644 --- a/examples/gallery/composite_test.go +++ b/examples/gallery/composite_test.go @@ -111,11 +111,11 @@ func TestCompositeFormInvalidThenValid(t *testing.T) { click(t, tt, "I accept the covenant of the masquerade") if s.name != "Mortis" || s.password != "hunter22secret" || s.debut.Format("2006-01-02") != "2026-12-24" || !slices.Equal(s.tags, []string{"guitar"}) || !s.terms { - t.Fatalf("values %q %q %v %q %v", s.name, s.password, s.debut, s.tags, s.terms) + t.Fatalf("values %q, password ok %v, %v %q %v", s.name, s.password == "hunter22secret", s.debut, s.tags, s.terms) } click(t, tt, "Submit entry") - await(tt, s.st.Submitting) // the name check finishes first - if !s.st.Submitting() || !tt.HasText("Submitting…") { + await(tt, func() bool { return tt.HasText("Submitting…") }) // the name check finishes first + if !s.st.Submitting() { t.Fatalf("no loading overlay in %q", tt.Texts()) } click(t, tt, "Submit entry") // the overlay blocks a second submit @@ -124,9 +124,9 @@ func TestCompositeFormInvalidThenValid(t *testing.T) { t.Fatalf("phase %d, texts %q", s.phase, tt.Texts()) } submits := 0 - for _, e := range g.events { + for i, e := range g.events { if strings.Contains(e.text, "hunter22secret") { - t.Fatalf("password logged: %q", e.text) + t.Fatalf("password logged in event %d of page %s", i, e.page) } if e.page == formID && strings.HasPrefix(e.text, "submitted ") { submits++ From 486d2c6a8659d2e8fc8800361f735808a75a986b 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:09:53 -0400 Subject: [PATCH 2/5] Web mirror: idle polling for timers, true wheel sign, modified click on release, numpad, Alt text, every announcement and copy, cursor notices, checked writes --- examples/gallery/main.go | 5 +- examples/gallery/web.go | 208 +++++++++----------------------- examples/gallery/web/index.html | 2 +- examples/gallery/web_input.go | 126 +++++++++++++++++++ examples/gallery/web_keys.go | 4 + examples/gallery/web_test.go | 127 +++++++++++++++++++ 6 files changed, 318 insertions(+), 154 deletions(-) create mode 100644 examples/gallery/web_input.go 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) + } +} From 3e011fa47fe455fb30bcf1b427fa7b2df3193017 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:10:53 -0400 Subject: [PATCH 3/5] FormItem takes *ui.Context first (PRD 6.1); drop dead internal.Rule --- datetime_review2_test.go | 2 +- docs/components/041-form.md | 2 +- example_form_test.go | 2 +- examples/gallery/composite_form.go | 10 +++++----- examples/gallery/pages_form.go | 8 ++++---- form.go | 7 +++---- form_test.go | 14 +++++++------- internal/display.go | 6 ------ 8 files changed, 22 insertions(+), 29 deletions(-) diff --git a/datetime_review2_test.go b/datetime_review2_test.go index 5654eb2..3de77a7 100644 --- a/datetime_review2_test.go +++ b/datetime_review2_test.go @@ -169,7 +169,7 @@ func TestFormFocusesDatePickerInput(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) Form(c, &st, FormOptions{}, func(f *FormScope) { - FormItem(f, "debut", &day, FormItemOptions[Date]{Label: "Debut date", Required: true}, func() *ui.Element { + FormItem(c, f, "debut", &day, FormItemOptions[Date]{Label: "Debut date", Required: true}, func() *ui.Element { r := DatePicker(c, &day, DatePickerOptions{Today: calendarToday}) input = r.Input return r.Input diff --git a/docs/components/041-form.md b/docs/components/041-form.md index f2efe30..5258920 100644 --- a/docs/components/041-form.md +++ b/docs/components/041-form.md @@ -14,7 +14,7 @@ name := "" view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) { - mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { + mujica.FormItem(c, f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input }) }) diff --git a/example_form_test.go b/example_form_test.go index e580dcf..d555109 100644 --- a/example_form_test.go +++ b/example_form_test.go @@ -15,7 +15,7 @@ func ExampleForm() { view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) { - mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { + mujica.FormItem(c, f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input }) }) diff --git a/examples/gallery/composite_form.go b/examples/gallery/composite_form.go index cb66bd3..7f71a4b 100644 --- a/examples/gallery/composite_form.go +++ b/examples/gallery/composite_form.go @@ -110,7 +110,7 @@ func (g *gallery) formEdit(c *ui.Context, log func(string)) { func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string)) { s := &g.cForm - mujica.FormItem(f, "name", &s.name, mujica.FormItemOptions[string]{Label: g.t("Stage name", "艺名"), Required: true, + mujica.FormItem(c, f, "name", &s.name, mujica.FormItemOptions[string]{Label: g.t("Stage name", "艺名"), Required: true, Description: g.t("Checked against the archive; “Oblivionis” is taken.", "将与档案核对;“Oblivionis”已被占用。"), Validate: func(v string) string { if len([]rune(strings.TrimSpace(v))) < 2 { @@ -134,7 +134,7 @@ func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string }}, func() *ui.Element { return mujica.TextInput(c, &s.name, mujica.TextInputOptions{}).Input }) - mujica.FormItem(f, "password", &s.password, mujica.FormItemOptions[string]{Label: g.t("Password", "密码"), Required: true, + mujica.FormItem(c, f, "password", &s.password, mujica.FormItemOptions[string]{Label: g.t("Password", "密码"), Required: true, Validate: func(v string) string { if len([]rune(v)) < 8 { return g.t("Use at least 8 characters.", "至少 8 个字符。") @@ -147,14 +147,14 @@ func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string } return r.Input }) - mujica.FormItem(f, "debut", &s.debut, mujica.FormItemOptions[mujica.Date]{Label: g.t("Debut date", "首演日期"), Required: true}, func() *ui.Element { + mujica.FormItem(c, f, "debut", &s.debut, mujica.FormItemOptions[mujica.Date]{Label: g.t("Debut date", "首演日期"), Required: true}, func() *ui.Element { r := mujica.DatePicker(c, &s.debut, mujica.DatePickerOptions{Clearable: true}) if r.Changed() { log("debut " + s.debut.Format("2006-01-02")) } return r.Input }) - mujica.FormItem(f, "roles", &s.tags, mujica.FormItemOptions[[]string]{Label: g.t("Roles", "担当"), Required: true, + mujica.FormItem(c, f, "roles", &s.tags, mujica.FormItemOptions[[]string]{Label: g.t("Roles", "担当"), Required: true, Description: g.t("Up to 4; press Enter after each.", "最多 4 个;每个后按 Enter。")}, func() *ui.Element { r := mujica.TagsInput(c, &s.tags, mujica.TagsInputOptions{Max: 4, Invalid: s.st.Error("roles") != "", Placeholder: g.t("keyboard, bass…", "键盘、贝斯…")}) if r.Changed() { @@ -162,7 +162,7 @@ func (g *gallery) formFields(c *ui.Context, f *mujica.FormScope, log func(string } return r.Input }) - mujica.FormItem(f, "terms", &s.terms, mujica.FormItemOptions[bool]{Label: g.t("Terms", "条款"), + mujica.FormItem(c, f, "terms", &s.terms, mujica.FormItemOptions[bool]{Label: g.t("Terms", "条款"), Validate: func(v bool) string { if !v { return g.t("Accept the terms to continue.", "请先同意条款。") diff --git a/examples/gallery/pages_form.go b/examples/gallery/pages_form.go index 9adba88..2d8aef9 100644 --- a/examples/gallery/pages_form.go +++ b/examples/gallery/pages_form.go @@ -118,7 +118,7 @@ var formPages = []page{ } formToggles(c, []string{"Labels beside fields", "Server rejects the submit"}, &d.horizontal, &d.slowServer) res := mujica.Form(c, &d.st, mujica.FormOptions{Horizontal: d.horizontal}, func(f *mujica.FormScope) { - mujica.FormItem(f, "name", &d.name, mujica.FormItemOptions[string]{Label: "Stage name", Required: true, Initial: "Sakiko", + mujica.FormItem(c, f, "name", &d.name, mujica.FormItemOptions[string]{Label: "Stage name", Required: true, Initial: "Sakiko", Description: "Checked against the archive; \"Oblivionis\" is taken.", Async: func(v string, version uint64) { log(fmt.Sprintf("async check started v%d", version)) @@ -134,7 +134,7 @@ var formPages = []page{ }}, func() *ui.Element { return mujica.InputGroup(c, &d.name, mujica.InputGroupOptions{Invalid: d.st.Error("name") != ""}).Input }) - mujica.FormItem(f, "email", &d.email, mujica.FormItemOptions[string]{Label: "Email", Required: true, + mujica.FormItem(c, f, "email", &d.email, mujica.FormItemOptions[string]{Label: "Email", Required: true, Validate: func(v string) string { if !strings.Contains(v, "@") { return "Enter an email address." @@ -147,7 +147,7 @@ var formPages = []page{ } return g.Input }) - mujica.FormItem(f, "tags", &d.tags, mujica.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() *ui.Element { + mujica.FormItem(c, f, "tags", &d.tags, mujica.FormItemOptions[[]string]{Label: "Roles", Required: true, Initial: []string{"keyboard"}}, func() *ui.Element { return mujica.TagsInput(c, &d.tags, mujica.TagsInputOptions{Max: 4, Invalid: d.st.Error("tags") != ""}).Input }) }) @@ -336,7 +336,7 @@ name := "" view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) res := mujica.Form(c, &state, mujica.FormOptions{}, func(f *mujica.FormScope) { - mujica.FormItem(f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { + mujica.FormItem(c, f, "name", &name, mujica.FormItemOptions[string]{Label: "Name", Required: true}, func() *ui.Element { return mujica.InputGroup(c, &name, mujica.InputGroupOptions{Invalid: state.Error("name") != ""}).Input }) }) diff --git a/form.go b/form.go index acee219..4c6314c 100644 --- a/form.go +++ b/form.go @@ -99,7 +99,6 @@ type FormOptions struct { // FormScope registers a Form's fields with FormItem. type FormScope struct { - c *ui.Context st *FormState horizontal bool } @@ -140,7 +139,7 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco parts := FormParts{} col := ui.Column(c).Gap(d.Unit() * 5) parts.Element = col - scope := &FormScope{c: c, st: st, horizontal: opts.Horizontal} + scope := &FormScope{st: st, horizontal: opts.Horizontal} col.Children(func() { // A new key after Reset drops the controls' drafts and errors. ui.Box(c).Key(st.generation).Children(func() { @@ -242,8 +241,8 @@ func formReset(st *FormState) { } // FormItem registers field name around the focusable element control returns. -func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element { - c, st := f.c, f.st +func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element { + st := f.st if name == "" || opts.Label == "" { panic("mujica: FormItem needs a name and a Label") } diff --git a/form_test.go b/form_test.go index 0e895ba..64ce79b 100644 --- a/form_test.go +++ b/form_test.go @@ -25,7 +25,7 @@ func (p *formProbe) run(c *ui.Context) { func (p *formProbe) view(c *ui.Context) { res := Form(c, &p.st, FormOptions{Horizontal: p.horizontal}, func(f *FormScope) { - FormItem(f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element { + FormItem(c, f, "name", &p.name, FormItemOptions[string]{Label: "Name", Required: true, Initial: "Sakiko"}, func() *ui.Element { return InputGroup(c, &p.name, InputGroupOptions{Invalid: p.st.Error("name") != ""}).Input }) opts := FormItemOptions[string]{Label: "Email", Validate: func(v string) string { @@ -40,10 +40,10 @@ func (p *formProbe) view(c *ui.Context) { p.asyncValues = append(p.asyncValues, v) } } - FormItem(f, "email", &p.email, opts, func() *ui.Element { + FormItem(c, f, "email", &p.email, opts, func() *ui.Element { return InputGroup(c, &p.email, InputGroupOptions{Invalid: p.st.Error("email") != ""}).Input }) - FormItem(f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element { + FormItem(c, f, "tags", &p.tags, FormItemOptions[[]string]{Label: "Tags", Required: true, Initial: []string{"rose"}}, func() *ui.Element { return TagsInput(c, &p.tags, TagsInputOptions{}).Input }) }) @@ -222,7 +222,7 @@ func TestFormResetClearsControlState(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) Form(c, &st, FormOptions{}, func(f *FormScope) { - FormItem(f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element { + FormItem(c, f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element { return TagsInput(c, &tags, TagsInputOptions{Validate: func(s string) string { seen = s return "Invalid tag" @@ -289,13 +289,13 @@ func TestFormCrossFieldRuleAtSettle(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) res := Form(c, &st, FormOptions{}, func(f *FormScope) { - FormItem(f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element { + FormItem(c, f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element { return InputGroup(c, &user, InputGroupOptions{}).Input }) - FormItem(f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element { + FormItem(c, f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element { return InputGroup(c, &pw, InputGroupOptions{}).Input }) - FormItem(f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string { + FormItem(c, f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string { if v != pw { return "Passwords differ." } diff --git a/internal/display.go b/internal/display.go index 76a35ab..3a00828 100644 --- a/internal/display.go +++ b/internal/display.go @@ -6,12 +6,6 @@ import ( "github.com/egoist/mygo/ui" ) -// Rule draws a pixel-aligned 1 DIP horizontal line at y. -func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) { - yy := ruleY(p, y) - p.Line(x0, yy, x1, yy, 1, c) -} - // ruleY moves y to where a 1 DIP line fills whole device pixels. func ruleY(p *ui.Painter, y float32) float32 { s := float64(p.Scale()) From af13fe3742633839c51f4c23fbdf7d04fd972444 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:14:38 -0400 Subject: [PATCH 4/5] Review fixes: ordered doc-snippet check, one page and doc per ID, fade test clock, listen-address docs, false placeholder gap, Today param --- README.md | 4 +- docs/compat.md | 2 +- docs/components/054-time-picker.md | 1 - docs/components/055-time-range-picker.md | 1 - docs/components/059-tabs.md | 6 +- docs/components/060-segmented-control.md | 6 +- docs/components/068-command-palette.md | 2 +- docs/upstream.md | 2 +- docs_test.go | 136 +++++++++++++---------- navigation_review_test.go | 7 +- 10 files changed, 98 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 38b8121..4b07234 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ k := mujica.Tokens(c) // 当前窗口的颜色 ```sh go run ./examples/gallery # 原生窗口 -go run ./examples/gallery -web :8080 # 浏览器镜像 +go run ./examples/gallery -web 127.0.0.1:8080 # 浏览器镜像,仅本机 ``` -浏览器镜像不在浏览器里运行组件。Go 进程用 `ui.NewTester` 驱动同一个 Gallery 视图,把帧推给页面,再把指针、键盘、输入法和剪贴板事件送回 Go。 +`-web` 按传入的地址监听;写 `:8080` 会对所有网络接口开放。浏览器镜像不在浏览器里运行组件。Go 进程用 `ui.NewTester` 驱动同一个 Gallery 视图,把帧推给页面,再把指针、键盘、输入法和剪贴板事件送回 Go。 ## 文档 diff --git a/docs/compat.md b/docs/compat.md index 44e807e..a193467 100644 --- a/docs/compat.md +++ b/docs/compat.md @@ -22,4 +22,4 @@ ## 离线 -库与 Gallery 不发起网络请求,无遥测、账号或远程字体。图标与角饰随代码嵌入。浏览器镜像只在本机监听调用方给定的地址。 +库与 Gallery 不发起网络请求,无遥测、账号或远程字体。图标与角饰随代码嵌入。浏览器镜像按 `-web` 传入的地址监听:`127.0.0.1:8080` 仅本机可达,`:8080` 对所有网络接口开放。 diff --git a/docs/components/054-time-picker.md b/docs/components/054-time-picker.md index 8efb00d..d57d23f 100644 --- a/docs/components/054-time-picker.md +++ b/docs/components/054-time-picker.md @@ -57,5 +57,4 @@ ui.Render(view, 400, 300, 1) - 段顺序固定为“时:分:秒 上午/下午”,zh-CN 下上午/下午也位于末尾。 - MyGo 的 `ui.TimeInput` 只有时分且无 12 小时制、步长和范围(ui/timeinput.go:18),因此本组件自绘分段。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 -- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 - 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/055-time-range-picker.md b/docs/components/055-time-range-picker.md index 03f1a27..e3c0fd0 100644 --- a/docs/components/055-time-range-picker.md +++ b/docs/components/055-time-range-picker.md @@ -53,5 +53,4 @@ ui.Render(view, 400, 300, 1) - 开始等于结束视为错误。 - `TimeRange.Overnight()` 报告是否跨午夜。 - 日期与时间值不带时区:`Date`、`YearMonth`、`TimeOfDay` 只保存日历字段,组件内部计算不做 UTC 转换。 -- `Today` 为零值时读取 `c.Now()`(系统本地时区);需要固定“今天”(测试、跨时区应用)时显式传入。 - 无障碍:MyGo v0.2.10 没有公开设置弹出按钮展开状态(`expanded`)、数值范围(`hasRange`/`accRange`)与值(`accValue`)的接口,这些字段只在 MyGo 内部组件中赋值(ui/element.go:286-295,例如 ui/base.go:297、ui/indicators.go:61)。因此触发器只能声明为 `RolePopUpButton` 而无法报告展开状态;时间段以 `Description` 读出当前值与范围,错误通过 `Error()` 关联。 diff --git a/docs/components/059-tabs.md b/docs/components/059-tabs.md index ec6257e..9b1b930 100644 --- a/docs/components/059-tabs.md +++ b/docs/components/059-tabs.md @@ -7,11 +7,13 @@ ## 最小示例 ```go -mujica.Tabs(c, &tab, []mujica.Tab{ +if mujica.Tabs(c, &tab, []mujica.Tab{ {Label: "General", Panel: func() { ui.Text(c, "General settings") }}, {Label: "Fonts", Icon: icons.Must("italic"), Panel: func() { ui.Text(c, "Fonts") }}, {Label: "Archive", Disabled: true}, -}, mujica.TabsOptions{Label: "Settings"}) +}, mujica.TabsOptions{Label: "Settings"}).Changed() { + fmt.Println("tab", tab) +} ``` 签名:`func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) ChoiceResult` diff --git a/docs/components/060-segmented-control.md b/docs/components/060-segmented-control.md index 1a93ae4..5802afa 100644 --- a/docs/components/060-segmented-control.md +++ b/docs/components/060-segmented-control.md @@ -7,10 +7,12 @@ ## 最小示例 ```go -mujica.SegmentedControl(c, &view, []mujica.Segment{ +if mujica.SegmentedControl(c, &view, []mujica.Segment{ {Label: "List", Icon: icons.Must("list"), HideLabel: true}, {Label: "Grid", Icon: icons.Must("layout-grid"), HideLabel: true}, -}, mujica.SegmentedControlOptions{Label: "View"}) +}, mujica.SegmentedControlOptions{Label: "View"}).Changed() { + fmt.Println("view", view) +} ``` 签名:`func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts SegmentedControlOptions) *ui.Element` diff --git a/docs/components/068-command-palette.md b/docs/components/068-command-palette.md index 4051dfd..9fa21f3 100644 --- a/docs/components/068-command-palette.md +++ b/docs/components/068-command-palette.md @@ -61,7 +61,7 @@ if id, ok := res.Chosen(); ok { ## 限制 -- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。 +- 操作说明放在底部提示栏而不放进输入框,避免与输入法组合文字重叠;搜索框以无障碍名称“搜索命令”标识。 - 搜索是子串匹配,不做模糊匹配或排序打分。 - 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。 - 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 diff --git a/docs/upstream.md b/docs/upstream.md index beea964..8242b76 100644 --- a/docs/upstream.md +++ b/docs/upstream.md @@ -33,6 +33,6 @@ PRD 第 2 节要求公开接口不足时向上游提交扩展。以下缺口在 | `activeDescendant` 不公开 | `ui/element.go:331` | Menubar、CommandPalette | | `SliderBase` 方向键步长固定 1%,构建时处理指针 | `ui/base.go:111-117` | Slider | | `SelectBase` 高亮无设置接口 | `ui/base.go:260` | Select | -| `TextInputBase` 无只读开关与占位符 | `ui/editor.go:139,734,811` | TextInput 只读模式、CommandPalette | +| `TextInputBase` 无只读开关 | `ui/editor.go:139,811` | TextInput 只读模式 | | `ui.Stepper` 禁用时仍可步进,固定 9 位取整 | `ui/indicators.go:216`、`ui/widgets.go` `snap` | NumberInput(已改为自绘) | | 路径遮罩缓存按四分之一像素取键,进程内共享 | `ui/path.go:189-210` | 自绘路径以 `internal.Snap` 对齐;MyGo 自绘的箭头(Outline 等)无法对齐;快照测试因此在独立子进程中渲染,结果与测试顺序无关 | diff --git a/docs_test.go b/docs_test.go index cc19843..f7f1d22 100644 --- a/docs_test.go +++ b/docs_test.go @@ -14,71 +14,93 @@ var ( docsPageID = regexp.MustCompile(`ID: "(\d{3})"`) ) -// TestDocSnippetsAreCompiledExamples keeps every doc's first Go block inside a compiled Example. +// docsLines normalizes code into its non-empty, non-comment lines without whitespace. +func docsLines(code string) []string { + var out []string + for _, l := range strings.Split(code, "\n") { + s := strings.TrimSpace(l) + if s != "" && !strings.HasPrefix(s, "//") { + out = append(out, docsSpace.ReplaceAllString(s, "")) + } + } + return out +} + +// docsContains reports whether want appears in order inside have. +func docsContains(have, want []string) bool { + i := 0 + for _, h := range have { + if i < len(want) && h == want[i] { + i++ + } + } + return i == len(want) +} + +func readGlob(t *testing.T, pattern string) map[string]string { + t.Helper() + files, err := filepath.Glob(pattern) + if err != nil { + t.Fatal(err) + } + out := map[string]string{} + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + out[f] = string(b) + } + return out +} + +// TestDocSnippetsAreCompiledExamples keeps each doc's first Go block, in order, inside one Example. func TestDocSnippetsAreCompiledExamples(t *testing.T) { - examples, err := filepath.Glob("example_*_test.go") - if err != nil { - t.Fatal(err) - } - var all strings.Builder - for _, f := range examples { - b, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - all.Write(b) - } - compiled := docsSpace.ReplaceAllString(all.String(), "") - docs, err := filepath.Glob("docs/components/*.md") - if err != nil { - t.Fatal(err) - } - pages, err := filepath.Glob("examples/gallery/pages_*.go") - if err != nil { - t.Fatal(err) - } - ids := map[string]bool{} - for _, f := range pages { - b, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - for _, m := range docsPageID.FindAllSubmatch(b, -1) { - ids[string(m[1])] = true + var examples [][]string + for _, src := range readGlob(t, "example_*_test.go") { + for _, fn := range strings.Split(src, "\nfunc Example")[1:] { + examples = append(examples, docsLines(fn)) } } - documented := map[string]bool{} - for _, d := range docs { - documented[filepath.Base(d)[:3]] = true - } - for id := range ids { - if !documented[id] { - t.Errorf("gallery page %s has no doc", id) - } - } - for id := range documented { - if !ids[id] { - t.Errorf("doc %s has no gallery page", id) - } - } - for _, d := range docs { - b, err := os.ReadFile(d) - if err != nil { - t.Fatal(err) - } - m := docsGoBlock.FindSubmatch(b) + for d, src := range readGlob(t, "docs/components/*.md") { + m := docsGoBlock.FindStringSubmatch(src) if m == nil { t.Errorf("%s has no Go example", d) continue } - for _, line := range strings.Split(string(m[1]), "\n") { - s := strings.TrimSpace(line) - if s == "" || strings.HasPrefix(s, "//") { - continue - } - if !strings.Contains(compiled, docsSpace.ReplaceAllString(s, "")) { - t.Errorf("%s: %q is not in any compiled Example", d, s) + want, found := docsLines(m[1]), false + for _, ex := range examples { + if docsContains(ex, want) { + found = true + break } } + if !found { + t.Errorf("%s: its Go block is not, in order, inside any compiled Example", d) + } + } +} + +// TestDocsMatchGalleryPages wants exactly one doc and one gallery page per component ID. +func TestDocsMatchGalleryPages(t *testing.T) { + pages := map[string]int{} + for _, src := range readGlob(t, "examples/gallery/pages_*.go") { + for _, m := range docsPageID.FindAllStringSubmatch(src, -1) { + pages[m[1]]++ + } + } + docs := map[string]int{} + for d := range readGlob(t, "docs/components/*.md") { + docs[filepath.Base(d)[:3]]++ + } + for id, n := range pages { + if n != 1 || docs[id] != 1 { + t.Errorf("component %s: %d gallery pages, %d docs; want 1 and 1", id, n, docs[id]) + } + } + for id, n := range docs { + if pages[id] == 0 { + t.Errorf("doc %s (%d files) has no gallery page", id, n) + } } } diff --git a/navigation_review_test.go b/navigation_review_test.go index 4ea939f..7a987b9 100644 --- a/navigation_review_test.go +++ b/navigation_review_test.go @@ -7,6 +7,8 @@ import ( "github.com/egoist/mygo/ui" + "mujicaui/theme" + "mujicaui/icons" ) @@ -34,6 +36,7 @@ func TestNavigationPopupsFadeIn(t *testing.T) { CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{}) }}, } { + opened := time.Now() tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{Mode: Light}) ui.Column(c).Fill().Padding(16).AlignItems(ui.Start).Children(func() { tc.view(c) }) @@ -49,9 +52,11 @@ func TestNavigationPopupsFadeIn(t *testing.T) { } x, y := int(r.X-3), int(r.Y+r.H/2) early := tt.Image().RGBAAt(x, y) + mid := time.Since(opened) < theme.PopupDuration/2 time.Sleep(200 * time.Millisecond) tt.Frame() - if late := tt.Image().RGBAAt(x, y); early == late { + // A slow run may finish the fade before the first sample. + if late := tt.Image().RGBAAt(x, y); mid && early == late { t.Errorf("%s: popup appeared at once (%v)", tc.name, late) } } From 6f0c02d3397270dd7e1a5b3db4adb9158ba1a9ab 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:18:17 -0400 Subject: [PATCH 5/5] internal: line diff helper from the Agent group, shared with Git --- internal/diff.go | 133 ++++++++++++++++++++++++++++++++++++++++++ internal/diff_test.go | 110 ++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 internal/diff.go create mode 100644 internal/diff_test.go diff --git a/internal/diff.go b/internal/diff.go new file mode 100644 index 0000000..ccc9071 --- /dev/null +++ b/internal/diff.go @@ -0,0 +1,133 @@ +package internal + +import "strings" + +// DiffOp says whether a diff line is kept, removed or added. +type DiffOp uint8 + +const ( + DiffEqual DiffOp = iota + DiffDelete + DiffInsert +) + +// DiffLine is one line of a line diff with its 1-based line numbers; 0 means absent. +type DiffLine struct { + Op DiffOp + Text string + Old, New int +} + +// SplitLines splits text into lines without their line breaks; "" has no lines. +func SplitLines(s string) []string { + if s == "" { + return nil + } + return strings.Split(strings.TrimSuffix(s, "\n"), "\n") +} + +// LineDiff returns a shortest edit script turning a into b (Myers, O((N+M)D)). +func LineDiff(a, b []string) []DiffLine { + // Common prefix and suffix need no search. + pre := 0 + for pre < len(a) && pre < len(b) && a[pre] == b[pre] { + pre++ + } + suf := 0 + for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] { + suf++ + } + out := make([]DiffLine, 0, len(a)+len(b)-pre-suf) + for i := range pre { + out = append(out, DiffLine{DiffEqual, a[i], i + 1, i + 1}) + } + out = myers(out, a[pre:len(a)-suf], b[pre:len(b)-suf], pre, pre) + for i := range suf { + ai, bi := len(a)-suf+i, len(b)-suf+i + out = append(out, DiffLine{DiffEqual, a[ai], ai + 1, bi + 1}) + } + return out +} + +// myers appends the edit script of a to b, numbering from offsets ao and bo. +func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine { + n, m := len(a), len(b) + total := n + m + if total == 0 { + return out + } + off := total + 1 + v := make([]int, 2*total+3) + var trace [][]int + end := -1 + for d := 0; d <= total && end < 0; d++ { + trace = append(trace, append([]int(nil), v[off-d:off+d+1]...)) // diagonals -d..d + for k := -d; k <= d; k += 2 { + var x int + if k == -d || k != d && v[off+k-1] < v[off+k+1] { + x = v[off+k+1] + } else { + x = v[off+k-1] + 1 + } + y := x - k + for x < n && y < m && a[x] == b[y] { + x, y = x+1, y+1 + } + v[off+k] = x + if x >= n && y >= m { + end = d + break + } + } + } + // Walk the trace back from (n, m), collecting moves in reverse. + var rev []DiffLine + x, y := n, m + for d := end; d >= 0; d-- { + vd := trace[d] + at := func(k int) int { return vd[k+d] } + k := x - y + var pk int + if k == -d || k != d && at(k-1) < at(k+1) { + pk = k + 1 + } else { + pk = k - 1 + } + px := 0 + if d > 0 { + px = at(pk) + } + py := px - pk + for x > px && y > py { + x, y = x-1, y-1 + rev = append(rev, DiffLine{DiffEqual, a[x], ao + x + 1, bo + y + 1}) + } + if d == 0 { + break + } + if x == px { + y-- + rev = append(rev, DiffLine{DiffInsert, b[y], 0, bo + y + 1}) + } else { + x-- + rev = append(rev, DiffLine{DiffDelete, a[x], ao + x + 1, 0}) + } + } + for i := len(rev) - 1; i >= 0; i-- { + out = append(out, rev[i]) + } + return out +} + +// DiffStats counts the added and removed lines of a diff. +func DiffStats(d []DiffLine) (added, removed int) { + for _, l := range d { + switch l.Op { + case DiffInsert: + added++ + case DiffDelete: + removed++ + } + } + return added, removed +} diff --git a/internal/diff_test.go b/internal/diff_test.go new file mode 100644 index 0000000..d087d5c --- /dev/null +++ b/internal/diff_test.go @@ -0,0 +1,110 @@ +package internal + +import ( + "fmt" + "math/rand" + "slices" + "strings" + "testing" +) + +// diffSides rebuilds both inputs from a diff and checks the line numbers. +func diffSides(t *testing.T, d []DiffLine) (a, b []string) { + t.Helper() + for _, l := range d { + if l.Op != DiffInsert { + a = append(a, l.Text) + if l.Old != len(a) { + t.Fatalf("old number %d, want %d", l.Old, len(a)) + } + } + if l.Op != DiffDelete { + b = append(b, l.Text) + if l.New != len(b) { + t.Fatalf("new number %d, want %d", l.New, len(b)) + } + } + } + return a, b +} + +// lcs is the reference longest common subsequence length. +func lcs(a, b []string) int { + prev := make([]int, len(b)+1) + for i := range a { + cur := make([]int, len(b)+1) + for j := range b { + if a[i] == b[j] { + cur[j+1] = prev[j] + 1 + } else { + cur[j+1] = max(prev[j+1], cur[j]) + } + } + prev = cur + } + return prev[len(b)] +} + +func TestLineDiffKnown(t *testing.T) { + d := LineDiff(SplitLines("a\nb\nc\nd\n"), SplitLines("a\nc\nd\ne\n")) + var got []string + for _, l := range d { + got = append(got, fmt.Sprintf("%d%s", l.Op, l.Text)) + } + want := []string{"0a", "1b", "0c", "0d", "2e"} + if !slices.Equal(got, want) { + t.Fatalf("diff %v, want %v", got, want) + } + if add, del := DiffStats(d); add != 1 || del != 1 { + t.Fatalf("stats +%d -%d", add, del) + } +} + +func TestLineDiffEdges(t *testing.T) { + for _, tc := range []struct{ a, b string }{{"", ""}, {"", "x\ny"}, {"x\ny", ""}, {"same\n", "same\n"}, {"a", "b"}} { + d := LineDiff(SplitLines(tc.a), SplitLines(tc.b)) + a, b := diffSides(t, d) + if !slices.Equal(a, SplitLines(tc.a)) || !slices.Equal(b, SplitLines(tc.b)) { + t.Errorf("%q -> %q: rebuilt %q, %q", tc.a, tc.b, a, b) + } + } +} + +func TestLineDiffMinimalRandom(t *testing.T) { + r := rand.New(rand.NewSource(7)) + for range 300 { + a := make([]string, r.Intn(30)) + b := make([]string, r.Intn(30)) + for i := range a { + a[i] = string(rune('a' + r.Intn(4))) + } + for i := range b { + b[i] = string(rune('a' + r.Intn(4))) + } + d := LineDiff(a, b) + ra, rb := diffSides(t, d) + if !slices.Equal(ra, a) || !slices.Equal(rb, b) { + t.Fatalf("rebuilt %q %q from %q %q", ra, rb, a, b) + } + add, del := DiffStats(d) + if keep := len(a) - del; keep != lcs(a, b) || len(b)-add != keep { + t.Fatalf("%q -> %q: kept %d lines, LCS is %d", a, b, keep, lcs(a, b)) + } + } +} + +func TestLineDiffLargeFileSmallEdit(t *testing.T) { + a := make([]string, 50000) + for i := range a { + a[i] = fmt.Sprint("line ", i) + } + b := slices.Clone(a) + b[25000] = "changed" + d := LineDiff(a, b) + if add, del := DiffStats(d); add != 1 || del != 1 || len(d) != 50001 { + t.Fatalf("+%d -%d over %d lines", add, del, len(d)) + } + if !strings.HasPrefix(d[25000].Text, "line 25000") || d[25000].Op != DiffDelete { + t.Fatalf("line 25000: %+v", d[25000]) + } +}