diff --git a/TASKS.md b/TASKS.md index 0971cdd..ccb54f9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,8 +4,8 @@ Ordered by PRD sections. Check an item only after its codex review passes. Revie - [x] T01 §1–2 Module, MyGo v0.2.10 pin, reuse rules, AGENTS.md — r1 FAIL (line height, enum checks), r2 PASS - [x] T02 §3 Tokens, density, type, motion, contrast tests, `Use`, icons, i18n — r1 FAIL, r2 PASS -- [ ] T03 §4.1 Layout 001–010 -- [ ] T04 §4.2 Display 011–020 +- [x] T03 §4.1 Layout 001–010 — r1 FAIL 8, r2 FAIL 2, r3 FAIL 1 (folded focus); fixed b34958b, verified by coordinator red/green after cap +- [x] T04 §4.2 Display 011–020 — r1 FAIL 5, r2 FAIL 5, r3 FAIL 2 (image sizing, Retry clip); fixed e922651, verified by coordinator tests and snapshot after cap - [ ] T05 §4.3 Inputs 021–030 - [ ] T06 §4.4 Choices 031–040 - [ ] T07 §4.5 Forms 041–050 diff --git a/alert_dialog.go b/alert_dialog.go index 1f619d1..e3268a2 100644 --- a/alert_dialog.go +++ b/alert_dialog.go @@ -31,17 +31,18 @@ type AlertDialogResult struct { // alertDialogState is the typed confirmation, kept while the alert shows. type alertDialogState struct{ typed string } -// AlertDialog asks to confirm a destructive action while *open is true. -// Cancel takes the focus, the backdrop ignores clicks, Escape cancels, -// and Enter never confirms before ConfirmText is typed. +// AlertDialog confirms a destructive action; Cancel has the focus. func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialogResult { if opts.Title == "" { panic("mujica: AlertDialog needs a Title") } var res AlertDialogResult + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return res } + overlayLayer(c, open) k := Tokens(c) u := Density(c).Unit() confirmLabel, cancelLabel := opts.ConfirmLabel, opts.CancelLabel @@ -52,7 +53,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog cancelLabel = msg(c, "alert_dialog.cancel") } shown, backClicked := true, false - ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { backClicked = back.Clicked() st := ui.Local(panel, "mujica.alert", func() alertDialogState { return alertDialogState{} }) p := overlayEnter(c, panel) @@ -62,15 +63,16 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Shrink(0).Children(func() { ui.Icon(c, icons.Must("triangle-alert")).Size(22, 22).TextColor(k.Danger).Shrink(0).Margin(1, 0, 0, 0) ui.Column(c).Gap(u * 2).Grow(1).Children(func() { - ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600) + ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600) if opts.Description != "" { ui.Text(c, opts.Description).TextColor(k.TextMuted) } }) }) - submitted := opts.ConfirmText != "" && alertDialogTyped(c, st, opts.ConfirmText) + if opts.ConfirmText != "" { + alertDialogTyped(c, st, opts.ConfirmText) + } unlocked := opts.ConfirmText == "" || st.typed == opts.ConfirmText - res.Confirmed = submitted && unlocked dialogActions(c, func() { if Button(c, cancelLabel, ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() { res.Cancelled = true @@ -80,6 +82,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog } }) }) + focus.shown(panel) if !shown && !backClicked { res.Cancelled = true // Escape } @@ -93,18 +96,15 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog return res } -// alertDialogTyped builds the confirmation field and reports Enter in it. -func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) bool { +// alertDialogTyped builds the confirmation field; Enter in it never confirms, even mid-composition. +func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) { k := Tokens(c) d := Density(c) prompt := fmt.Sprintf(msg(c, "alert_dialog.type_to_unlock"), want) - submitted := false ui.Column(c).Gap(d.Unit()).Shrink(0).Children(func() { - ui.Text(c, prompt).FontSize(theme.CaptionSize).TextColor(k.TextMuted) + ui.Text(c, prompt).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) in := ui.TextInputBase(c, &st.typed).Label(prompt).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3). Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) internal.Ring(in, k.Focus, theme.ControlRadius) - submitted = in.Submitted() }) - return submitted } diff --git a/alert_dialog_test.go b/alert_dialog_test.go index 0d23111..1a7651b 100644 --- a/alert_dialog_test.go +++ b/alert_dialog_test.go @@ -86,11 +86,33 @@ func TestAlertDialogConfirmTextGatesEnter(t *testing.T) { } tt.Type("e") tt.Key(0, ui.KeyEnter) + if a.confirmed != 0 || !a.open { + t.Fatalf("Enter in the field confirmed: %d", a.confirmed) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Delete") { + t.Fatal("Delete not reachable once unlocked") + } + tt.Key(0, ui.KeyEnter) if a.confirmed != 1 || a.open { t.Fatalf("typed target: confirmed %d open %v", a.confirmed, a.open) } } +// MyGo commits a composition on Enter and then handles the key (ui/editor.go:688). +func TestAlertDialogEnterDuringCompositionDoesNotConfirm(t *testing.T) { + a, tt := newAlertApp(AlertDialogOptions{ConfirmText: "archive"}) + if err := tt.Click("Type “archive” to confirm"); err != nil { + t.Fatal(err) + } + tt.Compose("archive", 7) + tt.Key(0, ui.KeyEnter) + if a.confirmed != 0 || !a.open { + t.Fatalf("Enter while composing confirmed: %d", a.confirmed) + } +} + func TestAlertDialogSnapshot(t *testing.T) { snapshot(t, "alert_dialog", 560, 320, func(c *ui.Context) { open := true diff --git a/breadcrumb.go b/breadcrumb.go index 3ea31e2..d0977fb 100644 --- a/breadcrumb.go +++ b/breadcrumb.go @@ -135,6 +135,7 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) { } b.Children(func() { ui.Icon(c, icons.Must("ellipsis")).Size(16, 16) }) ui.PopoverBase(c, b, open, func(panel *ui.Element) { + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) panel.Margin(u, 0, 0, 0).Padding(u).Gap(u*0.5).Radius(theme.PopupRadius).Background(k.Surface). Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(160).MaxWidth(320) for j, it := range hidden { diff --git a/button.go b/button.go index 83cfa26..d3c2e2f 100644 --- a/button.go +++ b/button.go @@ -29,6 +29,9 @@ type ButtonOptions struct { Label string } +// buttonSpinnerSize is the leading icon slot, which the spinner takes while loading. +const buttonSpinnerSize float32 = 16 + // Button is a push button. Loading keeps its size and blocks clicks. func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { if label == "" && opts.Label == "" { @@ -36,8 +39,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { } k := Tokens(c) d := Density(c) + pad, gap := d.Unit()*4, d.Unit()*2 + if opts.Loading && opts.Icon == nil && label != "" { + // The spinner slot comes out of the padding, so the width holds. + pad -= (buttonSpinnerSize + gap) / 2 + } b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize). - Padding(0, d.Unit()*4).Gap(d.Unit() * 2).Radius(theme.ControlRadius). + Padding(0, pad).Gap(gap).Radius(theme.ControlRadius). Disabled(opts.Disabled || opts.Loading) internal.Ring(b, k.Focus, theme.ControlRadius) if opts.Label != "" { @@ -49,25 +57,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { b.Border(theme.BorderWidth, border) } b.Children(func() { - switch { - case opts.Loading && opts.Icon != nil: - // The spinner takes the icon's 16 DIP slot. - Spinner(c, SpinnerOptions{Size: 16, Color: fg}) - case opts.Loading && label == "": - Spinner(c, SpinnerOptions{Size: 16, Color: fg}) - case opts.Loading: - // Over the hidden label, so the width stays. - ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Children(func() { - Spinner(c, SpinnerOptions{Size: 16, Color: fg}) - }) - case opts.Icon != nil: - ui.Icon(c, opts.Icon).Size(16, 16) + if opts.Loading { + Spinner(c, SpinnerOptions{Size: buttonSpinnerSize, Color: fg}) + } else if opts.Icon != nil { + ui.Icon(c, opts.Icon).Size(buttonSpinnerSize, buttonSpinnerSize) } if label != "" { - t := ui.Text(c, label).SingleLine() - if opts.Loading && opts.Icon == nil { - t.TextColor(fg.Alpha(0)) - } + ui.Text(c, label).SingleLine() } }) return b diff --git a/cascader.go b/cascader.go index 9be885f..de1e38c 100644 --- a/cascader.go +++ b/cascader.go @@ -43,8 +43,7 @@ type CascaderLevel struct { type CascaderOptions struct { // Level returns the children under path; the empty path is the roots. Level func(path []string) CascaderLevel - // Load fetches the children under path, once while Idle and on retry; it - // should mark the level Loading and finish through Window.Update. + // Load fetches the children under path while Idle and on retry; it marks the level Loading. Load func(path []string) // AnyLevel lets branches be chosen too; by default only leaves are. AnyLevel bool @@ -66,9 +65,7 @@ type cascaderState struct { type cascaderKey struct{} -// Cascader chooses a path through levels the caller supplies, loading -// children on demand. Up and Down move in a column, Right opens a branch, -// Left goes back, Enter or Space chooses; Escape closes. +// Cascader chooses a path through levels the caller supplies, loading children on demand. func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult { if opts.Level == nil { panic("mujica: Cascader needs Level") @@ -79,6 +76,9 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult tr.Label(opts.Label) } st := *ui.Local(tr, cascaderKey{}, func() *cascaderState { return &cascaderState{requested: map[string]bool{}} }) + if opts.Disabled { + st.open = false + } if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) { st.open = !st.open if st.open { @@ -186,7 +186,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader d := Density(c) full := append(slices.Clone(prefix), n.Value) depth := len(prefix) - b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2). + b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).FillWidth().Role(ui.RoleListItem) internal.Ring(b, k.Focus, theme.ControlRadius) if st.focusSet && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) { @@ -214,6 +214,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader st.focus, st.focusSet = slices.Clone(prefix), true } chosen := len(*path) > depth && slices.Equal((*path)[:depth+1], full) + b.Selected(chosen).Background(ui.Transparent).TextColor(k.Text) switch { case on: b.Background(k.Selection) @@ -224,6 +225,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader b.TextColor(k.AccentText) } b.Children(func() { + selectCheck(c, chosen) ui.Text(c, n.Label).SingleLine().Grow(1).Shrink(1) if !n.Leaf { ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/checkbox.go b/checkbox.go index c294aea..5a420ee 100644 --- a/checkbox.go +++ b/checkbox.go @@ -32,7 +32,7 @@ func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *u wrap.Children(func() { row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) }) - if opts.Disabled && wrap.FocusWithin() { + if wrap.IsDisabled() && wrap.FocusWithin() { wrap.FocusRing(false).Focus() } if row.Changed() && opts.Indeterminate && !*value { diff --git a/choice_blocked_test.go b/choice_blocked_test.go new file mode 100644 index 0000000..c2a4ca8 --- /dev/null +++ b/choice_blocked_test.go @@ -0,0 +1,105 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +// choiceBetween builds control between two buttons, for Tab order checks. +func choiceBetween(control func(c *ui.Context)) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Gap(10).Width(300).Children(func() { + Button(c, "Before", ButtonOptions{}) + control(c) + Button(c, "After", ButtonOptions{}) + }) + }, 400, 400) +} + +// choiceTabSkips reports whether Tab from Before lands on After. +func choiceTabSkips(t *testing.T, tt *ui.Tester) bool { + t.Helper() + if err := tt.Click("Before"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyTab) + return tt.Focused("After") +} + +func TestToggleDisabled(t *testing.T) { + for _, disabled := range []bool{false, true} { + on := false + tt := choiceBetween(func(c *ui.Context) { + Toggle(c, &on, "Italic", ToggleOptions{Disabled: disabled}) + }) + _ = tt.Click("Italic") + if on == disabled { + t.Fatalf("disabled=%v: click left on=%v", disabled, on) + } + if choiceTabSkips(t, tt) != disabled { + t.Fatalf("disabled=%v: Tab skip wrong", disabled) + } + } +} + +func TestSliderDisabled(t *testing.T) { + for _, disabled := range []bool{false, true} { + v := 30.0 + tt := choiceBetween(func(c *ui.Context) { + Slider(c, &v, SliderOptions{Step: 1, Disabled: disabled, Label: "Volume"}) + }) + _ = tt.Click("Volume") + if (v != 30) == disabled { + t.Fatalf("disabled=%v: click left %v", disabled, v) + } + if choiceTabSkips(t, tt) != disabled { + t.Fatalf("disabled=%v: Tab skip wrong", disabled) + } + } +} + +func TestRangeSliderDisabled(t *testing.T) { + for _, disabled := range []bool{false, true} { + low, high := 20.0, 40.0 + tt := choiceBetween(func(c *ui.Context) { + RangeSlider(c, &low, &high, RangeSliderOptions{Step: 1, Disabled: disabled, Label: "Price"}) + }) + _ = tt.Click("Price") + if (high != 40) == disabled || low != 20 { + t.Fatalf("disabled=%v: click left %v..%v", disabled, low, high) + } + if choiceTabSkips(t, tt) != disabled { + t.Fatalf("disabled=%v: Tab skip wrong", disabled) + } + } +} + +// Disabling a focused control in the frame a key arrives must block the key. +func TestChoiceDisabledWhileFocused(t *testing.T) { + disabled := false + on, pressed, group := false, false, []string{"left"} + items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}} + for name, build := range map[string]func(c *ui.Context){ + "switch": func(c *ui.Context) { Switch(c, &on, "Sync", SwitchOptions{Disabled: disabled}) }, + "toggle": func(c *ui.Context) { Toggle(c, &pressed, "Bold", ToggleOptions{Disabled: disabled}) }, + "group": func(c *ui.Context) { + items[1].Disabled = disabled + ToggleGroup(c, &group, items, ToggleGroupOptions{}) + }, + } { + disabled, on, pressed, group = false, false, false, []string{"left"} + tt := choiceBetween(build) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + if name == "group" { + tt.Key(0, ui.KeyRight) + } + disabled = true + tt.Key(0, ui.KeySpace) + if on || pressed || group[0] != "left" { + t.Errorf("%s: Space changed a disabled control: %v %v %v", name, on, pressed, group) + } + } +} diff --git a/choice_review_test.go b/choice_review_test.go new file mode 100644 index 0000000..ddf5f24 --- /dev/null +++ b/choice_review_test.go @@ -0,0 +1,211 @@ +package mujica + +import ( + "image/color" + "slices" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// A popup left open must stop taking choices once its control is disabled. +func TestChoicePopupsCloseWhenDisabled(t *testing.T) { + disabled := false + var multi []int + var paths, ids []string + f := newCascaderFake() + cases := []struct { + name, open, pick string + build func(c *ui.Context) + same func() bool + }{ + {"multi", "Select…", "Lily", func(c *ui.Context) { + MultiSelect(c, &multi, multiSelectTestOptions, MultiSelectOptions{Disabled: disabled}) + }, func() bool { return len(multi) == 0 }}, + {"cascader", "Select…", "Europe", func(c *ui.Context) { + Cascader(c, &paths, CascaderOptions{Level: f.level, Load: f.load, AnyLevel: true, Disabled: disabled}) + }, func() bool { return len(paths) == 0 }}, + {"tree", "Select…", "Guard", func(c *ui.Context) { + TreeSelect(c, &ids, treeSelectTestNodes, TreeSelectOptions{Disabled: disabled}) + }, func() bool { return len(ids) == 0 }}, + } + for _, tc := range cases { + disabled = false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Children(func() { tc.build(c) }) + }, 700, 500) + if err := tt.Click(tc.open); err != nil { + t.Fatal(err) + } + if !tt.HasText(tc.pick) { + t.Fatalf("%s: popup did not open", tc.name) + } + disabled = true + tt.Frame() + _ = tt.Click(tc.pick) + if !tc.same() || tt.HasText(tc.pick) { + t.Errorf("%s: disabled control took a choice or kept its popup", tc.name) + } + } +} + +func TestTreeSelectSingleMarksOwnNode(t *testing.T) { + single := TreeSelectOptions{} + court := &treeSelectTestNodes[0] + if got := treeSelectCheck(court, []string{"court"}, single); got != internal.Checked { + t.Fatalf("chosen parent shows %v", got) + } + if got := treeSelectCheck(court, []string{"king", "queen"}, single); got != internal.Unchecked { + t.Fatalf("parent of chosen leaves shows %v", got) + } + var sel []string + changes := 0 + tt := treeSelectTester(&sel, single, &changes) + _ = tt.Click("Select…") + _ = tt.Click("Court") + if !slices.Equal(sel, []string{"court"}) { + t.Fatalf("sel %v", sel) + } +} + +func TestTreeSelectRightSkipsHiddenChildren(t *testing.T) { + var sel []string + changes := 0 + tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true}, &changes) + _ = tt.Click("Select…") + tt.Type("queen") + tt.Key(0, ui.KeyDown) + if !tt.Focused("Court") { + t.Fatal("Down did not reach Court") + } + tt.Key(0, ui.KeyRight) + if !tt.Focused("Queen") { + t.Fatal("Right did not reach the only shown child") + } +} + +// pixel reads the light theme color at (x, y) of the last frame. +func pixel(tt *ui.Tester, x, y float32) color.RGBA { + return tt.Image().RGBAAt(int(x), int(y)) +} + +func TestSelectChosenRowHasSelectionFill(t *testing.T) { + v, changes := "banana", 0 + tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Move(0, 0) + tt.Key(0, ui.KeyDown) + blue, ok := tt.Find("Blueberry") + if !ok { + t.Fatal("popup closed") + } + h := theme.Default.ControlHeight() + got := pixel(tt, blue.X+blue.W+20, blue.Y+blue.H/2-h) + s := theme.Light().Selection + if got.R != s.R || got.G != s.G || got.B != s.B { + t.Fatalf("chosen row %v, want selection %v", got, s) + } +} + +func TestChoiceHitAreas(t *testing.T) { + v, changes := "apple", 0 + tt := selectTester(&v, selectTestOptions, SelectOptions{Clearable: true}, &changes) + if r, _ := tt.Find("Clear"); r.W < theme.MinHitSize || r.H < theme.MinHitSize { + t.Errorf("clear button %v", r) + } + var sel []string + tt = treeSelectTester(&sel, TreeSelectOptions{}, &changes) + _ = tt.Click("Select…") + if r, _ := tt.Find("Expand Court"); r.W < theme.MinHitSize || r.H < theme.MinHitSize { + t.Errorf("tree arrow %v", r) + } + font := "" + tt = ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Combobox(c, &font, comboboxTestFonts, ComboboxOptions{}) + }, 400, 300) + if r, _ := tt.Find("Show options"); r.W < theme.MinHitSize || r.H < theme.MinHitSize { + t.Errorf("combobox arrow %v", r) + } +} + +func TestChoiceRowsFollowDensity(t *testing.T) { + for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} { + v, on := "apple", false + var sw *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: d}) + ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() { + sw = Switch(c, &on, "Wi-Fi", SwitchOptions{}) + Select(c, &v, selectTestOptions, SelectOptions{Label: "Fruit"}) + }) + }, 400, 500) + if h := sw.Bounds().H; h < d.ControlHeight() { + t.Errorf("%v: switch %v high", d, h) + } + _ = tt.Click("Fruit") + a, _ := tt.Find("Cherry") + b, _ := tt.Find("Date") + if b.Y-a.Y < d.ControlHeight() { + t.Errorf("%v: option rows %v apart", d, b.Y-a.Y) + } + } +} + +func TestMultiSelectCaptionFollowsTextScale(t *testing.T) { + height := func(scale float32) float32 { + values := []int{1} + tt := multiSelectTester(&values, MultiSelectOptions{Max: 2}, new(int)) + tt.SetPreferences(ui.Preferences{TextScale: scale}) + _ = tt.Click("Rose") + r, ok := tt.Find("1 of 2 chosen") + if !ok { + t.Fatal("limit note missing") + } + return r.H + } + if one, two := height(1), height(2); two < one*1.5 { + t.Fatalf("caption %v high at 2x, %v at 1x", two, one) + } +} + +func TestComboboxCaretAfterChoice(t *testing.T) { + r := &comboboxRun{} + tt := comboboxTester(r, fixedFonts, ComboboxOptions{}) + tt.Key(0, ui.KeyTab) + tt.Type("di") + tt.Key(0, ui.KeyEnter) + if r.value != "Didot" { + t.Fatalf("value %q", r.value) + } + tt.Type("x") + if r.query != "Didotx" { + t.Fatalf("typing after a choice gave %q", r.query) + } +} + +// The first Tab enters a toggle group at its first enabled item; later at the last focused one. +func TestToggleGroupTabEntry(t *testing.T) { + value := []string{"right"} + tt := choiceBetween(func(c *ui.Context) { + ToggleGroup(c, &value, toggleGroupTestItems, ToggleGroupOptions{}) + }) + _ = tt.Click("Before") + tt.Key(0, ui.KeyTab) + if !tt.Focused("Left") { + t.Fatal("first Tab did not land on the first item") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyTab) + _ = tt.Click("Before") + tt.Key(0, ui.KeyTab) + if !tt.Focused("Center") { + t.Fatal("Tab did not return to the last focused item") + } +} diff --git a/color_picker.go b/color_picker.go index de993b6..1264351 100644 --- a/color_picker.go +++ b/color_picker.go @@ -91,13 +91,13 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP st.sync(*color) } } - if opts.Disabled { + if trigger.IsDisabled() { st.open = false } else if trigger.Clicked() { st.open = !st.open } border := k.ControlBorder - if trigger.Hovered() && !opts.Disabled { + if trigger.Hovered() { border = k.ControlBorder.Mix(k.Text, 0.25) trigger.Background(k.SurfaceHover) } @@ -160,7 +160,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP } }) }) - if st.wasOpen && !st.open && !opts.Disabled { + if st.wasOpen && !st.open && !trigger.IsDisabled() { trigger.Focus() } st.wasOpen = st.open @@ -212,10 +212,12 @@ func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPicker // colorPickerSlider is a channel slider from 0 to hi over the track track paints. func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element { k := Tokens(c) - const knob = 10 - s := ui.SliderBase(c, v, 0, hi).Height(18).PaddingX(knob / 2).FocusRing(false).Label(name) + const knob, look = 10, 18 + s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name) frac := float32(*v / hi) s.Draw(func(p *ui.Painter, r ui.Rect) { + // The hit area is taller than the drawn slider, which sits in its middle. + r = ui.Rect{X: r.X, Y: r.Y + (r.H-look)/2, W: r.W, H: look} bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6} p.Clip(bar, theme.ControlRadius, func() { track(p, bar) }) p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth) diff --git a/color_picker_test.go b/color_picker_test.go index 1b5e3d0..2a85958 100644 --- a/color_picker_test.go +++ b/color_picker_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester { @@ -111,7 +113,7 @@ func TestColorPickerDisabledAndParse(t *testing.T) { func TestColorPickerSnapshot(t *testing.T) { a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099") - snapshot(t, "color_picker", 340, 520, func(c *ui.Context) { + snapshot(t, "color_picker", 340, 560, func(c *ui.Context) { ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() { ColorPicker(c, &b, ColorPickerOptions{Disabled: true}) p := ColorPicker(c, &a, ColorPickerOptions{}) @@ -157,3 +159,17 @@ func TestColorPickerSwatchHitArea(t *testing.T) { t.Fatalf("swatch %v", r) } } + +func TestColorPickerSliderHitAreaCompact(t *testing.T) { + col := ui.Hex("#7D2034") + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + ColorPicker(c, &col, ColorPickerOptions{Label: "Accent"}) + }, 400, 560) + _ = tt.Click("Accent") + for _, name := range []string{"Hue", "Opacity"} { + if r, ok := tt.Find(name); !ok || r.H < 28 { + t.Fatalf("%s slider %v", name, r) + } + } +} diff --git a/combobox.go b/combobox.go index 8322289..d970a51 100644 --- a/combobox.go +++ b/combobox.go @@ -41,9 +41,7 @@ func (r ComboboxResult) QueryChanged() bool { return r.queryChanged } // comboboxKey keeps this file's state apart from MyGo's own. type comboboxKey struct{ name string } -// Combobox chooses *value by typing to filter options and picking one, as -// an autocomplete does. Up and Down move, Enter or a click chooses, Escape -// closes. With AllowCustom, Enter or leaving the field keeps typed text. +// Combobox chooses *value by typing to filter options and picking one, or typed text with AllowCustom. func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptions) ComboboxResult { k := Tokens(c) d := Density(c) @@ -52,9 +50,21 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio Gap(d.Unit() * 2).Radius(theme.ControlRadius).Background(k.Surface).Cursor(ui.CursorText).Disabled(opts.Disabled) query := ui.Local(f, comboboxKey{"query"}, func() string { return *value }) wasFocused := ui.Local(f, comboboxKey{"focused"}, func() bool { return false }) + // MyGo keeps the caret on replaced text (ui/editor.go:117): a choice builds a fresh input. + gen := ui.Local(f, comboboxKey{"input"}, func() int { return 0 }) + refocus := ui.Local(f, comboboxKey{"refocus"}, func() bool { return false }) var p *ui.ComboboxParts f.Children(func() { - p = ui.ComboboxBase(c, query) + ui.Row(c).Key(*gen).Grow(1).Children(func() { + p = ui.ComboboxBase(c, choiceTarget(query, opts.Disabled)) + }) + if *refocus { + p.Input.Focus() + *refocus = false + } + if opts.Disabled { + p.SetOpen(false) + } pad := max(0, (d.ControlHeight()-2-theme.BodySize*theme.LineHeight)/2) p.Input.Grow(1).MinWidth(80).Padding(pad, d.Unit()*3).Placeholder(opts.Placeholder) if opts.Label != "" { @@ -63,13 +73,16 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio if opts.Loading { Spinner(c, SpinnerOptions{Size: 14}) } - arrow := ui.Box(c).Size(16, 16).Shrink(0).Cursor(ui.CursorPointer) + arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Cursor(ui.CursorPointer). + Role(ui.RoleButton).Label(msg(c, "combobox.toggle")) if arrow.Clicked() { p.SetOpen(!p.Open()) p.Input.Focus() } open := p.Open() - arrow.Draw(func(pt *ui.Painter, r ui.Rect) { internal.Mark(pt, r, selectChevron(open), k.TextMuted) }) + arrow.Draw(func(pt *ui.Painter, r ui.Rect) { + internal.Mark(pt, selectCenter(r, 16), selectChevron(open), k.TextMuted) + }) }) in := p.Input if f.Clicked() { @@ -116,11 +129,13 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio focused := in.Focused() if v, ok := p.Chosen(); ok { *value, *query = v, v + *gen, *refocus = *gen+1, focused } else if in.Submitted() && opts.AllowCustom { *value = strings.TrimSpace(*query) } else if in.Submitted() && p.Open() && len(shown) > 0 { // Results came after typing left nothing highlighted: Enter takes the first. *value, *query = shown[0], shown[0] + *gen, *refocus = *gen+1, true p.SetOpen(false) } else if *wasFocused && !focused { if opts.AllowCustom { diff --git a/command_palette.go b/command_palette.go index f440299..1237bbe 100644 --- a/command_palette.go +++ b/command_palette.go @@ -81,6 +81,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { ww, wh := c.Size() back.Background(ui.RGBA(0, 0, 0, 0.4)).Justify(ui.Start) + internal.PopIn(c, back, Motion(c, theme.PopupDuration)) panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww-32).MaxHeight(wh*0.76). Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). Shadow(0, 12, 32, 0, ui.RGBA(0, 0, 0, 0.3)).AlignItems(ui.Stretch).Label(msg(c, "command_palette.label")) @@ -92,7 +93,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP }) ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone) if in.Changed() { - st.hl = 0 + st.hl, st.reveal = 0, true } n := len(found) switch { diff --git a/command_palette_test.go b/command_palette_test.go index 5384491..a7278ff 100644 --- a/command_palette_test.go +++ b/command_palette_test.go @@ -1,6 +1,7 @@ package mujica import ( + "fmt" "testing" "github.com/egoist/mygo/ui" @@ -100,6 +101,28 @@ func TestCommandPaletteAnnouncesHighlight(t *testing.T) { } } +func TestCommandPaletteFilterRevealsHighlight(t *testing.T) { + var cmds []Command + for i := range 30 { + cmds = append(cmds, Command{ID: fmt.Sprint(i), Label: fmt.Sprintf("Command %02d", i), Keywords: []string{"royal"}}) + } + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CommandPalette(c, &open, cmds, CommandPaletteOptions{}) + }, 800, 600) + tt.Key(0, ui.KeyUp) // wraps to the last, scrolling to the bottom + tt.Frame() + if r, _ := tt.Find("Command 00"); r.H > 0 { + t.Fatalf("list did not scroll to the end: first row at %+v", r) + } + tt.Type("royal") + tt.Frame() + if r, _ := tt.Find("Command 00"); r.H == 0 { + t.Fatal("highlighted first match is off screen after filtering") + } +} + func TestCommandPaletteEmptyClickEscape(t *testing.T) { a := &paletteApp{open: true} tt := ui.NewTester(a.view, 800, 600) diff --git a/context_menu.go b/context_menu.go index 468029c..5644a06 100644 --- a/context_menu.go +++ b/context_menu.go @@ -17,11 +17,7 @@ type contextMenuState struct { x, y float32 } -// ContextMenu gives target an in-window menu that build fills. A secondary -// click opens it at the pointer; the menu key or Shift+F10, while the focus -// is in target, opens it below target. It flips to stay in the window, and -// the focus returns on close. target must be a container: the menu adds an -// anchor child to it, and takes its HandleInput. It returns target. +// ContextMenu gives the container target an in-window menu at the pointer or below the focus. func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element { if target == nil { panic("mujica: ContextMenu needs a target") diff --git a/context_menu_test.go b/context_menu_test.go index c14c3c1..d767599 100644 --- a/context_menu_test.go +++ b/context_menu_test.go @@ -92,14 +92,19 @@ func TestContextMenuDisabled(t *testing.T) { } func TestContextMenuSnapshot(t *testing.T) { - snapshot(t, "context_menu", 460, 320, func(c *ui.Context) { - a := &menuApp{} + a := &menuApp{} + overlaySnapshot(t, "context_menu", 460, 320, func(c *ui.Context) { ui.Column(c).Padding(20).Children(func() { row := ui.Row(c).Height(36).FillWidth().Padding(0, 12).AlignItems(ui.Center).Background(Tokens(c).Selection) row.Children(func() { ui.Text(c, "Letter to the Countess") }) - anchor := ui.Box(c).Absolute().Left(140).Top(48).Size(1, 1) - open := true - popupMenuOpen(c, anchor, &open, a.build) + ContextMenu(c, row, ContextMenuOptions{}, a.build) }) + }, func(tt *ui.Tester) { + tt.RightClickAt(160, 40) + r, ok := tt.Find("Pinned") + if !ok { + t.Fatal("menu did not open") + } + tt.Move(r.X+4, r.Y+4) // hover highlight on Pinned }) } diff --git a/dialog.go b/dialog.go index 486e4f1..4aa7ed2 100644 --- a/dialog.go +++ b/dialog.go @@ -28,15 +28,17 @@ type DialogOptions struct { HideClose bool } -// Dialog shows a modal dialog while *open is true. It traps the focus, -// makes the window inert and gives the focus back to the opener on close. +// Dialog shows a modal dialog while *open is true, trapping and then restoring the focus. func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element { if opts.Title == "" { panic("mujica: Dialog needs a Title") } + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return nil } + overlayLayer(c, open) k := Tokens(c) shown, backClicked, closed := true, false, false panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { @@ -56,6 +58,7 @@ func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.E dialogActions(c, opts.Actions) } }) + focus.shown(panel) switch { case closed: slog.Debug("mujica: dialog close", "by", "button") @@ -106,7 +109,7 @@ func dialogHeader(c *ui.Context, title, description string, decorated, closable clicked := false ui.Column(c).Gap(u).Shrink(0).Children(func() { ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() { - ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).Grow(1) + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1) if closable { clicked = dialogCloseButton(c, msg(c, closeKey)).Clicked() } diff --git a/dialog_test.go b/dialog_test.go index 3706ea7..5546998 100644 --- a/dialog_test.go +++ b/dialog_test.go @@ -2,6 +2,7 @@ package mujica import ( "testing" + "time" "github.com/egoist/mygo/ui" @@ -173,6 +174,53 @@ func dialogSnapshotView(d theme.Density, decorated bool) func(c *ui.Context) { } } +func TestDialogEscapeHidesTooltipBeforeClosing(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Dialog(c, &open, DialogOptions{Title: "Outer", HideClose: true}, func() { + b := Button(c, "Seal", ButtonOptions{}) + Tooltip(c, b, "Seal with wax", TooltipOptions{Delay: time.Millisecond}) + }) + }, 600, 400) + tt.Key(0, ui.KeyTab) // keyboard focus on Seal + if !overlayUntil(tt, func() bool { return tt.HasText("Seal with wax") }) { + t.Fatal("tooltip did not show in the dialog") + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Seal with wax") || !open { + t.Fatalf("first Escape: tip shown %v, dialog open %v", tt.HasText("Seal with wax"), open) + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("second Escape did not close the dialog") + } +} + +func TestDialogFocusFallsBackWhenOpenerIsGone(t *testing.T) { + open, gone := false, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + Button(c, "First", ButtonOptions{}) + if !gone && Button(c, "Open", ButtonOptions{}).Clicked() { + open, gone = true, true + } + Dialog(c, &open, DialogOptions{Title: "Gone"}, nil) + Button(c, "Next", ButtonOptions{}) + }) + }, 600, 400) + _ = tt.Click("Open") + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Next") { + t.Fatal("focus did not fall back to the dialog's place in the window") + } +} + func TestDialogSnapshot(t *testing.T) { snapshot(t, "dialog", 560, 360, dialogSnapshotView(theme.Default, true)) snapshot(t, "dialog-compact", 560, 360, dialogSnapshotView(theme.Compact, false)) diff --git a/docs/components/003-status-bar.md b/docs/components/003-status-bar.md index 620b910..2610f7a 100644 --- a/docs/components/003-status-bar.md +++ b/docs/components/003-status-bar.md @@ -47,6 +47,7 @@ if bar.Clicked == "bell" { - 操作条目:悬停 SurfaceHover,按下 Selection,键盘焦点环。 - 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下(按实际宽度计算,操作条目至少 28 DIP,含条目间距);显示 `+N` 按钮,无障碍名称为“另有 N 项”,点击或 Enter/Space 打开弹层,列出被隐藏的条目,其中的操作照常可用,执行后弹层关闭。主要条目永不隐藏。 - 文本放大时状态栏随文字增高。 +- 焦点所在条目被折叠时,焦点移到收纳它的 `+N` 按钮上,不会丢失。 - 进度:AccentText 填充,Border 轨道,描述为百分比。 ## 事件 diff --git a/docs/components/021-button.md b/docs/components/021-button.md index ec90fe8..e22d459 100644 --- a/docs/components/021-button.md +++ b/docs/components/021-button.md @@ -21,7 +21,7 @@ if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Cli | `label` | `string` | 必填(仅图标时可为空) | 按钮文字;为空时必须提供 `Label`,否则 panic。 | | `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 | | `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 | -| `Loading` | `bool` | `false` | 有图标时加载标记占用图标位置;纯文字时标记居中覆盖在隐藏的文字上,宽度不变;阻止点击。 | +| `Loading` | `bool` | `false` | 加载标记出现在前置 16 DIP 图标位置,文字保持可见;有图标时替换图标,纯文字时从左右内边距中让出该位置,宽度不变;阻止点击。 | | `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 | | `Label` | `string` | `""` | 无障碍名称;`label` 为空时必填。 | diff --git a/docs/components/024-text-input.md b/docs/components/024-text-input.md index d363468..32d4e5f 100644 --- a/docs/components/024-text-input.md +++ b/docs/components/024-text-input.md @@ -32,7 +32,7 @@ if r.Submitted() { ## 状态 -默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。聚焦时被禁用会放弃焦点,已排队的按键不再修改值、不触发事件。颜色变化 120 毫秒过渡,减少动画时直接切换。 +默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。禁用来自自身或任一祖先容器;聚焦时被禁用会放弃焦点,已排队的按键不再修改值、不触发事件。颜色变化 120 毫秒过渡,减少动画时直接切换。 ## 事件 @@ -41,9 +41,10 @@ if r.Submitted() { ## 键盘操作 -Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;输入法组合期间不提交。 +Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;组合中的文本不会写入值。 ## 限制 +- 输入法组合期间无法阻止 Enter 提交:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。各平台输入法在组合时通常自己消费 Enter。 - MyGo 没有公开的只读输入接口(只读模式仅用于 `Text.Selectable`,`editor.go:77`)。`ReadOnly` 通过让编辑器编辑一个副本并在同帧还原实现,撤销栈里可能出现被还原的步骤。 - 清空按钮不进入 Tab 序列(与 MyGo SearchField 相同),键盘用 Escape 清空。 diff --git a/docs/components/025-text-area.md b/docs/components/025-text-area.md index c4f1b51..1204c78 100644 --- a/docs/components/025-text-area.md +++ b/docs/components/025-text-area.md @@ -45,5 +45,5 @@ if r.Submitted() { ## 限制 - `MaxLength` 只提示,不截断输入,以免打断输入法组合与粘贴。 -- 输入法组合期间无法阻止提交快捷键:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交。 +- 输入法组合期间无法阻止提交快捷键:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。 - 只读实现方式同 TextInput。 diff --git a/docs/components/026-number-input.md b/docs/components/026-number-input.md index 2c67ff0..17e1ec3 100644 --- a/docs/components/026-number-input.md +++ b/docs/components/026-number-input.md @@ -2,7 +2,7 @@ ## 用途 -输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式)。 +输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式);两种模式的步进计算都由组件完成。 ## 最小示例 @@ -26,7 +26,7 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab | `StepperOnly` | `bool` | `false` | 只显示数值与上下箭头,不可输入;必须给出 `Min < Max`,且不能与 `AllowEmpty` 同用。 | | `Placeholder` | `string` | `""` | 空值时的占位。 | | `Label` | `string` | `""` | 无障碍名称。 | -| `Disabled` | `bool` | `false` | 禁用。 | +| `Disabled` | `bool` | `false` | 禁用;任一祖先容器禁用时同样生效。 | | `Error` | `string` | `""` | 调用方给出的错误。 | ## 状态 @@ -39,11 +39,13 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab ## 键盘操作 -输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到最小、End 到最大;按住箭头持续步进。 +步进沿以 `Min`(无界时为 0)为起点、`Step` 为间隔的网格移动,结果按精度取整,并停在范围内(范围先向内取到精度上,如 `Max` 0.29999999、步长 0.1 时最大值为 0.2)。不在网格上的值先吸附到相邻网格点。 + +输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到 `Min`、End 到 `Max`(如 [0, 10] 步长 3 时 End 为 10,再 Down 为 9)。 ## 限制 - 输入模式下的加减按钮不进入 Tab 序列,键盘用 Up/Down。 -- 仅步进模式复用 MyGo `ui.Stepper`,箭头高度来自主题间距,不随密度的控件高度变化。 -- MyGo `ui.Stepper` 的箭头不检查禁用(`indicators.go:216`),禁用时组件向它传入副本,保证值不被修改。 -- MyGo 的步进器吸附依赖有限边界(`widgets.go` 中 `snap`),所以仅步进模式要求有界。 +- 仅步进模式不使用 MyGo `ui.Stepper`:它的箭头不检查禁用(`ui/indicators.go:216`),吸附固定按 9 位小数取整(`ui/widgets.go` 中 `snap`),步长 1e-10 时无法前进。组件自绘箭头并自行计算;按住箭头不会连续步进,需重复点击或使用方向键。 +- 仅步进模式要求有界,以便 Home/End 有目标值。 +- 仅步进模式的值通过无障碍描述朗读,MyGo 未公开为自定义元素设置数值范围的接口。 diff --git a/docs/components/028-search-input.md b/docs/components/028-search-input.md index afec587..c1d6ca8 100644 --- a/docs/components/028-search-input.md +++ b/docs/components/028-search-input.md @@ -48,5 +48,5 @@ if r.Moved() { ## 限制 - MyGo 单行编辑器把 Shift+Enter 当作普通提交且不公开修饰键(`editor.go:489`,`submitMods` 未导出),组件用 `HandleInput` 先截获 Shift+Enter。 -- 输入法组合期间无法阻止提交与匹配跳转:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交或跳转。 +- 输入法组合期间无法阻止提交与匹配跳转:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交或跳转。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。 - 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。 diff --git a/docs/components/030-radio-group.md b/docs/components/030-radio-group.md index 85e7886..3f6c9b7 100644 --- a/docs/components/030-radio-group.md +++ b/docs/components/030-radio-group.md @@ -49,6 +49,6 @@ Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循 ## 限制 -- 每个选项以 `Value` 为键,选项重新排序后焦点仍跟随原来的值。 +- 每个选项按 `Value` 相等性取得稳定键(首次出现的序号),选项重新排序后焦点仍跟随原来的值;组件记住见过的值,长期不断变化的选项集合会让这份记录增长。 - 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。 diff --git a/docs/components/033-toggle-group.md b/docs/components/033-toggle-group.md index f4acbce..4361ce2 100644 --- a/docs/components/033-toggle-group.md +++ b/docs/components/033-toggle-group.md @@ -54,7 +54,7 @@ ui.Render(view, 400, 300, 1) ## 键盘操作 -- Tab:进入组,落在按下项(或上次聚焦项);再按 Tab 离开。 +- Tab:进入组。首次落在第一个可用项(与哪项按下无关);之后回到组内上次聚焦的项。再按 Tab 离开。 - ←/→(纵向为 ↑/↓)、Home、End:在项之间移动焦点,跳过禁用项,首尾循环。 - Space:按下或松开当前项。 diff --git a/docs/components/034-slider.md b/docs/components/034-slider.md index f505676..2c26cec 100644 --- a/docs/components/034-slider.md +++ b/docs/components/034-slider.md @@ -25,7 +25,7 @@ ui.Render(view, 400, 300, 1) | `Step` | `float64` | 0 | 步长;值为 `Min + n·Step`。0 为连续。 | | `Marks` | `[]SliderMark` | nil | 刻度:`Value` 处画刻线,`Label` 非空时在下方显示文字。 | | `ShowValue` | `bool` | false | 悬停、拖动或聚焦时在滑块上方显示数值提示(预留高度,不改变布局)。 | -| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 | +| `Format` | `func(float64) string` | 按 `Min`、`Step` 所需小数位(连续时至少 1 位) | 数值文字。 | | `Disabled` | `bool` | false | 禁用。 | | `Label` | `string` | "" | 无障碍名称。 | @@ -44,5 +44,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 基于 `ui.SliderBase`。MyGo 的方向键步长固定为范围的 1%(ui/base.go:116),本组件把每次按键结果换算为一个 `Step`;当 `Step` 小于范围的 1% 且滑块距端点不足 1% 时,方向键会直接到端点。 -- `SliderBase` 在构建时就处理指针(ui/base.go:111),禁用时组件改为让它操作副本,保证值不变。 +- 基于 `ui.SliderBase`,它向辅助技术报告 Slider 角色、范围与当前值(ui/base.go:128)。 +- MyGo 只把方向键、Home、End 交给 `SliderBase` 本身(ui/base.go:118,按键路由见 ui/input.go:495),并固定以范围的 1% 移动。本组件让它操作副本,按方向判断按键后自己按 `Step` 移动;距端点不足 1% 时 MyGo 的方向键与 Home/End 结果相同,组件改看松开的是哪个键(`HandleInput` 能收到 KeyUp),在键松开前不改值。 +- `SliderBase` 在构建时就处理指针(ui/base.go:111),禁用时外层先禁用,且只操作副本,保证值不变。 diff --git a/docs/components/035-range-slider.md b/docs/components/035-range-slider.md index 9d0184f..1e70940 100644 --- a/docs/components/035-range-slider.md +++ b/docs/components/035-range-slider.md @@ -25,9 +25,9 @@ ui.Render(view, 400, 300, 1) | `Step` | `float64` | 0 | 步长,0 为连续。 | | `Marks` | `[]SliderMark` | nil | 刻度与文字。 | | `ShowValue` | `bool` | false | 悬停、拖动或聚焦时显示两端数值;低端提示向左、高端向右,互不遮挡。 | -| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 | +| `Format` | `func(float64) string` | 按 `Min`、`Step` 所需小数位(连续时至少 1 位) | 数值文字。 | | `Disabled` | `bool` | false | 禁用。 | -| `Label` | `string` | "" | 区间名称;两个滑块分别读作“`Label` 最小值, 20”“`Label` 最大值, 80”。 | +| `Label` | `string` | "" | 区间名称;两个滑块分别名为“`Label` 最小值”“`Label` 最大值”,数值与范围由 Slider 角色报告。 | ## 状态 @@ -46,5 +46,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- MyGo 自带的 `ui.RangeSlider` 使用玻璃质感滑块(ui/widgets.go:419),不符合本库风格;MyGo 也没有双滑块 Base,故本组件用公开的 `Dragged`、`PointerPosition`、`Shortcut` 组合实现。 -- 滑块的无障碍角色为 Slider,但数值范围属性只能由 `SliderBase` 设置(ui/base.go:128),因此当前值写在名称里(如“Price 最小值, 20”),屏幕阅读器读名称而非数值属性。 +- MyGo 自带的 `ui.RangeSlider` 使用玻璃质感滑块(ui/widgets.go:419),不符合本库风格;MyGo 也没有双滑块 Base,故每个滑块各是一个 `SliderBase`(报告 Slider 角色、范围与当前值,ui/base.go:128),拖动由外层的 `Dragged`、`PointerPosition` 处理。 +- 两个滑块的范围都报告为整条轨道 `Min`–`Max`;“不能越过另一端”这一动态上下限无法告诉辅助技术。 +- 按键处理与 Slider 相同:距端点不足 1% 时等到按键松开再移动(见 034 限制)。 diff --git a/docs/components/036-select.md b/docs/components/036-select.md index 0d16a96..5e0745e 100644 --- a/docs/components/036-select.md +++ b/docs/components/036-select.md @@ -36,7 +36,7 @@ ui.Render(view, 400, 300, 1) ## 状态 -默认、悬停(边框加深)、展开(酒红边框、箭头朝上)、聚焦、禁用;弹层中高亮项(浅底)、已选项(勾号 + 酒红文字)、禁用项、空状态。 +默认、悬停(边框加深)、展开(酒红边框、箭头朝上)、聚焦、禁用;弹层中高亮项(浅底)、已选项(玫瑰底 + 勾号 + 酒红文字)、键盘高亮项另有左侧竖条、禁用项、空状态。 ## 事件 @@ -52,4 +52,5 @@ ui.Render(view, 400, 300, 1) ## 限制 - 基于 `ui.SelectBase`。其高亮索引没有公开的设置接口(ui/base.go:260),所以弹层打开时输入字母不移动高亮,只在弹层关闭时定位。 -- 清空按钮不占 Tab 位(与 MyGo 搜索框一致),键盘用 Backspace/Delete 清空。 +- 清空按钮不占 Tab 位(与 MyGo 搜索框一致),键盘用 Backspace/Delete 清空;它的命中区域为 28×28 DIP。 +- 选项行的角色为 ListItem,选中状态由 `SelectBase.Item` 报告。`SelectParts` 没有关闭弹层的接口(ui/base.go:310),所以选择器在弹层打开时被禁用后不再构建弹层;重新启用时弹层会再次显示。 diff --git a/docs/components/037-multi-select.md b/docs/components/037-multi-select.md index c02624f..ba56e64 100644 --- a/docs/components/037-multi-select.md +++ b/docs/components/037-multi-select.md @@ -49,6 +49,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 选项行基于 `ui.CheckboxBase` 自绘方框;全选的“半选”只在画面上表示,MyGo 没有为自定义元素设置 mixed 状态的公开接口(ui/element.go:282 的 checked 字段仅由内部设置),屏幕阅读器读作未选中。 +- 选项行基于 `ui.CheckboxBase` 自绘方框,选中状态由 MyGo 报告。全选的“半选”无法作为 mixed 状态报告:MyGo 没有为自定义元素设置它的公开接口(ui/element.go:282 的 checked 字段仅由内部设置)。组件改用 `Description` 读出“部分选中”,屏幕阅读器仍把复选框本身读作未选中。 - 标签上的移除按钮不占 Tab 位,键盘用 Backspace 移除。 - 列表未虚拟化,适合数百项以内。 diff --git a/docs/components/038-combobox.md b/docs/components/038-combobox.md index 50ffaea..ac2a450 100644 --- a/docs/components/038-combobox.md +++ b/docs/components/038-combobox.md @@ -53,3 +53,4 @@ ui.Render(view, 400, 300, 1) - 基于 `ui.ComboboxBase`。它在没有选项时隐藏弹层(ui/combobox.go:191),所以“加载中”“无匹配”用一个不接收输入的独立浮层显示。 - 异步结果在打字后才到达时 MyGo 不会自动高亮首项,组件让 Enter 选择首个结果。 +- MyGo 替换输入框文字时保留原光标位置(ui/editor.go:117),且不公开光标设置;组件在选择后重建输入框,使光标落在新文字末尾并保持焦点。 diff --git a/docs/components/039-cascader.md b/docs/components/039-cascader.md index 82499ad..58ad106 100644 --- a/docs/components/039-cascader.md +++ b/docs/components/039-cascader.md @@ -58,3 +58,4 @@ ui.Render(view, 400, 300, 1) - `Load` 在视图构建中同步调用,不得阻塞;异步任务完成后请用 `Window.Update` 回到界面线程更新数据。 - 列表未虚拟化,每列适合数百项以内。 +- 已选路径上的节点通过 `Selected` 报告选中;当前展开的分支无法报告 expanded 状态(ui/base.go:297 等处仅内部设置),仅在画面上用底色表示。 diff --git a/docs/components/040-tree-select.md b/docs/components/040-tree-select.md index 89611cb..d49adee 100644 --- a/docs/components/040-tree-select.md +++ b/docs/components/040-tree-select.md @@ -55,5 +55,6 @@ ui.Render(view, 400, 300, 1) ## 限制 - 树行基于 `ui.CheckboxBase` / `ui.ButtonBase` 自绘。MyGo 的 `TreeItem` 只能显示文字标签,无法放入复选框(ui/tree.go:44),故未复用。 -- 展开状态与半选状态只在画面上表示:MyGo 不对自定义元素公开 expanded 与 mixed 的设置(ui/base.go:297、ui/element.go:282 仅内部设置),屏幕阅读器读不到这两个状态;展开箭头带“展开/收起 + 节点名”的名称。 +- MyGo 不对自定义元素公开 expanded 与 mixed 状态的设置(ui/tree.go:98、ui/element.go:282 仅内部设置)。组件改用 `Description` 读出“已展开/已收起”“部分选中”,展开箭头带“展开/收起 + 节点名”的名称;屏幕阅读器读不到真正的 expanded/mixed 状态属性。单选行通过 `Selected` 报告选中,并显示勾号。 +- `CollapsibleBase` 的触发器能报告 expanded,但它自身要占一个焦点位,会让方向键在箭头与节点间交替停留,故未采用。 - 列表未虚拟化,适合数千节点以内。 diff --git a/docs/components/061-breadcrumb.md b/docs/components/061-breadcrumb.md index 15240cd..482f51a 100644 --- a/docs/components/061-breadcrumb.md +++ b/docs/components/061-breadcrumb.md @@ -55,3 +55,4 @@ if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok - 完整名称提示对每一项都提供,MyGo 不报告文字是否被截断。 - 未复用 MyGo `ui.Breadcrumbs`:它不支持折叠与提示,本组件用 `ButtonBase`、`PopoverBase` 组合实现。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 diff --git a/docs/components/062-pagination.md b/docs/components/062-pagination.md index 36943b7..1b4e370 100644 --- a/docs/components/062-pagination.md +++ b/docs/components/062-pagination.md @@ -51,3 +51,4 @@ if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageS ## 限制 - 页容量选择基于 MyGo `SelectBase`,弹层由 MujicaUI 绘制。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 diff --git a/docs/components/065-navigation-menu.md b/docs/components/065-navigation-menu.md index 630ca64..032f112 100644 --- a/docs/components/065-navigation-menu.md +++ b/docs/components/065-navigation-menu.md @@ -59,3 +59,5 @@ router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) }) - MyGo 历史按钮的提示文字固定为英文 “Back” / “Forward”(`ui/router.go:713`);本组件覆盖了无障碍名称,但提示仍为英文。 - 历史按钮的外观来自 MyGo 主题按钮样式(经 `mujica.Use` 映射为 MujicaUI 颜色)。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 +- 下拉面板最高为锚点上方或下方可用空间,超出时在面板内滚动;方向键移动焦点时自动滚入视野。 diff --git a/docs/components/066-menubar.md b/docs/components/066-menubar.md index 985d943..76ca596 100644 --- a/docs/components/066-menubar.md +++ b/docs/components/066-menubar.md @@ -72,3 +72,5 @@ if id, ok := res.Chosen(); ok && id == "wrap" { - MyGo 没有 menu / menuitem 角色(`ui/access.go:25-79`),项以按钮呈现;勾选状态无法通过公开接口写入(`ui/base.go:56` 的 checked 为内部字段),因此写入名称。 - 指针点击非可聚焦元素会清除焦点(`ui/input.go:238`);选择后组件把焦点还给对应标题。 - 菜单打开期间,焦点保持在标题上,高亮项由组件管理。驱动菜单的按键注册在标题上,而 MyGo 的 active descendant 不公开,因此高亮变化时用 `c.Announce` 朗读当前项,并把它写入标题的 `Description`。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 +- 点击菜单内任意位置(包括禁用项)或用指针打开子菜单后,焦点回到所属标题,方向键、Enter、Escape 继续有效。 diff --git a/docs/components/068-command-palette.md b/docs/components/068-command-palette.md index 85b5911..c232838 100644 --- a/docs/components/068-command-palette.md +++ b/docs/components/068-command-palette.md @@ -64,3 +64,5 @@ 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/components/081-dialog.md b/docs/components/081-dialog.md index 679d06d..73afded 100644 --- a/docs/components/081-dialog.md +++ b/docs/components/081-dialog.md @@ -71,5 +71,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 关闭后焦点回到打开时的控件;若该控件已不存在,MyGo 不回退到父级(`ui/scope.go:137` restoreFocus 只恢复原控件),焦点为空,再按 Tab 从窗口开头开始。 -- 没有关闭过渡:MyGo 在 `*open` 变为 false 的下一帧即移除浮层元素,`Transition` 的 Exit 需要父元素保留,而浮层根只在有浮层时存在。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 +- 没有关闭过渡:`*open` 变为 false 的下一帧浮层元素即被移除。 diff --git a/docs/components/082-alert-dialog.md b/docs/components/082-alert-dialog.md index 40c2a18..b352f42 100644 --- a/docs/components/082-alert-dialog.md +++ b/docs/components/082-alert-dialog.md @@ -2,7 +2,7 @@ ## 用途 -确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未确认目标前 Enter 不会执行。 +确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未输入前确认按钮禁用;输入框中的 Enter 从不确认。 ## 最小示例 @@ -46,7 +46,7 @@ ui.Render(view, 400, 300, 1) ## 事件 -- `Confirmed`:点击确认,或在输入框内按 Enter 且目标已匹配。 +- `Confirmed`:点击确认,或焦点在已解锁的确认按钮上按 Enter / Space。 - `Cancelled`:点击取消、在取消上按 Enter / Space、或按 Escape。 - 点击遮罩不产生事件。 @@ -55,11 +55,13 @@ ui.Render(view, 400, 300, 1) | 按键 | 行为 | | --- | --- | | 打开时 | 焦点在“取消”。 | -| Enter | 触发聚焦的按钮;在输入框中只有目标匹配时确认。 | +| Enter | 触发聚焦的按钮;在输入框中不确认,Tab 到确认按钮后再按。 | | Escape | 取消并关闭。 | | Tab | 在对话框内循环。 | ## 限制 -- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(MyGo 只在键盘移动焦点后设置可见焦点,`ui/input.go:868`);按 Tab 后显示。 -- 不替代系统原生警示框;需要系统样式时用 MyGo 的桌面对话框。 +- 输入框中的 Enter 从不确认,必须用 Tab 移到确认按钮:MyGo 在 Enter 时先提交输入法组合再处理按键(`ui/editor.go:688`),而组合状态不公开,无法区分“提交组合”的 Enter。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 +- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(`ui/input.go:868`);按 Tab 后显示。 diff --git a/docs/components/083-drawer.md b/docs/components/083-drawer.md index dce31ee..5f2a080 100644 --- a/docs/components/083-drawer.md +++ b/docs/components/083-drawer.md @@ -51,5 +51,7 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 非模态抽屉不在打开时移动焦点,关闭时也不自动恢复焦点:MyGo 记录与恢复打开者的 `openOverlay` 不公开(`ui/scope.go:123`),也没有读取当前焦点的公开接口。 -- 非模态抽屉的 Escape 是窗口级快捷键,优先级最低;同时打开多个非模态浮层时由先构建的那个响应。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 +- 非模态抽屉打开时不移动焦点,其内容排在窗口焦点顺序末尾。 diff --git a/docs/components/084-popover.md b/docs/components/084-popover.md index a1cf618..96b43a4 100644 --- a/docs/components/084-popover.md +++ b/docs/components/084-popover.md @@ -50,5 +50,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 只支持下方与上方两个位置:位置由 MyGo `PopoverBase` 决定(`ui/widgets.go:689` keepInWindow)。 -- 打开时外部点击只用于关闭,不会传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 只支持下方与上方两个位置(`ui/widgets.go:689` keepInWindow);宽度不超过窗口,过高时内容滚动。 diff --git a/docs/components/085-tooltip.md b/docs/components/085-tooltip.md index c3137b2..0700c27 100644 --- a/docs/components/085-tooltip.md +++ b/docs/components/085-tooltip.md @@ -41,5 +41,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发,避免点击后弹出提示。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发。 - 不使用 MyGo 的 `Element.Tooltip`:它只跟随指针、不响应焦点(`ui/widgets.go:549`)。 diff --git a/docs/components/086-hover-card.md b/docs/components/086-hover-card.md index 18c1dc5..16701ac 100644 --- a/docs/components/086-hover-card.md +++ b/docs/components/086-hover-card.md @@ -48,5 +48,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 键盘打开时卡片由 `PopoverBase` 承载,以便焦点顺序紧跟锚点;此时卡片外的点击只用于关闭(`ui/base.go:373`)。指针打开时卡片不拦截窗口其余部分。 -- 指针打开时卡片元素在窗口焦点顺序的末尾。 +- 卡片由 `PopoverBase` 承载,使焦点顺序紧跟锚点,在 Dialog 内也属于对话框的焦点范围;代价是打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- 指针在卡片外但焦点仍在卡片内时卡片保持打开,点击外部或按 Escape 关闭。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 diff --git a/docs/components/087-dropdown-menu.md b/docs/components/087-dropdown-menu.md index 605c7a7..6eb6bad 100644 --- a/docs/components/087-dropdown-menu.md +++ b/docs/components/087-dropdown-menu.md @@ -72,4 +72,5 @@ ui.Render(view, 400, 300, 1) ## 限制 - MyGo 没有 menu / menuitem 无障碍角色(`ui/access.go:22` Role 列表),菜单面板报告为 popup,普通项为 button,勾选项为 checkbox,触发按钮为 menu button。 -- 菜单打开期间外部点击只用于关闭(`ui/base.go:373`)。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 diff --git a/docs/components/088-context-menu.md b/docs/components/088-context-menu.md index b37874d..f4f0117 100644 --- a/docs/components/088-context-menu.md +++ b/docs/components/088-context-menu.md @@ -51,5 +51,7 @@ ui.Render(view, 400, 300, 1) ## 限制 - `target` 必须是容器:菜单会向它追加一个不可见的定位子元素,文字元素不能有非文字子元素(MyGo 会 panic)。 -- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(MyGo 每个元素只保存一个处理函数,`ui/handler.go:73`)。 +- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(`ui/handler.go:73`)。 - 不使用 MyGo 的 `Element.ContextMenu`:那是系统菜单,PRD 不允许把它当作哥特样式菜单。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 diff --git a/docs/components/089-popconfirm.md b/docs/components/089-popconfirm.md index 7998d35..caea581 100644 --- a/docs/components/089-popconfirm.md +++ b/docs/components/089-popconfirm.md @@ -55,5 +55,5 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 非模态:不屏蔽窗口,但外部点击只用于取消,不传给下方控件(`ui/base.go:373`)。 -- 位置只有下方与上方(`ui/widgets.go:689`)。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- 位置只有下方与上方(`ui/widgets.go:689`);宽度不超过窗口,说明过长时滚动,按钮始终可见。 diff --git a/docs/components/090-overlay.md b/docs/components/090-overlay.md index 053a732..3aac2c8 100644 --- a/docs/components/090-overlay.md +++ b/docs/components/090-overlay.md @@ -46,6 +46,7 @@ Escape 与(模态时)背景点击把 `*open` 设为 false。 ## 限制 -- 非模态覆盖层的 Escape 是窗口级快捷键(MyGo 浮层专用快捷键 `overlayShortcut` 不公开,`ui/scope.go:109`),同时打开多个非模态浮层时由先构建的那个响应。 -- 非模态覆盖层关闭时不自动恢复焦点(`ui/scope.go:123` openOverlay 不公开)。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 - 非模态面板首帧尚未测量尺寸,该帧透明,下一帧定位后淡入。 diff --git a/docs/components/093-progress.md b/docs/components/093-progress.md index 36caa84..ebfc47f 100644 --- a/docs/components/093-progress.md +++ b/docs/components/093-progress.md @@ -42,7 +42,7 @@ mujica.Meter(c, 93, mujica.MeterOptions{ | `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 | | `Label` | `string` | `""` | 名称。 | | `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 | -| `Width` | `float32` | 填满 | 宽度。 | +| `Width` | `float32` | 填满 | 宽度;负数、NaN 或无穷 panic。 | ## 状态 @@ -60,5 +60,5 @@ mujica.Meter(c, 93, mujica.MeterOptions{ ## 限制 -- 环形直径小于 44 DIP 时百分比显示在旁边,而不是环内。 +- 百分比按缩放后的文字尺寸测量,放不进环内时显示在环旁边。 - MyGo 不提供自定义无障碍数值的公开接口,因此必须基于 `ui.Progress` / `ui.Meter` 元素绘制。 diff --git a/docs/upstream.md b/docs/upstream.md new file mode 100644 index 0000000..8e180d0 --- /dev/null +++ b/docs/upstream.md @@ -0,0 +1,38 @@ +# 上游归并与上游缺口 + +## 归并(PRD 4.11) + +MyGo 已有能力纳入下列组件,不增加计数。 + +| MyGo 能力 | MujicaUI 组件 | 文件 | +| --- | --- | --- | +| Primary button、Menu button | Button 主按钮变体;DropdownMenu | `button.go`、`dropdown_menu.go` | +| Stepper、Number input | NumberInput 仅步进模式与输入模式 | `number_input.go` | +| Search field、Find bar | SearchInput 搜索模式与查找栏模式 | `search_input.go` | +| Autocomplete、Combobox | Combobox 自由输入与选项选择 | `combobox.go` | +| Token field、Editable text | TagsInput;Text 的 `EditableText` | `tags_input.go`、`text.go` | +| List、Grid view | List 列表模式与网格模式 | `list.go` | +| Table、Outline、Tree | DataTable;TreeTable;Tree | `data_table.go`、`tree_table.go`、`tree.go` | +| Date input、Time input | DatePicker;TimePicker | `date_picker.go`、`time_picker.go` | +| Back and forward buttons | NavigationMenu 历史导航 | `navigation_menu.go` | +| Meter、Progress bar | Progress 计量模式与进度模式 | `progress.go` | + +## 上游缺口(待提交 MyGo) + +PRD 第 2 节要求公开接口不足时向上游提交扩展。以下缺口在 MyGo v0.2.10 中核实,组件文档的“限制”节写明影响。本仓库不访问 MyGo internal,也未修改依赖。 + +| 缺口 | 位置 | 受影响组件 | +| --- | --- | --- | +| 自定义元素无法设置 expanded、mixed、数值范围与值 | `ui/element.go:282-295`、`ui/base.go:128,297` | Checkbox 半选、MultiSelect、TreeSelect、RangeSlider、Rating、全部日期时间弹出按钮 | +| 无 menu、menuitem 角色 | `ui/access.go:22-79` | Menubar、DropdownMenu、ContextMenu | +| 无 heading 角色 | `ui/access.go` | Heading | +| 输入法组合状态不公开 | `ui/editor.go:73`、`ui/input.go:689` | TextInput、TextArea、SearchInput、Dialog、AlertDialog | +| 打开链接失败不回传 | `ui/context.go:270`、`ui/window.go:583` | Link | +| 浮层焦点恢复与浮层快捷键不公开 | `ui/scope.go:109,123,137` | Dialog、Drawer、Overlay | +| `PopoverBase` 背板吞掉外部点击;只有上下两个位置 | `ui/base.go:373`、`ui/widgets.go:689` | Popover、Popconfirm、DropdownMenu、HoverCard | +| `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 | +| `ui.Stepper` 禁用时仍可步进,固定 9 位取整 | `ui/indicators.go:216`、`ui/widgets.go` `snap` | NumberInput(已改为自绘) | +| 路径遮罩缓存按四分之一像素取键 | `ui/path.go:189-210` | 全部自绘路径(以 `internal.Snap` 对齐网格) | diff --git a/drawer.go b/drawer.go index 5897a86..dee05e8 100644 --- a/drawer.go +++ b/drawer.go @@ -22,8 +22,7 @@ const ( type DrawerOptions struct { Title string Side DrawerSide - // Size is the width (left, right) or height (top, bottom): 360 or 280 DIPs when zero. - // It is kept between 160 DIPs and 90% of the window. + // Size is the width or height (360 or 280 when zero), kept within 160 DIPs and 90% of the window. Size float32 // NonModal leaves the window usable and the focus free. NonModal bool @@ -31,8 +30,7 @@ type DrawerOptions struct { Actions func() } -// Drawer shows a panel along a window edge while *open is true. A modal -// drawer traps the focus and restores it on close; Escape closes either kind. +// Drawer shows a panel along a window edge while *open is true; Escape closes it. func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element { if opts.Title == "" { panic("mujica: Drawer needs a Title") @@ -43,7 +41,9 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E if opts.Size < 0 { panic("mujica: Drawer Size is negative") } + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return nil } k := Tokens(c) @@ -69,8 +69,9 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E drawerPlace(c, panel, opts.Side, size*p) panel.Children(func() { body(panel, p) }) }) - escaped = c.Shortcut(0, ui.KeyEscape) + escaped = overlayEscape(c, open) } else { + overlayLayer(c, open) shown := true panel = ui.DialogBase(c, &shown, func(back, panel *ui.Element) { p := overlayEnter(c, panel) @@ -80,6 +81,7 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E }) escaped = !shown } + focus.shown(panel) if closed || escaped { slog.Debug("mujica: drawer close", "escape", escaped) *open = false diff --git a/dropdown_menu.go b/dropdown_menu.go index aafd4cd..87bc084 100644 --- a/dropdown_menu.go +++ b/dropdown_menu.go @@ -15,10 +15,7 @@ type DropdownMenuOptions struct { Label string } -// DropdownMenu is a button opening an in-window menu that build fills. -// Click, Enter, Space or Down opens it with the focus on the first item; -// the arrows, Home and End move, Escape or Tab closes, and the focus -// returns to the button. It returns the button. +// DropdownMenu is a button opening an in-window menu that build fills; it returns the button. func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element { if build == nil { panic("mujica: DropdownMenu needs a build function") diff --git a/dropdown_menu_test.go b/dropdown_menu_test.go index f6b46b6..394ef59 100644 --- a/dropdown_menu_test.go +++ b/dropdown_menu_test.go @@ -145,14 +145,38 @@ func TestDropdownMenuDisabled(t *testing.T) { } } +func TestDropdownMenuTabInSubmenuClosesMenu(t *testing.T) { + a := &menuApp{} + tt := ui.NewTester(a.view, 500, 400) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Archive") { + t.Fatal("submenu not entered") + } + tt.Key(0, ui.KeyTab) + if tt.HasText("Archive") || tt.HasText("Rename") { + t.Fatal("Tab in a submenu left the menu open") + } + if !tt.Focused("Actions") { + t.Fatal("focus did not return to the trigger") + } +} + func TestDropdownMenuSnapshot(t *testing.T) { - snapshot(t, "dropdown_menu", 460, 320, func(c *ui.Context) { - a := &menuApp{pinned: true} + a := &menuApp{pinned: true} + overlaySnapshot(t, "dropdown_menu", 520, 320, func(c *ui.Context) { ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { - b := ui.ButtonBase(c) - popupMenuTrigger(c, b, "Actions", nil, true) - open := true - popupMenuOpen(c, b, &open, a.build) + DropdownMenu(c, "Actions", DropdownMenuOptions{}, a.build) }) + }, func(tt *ui.Tester) { + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) // opens with the first item focused + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyRight) // opens Move to, focus ring on Archive + if !tt.Focused("Archive") { + t.Fatal("submenu did not open") + } }) } diff --git a/examples/gallery/pages_choice.go b/examples/gallery/pages_choice.go index fc64345..fbdd6a9 100644 --- a/examples/gallery/pages_choice.go +++ b/examples/gallery/pages_choice.go @@ -105,10 +105,10 @@ view := func(c *ui.Context) { func choiceDemo(c *ui.Context, controls, samples func()) { k := mujica.Tokens(c) ui.Column(c).Gap(20).AlignItems(ui.Start).Children(func() { - ui.Text(c, "Parameters").Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text) + ui.Text(c, "Parameters").Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text) ui.Row(c).Gap(20).Wrap().AlignItems(ui.Center).Children(controls) ui.Box(c).Height(1).FillWidth().Background(k.Ornament) - ui.Text(c, "States").Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text) + ui.Text(c, "States").Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text) ui.Column(c).Gap(16).AlignItems(ui.Start).Children(samples) }) } @@ -123,7 +123,7 @@ func choiceFlag(c *ui.Context, on *bool, label string, log func(string)) { // choiceLabeled shows a caption above a sample. func choiceLabeled(c *ui.Context, caption string, fn func()) { ui.Column(c).Gap(6).Children(func() { - ui.Text(c, caption).FontSize(theme.CaptionSize).TextColor(mujica.Tokens(c).TextMuted) + ui.Text(c, caption).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(mujica.Tokens(c).TextMuted) fn() }) } @@ -288,7 +288,7 @@ var choiceFruits = []mujica.SelectOption[string]{ func selectDemo(c *ui.Context, log func(string)) { type state struct { - value string + value, picked string clearable, disabled, noData bool } s := *ui.Local(c.Root(), "036", func() *state { return &state{value: "banana", clearable: true} }) @@ -306,9 +306,10 @@ func selectDemo(c *ui.Context, log func(string)) { log(fmt.Sprintf("Fruit: %q", s.value)) } }) - empty := "" choiceLabeled(c, "Placeholder", func() { - mujica.Select(c, &empty, choiceFruits, mujica.SelectOptions{Placeholder: "Pick a fruit"}) + if mujica.Select(c, &s.picked, choiceFruits, mujica.SelectOptions{Placeholder: "Pick a fruit", Clearable: true}).Changed() { + log(fmt.Sprintf("Picked: %q", s.picked)) + } }) }) } diff --git a/examples/gallery/pages_choice_test.go b/examples/gallery/pages_choice_test.go new file mode 100644 index 0000000..9f22100 --- /dev/null +++ b/examples/gallery/pages_choice_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// choiceDemoTester runs the demo of page id at text scale and returns its tester and event log. +func choiceDemoTester(t *testing.T, id string, scale float32) (*ui.Tester, *[]string) { + t.Helper() + i := slices.IndexFunc(choicePages, func(p page) bool { return p.ID == id }) + if i < 0 { + t.Fatalf("no page %s", id) + } + events := &[]string{} + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{Mode: mujica.Light}) + ui.Scroll(c).Fill().Padding(24).Children(func() { + choicePages[i].Demo(c, func(s string) { *events = append(*events, s) }) + }) + }, 900, 700) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: scale}) + return tt, events +} + +func TestChoiceSelectDemoKeepsPlaceholderChoice(t *testing.T) { + tt, events := choiceDemoTester(t, "036", 1) + if err := tt.Click("Pick a fruit"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Fig"); err != nil { + t.Fatal(err) + } + tt.Frame() + if !tt.HasText("Fig") || !slices.Contains(*events, `Picked: "fig"`) { + t.Fatalf("choice lost: texts %q, events %q", tt.Texts(), *events) + } +} + +func TestChoiceDemoHeadingsFollowTextScale(t *testing.T) { + height := func(scale float32) float32 { + tt, _ := choiceDemoTester(t, "031", scale) + tt.Frame() + r, ok := tt.Find("Parameters") + if !ok { + t.Fatal("heading missing") + } + return r.H + } + if one, two := height(1), height(2); two < one*1.5 { + t.Fatalf("heading %v high at 2x, %v at 1x", two, one) + } +} diff --git a/examples/gallery/pages_display.go b/examples/gallery/pages_display.go index 2f11f00..296c0cc 100644 --- a/examples/gallery/pages_display.go +++ b/examples/gallery/pages_display.go @@ -74,7 +74,6 @@ func textDemo(c *ui.Context, log func(string)) { log("edit canceled") } }) - displayCode(c, textCode, log) } const textCode = `mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true}) @@ -113,7 +112,6 @@ func headingDemo(c *ui.Context, log func(string)) { mujica.Heading(c, fmt.Sprintf("Level %d · 宫廷", l), mujica.HeadingOptions{Level: l}) } }) - displayCode(c, headingCode, log) } const headingCode = `mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true}) @@ -163,7 +161,6 @@ func iconDemo(c *ui.Context, log func(string)) { mujica.Icon(c, "missing-name", mujica.IconOptions{Size: 20}) }) }) - displayCode(c, iconCode, log) } const iconCode = `ui.Row(c).Gap(8).Children(func() { @@ -203,7 +200,6 @@ func avatarGroupDemo(c *ui.Context, log func(string)) { } } }) - displayCode(c, avatarGroupCode, log) } const avatarGroupCode = `mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})` @@ -250,7 +246,6 @@ func badgeDemo(c *ui.Context, log func(string)) { mujica.Badge(c, opts, func() { mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40}) }) }) }) - displayCode(c, badgeCode, log) } const badgeCode = `mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil) @@ -303,7 +298,6 @@ func tagDemo(c *ui.Context, log func(string)) { } }) }) - displayCode(c, tagCode, log) } const tagCode = `for i, t := range tags { @@ -345,7 +339,6 @@ func linkDemo(c *ui.Context, log func(string)) { log("in-app action") } }) - displayCode(c, linkCode, log) } const linkCode = `mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"}) @@ -384,7 +377,6 @@ func kbdDemo(c *ui.Context, log func(string)) { }) } }) - displayCode(c, kbdCode, log) } const kbdCode = `ui.Row(c).Gap(6).Children(func() { diff --git a/examples/gallery/pages_display_media.go b/examples/gallery/pages_display_media.go index 945b929..b22abc5 100644 --- a/examples/gallery/pages_display_media.go +++ b/examples/gallery/pages_display_media.go @@ -59,7 +59,6 @@ func imageDemo(c *ui.Context, log func(string)) { } }) }) - displayCode(c, imageCode, log) } const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{ @@ -111,7 +110,6 @@ func avatarDemo(c *ui.Context, log func(string)) { mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40}) }) }) - displayCode(c, avatarCode, log) } const avatarCode = `ui.Row(c).Gap(8).Children(func() { diff --git a/examples/gallery/pages_display_parts.go b/examples/gallery/pages_display_parts.go index b0bc3fb..e724d2d 100644 --- a/examples/gallery/pages_display_parts.go +++ b/examples/gallery/pages_display_parts.go @@ -6,7 +6,6 @@ import ( "github.com/egoist/mygo/ui" mujica "mujicaui" - "mujicaui/theme" ) // displaySection shows a titled block of a demo. @@ -22,17 +21,6 @@ func displayControls(c *ui.Context, fn func()) { ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn) } -// displayCode shows a page's snippet with a copy button. -func displayCode(c *ui.Context, code string, log func(string)) { - displaySection(c, "Code", func() { - if mujica.Button(c, "Copy code", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() { - c.WriteClipboard(code) - log("code copied") - } - mujica.Text(c, code, mujica.TextOptions{Selectable: true}).Font(theme.MonoFont).FontSize(12) - }) -} - // displayLog logs a control's new value when it changed. func displayLog[T any](log func(string), e *ui.Element, name string, v *T) { if e.Changed() { diff --git a/examples/gallery/pages_overlay.go b/examples/gallery/pages_overlay.go index 2458fd9..e4e650c 100644 --- a/examples/gallery/pages_overlay.go +++ b/examples/gallery/pages_overlay.go @@ -15,7 +15,7 @@ var overlayPages = []page{ {ID: "081", Name: "Dialog", ZhName: "对话框", Category: overlayCategory, Demo: dialogDemo, Code: dialogCode, Keys: "Tab / Shift+Tab 在对话框内循环;Escape 关闭(可关闭此规则);关闭后焦点回到触发按钮。"}, {ID: "082", Name: "AlertDialog", ZhName: "警示对话框", Category: overlayCategory, Demo: alertDialogDemo, Code: alertDialogCode, - Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮;Escape 等同取消;点击遮罩无效。"}, + Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮,输入框中的 Enter 不确认;Escape 等同取消;点击遮罩无效。"}, {ID: "083", Name: "Drawer", ZhName: "抽屉", Category: overlayCategory, Demo: drawerDemo, Code: drawerCode, Keys: "模态:焦点限制在抽屉内并在关闭后恢复;Escape 关闭。非模态:窗口仍可操作,Escape 关闭。"}, {ID: "084", Name: "Popover", ZhName: "弹出框", Category: overlayCategory, Demo: popoverDemo, Code: popoverCode, @@ -25,7 +25,7 @@ var overlayPages = []page{ {ID: "086", Name: "HoverCard", ZhName: "悬停卡片", Category: overlayCategory, Demo: hoverCardDemo, Code: hoverCardCode, Keys: "键盘聚焦锚点后显示,Tab 进入卡片;Escape 关闭。"}, {ID: "087", Name: "DropdownMenu", ZhName: "下拉菜单", Category: overlayCategory, Demo: dropdownMenuDemo, Code: dropdownMenuCode, - Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 或 Tab 关闭。"}, + Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 关闭最上层;Tab 在任一层关闭整个菜单。"}, {ID: "088", Name: "ContextMenu", ZhName: "右键菜单", Category: overlayCategory, Demo: contextMenuDemo, Code: contextMenuCode, Keys: "右键在指针处打开;焦点在区域内时按 Menu 键或 Shift+F10 在区域下方打开;其余按键同下拉菜单。"}, {ID: "089", Name: "Popconfirm", ZhName: "气泡确认", Category: overlayCategory, Demo: popconfirmDemo, Code: popconfirmCode, @@ -171,10 +171,7 @@ func drawerDemo(c *ui.Context, log func(string)) { log("side = " + s.side) } overlayCheck(c, log, &s.nonModal, "NonModal") - ui.Text(c, fmt.Sprintf("Size %.0f", s.size)) - if ui.StepSlider(c, &s.size, 120, 900, 20).Width(160).Changed() { - log(fmt.Sprintf("size = %.0f", s.size)) - } + overlaySlider(c, log, &s.size, "Size", "DIP", 120, 900, 20) }) if mujica.Button(c, "Open drawer", mujica.ButtonOptions{}).Clicked() { s.open = true @@ -247,10 +244,7 @@ ui.Render(view, 400, 300, 1)` func tooltipDemo(c *ui.Context, log func(string)) { delay := ui.Local(c.Root(), "085", func() float64 { return 500 }) overlayControls(c, func() { - ui.Text(c, fmt.Sprintf("Delay %.0f ms", *delay)) - if ui.StepSlider(c, delay, 100, 1500, 100).Width(160).Changed() { - log(fmt.Sprintf("delay = %.0f ms", *delay)) - } + overlaySlider(c, log, delay, "Delay", "ms", 100, 1500, 100) }) opts := mujica.TooltipOptions{Delay: time.Duration(*delay) * time.Millisecond} ui.Row(c).Gap(12).Children(func() { @@ -276,19 +270,43 @@ const hoverCardCode = `view := func(c *ui.Context) { } ui.Render(view, 400, 300, 1)` +type hoverCardDemoState struct { + shown bool + openDelay, closeDelay, width float64 +} + func hoverCardDemo(c *ui.Context, log func(string)) { - shown := ui.Local(c.Root(), "086", func() bool { return false }) + s := ui.Local(c.Root(), "086", func() hoverCardDemoState { + return hoverCardDemoState{openDelay: 500, closeDelay: 300, width: 280} + }) + overlayControls(c, func() { + overlaySlider(c, log, &s.openDelay, "OpenDelay", "ms", 100, 1500, 100) + overlaySlider(c, log, &s.closeDelay, "CloseDelay", "ms", 100, 1500, 100) + overlaySlider(c, log, &s.width, "Width", "DIP", 200, 420, 20) + }) b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost}) - card := mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() { + card := mujica.HoverCard(c, b, mujica.HoverCardOptions{ + OpenDelay: time.Duration(s.openDelay) * time.Millisecond, + CloseDelay: time.Duration(s.closeDelay) * time.Millisecond, + Width: float32(s.width), + }, func() { ui.Text(c, "Lady Sakiko").FontWeight(600) ui.Text(c, "Keyboardist of the court; writes the masquerade's score.").TextColor(mujica.Tokens(c).TextMuted) if mujica.Button(c, "Follow", mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() { log("follow") } }) - if (card != nil) != *shown { - *shown = card != nil - log(fmt.Sprintf("card shown = %v", *shown)) + if (card != nil) != s.shown { + s.shown = card != nil + log(fmt.Sprintf("card shown = %v", s.shown)) + } +} + +// overlaySlider is a labeled step slider logging its value. +func overlaySlider(c *ui.Context, log func(string), v *float64, name, unit string, lo, hi, step float64) { + ui.Text(c, fmt.Sprintf("%s %.0f %s", name, *v, unit)) + if ui.StepSlider(c, v, lo, hi, step).Width(140).Changed() { + log(fmt.Sprintf("%s = %.0f %s", name, *v, unit)) } } diff --git a/feedback_review_test.go b/feedback_review_test.go index d332dad..39db79e 100644 --- a/feedback_review_test.go +++ b/feedback_review_test.go @@ -60,6 +60,9 @@ func TestFeedbackRejectsInvalidInput(t *testing.T) { "status unknown labeled": func(c *ui.Context) { StatusIndicator(c, 42, StatusIndicatorOptions{Label: "Custom"}) }, "alert severity": func(c *ui.Context) { Alert(c, nil, "x", AlertOptions{Severity: 9}) }, "toast severity": func(c *ui.Context) { ShowToast(c, ToastMessage{Message: "x", Severity: -1}) }, + "meter negative width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: -1}) }, + "meter NaN width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(nan)}) }, + "meter infinite width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(inf)}) }, } { func() { defer func() { @@ -82,3 +85,75 @@ func TestNotificationTitleOnlyHitHeight(t *testing.T) { t.Fatalf("title-only notification hit box %v %v", r, ok) } } + +func TestNotificationHeaderWrapsAtDoubleText(t *testing.T) { + items := []Notification{{ID: "a", Title: "Build failed"}, {ID: "b", Title: "Backup complete", Read: true}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(520).Height(700).Children(func() { + NotificationCenter(c, &items, NotificationCenterOptions{}) + }) + }, 600, 760) + tt.SetPreferences(ui.Preferences{TextScale: 2}) + for _, s := range []string{"Notifications", "1 unread", "Mark all read", "Clear all"} { + if r, ok := tt.Find(s); !ok || r.W == 0 || r.H == 0 || r.X < 0 || r.X+r.W > 520 { + t.Errorf("%q at 200%%: %v %v", s, r, ok) + } + } + if err := tt.Click("Clear all"); err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("Clear all at 200%% left %d items", len(items)) + } +} + +func TestProgressRingCaptionFitsAtDoubleText(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + Progress(c, 1, ProgressOptions{Shape: ProgressRing, ShowValue: true}) + }) + }, 400, 200) + for _, scale := range []float32{1, 2} { + tt.SetPreferences(ui.Preferences{TextScale: scale}) + tt.Frame() + ring, ok := tt.Find("Loading") + text, ok2 := tt.Find("100%") + if !ok || !ok2 { + t.Fatalf("scale %v: ring %v text %v", scale, ok, ok2) + } + stroke := max(float32(3), ring.W/10) + inside := text.X >= ring.X+stroke && text.X+text.W <= ring.X+ring.W-stroke + outside := text.X >= ring.X+ring.W + if !inside && !outside { + t.Errorf("scale %v: caption %v clipped by ring %v", scale, text, ring) + } + if scale == 1 && !inside { + t.Errorf("caption left the ring at 100%%: %v", text) + } + } +} + +func TestProgressResumesFromDisplayedValueAfterReducedMotion(t *testing.T) { + v := 0.0 + var bar ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Width(300).Children(func() { + bar = Progress(c, v, ProgressOptions{}).Bounds() + }) + }, 400, 100) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + v = 0.5 + tt.Frame() + tt.SetPreferences(ui.Preferences{TextScale: 1}) + if got, want := progressPixel(tt, bar.X+bar.W*0.45, bar.Y+bar.H/2), theme.Light().AccentText; got != want { + t.Fatalf("after motion returned the fill at 45%% is %v, want %v", got, want) + } + v = 1 + tt.Frame() + if got := progressPixel(tt, bar.X+bar.W*0.95, bar.Y+bar.H/2); got == theme.Light().AccentText { + t.Fatal("restored motion did not animate the next change") + } +} diff --git a/file_drop_zone.go b/file_drop_zone.go index 1f0a979..5420580 100644 --- a/file_drop_zone.go +++ b/file_drop_zone.go @@ -152,11 +152,12 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) col.Children(func() { zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5). Radius(theme.CardRadius).Disabled(opts.Disabled) - if dropped := zone.DroppedFiles(); dropped != nil && !opts.Disabled { + disabled := zone.IsDisabled() + if dropped := zone.DroppedFiles(); dropped != nil && !disabled { slog.Debug("mujica: file drop zone dropped", "count", len(dropped)) take(dropped) } - over := zone.FileDragOver() && !opts.Disabled + over := zone.FileDragOver() && !disabled bg, border := k.Surface, k.ControlBorder if over { bg, border = k.Selection, k.AccentText diff --git a/form.go b/form.go index bb89323..acee219 100644 --- a/form.go +++ b/form.go @@ -35,6 +35,7 @@ type formFieldState struct { pending bool hasAsync bool check func() string + refresh func() startAsync func() } @@ -196,6 +197,12 @@ func formStartSubmit(st *FormState) { // formSettle finishes a waiting submit once no check is pending; it reports success. func formSettle(c *ui.Context, st *FormState) bool { + // Current values only: later edits and cross-field rules count. + for _, name := range st.order { + f := st.fields[name] + f.refresh() + f.err = f.check() + } first, pending := "", false for _, name := range st.order { if st.Error(name) != "" && first == "" { @@ -244,11 +251,10 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T panic("mujica: FormItem " + name + " registered twice") } st.order = append(st.order, name) - snap := fmt.Sprintf("%#v", *value) fs := st.fields[name] if fs == nil { st.seq++ - fs = &formFieldState{snapshot: snap, version: st.seq} + fs = &formFieldState{snapshot: fmt.Sprintf("%#v", *value), version: st.seq} st.fields[name] = fs } fs.hasAsync = opts.Async != nil @@ -268,12 +274,18 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T opts.Async(*value, fs.version) } } - if snap != fs.snapshot { + // refresh gives an edited value a new version; it runs after the control took its input. + fs.refresh = func() { + snap := fmt.Sprintf("%#v", *value) + if snap == fs.snapshot { + return + } st.seq++ fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, "" if fs.touched || st.tried { fs.err = fs.check() } + c.Invalidate() } initial := opts.Initial st.resets = append(st.resets, func() { *value = initial }) @@ -286,6 +298,7 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T ctrl = control() return ctrl }) + fs.refresh() focused := ctrl.FocusWithin() if fs.focused && !focused { fs.touched = true diff --git a/form_test.go b/form_test.go index afe5ddc..0e895ba 100644 --- a/form_test.go +++ b/form_test.go @@ -262,3 +262,59 @@ func TestFormSnapshot(t *testing.T) { }) }) } + +// Text typed in the frame the check passes gets its own check before submitting. +func TestFormInputAfterAsyncPassIsRechecked(t *testing.T) { + p := &formProbe{name: "Uika", tags: []string{"x"}, email: "good@x", useAsync: true} + tt := ui.NewTester(p.run, 600, 500) + _ = tt.Click("Email") + p.st.Submit() + tt.Frame() + if len(p.asyncCalls) != 1 { + t.Fatalf("calls %v", p.asyncCalls) + } + p.st.Resolve("email", p.asyncCalls[0], "") + tt.Type("bad") + if p.submits != 0 || len(p.asyncValues) != 2 || p.asyncValues[1] != "good@xbad" { + t.Fatalf("submits %d checked %q", p.submits, p.asyncValues) + } +} + +// A cross-field rule sees the other field's latest value when the submit settles. +func TestFormCrossFieldRuleAtSettle(t *testing.T) { + var st FormState + user, pw, confirm := "uika", "a", "a" + var version uint64 + submits := 0 + 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 { + return InputGroup(c, &user, InputGroupOptions{}).Input + }) + FormItem(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 { + if v != pw { + return "Passwords differ." + } + return "" + }}, func() *ui.Element { + return InputGroup(c, &confirm, InputGroupOptions{}).Input + }) + }) + if res.Submitted() { + submits++ + } + }, 600, 500) + _ = tt.Click("Submit") + pw = "b" + tt.Frame() + st.Resolve("user", version, "") + tt.Frame() + tt.Frame() + if submits != 0 || !tt.HasText("Passwords differ.") { + t.Fatalf("mismatched pair submitted: %d", submits) + } +} diff --git a/hover_card.go b/hover_card.go index 6c9d658..030e5e1 100644 --- a/hover_card.go +++ b/hover_card.go @@ -20,17 +20,12 @@ type HoverCardOptions struct { // hoverCardState is what a card keeps between frames while its anchor lives. type hoverCardState struct { - open, dismissed bool - // keyMode keeps the card in the focus order while the keyboard is on it. - keyMode bool - enterAt, leaveAt time.Time - panel ui.Rect + open, dismissed, focusIn bool + enterAt, leaveAt time.Time + panel ui.Rect } -// HoverCard previews content beside anchor while the pointer rests on it or -// it has the keyboard focus. The pointer may move into the card; with the -// keyboard, Tab from the anchor enters it. It closes after CloseDelay once -// neither holds it, or at once on Escape. It returns the card, or nil. +// HoverCard previews content by a hovered or focused anchor; nil while hidden. func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element { if anchor == nil { panic("mujica: HoverCard needs an anchor") @@ -50,11 +45,11 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content keyboard := anchor.FocusVisible() pointer := anchor.Hovered() if st.open { - // The card's layer covers the anchor, so test the pointer by geometry. + // The popover's backdrop covers the anchor, so test the pointer by geometry. x, y, in := c.Root().PointerPosition() pointer = pointer || in && (b.Contains(x, y) || st.panel.Contains(x, y)) } - hot := pointer || keyboard || st.keyMode + hot := pointer || keyboard || st.focusIn if !hot { st.dismissed = false } @@ -87,7 +82,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content c.After(wait) } else { slog.Debug("mujica: hover_card close") - st.open, st.keyMode, st.leaveAt = false, false, time.Time{} + st.open, st.focusIn, st.leaveAt = false, false, time.Time{} return nil } } @@ -95,43 +90,23 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content if width == 0 { width = 280 } - style := func(panel *ui.Element, p float32) { - overlayStyle(c, panel, p) - panel.Width(width).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2).Label(opts.Label). - MaxHeight(popoverRoom(c, b)) + overlayLayer(c, st) + // A popover keeps the card in the anchor's focus order and modal scope, even inside a Dialog. + shown := true + panel := ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { + overlayStyle(c, panel, overlayAnchored(c, panel, b)) + panel.Margin(6, 0, 0, 0).Width(popoverWide(c, width)).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2). + Label(opts.Label).MaxHeight(popoverRoom(c, b)) ui.Scroll(c).Shrink(1).Children(content) - } - var panel *ui.Element - dismissed, keyMode := false, keyboard || st.keyMode - if keyMode { - shown := true - panel = ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { - style(panel.Margin(6, 0, 0, 0), overlayAnchored(c, panel, b)) - }) - dismissed = !shown - } else { - ui.Overlay(c, func() { - panel = ui.Column(c).Absolute().Role(ui.RolePopup) - prev := panel.Bounds() - x, y := overlayPlace(c, b, width, prev.H, 6) - panel.Left(x).Top(y) - p := overlayEnter(c, panel) - if prev.H == 0 { - p = 0 // not measured yet - c.AnimationFrame() - } - panel.Children(func() { style(panel, p) }) - }) - dismissed = c.Shortcut(0, ui.KeyEscape) - } - if dismissed { + }) + if !shown { slog.Debug("mujica: hover_card dismiss") - st.open, st.dismissed, st.keyMode = false, true, false - if keyMode { + if keyboard || st.focusIn { anchor.Focus() // at once, so the anchor stays hot and the card stays dismissed } + st.open, st.dismissed, st.focusIn = false, true, false return panel } - st.panel, st.keyMode = panel.Bounds(), keyboard || keyMode && panel.FocusWithin() + st.panel, st.focusIn = panel.Bounds(), panel.FocusWithin() return panel } diff --git a/hover_card_test.go b/hover_card_test.go index 815ff06..8314668 100644 --- a/hover_card_test.go +++ b/hover_card_test.go @@ -7,30 +7,45 @@ import ( "github.com/egoist/mygo/ui" ) -const hoverCardTestDelay = 20 * time.Millisecond +const hoverCardTestDelay = 150 * time.Millisecond type hoverCardApp struct { follows, next int + inDialog bool +} + +func (a *hoverCardApp) card(c *ui.Context) { + b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost}) + HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() { + ui.Text(c, "Keyboardist of the court") + if Button(c, "Follow", ButtonOptions{}).Clicked() { + a.follows++ + } + }) + if Button(c, "Next", ButtonOptions{}).Clicked() { + a.next++ + } } func (a *hoverCardApp) view(c *ui.Context) { Use(c, Settings{}) - ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() { - b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost}) - HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() { - ui.Text(c, "Keyboardist of the court") - if Button(c, "Follow", ButtonOptions{}).Clicked() { - a.follows++ - } + if a.inDialog { + open := true + Dialog(c, &open, DialogOptions{Title: "Court", HideClose: true}, func() { + ui.Column(c).Gap(80).AlignItems(ui.Start).Children(func() { a.card(c) }) }) - if Button(c, "Next", ButtonOptions{}).Clicked() { - a.next++ - } - }) + return + } + ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() { a.card(c) }) } -func hoverCardWait(tt *ui.Tester) { - time.Sleep(hoverCardTestDelay + 15*time.Millisecond) +func hoverCardShown(tt *ui.Tester) func() bool { + return func() bool { return tt.HasText("Keyboardist of the court") } +} + +// hoverCardSettle waits past both delays so any pending open or close has happened. +func hoverCardSettle(tt *ui.Tester) { + time.Sleep(hoverCardTestDelay + 30*time.Millisecond) tt.Frame() } @@ -38,34 +53,54 @@ func TestHoverCardPointerCanEnterCard(t *testing.T) { a := &hoverCardApp{} tt := ui.NewTester(a.view, 500, 400) r, _ := tt.Find("Lady Sakiko") + start := time.Now() tt.Move(r.X+5, r.Y+5) - if tt.HasText("Keyboardist of the court") { + if time.Since(start) < hoverCardTestDelay && tt.HasText("Keyboardist of the court") { t.Fatal("opened before the delay") } - hoverCardWait(tt) - tt.Frame() - f, ok := tt.Find("Follow") - if !ok { + if !overlayUntil(tt, hoverCardShown(tt)) { t.Fatalf("card did not open: %q", tt.Texts()) } - // Moving into the card keeps it; its button works. - tt.Move(f.X+5, f.Y+5) - hoverCardWait(tt) + // The pointer inside the card keeps it open past the close delay. + k, _ := tt.Find("Keyboardist of the court") + tt.Move(k.X+5, k.Y+5) + hoverCardSettle(tt) + if !tt.HasText("Follow") { + t.Fatal("card closed with the pointer inside it") + } if err := tt.Click("Follow"); err != nil { t.Fatal(err) } - if a.follows != 1 { - t.Fatalf("follows %d", a.follows) + if a.follows != 1 || !tt.Focused("Follow") { + t.Fatalf("follows %d, Follow focused %v", a.follows, tt.Focused("Follow")) } - // Leaving both closes it after the delay, and the window stays usable. + // With the focus inside, leaving keeps it; a click outside closes it. tt.Move(490, 390) - tt.Frame() + hoverCardSettle(tt) if !tt.HasText("Follow") { + t.Fatal("card closed with the focus inside it") + } + tt.ClickAt(490, 390) + if tt.HasText("Follow") { + t.Fatal("outside click did not close the card") + } +} + +func TestHoverCardCloseDelay(t *testing.T) { + a := &hoverCardApp{} + tt := ui.NewTester(a.view, 500, 400) + r, _ := tt.Find("Lady Sakiko") + tt.Move(r.X+5, r.Y+5) + if !overlayUntil(tt, hoverCardShown(tt)) { + t.Fatal("card did not open") + } + start := time.Now() + tt.Move(490, 390) + if time.Since(start) < hoverCardTestDelay && !tt.HasText("Follow") { t.Fatal("closed without the close delay") } - hoverCardWait(tt) - if tt.HasText("Follow") { - t.Fatal("card stayed open") + if !overlayUntil(tt, func() bool { return !tt.HasText("Follow") }) { + t.Fatal("card stayed open after the close delay") } } @@ -73,8 +108,7 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) { a := &hoverCardApp{} tt := ui.NewTester(a.view, 500, 400) tt.Key(0, ui.KeyTab) - hoverCardWait(tt) - if !tt.HasText("Keyboardist of the court") { + if !overlayUntil(tt, hoverCardShown(tt)) { t.Fatal("no card on keyboard focus") } tt.Key(0, ui.KeyTab) @@ -89,7 +123,7 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) { if tt.HasText("Follow") || !tt.Focused("Lady Sakiko") { t.Fatalf("Escape: card shown %v, anchor focused %v", tt.HasText("Follow"), tt.Focused("Lady Sakiko")) } - hoverCardWait(tt) + hoverCardSettle(tt) if tt.HasText("Follow") { t.Fatal("dismissed card came back while focused") } @@ -99,19 +133,43 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) { } } +func TestHoverCardInsideDialog(t *testing.T) { + a := &hoverCardApp{inDialog: true} + tt := ui.NewTester(a.view, 600, 500) + r, _ := tt.Find("Lady Sakiko") + tt.Move(r.X+5, r.Y+5) + if !overlayUntil(tt, hoverCardShown(tt)) { + t.Fatal("card did not open in the dialog") + } + if err := tt.Click("Follow"); err != nil { + t.Fatal(err) + } + if a.follows != 1 || !tt.Focused("Follow") { + t.Fatalf("follows %d, focus kept on Follow %v", a.follows, tt.Focused("Follow")) + } + tt.Key(ui.Shift, ui.KeyTab) + if !tt.Focused("Lady Sakiko") { + t.Fatal("Shift+Tab from the card did not reach its anchor in the dialog") + } +} + func TestHoverCardSnapshot(t *testing.T) { - snapshot(t, "hover_card", 420, 240, func(c *ui.Context) { + overlaySnapshot(t, "hover_card", 420, 240, func(c *ui.Context) { ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { - Button(c, "Lady Uika", ButtonOptions{Variant: Ghost}) - }) - ui.Overlay(c, func() { - p := ui.Column(c).Absolute().Left(24).Top(66) - overlayStyle(c, p, 1) - p.Width(280).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() { - ui.Text(c, "Lady Uika").FontWeight(600) - ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted) - Button(c, "Follow", ButtonOptions{Variant: Outline}) + b := Button(c, "Lady Uika", ButtonOptions{Variant: Ghost}) + HoverCard(c, b, HoverCardOptions{OpenDelay: time.Millisecond}, func() { + ui.Column(c).Gap(8).AlignItems(ui.Start).Children(func() { + ui.Text(c, "Lady Uika").FontWeight(600) + ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted) + Button(c, "Follow", ButtonOptions{Variant: Outline}) + }) }) }) + }, func(tt *ui.Tester) { + r, _ := tt.Find("Lady Uika") + tt.Move(r.X+5, r.Y+5) + if !overlayUntil(tt, func() bool { return tt.HasText("Follow") }) { + t.Fatal("card did not open") + } }) } diff --git a/input_group.go b/input_group.go index 8f0b136..4ec5f31 100644 --- a/input_group.go +++ b/input_group.go @@ -41,13 +41,14 @@ type InputGroupParts struct { Input *ui.Element // Clicked is the index in Buttons of the button clicked, or -1. Clicked int + locked bool } // Changed reports whether the user edited the value this frame. -func (p InputGroupParts) Changed() bool { return p.Input.Changed() } +func (p InputGroupParts) Changed() bool { return !p.locked && p.Input.Changed() } // Submitted reports whether Enter was pressed in the input. -func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() } +func (p InputGroupParts) Submitted() bool { return !p.locked && p.Input.Submitted() } // InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border. func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts { @@ -91,7 +92,7 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup } button := func(i int) { b := opts.Buttons[i] - bb := ui.ButtonBase(c).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()). + bb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()). Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label) segment(bb) internal.Ring(bb, k.Focus, theme.ControlRadius) @@ -133,7 +134,8 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup // As wide as MyGo makes a text input. parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0) } else { - parts.Input = ui.TextInputBase(c, value).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder) + parts.locked = box.IsDisabled() + parts.Input = inputGroupEditor(c, box, value, parts.locked).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder) } if opts.Label != "" { parts.Input.Label(opts.Label) @@ -169,3 +171,15 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup }) return parts } + +// inputGroupEditor builds the input; a disabled one edits a copy, so queued input cannot reach value. +func inputGroupEditor(c *ui.Context, box *ui.Element, value *string, locked bool) *ui.Element { + if !locked { + return ui.TextInputBase(c, value) + } + shown := ui.Local(box, "locked", func() string { return *value }) + *shown = *value + in := ui.TextInputBase(c, shown) + *shown = *value + return in +} diff --git a/input_group_test.go b/input_group_test.go index bcdd4e1..34a7e3e 100644 --- a/input_group_test.go +++ b/input_group_test.go @@ -6,6 +6,7 @@ import ( "github.com/egoist/mygo/ui" "mujicaui/icons" + "mujicaui/theme" ) func TestInputGroupTypingAndButtons(t *testing.T) { @@ -108,3 +109,35 @@ func TestInputGroupSnapshot(t *testing.T) { }) }) } + +// Input queued while focused must not edit a group disabled since. +func TestInputGroupDisabledAfterFocus(t *testing.T) { + value, disabled, changes := "abc", false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if InputGroup(c, &value, InputGroupOptions{Label: "Code"}).Changed() { + changes++ + } + }) + }, 400, 100) + _ = tt.Click("Code") + disabled = true + tt.Type("zz") + tt.Key(0, ui.KeyBackspace) + tt.Frame() + if value != "abc" || changes != 0 { + t.Fatalf("value %q changes %d", value, changes) + } +} + +func TestInputGroupHitAreasCompact(t *testing.T) { + value := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}}}) + }, 400, 100) + if r, ok := tt.Find("Clear"); !ok || r.W < 28 || r.H < 28 { + t.Fatalf("button %v", r) + } +} diff --git a/input_review2_test.go b/input_review2_test.go new file mode 100644 index 0000000..4544940 --- /dev/null +++ b/input_review2_test.go @@ -0,0 +1,177 @@ +package mujica + +import ( + "image/color" + "runtime" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +func TestInputsObeyDisabledAncestor(t *testing.T) { + text, area, num, find, cur := "", "", 5.0, "a", 0 + off, events := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Gap(8).Disabled(off).Children(func() { + for _, r := range []InputResult{ + TextInput(c, &text, TextInputOptions{Label: "Text"}), + TextArea(c, &area, TextAreaOptions{Label: "Area", SubmitKey: ui.KeyEnter}), + NumberInput(c, &num, NumberInputOptions{Label: "Num"}), + } { + if r.Changed() || r.Submitted() { + events++ + } + } + if SearchInput(c, &find, SearchInputOptions{Label: "Find", Find: true, Matches: 3, Current: &cur}).Moved() { + events++ + } + }) + }, 400, 500) + next := func() { + if runtime.GOOS == "darwin" { + tt.Key(ui.Cmd, ui.KeyG) + } else { + tt.Key(0, ui.KeyF3) + } + } + for _, name := range []string{"Text", "Area", "Num"} { + _ = tt.Click(name) + events = 0 + off = true + tt.Type("x") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyUp) + next() + if events != 0 { + t.Errorf("%s: %d events under a disabled ancestor", name, events) + } + off = false + tt.Frame() + } + if text != "" || area != "" || num != 5 || cur != 0 { + t.Fatalf("values changed: %q %q %v current %d", text, area, num, cur) + } +} + +// buttonLabelInk counts pixels in the label's box that differ from its first pixel. +func buttonLabelInk(tt *ui.Tester, label string) int { + r, _ := tt.Find(label) + img := tt.Image() + bg := img.At(int(r.X), int(r.Y)) + n := 0 + for y := int(r.Y); y < int(r.Y+r.H); y++ { + for x := int(r.X); x < int(r.X+r.W); x++ { + if !sameColor(img.At(x, y), bg) { + n++ + } + } + } + return n +} + +func sameColor(a, b color.Color) bool { + r1, g1, b1, a1 := a.RGBA() + r2, g2, b2, a2 := b.RGBA() + return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 +} + +func TestButtonLoadingKeepsLabelAndWidth(t *testing.T) { + for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} { + for _, icon := range []*ui.SVG{nil, icons.Must("check")} { + loading := false + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: d}) + ui.Row(c).Children(func() { + w = Button(c, "Save", ButtonOptions{Icon: icon, Loading: loading}).Bounds().W + }) + }, 300, 200) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Frame() + before := w + loading = true + tt.Frame() + tt.Frame() + if w != before { + t.Errorf("%v icon %v: width %v -> %v", d, icon != nil, before, w) + } + // The spinner sits beside the label, never over it, and the label has ink. + sp, _ := tt.Find("Loading") + lb, _ := tt.Find("Save") + if sp.X+sp.W > lb.X || buttonLabelInk(tt, "Save") == 0 { + t.Errorf("%v icon %v: label hidden while loading (spinner %v, label %v)", d, icon != nil, sp, lb) + } + } + } +} + +func numberInputStepTester(v *float64, opts NumberInputOptions) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NumberInput(c, v, opts) + }, 400, 200) + tt.Key(0, ui.KeyTab) + return tt +} + +func TestNumberInputStepGrid(t *testing.T) { + for _, stepper := range []bool{false, true} { + v := 0.2 + tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 0.29999999, Step: 0.1, StepperOnly: stepper}) + tt.Key(0, ui.KeyUp) + if v != 0.2 { + t.Errorf("stepper %v: Up past Max 0.29999999 gave %v", stepper, v) + } + v = 0 + tt = numberInputStepTester(&v, NumberInputOptions{Min: -1, Max: 1, Step: 1e-10, StepperOnly: stepper}) + tt.Key(0, ui.KeyUp) + if v != 1e-10 { + t.Errorf("stepper %v: Up by 1e-10 from 0 gave %v", stepper, v) + } + } + v := 0.0 + tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 10, Step: 3, StepperOnly: true}) + var got []float64 + for _, k := range []ui.Key{ui.KeyUp, ui.KeyEnd, ui.KeyDown, ui.KeyHome, ui.KeyDown} { + tt.Key(0, k) + got = append(got, v) + } + want := []float64{3, 10, 9, 0, 0} + for i := range want { + if got[i] != want[i] { + t.Fatalf("stepper [0,10] by 3: got %v, want %v", got, want) + } + } +} + +// radioSameName prints the same for every value, as fmt keys would see it. +type radioSameName struct{ id int } + +func (radioSameName) String() string { return "same" } + +func TestRadioGroupKeysByEquality(t *testing.T) { + v := radioSameName{0} + items := []RadioItem[radioSameName]{{Value: radioSameName{1}, Label: "One"}, {Value: radioSameName{2}, Label: "Two"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + RadioGroup(c, &v, items, RadioGroupOptions{}) + }, 300, 200) + _ = tt.Click("Two") + if v.id != 2 { + t.Fatalf("click chose %v", v) + } + tt.Key(0, ui.KeyUp) + if v.id != 1 { + t.Fatalf("Up chose %v", v) + } + items[0], items[1] = items[1], items[0] + tt.Frame() + tt.Key(0, ui.KeySpace) + if v.id != 1 || !tt.Focused("One") { + t.Fatalf("after reorder chose %v, One focused %v", v, tt.Focused("One")) + } +} diff --git a/internal/choice.go b/internal/choice.go index d758776..e6dcd59 100644 --- a/internal/choice.go +++ b/internal/choice.go @@ -36,12 +36,11 @@ func CheckBox(p *ui.Painter, r ui.Rect, s CheckState, k theme.Tokens, hover bool if s == Mixed { mark = "minus" } - Mark(p, r, mark, k.OnAccent) + in := r.W * 0.12 + Mark(p, ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, mark, k.OnAccent) } -// Mark paints the Lucide icon name inset in r. Icons sit on the pixel grid, -// so their cached masks never differ between frames or tests. +// Mark paints the Lucide icon name in r, on the pixel grid so masks never vary. func Mark(p *ui.Painter, r ui.Rect, name string, c ui.Color) { - in := r.W * 0.12 - p.Icon(icons.Must(name), ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, c) + p.Icon(icons.Must(name), r, c) } diff --git a/internal/navigation.go b/internal/navigation.go index d5f4841..8564599 100644 --- a/internal/navigation.go +++ b/internal/navigation.go @@ -17,3 +17,15 @@ func Ease(e *ui.Element, d time.Duration, fg ui.Color) *ui.Element { } return e.TextColor(ui.Color{R: ch("mujica.fg.r", fg.R), G: ch("mujica.fg.g", fg.G), B: ch("mujica.fg.b", fg.B), A: ch("mujica.fg.a", fg.A)}) } + +// PopIn fades popup e in over d from the frame it appeared and returns the progress, 0 to 1. +func PopIn(c *ui.Context, e *ui.Element, d time.Duration) float32 { + born := ui.Local(e, "mujica.popin", func() time.Time { return c.Now() }) + p := float32(1) + if t := float32(c.Now().Sub(*born)) / float32(max(d, 1)); d > 0 && t < 1 { + p = ui.EaseOut(t) + c.AnimationFrame() + } + e.Opacity(p) + return p +} diff --git a/menubar.go b/menubar.go index d27b21e..5a04be4 100644 --- a/menubar.go +++ b/menubar.go @@ -285,6 +285,7 @@ func (m *menubarRun) activate(l, h int) { } if it.Items != nil { m.st.hl = append(m.st.hl[:l+1], menubarStep(it.Items, -1, 1)) + m.focus(m.st.open) return } m.chosen = it.ID diff --git a/menubar_panel.go b/menubar_panel.go index 1ea06b1..cecba9c 100644 --- a/menubar_panel.go +++ b/menubar_panel.go @@ -64,6 +64,7 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u panel := ui.Scroll(c).Absolute().Padding(u).MinWidth(200).MaxHeight(wh-8).AlignItems(ui.Stretch).Role(ui.RolePopup). Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). Shadow(0, 6, 16, 0, ui.RGBA(0, 0, 0, 0.18)) + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) pb := panel.Bounds() if x+pb.W > ww-4 { x = ww - 4 - pb.W @@ -75,6 +76,9 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u y = max(4, wh-4-pb.H) } panel.Left(max(4, x)).Top(y) + if panel.Clicked() { + m.focus(st.open) + } var anchor *ui.Element panel.Children(func() { for j, it := range items { @@ -95,6 +99,10 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u m.activate(l, j) return } + if row.Pressed() { + // A press takes the focus off the title, which drives the keys. + m.focus(st.open) + } px, py, over := row.PointerPosition() b := row.Bounds() at := [2]float32{b.X + px, b.Y + py} diff --git a/menubar_test.go b/menubar_test.go index 2f97d12..db09c7a 100644 --- a/menubar_test.go +++ b/menubar_test.go @@ -150,6 +150,34 @@ func TestMenubarKeyboard(t *testing.T) { } } +func TestMenubarKeysAfterClickInMenu(t *testing.T) { + a := &menubarApp{} + tt := ui.NewTester(a.view, 700, 500) + _ = tt.Click("File") + if err := tt.Click("Export"); err != nil { + t.Fatal(err) + } + if !tt.Focused("File") { + t.Fatal("click into the menu left the title without focus") + } + tt.Key(0, ui.KeyDown) // PDF to Vector, skipping disabled Image + tt.Key(0, ui.KeyEnter) + if len(a.chosen) != 1 || a.chosen[0] != "svg" { + t.Fatalf("arrows after a click chose %q", a.chosen) + } + _ = tt.Click("File") + _ = tt.Click("Export") + _ = tt.Click("Image") // disabled: the press still keeps keys working + tt.Key(0, ui.KeyEscape) + if tt.HasText("Vector") || !tt.HasText("Quit") { + t.Fatalf("Escape did not close only the submenu: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Quit") { + t.Fatal("second Escape did not close the menu") + } +} + func TestMenubarLongMenuScrolls(t *testing.T) { var items []MenubarItem for i := range 30 { diff --git a/messages_choice.go b/messages_choice.go index 06e86e3..5075a5a 100644 --- a/messages_choice.go +++ b/messages_choice.go @@ -13,6 +13,7 @@ func init() { "multi_select.limit": {"%d of %d chosen", "已选 %d / %d"}, "multi_select.more": {"+%d", "+%d"}, "multi_select.remove": {"Remove %s", "移除 %s"}, + "combobox.toggle": {"Show options", "显示选项"}, "combobox.loading": {"Loading…", "加载中…"}, "combobox.no_results": {"No matches", "无匹配项"}, "combobox.use": {"Use “%s”", "使用“%s”"}, @@ -24,5 +25,9 @@ func init() { "tree_select.no_results": {"No results", "无匹配结果"}, "tree_select.expand": {"Expand %s", "展开 %s"}, "tree_select.collapse": {"Collapse %s", "收起 %s"}, + "tree_select.expanded": {"expanded", "已展开"}, + "tree_select.collapsed": {"collapsed", "已收起"}, + "tree_select.mixed": {"partly selected", "部分选中"}, + "multi_select.mixed": {"partly selected", "部分选中"}, }) } diff --git a/multi_select.go b/multi_select.go index f141120..a0ca85d 100644 --- a/multi_select.go +++ b/multi_select.go @@ -28,9 +28,7 @@ type MultiSelectOptions struct { // multiSelectKey keeps this file's state apart from MyGo's own. type multiSelectKey struct{ name string } -// MultiSelect chooses several *values from options in a popup with a -// search field, "Select all" for the matches, and an optional limit. -// Down from the search moves into the list; Space checks; Escape closes. +// MultiSelect chooses several *values from options in a popup with search and "Select all". func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) ChoiceResult { if opts.Max < 0 || opts.MaxTags < 0 { panic("mujica: MultiSelect Max and MaxTags must not be negative") @@ -42,6 +40,9 @@ func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOptio tr.Label(opts.Label) } open := ui.Local(tr, multiSelectKey{"open"}, func() bool { return false }) + if opts.Disabled { + *open = false + } query := ui.Local(tr, multiSelectKey{"query"}, func() string { return "" }) if tr.Clicked() || (!*open && tr.Shortcut(0, ui.KeyDown)) { *open = !*open @@ -100,7 +101,7 @@ func multiSelectTag(c *ui.Context, label string, disabled bool) bool { removed := false ui.Row(c).Gap(2).Padding(1, 2, 1, 6).Radius(theme.ControlRadius).Background(k.Selection). Border(theme.BorderWidth, k.Border).Shrink(0).Children(func() { - ui.Text(c, label).FontSize(theme.CaptionSize).TextColor(k.Text).SingleLine() + ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine() if !disabled { removed = selectClear(c, k, fmt.Sprintf(msg(c, "multi_select.remove"), label)) } @@ -170,7 +171,7 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T, }) if opts.Max > 0 { ui.Text(c, fmt.Sprintf(msg(c, "multi_select.limit"), len(*values), opts.Max)). - FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(2, d.Unit()*2) + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(2, d.Unit()*2) } }) } @@ -178,12 +179,16 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T, // multiSelectCheck is a CheckboxBase row drawing its own box. func multiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) *ui.Element { d := Density(c) - scratch := *on - if disabled { - on = &scratch + var cb *ui.Element + // A disabled wrapper and a copy block the base, which toggles as it is built. + ui.Row(c).Grow(1).Disabled(disabled).Children(func() { + cb = ui.CheckboxBase(c, choiceTarget(on, disabled)).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2). + Gap(d.Unit() * 2).AlignItems(ui.Center).Radius(theme.ControlRadius).Grow(1) + }) + if st == internal.Mixed { + // MyGo sets the mixed state only inside its own widgets (ui/element.go:282). + cb.Description(msg(c, "multi_select.mixed")) } - cb := ui.CheckboxBase(c, on).Disabled(disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2). - Gap(d.Unit() * 2).AlignItems(ui.Center).Radius(theme.ControlRadius).Grow(1) internal.Ring(cb, k.Focus, theme.ControlRadius) hover := cb.Hovered() if hover { diff --git a/navigation_menu.go b/navigation_menu.go index 94783ad..af67349 100644 --- a/navigation_menu.go +++ b/navigation_menu.go @@ -147,36 +147,48 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s *open = true } ui.PopoverBase(c, b, open, func(panel *ui.Element) { - panel.Margin(u, 0, 0, 0).Padding(u*2).Gap(u).Radius(theme.PopupRadius).Background(k.Surface). + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) + // Capped to the room beside the anchor; the links scroll inside. + _, wh := c.Size() + r := b.Bounds() + panel.Margin(u, 0, 0, 0).Padding(u*2).Radius(theme.PopupRadius).Background(k.Surface).MaxHeight(max(r.Y, wh-r.Y-r.H)-16). Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(240).MaxWidth(360) - panel.FocusGroup(ui.Vertical) - first := true - var list func(items []NavigationLink) - list = func(items []NavigationLink) { - for _, it := range items { - if it.Label == "" { - panic("mujica: NavigationLink needs a Label") - } - if it.Items != nil { - ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)). - Padding(u*2, u*2, u, u*2).SingleLine() - list(it.Items) - continue - } - if it.Path == "" { - panic("mujica: NavigationLink needs a Path or Items") - } - if navigationMenuItem(c, it, it.Path == path, first).Clicked() { - *open = false - push(it.Path) - } - first = false - } - } - list(l.Items) + ui.Scroll(c).Shrink(1).Gap(u).FocusGroup(ui.Vertical).Children(func() { + navigationMenuPanel(c, l.Items, path, open, push) + }) }) } +// navigationMenuPanel lists a group's links, its subgroups under serif headings. +func navigationMenuPanel(c *ui.Context, items []NavigationLink, path string, open *bool, push func(string)) { + k := Tokens(c) + u := Density(c).Unit() + first := true + var list func(items []NavigationLink) + list = func(items []NavigationLink) { + for _, it := range items { + if it.Label == "" { + panic("mujica: NavigationLink needs a Label") + } + if it.Items != nil { + ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)). + Padding(u*2, u*2, u, u*2).SingleLine() + list(it.Items) + continue + } + if it.Path == "" { + panic("mujica: NavigationLink needs a Path or Items") + } + if navigationMenuItem(c, it, it.Path == path, first).Clicked() { + *open = false + push(it.Path) + } + first = false + } + } + list(items) +} + func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element { k := Tokens(c) u := Density(c).Unit() diff --git a/navigation_review_test.go b/navigation_review_test.go index ff29221..4ea939f 100644 --- a/navigation_review_test.go +++ b/navigation_review_test.go @@ -1,13 +1,85 @@ package mujica import ( + "fmt" "testing" + "time" "github.com/egoist/mygo/ui" "mujicaui/icons" ) +// TestNavigationPopupsFadeIn checks each popup fades in over PopupDuration instead of appearing at once. +func TestNavigationPopupsFadeIn(t *testing.T) { + for _, tc := range []struct { + name, open, sample string + view func(c *ui.Context) + }{ + {"breadcrumb", "Show hidden path", "Alpha", func(c *ui.Context) { + Breadcrumb(c, breadcrumbPath("Root", "Alpha", "Beta", "Gamma", "Omega", "Leaf"), BreadcrumbOptions{}) + }}, + {"pagination", "10 / page", "50 / page", func(c *ui.Context) { + page, size := 1, 10 + Pagination(c, &page, &size, PaginationOptions{Total: 100, PageSizes: []int{10, 50}}) + }}, + {"navigation menu", "Library", "Letters", func(c *ui.Context) { + NavigationMenu(c, ui.NewRouter("/"), navigationMenuSample(), NavigationMenuOptions{}) + }}, + {"menubar", "File", "Quit", func(c *ui.Context) { + Menubar(c, (&menubarApp{}).menus(), MenubarOptions{}) + }}, + {"command palette", "", "Esc close", func(c *ui.Context) { + open := true + CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{}) + }}, + } { + 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) }) + }, 700, 500) + if tc.open != "" { + if err := tt.Click(tc.open); err != nil { + t.Fatal(err) + } + } + r, ok := tt.Find(tc.sample) + if !ok { + t.Fatalf("%s: no %q", tc.name, tc.sample) + } + x, y := int(r.X-3), int(r.Y+r.H/2) + early := tt.Image().RGBAAt(x, y) + time.Sleep(200 * time.Millisecond) + tt.Frame() + if late := tt.Image().RGBAAt(x, y); early == late { + t.Errorf("%s: popup appeared at once (%v)", tc.name, late) + } + } +} + +// TestNavigationMenuLongPanelScrolls checks a tall group stays in the window and follows the focus. +func TestNavigationMenuLongPanelScrolls(t *testing.T) { + var links []NavigationLink + for i := range 30 { + links = append(links, NavigationLink{Label: fmt.Sprintf("Link %02d", i), Path: fmt.Sprintf("/l/%d", i)}) + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NavigationMenu(c, ui.NewRouter("/"), []NavigationLink{{Label: "Group", Items: links}}, NavigationMenuOptions{}) + }, 400, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnd) + tt.Frame() + if !tt.Focused("Link 29") { + t.Fatal("End did not focus the last link") + } + r, _ := tt.Find("Link 29") + if r.H == 0 || r.Y+r.H > 300 { + t.Fatalf("focused link off screen at %+v", r) + } +} + // TestNavigationCaptionsFollowTextScale checks small text grows with the desktop text scale. func TestNavigationCaptionsFollowTextScale(t *testing.T) { for _, tc := range []struct { diff --git a/notification_center.go b/notification_center.go index f712d4d..cbcb116 100644 --- a/notification_center.go +++ b/notification_center.go @@ -67,24 +67,28 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC panel := ui.Column(c).FillWidth().Shrink(1).MinHeight(0).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). Role(ui.RoleGroup).Label(title).Clip() panel.Children(func() { - ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() { - ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text) - if unread > 0 { - ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText) - } - ui.Spacer(c) - if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() { - for i := range *items { - (*items)[i].Read = true + // Wraps so title and actions stay whole when text is enlarged. + ui.Row(c).Wrap().AlignItems(ui.Center).Justify(ui.SpaceBetween).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).SingleLine() + if unread > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).SingleLine() } - res.MarkedAllRead = true - slog.Debug("mujica: notification_center mark all read", "count", unread) - } - if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() { - slog.Debug("mujica: notification_center clear all", "count", len(*items)) - *items = (*items)[:0] - res.ClearedAll = true - } + }) + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() { + for i := range *items { + (*items)[i].Read = true + } + res.MarkedAllRead = true + slog.Debug("mujica: notification_center mark all read", "count", unread) + } + if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() { + slog.Debug("mujica: notification_center clear all", "count", len(*items)) + *items = (*items)[:0] + res.ClearedAll = true + } + }) }) // Ornament hairline under the panel title. ui.Box(c).FillWidth().Height(1).Background(k.Ornament).Role(ui.RoleNone) diff --git a/number_input.go b/number_input.go index efed56a..d3d3d1d 100644 --- a/number_input.go +++ b/number_input.go @@ -50,9 +50,34 @@ func (s numberInputSpec) format(v float64) string { // clamp rounds v to the precision, then bounds it; the bounds sit on that grid. func (s numberInputSpec) clamp(v float64) float64 { p := math.Pow(10, float64(s.decimals)) - return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p)) + return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p)) + 0 // + 0 turns -0 into 0 } +// stepBy moves v n steps along the grid of Step from Min, stopping at the bounds. +func (s numberInputSpec) stepBy(v float64, n int) float64 { + if math.IsNaN(v) { + // From empty, the first step lands next to zero. + v = s.clamp(0) + } + base := s.lo + if math.IsInf(base, 0) { + base = 0 + } + k := (v - base) / s.step + if n > 0 { + k = numberInputFloor(k) + float64(n) + } else { + k = numberInputCeil(k) + float64(n) + } + return s.clamp(base + k*s.step) +} + +// numberInputFloor floors x, forgiving floating-point error just below an integer. +func numberInputFloor(x float64) float64 { return math.Floor(x + math.Max(1e-9, 1e-12*math.Abs(x))) } + +// numberInputCeil ceils x, forgiving floating-point error just above an integer. +func numberInputCeil(x float64) float64 { return math.Ceil(x - math.Max(1e-9, 1e-12*math.Abs(x))) } + // numberInputSame reports equal values, counting NaN (empty) as equal. func numberInputSame(a, b float64) bool { return a == b || math.IsNaN(a) && math.IsNaN(b) } @@ -80,8 +105,8 @@ func numberInputResolve(opts NumberInputOptions) numberInputSpec { } // Bounds move inward onto the precision grid, so clamped values keep both. p := math.Pow(10, float64(s.decimals)) - s.lo = math.Ceil(math.Round(s.lo*p*1e6)/1e6) / p - s.hi = math.Floor(math.Round(s.hi*p*1e6)/1e6) / p + s.lo = numberInputCeil(s.lo*p)/p + 0 // + 0 turns -0 into 0 + s.hi = numberInputFloor(s.hi*p)/p + 0 if s.lo > s.hi { panic("mujica: NumberInput has no value in range at this precision") } @@ -113,13 +138,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe r.changed = true } } - stepBy := func(n float64) { - base := *value - if math.IsNaN(base) { - base = math.Max(s.lo, math.Min(s.hi, 0)) - } - set(base + n*s.step) - } + stepBy := func(n int) { set(s.stepBy(*value, n)) } look := &textInputLook{disabled: opts.Disabled, err: opts.Error} r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { text := ui.Local(frame, "text", func() string { return s.format(*value) }) @@ -129,13 +148,13 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe // Set from outside: the text follows, and a typing error goes. *text, *invalid = s.format(*value), "" } - in := textInputEditor(c, frame, text, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 12). + in := textInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 12). Placeholder(opts.Placeholder).Label(opts.Label) - if in.Changed() && !opts.Disabled { + if in.Changed() && !look.disabled { *invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set) } switch { - case opts.Disabled: + case look.disabled: case in.Shortcut(0, ui.KeyUp): stepBy(1) *text, *invalid = s.format(*value), "" @@ -151,7 +170,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe for _, b := range []struct { name string icon string - delta float64 + delta int can bool }{ {msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.lo}, @@ -209,37 +228,78 @@ func numberInputButton(c *ui.Context, icon, name string, can bool) bool { return b.Clicked() } -// numberInputStepper shows the value beside MyGo's stepper, restyled. +// numberInputStepper shows the value beside up and down arrows; it steps itself. func numberInputStepper(c *ui.Context, value *float64, s numberInputSpec, opts NumberInputOptions) InputResult { k := Tokens(c) d := Density(c) var r InputResult r.Element = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(opts.Disabled) + disabled := r.Element.IsDisabled() + set := func(v float64) { + if v != *value && !disabled { + *value = v + r.changed = true + } + } r.Element.Children(func() { ui.Row(c).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).Children(func() { ui.Text(c, s.format(*value)).FontFeatures("tnum").MinWidth(d.Unit() * 8).TextAlign(ui.End) - // MyGo's stepper arrows ignore Disabled: give them a copy. - target := value - if opts.Disabled { - held := *value - target = &held - } - st := ui.Stepper(c, target, s.lo, s.hi, s.step).Label(opts.Label).Width(d.Unit()*6). - Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) + border := k.ControlBorder if opts.Error != "" { - st.Error(opts.Error).Border(theme.BorderWidth, k.Danger) + border = k.Danger } + st := ui.Column(c).Focusable().Shrink(0).Width(d.Unit()*6).Radius(theme.ControlRadius).Clip(). + Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleStepper).Label(opts.Label). + Description(s.format(*value)).Error(opts.Error) internal.Ring(st, k.Focus, theme.ControlRadius) + switch { + case st.Shortcut(0, ui.KeyUp), st.Shortcut(0, ui.KeyRight): + set(s.stepBy(*value, 1)) + case st.Shortcut(0, ui.KeyDown), st.Shortcut(0, ui.KeyLeft): + set(s.stepBy(*value, -1)) + case st.Shortcut(0, ui.KeyHome): + set(s.lo) + case st.Shortcut(0, ui.KeyEnd): + set(s.hi) + } + st.Children(func() { + if numberInputArrow(c, "chevron-up", *value < s.hi, false) { + set(s.stepBy(*value, 1)) + st.Focus() + } + if numberInputArrow(c, "chevron-down", *value > s.lo, true) { + set(s.stepBy(*value, -1)) + st.Focus() + } + }) r.Input = st - r.changed = st.Changed() && !opts.Disabled }) if opts.Error != "" { - ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize) + ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)) } }) if r.changed { - *value = s.clamp(*value) slog.Debug("mujica: number_input step", "value", *value) } return r } + +// numberInputArrow builds one half of the stepper and reports a click. +func numberInputArrow(c *ui.Context, icon string, can, lower bool) bool { + k := Tokens(c) + h := Density(c).ControlHeight()/2 - theme.BorderWidth + a := ui.Box(c).Height(h).Center().Role(ui.RoleNone).Cursor(ui.CursorPointer).Disabled(!can) + if lower { + a.BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border) + } + fg := k.TextMuted + if a.Pressed() { + a.Background(k.Selection) + fg = k.Text + } else if a.Hovered() { + a.Background(k.SurfaceHover) + fg = k.Text + } + a.Children(func() { ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(fg) }) + return a.Clicked() +} diff --git a/overlay.go b/overlay.go index 7b4a4a0..e328e9b 100644 --- a/overlay.go +++ b/overlay.go @@ -37,8 +37,7 @@ type OverlayOptions struct { Label string } -// Overlay shows content in a layer above the window while *open is true. -// Escape closes it; a modal one also closes on a backdrop click. +// Overlay shows content in a layer above the window while *open is true; Escape closes it. func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element { if opts.Dim && !opts.Modal { panic("mujica: Overlay Dim requires Modal") @@ -46,7 +45,9 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui if opts.Place < OverlayCenter || opts.Place > OverlayBottomRight { panic("mujica: Overlay Place out of range") } + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return nil } k := Tokens(c) @@ -56,6 +57,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui } fx, fy := overlayFractions(opts.Place) if opts.Modal { + overlayLayer(c, open) var backClicked bool shown := true panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { @@ -69,6 +71,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui panel.Label(opts.Label).Padding(Density(c).Unit() * 4) content() }) + focus.shown(panel) if !shown { slog.Debug("mujica: overlay close", "backdrop", backClicked) *open = false @@ -89,7 +92,8 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui overlayStyle(c, panel, p) panel.Children(content) }) - if c.Shortcut(0, ui.KeyEscape) { + focus.shown(panel) + if overlayEscape(c, open) { slog.Debug("mujica: overlay close", "escape", true) *open = false } diff --git a/overlay_layers.go b/overlay_layers.go new file mode 100644 index 0000000..6b9c419 --- /dev/null +++ b/overlay_layers.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" +) + +// overlayLayers is the window's open MujicaUI layers, oldest first. +type overlayLayers struct { + frame, prev time.Time + keys []any + seen []time.Time +} + +type overlayLayersKey struct{} + +// overlayLayer marks the layer key open in this frame and reports whether it is on top. +func overlayLayer(c *ui.Context, key any) bool { + s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} }) + if now := c.Now(); now != s.frame { + s.prev, s.frame = s.frame, now + n := 0 + for i, k := range s.keys { + if !s.seen[i].Before(s.prev) { // drop layers the last frame did not build + s.keys[n], s.seen[n] = k, s.seen[i] + n++ + } + } + clear(s.keys[n:]) + s.keys, s.seen = s.keys[:n], s.seen[:n] + } + at := -1 + for i, k := range s.keys { + if k == key { + at = i + } + } + if at < 0 { + s.keys, s.seen = append(s.keys, key), append(s.seen, s.frame) + at = len(s.keys) - 1 + } + s.seen[at] = s.frame + return at == len(s.keys)-1 +} + +// overlayEscape reports Escape for a window-level layer, delivered only while it is on top. +func overlayEscape(c *ui.Context, key any) bool { + if !overlayLayer(c, key) { + return false + } + // The root is around every focus, so it takes Escape before MyGo's overlays. + byFocus := c.Root().Shortcut(0, ui.KeyEscape) + byWindow := c.Shortcut(0, ui.KeyEscape) + return byFocus || byWindow +} + +// overlaySiteState follows the focus of a layer after it closes. +type overlaySiteState struct { + focusIn bool // the focus was inside the layer when it last showed + wait int // frames left to check the focus after a close + holding bool // the site holds the fallback focus +} + +// overlaySite builds the invisible focus fallback at a layer's call site. +func overlaySite(c *ui.Context) (*ui.Element, *overlaySiteState) { + site := ui.Box(c).Size(0, 0).FocusRing(false) + st := ui.Local(site, "mujica.overlay.site", func() overlaySiteState { return overlaySiteState{} }) + st.holding = st.holding && site.FocusWithin() + if st.holding { + site.Focusable() // in flow, so Tab goes on from the layer's place + } else { + site.Absolute() + } + return site, st +} + +// shown records whether the focus is in the open layer's panel. +func (st *overlaySiteState) shown(panel *ui.Element) { + st.focusIn, st.wait = panel != nil && panel.FocusWithin(), 0 +} + +// closed moves a lost focus to the site once MyGo has had its chance to restore it. +func (st *overlaySiteState) closed(c *ui.Context, site *ui.Element) { + if st.focusIn { + st.focusIn, st.wait = false, 2 // MyGo prunes the panel at the end of the next frame + } + if st.wait == 0 { + return + } + st.wait-- + if st.wait > 0 { + c.AnimationFrame() + return + } + // The site itself counts as lost: it may inherit a vanished opener's positional ID. + if !c.Root().FocusWithin() || site.FocusWithin() { + slog.Debug("mujica: overlay focus fallback") + site.Focus() + st.holding = true + } +} diff --git a/overlay_snapshot_test.go b/overlay_snapshot_test.go new file mode 100644 index 0000000..4388b69 --- /dev/null +++ b/overlay_snapshot_test.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +// overlaySnapshot is snapshot, with drive opening the real component through input before the capture. +func overlaySnapshot(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) { + t.Helper() + for _, m := range []Mode{Light, Dark} { + ready := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: m}) + if ready { + view(c) + } + }, w, h) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + ready = true + tt.Frame() + drive(tt) + got := encode(t, tt.Image()) + path := filepath.Join("testdata", "snapshots", runtime.GOOS, name+"-"+strings.ToLower(m.String())+".png") + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + continue + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run go test -update)", err) + } + if !bytes.Equal(got, want) { + if err := os.WriteFile(path+".got.png", got, 0o644); err != nil { + t.Fatal(err) + } + t.Errorf("%s differs; see %s.got.png", path, path) + } + } +} + +// overlayUntil draws frames until cond holds, for at most 5 s of wall time. +func overlayUntil(tt *ui.Tester, cond func() bool) bool { + end := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(end) { + return false + } + time.Sleep(2 * time.Millisecond) + tt.Frame() + } + return true +} diff --git a/overlay_test.go b/overlay_test.go index c66bc7a..f7ad203 100644 --- a/overlay_test.go +++ b/overlay_test.go @@ -3,6 +3,7 @@ package mujica import ( "runtime" "testing" + "time" "github.com/egoist/mygo/ui" ) @@ -93,6 +94,57 @@ func TestOverlayNonModal(t *testing.T) { } } +func TestOverlayEscapeClosesTopNonModalLayerFirst(t *testing.T) { + bottom, top := false, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Show", ButtonOptions{}).Clicked() { + if bottom { + top = true + } + bottom = true + } + Overlay(c, &top, OverlayOptions{Place: OverlayTopRight}, func() { ui.Text(c, "Top layer") }) + Drawer(c, &bottom, DrawerOptions{Title: "Bottom layer", NonModal: true, Side: DrawerLeft}, nil) + }, 600, 400) + _ = tt.Click("Show") + _ = tt.Click("Show") // the overlay opens after the drawer, though built before it + if !bottom || !top { + t.Fatalf("bottom %v top %v", bottom, top) + } + tt.Key(0, ui.KeyEscape) + if top || !bottom { + t.Fatalf("first Escape: top %v bottom %v", top, bottom) + } + tt.Key(0, ui.KeyEscape) + if bottom { + t.Fatal("second Escape did not close the drawer") + } +} + +func TestOverlayNonModalRestoresFocusToItsPlace(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + Button(c, "First", ButtonOptions{}) + Overlay(c, &open, OverlayOptions{}, func() { Button(c, "Inside", ButtonOptions{}) }) + Button(c, "Next", ButtonOptions{}) + }) + }, 600, 400) + if err := tt.Click("Inside"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Next") { + t.Fatal("focus lost on close instead of staying at the overlay's place") + } +} + func TestOverlayDimNeedsModal(t *testing.T) { defer func() { if recover() == nil { @@ -106,82 +158,130 @@ func TestOverlayDimNeedsModal(t *testing.T) { }, 200, 200) } -// TestOverlayLifecycle opens and closes every overlay 100 times and checks -// that nothing grows: goroutines, heap objects or the elements left behind. -func TestOverlayLifecycle(t *testing.T) { - var open bool - views := map[string]func(c *ui.Context, b *ui.Element){ - "Dialog": func(c *ui.Context, b *ui.Element) { Dialog(c, &open, DialogOptions{Title: "T"}, func() {}) }, - "Alert": func(c *ui.Context, b *ui.Element) { - AlertDialog(c, &open, AlertDialogOptions{Title: "T", ConfirmText: "x"}) - }, - "Drawer": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T"}, nil) }, - "Drawer2": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T", NonModal: true}, nil) }, - "Popover": func(c *ui.Context, b *ui.Element) { Popover(c, b, &open, PopoverOptions{}, func() {}) }, - "Confirm": func(c *ui.Context, b *ui.Element) { Popconfirm(c, b, &open, PopconfirmOptions{Title: "T"}) }, - "Overlay": func(c *ui.Context, b *ui.Element) { Overlay(c, &open, OverlayOptions{Modal: true}, func() {}) }, - "Menu": func(c *ui.Context, b *ui.Element) { - popupMenuOpen(c, b, &open, (&menuApp{}).build) - }, - "Context": nil, +// overlayCase drives one component open and closed through real input. +type overlayCase struct { + name string + view func(c *ui.Context, open *bool) + open, close func(tt *ui.Tester) + marker string +} + +func overlayCases() []overlayCase { + clickAnchor := func(tt *ui.Tester) { _ = tt.Click("Anchor") } + escape := func(tt *ui.Tester) { tt.Key(0, ui.KeyEscape) } + hover := func(tt *ui.Tester) { + r, _ := tt.Find("Anchor") + tt.Move(r.X+4, r.Y+4) } - for name, view := range views { + leave := func(tt *ui.Tester) { tt.Move(590, 390) } + anchor := func(c *ui.Context, open *bool) *ui.Element { + b := Button(c, "Anchor", ButtonOptions{}) + if b.Clicked() { + *open = true + } + return b + } + body := func(c *ui.Context) func() { return func() { ui.Text(c, "Layer body") } } + return []overlayCase{ + {"Dialog", func(c *ui.Context, open *bool) { + anchor(c, open) + Dialog(c, open, DialogOptions{Title: "T"}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"AlertDialog", func(c *ui.Context, open *bool) { + anchor(c, open) + AlertDialog(c, open, AlertDialogOptions{Title: "Layer body", ConfirmText: "x"}) + }, clickAnchor, escape, "Layer body"}, + {"Drawer", func(c *ui.Context, open *bool) { + anchor(c, open) + Drawer(c, open, DrawerOptions{Title: "T"}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"DrawerNonModal", func(c *ui.Context, open *bool) { + anchor(c, open) + Drawer(c, open, DrawerOptions{Title: "T", NonModal: true}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"Popover", func(c *ui.Context, open *bool) { + Popover(c, anchor(c, open), open, PopoverOptions{}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"Tooltip", func(c *ui.Context, open *bool) { + Tooltip(c, anchor(c, open), "Layer body", TooltipOptions{Delay: time.Millisecond}) + }, hover, leave, "Layer body"}, + {"HoverCard", func(c *ui.Context, open *bool) { + HoverCard(c, anchor(c, open), HoverCardOptions{OpenDelay: time.Millisecond, CloseDelay: time.Millisecond}, body(c)) + }, hover, leave, "Layer body"}, + {"DropdownMenu", func(c *ui.Context, open *bool) { + DropdownMenu(c, "Anchor", DropdownMenuOptions{}, (&menuApp{}).build) + }, clickAnchor, escape, "Rename"}, + {"ContextMenu", func(c *ui.Context, open *bool) { + row := ui.Row(c) + row.Children(func() { Button(c, "Anchor", ButtonOptions{}) }) + ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build) + }, func(tt *ui.Tester) { + clickAnchor(tt) + tt.Key(ui.Shift, ui.KeyF10) + }, escape, "Rename"}, + {"Popconfirm", func(c *ui.Context, open *bool) { + Popconfirm(c, anchor(c, open), open, PopconfirmOptions{Title: "Layer body"}) + }, clickAnchor, escape, "Layer body"}, + {"Overlay", func(c *ui.Context, open *bool) { + anchor(c, open) + Overlay(c, open, OverlayOptions{Modal: true}, body(c)) + }, clickAnchor, escape, "Layer body"}, + } +} + +// TestOverlayLifecycle opens and closes every overlay 100 times; goroutines, heap objects and elements must not grow. +func TestOverlayLifecycle(t *testing.T) { + for _, oc := range overlayCases() { + open := false tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) - row := ui.Row(c) - var b *ui.Element - row.Children(func() { b = Button(c, "Anchor", ButtonOptions{}) }) - if view == nil { - ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build) - } else { - view(c, b) - } + oc.view(c, &open) }, 600, 400) tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) - cycle := func() { - open = true - if view == nil { - _ = tt.Click("Anchor") - tt.Key(ui.Shift, ui.KeyF10) - if !tt.HasText("Rename") { - t.Fatalf("%s: did not open", name) - } + shown := func() bool { return tt.HasText(oc.marker) } + cycle := func(i int) { + oc.open(tt) + if !overlayUntil(tt, shown) { + t.Fatalf("%s: cycle %d did not open", oc.name, i) } - tt.Frame() - if name != "Drawer2" { - tt.Key(0, ui.KeyEscape) - if view == nil { - open = false - } - } else { - open = false - tt.Frame() - } - if open || tt.HasText("Rename") { - t.Fatalf("%s: Escape did not close", name) + oc.close(tt) + if !overlayUntil(tt, func() bool { return !shown() }) { + t.Fatalf("%s: cycle %d did not close", oc.name, i) } } - for range 20 { - cycle() + for i := range 20 { + cycle(i) } texts := len(tt.Texts()) before := heapObjects() goroutines := runtime.NumGoroutine() - for range 100 { - cycle() + for i := range 100 { + cycle(i) } if got := len(tt.Texts()); got != texts { - t.Errorf("%s: texts %d -> %d", name, texts, got) + t.Errorf("%s: texts %d -> %d", oc.name, texts, got) } - if g := runtime.NumGoroutine(); g > goroutines { - t.Errorf("%s: goroutines %d -> %d", name, goroutines, g) + // A fired c.After timer (ui/runtime.go:596) runs briefly in its own goroutine; only lasting growth counts. + if g := overlayGoroutines(goroutines); g > goroutines { + t.Errorf("%s: goroutines %d -> %d", oc.name, goroutines, g) } if after := heapObjects(); after > before+2000 { - t.Errorf("%s: heap objects %d -> %d", name, before, after) + t.Errorf("%s: heap objects %d -> %d", oc.name, before, after) } } } +// overlayGoroutines waits up to 2 s for the goroutine count to fall to want, and returns it. +func overlayGoroutines(want int) int { + end := time.Now().Add(2 * time.Second) + g := runtime.NumGoroutine() + for g > want && time.Now().Before(end) { + time.Sleep(10 * time.Millisecond) + g = runtime.NumGoroutine() + } + return g +} + func heapObjects() uint64 { runtime.GC() var m runtime.MemStats diff --git a/pagination.go b/pagination.go index f025bd3..f73b1aa 100644 --- a/pagination.go +++ b/pagination.go @@ -36,8 +36,7 @@ type PaginationResult struct { // Changed reports a new page or page size in this frame, from the user or a correction. func (r PaginationResult) Changed() bool { return r.changed } -// Pagination moves *page (from 1) through Total records, *pageSize per page. -// A page past the end after Total shrinks is corrected to the last page. +// Pagination pages *page (from 1) through Total records, clamping it when Total shrinks. func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult { if opts.Total < 0 { panic("mujica: Pagination Total is negative") @@ -185,6 +184,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted) }) sel.Popup(func(panel *ui.Element) { + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) panel.Margin(u, 0, 0, 0).Padding(u).Radius(theme.PopupRadius).Background(k.Surface). Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)) for _, s := range sizes { diff --git a/password_input.go b/password_input.go index 4c025c1..f71816c 100644 --- a/password_input.go +++ b/password_input.go @@ -36,14 +36,14 @@ func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) Inpu mode = "shown" } ui.Row(c).Key(mode).Grow(1).Children(func() { - in = textInputEditor(c, frame, value, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). + in = textInputEditor(c, frame, value, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). Placeholder(opts.Placeholder).Label(opts.Label) if !*shown { in.Password() } }) - r.changed = in.Changed() && !opts.Disabled - r.submitted = in.Submitted() && !opts.Disabled + r.changed = in.Changed() && !look.disabled + r.submitted = in.Submitted() && !look.disabled if passwordInputToggle(c, *shown) { *shown = !*shown slog.Debug("mujica: password_input reveal", "shown", *shown) diff --git a/pin_input.go b/pin_input.go index 6dc4f9d..00cc6eb 100644 --- a/pin_input.go +++ b/pin_input.go @@ -86,6 +86,10 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts slog.Debug("mujica: pin input completed", "length", n) } } + disabled := e.IsDisabled() + if disabled { + st.queue, st.compose = st.queue[:0], "" + } for _, ev := range st.queue { switch { case ev.paste: @@ -97,9 +101,7 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts } } st.queue = st.queue[:0] - if opts.Disabled { - st.compose = "" - } else { + if !disabled { e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool { switch { case ev.Kind == ui.InputText: diff --git a/pin_input_test.go b/pin_input_test.go index 07bcf9f..9fc79f5 100644 --- a/pin_input_test.go +++ b/pin_input_test.go @@ -102,3 +102,23 @@ func TestPinInputSnapshot(t *testing.T) { }) }) } + +func TestPinInputDisabledAfterFocus(t *testing.T) { + p := &pinInputProbe{value: "12"} + opts := PinInputOptions{Label: "Code"} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := PinInput(c, &p.value, opts) + if r.Changed() { + p.changes++ + } + }, 480, 120) + tt.Key(0, ui.KeyTab) + opts.Disabled = true + tt.Type("3") + tt.Key(0, ui.KeyBackspace) + tt.Frame() + if p.value != "12" || p.changes != 0 { + t.Fatalf("value %q changes %d", p.value, p.changes) + } +} diff --git a/popconfirm.go b/popconfirm.go index 5f2c9b8..f4bc647 100644 --- a/popconfirm.go +++ b/popconfirm.go @@ -25,9 +25,7 @@ type PopconfirmResult struct { Cancelled bool } -// Popconfirm asks for a light confirmation beside anchor while *open is -// true. Confirm takes the focus; Cancel, Escape and an outside click cancel. -// Use AlertDialog for high-risk actions. +// Popconfirm asks for a light confirmation by anchor; use AlertDialog for high-risk actions. func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult { if anchor == nil { panic("mujica: Popconfirm needs an anchor") @@ -39,7 +37,7 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp if !*open { return res } - k := Tokens(c) + overlayLayer(c, open) u := Density(c).Unit() confirm, cancel := opts.ConfirmLabel, opts.CancelLabel if confirm == "" { @@ -56,22 +54,11 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp shown := true ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { overlayStyle(c, panel, overlayAnchored(c, panel, b)) - panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(320).Role(ui.RoleAlertDialog). + panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(popoverWide(c, 320)).MaxHeight(popoverRoom(c, b)).Role(ui.RoleAlertDialog). Label(opts.Title).Description(opts.Description) - ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { - color := k.Warning - if opts.Danger { - color = k.Danger - } - ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0) - ui.Column(c).Gap(u).Shrink(1).Children(func() { - ui.Text(c, opts.Title).FontWeight(600) - if opts.Description != "" { - ui.Text(c, opts.Description).TextColor(k.TextMuted) - } - }) - }) - ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + // The text scrolls so the actions stay in the window. + ui.Scroll(c).Shrink(1).Children(func() { popconfirmText(c, opts) }) + ui.Row(c).Gap(u * 2).Justify(ui.End).Shrink(0).Children(func() { if Button(c, cancel, ButtonOptions{Variant: Secondary}).Clicked() { res.Cancelled = true } @@ -89,3 +76,22 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp } return res } + +// popconfirmText is the icon, title and description of a Popconfirm. +func popconfirmText(c *ui.Context, opts PopconfirmOptions) { + k := Tokens(c) + u := Density(c).Unit() + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { + color := k.Warning + if opts.Danger { + color = k.Danger + } + ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0) + ui.Column(c).Gap(u).Shrink(1).Children(func() { + ui.Text(c, opts.Title).FontWeight(600) + if opts.Description != "" { + ui.Text(c, opts.Description).TextColor(k.TextMuted) + } + }) + }) +} diff --git a/popconfirm_test.go b/popconfirm_test.go index 32aef2a..b939a45 100644 --- a/popconfirm_test.go +++ b/popconfirm_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strings" "testing" "github.com/egoist/mygo/ui" @@ -59,6 +60,25 @@ func TestPopconfirmCancelPaths(t *testing.T) { } } +func TestPopconfirmKeepsActionsInWindow(t *testing.T) { + long := strings.Repeat("The east gate stays unwatched tonight. ", 60) + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(30).Children(func() { + b := Button(c, "Archive", ButtonOptions{}) + Popconfirm(c, b, &open, PopconfirmOptions{Title: "Archive?", Description: long}) + }) + }, 400, 300) + tt.Frame() + for _, label := range []string{"Confirm", "Cancel"} { + r, ok := tt.Find(label) + if !ok || r.Y < 0 || r.Y+r.H > 300 || r.X+r.W > 400 { + t.Fatalf("%s at %+v outside the 400×300 window", label, r) + } + } +} + func TestPopconfirmSnapshot(t *testing.T) { snapshot(t, "popconfirm", 420, 220, func(c *ui.Context) { open := true diff --git a/popover.go b/popover.go index aef1a18..609a16d 100644 --- a/popover.go +++ b/popover.go @@ -18,9 +18,7 @@ type PopoverOptions struct { Label string } -// Popover shows content in a panel below anchor while *open is true. The -// panel flips above where there is no room, stays in the window and scrolls -// when taller than the room; an outside click or Escape closes it. +// Popover shows content below anchor, or above it, inside the window while *open is true. func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element { if anchor == nil { panic("mujica: Popover needs an anchor") @@ -28,6 +26,7 @@ func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, if !*open { return nil } + overlayLayer(c, open) u := Density(c).Unit() b := anchor.Bounds() label := opts.Label @@ -36,14 +35,14 @@ func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, } panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { overlayStyle(c, panel, overlayAnchored(c, panel, b)) - panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(b.W).MaxHeight(popoverRoom(c, b)).Label(label) + panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(popoverWide(c, b.W)).MaxHeight(popoverRoom(c, b)).Label(label) if opts.Width > 0 { - panel.Width(opts.Width) + panel.Width(popoverWide(c, opts.Width)) } else { - panel.MaxWidth(360) + panel.MaxWidth(popoverWide(c, 360)) } if opts.Title != "" { - ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).FontWeight(600).Shrink(0) + ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.BodySize+2)).FontWeight(600).Shrink(0) } ui.Scroll(c).Shrink(1).Children(content) }) @@ -58,3 +57,9 @@ func popoverRoom(c *ui.Context, b ui.Rect) float32 { _, h := c.Size() return max(b.Y, h-b.Y-b.H) - 16 } + +// popoverWide caps a panel width w to the window. +func popoverWide(c *ui.Context, w float32) float32 { + ww, _ := c.Size() + return min(w, ww-8) +} diff --git a/popover_test.go b/popover_test.go index a5226b5..db3887e 100644 --- a/popover_test.go +++ b/popover_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strings" "testing" "github.com/egoist/mygo/ui" @@ -76,6 +77,27 @@ func TestPopoverOutsideClickAndEscape(t *testing.T) { } } +func TestPopoverFitsWindow(t *testing.T) { + open := true + var panel ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(30).Children(func() { + b := Button(c, "Filter", ButtonOptions{}) + if p := Popover(c, b, &open, PopoverOptions{Width: 1000}, func() { + ui.Text(c, strings.Repeat("Long row of guests. ", 200)) + Button(c, "Bottom", ButtonOptions{}) + }); p != nil { + panel = p.Bounds() + } + }) + }, 400, 300) + tt.Frame() + if panel.W > 400 || panel.X < 0 || panel.Y+panel.H > 300 { + t.Fatalf("panel %+v outside the 400×300 window", panel) + } +} + func TestPopoverNeedsAnchor(t *testing.T) { defer func() { if recover() == nil { diff --git a/popup_menu.go b/popup_menu.go index 0b3db9e..d0d272a 100644 --- a/popup_menu.go +++ b/popup_menu.go @@ -17,8 +17,7 @@ type PopupMenuItemOptions struct { KeepOpen bool } -// PopupMenu builds the items of a DropdownMenu or ContextMenu. Its methods -// add items in order and report choices in the frame they happen. +// PopupMenu builds the items of a DropdownMenu or ContextMenu, reporting choices as they happen. type PopupMenu struct { c *ui.Context root *popupMenuRoot @@ -43,8 +42,7 @@ type popupMenuSub struct { build func(m *PopupMenu) } -// Item adds an item showing label and reports whether it was chosen. -// Choosing it closes the menu unless KeepOpen is set. +// Item adds an item and reports its choice, which closes the menu unless KeepOpen. func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool { it := m.row(ui.ButtonBase(m.c), opts.Disabled, false) m.content(it, label, false, false) @@ -68,8 +66,7 @@ func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions return true } -// Submenu adds an item opening a nested menu that build fills. Hover, -// click, Enter, Space or Right opens it; Left or Escape closes it. +// Submenu adds an item opening a nested menu that build fills. func (m *PopupMenu) Submenu(label string, opts PopupMenuItemOptions, build func(m *PopupMenu)) { if build == nil { panic("mujica: PopupMenu Submenu needs a build function") @@ -95,7 +92,7 @@ func (m *PopupMenu) Group(label string, fn func()) { m.Separator() } if label != "" { - ui.Text(m.c, label).FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0) + ui.Text(m.c, label).FontSize(FontSize(m.c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0) } fn() } @@ -115,7 +112,8 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { m.rows++ seq := m.seq it.Justify(ui.Start).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). - Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).FocusRing(false).TextColor(k.Text) + Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.Text) + internal.Ring(it, k.Focus, theme.ControlRadius) if disabled { return it } @@ -135,7 +133,7 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { } *was = hovered if hovered || it.FocusVisible() || m.level.open == seq { - it.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) { + it.Background(k.Selection).Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) }) } @@ -163,13 +161,15 @@ func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m * if !*open { return nil } + overlayLayer(c, open) b := anchor.Bounds() root := &popupMenuRoot{} panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { overlayStyle(c, panel, overlayAnchored(c, panel, b)) - panel.Margin(4, 0, 0, 0).MinWidth(max(b.W, 200)).MaxWidth(360).MaxHeight(popoverRoom(c, b)) - items := popupMenuLevelBuild(c, panel, root, build, true) - if items.Shortcut(0, ui.KeyTab) || items.Shortcut(ui.Shift, ui.KeyTab) { + panel.Margin(4, 0, 0, 0).MinWidth(popoverWide(c, max(b.W, 200))).MaxWidth(popoverWide(c, 360)).MaxHeight(popoverRoom(c, b)) + popupMenuLevelBuild(c, panel, root, build, true) + // The panel holds every level, so Tab in a submenu closes the whole menu too. + if panel.Shortcut(0, ui.KeyTab) || panel.Shortcut(ui.Shift, ui.KeyTab) { root.close = true } }) @@ -183,9 +183,10 @@ func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m * } // popupMenuLevelBuild builds one level's items in outer, then its open submenu. -func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) *ui.Element { +func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) { u := Density(c).Unit() - items := ui.Scroll(c).Shrink(1).Padding(u).FocusGroup(ui.Vertical) + // The padding leaves room for the items' focus rings inside the clipping scroll. + items := ui.Scroll(c).Shrink(1).Padding(u + theme.FocusGap + theme.FocusWidth).FocusGroup(ui.Vertical) m := &PopupMenu{c: c, root: root, autofocus: autofocus} m.level = ui.Local(items, "mujica.menu.level", func() popupMenuLevel { return popupMenuLevel{} }) outer.Role(ui.RolePopup) @@ -195,7 +196,6 @@ func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, popupMenuSubmenu(c, outer, root, m.level, s) } } - return items } // popupMenuSubmenu builds a submenu beside its item, flipped to stay in the window. diff --git a/progress.go b/progress.go index 08f60cd..1bac8af 100644 --- a/progress.go +++ b/progress.go @@ -78,8 +78,8 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element { } bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border) shown := float32(value) - if !opts.Indeterminate && !still { - shown = bar.Animate("progress.value", shown, theme.StateDuration) + if !opts.Indeterminate { + shown = progressAnimate(bar, shown, still) } bar.Draw(func(p *ui.Painter, r ui.Rect) { switch { @@ -110,10 +110,13 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c row.Children(func() { ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent) shown := float32(acc) - if !opts.Indeterminate && !still { - shown = ring.Animate("progress.value", shown, theme.StateDuration) + if !opts.Indeterminate { + shown = progressAnimate(ring, shown, still) } stroke := max(3, d/10) + size := FontSize(c, theme.CaptionSize) + tw, th := c.MeasureText(0, ui.Span{Text: caption, Size: size}) + inside := caption != "" && !opts.Indeterminate && tw+4 <= d-2*stroke && th <= d-2*stroke ring.Draw(func(p *ui.Painter, r ui.Rect) { cx, cy, rad := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, (r.W-stroke)/2) var track ui.Path @@ -132,19 +135,19 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c p.StrokePath(progressArc(p, cx, cy, rad, start, start+0.25), stroke, k.AccentText) } }) - if caption != "" && d >= 44 && !opts.Indeterminate { + if inside { ring.Children(func() { - ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text) + ui.Text(c, caption).FontSize(size).FontFeatures("tnum").TextColor(k.Text) }) ring.Center() } - if opts.Label != "" || (caption != "" && (d < 44 || opts.Indeterminate)) { + if opts.Label != "" || (caption != "" && !inside) { ui.Column(c).Children(func() { if opts.Label != "" { ui.Text(c, opts.Label).TextColor(k.Text) } - if caption != "" && (d < 44 || opts.Indeterminate) { - ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + if caption != "" && !inside { + ui.Text(c, caption).TextColor(k.TextMuted).FontSize(size).FontFeatures("tnum") } }) } @@ -152,6 +155,15 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c return row } +// progressAnimate eases toward target; under reduced motion it lands there and keeps the state in step. +func progressAnimate(e *ui.Element, target float32, still bool) float32 { + if still { + // A changed target lands at once under reduced motion; the detour stops a running transition. + e.Animate("progress.value", target+1, theme.StateDuration) + } + return e.Animate("progress.value", target, theme.StateDuration) +} + // progressArc is the arc from turn a to turn b, clockwise from twelve o'clock. func progressArc(pt *ui.Painter, cx, cy, r float32, a, b float32) *ui.Path { var p ui.Path @@ -230,6 +242,9 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element { } frac := func(v float64) float32 { return float32((v - lo) / (hi - lo)) } col := ui.Column(c).Gap(u * 1.5) + if !(opts.Width >= 0) || math.IsInf(float64(opts.Width), 1) { + panic(fmt.Sprintf("mujica: Meter Width %v invalid", opts.Width)) + } if opts.Width > 0 { col.Width(opts.Width) } else { diff --git a/radio_group.go b/radio_group.go index b0ac1a8..9f6b11c 100644 --- a/radio_group.go +++ b/radio_group.go @@ -2,6 +2,7 @@ package mujica import ( "log/slog" + "slices" "github.com/egoist/mygo/ui" @@ -45,10 +46,17 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt var r InputResult // Disabled on a wrapper blocks the bases' clicks before they choose. wrap := ui.Box(c).Shrink(0).Disabled(opts.Disabled) + // Keys by first appearance of each value: fmt keys would merge equal-looking values. + known := ui.Local(wrap, "values", func() []T { return nil }) wrap.Children(func() { r.Element = ui.RadioGroup(c, func() { for _, it := range items { - if radioGroupItem(c, k, d, value, it) { + 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) { r.changed = true slog.Debug("mujica: radio_group choose", "label", it.Label) } @@ -58,21 +66,21 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt if opts.Horizontal { r.Element.Row().Gap(d.Unit() * 6) } - if opts.Disabled && wrap.FocusWithin() { + if wrap.IsDisabled() && wrap.FocusWithin() { wrap.FocusRing(false).Focus() } return r } // radioGroupItem builds one radio and reports whether it was chosen. -func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T]) bool { +func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T], key int) bool { // Keyed by value so focus follows the item, not its position. var row *ui.Element - item := ui.Box(c).Key(it.Value).Disabled(it.Disabled) + item := ui.Box(c).Key(key).Disabled(it.Disabled) item.Children(func() { row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) }) - if it.Disabled && item.FocusWithin() { + if item.IsDisabled() && item.FocusWithin() { item.FocusRing(false).Focus() } on := *value == it.Value diff --git a/range_slider.go b/range_slider.go index 26d62d2..7a3b088 100644 --- a/range_slider.go +++ b/range_slider.go @@ -22,11 +22,11 @@ type RangeSliderOptions struct { Label string } -// RangeSlider sets *low and *high, which never cross. A press moves the -// nearer thumb; each thumb takes the focus and the arrows, Home and End. +// RangeSlider sets *low and *high, which never cross; a press moves the nearer thumb, each takes the keys. func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) ChoiceResult { g, h := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c) - format := sliderFormat(opts.Format, opts.Step) + format := sliderFormat(opts.Format, g) + size := FontSize(c, theme.CaptionSize) k := Tokens(c) *low, *high = g.snap(math.Min(*low, *high)), g.snap(math.Max(*low, *high)) oldLow, oldHigh := *low, *high @@ -47,32 +47,24 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho from, to = *low, g.hi } x := g.x(ui.Rect{W: r.W}, *v) - sliderThumb/2 - kb := ui.Box(c).Key(i).Absolute().Left(x).Top(g.trackY-sliderThumb/2).Size(sliderThumb, sliderThumb). - Focusable().FocusRing(false).Role(ui.RoleSlider) + var kb *ui.Element + var next float64 + var held bool + // Each thumb is a SliderBase, which reports the range and value to assistive technology. + ui.Box(c).Key(i).Absolute().Left(x).Top(g.trackY-sliderThumb/2).Size(sliderThumb, sliderThumb).Children(func() { + kb, next, held = sliderControl(c, *v, g, from, to) + }) + kb.Size(sliderThumb, sliderThumb).FocusRing(false) name := msg(c, []string{"range_slider.min", "range_slider.max"}[i]) if opts.Label != "" { name = opts.Label + " " + name } - kb.Label(name + ", " + format(*v)) - step := func(d float64) float64 { - if g.step > 0 { - return g.snap(*v + d*g.step) - } - return *v + d*(g.hi-g.lo)/100 - } - switch { - case kb.Shortcut(0, ui.KeyLeft), kb.Shortcut(0, ui.KeyDown): - *v = max(from, step(-1)) - case kb.Shortcut(0, ui.KeyRight), kb.Shortcut(0, ui.KeyUp): - *v = min(to, step(1)) - case kb.Shortcut(0, ui.KeyHome): - *v = from - case kb.Shortcut(0, ui.KeyEnd): - *v = to - } - if _, _, ok := kb.Dragged(); ok { + kb.Label(name) + if held { pressed = true *drag = i + } else if !opts.Disabled { + *v = next } knobs[i] = kb } @@ -104,12 +96,12 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho hover := e.Hovered() e.Draw(func(p *ui.Painter, r ui.Rect) { x0, x1 := g.x(r, lo), g.x(r, hi) - sliderTrackPaint(p, r, g, k, opts.Marks, format, x0, x1) + sliderTrackPaint(p, r, g, k, opts.Marks, size, x0, x1) for i, x := range []float32{x0, x1} { hot := which == i || knobs[i].Hovered() sliderThumbPaint(p, k, x, r.Y+g.trackY, hot, knobs[i].FocusVisible()) if opts.ShowValue && (hot || hover || knobs[i].Focused()) { - rangeSliderBubble(p, k, x, r.Y+g.trackY, format([]float64{lo, hi}[i]), i) + rangeSliderBubble(p, k, x, r.Y+g.trackY, format([]float64{lo, hi}[i]), i, size) } } }) @@ -120,11 +112,11 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho type rangeSliderDrag struct{} // rangeSliderBubble keeps the two tips apart: the low one leans left, the high one right. -func rangeSliderBubble(p *ui.Painter, k theme.Tokens, x, y float32, s string, i int) { - w, _ := p.MeasureText(1000, ui.Span{Text: s, Size: theme.CaptionSize}) +func rangeSliderBubble(p *ui.Painter, k theme.Tokens, x, y float32, s string, i int, size float32) { + w, _ := p.MeasureText(1000, ui.Span{Text: s, Size: size}) shift := (w+12)/2 - sliderThumb/2 if i == 0 { shift = -shift } - sliderBubblePaint(p, k, x+shift, y, s) + sliderBubblePaint(p, k, x+shift, y, s, size) } diff --git a/range_slider_test.go b/range_slider_test.go index d8ba558..d82bbf6 100644 --- a/range_slider_test.go +++ b/range_slider_test.go @@ -21,7 +21,7 @@ func TestRangeSliderKeysPerThumb(t *testing.T) { low, high, changes := 20.0, 80.0, 0 tt := rangeTester(&low, &high, RangeSliderOptions{Step: 10, Label: "Price"}, &changes) tt.Key(0, ui.KeyTab) - if !tt.Focused("Price minimum, 20") { + if !tt.Focused("Price minimum") { t.Fatal("first Tab did not focus the low thumb") } tt.Key(0, ui.KeyRight) @@ -37,7 +37,7 @@ func TestRangeSliderKeysPerThumb(t *testing.T) { t.Fatalf("low crossed high: %v", low) } tt.Key(0, ui.KeyTab) - if !tt.Focused("Price maximum, 80") { + if !tt.Focused("Price maximum") { t.Fatal("second Tab did not focus the high thumb") } tt.Key(0, ui.KeyLeft) @@ -77,18 +77,6 @@ func TestRangeSliderOrdersSwappedEnds(t *testing.T) { } } -func TestRangeSliderDisabled(t *testing.T) { - low, high, changes := 20.0, 40.0, 0 - tt := rangeTester(&low, &high, RangeSliderOptions{Disabled: true}, &changes) - tt.Key(0, ui.KeyTab) - tt.Key(0, ui.KeyRight) - tt.Press(150, 18) - tt.Release(150, 18) - if low != 20 || high != 40 || changes != 0 { - t.Fatalf("disabled moved: %v..%v", low, high) - } -} - func TestRangeSliderSnapshot(t *testing.T) { snapshot(t, "range_slider", 420, 170, func(c *ui.Context) { a, b := 20.0, 70.0 diff --git a/rating.go b/rating.go index ccf7942..f0110f9 100644 --- a/rating.go +++ b/rating.go @@ -48,6 +48,8 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { } k := Tokens(c) size := Density(c).ControlHeight() * 0.6 + // Each star sits centered in a hit area of at least the minimum size. + hit := max(size, theme.MinHitSize) label := opts.Label if label == "" { label = msg(c, "rating.label") @@ -64,10 +66,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { e := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Shrink(0) parts.Element = e e.Label(label) - interactive := !opts.ReadOnly && !opts.Disabled - if opts.Disabled { - e.Disabled(true) - } else { + e.Disabled(opts.Disabled) + disabled := e.IsDisabled() + interactive := !opts.ReadOnly && !disabled + if !disabled { e.Focusable() internal.Ring(e, k.Focus, theme.ControlRadius) } @@ -85,10 +87,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { } } e.Children(func() { - stars := ui.Box(c).Size(size*float32(n), size).Shrink(0) + stars := ui.Box(c).Size(hit*float32(n), hit).Shrink(0) shown, fill := *value, k.AccentText if interactive { - at := func(x float32) float64 { return math.Ceil(float64(x/size)/step-1e-9) * step } + at := func(x float32) float64 { return math.Ceil(float64(x/hit)/step-1e-9) * step } if _, _, ok := stars.Dragged(); ok { x, _, _ := stars.PointerPosition() set(at(x)) @@ -99,7 +101,7 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts { } stars.Draw(func(p *ui.Painter, r ui.Rect) { for i := range n { - cell := ui.Rect{X: r.X + float32(i)*size, Y: r.Y, W: size, H: size} + cell := ui.Rect{X: r.X + float32(i)*hit + (hit-size)/2, Y: r.Y + (hit-size)/2, W: size, H: size} path := ratingStar(cell) p.StrokePath(path, 1.25, k.ControlBorder) part := float32(math.Max(0, math.Min(1, shown-float64(i)))) diff --git a/rating_test.go b/rating_test.go index b814fc2..c48634e 100644 --- a/rating_test.go +++ b/rating_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) func ratingTester(v *float64, opts RatingOptions, changes *int) *ui.Tester { @@ -48,7 +50,7 @@ func TestRatingKeyboardSteps(t *testing.T) { func TestRatingPointer(t *testing.T) { v, n := 0.0, 0 tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n) - size := float32(36) * 0.6 + size := theme.MinHitSize // The left half of the third star. tt.ClickAt(10+2*size+size*0.25, 10+size/2) if v != 2.5 || n != 1 { @@ -100,3 +102,18 @@ func TestRatingSnapshot(t *testing.T) { }) }) } + +func TestRatingHitAreaCompact(t *testing.T) { + v := 2.0 + var r RatingParts + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + r = Rating(c, &v, RatingOptions{}) + }) + }, 300, 100) + tt.Frame() + if b := r.Element.Bounds(); b.W < 5*28 || b.H < 28 { + t.Fatalf("stars %v", b) + } +} diff --git a/search_input.go b/search_input.go index 176b042..ead6c59 100644 --- a/search_input.go +++ b/search_input.go @@ -59,8 +59,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn name = place } var r SearchInputResult + look := &textInputLook{disabled: opts.Disabled} step := func(d int) { - if opts.Matches == 0 || opts.Disabled { + if opts.Matches == 0 || look.disabled { return } *opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches @@ -69,7 +70,10 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn } if opts.Find { *opts.Current = max(0, min(*opts.Current, opts.Matches-1)) - if !opts.Disabled { + } + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + if opts.Find { + // Built inside the frame, where step knows of disabled ancestors. mac := runtime.GOOS == "darwin" switch { case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3): @@ -78,13 +82,10 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn step(-1) } } - } - look := &textInputLook{disabled: opts.Disabled} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { ui.Icon(c, icons.Must("search")).Size(16, 16).Shrink(0).TextColor(k.TextMuted) back := ui.Local(frame, "back", func() bool { return false }) - in := textInputEditor(c, frame, query, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name) - r.changed = in.Changed() && !opts.Disabled + in := textInputEditor(c, frame, query, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name) + r.changed = in.Changed() && !look.disabled if opts.Find { // Shift+Enter would reach the editor as a plain submit. in.HandleInput(func(ev ui.InputEvent) bool { @@ -102,9 +103,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn step(1) } } else { - r.submitted = in.Submitted() && !opts.Disabled + r.submitted = in.Submitted() && !look.disabled } - if *query != "" && !opts.Disabled && textInputClear(c, in) { + if *query != "" && !look.disabled && textInputClear(c, in) { *query = "" r.changed = true slog.Debug("mujica: search_input clear") diff --git a/select.go b/select.go index 1a6e1ef..78068ab 100644 --- a/select.go +++ b/select.go @@ -41,19 +41,15 @@ type selectTypeahead struct { at time.Time } -// Select chooses *value from options in a popup. Enter, Space and the -// arrows open it; the arrows, Home and End move; Enter chooses; Escape -// closes. Typing on the closed select chooses the option starting with it. +// Select chooses *value from options in a popup; typing on the closed select jumps to a match. func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], opts SelectOptions) ChoiceResult { k := Tokens(c) old := *value - if opts.Disabled { - // SelectBase takes clicks as it is built: a disabled one works on a copy. - scratch := old - value = &scratch - } - s := ui.SelectBase(c, value) - tr := s.Trigger.Disabled(opts.Disabled) + value = choiceTarget(value, opts.Disabled) + var s *ui.SelectParts[T] + // SelectBase takes clicks as it is built: a disabled wrapper and a copy block them. + ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { s = ui.SelectBase(c, value) }) + tr := s.Trigger if opts.Label != "" { tr.Label(opts.Label) } @@ -93,6 +89,10 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op } }) lastHi := ui.Local(tr, selectKey{"shown"}, func() int { return -1 }) + if opts.Disabled { + // SelectParts has no way to close it (ui/base.go:310): a disabled select builds no popup. + return ChoiceResult{tr, false} + } s.Popup(func(panel *ui.Element) { selectPanel(c, panel) ui.Scroll(c).MaxHeight(280).Children(func() { @@ -191,17 +191,33 @@ func selectChevron(open bool) string { return "chevron-down" } -// selectClear draws a small clear button that takes no Tab stop; it reports a click. +// selectClear draws a clear cross outside the Tab order, hit area 28 DIP laid out as 18; it reports a click. func selectClear(c *ui.Context, k theme.Tokens, name string) bool { - x := ui.Box(c).Size(18, 18).Shrink(0).Radius(theme.ControlRadius).Role(ui.RoleButton).Label(name) + x := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Margin(-5).Shrink(0).Radius(theme.ControlRadius). + Role(ui.RoleButton).Label(name).Cursor(ui.CursorPointer) clicked := x.Clicked() if x.Hovered() { x.Background(k.SurfaceHover) } - x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, "x", k.TextMuted) }) + x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, selectCenter(r, 14), "x", k.TextMuted) }) return clicked } +// selectCenter returns the size×size square centered in r. +func selectCenter(r ui.Rect, size float32) ui.Rect { + return ui.Rect{X: r.X + (r.W-size)/2, Y: r.Y + (r.H-size)/2, W: size, H: size} +} + +// selectCheck reserves room for the chosen mark and draws it when on. +func selectCheck(c *ui.Context, on bool) { + k := Tokens(c) + ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + if on { + internal.Mark(p, r, "check", k.AccentText) + } + }) +} + // selectPanel styles a popup panel. func selectPanel(c *ui.Context, panel *ui.Element) { k := Tokens(c) @@ -209,24 +225,25 @@ func selectPanel(c *ui.Context, panel *ui.Element) { Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14)) } -// selectRow styles an option row: highlight background, check mark when chosen. +// selectRow styles an option row: chosen has the selection fill and a check, highlighted a side bar. func selectRow(c *ui.Context, row *ui.Element, highlighted, chosen bool, label string) { k := Tokens(c) d := Density(c) - row.MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2). - AlignItems(ui.Center).Radius(theme.ControlRadius) - if highlighted { + row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2). + AlignItems(ui.Center).Radius(theme.ControlRadius).Role(ui.RoleListItem) + switch { + case chosen: + row.Background(k.Selection).TextColor(k.AccentText) + case highlighted: row.Background(k.SurfaceHover) } - if chosen { - row.TextColor(k.AccentText) + if highlighted { + row.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) + }) } row.Children(func() { - ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { - if chosen { - internal.Mark(p, r, "check", k.AccentText) - } - }) + selectCheck(c, chosen) ui.Text(c, label).SingleLine() }) } diff --git a/slider.go b/slider.go index cea855b..1b31f97 100644 --- a/slider.go +++ b/slider.go @@ -4,6 +4,7 @@ import ( "log/slog" "math" "strconv" + "strings" "github.com/egoist/mygo/ui" @@ -25,7 +26,7 @@ type SliderOptions struct { Marks []SliderMark // ShowValue shows the value above the thumb while hovered, dragged or focused. ShowValue bool - // Format writes the value; nil prints it with the step's precision. + // Format writes the value; nil prints as many decimals as Min and Step need. Format func(float64) string Disabled bool // Label names the slider for assistive technology. @@ -63,20 +64,67 @@ func (g sliderGeom) snap(v float64) float64 { return math.Round(v*1e9) / 1e9 } -// keyed returns the value after a key moved old to raw, MyGo's 1% key step, -// as one step of the slider: Home and End land on the ends. -func (g sliderGeom) keyed(old, raw float64) float64 { - switch { - case g.step <= 0: - return raw - case raw == g.lo: - return g.lo - case raw == g.hi: - return g.snap(g.hi) - case raw > old: - return g.snap(min(g.hi, old+g.step)) +// sliderKey is the last key released on a slider, and a move waiting for it. +type sliderKey struct { + up ui.Key + pending int +} + +type sliderKeyID struct{} + +// sliderControl builds a SliderBase on a copy of v and returns where the pointer or a key moves v. +func sliderControl(c *ui.Context, v float64, g sliderGeom, from, to float64) (s *ui.Element, next float64, dragged bool) { + raw := v + s = ui.SliderBase(c, &raw, g.lo, g.hi) + ks := *ui.Local(s, sliderKeyID{}, func() *sliderKey { return &sliderKey{} }) + // MyGo gives key downs to the base alone (ui/base.go:118); releases still reach this handler. + s.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind != ui.InputKeyUp { + return false + } + ks.up = ev.Key + return true + }) + up, dir := ks.up, ks.pending + ks.up, ks.pending = 0, 0 + if _, _, dragged = s.Dragged(); dragged { + return s, max(from, min(to, g.snap(raw))), true } - return g.snap(old - g.step) + unsure := dir != 0 + switch { + case raw < v: + dir, unsure = -1, false + case raw > v: + dir, unsure = 1, false + } + if dir == 0 { + return s, v, false + } + end, limit := g.lo, from + if dir > 0 { + end, limit = g.hi, to + } + jump := raw == end + // An arrow within 1% of an end lands on it too: the released key tells which it was. + if unsure || jump && math.Abs(v-end) <= (g.hi-g.lo)/100*(1+1e-9) { + switch up { + case ui.KeyHome, ui.KeyEnd: + jump = true + case ui.KeyLeft, ui.KeyRight, ui.KeyUp, ui.KeyDown: + jump = false + default: + ks.pending = dir + return s, v, false + } + } + if jump { + return s, limit, false + } + step := g.step + if step <= 0 { + step = (g.hi - g.lo) / 100 + } + return s, max(from, min(to, g.snap(v+float64(dir)*step))), false } func sliderSetup(min, max, step float64, marks []SliderMark, showValue bool, c *ui.Context) (sliderGeom, float32) { @@ -101,66 +149,70 @@ func sliderSetup(min, max, step float64, marks []SliderMark, showValue bool, c * return g, h } -func sliderFormat(format func(float64) string, step float64) func(float64) string { +// sliderDecimals counts the decimals the shortest form of each value needs. +func sliderDecimals(vs ...float64) int { + n := 0 + for _, v := range vs { + s := strconv.FormatFloat(v, 'f', -1, 64) + if i := strings.IndexByte(s, '.'); i >= 0 { + n = max(n, len(s)-i-1) + } + } + return n +} + +func sliderFormat(format func(float64) string, g sliderGeom) func(float64) string { if format != nil { return format } - prec := 0 - if step > 0 && step < 1 { - prec = int(math.Ceil(-math.Log10(step) - 1e-9)) - } else if step == 0 { - prec = 1 + prec := sliderDecimals(g.lo, g.step) + if g.step == 0 { + prec = max(prec, 1) } return func(v float64) string { return strconv.FormatFloat(v, 'f', prec, 64) } } -// Slider sets *value between Min and Max by dragging anywhere on its box, -// the arrows (one Step), Home and End. The thumb is a diamond; the hit area -// is the whole rectangle. +// Slider sets *value by dragging anywhere on its box, the arrows (one Step), Home and End. func Slider(c *ui.Context, value *float64, opts SliderOptions) ChoiceResult { g, h := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c) - format := sliderFormat(opts.Format, opts.Step) + format := sliderFormat(opts.Format, g) + size := FontSize(c, theme.CaptionSize) k := Tokens(c) old := g.snap(*value) *value = old - target := value - if opts.Disabled { - // SliderBase takes the pointer as it is built: a disabled one moves a copy. - scratch := old - target = &scratch - } - s := ui.SliderBase(c, target, g.lo, g.hi).Height(h).MinWidth(120).PaddingX(sliderThumb / 2). - FocusRing(false).Disabled(opts.Disabled).Cursor(ui.CursorPointer) + var s *ui.Element + var next float64 + var held bool + // Disabled on a wrapper blocks the base before it takes input. + ui.Column(c).Disabled(opts.Disabled).Children(func() { + s, next, held = sliderControl(c, old, g, g.lo, g.hi) + }) + s.Height(h).MinWidth(120).PaddingX(sliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer) if opts.Label != "" { s.Label(opts.Label) } - if *value != old { - if _, _, drag := s.Dragged(); drag { - *value = g.snap(*value) - } else { - *value = g.keyed(old, *value) - } + if !opts.Disabled { + *value = next } changed := *value != old if changed { slog.Debug("mujica: slider changed", "label", opts.Label, "value", *value) } - _, _, held := s.Dragged() hover := s.Hovered() v := *value s.Draw(func(p *ui.Painter, r ui.Rect) { - sliderTrackPaint(p, r, g, k, opts.Marks, format, g.x(r, g.lo), g.x(r, v)) + sliderTrackPaint(p, r, g, k, opts.Marks, size, g.x(r, g.lo), g.x(r, v)) x := g.x(r, v) sliderThumbPaint(p, k, x, r.Y+g.trackY, hover || held, s.FocusVisible()) if opts.ShowValue && (hover || held || s.Focused()) { - sliderBubblePaint(p, k, x, r.Y+g.trackY, format(v)) + sliderBubblePaint(p, k, x, r.Y+g.trackY, format(v), size) } }) return ChoiceResult{s, changed} } // sliderTrackPaint draws the rail, the filled part from x0 to x1, and the marks. -func sliderTrackPaint(p *ui.Painter, r ui.Rect, g sliderGeom, k theme.Tokens, marks []SliderMark, format func(float64) string, x0, x1 float32) { +func sliderTrackPaint(p *ui.Painter, r ui.Rect, g sliderGeom, k theme.Tokens, marks []SliderMark, size, x0, x1 float32) { y := r.Y + g.trackY left, right := g.x(r, g.lo), g.x(r, g.hi) p.Fill(ui.Rect{X: left, Y: y - sliderTrack/2, W: right - left, H: sliderTrack}, k.Border, 0) @@ -170,8 +222,8 @@ func sliderTrackPaint(p *ui.Painter, r ui.Rect, g sliderGeom, k theme.Tokens, ma x := g.x(r, m.Value) p.Fill(ui.Rect{X: x - 0.5, Y: y + sliderTrack/2 + 2, W: 1, H: 5}, k.TextMuted, 0) if m.Label != "" { - w, _ := p.MeasureText(1000, ui.Span{Text: m.Label, Size: theme.CaptionSize}) - p.Text(x-w/2, y+sliderTrack/2+8, m.Label, theme.CaptionSize, k.TextMuted) + w, _ := p.MeasureText(1000, ui.Span{Text: m.Label, Size: size}) + p.Text(x-w/2, y+sliderTrack/2+8, m.Label, size, k.TextMuted) } } } @@ -191,10 +243,10 @@ func sliderThumbPaint(p *ui.Painter, k theme.Tokens, x, y float32, hot, focus bo } // sliderBubblePaint draws the value tip above the thumb at (x, y). -func sliderBubblePaint(p *ui.Painter, k theme.Tokens, x, y float32, s string) { - w, h := p.MeasureText(1000, ui.Span{Text: s, Size: theme.CaptionSize}) +func sliderBubblePaint(p *ui.Painter, k theme.Tokens, x, y float32, s string, size float32) { + w, h := p.MeasureText(1000, ui.Span{Text: s, Size: size}) b := ui.Rect{X: x - w/2 - 6, Y: y - sliderThumb/2 - h - 10, W: w + 12, H: h + 4} p.Fill(b, k.Surface, theme.ControlRadius) p.Stroke(b, k.ControlBorder, theme.ControlRadius, theme.BorderWidth) - p.Text(b.X+6, b.Y+2, s, theme.CaptionSize, k.Text) + p.Text(b.X+6, b.Y+2, s, size, k.Text) } diff --git a/slider_keys_test.go b/slider_keys_test.go new file mode 100644 index 0000000..0b27069 --- /dev/null +++ b/slider_keys_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "bytes" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSliderFineStepNearEnds(t *testing.T) { + v, changes := 99.2, 0 + tt := sliderTester(&v, SliderOptions{Step: 0.1, Label: "Gain"}, &changes) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if v != 99.3 { + t.Fatalf("Right from 99.2: %v", v) + } + tt.Key(0, ui.KeyEnd) + if v != 100 { + t.Fatalf("End: %v", v) + } + v = 0.5 + tt.Frame() + tt.Key(0, ui.KeyLeft) + if v != 0.4 { + t.Fatalf("Left from 0.5: %v", v) + } + tt.Key(0, ui.KeyHome) + if v != 0 { + t.Fatalf("Home: %v", v) + } +} + +func TestRangeSliderFineStepNearEnds(t *testing.T) { + low, high, changes := 0.3, 99.8, 0 + tt := rangeTester(&low, &high, RangeSliderOptions{Step: 0.1, Label: "Band"}, &changes) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyLeft) + if low != 0.2 { + t.Fatalf("Left from 0.3: %v", low) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if high != 99.9 { + t.Fatalf("Right from 99.8: %v", high) + } +} + +func TestSliderFormatKeepsStepDecimals(t *testing.T) { + for _, tc := range []struct { + g sliderGeom + v float64 + want string + }{ + {sliderGeom{lo: 0, hi: 1, step: 0.25}, 0.75, "0.75"}, + {sliderGeom{lo: 0.5, hi: 10, step: 1}, 2.5, "2.5"}, + {sliderGeom{lo: 0, hi: 100, step: 5}, 35, "35"}, + } { + if got := sliderFormat(nil, tc.g)(tc.v); got != tc.want { + t.Errorf("%+v: %q, want %q", tc.g, got, tc.want) + } + } +} + +func TestSliderMarksFollowTextScale(t *testing.T) { + render := func(scale float32) []byte { + v, low, high := 50.0, 20.0, 80.0 + marks := []SliderMark{{Value: 0, Label: "Low"}, {Value: 100, Label: "High"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(300).Children(func() { + Slider(c, &v, SliderOptions{Marks: marks}) + RangeSlider(c, &low, &high, RangeSliderOptions{Marks: marks}) + }) + }, 320, 200) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: scale}) + tt.Frame() + return encode(t, tt.Image()) + } + if bytes.Equal(render(1), render(2)) { + t.Fatal("mark captions ignore the text scale") + } +} diff --git a/slider_test.go b/slider_test.go index c45a28d..2ebaf2e 100644 --- a/slider_test.go +++ b/slider_test.go @@ -70,18 +70,6 @@ func TestSliderClampsOutOfRangeValue(t *testing.T) { } } -func TestSliderDisabled(t *testing.T) { - v, changes := 30.0, 0 - tt := sliderTester(&v, SliderOptions{Disabled: true, Step: 1}, &changes) - tt.Key(0, ui.KeyTab) - tt.Key(0, ui.KeyRight) - tt.Press(100, 18) - tt.Release(100, 18) - if v != 30 { - t.Fatalf("disabled slider moved to %v", v) - } -} - func TestSliderBadRange(t *testing.T) { defer func() { if recover() == nil { diff --git a/status_bar.go b/status_bar.go index 5786410..8e9d1a2 100644 --- a/status_bar.go +++ b/status_bar.go @@ -70,19 +70,25 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { hidden := statusBarHide(c, opts, was.W-d.Unit()*4, gap) open := ui.Local(e, "status_bar.open", func() bool { return false }) *open = *open && len(hidden) > 0 + // focused is the bar item that had the focus last frame, for moving it to "+N" when that item folds. + focused := ui.Local(e, "status_bar.focused", func() string { return "" }) + wasFocused := *focused + *focused = "" var res StatusBarResult - item := func(it StatusItem) { - if statusBarItem(c, it) { + item := func(it StatusItem) bool { + clicked, hasFocus := statusBarItem(c, it) + if clicked { res.Clicked = it.ID *open = false slog.Debug("mujica: status_bar clicked", "id", it.ID) } + return hasFocus } group := func(key string, items []StatusItem) { ui.Row(c).Key(key).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() { for _, it := range items { - if !hidden[it.ID] { - item(it) + if !hidden[it.ID] && item(it) { + *focused = it.ID } } }) @@ -92,6 +98,10 @@ func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult { ui.Spacer(c).Key("status_bar.spacer") if len(hidden) > 0 { more := statusBarMore(c, len(hidden)) + if wasFocused != "" && hidden[wasFocused] { + more.Focus() + slog.Debug("mujica: status_bar focus folded", "id", wasFocused) + } if more.Clicked() { *open = !*open slog.Debug("mujica: status_bar hidden items", "open", *open) @@ -217,8 +227,8 @@ func statusBarWidth(c *ui.Context, it StatusItem) float32 { return w } -// statusBarItem builds one item and reports whether its action was clicked. -func statusBarItem(c *ui.Context, it StatusItem) bool { +// statusBarItem builds one item and reports whether its action was clicked and whether it has the focus. +func statusBarItem(c *ui.Context, it StatusItem) (clicked, focused bool) { k := Tokens(c) d := Density(c) fg := k.TextMuted @@ -265,7 +275,7 @@ func statusBarItem(c *ui.Context, it StatusItem) bool { statusBarProgress(c, it.Progress) } }) - return it.Action && e.Clicked() + return it.Action && e.Clicked(), e.FocusWithin() } func statusBarProgress(c *ui.Context, v float32) { diff --git a/status_bar_test.go b/status_bar_test.go index 6c2fc4e..541f099 100644 --- a/status_bar_test.go +++ b/status_bar_test.go @@ -286,3 +286,39 @@ func TestStatusBarKeepsFocusAcrossDensity(t *testing.T) { t.Fatalf("Enter after the switch activated %d times, want 1", n) } } + +func TestStatusBarFocusMovesToMoreWhenFocusedItemFolds(t *testing.T) { + density := theme.Default + var clicked []string + o := StatusBarOptions{ + Left: []StatusItem{{ID: "main", Text: "main"}, {ID: "fmt", Text: "Format", Action: true, Secondary: true}}, + Right: []StatusItem{{ID: "bell", Label: "Notifications", Icon: icons.Must("bell"), Action: true}}, + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: density}) + if id := StatusBar(c, o).Clicked; id != "" { + clicked = append(clicked, id) + } + }, 152, 300) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Format") { + t.Fatal("Tab did not focus Format at Default density") + } + density = theme.Comfortable + tt.Frame() + if tt.HasText("Format") { + t.Fatal("Format did not fold at Comfortable density") + } + if !tt.Focused("1 more items") { + t.Fatal("focus did not move to the +N button that holds Format") + } + tt.Key(0, ui.KeyEnter) + if !tt.HasText("Format") { + t.Fatal("Enter on +N did not open the list with Format") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(clicked) != 1 || clicked[0] != "fmt" { + t.Fatalf("clicked %v, want [fmt]", clicked) + } +} diff --git a/switch.go b/switch.go index 3d1a860..e2f2d22 100644 --- a/switch.go +++ b/switch.go @@ -24,13 +24,17 @@ const ( switchK float32 = 14 ) -// Switch turns *on over when clicked or with Space. Changed reports the -// new value; the caller applies it and may set Loading while it does. +// Switch turns *on over by click or Space; Changed reports it, the caller applies it. func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element { k := Tokens(c) d := Density(c) - sw := ui.SwitchBase(c, on).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize). - FocusRing(false).Disabled(opts.Disabled || opts.Loading) + blocked := opts.Disabled || opts.Loading + var sw *ui.Element + // Disabled on a wrapper, and a copy, block the base before it takes input. + ui.Row(c).Shrink(0).Disabled(blocked).Children(func() { + sw = ui.SwitchBase(c, choiceTarget(on, blocked)).Gap(d.Unit() * 2).AlignItems(ui.Center). + MinHeight(d.ControlHeight()).FocusRing(false) + }) if opts.Description != "" { sw.Description(opts.Description) } @@ -78,6 +82,15 @@ func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Eleme return sw } +// choiceTarget is v, or a throwaway copy of it while input is blocked. +func choiceTarget[T any](v *T, blocked bool) *T { + if !blocked { + return v + } + cp := *v + return &cp +} + func switchFloat(b bool) float32 { if b { return 1 diff --git a/tags_input.go b/tags_input.go index 424e876..3f9afc6 100644 --- a/tags_input.go +++ b/tags_input.go @@ -89,9 +89,10 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa parts.changed, st.err = true, "" slog.Debug("mujica: tags input removed", "count", len(*tags)) } + disabled := opts.Disabled || outer.IsDisabled() if st.backspace { st.backspace = false - if len(*tags) > 0 && st.query == "" { + if !disabled && len(*tags) > 0 && st.query == "" { remove(len(*tags) - 1) } } @@ -117,14 +118,15 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa }) } // The input keeps its identity whatever chips come before it. - ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24).Children(func() { - in := ui.TextInputBase(c, &st.query).FocusRing(false).FillWidth().Placeholder(opts.Placeholder) + holder := ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24) + holder.Children(func() { + in := inputGroupEditor(c, holder, &st.query, disabled).FocusRing(false).FillWidth().Placeholder(opts.Placeholder) parts.Input = in if opts.Label != "" { in.Label(opts.Label) } in.HandleInput(func(ev ui.InputEvent) bool { - if ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" { + if !disabled && ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" { st.backspace = true return true } @@ -137,22 +139,8 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa } }) in := parts.Input - if in.Changed() { - st.err = "" - } - if strings.ContainsAny(st.query, seps) { - pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) }) - last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00") - var kept []string - for _, p := range pieces[:len(pieces)-1] { - if !add(p) { - kept = append(kept, strings.TrimSpace(p)) - } - } - st.query = strings.Join(append(kept, last), string([]rune(seps)[0])) - } - if in.Submitted() && add(st.query) { - st.query = "" + if !disabled { + tagsInputCommit(st, in, seps, add) } if box.Clicked() { in.Focus() @@ -174,3 +162,24 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa }) return parts } + +// tagsInputCommit adds the text before each separator, and all of it on Enter. +func tagsInputCommit(st *tagsInputState, in *ui.Element, seps string, add func(string) bool) { + if in.Changed() { + st.err = "" + } + if strings.ContainsAny(st.query, seps) { + pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) }) + last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00") + var kept []string + for _, p := range pieces[:len(pieces)-1] { + if !add(p) { + kept = append(kept, strings.TrimSpace(p)) + } + } + st.query = strings.Join(append(kept, last), string([]rune(seps)[0])) + } + if in.Submitted() && add(st.query) { + st.query = "" + } +} diff --git a/tags_input_test.go b/tags_input_test.go index da8aeb3..fb1be8d 100644 --- a/tags_input_test.go +++ b/tags_input_test.go @@ -124,3 +124,23 @@ func TestTagsInputRemoveHitArea(t *testing.T) { t.Fatalf("remove button %v", r) } } + +func TestTagsInputDisabledAfterFocus(t *testing.T) { + tags, n := []string{"rose"}, 0 + opts := TagsInputOptions{Label: "Tags"} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TagsInput(c, &tags, opts).Changed() { + n++ + } + }, 520, 200) + _ = tt.Click("Tags") + opts.Disabled = true + tt.Key(0, ui.KeyBackspace) + tt.Type("ivy,") + tt.Key(0, ui.KeyEnter) + tt.Frame() + if !slices.Equal(tags, []string{"rose"}) || n != 0 { + t.Fatalf("tags %q changes %d", tags, n) + } +} diff --git a/testdata/snapshots/darwin/button-comfortable-dark.png b/testdata/snapshots/darwin/button-comfortable-dark.png index afce442..309ee17 100644 Binary files a/testdata/snapshots/darwin/button-comfortable-dark.png and b/testdata/snapshots/darwin/button-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/button-comfortable-light.png b/testdata/snapshots/darwin/button-comfortable-light.png index 0980b9b..492caf4 100644 Binary files a/testdata/snapshots/darwin/button-comfortable-light.png and b/testdata/snapshots/darwin/button-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/button-compact-dark.png b/testdata/snapshots/darwin/button-compact-dark.png index e0868f3..a78d663 100644 Binary files a/testdata/snapshots/darwin/button-compact-dark.png and b/testdata/snapshots/darwin/button-compact-dark.png differ diff --git a/testdata/snapshots/darwin/button-compact-light.png b/testdata/snapshots/darwin/button-compact-light.png index 8afc2ba..0ea1a55 100644 Binary files a/testdata/snapshots/darwin/button-compact-light.png and b/testdata/snapshots/darwin/button-compact-light.png differ diff --git a/testdata/snapshots/darwin/button-dark.png b/testdata/snapshots/darwin/button-dark.png index 365330b..962fc23 100644 Binary files a/testdata/snapshots/darwin/button-dark.png and b/testdata/snapshots/darwin/button-dark.png differ diff --git a/testdata/snapshots/darwin/button-light.png b/testdata/snapshots/darwin/button-light.png index dbf5a78..5944471 100644 Binary files a/testdata/snapshots/darwin/button-light.png and b/testdata/snapshots/darwin/button-light.png differ diff --git a/testdata/snapshots/darwin/cascader-dark.png b/testdata/snapshots/darwin/cascader-dark.png index fbfab03..f1ddf9a 100644 Binary files a/testdata/snapshots/darwin/cascader-dark.png and b/testdata/snapshots/darwin/cascader-dark.png differ diff --git a/testdata/snapshots/darwin/cascader-light.png b/testdata/snapshots/darwin/cascader-light.png index 12bfc38..0c62d42 100644 Binary files a/testdata/snapshots/darwin/cascader-light.png and b/testdata/snapshots/darwin/cascader-light.png differ diff --git a/testdata/snapshots/darwin/color_picker-dark.png b/testdata/snapshots/darwin/color_picker-dark.png index 00f3d75..db1b531 100644 Binary files a/testdata/snapshots/darwin/color_picker-dark.png and b/testdata/snapshots/darwin/color_picker-dark.png differ diff --git a/testdata/snapshots/darwin/color_picker-light.png b/testdata/snapshots/darwin/color_picker-light.png index 804c5b8..f06cb6a 100644 Binary files a/testdata/snapshots/darwin/color_picker-light.png and b/testdata/snapshots/darwin/color_picker-light.png differ diff --git a/testdata/snapshots/darwin/combobox-dark.png b/testdata/snapshots/darwin/combobox-dark.png index 8817795..f203a78 100644 Binary files a/testdata/snapshots/darwin/combobox-dark.png and b/testdata/snapshots/darwin/combobox-dark.png differ diff --git a/testdata/snapshots/darwin/combobox-light.png b/testdata/snapshots/darwin/combobox-light.png index fb9d148..38364d3 100644 Binary files a/testdata/snapshots/darwin/combobox-light.png and b/testdata/snapshots/darwin/combobox-light.png differ diff --git a/testdata/snapshots/darwin/context_menu-dark.png b/testdata/snapshots/darwin/context_menu-dark.png index 9cda9f7..199f092 100644 Binary files a/testdata/snapshots/darwin/context_menu-dark.png and b/testdata/snapshots/darwin/context_menu-dark.png differ diff --git a/testdata/snapshots/darwin/context_menu-light.png b/testdata/snapshots/darwin/context_menu-light.png index 6699812..9f58256 100644 Binary files a/testdata/snapshots/darwin/context_menu-light.png and b/testdata/snapshots/darwin/context_menu-light.png differ diff --git a/testdata/snapshots/darwin/dropdown_menu-dark.png b/testdata/snapshots/darwin/dropdown_menu-dark.png index 4213dd3..4c587fa 100644 Binary files a/testdata/snapshots/darwin/dropdown_menu-dark.png and b/testdata/snapshots/darwin/dropdown_menu-dark.png differ diff --git a/testdata/snapshots/darwin/dropdown_menu-light.png b/testdata/snapshots/darwin/dropdown_menu-light.png index 426e388..f8b2b87 100644 Binary files a/testdata/snapshots/darwin/dropdown_menu-light.png and b/testdata/snapshots/darwin/dropdown_menu-light.png differ diff --git a/testdata/snapshots/darwin/hover_card-dark.png b/testdata/snapshots/darwin/hover_card-dark.png index 8de2e5e..6775937 100644 Binary files a/testdata/snapshots/darwin/hover_card-dark.png and b/testdata/snapshots/darwin/hover_card-dark.png differ diff --git a/testdata/snapshots/darwin/hover_card-light.png b/testdata/snapshots/darwin/hover_card-light.png index 1efdfc9..2a5f55f 100644 Binary files a/testdata/snapshots/darwin/hover_card-light.png and b/testdata/snapshots/darwin/hover_card-light.png differ diff --git a/testdata/snapshots/darwin/multi_select-dark.png b/testdata/snapshots/darwin/multi_select-dark.png index 01941b7..1e764a4 100644 Binary files a/testdata/snapshots/darwin/multi_select-dark.png and b/testdata/snapshots/darwin/multi_select-dark.png differ diff --git a/testdata/snapshots/darwin/multi_select-light.png b/testdata/snapshots/darwin/multi_select-light.png index 1caa04f..1f9d5fc 100644 Binary files a/testdata/snapshots/darwin/multi_select-light.png and b/testdata/snapshots/darwin/multi_select-light.png differ diff --git a/testdata/snapshots/darwin/number_input-dark.png b/testdata/snapshots/darwin/number_input-dark.png index 959270c..102c51f 100644 Binary files a/testdata/snapshots/darwin/number_input-dark.png and b/testdata/snapshots/darwin/number_input-dark.png differ diff --git a/testdata/snapshots/darwin/number_input-light.png b/testdata/snapshots/darwin/number_input-light.png index 74f4781..f3ab0a9 100644 Binary files a/testdata/snapshots/darwin/number_input-light.png and b/testdata/snapshots/darwin/number_input-light.png differ diff --git a/testdata/snapshots/darwin/progress-dark.png b/testdata/snapshots/darwin/progress-dark.png index cead596..14f8a0b 100644 Binary files a/testdata/snapshots/darwin/progress-dark.png and b/testdata/snapshots/darwin/progress-dark.png differ diff --git a/testdata/snapshots/darwin/progress-light.png b/testdata/snapshots/darwin/progress-light.png index 459659c..2e2b633 100644 Binary files a/testdata/snapshots/darwin/progress-light.png and b/testdata/snapshots/darwin/progress-light.png differ diff --git a/testdata/snapshots/darwin/rating-dark.png b/testdata/snapshots/darwin/rating-dark.png index 7c28431..75d7077 100644 Binary files a/testdata/snapshots/darwin/rating-dark.png and b/testdata/snapshots/darwin/rating-dark.png differ diff --git a/testdata/snapshots/darwin/rating-light.png b/testdata/snapshots/darwin/rating-light.png index cf87bd9..45171fe 100644 Binary files a/testdata/snapshots/darwin/rating-light.png and b/testdata/snapshots/darwin/rating-light.png differ diff --git a/testdata/snapshots/darwin/select-dark.png b/testdata/snapshots/darwin/select-dark.png index 0141a4e..5162a2e 100644 Binary files a/testdata/snapshots/darwin/select-dark.png and b/testdata/snapshots/darwin/select-dark.png differ diff --git a/testdata/snapshots/darwin/select-light.png b/testdata/snapshots/darwin/select-light.png index 9175808..3ef902f 100644 Binary files a/testdata/snapshots/darwin/select-light.png and b/testdata/snapshots/darwin/select-light.png differ diff --git a/testdata/snapshots/darwin/spinner-dark.png b/testdata/snapshots/darwin/spinner-dark.png index a34bd86..1a9fe92 100644 Binary files a/testdata/snapshots/darwin/spinner-dark.png and b/testdata/snapshots/darwin/spinner-dark.png differ diff --git a/testdata/snapshots/darwin/spinner-light.png b/testdata/snapshots/darwin/spinner-light.png index aed49d0..0353909 100644 Binary files a/testdata/snapshots/darwin/spinner-light.png and b/testdata/snapshots/darwin/spinner-light.png differ diff --git a/testdata/snapshots/darwin/switch-dark.png b/testdata/snapshots/darwin/switch-dark.png index d9c095b..4f6188a 100644 Binary files a/testdata/snapshots/darwin/switch-dark.png and b/testdata/snapshots/darwin/switch-dark.png differ diff --git a/testdata/snapshots/darwin/switch-light.png b/testdata/snapshots/darwin/switch-light.png index fba34d7..1f88256 100644 Binary files a/testdata/snapshots/darwin/switch-light.png and b/testdata/snapshots/darwin/switch-light.png differ diff --git a/testdata/snapshots/darwin/tooltip-dark.png b/testdata/snapshots/darwin/tooltip-dark.png index b91ffc5..ae15482 100644 Binary files a/testdata/snapshots/darwin/tooltip-dark.png and b/testdata/snapshots/darwin/tooltip-dark.png differ diff --git a/testdata/snapshots/darwin/tooltip-light.png b/testdata/snapshots/darwin/tooltip-light.png index 1e1c840..e114a88 100644 Binary files a/testdata/snapshots/darwin/tooltip-light.png and b/testdata/snapshots/darwin/tooltip-light.png differ diff --git a/testdata/snapshots/darwin/transfer-dark.png b/testdata/snapshots/darwin/transfer-dark.png index c9c50e0..44efb42 100644 Binary files a/testdata/snapshots/darwin/transfer-dark.png and b/testdata/snapshots/darwin/transfer-dark.png differ diff --git a/testdata/snapshots/darwin/transfer-light.png b/testdata/snapshots/darwin/transfer-light.png index 1030c96..11a1ac3 100644 Binary files a/testdata/snapshots/darwin/transfer-light.png and b/testdata/snapshots/darwin/transfer-light.png differ diff --git a/testdata/snapshots/darwin/tree_select-dark.png b/testdata/snapshots/darwin/tree_select-dark.png index aba3b73..d3ebe83 100644 Binary files a/testdata/snapshots/darwin/tree_select-dark.png and b/testdata/snapshots/darwin/tree_select-dark.png differ diff --git a/testdata/snapshots/darwin/tree_select-light.png b/testdata/snapshots/darwin/tree_select-light.png index 6fdfd09..323e67a 100644 Binary files a/testdata/snapshots/darwin/tree_select-light.png and b/testdata/snapshots/darwin/tree_select-light.png differ diff --git a/text_area.go b/text_area.go index 7dc8eac..b9e82b8 100644 --- a/text_area.go +++ b/text_area.go @@ -55,7 +55,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { var r InputResult look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: errMsg, area: true} r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { - in := textInputEditor(c, frame, value, opts.ReadOnly || opts.Disabled, ui.TextAreaBase).Grow(1).Padding(d.Unit()*2, d.Unit()*3). + in := textInputEditor(c, frame, value, opts.ReadOnly || look.disabled, ui.TextAreaBase).Grow(1).Padding(d.Unit()*2, d.Unit()*3). Placeholder(opts.Placeholder).Label(opts.Label) if opts.MinHeight > 0 { in.MinHeight(opts.MinHeight) @@ -63,7 +63,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { if opts.MaxHeight > 0 { in.MaxHeight(opts.MaxHeight) } - r.changed = in.Changed() && !opts.ReadOnly && !opts.Disabled + r.changed = in.Changed() && !opts.ReadOnly && !look.disabled // Taken before the editor, which would make a plain Enter a newline. submit := ui.Local(frame, "submit", func() bool { return false }) in.HandleInput(func(ev ui.InputEvent) bool { @@ -73,7 +73,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { } return false }) - r.submitted = *submit && !opts.Disabled + r.submitted = *submit && !look.disabled *submit = false return in }, count) diff --git a/text_input.go b/text_input.go index acacf0d..961e082 100644 --- a/text_input.go +++ b/text_input.go @@ -53,11 +53,11 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult if opts.Leading != nil { opts.Leading() } - in := textInputEditor(c, frame, value, opts.ReadOnly || opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). + in := textInputEditor(c, frame, value, opts.ReadOnly || look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). Placeholder(opts.Placeholder).Label(opts.Label) - r.changed = in.Changed() && !opts.ReadOnly && !opts.Disabled - r.submitted = in.Submitted() && !opts.Disabled - if opts.Clearable && *value != "" && !opts.ReadOnly && !opts.Disabled { + r.changed = in.Changed() && !opts.ReadOnly && !look.disabled + r.submitted = in.Submitted() && !look.disabled + if opts.Clearable && *value != "" && !opts.ReadOnly && !look.disabled { if textInputClear(c, in) { *value = "" r.changed = true @@ -75,7 +75,7 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult // textInputFade eases hover, focus and error colors. var textInputFade = ui.ElementTransition{Colors: true, Duration: theme.StateDuration} -// textInputLook is the state a field frame shows. +// textInputLook is the state a field frame shows; disabled gains any disabled ancestor. type textInputLook struct { disabled, readOnly, area bool err string @@ -86,6 +86,8 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele k := Tokens(c) d := Density(c) outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled) + // One disabled state, the field's own or an ancestor's, for build to obey. + look.disabled = outer.IsDisabled() outer.Children(func() { frame := ui.Row(c).Radius(theme.ControlRadius).Gap(d.Unit() * 2).Cursor(ui.CursorText) if look.area { diff --git a/toggle.go b/toggle.go index 7ad9b1d..29fa4f0 100644 --- a/toggle.go +++ b/toggle.go @@ -23,7 +23,10 @@ func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Eleme if label == "" && opts.Label == "" { panic("mujica: Toggle needs a label or Label") } - b := ui.ToggleBase(c, on).Disabled(opts.Disabled) + var b *ui.Element + ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { + b = ui.ToggleBase(c, choiceTarget(on, opts.Disabled)) + }) if b.Changed() { slog.Debug("mujica: toggle changed", "label", label+opts.Label, "on", *on) } diff --git a/toggle_group.go b/toggle_group.go index a1bb1ac..246344e 100644 --- a/toggle_group.go +++ b/toggle_group.go @@ -10,8 +10,7 @@ import ( "mujicaui/theme" ) -// ChoiceResult is the element of a choice control and whether its value -// changed in this frame. +// ChoiceResult is a choice control's element and whether its value changed this frame. type ChoiceResult struct { *ui.Element changed bool @@ -42,9 +41,7 @@ type ToggleGroupOptions struct { Label string } -// ToggleGroup is a joined row of toggles choosing *value: one item, or -// several with Multiple. Tab enters the group, the arrows move among the -// items, Space presses one. +// ToggleGroup is a joined row of toggles choosing one item of *value, or several with Multiple. func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ToggleGroupOptions) ChoiceResult { if !opts.Multiple && len(*value) > 1 { panic("mujica: single ToggleGroup holds more than one value") @@ -67,7 +64,8 @@ func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], on := slices.Contains(*value, it.Value) pressed := on b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled) - if b.Changed() && toggleGroupApply(value, it.Value, pressed, opts) { + // 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 } diff --git a/toggle_test.go b/toggle_test.go index 529a3b8..7186775 100644 --- a/toggle_test.go +++ b/toggle_test.go @@ -42,20 +42,6 @@ func TestToggleIconOnlyNamed(t *testing.T) { } } -func TestToggleDisabled(t *testing.T) { - on := false - tt := ui.NewTester(func(c *ui.Context) { - Use(c, Settings{}) - Toggle(c, &on, "Italic", ToggleOptions{Disabled: true}) - }, 300, 120) - _ = tt.Click("Italic") - tt.Key(0, ui.KeyTab) - tt.Key(0, ui.KeySpace) - if on { - t.Fatal("disabled toggle changed") - } -} - func TestToggleNeedsName(t *testing.T) { defer func() { if recover() == nil { diff --git a/toolbar.go b/toolbar.go index 2982c3f..fff99f8 100644 --- a/toolbar.go +++ b/toolbar.go @@ -13,8 +13,7 @@ type ToolbarOptions struct { Label string } -// Toolbar is a row of actions built by fn, one Tab stop with arrow keys inside. -// Actions that do not fit move, from the last, into an overflow menu. +// Toolbar is one Tab stop of actions; those that do not fit go to an overflow menu. func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element { k := Tokens(c) tb := ui.Toolbar(c, fn).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). diff --git a/tooltip.go b/tooltip.go index ee82404..95bbbd8 100644 --- a/tooltip.go +++ b/tooltip.go @@ -23,10 +23,7 @@ type tooltipState struct { const tooltipMaxWidth = 240 -// Tooltip shows text near anchor once the pointer rests on it or it has -// the keyboard focus. The tip holds no interactive content, passes the -// pointer through, stays in the window and hides on leave, blur or Escape. -// It also describes anchor to assistive technology. +// Tooltip shows a non-interactive tip by anchor after hover or keyboard focus rests on it for Delay. func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element { if anchor == nil { panic("mujica: Tooltip needs an anchor") @@ -58,7 +55,7 @@ func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions c.After(wait) return anchor } - if c.Shortcut(0, ui.KeyEscape) { + if overlayEscape(c, st) { slog.Debug("mujica: tooltip dismiss") st.dismissed = true return anchor @@ -71,7 +68,7 @@ func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions func tooltipShow(c *ui.Context, a ui.Rect, text string) { k := Tokens(c) u := Density(c).Unit() - size := theme.CaptionSize + 1 + size := FontSize(c, theme.CaptionSize+1) tw, th := c.MeasureText(tooltipMaxWidth, ui.Span{Text: text, Size: size, Font: c.Theme().Font}) padX, padY := u*2.5, u*1.5 w, h := tw+2*(padX+theme.BorderWidth)+1, th+2*(padY+theme.BorderWidth) diff --git a/tooltip_test.go b/tooltip_test.go index 190da23..8f68a89 100644 --- a/tooltip_test.go +++ b/tooltip_test.go @@ -7,7 +7,7 @@ import ( "github.com/egoist/mygo/ui" ) -const tooltipTestDelay = 20 * time.Millisecond +const tooltipTestDelay = 150 * time.Millisecond func tooltipTester(x, y float32) (*ui.Tester, *int) { clicks := 0 @@ -24,16 +24,17 @@ func tooltipTester(x, y float32) (*ui.Tester, *int) { return tt, &clicks } -func waitTooltip(tt *ui.Tester) { - time.Sleep(tooltipTestDelay + 10*time.Millisecond) - tt.Frame() +// waitTooltip waits until the tip shows, however slow the machine. +func waitTooltip(tt *ui.Tester) bool { + return overlayUntil(tt, func() bool { return tt.HasText("Share with the court") }) } func TestTooltipHoverDelay(t *testing.T) { tt, clicks := tooltipTester(40, 40) r, _ := tt.Find("Share") + start := time.Now() tt.Move(r.X+4, r.Y+4) - if tt.HasText("Share with the court") { + if time.Since(start) < tooltipTestDelay && tt.HasText("Share with the court") { t.Fatal("tip showed before the delay") } waitTooltip(tt) @@ -41,7 +42,6 @@ func TestTooltipHoverDelay(t *testing.T) { if !ok || tip.Y < r.Y+r.H { t.Fatalf("tip %+v ok %v below %+v", tip, ok, r) } - // The tip passes the pointer through: clicking it clicks what is below. tt.Move(400, 300) tt.Frame() if tt.HasText("Share with the court") { @@ -56,15 +56,15 @@ func TestTooltipHoverDelay(t *testing.T) { func TestTooltipFocusAndEscape(t *testing.T) { tt, _ := tooltipTester(40, 40) tt.Key(0, ui.KeyTab) - waitTooltip(tt) - if !tt.HasText("Share with the court") { + if !waitTooltip(tt) { t.Fatal("no tip on keyboard focus") } tt.Key(0, ui.KeyEscape) if tt.HasText("Share with the court") { t.Fatal("Escape did not hide the tip") } - waitTooltip(tt) + time.Sleep(tooltipTestDelay + 30*time.Millisecond) + tt.Frame() if tt.HasText("Share with the court") { t.Fatal("tip came back while still focused") } @@ -81,11 +81,33 @@ func TestTooltipEdgeFlips(t *testing.T) { } } +func TestTooltipScalesWithText(t *testing.T) { + widths := map[float32]float32{} + for _, scale := range []float32{1, 1.5} { + tt, _ := tooltipTester(20, 20) + tt.SetPreferences(ui.Preferences{TextScale: scale}) + tt.Key(0, ui.KeyTab) + if !overlayUntil(tt, func() bool { return tt.HasText("Share with the court") }) { + t.Fatalf("scale %v: no tip", scale) + } + r, _ := tt.Find("Share with the court") + widths[scale] = r.W + } + if widths[1.5] < widths[1]*1.3 { + t.Fatalf("tip text width %v at 1.5x, %v at 1x: not scaled", widths[1.5], widths[1]) + } +} + func TestTooltipSnapshot(t *testing.T) { - snapshot(t, "tooltip", 360, 140, func(c *ui.Context) { + overlaySnapshot(t, "tooltip", 360, 140, func(c *ui.Context) { ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { - Button(c, "Seal", ButtonOptions{Variant: Secondary}) - tooltipShow(c, ui.Rect{X: 24, Y: 24, W: 62, H: 36}, "Seal the letter with wax") + b := Button(c, "Seal", ButtonOptions{Variant: Secondary}) + Tooltip(c, b, "Seal the letter with wax", TooltipOptions{Delay: time.Millisecond}) }) + }, func(tt *ui.Tester) { + tt.Key(0, ui.KeyTab) + if !overlayUntil(tt, func() bool { return tt.HasText("Seal the letter with wax") }) { + t.Fatal("tip did not show") + } }) } diff --git a/transfer.go b/transfer.go index 3b300d9..64771b2 100644 --- a/transfer.go +++ b/transfer.go @@ -151,11 +151,11 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite } checked := transferCount(st, shown) ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() { - head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3). + head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*3-(theme.MinHitSize-16)/2). BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border) head.Children(func() { all := checked > 0 && checked == enabled - box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0) + box := ui.CheckboxBase(c, &all).Size(theme.MinHitSize, theme.MinHitSize).Justify(ui.Center).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0) transferMark(c, box, all, checked > 0 && !all) box.Description(transferPartial(c, checked, enabled)) if box.Changed() { @@ -184,7 +184,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite for _, it := range shown { on := st.checked[it.ID] ui.Box(c).Key(it.ID).Children(func() { - cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(d.ControlHeight()-d.Unit()).Padding(0, d.Unit()*2). + cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(max(d.ControlHeight()-d.Unit(), theme.MinHitSize)).Padding(0, d.Unit()*2). Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled) if cb.Changed() { st.checked[it.ID] = on diff --git a/transfer_test.go b/transfer_test.go index 427d4d2..13e1d69 100644 --- a/transfer_test.go +++ b/transfer_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) var transferCast = []TransferItem{ @@ -116,3 +118,14 @@ func TestTransferPartialDescription(t *testing.T) { t.Fatalf("descriptions %q", got) } } + +func TestTransferSelectAllHitAreaCompact(t *testing.T) { + target := []string{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + Transfer(c, &target, transferCast, TransferOptions{}) + }, 640, 420) + if r, ok := tt.Find("Select all in Available"); !ok || r.W < 28 || r.H < 28 { + t.Fatalf("select all %v", r) + } +} diff --git a/tree_select.go b/tree_select.go index 1bc09ef..6194f48 100644 --- a/tree_select.go +++ b/tree_select.go @@ -59,9 +59,7 @@ type treeSelectRow struct { parent string } -// TreeSelect chooses one node, or several with Multiple, from a tree in a -// popup with search. Up and Down move, Right expands, Left collapses or -// goes to the parent, Space or Enter chooses; Escape closes. +// TreeSelect chooses one node, or several with Multiple, from a searchable tree in a popup. func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts TreeSelectOptions) ChoiceResult { if !opts.Multiple && len(*selected) > 1 { panic("mujica: single TreeSelect holds more than one ID") @@ -73,6 +71,9 @@ func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts tr.Label(opts.Label) } st := *ui.Local(tr, treeSelectKey{}, func() *treeSelectState { return &treeSelectState{expanded: map[string]bool{}} }) + if opts.Disabled { + st.open = false + } if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) { st.open, st.query = !st.open, "" } @@ -104,10 +105,14 @@ func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts selectNote(c, msg(c, "tree_select.no_results")) return } + shown := make(map[string]bool, len(rows)) + for _, r := range rows { + shown[r.node.ID] = true + } ui.Scroll(c).MaxHeight(300).Padding(4).Children(func() { ui.Column(c).FocusGroup(ui.Vertical).Children(func() { for i, r := range rows { - treeSelectRowView(c, st, selected, r, i == 0, opts) + treeSelectRowView(c, st, selected, r, i == 0, shown, opts) } }) }) @@ -127,7 +132,7 @@ func treeSelectWalk(nodes []TreeSelectNode, fn func(n *TreeSelectNode)) { } } -// treeSelectVisible lists the rows to show: expanded ones, or while searching the matches and their ancestors. +// treeSelectVisible lists the rows to show: expanded ones, or matches and their ancestors. func treeSelectVisible(nodes []TreeSelectNode, st *treeSelectState) []treeSelectRow { q := strings.ToLower(strings.TrimSpace(st.query)) var rows []treeSelectRow @@ -155,9 +160,9 @@ func treeSelectVisible(nodes []TreeSelectNode, st *treeSelectState) []treeSelect return rows } -// treeSelectCheck returns a node's check state under the rule. +// treeSelectCheck returns a node's check state: its own in single mode, else by the rule. func treeSelectCheck(n *TreeSelectNode, selected []string, opts TreeSelectOptions) internal.CheckState { - if opts.Rule == TreeSelectIndependent || len(n.Children) == 0 { + if !opts.Multiple || opts.Rule == TreeSelectIndependent || len(n.Children) == 0 { if slices.Contains(selected, n.ID) { return internal.Checked } @@ -201,14 +206,38 @@ func treeSelectToggle(n *TreeSelectNode, selected []string, on bool, opts TreeSe return selected } -func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r treeSelectRow, first bool, opts TreeSelectOptions) { +// treeSelectFirstChild returns the first child of n that shows and is enabled, or "". +func treeSelectFirstChild(n *TreeSelectNode, shown map[string]bool) string { + for _, ch := range n.Children { + if shown[ch.ID] && !ch.Disabled { + return ch.ID + } + } + return "" +} + +// treeSelectDescribe says what MyGo cannot set on custom rows (ui/element.go:282). +func treeSelectDescribe(c *ui.Context, b *ui.Element, branch, open bool, state internal.CheckState) { + var parts []string + if branch { + parts = append(parts, msg(c, map[bool]string{true: "tree_select.expanded", false: "tree_select.collapsed"}[open])) + } + if state == internal.Mixed { + parts = append(parts, msg(c, "tree_select.mixed")) + } + if len(parts) > 0 { + b.Description(strings.Join(parts, ", ")) + } +} + +func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r treeSelectRow, first bool, shown map[string]bool, opts TreeSelectOptions) { k := Tokens(c) d := Density(c) n := r.node branch := len(n.Children) > 0 open := branch && (st.expanded[n.ID] || strings.TrimSpace(st.query) != "") ui.Row(c).Key(n.ID).AlignItems(ui.Center).Gap(2).Padding(0, 0, 0, float32(r.depth)*18).Children(func() { - arrow := ui.Box(c).Size(18, 18).Shrink(0).Radius(theme.ControlRadius) + arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Radius(theme.ControlRadius) if branch { arrow.Role(ui.RoleButton).Label(fmt.Sprintf(msg(c, map[bool]string{true: "tree_select.collapse", false: "tree_select.expand"}[open]), n.Label)) if arrow.Clicked() { @@ -218,6 +247,8 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r arrow.Background(k.SurfaceHover) } arrow.Draw(func(p *ui.Painter, rc ui.Rect) { + in := (rc.W - 16) / 2 + rc = ui.Rect{X: rc.X + in, Y: rc.Y + in, W: 16, H: 16} internal.Mark(p, rc, map[bool]string{true: "chevron-down", false: "chevron-right"}[open], k.TextMuted) }) } @@ -230,20 +261,28 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r *selected = treeSelectToggle(n, *selected, on, opts) } } else { - b = ui.ButtonBase(c).Disabled(n.Disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2). - Justify(ui.Start).Radius(theme.ControlRadius).Grow(1) + chosen := state == internal.Checked + ui.Row(c).Grow(1).Disabled(n.Disabled).Children(func() { + b = ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2). + Justify(ui.Start).Radius(theme.ControlRadius).Grow(1) + }) internal.Ring(b, k.Focus, theme.ControlRadius) if b.Clicked() { *selected, st.open = []string{n.ID}, false } + b.Role(ui.RoleTreeItem).Selected(chosen).Background(ui.Transparent).TextColor(k.Text) switch { - case state == internal.Checked: + case chosen: b.Background(k.Selection).TextColor(k.AccentText) case b.Hovered(): b.Background(k.SurfaceHover) } - b.Role(ui.RoleTreeItem).Children(func() { ui.Text(c, n.Label).SingleLine() }) + b.Children(func() { + ui.Text(c, n.Label).SingleLine().Grow(1) + selectCheck(c, chosen) + }) } + treeSelectDescribe(c, b, branch, open, state) if st.focus == n.ID || first && st.focus == "\x00" { b.Focus() st.focus = "" @@ -253,7 +292,7 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r case right && branch && !open: st.expanded[n.ID] = true case right && branch: - st.focus = n.Children[0].ID + st.focus = treeSelectFirstChild(n, shown) case left && open && st.expanded[n.ID]: st.expanded[n.ID] = false case left && r.parent != "":