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] 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 {