From b57d8e7da7e62e80fac65aed3664fb47147ee4e9 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:44:58 -0400 Subject: [PATCH 1/3] Form fields and ToggleGroup items keyed by name and value; shared stableKey --- form.go | 2 +- form_field.go | 25 +++++++++++++----- radio_group.go | 17 +++++++----- stable_keys_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++ toggle_group.go | 18 ++++++++----- 5 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 stable_keys_test.go diff --git a/form.go b/form.go index 4c6314c..c3ea2b1 100644 --- a/form.go +++ b/form.go @@ -293,7 +293,7 @@ func FormItem[T any](c *ui.Context, f *FormScope, name string, value *T, opts Fo desc = msg(c, "form.checking") } var ctrl *ui.Element - field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element { + field := formFieldBuild(c, name, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element { ctrl = control() return ctrl }) diff --git a/form_field.go b/form_field.go index 47f9e8c..ee589fc 100644 --- a/form_field.go +++ b/form_field.go @@ -20,10 +20,10 @@ type FormFieldOptions struct { // FormField labels the element control returns and shows help and error text for it. func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element { if !opts.Horizontal { - return formFieldBuild(c, label, opts, false, control) + return formFieldBuild(c, "", label, opts, false, control) } var f *ui.Element - formFieldGrid(c, func() { f = formFieldBuild(c, label, opts, true, control) }) + formFieldGrid(c, func() { f = formFieldBuild(c, "", label, opts, true, control) }) return f } @@ -35,7 +35,8 @@ func formFieldGrid(c *ui.Context, fields func()) *ui.Element { } // formFieldBuild builds the label and the control column; beside makes them two grid cells. -func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element { +// formFieldBuild keys its cells by key when set, so focus follows a reordered field. +func formFieldBuild(c *ui.Context, key, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element { if label == "" { panic("mujica: FormField needs a label") } @@ -43,7 +44,11 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b d := Density(c) var head, content, ctrl *ui.Element body := func() { - head = ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center) + head = ui.Row(c) + if key != "" && beside { + head.Key(key + "/label") + } + head.Gap(d.Unit()).AlignItems(ui.Center) if beside { head.MinHeight(d.ControlHeight()).Justify(ui.End) } @@ -55,7 +60,11 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b } }) clicked := head.Clicked() - content = ui.Column(c).Gap(d.Unit() * 1.5).MinWidth(0) + content = ui.Column(c) + if key != "" && beside { + content.Key(key + "/control") + } + content.Gap(d.Unit() * 1.5).MinWidth(0) content.Children(func() { ctrl = control() if ctrl == nil { @@ -82,5 +91,9 @@ func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside b body() return content } - return ui.Column(c).Gap(d.Unit() * 1.5).Children(body) + root := ui.Column(c) + if key != "" { + root.Key(key) + } + return root.Gap(d.Unit() * 1.5).Children(body) } diff --git a/radio_group.go b/radio_group.go index 6a87ade..0cfca88 100644 --- a/radio_group.go +++ b/radio_group.go @@ -51,12 +51,7 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt wrap.Children(func() { r.Element = ui.RadioGroup(c, func() { for _, it := range items { - key := slices.Index(*known, it.Value) - if key < 0 { - key = len(*known) - *known = append(*known, it.Value) - } - if radioGroupItem(c, k, d, value, it, key) { + if radioGroupItem(c, k, d, value, it, stableKey(known, it.Value)) { r.changed = true slog.Debug("mujica: radio_group choose", "label", it.Label) } @@ -103,3 +98,13 @@ func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density }) return row.Changed() } + +// stableKey keys v by its first appearance in *known, so equal-looking values stay apart. +func stableKey[T comparable](known *[]T, v T) int { + i := slices.Index(*known, v) + if i < 0 { + i = len(*known) + *known = append(*known, v) + } + return i +} diff --git a/stable_keys_test.go b/stable_keys_test.go new file mode 100644 index 0000000..8cc1d50 --- /dev/null +++ b/stable_keys_test.go @@ -0,0 +1,63 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestFormFocusFollowsFieldAfterReorder(t *testing.T) { + var st FormState + first, second := "", "" + swapped := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Form(c, &st, FormOptions{}, func(f *FormScope) { + fields := []func(){ + func() { + FormItem(c, f, "first", &first, FormItemOptions[string]{Label: "First"}, func() *ui.Element { + return TextInput(c, &first, TextInputOptions{Label: "First"}).Input + }) + }, + func() { + FormItem(c, f, "second", &second, FormItemOptions[string]{Label: "Second"}, func() *ui.Element { + return TextInput(c, &second, TextInputOptions{Label: "Second"}).Input + }) + }, + } + if swapped { + slices.Reverse(fields) + } + for _, fn := range fields { + fn() + } + }) + }, 500, 400) + if err := tt.Click("First"); err != nil { + t.Fatal(err) + } + tt.Type("a") + swapped = true + tt.Frame() + tt.Type("b") + if first != "ab" || second != "" { + t.Fatalf("first %q second %q, want typing to stay in First", first, second) + } +} + +func TestToggleGroupFocusFollowsValueAfterReorder(t *testing.T) { + var on []string + items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToggleGroup(c, &on, items, ToggleGroupOptions{Multiple: true, Label: "Align"}) + }, 400, 200) + tt.Key(0, ui.KeyTab) + slices.Reverse(items) + tt.Frame() + tt.Key(0, ui.KeySpace) + if !slices.Equal(on, []string{"left"}) { + t.Fatalf("pressed %v, want [left]", on) + } +} diff --git a/toggle_group.go b/toggle_group.go index e8617ec..1c87cc3 100644 --- a/toggle_group.go +++ b/toggle_group.go @@ -59,17 +59,21 @@ func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], } g.Dividers(theme.BorderWidth, k.ControlBorder) changed := false + known := ui.Local(g, "values", func() []T { return nil }) g.Children(func() { for _, it := range items { on := slices.Contains(*value, it.Value) pressed := on - b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) - // The base took input before Disabled applied: drop a disabled item's press. - if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) { - changed = true - on = pressed - } - toggleGroupLook(c, k, b, on, it) + // Keyed by value so focus follows the item, not its position. + ui.Box(c).Key(stableKey(known, it.Value)).Children(func() { + b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) + // The base took input before Disabled applied: drop a disabled item's press. + if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) { + changed = true + on = pressed + } + toggleGroupLook(c, k, b, on, it) + }) } }) if changed { 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 2/3] 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) + } +} From c804f2ab946e65978e8cd0443e669eda3c9ddfca 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:46 -0400 Subject: [PATCH 3/3] TASKS/AGENTS: record the category package split decision --- AGENTS.md | 3 ++- TASKS.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 376e0de..5ee7e4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # AGENTS.md MujicaUI: court-gothic components for MyGo native UI. Go library, package `mujica`, module `mujicaui`. -Progress truth: `TASKS.md`. Requirements: `PRD.md`. +Progress truth: `TASKS.md`. Requirements: `PRD.md` and `docs/prd/ext-*.md` (101–500). +Planned: once 001–500 land, the root package splits into category sub-packages (TASKS T22). Until then everything stays in the root package. ## Stack diff --git a/TASKS.md b/TASKS.md index 1762d31..d3c27b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -43,4 +43,5 @@ Ordered by PRD sections. Check an item only after its codex review passes. Revie - [ ] T19 §8 Docs: install, minimal example, theme, compat; 100 component pages; examples compile - [ ] T20 §9 Snapshots, density baselines, large-data and lifecycle tests - [ ] T21 §9 CI workflow -- [ ] T22 §10 Acceptance checklist +- [ ] T22 §6.3 Split into category sub-packages after 001–500 land (user decision 2026-10-05); update PRD §6.3, docs, Gallery, CI +- [ ] T23 §10 Acceptance checklist