From d67eb60bf9f243c192aa2d94524617c98a2f1eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 12:26:17 -0400 Subject: [PATCH] Choices: components 031-040 (wip) --- cascader.go | 234 +++++++++ cascader_test.go | 167 ++++++ combobox.go | 190 +++++++ combobox_test.go | 162 ++++++ docs/components/031-switch.md | 46 ++ docs/components/032-toggle.md | 43 ++ docs/components/033-toggle-group.md | 63 +++ docs/components/034-slider.md | 48 ++ docs/components/035-range-slider.md | 50 ++ docs/components/036-select.md | 55 ++ docs/components/037-multi-select.md | 54 ++ docs/components/038-combobox.md | 55 ++ docs/components/039-cascader.md | 60 +++ docs/components/040-tree-select.md | 59 +++ example_choice_test.go | 133 +++++ examples/gallery/pages_choice.go | 477 +++++++++++++++++- internal/choice.go | 60 +++ messages_choice.go | 28 + multi_select.go | 243 +++++++++ multi_select_test.go | 141 ++++++ range_slider.go | 130 +++++ range_slider_test.go | 102 ++++ select.go | 238 +++++++++ select_test.go | 148 ++++++ slider.go | 200 ++++++++ slider_test.go | 109 ++++ switch.go | 86 ++++ switch_test.go | 79 +++ testdata/snapshots/darwin/cascader-dark.png | Bin 0 -> 5436 bytes testdata/snapshots/darwin/cascader-light.png | Bin 0 -> 5218 bytes testdata/snapshots/darwin/combobox-dark.png | Bin 0 -> 3911 bytes testdata/snapshots/darwin/combobox-light.png | Bin 0 -> 3868 bytes .../snapshots/darwin/multi_select-dark.png | Bin 0 -> 4486 bytes .../snapshots/darwin/multi_select-light.png | Bin 0 -> 4417 bytes .../snapshots/darwin/range_slider-dark.png | Bin 0 -> 3282 bytes .../snapshots/darwin/range_slider-light.png | Bin 0 -> 3204 bytes testdata/snapshots/darwin/select-dark.png | Bin 0 -> 4994 bytes testdata/snapshots/darwin/select-light.png | Bin 0 -> 4841 bytes testdata/snapshots/darwin/slider-dark.png | Bin 0 -> 5286 bytes testdata/snapshots/darwin/slider-light.png | Bin 0 -> 5137 bytes testdata/snapshots/darwin/switch-dark.png | Bin 0 -> 7288 bytes testdata/snapshots/darwin/switch-light.png | Bin 0 -> 7120 bytes testdata/snapshots/darwin/toggle-dark.png | Bin 0 -> 4449 bytes testdata/snapshots/darwin/toggle-light.png | Bin 0 -> 4458 bytes .../snapshots/darwin/toggle_group-dark.png | Bin 0 -> 8761 bytes .../snapshots/darwin/toggle_group-light.png | Bin 0 -> 8407 bytes .../snapshots/darwin/tree_select-dark.png | Bin 0 -> 4979 bytes .../snapshots/darwin/tree_select-light.png | Bin 0 -> 4837 bytes toggle.go | 72 +++ toggle_group.go | 127 +++++ toggle_group_test.go | 110 ++++ toggle_test.go | 82 +++ tree_select.go | 261 ++++++++++ tree_select_test.go | 142 ++++++ 54 files changed, 4253 insertions(+), 1 deletion(-) create mode 100644 cascader.go create mode 100644 cascader_test.go create mode 100644 combobox.go create mode 100644 combobox_test.go create mode 100644 docs/components/031-switch.md create mode 100644 docs/components/032-toggle.md create mode 100644 docs/components/033-toggle-group.md create mode 100644 docs/components/034-slider.md create mode 100644 docs/components/035-range-slider.md create mode 100644 docs/components/036-select.md create mode 100644 docs/components/037-multi-select.md create mode 100644 docs/components/038-combobox.md create mode 100644 docs/components/039-cascader.md create mode 100644 docs/components/040-tree-select.md create mode 100644 example_choice_test.go create mode 100644 internal/choice.go create mode 100644 messages_choice.go create mode 100644 multi_select.go create mode 100644 multi_select_test.go create mode 100644 range_slider.go create mode 100644 range_slider_test.go create mode 100644 select.go create mode 100644 select_test.go create mode 100644 slider.go create mode 100644 slider_test.go create mode 100644 switch.go create mode 100644 switch_test.go create mode 100644 testdata/snapshots/darwin/cascader-dark.png create mode 100644 testdata/snapshots/darwin/cascader-light.png create mode 100644 testdata/snapshots/darwin/combobox-dark.png create mode 100644 testdata/snapshots/darwin/combobox-light.png create mode 100644 testdata/snapshots/darwin/multi_select-dark.png create mode 100644 testdata/snapshots/darwin/multi_select-light.png create mode 100644 testdata/snapshots/darwin/range_slider-dark.png create mode 100644 testdata/snapshots/darwin/range_slider-light.png create mode 100644 testdata/snapshots/darwin/select-dark.png create mode 100644 testdata/snapshots/darwin/select-light.png create mode 100644 testdata/snapshots/darwin/slider-dark.png create mode 100644 testdata/snapshots/darwin/slider-light.png create mode 100644 testdata/snapshots/darwin/switch-dark.png create mode 100644 testdata/snapshots/darwin/switch-light.png create mode 100644 testdata/snapshots/darwin/toggle-dark.png create mode 100644 testdata/snapshots/darwin/toggle-light.png create mode 100644 testdata/snapshots/darwin/toggle_group-dark.png create mode 100644 testdata/snapshots/darwin/toggle_group-light.png create mode 100644 testdata/snapshots/darwin/tree_select-dark.png create mode 100644 testdata/snapshots/darwin/tree_select-light.png create mode 100644 toggle.go create mode 100644 toggle_group.go create mode 100644 toggle_group_test.go create mode 100644 toggle_test.go create mode 100644 tree_select.go create mode 100644 tree_select_test.go diff --git a/cascader.go b/cascader.go new file mode 100644 index 0000000..74d4c34 --- /dev/null +++ b/cascader.go @@ -0,0 +1,234 @@ +package mujica + +import ( + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// CascaderNode is one choice at a level of a Cascader. +type CascaderNode struct { + Value, Label string + // Leaf marks a node without children. + Leaf bool + Disabled bool +} + +// CascaderStatus is how far the caller got loading a level. The zero value is loaded. +type CascaderStatus int + +const ( + CascaderLoaded CascaderStatus = iota + // CascaderIdle asks the Cascader to call Load for the level. + CascaderIdle + CascaderLoading + CascaderFailed +) + +// CascaderLevel is what the caller knows of the children under a path. +type CascaderLevel struct { + Nodes []CascaderNode + Status CascaderStatus + // Err explains a failed load; it shows beside the retry button. + Err error +} + +// CascaderOptions configure a Cascader. Level is required. +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 func(path []string) + // AnyLevel lets branches be chosen too; by default only leaves are. + AnyLevel bool + // Placeholder shows while nothing is chosen; empty uses "Select…". + Placeholder string + Disabled bool + // Label names the field for assistive technology. + Label string +} + +// cascaderState is the popup's open path and the row the keys move to. +type cascaderState struct { + open bool + active []string + requested map[string]bool + focus []string + focusSet bool +} + +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. +func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult { + if opts.Level == nil { + panic("mujica: Cascader needs Level") + } + before := slices.Clone(*path) + tr := ui.ButtonBase(c).Disabled(opts.Disabled).Role(ui.RolePopUpButton) + if opts.Label != "" { + tr.Label(opts.Label) + } + st := *ui.Local(tr, cascaderKey{}, func() *cascaderState { return &cascaderState{requested: map[string]bool{}} }) + if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) { + st.open = !st.open + if st.open { + st.active = slices.Clone(*path) + st.focus, st.focusSet = slices.Clone(*path), true + if len(*path) == 0 { + st.focus = []string{""} + } + } + } + selectField(c, tr, st.open, func() { + if len(*path) == 0 { + ui.Text(c, selectOr(opts.Placeholder, msg(c, "select.placeholder"))).SingleLine().TextColor(Tokens(c).TextMuted).Grow(1) + return + } + ui.Text(c, cascaderLabels(opts, *path)).SingleLine().Grow(1).Shrink(1) + }) + ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) { + selectPanel(c, panel) + panel.Padding(0) + ui.Row(c).AlignItems(ui.Stretch).Dividers(theme.BorderWidth, Tokens(c).Border).Children(func() { + for depth := 0; depth <= len(st.active); depth++ { + if !cascaderColumn(c, st, path, opts, depth) { + break + } + } + }) + }) + changed := !slices.Equal(before, *path) + if changed { + slog.Debug("mujica: cascader changed", "label", opts.Label, "path", *path) + } + return ChoiceResult{tr, changed} +} + +// cascaderLabels writes the full path of labels, as "Europe / France / Paris". +func cascaderLabels(opts CascaderOptions, path []string) string { + labels := make([]string, len(path)) + for i, v := range path { + labels[i] = v + for _, n := range opts.Level(path[:i]).Nodes { + if n.Value == v { + labels[i] = n.Label + } + } + } + return strings.Join(labels, " / ") +} + +// cascaderColumn builds the column at depth and reports whether a deeper one may follow. +func cascaderColumn(c *ui.Context, st *cascaderState, path *[]string, opts CascaderOptions, depth int) bool { + k := Tokens(c) + prefix := slices.Clone(st.active[:depth]) + lv := opts.Level(prefix) + id := strings.Join(prefix, "\x00") + if lv.Status == CascaderIdle && !st.requested[id] { + if opts.Load == nil { + panic("mujica: Cascader level is Idle but Load is nil") + } + st.requested[id] = true + slog.Debug("mujica: cascader load", "path", prefix) + opts.Load(prefix) + } + if lv.Status != CascaderIdle { + delete(st.requested, id) + } + deeper := false + col := ui.Column(c).Key(id).Width(180).FocusGroup(ui.Vertical) + col.Children(func() { + switch { + case lv.Status == CascaderIdle || lv.Status == CascaderLoading: + ui.Row(c).Gap(8).Padding(8).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() { + Spinner(c, SpinnerOptions{}) + ui.Text(c, msg(c, "cascader.loading")).TextColor(k.TextMuted) + }) + case lv.Status == CascaderFailed: + ui.Column(c).Gap(8).Padding(8).AlignItems(ui.Start).Children(func() { + text := msg(c, "cascader.failed") + if lv.Err != nil { + text += ": " + lv.Err.Error() + } + ui.Text(c, text).TextColor(k.Danger) + if Button(c, msg(c, "cascader.retry"), ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw")}).Clicked() { + slog.Debug("mujica: cascader retry", "path", prefix) + opts.Load(prefix) + } + }) + case len(lv.Nodes) == 0: + selectNote(c, msg(c, "cascader.empty")) + default: + ui.Scroll(c).MaxHeight(260).Padding(4).Children(func() { + for i, n := range lv.Nodes { + on := depth < len(st.active) && st.active[depth] == n.Value + deeper = deeper || on && !n.Leaf + cascaderRow(c, st, path, opts, prefix, n, on, i == 0) + } + }) + } + }) + return deeper +} + +func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts CascaderOptions, prefix []string, n CascaderNode, on, first bool) { + k := Tokens(c) + 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). + 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), ""))) { + b.Focus() + st.focusSet = false + } + open := func(focusChild bool) { + st.active = full + if focusChild { + st.focus, st.focusSet = append(slices.Clone(full), ""), true + } + if opts.AnyLevel { + *path = slices.Clone(full) + } + } + clicked := b.Clicked() + switch { + case clicked && n.Leaf: + st.active, *path, st.open = full, full, false + case clicked: + open(false) + case b.Shortcut(0, ui.KeyRight) && !n.Leaf: + open(true) + case b.Shortcut(0, ui.KeyLeft) && depth > 0: + st.focus, st.focusSet = slices.Clone(prefix), true + } + chosen := len(*path) > depth && slices.Equal((*path)[:depth+1], full) + switch { + case on: + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + if chosen { + b.TextColor(k.AccentText) + } + b.Children(func() { + 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) { + internal.Chevron(p, r, true, k.TextMuted) + }) + } + }) +} diff --git a/cascader_test.go b/cascader_test.go new file mode 100644 index 0000000..7d5dbe2 --- /dev/null +++ b/cascader_test.go @@ -0,0 +1,167 @@ +package mujica + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +// cascaderFake is a caller's store: roots are known, children load on request. +type cascaderFake struct { + levels map[string]CascaderLevel + loads []string +} + +func newCascaderFake() *cascaderFake { + return &cascaderFake{levels: map[string]CascaderLevel{ + "": {Nodes: []CascaderNode{{Value: "eu", Label: "Europe"}, {Value: "as", Label: "Asia"}, {Value: "aq", Label: "Antarctica", Disabled: true}}}, + }} +} + +func (f *cascaderFake) level(path []string) CascaderLevel { + if lv, ok := f.levels[strings.Join(path, "/")]; ok { + return lv + } + return CascaderLevel{Status: CascaderIdle} +} + +func (f *cascaderFake) load(path []string) { + key := strings.Join(path, "/") + f.loads = append(f.loads, key) + f.levels[key] = CascaderLevel{Status: CascaderLoading} +} + +func (f *cascaderFake) finish(key string, lv CascaderLevel) { f.levels[key] = lv } + +func cascaderTester(path *[]string, f *cascaderFake, anyLevel bool, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Children(func() { + if Cascader(c, path, CascaderOptions{Level: f.level, Load: f.load, AnyLevel: anyLevel, Label: "Place"}).Changed() { + *changes++ + } + }) + }, 700, 500) +} + +func TestCascaderLoadsAndChoosesLeaf(t *testing.T) { + var path []string + changes := 0 + f := newCascaderFake() + tt := cascaderTester(&path, f, false, &changes) + _ = tt.Click("Select…") + _ = tt.Click("Europe") + if !slices.Equal(f.loads, []string{"eu"}) || !tt.HasText("Loading…") { + t.Fatalf("loads %v, texts %v", f.loads, tt.Texts()) + } + if len(path) != 0 { + t.Fatalf("branch chosen without AnyLevel: %v", path) + } + f.finish("eu", CascaderLevel{Nodes: []CascaderNode{{Value: "fr", Label: "France"}}}) + tt.Frame() + _ = tt.Click("France") + f.finish("eu/fr", CascaderLevel{Nodes: []CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}}) + tt.Frame() + _ = tt.Click("Paris") + if !slices.Equal(path, []string{"eu", "fr", "paris"}) || changes != 1 { + t.Fatalf("path %v changes %d", path, changes) + } + if !tt.HasText("Europe / France / Paris") || tt.HasText("Asia") { + t.Fatalf("field shows %v", tt.Texts()) + } +} + +func TestCascaderRetryAfterFailure(t *testing.T) { + var path []string + changes := 0 + f := newCascaderFake() + tt := cascaderTester(&path, f, false, &changes) + _ = tt.Click("Select…") + _ = tt.Click("Asia") + f.finish("as", CascaderLevel{Status: CascaderFailed, Err: errors.New("timeout")}) + tt.Frame() + if !tt.HasText("Couldn't load: timeout") { + t.Fatalf("texts %v", tt.Texts()) + } + if err := tt.Click("Retry"); err != nil { + t.Fatal(err) + } + if !slices.Equal(f.loads, []string{"as", "as"}) || !tt.HasText("Loading…") { + t.Fatalf("loads %v", f.loads) + } +} + +func TestCascaderAnyLevelAndDisabled(t *testing.T) { + var path []string + changes := 0 + f := newCascaderFake() + tt := cascaderTester(&path, f, true, &changes) + _ = tt.Click("Select…") + _ = tt.Click("Antarctica") + if len(path) != 0 { + t.Fatalf("disabled node chosen: %v", path) + } + _ = tt.Click("Asia") + if !slices.Equal(path, []string{"as"}) { + t.Fatalf("AnyLevel path %v", path) + } +} + +func TestCascaderKeyboard(t *testing.T) { + var path []string + changes := 0 + f := newCascaderFake() + f.finish("eu", CascaderLevel{Nodes: []CascaderNode{{Value: "fr", Label: "France", Leaf: true}, {Value: "it", Label: "Italy", Leaf: true}}}) + tt := cascaderTester(&path, f, false, &changes) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if !tt.Focused("Europe") { + t.Fatal("first row not focused on open") + } + tt.Key(0, ui.KeyRight) + if !tt.Focused("France") { + t.Fatal("Right did not move into the children") + } + tt.Key(0, ui.KeyLeft) + if !tt.Focused("Europe") { + t.Fatal("Left did not go back") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if !slices.Equal(path, []string{"eu", "it"}) { + t.Fatalf("path %v", path) + } + if !tt.Focused("Place") { + t.Fatal("focus did not return to the field") + } +} + +func TestCascaderNeedsLevel(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + var path []string + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Cascader(c, &path, CascaderOptions{}) + }, 100, 100, 1) +} + +func TestCascaderSnapshot(t *testing.T) { + f := newCascaderFake() + f.finish("eu", CascaderLevel{Nodes: []CascaderNode{{Value: "fr", Label: "France"}}}) + f.finish("eu/fr", CascaderLevel{Nodes: []CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}}) + snapshot(t, "cascader", 460, 110, func(c *ui.Context) { + none, some := []string{}, []string{"eu", "fr", "paris"} + ui.Row(c).Padding(20).Gap(16).Children(func() { + Cascader(c, &none, CascaderOptions{Level: f.level, Load: f.load}) + Cascader(c, &some, CascaderOptions{Level: f.level, Load: f.load}) + }) + }) +} diff --git a/combobox.go b/combobox.go new file mode 100644 index 0000000..314aa26 --- /dev/null +++ b/combobox.go @@ -0,0 +1,190 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ComboboxOptions configure a Combobox. +type ComboboxOptions struct { + Placeholder string + // AllowCustom lets typed text that matches no option become the value. + AllowCustom bool + // ExternalFilter shows options as given: the caller already filtered them for Query. + ExternalFilter bool + // Loading shows that the caller is fetching options for Query. + Loading bool + Disabled bool + // Label names the field for assistive technology. + Label string +} + +// ComboboxResult is a Combobox's field, its value change and its query. +type ComboboxResult struct { + ChoiceResult + query string + queryChanged bool +} + +// Query returns the text in the field. +func (r ComboboxResult) Query() string { return r.query } + +// QueryChanged reports whether the user typed in this frame: fetch new options then. +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. +func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptions) ComboboxResult { + k := Tokens(c) + d := Density(c) + old := *value + f := ui.Row(c).MinHeight(d.ControlHeight()).MinWidth(200).AlignItems(ui.Center).Padding(0, d.Unit()*2, 0, 0). + 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 }) + var p *ui.ComboboxParts + f.Children(func() { + p = ui.ComboboxBase(c, query) + 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 != "" { + p.Input.Label(opts.Label) + } + if opts.Loading { + Spinner(c, SpinnerOptions{Size: 14}) + } + arrow := ui.Box(c).Size(16, 16).Shrink(0).Cursor(ui.CursorPointer) + if arrow.Clicked() { + p.SetOpen(!p.Open()) + p.Input.Focus() + } + open := p.Open() + arrow.Draw(func(pt *ui.Painter, r ui.Rect) { + if open { + r.Y, r.H = r.Y+r.H, -r.H + } + internal.Chevron(pt, r, false, k.TextMuted) + }) + }) + in := p.Input + if f.Clicked() { + in.Focus() + } + border := k.ControlBorder + switch { + case in.Focused(): + border = k.AccentText + case f.Hovered(): + border = k.Text + } + f.Border(theme.BorderWidth, border) + queryChanged := in.Changed() + shown := comboboxShown(options, *query, p.Filtering() && !opts.ExternalFilter) + custom := "" + if q := strings.TrimSpace(*query); opts.AllowCustom && p.Filtering() && q != "" && !comboboxHas(shown, q) { + custom = q + } + lastHi := ui.Local(f, comboboxKey{"shown"}, func() int { return -1 }) + if !p.Open() { + *lastHi = -1 + } + p.Popup(func(panel *ui.Element) { + selectPanel(c, panel) + ui.Scroll(c).MaxHeight(240).Children(func() { + if custom != "" { + it := p.Item(custom) + selectRow(c, it, it.Highlighted(), false, fmt.Sprintf(msg(c, "combobox.use"), custom)) + } + for i, o := range shown { + it := p.Item(o) + if it.Highlighted() && *lastHi != i { + *lastHi = i + it.ScrollIntoView() + } + selectRow(c, it, it.Highlighted(), o == *value, o) + } + }) + }) + if p.Open() && len(shown) == 0 && custom == "" { + comboboxStatus(c, f.Bounds(), opts.Loading) + } + focused := in.Focused() + if v, ok := p.Chosen(); ok { + *value, *query = v, v + } 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] + p.SetOpen(false) + } else if *wasFocused && !focused { + if opts.AllowCustom { + *value = strings.TrimSpace(*query) + } + *query = *value + } else if !focused && *query != *value { + // The caller set the value. + *query = *value + } + *wasFocused = focused + changed := *value != old + if changed { + slog.Debug("mujica: combobox changed", "label", opts.Label, "value", *value) + } + return ComboboxResult{ChoiceResult{f, changed}, *query, queryChanged} +} + +// comboboxShown returns the options containing query, those starting with it first. +func comboboxShown(options []string, query string, filter bool) []string { + q := strings.ToLower(strings.TrimSpace(query)) + if !filter || q == "" { + return options + } + var starts, contains []string + for _, o := range options { + switch l := strings.ToLower(o); { + case strings.HasPrefix(l, q): + starts = append(starts, o) + case strings.Contains(l, q): + contains = append(contains, o) + } + } + return append(starts, contains...) +} + +func comboboxHas(options []string, s string) bool { + for _, o := range options { + if strings.EqualFold(o, s) { + return true + } + } + return false +} + +// comboboxStatus shows loading or no matches under the field r; it takes no input. +func comboboxStatus(c *ui.Context, r ui.Rect, loading bool) { + ui.Overlay(c, func() { + panel := ui.Row(c).Absolute().Left(r.X).Top(r.Y + r.H).MinWidth(r.W).PassThrough().Role(ui.RoleStatus) + selectPanel(c, panel) + panel.Children(func() { + if loading { + ui.Row(c).Gap(8).Padding(8, 12).AlignItems(ui.Center).Children(func() { + Spinner(c, SpinnerOptions{}) + ui.Text(c, msg(c, "combobox.loading")).TextColor(Tokens(c).TextMuted) + }) + return + } + selectNote(c, msg(c, "combobox.no_results")) + }) + }) +} diff --git a/combobox_test.go b/combobox_test.go new file mode 100644 index 0000000..4a5072f --- /dev/null +++ b/combobox_test.go @@ -0,0 +1,162 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +var comboboxTestFonts = []string{"Garamond", "Georgia", "Baskerville", "Didot", "Bodoni"} + +type comboboxRun struct { + value, query string + changes int + queries []string +} + +func comboboxTester(r *comboboxRun, options func(query string) []string, opts ComboboxOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Gap(10).Children(func() { + res := Combobox(c, &r.value, options(r.query), opts) + if res.Changed() { + r.changes++ + } + if res.QueryChanged() { + r.queries = append(r.queries, res.Query()) + } + r.query = res.Query() + Button(c, "Other", ButtonOptions{}) + }) + }, 400, 400) +} + +func fixedFonts(string) []string { return comboboxTestFonts } + +func TestComboboxFiltersAndChooses(t *testing.T) { + r := &comboboxRun{} + tt := comboboxTester(r, fixedFonts, ComboboxOptions{Label: "Font"}) + tt.Key(0, ui.KeyTab) + tt.Type("bo") + if !tt.HasText("Bodoni") || tt.HasText("Garamond") { + t.Fatalf("shown %v", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if r.value != "Bodoni" || r.changes != 1 { + t.Fatalf("value %q changes %d", r.value, r.changes) + } +} + +func TestComboboxArrowsAndClick(t *testing.T) { + r := &comboboxRun{} + tt := comboboxTester(r, fixedFonts, ComboboxOptions{Label: "Font"}) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if r.value != "Georgia" { + t.Fatalf("arrows chose %q", r.value) + } + tt.Key(0, ui.KeyDown) + if err := tt.Click("Didot"); err != nil { + t.Fatal(err) + } + if r.value != "Didot" || r.query != "Didot" { + t.Fatalf("click chose %q, field %q", r.value, r.query) + } +} + +func TestComboboxRevertsUnknownText(t *testing.T) { + r := &comboboxRun{value: "Didot"} + tt := comboboxTester(r, fixedFonts, ComboboxOptions{}) + tt.Key(0, ui.KeyTab) + tt.Type("zzz") + if !tt.HasText("No matches") { + t.Fatal("no-match state missing") + } + tt.Key(0, ui.KeyTab) + if r.value != "Didot" || r.query != "Didot" || r.changes != 0 { + t.Fatalf("value %q field %q changes %d", r.value, r.query, r.changes) + } +} + +func TestComboboxCustomValue(t *testing.T) { + r := &comboboxRun{} + tt := comboboxTester(r, fixedFonts, ComboboxOptions{AllowCustom: true}) + tt.Key(0, ui.KeyTab) + tt.Type("Caslon") + if !tt.HasText("Use “Caslon”") { + t.Fatalf("custom option missing: %v", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if r.value != "Caslon" { + t.Fatalf("value %q", r.value) + } + tt.Type(" Pro") + tt.Key(0, ui.KeyTab) + if r.value != "Caslon Pro" { + t.Fatalf("blur kept %q", r.value) + } +} + +func TestComboboxAsyncResults(t *testing.T) { + r := &comboboxRun{} + loading := true + remote := func(q string) []string { + if loading || q == "" { + return nil + } + var out []string + for _, f := range comboboxTestFonts { + if strings.Contains(strings.ToLower(f), strings.ToLower(q)) { + out = append(out, f+" (remote)") + } + } + return out + } + opts := ComboboxOptions{ExternalFilter: true} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + opts.Loading = loading + res := Combobox(c, &r.value, remote(r.query), opts) + if res.QueryChanged() { + r.queries = append(r.queries, res.Query()) + } + r.query = res.Query() + }, 400, 400) + tt.Key(0, ui.KeyTab) + tt.Type("di") + if len(r.queries) != 1 || r.queries[0] != "di" { + t.Fatalf("queries %v", r.queries) + } + if !tt.HasText("Loading…") { + t.Fatal("loading state missing") + } + loading = false + tt.Frame() + tt.Key(0, ui.KeyEnter) + if r.value != "Didot (remote)" { + t.Fatalf("value %q", r.value) + } +} + +func TestComboboxDisabled(t *testing.T) { + r := &comboboxRun{value: "Didot"} + tt := comboboxTester(r, fixedFonts, ComboboxOptions{Disabled: true}) + _ = tt.Click("Didot") + tt.Type("x") + if r.value != "Didot" || r.query != "Didot" || tt.HasText("Bodoni") { + t.Fatalf("disabled edited: %q %q", r.value, r.query) + } +} + +func TestComboboxSnapshot(t *testing.T) { + snapshot(t, "combobox", 460, 110, func(c *ui.Context) { + a, b := "", "Didot" + ui.Row(c).Padding(20).Gap(16).Children(func() { + Combobox(c, &a, comboboxTestFonts, ComboboxOptions{Placeholder: "Typeface"}) + Combobox(c, &b, comboboxTestFonts, ComboboxOptions{Loading: true}) + }) + }) +} diff --git a/docs/components/031-switch.md b/docs/components/031-switch.md new file mode 100644 index 0000000..5b5b718 --- /dev/null +++ b/docs/components/031-switch.md @@ -0,0 +1,46 @@ +# 031 Switch · 开关 + +## 用途 + +表示立即生效的开/关设置,如“自动备份”。组件只翻转值并回传结果,不调用任何业务接口;调用方在 `Changed()` 时执行操作,可用 `Loading` 表示“正在应用”。 + +## 最小示例 + +```go +wifi := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Switch(c, &wifi, "Wi-Fi", mujica.SwitchOptions{}).Changed() { + log.Println("Wi-Fi:", wifi) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element`。`label` 为空时请在返回值上调用 `Label` 命名。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Disabled` | `bool` | false | 禁用:不响应点击与键盘,退出 Tab 序列,由 MyGo 置灰。 | +| `Loading` | `bool` | false | 调用方正在应用结果:滑块内显示旋转标记,保留尺寸与标签,暂不接受输入。 | +| `Description` | `string` | "" | 辅助说明,屏幕阅读器在标签后读出。 | + +## 状态 + +关(象牙底、褐色方块滑块)、开(酒红轨道、象牙滑块,并在左侧显示竖线标记,不只依赖颜色)、悬停(边框与底色加深)、聚焦(轨道外 2 DIP 焦点环)、禁用、加载。切换动画 120 ms,减少动画模式下直接切换。 + +## 事件 + +`Changed()` 在用户切换的那一帧返回 true,`*on` 已是新值。若业务失败,调用方自行把值改回并提示错误。 + +## 键盘操作 + +- Tab / Shift+Tab:聚焦或离开。 +- Space:切换。 + +## 限制 + +- 基于 `ui.SwitchBase`,无障碍角色为 Switch,开关状态由 MyGo 报告。 +- 组件不保存“请求中”状态;`Loading` 由调用方根据自己的异步任务设置。 diff --git a/docs/components/032-toggle.md b/docs/components/032-toggle.md new file mode 100644 index 0000000..db685a9 --- /dev/null +++ b/docs/components/032-toggle.md @@ -0,0 +1,43 @@ +# 032 Toggle · 切换按钮 + +## 用途 + +保持按下状态的按钮,例如编辑器工具栏中的“加粗”。可显示文字、图标或二者兼有。 + +## 最小示例 + +```go +bold := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Toggle(c, &bold, "", mujica.ToggleOptions{Icon: icons.Must("bold"), Label: "Bold"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Element`。`label` 与 `opts.Label` 至少提供一个,否则 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Icon` | `*ui.SVG` | nil | 显示在文字前的图标。 | +| `Disabled` | `bool` | false | 禁用:不响应输入,退出 Tab 序列。 | +| `Label` | `string` | "" | 无文字按钮的无障碍名称。 | + +## 状态 + +未按下(象牙底、描边)、按下(玫瑰底、酒红边框与文字,底部 2 DIP 横条标记——选中不只依赖颜色)、悬停、按住、聚焦(2 DIP 焦点环)、禁用。 + +## 事件 + +`Changed()` 在用户切换的那一帧返回 true。 + +## 键盘操作 + +- Tab:聚焦。 +- Space:按下或松开。 + +## 限制 + +- 基于 `ui.ToggleBase`,无障碍角色为 ToggleButton,按下状态由 MyGo 报告。 diff --git a/docs/components/033-toggle-group.md b/docs/components/033-toggle-group.md new file mode 100644 index 0000000..f4acbce --- /dev/null +++ b/docs/components/033-toggle-group.md @@ -0,0 +1,63 @@ +# 033 ToggleGroup · 切换按钮组 + +## 用途 + +一排相连的切换按钮,单选(如对齐方式)或多选(如粗体、斜体、下划线)。可设置是否允许全部松开。 + +## 最小示例 + +```go +align := []string{"left"} +items := []mujica.ToggleItem[string]{ + {Value: "left", Label: "Left"}, + {Value: "center", Label: "Center"}, + {Value: "right", Label: "Right"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToggleGroup(c, &align, items, mujica.ToggleGroupOptions{Label: "Align"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ToggleGroupOptions) ChoiceResult`。单选模式下 `*value` 最多一个元素,否则 panic。 + +`ToggleItem[T]`: + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Value` | `T` | — | 稳定的选项值。 | +| `Label` | `string` | "" | 文字。 | +| `Icon` | `*ui.SVG` | nil | 图标。 | +| `Name` | `string` | "" | 仅图标时的无障碍名称。 | +| `Disabled` | `bool` | false | 单项禁用,键盘导航跳过它。 | + +`ToggleGroupOptions`: + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Multiple` | `bool` | false | 允许同时按下多项。 | +| `AllowEmpty` | `bool` | false | 允许松开最后一个已按下项。 | +| `Vertical` | `bool` | false | 纵向排列,↑/↓ 导航。 | +| `Disabled` | `bool` | false | 禁用整组。 | +| `Label` | `string` | "" | 组的无障碍名称。 | + +## 状态 + +未按下、按下(玫瑰底 + 酒红文字 + 底部横条)、悬停、按住、聚焦、单项禁用、整组禁用。 + +## 事件 + +返回的 `ChoiceResult.Changed()` 在值变化的那一帧返回 true。不允许空选时点击唯一按下项不产生事件。 + +## 键盘操作 + +- Tab:进入组,落在按下项(或上次聚焦项);再按 Tab 离开。 +- ←/→(纵向为 ↑/↓)、Home、End:在项之间移动焦点,跳过禁用项,首尾循环。 +- Space:按下或松开当前项。 + +## 限制 + +- 每项基于 `ui.ToggleBase`,组用 MyGo `FocusGroup` 实现方向键导航;MyGo 的 `Key` 不能用于自带输入处理的控件(ui/element.go:474),所以项以位置区分,`items` 在帧之间增删时焦点可能落到相邻项。 diff --git a/docs/components/034-slider.md b/docs/components/034-slider.md new file mode 100644 index 0000000..f505676 --- /dev/null +++ b/docs/components/034-slider.md @@ -0,0 +1,48 @@ +# 034 Slider · 滑块 + +## 用途 + +在连续或分步的数值范围内取值,如音量。滑块为菱形,命中区域是整个矩形控件。 + +## 最小示例 + +```go +volume := 40.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Slider(c, &volume, mujica.SliderOptions{Step: 5, ShowValue: true, Label: "Volume"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Slider(c *ui.Context, value *float64, opts SliderOptions) ChoiceResult`。范围外的值在构建时被夹到范围内并对齐步长(不算一次用户变更)。`Min >= Max` 或 `Step < 0` 时 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Min`, `Max` | `float64` | 0, 0 → 0–100 | 范围;二者都为 0 时取 0–100。 | +| `Step` | `float64` | 0 | 步长;值为 `Min + n·Step`。0 为连续。 | +| `Marks` | `[]SliderMark` | nil | 刻度:`Value` 处画刻线,`Label` 非空时在下方显示文字。 | +| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时在滑块上方显示数值提示(预留高度,不改变布局)。 | +| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "" | 无障碍名称。 | + +## 状态 + +默认、悬停(菱形放大 2 DIP)、拖动、聚焦(菱形外菱形焦点环)、禁用。已选段用 `AccentText`,深色主题对比度满足 3:1。 + +## 事件 + +`Changed()` 在值变化的那一帧返回 true。 + +## 键盘操作 + +- ←/↓、→/↑:减少或增加一个步长(连续模式为范围的 1%)。 +- Home / End:最小值 / 最大值(对齐到步长)。 + +## 限制 + +- 基于 `ui.SliderBase`。MyGo 的方向键步长固定为范围的 1%(ui/base.go:116),本组件把每次按键结果换算为一个 `Step`;当 `Step` 小于范围的 1% 且滑块距端点不足 1% 时,方向键会直接到端点。 +- `SliderBase` 在构建时就处理指针(ui/base.go:111),禁用时组件改为让它操作副本,保证值不变。 diff --git a/docs/components/035-range-slider.md b/docs/components/035-range-slider.md new file mode 100644 index 0000000..9d0184f --- /dev/null +++ b/docs/components/035-range-slider.md @@ -0,0 +1,50 @@ +# 035 RangeSlider · 范围滑块 + +## 用途 + +用两个菱形滑块选择一个区间,如价格范围。两端不会交叉(允许相等)。 + +## 最小示例 + +```go +low, high := 20.0, 80.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RangeSlider(c, &low, &high, mujica.RangeSliderOptions{Step: 10, Label: "Price"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) ChoiceResult`。`*low > *high` 时两者交换;超出范围的值被夹回并对齐步长。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Min`, `Max` | `float64` | 0, 0 → 0–100 | 范围。 | +| `Step` | `float64` | 0 | 步长,0 为连续。 | +| `Marks` | `[]SliderMark` | nil | 刻度与文字。 | +| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时显示两端数值;低端提示向左、高端向右,互不遮挡。 | +| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "" | 区间名称;两个滑块分别读作“`Label` 最小值, 20”“`Label` 最大值, 80”。 | + +## 状态 + +默认、悬停、拖动、聚焦(各滑块独立焦点环)、禁用。 + +## 事件 + +`Changed()` 在任一端变化的那一帧返回 true。 + +## 键盘操作 + +- Tab:依次聚焦低端、高端滑块。 +- ←/↓、→/↑:一个步长;不能越过另一端。 +- Home / End:到范围端点或另一端所在位置。 +- 指针按下轨道任意处:移动较近的一端,并把焦点交给它。 + +## 限制 + +- MyGo 自带的 `ui.RangeSlider` 使用玻璃质感滑块(ui/widgets.go:419),不符合本库风格;MyGo 也没有双滑块 Base,故本组件用公开的 `Dragged`、`PointerPosition`、`Shortcut` 组合实现。 +- 滑块的无障碍角色为 Slider,但数值范围属性只能由 `SliderBase` 设置(ui/base.go:128),因此当前值写在名称里(如“Price 最小值, 20”),屏幕阅读器读名称而非数值属性。 diff --git a/docs/components/036-select.md b/docs/components/036-select.md new file mode 100644 index 0000000..0d16a96 --- /dev/null +++ b/docs/components/036-select.md @@ -0,0 +1,55 @@ +# 036 Select · 选择器 + +## 用途 + +从弹出列表中选择一个值。支持占位、清空、禁用项、空状态与输入首字母定位。 + +## 最小示例 + +```go +plan := "" +options := []mujica.SelectOption[string]{ + {Value: "free", Label: "Free"}, + {Value: "pro", Label: "Pro"}, + {Value: "team", Label: "Team", Disabled: true}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Select(c, &plan, options, mujica.SelectOptions{Clearable: true, Label: "Plan"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], opts SelectOptions) ChoiceResult`。`*value` 不等于任何选项时显示占位;清空把值设为 `T` 的零值。 + +`SelectOption[T]`:`Value T`(稳定值)、`Label string`、`Disabled bool`(显示但不可选,方向键跳过)。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Placeholder` | `string` | 内置“请选择…” | 无值时的提示。 | +| `Clearable` | `bool` | false | 有值时显示清空按钮,并允许 Backspace/Delete 清空。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Empty` | `string` | 内置“暂无选项” | `options` 为空时弹层中的说明。 | +| `Label` | `string` | "" | 无障碍名称。 | + +## 状态 + +默认、悬停(边框加深)、展开(酒红边框、箭头朝上)、聚焦、禁用;弹层中高亮项(浅底)、已选项(勾号 + 酒红文字)、禁用项、空状态。 + +## 事件 + +`Changed()` 在值变化(选择或清空)的那一帧返回 true。 + +## 键盘操作 + +- Enter / Space / ↑ / ↓:打开弹层。 +- 弹层中 ↑/↓、Home、End 移动高亮,Enter 或 Space 选择,Escape 关闭;关闭后焦点回到选择器。 +- 弹层关闭时输入字母:选中以该前缀开头的选项(1 秒内连续输入组成前缀,重复同一字母则循环);支持输入法提交的中文。 +- Backspace / Delete:`Clearable` 时清空。 + +## 限制 + +- 基于 `ui.SelectBase`。其高亮索引没有公开的设置接口(ui/base.go:260),所以弹层打开时输入字母不移动高亮,只在弹层关闭时定位。 +- 清空按钮不占 Tab 位(与 MyGo 搜索框一致),键盘用 Backspace/Delete 清空。 diff --git a/docs/components/037-multi-select.md b/docs/components/037-multi-select.md new file mode 100644 index 0000000..c02624f --- /dev/null +++ b/docs/components/037-multi-select.md @@ -0,0 +1,54 @@ +# 037 MultiSelect · 多选选择器 + +## 用途 + +从列表中选择多个值,弹层内可搜索、全选当前匹配项,可限制数量;已选值以标签显示,超出部分折叠为“+N”。 + +## 最小示例 + +```go +tags := []int{1} +options := []mujica.SelectOption[int]{ + {Value: 1, Label: "Rose"}, + {Value: 2, Label: "Lily"}, + {Value: 3, Label: "Iris"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiSelect(c, &tags, options, mujica.MultiSelectOptions{Max: 2, Label: "Flowers"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) ChoiceResult`。`*values` 按选择顺序保存;组件每次修改都会生成新切片。`Max` 或 `MaxTags` 为负数时 panic。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Placeholder` | `string` | 内置“请选择…” | 无选择时的提示。 | +| `Max` | `int` | 0 | 最多选择数,0 不限;达到上限后其余项禁用,并显示“已选 n / Max”。 | +| `MaxTags` | `int` | 0 → 3 | 字段中显示的标签数,其余折叠为“+N”。 | +| `Disabled` | `bool` | false | 禁用(标签不可移除)。 | +| `Label` | `string` | "" | 无障碍名称。 | + +## 状态 + +字段:占位、标签、折叠、展开、悬停、聚焦、禁用。弹层:搜索框、全选(全选/半选/未选)、选中项(酒红方框 + 白勾)、禁用项、达到上限、无结果。 + +## 事件 + +`Changed()` 在勾选、取消、全选或移除标签的那一帧返回 true。 + +## 键盘操作 + +- Enter / Space / ↓:打开弹层,焦点在搜索框。 +- 搜索框中 ↓:进入列表(“全选”);列表内 ↑/↓、Home、End 移动;Space 勾选。 +- Escape:关闭,焦点回到字段。 +- 字段上 Backspace:移除最后一个值。 + +## 限制 + +- 选项行基于 `ui.CheckboxBase` 自绘方框;全选的“半选”只在画面上表示,MyGo 没有为自定义元素设置 mixed 状态的公开接口(ui/element.go:282 的 checked 字段仅由内部设置),屏幕阅读器读作未选中。 +- 标签上的移除按钮不占 Tab 位,键盘用 Backspace 移除。 +- 列表未虚拟化,适合数百项以内。 diff --git a/docs/components/038-combobox.md b/docs/components/038-combobox.md new file mode 100644 index 0000000..50ffaea --- /dev/null +++ b/docs/components/038-combobox.md @@ -0,0 +1,55 @@ +# 038 Combobox · 组合框 + +## 用途 + +输入文字筛选选项并选择,覆盖 MyGo 的 Autocomplete(自由输入)与 Combobox(选项选择)。可允许自定义值;选项可由调用方异步提供。 + +## 最小示例 + +```go +font := "" +fonts := []string{"Garamond", "Georgia", "Didot"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Combobox(c, &font, fonts, mujica.ComboboxOptions{AllowCustom: true, Label: "Font"}) + if r.QueryChanged() { + log.Println("search:", r.Query()) + } +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptions) ComboboxResult`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Placeholder` | `string` | "" | 空输入时的提示。 | +| `AllowCustom` | `bool` | false | 允许不在选项中的文字成为值:列表首项为“使用“xxx””,Enter 或离开字段时保留输入。 | +| `ExternalFilter` | `bool` | false | 选项已由调用方按 `Query` 过滤,组件不再筛选。 | +| `Loading` | `bool` | false | 调用方正在获取选项:字段内显示旋转标记,无选项时弹层显示“加载中…”。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "" | 无障碍名称。 | + +`ComboboxResult` 嵌入 `ChoiceResult`,另有 `Query() string`(当前输入)与 `QueryChanged() bool`(用户本帧输入了文字,此时发起请求)。 + +## 状态 + +默认、悬停、聚焦(酒红边框 + 光标)、展开、加载、无匹配(“无匹配项”)、禁用;列表高亮项、当前值(勾号)。 + +## 事件 + +- `Changed()`:值变化(选择、自定义提交)。 +- `QueryChanged()`:输入变化。异步流程:在此发起请求,结果到达后(经 `Window.Update`)通过 `options` 传入并清除 `Loading`。 + +## 键盘操作 + +- 输入:筛选(前缀匹配优先)并打开列表,高亮第一项。 +- ↓ / ↑:打开或移动高亮;Enter 选择;Escape 关闭。 +- Tab 离开:不允许自定义时输入恢复为当前值;允许时提交输入。 + +## 限制 + +- 基于 `ui.ComboboxBase`。它在没有选项时隐藏弹层(ui/combobox.go:191),所以“加载中”“无匹配”用一个不接收输入的独立浮层显示。 +- 异步结果在打字后才到达时 MyGo 不会自动高亮首项,组件让 Enter 选择首个结果。 diff --git a/docs/components/039-cascader.md b/docs/components/039-cascader.md new file mode 100644 index 0000000..82499ad --- /dev/null +++ b/docs/components/039-cascader.md @@ -0,0 +1,60 @@ +# 039 Cascader · 级联选择 + +## 用途 + +逐级选择一条路径,如“欧洲 / 法国 / 巴黎”。字段显示完整路径;子级可由调用方按需加载,并有加载中、失败与重试状态。 + +## 最小示例 + +```go +var path []string +level := func(p []string) mujica.CascaderLevel { + if len(p) == 0 { + return mujica.CascaderLevel{Nodes: []mujica.CascaderNode{{Value: "fr", Label: "France"}}} + } + return mujica.CascaderLevel{Nodes: []mujica.CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}} +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Cascader(c, &path, mujica.CascaderOptions{Level: level, Label: "City"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult`。`*path` 是各级 `Value` 组成的路径。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Level` | `func(path []string) CascaderLevel` | 必填 | 返回某路径下的子级(空路径为根)。为 nil 时 panic。 | +| `Load` | `func(path []string)` | nil | 子级状态为 `CascaderIdle` 时调用一次,点击重试时再次调用。应把该级标为 `CascaderLoading`,结果经 `Window.Update` 写回。有 Idle 级别但 `Load` 为 nil 时 panic。 | +| `AnyLevel` | `bool` | false | 允许选择非叶节点;默认只有叶节点(`Leaf: true`)可成为值。 | +| `Placeholder` | `string` | 内置“请选择…” | 无值时提示。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "" | 无障碍名称。 | + +`CascaderLevel`:`Nodes []CascaderNode`、`Status CascaderStatus`(零值 `CascaderLoaded`;另有 `CascaderIdle`、`CascaderLoading`、`CascaderFailed`)、`Err error`(失败原因,显示在重试按钮旁)。 + +`CascaderNode`:`Value`、`Label`、`Leaf`(无子级)、`Disabled`。 + +## 状态 + +字段:占位、完整路径、展开、聚焦、禁用。弹层每列:加载中(旋转标记)、失败(错误文字 + 重试按钮)、空(“暂无数据”)、节点列表;展开路径上的节点用玫瑰底,已选路径用酒红文字,分支节点带右箭头。 + +## 事件 + +`Changed()` 在路径变化的那一帧返回 true。叶节点选择后弹层关闭。 + +## 键盘操作 + +- Enter / Space / ↓:打开弹层,焦点在当前路径或第一项。 +- 列内 ↑/↓、Home、End:移动。 +- →:展开分支并进入下一列;←:回到上一列的父节点。 +- Enter / Space:叶节点选择并关闭;分支展开(`AnyLevel` 时同时选择)。 +- Escape:关闭,焦点回到字段。 + +## 限制 + +- `Load` 在视图构建中同步调用,不得阻塞;异步任务完成后请用 `Window.Update` 回到界面线程更新数据。 +- 列表未虚拟化,每列适合数百项以内。 diff --git a/docs/components/040-tree-select.md b/docs/components/040-tree-select.md new file mode 100644 index 0000000..89611cb --- /dev/null +++ b/docs/components/040-tree-select.md @@ -0,0 +1,59 @@ +# 040 TreeSelect · 树选择 + +## 用途 + +在树形数据中选择一个或多个节点,支持搜索、展开折叠、半选显示,父子联动规则可配置。 + +## 最小示例 + +```go +var roles []string +nodes := []mujica.TreeSelectNode{ + {ID: "court", Label: "Court", Children: []mujica.TreeSelectNode{ + {ID: "king", Label: "King"}, + {ID: "queen", Label: "Queen"}, + }}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TreeSelect(c, &roles, nodes, mujica.TreeSelectOptions{Multiple: true, Label: "Roles"}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts TreeSelectOptions) ChoiceResult`。单选模式下 `*selected` 最多一个 ID,否则 panic。节点 ID 在整棵树中必须唯一。 + +`TreeSelectNode`:`ID`、`Label`、`Children []TreeSelectNode`、`Disabled`。 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Multiple` | `bool` | false | 多选(复选框);单选时点击节点即选择并关闭。 | +| `Rule` | `TreeSelectRule` | `TreeSelectCascade` | 多选父子规则:`TreeSelectCascade` 勾选父节点即勾选其全部可用叶节点,值只含叶节点 ID,父节点按子节点显示全选/半选;`TreeSelectIndependent` 每个节点独立勾选,值可含任意 ID。 | +| `Placeholder` | `string` | 内置“请选择…” | 无值时提示。 | +| `MaxTags` | `int` | 0 → 3 | 多选时字段中显示的标签数,其余折叠为“+N”。 | +| `Disabled` | `bool` | false | 禁用。 | +| `Label` | `string` | "" | 无障碍名称。 | + +## 状态 + +字段:占位、单值文字、多值标签与折叠、展开、聚焦、禁用。弹层:搜索框、展开/折叠箭头、未选/选中/半选方框(单选为玫瑰底高亮)、禁用节点(联动时被跳过)、无结果。搜索时显示匹配节点及其祖先并自动展开。 + +## 事件 + +`Changed()` 在选择变化的那一帧返回 true。 + +## 键盘操作 + +- Enter / Space / ↓:打开弹层,焦点在搜索框;搜索框中 ↓ 进入树。 +- ↑/↓、Home、End:在可见节点间移动。 +- →:展开;已展开时进入第一个子节点。←:折叠;已折叠或叶节点时回到父节点。 +- Space:勾选(多选);Enter / Space:选择(单选)。 +- Escape:关闭,焦点回到字段。 + +## 限制 + +- 树行基于 `ui.CheckboxBase` / `ui.ButtonBase` 自绘。MyGo 的 `TreeItem` 只能显示文字标签,无法放入复选框(ui/tree.go:44),故未复用。 +- 展开状态与半选状态只在画面上表示:MyGo 不对自定义元素公开 expanded 与 mixed 的设置(ui/base.go:297、ui/element.go:282 仅内部设置),屏幕阅读器读不到这两个状态;展开箭头带“展开/收起 + 节点名”的名称。 +- 列表未虚拟化,适合数千节点以内。 diff --git a/example_choice_test.go b/example_choice_test.go new file mode 100644 index 0000000..af5eae2 --- /dev/null +++ b/example_choice_test.go @@ -0,0 +1,133 @@ +package mujica_test + +import ( + "log" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +func ExampleSwitch() { + wifi := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Switch(c, &wifi, "Wi-Fi", mujica.SwitchOptions{}).Changed() { + log.Println("Wi-Fi:", wifi) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleToggle() { + bold := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Toggle(c, &bold, "", mujica.ToggleOptions{Icon: icons.Must("bold"), Label: "Bold"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleToggleGroup() { + align := []string{"left"} + items := []mujica.ToggleItem[string]{ + {Value: "left", Label: "Left"}, + {Value: "center", Label: "Center"}, + {Value: "right", Label: "Right"}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToggleGroup(c, &align, items, mujica.ToggleGroupOptions{Label: "Align"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSlider() { + volume := 40.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Slider(c, &volume, mujica.SliderOptions{Step: 5, ShowValue: true, Label: "Volume"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRangeSlider() { + low, high := 20.0, 80.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RangeSlider(c, &low, &high, mujica.RangeSliderOptions{Step: 10, Label: "Price"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSelect() { + plan := "" + options := []mujica.SelectOption[string]{ + {Value: "free", Label: "Free"}, + {Value: "pro", Label: "Pro"}, + {Value: "team", Label: "Team", Disabled: true}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Select(c, &plan, options, mujica.SelectOptions{Clearable: true, Label: "Plan"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleMultiSelect() { + tags := []int{1} + options := []mujica.SelectOption[int]{ + {Value: 1, Label: "Rose"}, + {Value: 2, Label: "Lily"}, + {Value: 3, Label: "Iris"}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiSelect(c, &tags, options, mujica.MultiSelectOptions{Max: 2, Label: "Flowers"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCombobox() { + font := "" + fonts := []string{"Garamond", "Georgia", "Didot"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Combobox(c, &font, fonts, mujica.ComboboxOptions{AllowCustom: true, Label: "Font"}) + if r.QueryChanged() { + log.Println("search:", r.Query()) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCascader() { + var path []string + level := func(p []string) mujica.CascaderLevel { + if len(p) == 0 { + return mujica.CascaderLevel{Nodes: []mujica.CascaderNode{{Value: "fr", Label: "France"}}} + } + return mujica.CascaderLevel{Nodes: []mujica.CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}} + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Cascader(c, &path, mujica.CascaderOptions{Level: level, Label: "City"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTreeSelect() { + var roles []string + nodes := []mujica.TreeSelectNode{ + {ID: "court", Label: "Court", Children: []mujica.TreeSelectNode{ + {ID: "king", Label: "King"}, + {ID: "queen", Label: "Queen"}, + }}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TreeSelect(c, &roles, nodes, mujica.TreeSelectOptions{Multiple: true, Label: "Roles"}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_choice.go b/examples/gallery/pages_choice.go index f5bfe59..fc64345 100644 --- a/examples/gallery/pages_choice.go +++ b/examples/gallery/pages_choice.go @@ -1,3 +1,478 @@ package main -var choicePages = []page{} +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" + "mujicaui/theme" +) + +const choiceCategory = "Choices" + +var choicePages = []page{ + {ID: "031", Name: "Switch", ZhName: "开关", Category: choiceCategory, Demo: switchDemo, Code: `wifi := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Switch(c, &wifi, "Wi-Fi", mujica.SwitchOptions{}).Changed() { + log.Println("Wi-Fi:", wifi) + } +}`, Keys: "Tab focuses the switch; Space turns it over."}, + {ID: "032", Name: "Toggle", ZhName: "切换按钮", Category: choiceCategory, Demo: toggleDemo, Code: `bold := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Toggle(c, &bold, "", mujica.ToggleOptions{Icon: icons.Must("bold"), Label: "Bold"}) +}`, Keys: "Tab focuses the toggle; Space or Enter presses or releases it."}, + {ID: "033", Name: "ToggleGroup", ZhName: "切换按钮组", Category: choiceCategory, Demo: toggleGroupDemo, Code: `align := []string{"left"} +items := []mujica.ToggleItem[string]{ + {Value: "left", Label: "Left"}, + {Value: "center", Label: "Center"}, + {Value: "right", Label: "Right"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ToggleGroup(c, &align, items, mujica.ToggleGroupOptions{Label: "Align"}) +}`, Keys: "Tab enters the group at the pressed item; Left/Right (Up/Down when vertical), Home and End move; Space presses."}, + {ID: "034", Name: "Slider", ZhName: "滑块", Category: choiceCategory, Demo: sliderDemo, Code: `volume := 40.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Slider(c, &volume, mujica.SliderOptions{Step: 5, ShowValue: true, Label: "Volume"}) +}`, Keys: "Arrows move one step; Home and End go to the ends."}, + {ID: "035", Name: "RangeSlider", ZhName: "范围滑块", Category: choiceCategory, Demo: rangeSliderDemo, Code: `low, high := 20.0, 80.0 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.RangeSlider(c, &low, &high, mujica.RangeSliderOptions{Step: 10, Label: "Price"}) +}`, Keys: "Tab moves between the two thumbs; arrows move one step; Home and End go as far as the other thumb allows."}, + {ID: "036", Name: "Select", ZhName: "选择器", Category: choiceCategory, Demo: selectDemo, Code: `plan := "" +options := []mujica.SelectOption[string]{ + {Value: "free", Label: "Free"}, + {Value: "pro", Label: "Pro"}, + {Value: "team", Label: "Team", Disabled: true}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Select(c, &plan, options, mujica.SelectOptions{Clearable: true, Label: "Plan"}) +}`, Keys: "Enter, Space, Up or Down open; arrows, Home, End move; Enter chooses; Escape closes; typing letters on the closed select jumps to a match; Backspace/Delete clear when Clearable."}, + {ID: "037", Name: "MultiSelect", ZhName: "多选选择器", Category: choiceCategory, Demo: multiSelectDemo, Code: `tags := []int{1} +options := []mujica.SelectOption[int]{ + {Value: 1, Label: "Rose"}, + {Value: 2, Label: "Lily"}, + {Value: 3, Label: "Iris"}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiSelect(c, &tags, options, mujica.MultiSelectOptions{Max: 2, Label: "Flowers"}) +}`, Keys: "Enter, Space or Down open with the search focused; Down enters the list; Up/Down move; Space checks; Escape closes; Backspace on the field removes the last tag."}, + {ID: "038", Name: "Combobox", ZhName: "组合框", Category: choiceCategory, Demo: comboboxDemo, Code: `font := "" +fonts := []string{"Garamond", "Georgia", "Didot"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Combobox(c, &font, fonts, mujica.ComboboxOptions{AllowCustom: true, Label: "Font"}) + if r.QueryChanged() { + log.Println("search:", r.Query()) + } +}`, Keys: "Typing filters; Down opens or moves; Up moves; Enter chooses; Escape closes; Tab leaves (keeping typed text with AllowCustom)."}, + {ID: "039", Name: "Cascader", ZhName: "级联选择", Category: choiceCategory, Demo: cascaderDemo, Code: `var path []string +level := func(p []string) mujica.CascaderLevel { + if len(p) == 0 { + return mujica.CascaderLevel{Nodes: []mujica.CascaderNode{{Value: "fr", Label: "France"}}} + } + return mujica.CascaderLevel{Nodes: []mujica.CascaderNode{{Value: "paris", Label: "Paris", Leaf: true}}} +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Cascader(c, &path, mujica.CascaderOptions{Level: level, Label: "City"}) +}`, Keys: "Enter, Space or Down open; Up/Down move in a column; Right opens a branch; Left goes back; Enter chooses; Escape closes."}, + {ID: "040", Name: "TreeSelect", ZhName: "树选择", Category: choiceCategory, Demo: treeSelectDemo, Code: `var roles []string +nodes := []mujica.TreeSelectNode{ + {ID: "court", Label: "Court", Children: []mujica.TreeSelectNode{ + {ID: "king", Label: "King"}, + {ID: "queen", Label: "Queen"}, + }}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TreeSelect(c, &roles, nodes, mujica.TreeSelectOptions{Multiple: true, Label: "Roles"}) +}`, Keys: "Enter, Space or Down open with the search focused; Down enters the tree; Up/Down move; Right expands or enters; Left collapses or goes to the parent; Space checks; Escape closes."}, +} + +// choiceDemo lays out a demo: parameter controls, then the samples. +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.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.Column(c).Gap(16).AlignItems(ui.Start).Children(samples) + }) +} + +// choiceFlag is a parameter switch that logs its change. +func choiceFlag(c *ui.Context, on *bool, label string, log func(string)) { + if mujica.Switch(c, on, label, mujica.SwitchOptions{}).Changed() { + log(fmt.Sprintf("%s = %v", label, *on)) + } +} + +// 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) + fn() + }) +} + +func switchDemo(c *ui.Context, log func(string)) { + s := *ui.Local(c.Root(), "031", func() *struct{ wifi, disabled, loading, pending bool } { + return &struct{ wifi, disabled, loading, pending bool }{wifi: true} + }) + choiceDemo(c, func() { + choiceFlag(c, &s.disabled, "Disabled", log) + choiceFlag(c, &s.loading, "Loading", log) + }, func() { + if mujica.Switch(c, &s.wifi, "Wi-Fi", mujica.SwitchOptions{Disabled: s.disabled, Loading: s.loading}).Changed() { + log(fmt.Sprintf("Wi-Fi changed: %v", s.wifi)) + } + // A caller applying the change: loading for a moment, then done. + at := ui.Local(c.Root(), "031-at", func() time.Time { return time.Time{} }) + if mujica.Switch(c, &s.pending, "Backup (saves for 1 s)", mujica.SwitchOptions{Loading: !at.IsZero()}).Changed() { + *at = c.Now() + log(fmt.Sprintf("Backup requested: %v", s.pending)) + } + if !at.IsZero() { + if c.Now().Sub(*at) >= time.Second { + *at = time.Time{} + log("Backup saved") + } else { + c.After(time.Second - c.Now().Sub(*at)) + } + } + off, on := false, true + ui.Row(c).Gap(24).Children(func() { + mujica.Switch(c, &off, "Disabled off", mujica.SwitchOptions{Disabled: true}) + mujica.Switch(c, &on, "Disabled on", mujica.SwitchOptions{Disabled: true}) + }) + }) +} + +func toggleDemo(c *ui.Context, log func(string)) { + s := *ui.Local(c.Root(), "032", func() *struct{ bold, italic, underline, disabled bool } { + return &struct{ bold, italic, underline, disabled bool }{bold: true} + }) + choiceDemo(c, func() { + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + ui.Row(c).Gap(8).Children(func() { + for _, t := range []struct { + on *bool + label, ico string + }{{&s.bold, "Bold", "bold"}, {&s.italic, "Italic", "italic"}, {&s.underline, "Underline", "underline"}} { + if mujica.Toggle(c, t.on, "", mujica.ToggleOptions{Icon: icons.Must(t.ico), Label: t.label, Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("%s: %v", t.label, *t.on)) + } + } + }) + if mujica.Toggle(c, &s.bold, "Bold text", mujica.ToggleOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("Bold: %v", s.bold)) + } + }) +} + +func toggleGroupDemo(c *ui.Context, log func(string)) { + type state struct { + value []string + multiple, allowEmpty, vertical, disabled bool + } + s := *ui.Local(c.Root(), "033", func() *state { return &state{value: []string{"left"}} }) + items := []mujica.ToggleItem[string]{ + {Value: "left", Label: "Left"}, {Value: "center", Label: "Center"}, + {Value: "right", Label: "Right"}, {Value: "justify", Label: "Justify", Disabled: true}, + } + choiceDemo(c, func() { + if mujica.Switch(c, &s.multiple, "Multiple", mujica.SwitchOptions{}).Changed() { + s.value = s.value[:min(len(s.value), 1)] + log(fmt.Sprintf("Multiple = %v", s.multiple)) + } + choiceFlag(c, &s.allowEmpty, "AllowEmpty", log) + choiceFlag(c, &s.vertical, "Vertical", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + opts := mujica.ToggleGroupOptions{Multiple: s.multiple, AllowEmpty: s.allowEmpty, Vertical: s.vertical, Disabled: s.disabled, Label: "Align"} + if mujica.ToggleGroup(c, &s.value, items, opts).Changed() { + log(fmt.Sprintf("value: %v", s.value)) + } + ui.Text(c, fmt.Sprintf("Value: %v", s.value)).TextColor(mujica.Tokens(c).TextMuted) + }) +} + +func sliderDemo(c *ui.Context, log func(string)) { + type state struct { + value, step float64 + marks, showValue, disabled bool + } + s := *ui.Local(c.Root(), "034", func() *state { return &state{value: 40, step: 5, showValue: true} }) + choiceDemo(c, func() { + choiceFlag(c, &s.marks, "Marks", log) + choiceFlag(c, &s.showValue, "ShowValue", log) + choiceFlag(c, &s.disabled, "Disabled", log) + choiceLabeled(c, fmt.Sprintf("Step %v", s.step), func() { + if mujica.Slider(c, &s.step, mujica.SliderOptions{Max: 25, Step: 5, Label: "Step"}).Changed() { + log(fmt.Sprintf("Step = %v", s.step)) + } + }) + }, func() { + var marks []mujica.SliderMark + if s.marks { + for v := 0.0; v <= 100; v += 25 { + marks = append(marks, mujica.SliderMark{Value: v, Label: fmt.Sprint(v)}) + } + } + ui.Column(c).Width(360).Children(func() { + opts := mujica.SliderOptions{Step: s.step, Marks: marks, ShowValue: s.showValue, Disabled: s.disabled, Label: "Volume"} + if mujica.Slider(c, &s.value, opts).Changed() { + log(fmt.Sprintf("Volume: %v", s.value)) + } + }) + ui.Text(c, fmt.Sprintf("Value: %v", s.value)).TextColor(mujica.Tokens(c).TextMuted) + }) +} + +func rangeSliderDemo(c *ui.Context, log func(string)) { + type state struct { + low, high float64 + showValue, disabled, marks bool + } + s := *ui.Local(c.Root(), "035", func() *state { return &state{low: 20, high: 80, showValue: true} }) + choiceDemo(c, func() { + choiceFlag(c, &s.marks, "Marks", log) + choiceFlag(c, &s.showValue, "ShowValue", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + var marks []mujica.SliderMark + if s.marks { + marks = []mujica.SliderMark{{Value: 0, Label: "£0"}, {Value: 500, Label: "£500"}, {Value: 1000, Label: "£1000"}} + } + ui.Column(c).Width(360).Children(func() { + opts := mujica.RangeSliderOptions{Max: 1000, Step: 50, Marks: marks, ShowValue: s.showValue, Disabled: s.disabled, Label: "Price", + Format: func(v float64) string { return fmt.Sprintf("£%.0f", v) }} + if mujica.RangeSlider(c, &s.low, &s.high, opts).Changed() { + log(fmt.Sprintf("Price: %v–%v", s.low, s.high)) + } + }) + ui.Text(c, fmt.Sprintf("Range: £%.0f – £%.0f", s.low, s.high)).TextColor(mujica.Tokens(c).TextMuted) + }) +} + +// choiceDelay is how long the demos' pretend server takes. +const choiceDelay = 700 * time.Millisecond + +// choiceDue reports whether a request started at "at" has finished, asking for a frame when it will. +func choiceDue(c *ui.Context, at time.Time) bool { + if left := choiceDelay - c.Now().Sub(at); left > 0 { + c.After(left) + return false + } + return true +} + +var choiceFruits = []mujica.SelectOption[string]{ + {Value: "apple", Label: "Apple"}, {Value: "banana", Label: "Banana"}, {Value: "blueberry", Label: "Blueberry"}, + {Value: "cherry", Label: "Cherry", Disabled: true}, {Value: "date", Label: "Date"}, {Value: "fig", Label: "Fig"}, +} + +func selectDemo(c *ui.Context, log func(string)) { + type state struct { + value string + clearable, disabled, noData bool + } + s := *ui.Local(c.Root(), "036", func() *state { return &state{value: "banana", clearable: true} }) + choiceDemo(c, func() { + choiceFlag(c, &s.clearable, "Clearable", log) + choiceFlag(c, &s.disabled, "Disabled", log) + choiceFlag(c, &s.noData, "No options", log) + }, func() { + options := choiceFruits + if s.noData { + options = nil + } + choiceLabeled(c, "Fruit", func() { + if mujica.Select(c, &s.value, options, mujica.SelectOptions{Clearable: s.clearable, Disabled: s.disabled, Label: "Fruit"}).Changed() { + log(fmt.Sprintf("Fruit: %q", s.value)) + } + }) + empty := "" + choiceLabeled(c, "Placeholder", func() { + mujica.Select(c, &empty, choiceFruits, mujica.SelectOptions{Placeholder: "Pick a fruit"}) + }) + }) +} + +func multiSelectDemo(c *ui.Context, log func(string)) { + type state struct { + values []string + limit, disable bool + } + s := *ui.Local(c.Root(), "037", func() *state { return &state{values: []string{"apple", "date"}} }) + choiceDemo(c, func() { + choiceFlag(c, &s.limit, "Max 3", log) + choiceFlag(c, &s.disable, "Disabled", log) + }, func() { + limit := 0 + if s.limit { + limit = 3 + } + choiceLabeled(c, "Fruits", func() { + r := mujica.MultiSelect(c, &s.values, choiceFruits, mujica.MultiSelectOptions{Max: limit, MaxTags: 2, Disabled: s.disable, Label: "Fruits"}) + if r.Width(320).Changed() { + log(fmt.Sprintf("Fruits: %v", s.values)) + } + }) + }) +} + +var choiceFonts = []string{"Baskerville", "Bodoni", "Caslon", "Didot", "Garamond", "Georgia", "Jenson", "Palatino"} + +func comboboxDemo(c *ui.Context, log func(string)) { + type state struct { + value, query, asked string + custom, remote bool + at time.Time + results []string + } + s := *ui.Local(c.Root(), "038", func() *state { return &state{custom: true} }) + choiceDemo(c, func() { + choiceFlag(c, &s.custom, "AllowCustom", log) + choiceFlag(c, &s.remote, "Remote results", log) + }, func() { + options, loading := choiceFonts, false + if s.remote { + // The caller fetches results for the query and hands them back. + if s.asked != s.query && choiceDue(c, s.at) { + s.asked = s.query + s.results = nil + for _, f := range choiceFonts { + if strings.Contains(strings.ToLower(f), strings.ToLower(s.query)) { + s.results = append(s.results, f) + } + } + log(fmt.Sprintf("results for %q: %d", s.query, len(s.results))) + } + options, loading = s.results, s.asked != s.query + } + choiceLabeled(c, "Typeface", func() { + r := mujica.Combobox(c, &s.value, options, mujica.ComboboxOptions{AllowCustom: s.custom, ExternalFilter: s.remote, Loading: loading, Label: "Typeface"}) + if r.QueryChanged() { + s.query, s.at = r.Query(), c.Now() + log(fmt.Sprintf("query: %q", s.query)) + } + if r.Changed() { + log(fmt.Sprintf("Typeface: %q", s.value)) + } + }) + disabled := "Didot" + choiceLabeled(c, "Disabled", func() { + mujica.Combobox(c, &disabled, choiceFonts, mujica.ComboboxOptions{Disabled: true}) + }) + }) +} + +// choiceAtlas is the cascader demo's pretend server data. +var choiceAtlas = map[string][]mujica.CascaderNode{ + "": {{Value: "eu", Label: "Europe"}, {Value: "as", Label: "Asia"}, {Value: "aq", Label: "Antarctica", Disabled: true}}, + "eu": {{Value: "fr", Label: "France"}, {Value: "it", Label: "Italy"}}, + "as": {{Value: "jp", Label: "Japan"}, {Value: "cn", Label: "China"}}, + "eu/fr": {{Value: "paris", Label: "Paris", Leaf: true}, {Value: "lyon", Label: "Lyon", Leaf: true}}, + "eu/it": {{Value: "rome", Label: "Rome", Leaf: true}}, + "as/jp": {{Value: "tokyo", Label: "Tokyo", Leaf: true}, {Value: "kyoto", Label: "Kyoto", Leaf: true}}, + "as/cn": {{Value: "beijing", Label: "Beijing", Leaf: true}}, +} + +func cascaderDemo(c *ui.Context, log func(string)) { + type state struct { + path []string + levels map[string]mujica.CascaderLevel + pending map[string]time.Time + anyLevel, fail, ds bool + } + s := *ui.Local(c.Root(), "039", func() *state { + return &state{levels: map[string]mujica.CascaderLevel{"": {Nodes: choiceAtlas[""]}}, pending: map[string]time.Time{}} + }) + for key, at := range s.pending { + if choiceDue(c, at) { + delete(s.pending, key) + if s.fail { + s.levels[key] = mujica.CascaderLevel{Status: mujica.CascaderFailed, Err: errors.New("server busy")} + log(fmt.Sprintf("load %q failed", key)) + continue + } + s.levels[key] = mujica.CascaderLevel{Nodes: choiceAtlas[key]} + log(fmt.Sprintf("loaded %q", key)) + } + } + level := func(p []string) mujica.CascaderLevel { + if lv, ok := s.levels[strings.Join(p, "/")]; ok { + return lv + } + return mujica.CascaderLevel{Status: mujica.CascaderIdle} + } + load := func(p []string) { + key := strings.Join(p, "/") + s.levels[key] = mujica.CascaderLevel{Status: mujica.CascaderLoading} + s.pending[key] = c.Now() + log(fmt.Sprintf("load %q", key)) + } + choiceDemo(c, func() { + choiceFlag(c, &s.anyLevel, "AnyLevel", log) + choiceFlag(c, &s.fail, "Fail loads", log) + choiceFlag(c, &s.ds, "Disabled", log) + }, func() { + choiceLabeled(c, "City (children load on demand)", func() { + opts := mujica.CascaderOptions{Level: level, Load: load, AnyLevel: s.anyLevel, Disabled: s.ds, Label: "City"} + if mujica.Cascader(c, &s.path, opts).Width(320).Changed() { + log(fmt.Sprintf("path: %v", s.path)) + } + }) + }) +} + +var choiceRoles = []mujica.TreeSelectNode{ + {ID: "court", Label: "Court", Children: []mujica.TreeSelectNode{ + {ID: "king", Label: "King"}, {ID: "queen", Label: "Queen"}, {ID: "jester", Label: "Jester", Disabled: true}, + {ID: "guard", Label: "Royal guard", Children: []mujica.TreeSelectNode{{ID: "captain", Label: "Captain"}, {ID: "archer", Label: "Archer"}}}, + }}, + {ID: "church", Label: "Church", Children: []mujica.TreeSelectNode{{ID: "bishop", Label: "Bishop"}, {ID: "monk", Label: "Monk"}}}, + {ID: "merchant", Label: "Merchant"}, +} + +func treeSelectDemo(c *ui.Context, log func(string)) { + type state struct { + sel []string + multiple, independent, disabled bool + } + s := *ui.Local(c.Root(), "040", func() *state { return &state{sel: []string{"queen", "bishop"}, multiple: true} }) + choiceDemo(c, func() { + if mujica.Switch(c, &s.multiple, "Multiple", mujica.SwitchOptions{}).Changed() { + s.sel = s.sel[:min(len(s.sel), 1)] + log(fmt.Sprintf("Multiple = %v", s.multiple)) + } + choiceFlag(c, &s.independent, "Independent rule", log) + choiceFlag(c, &s.disabled, "Disabled", log) + }, func() { + rule := mujica.TreeSelectCascade + if s.independent { + rule = mujica.TreeSelectIndependent + } + choiceLabeled(c, "Roles", func() { + opts := mujica.TreeSelectOptions{Multiple: s.multiple, Rule: rule, MaxTags: 2, Disabled: s.disabled, Label: "Roles"} + if mujica.TreeSelect(c, &s.sel, choiceRoles, opts).Width(320).Changed() { + log(fmt.Sprintf("Roles: %v", s.sel)) + } + }) + }) +} diff --git a/internal/choice.go b/internal/choice.go new file mode 100644 index 0000000..36ec838 --- /dev/null +++ b/internal/choice.go @@ -0,0 +1,60 @@ +package internal + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CheckState is what a drawn check box shows. +type CheckState int + +const ( + Unchecked CheckState = iota + Checked + Mixed +) + +// CheckBox paints a square check box filling r: a tick, a dash for mixed. +func CheckBox(p *ui.Painter, r ui.Rect, s CheckState, k theme.Tokens, hover bool) { + if s == Unchecked { + bg := k.Surface + if hover { + bg = k.SurfaceHover + } + p.Fill(r, bg, theme.ControlRadius) + p.Stroke(r, k.ControlBorder, theme.ControlRadius, theme.BorderWidth) + return + } + bg := k.Accent + if hover { + bg = k.AccentHover + } + p.Fill(r, bg, theme.ControlRadius) + var path ui.Path + if s == Mixed { + path.MoveTo(r.X+r.W*0.25, r.Y+r.H/2).LineTo(r.X+r.W*0.75, r.Y+r.H/2) + } else { + path.MoveTo(r.X+r.W*0.22, r.Y+r.H*0.52).LineTo(r.X+r.W*0.42, r.Y+r.H*0.72).LineTo(r.X+r.W*0.78, r.Y+r.H*0.3) + } + p.StrokePath(&path, 1.75, k.OnAccent) +} + +// Chevron strokes an arrow in r pointing down, or right when right. +func Chevron(p *ui.Painter, r ui.Rect, right bool, c ui.Color) { + var path ui.Path + if right { + path.MoveTo(r.X+r.W*0.38, r.Y+r.H*0.22).LineTo(r.X+r.W*0.66, r.Y+r.H/2).LineTo(r.X+r.W*0.38, r.Y+r.H*0.78) + } else { + path.MoveTo(r.X+r.W*0.22, r.Y+r.H*0.38).LineTo(r.X+r.W/2, r.Y+r.H*0.66).LineTo(r.X+r.W*0.78, r.Y+r.H*0.38) + } + p.StrokePath(&path, 1.5, c) +} + +// Cross strokes an x in r. +func Cross(p *ui.Painter, r ui.Rect, c ui.Color) { + var path ui.Path + k := r.W * 0.28 + path.MoveTo(r.X+k, r.Y+k).LineTo(r.X+r.W-k, r.Y+r.H-k).MoveTo(r.X+r.W-k, r.Y+k).LineTo(r.X+k, r.Y+r.H-k) + p.StrokePath(&path, 1.5, c) +} diff --git a/messages_choice.go b/messages_choice.go new file mode 100644 index 0000000..06e86e3 --- /dev/null +++ b/messages_choice.go @@ -0,0 +1,28 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "range_slider.min": {"minimum", "最小值"}, + "range_slider.max": {"maximum", "最大值"}, + "select.placeholder": {"Select…", "请选择…"}, + "select.empty": {"No options", "暂无选项"}, + "select.clear": {"Clear", "清空"}, + "multi_select.search": {"Search", "搜索"}, + "multi_select.all": {"Select all", "全选"}, + "multi_select.no_results": {"No results", "无匹配结果"}, + "multi_select.limit": {"%d of %d chosen", "已选 %d / %d"}, + "multi_select.more": {"+%d", "+%d"}, + "multi_select.remove": {"Remove %s", "移除 %s"}, + "combobox.loading": {"Loading…", "加载中…"}, + "combobox.no_results": {"No matches", "无匹配项"}, + "combobox.use": {"Use “%s”", "使用“%s”"}, + "cascader.loading": {"Loading…", "加载中…"}, + "cascader.failed": {"Couldn't load", "加载失败"}, + "cascader.retry": {"Retry", "重试"}, + "cascader.empty": {"Nothing here", "暂无数据"}, + "tree_select.search": {"Search", "搜索"}, + "tree_select.no_results": {"No results", "无匹配结果"}, + "tree_select.expand": {"Expand %s", "展开 %s"}, + "tree_select.collapse": {"Collapse %s", "收起 %s"}, + }) +} diff --git a/multi_select.go b/multi_select.go new file mode 100644 index 0000000..50c6200 --- /dev/null +++ b/multi_select.go @@ -0,0 +1,243 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// MultiSelectOptions configure a MultiSelect. +type MultiSelectOptions struct { + // Placeholder shows while nothing is chosen; empty uses "Select…". + Placeholder string + // Max limits how many values may be chosen; zero is no limit. + Max int + // MaxTags is how many chosen values show as tags before "+N"; zero is 3. + MaxTags int + Disabled bool + // Label names the field for assistive technology. + Label string +} + +// 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. +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") + } + k := Tokens(c) + before := slices.Clone(*values) + tr := ui.ButtonBase(c).Disabled(opts.Disabled).Role(ui.RolePopUpButton) + if opts.Label != "" { + tr.Label(opts.Label) + } + open := ui.Local(tr, multiSelectKey{"open"}, func() bool { return false }) + query := ui.Local(tr, multiSelectKey{"query"}, func() string { return "" }) + if tr.Clicked() || (!*open && tr.Shortcut(0, ui.KeyDown)) { + *open = !*open + *query = "" + } + if n := len(*values); n > 0 && tr.Shortcut(0, ui.KeyBackspace) { + *values = slices.Clone((*values)[:n-1]) + } + selectField(c, tr, *open, func() { + multiSelectTags(c, values, options, opts) + }) + ui.PopoverBase(c, tr, open, func(panel *ui.Element) { + selectPanel(c, panel) + panel.MinWidth(tr.Bounds().W).MaxWidth(max(tr.Bounds().W, 320)) + multiSelectPopup(c, k, values, options, query, opts) + }) + changed := !slices.Equal(before, *values) + if changed { + slog.Debug("mujica: multi select changed", "label", opts.Label, "count", len(*values)) + } + return ChoiceResult{tr, changed} +} + +// multiSelectTags shows the chosen values as removable tags, collapsing the rest into "+N". +func multiSelectTags[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) { + k := Tokens(c) + if len(*values) == 0 { + ui.Text(c, selectOr(opts.Placeholder, msg(c, "select.placeholder"))).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1) + return + } + limit := opts.MaxTags + if limit == 0 { + limit = 3 + } + ui.Row(c).Gap(4).Grow(1).Shrink(1).ClipX().Children(func() { + for i, v := range *values { + if i == limit { + break + } + label, _ := selectLabel(options, v) + ui.Row(c).Key(v).Shrink(0).Children(func() { + if multiSelectTag(c, label, opts.Disabled) { + *values = slices.Delete(slices.Clone(*values), i, i+1) + } + }) + } + if n := len(*values) - limit; n > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "multi_select.more"), n)).TextColor(k.TextMuted).SingleLine().Shrink(0) + } + }) +} + +// multiSelectTag draws a chosen value with a remove cross outside the Tab order; it reports a removal. +func multiSelectTag(c *ui.Context, label string, disabled bool) bool { + k := Tokens(c) + 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() + if !disabled { + removed = selectClear(c, k, fmt.Sprintf(msg(c, "multi_select.remove"), label)) + } + }) + return removed +} + +func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T, options []SelectOption[T], query *string, opts MultiSelectOptions) { + d := Density(c) + var shown []SelectOption[T] + q := strings.ToLower(strings.TrimSpace(*query)) + for _, o := range options { + if strings.Contains(strings.ToLower(o.Label), q) { + shown = append(shown, o) + } + } + full := opts.Max > 0 && len(*values) >= opts.Max + into := false + ui.Column(c).Gap(4).Children(func() { + search := selectSearch(c, query, msg(c, "multi_select.search")) + into = search.Shortcut(0, ui.KeyDown) + if len(shown) == 0 { + selectNote(c, msg(c, "multi_select.no_results")) + return + } + list := ui.Column(c).FocusGroup(ui.Vertical) + list.Children(func() { + all, some := true, false + for _, o := range shown { + on := slices.Contains(*values, o.Value) + all = all && (on || o.Disabled) + some = some || on + } + state := internal.Unchecked + if all && some { + state = internal.Checked + } else if some { + state = internal.Mixed + } + tick := state == internal.Checked + ui.Column(c).PaddingX(4).Children(func() { + b := multiSelectCheck(c, k, &tick, state, msg(c, "multi_select.all"), opts.Max > 0 && !some && full) + if into { + b.Focus() + } + if b.Changed() { + *values = multiSelectAll(*values, shown, tick, opts.Max) + } + }) + ui.Box(c).Height(1).Background(k.Border).MarginY(2) + ui.Scroll(c).MaxHeight(240).Padding(4).Children(func() { + for _, o := range shown { + on := slices.Contains(*values, o.Value) + st := internal.Unchecked + if on { + st = internal.Checked + } + row := ui.Row(c).Key(o.Value) + row.Children(func() { + cb := multiSelectCheck(c, k, &on, st, o.Label, o.Disabled || (full && !on)) + if cb.Changed() { + *values = multiSelectSet(*values, o.Value, on) + } + }) + } + }) + }) + 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) + } + }) +} + +// 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 + } + 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 { + cb.Background(k.SurfaceHover) + } + cb.Children(func() { + ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + internal.CheckBox(p, r, st, k, false) + }) + ui.Text(c, label).SingleLine() + }) + return cb +} + +// multiSelectSet adds or removes v. +func multiSelectSet[T comparable](values []T, v T, on bool) []T { + if on { + return append(slices.Clone(values), v) + } + return slices.DeleteFunc(slices.Clone(values), func(x T) bool { return x == v }) +} + +// multiSelectAll checks every enabled shown option up to max, or unchecks them all. +func multiSelectAll[T comparable](values []T, shown []SelectOption[T], on bool, limit int) []T { + for _, o := range shown { + if o.Disabled || slices.Contains(values, o.Value) == on { + continue + } + if on && limit > 0 && len(values) >= limit { + break + } + values = multiSelectSet(values, o.Value, on) + } + return values +} + +// selectSearch is the search field at the top of a popup; it takes the focus as the popup opens. +func selectSearch(c *ui.Context, query *string, placeholder string) *ui.Element { + k := Tokens(c) + d := Density(c) + f := ui.Row(c).Gap(d.Unit()*2).Padding(0, d.Unit()*2).MinHeight(d.ControlHeight()-4).AlignItems(ui.Center). + Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.ControlBorder) + var in *ui.Element + f.Children(func() { + ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + var path ui.Path + path.Circle(r.X+r.W*0.42, r.Y+r.H*0.42, r.W*0.32) + path.MoveTo(r.X+r.W*0.66, r.Y+r.H*0.66).LineTo(r.X+r.W*0.92, r.Y+r.H*0.92) + p.StrokePath(&path, 1.5, k.TextMuted) + }) + in = ui.TextInputBase(c, query).Grow(1).Placeholder(placeholder).Label(placeholder).AutoFocus() + }) + if in.Focused() { + f.Border(theme.BorderWidth, k.AccentText) + } + return in +} diff --git a/multi_select_test.go b/multi_select_test.go new file mode 100644 index 0000000..db8a572 --- /dev/null +++ b/multi_select_test.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +var multiSelectTestOptions = []SelectOption[int]{ + {Value: 1, Label: "Rose"}, + {Value: 2, Label: "Lily"}, + {Value: 3, Label: "Iris"}, + {Value: 4, Label: "Violet", Disabled: true}, + {Value: 5, Label: "Lilac"}, +} + +func multiSelectTester(values *[]int, opts MultiSelectOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Children(func() { + if MultiSelect(c, values, multiSelectTestOptions, opts).Changed() { + *changes++ + } + }) + }, 500, 500) +} + +func TestMultiSelectCheckAndRemove(t *testing.T) { + var values []int + changes := 0 + tt := multiSelectTester(&values, MultiSelectOptions{Label: "Flowers"}, &changes) + if err := tt.Click("Select…"); err != nil { + t.Fatal(err) + } + _ = tt.Click("Iris") + _ = tt.Click("Rose") + _ = tt.Click("Violet") + if !slices.Equal(values, []int{3, 1}) || changes != 2 { + t.Fatalf("values %v changes %d", values, changes) + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Lily") { + t.Fatal("Escape did not close") + } + if err := tt.Click("Remove Iris"); err != nil { + t.Fatal(err) + } + if !slices.Equal(values, []int{1}) { + t.Fatalf("after remove %v", values) + } +} + +func TestMultiSelectSearchAndSelectAll(t *testing.T) { + var values []int + changes := 0 + tt := multiSelectTester(&values, MultiSelectOptions{}, &changes) + _ = tt.Click("Select…") + tt.Type("li") + if tt.HasText("Rose") || !tt.HasText("Lily") || !tt.HasText("Lilac") { + t.Fatalf("filter shows %v", tt.Texts()) + } + _ = tt.Click("Select all") + if !slices.Equal(values, []int{2, 5}) { + t.Fatalf("select all of matches: %v", values) + } + _ = tt.Click("Select all") + if len(values) != 0 { + t.Fatalf("second select all left %v", values) + } + _ = tt.Click("Search") + tt.Type("zz") + if !tt.HasText("No results") { + t.Fatal("no-results state missing") + } +} + +func TestMultiSelectLimit(t *testing.T) { + values := []int{1} + changes := 0 + tt := multiSelectTester(&values, MultiSelectOptions{Max: 2}, &changes) + _ = tt.Click("Rose") + _ = tt.Click("Select all") + if !slices.Equal(values, []int{1, 2}) { + t.Fatalf("select all past limit: %v", values) + } + if !tt.HasText("2 of 2 chosen") { + t.Fatal("limit note missing") + } + _ = tt.Click("Lilac") + if len(values) != 2 { + t.Fatalf("limit broken: %v", values) + } +} + +func TestMultiSelectKeyboard(t *testing.T) { + var values []int + changes := 0 + tt := multiSelectTester(&values, MultiSelectOptions{Label: "Flowers"}, &changes) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !tt.Focused("Search") { + t.Fatal("search not focused on open") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeySpace) + if !slices.Equal(values, []int{1, 2}) { + t.Fatalf("values %v", values) + } + tt.Key(0, ui.KeyEscape) + if !tt.Focused("Flowers") { + t.Fatal("focus did not return to the field") + } + tt.Key(0, ui.KeyBackspace) + if !slices.Equal(values, []int{1}) { + t.Fatalf("Backspace left %v", values) + } +} + +func TestMultiSelectCollapsesTags(t *testing.T) { + values := []int{1, 2, 3, 5} + changes := 0 + tt := multiSelectTester(&values, MultiSelectOptions{MaxTags: 2}, &changes) + if !tt.HasText("+2") || tt.HasText("Iris") { + t.Fatalf("texts %v", tt.Texts()) + } +} + +func TestMultiSelectSnapshot(t *testing.T) { + snapshot(t, "multi_select", 460, 110, func(c *ui.Context) { + var none []int + some := []int{1, 2, 3, 5} + ui.Row(c).Padding(20).Gap(16).Children(func() { + MultiSelect(c, &none, multiSelectTestOptions, MultiSelectOptions{}) + MultiSelect(c, &some, multiSelectTestOptions, MultiSelectOptions{MaxTags: 2}) + }) + }) +} diff --git a/range_slider.go b/range_slider.go new file mode 100644 index 0000000..26d62d2 --- /dev/null +++ b/range_slider.go @@ -0,0 +1,130 @@ +package mujica + +import ( + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// RangeSliderOptions configure a RangeSlider. Min and Max both zero mean 0 to 100. +type RangeSliderOptions struct { + Min, Max float64 + // Step snaps both ends to Min plus multiples of it; zero is continuous. + Step float64 + Marks []SliderMark + ShowValue bool + Format func(float64) string + Disabled bool + // Label names the range; the thumbs read "