Choices: components 031-040 (wip)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:26:17 -04:00
1 parent cfc7ab023d
commit d67eb60bf9
54 files changed
+4253 -1

No files matched your search

+234
View File
@@ -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)
})
}
})
}
+167
View File
@@ -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})
})
})
}
+190
View File
@@ -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"))
})
})
}
+162
View File
@@ -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})
})
})
}
+46
View File
@@ -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` 由调用方根据自己的异步任务设置。
+43
View File
@@ -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 报告。
+63
View File
@@ -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` 在帧之间增删时焦点可能落到相邻项。
+48
View File
@@ -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),禁用时组件改为让它操作副本,保证值不变。
+50
View File
@@ -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”),屏幕阅读器读名称而非数值属性。
+55
View File
@@ -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 清空。
+54
View File
@@ -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 移除。
- 列表未虚拟化,适合数百项以内。
+55
View File
@@ -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 选择首个结果。
+60
View File
@@ -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` 回到界面线程更新数据。
- 列表未虚拟化,每列适合数百项以内。
+59
View File
@@ -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 仅内部设置),屏幕阅读器读不到这两个状态;展开箭头带“展开/收起 + 节点名”的名称。
- 列表未虚拟化,适合数千节点以内。
+133
View File
@@ -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)
}
+476 -1
View File
@@ -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))
}
})
})
}
+60
View File
@@ -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)
}
+28
View File
@@ -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"},
})
}
+243
View File
@@ -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
}
+141
View File
@@ -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})
})
})
}
+130
View File
@@ -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 "<Label> minimum/maximum, value".
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.
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)
k := Tokens(c)
*low, *high = g.snap(math.Min(*low, *high)), g.snap(math.Max(*low, *high))
oldLow, oldHigh := *low, *high
e := ui.Box(c).Height(h).MinWidth(120).Shrink(0).Role(ui.RoleGroup).Disabled(opts.Disabled).Cursor(ui.CursorPointer)
if opts.Label != "" {
e.Label(opts.Label)
}
drag := ui.Local(e, rangeSliderDrag{}, func() int { return -1 })
px, _, _ := e.PointerPosition()
r := e.Bounds()
at := g.snap(g.lo + float64((px-sliderThumb/2)/max(r.W-sliderThumb, 1))*(g.hi-g.lo))
var knobs [2]*ui.Element
pressed := false
e.Children(func() {
for i, v := range []*float64{low, high} {
from, to := g.lo, *high
if i == 1 {
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)
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 {
pressed = true
*drag = i
}
knobs[i] = kb
}
})
if _, _, ok := e.Dragged(); ok {
pressed = true
}
if !pressed || opts.Disabled {
*drag = -1
} else {
if *drag < 0 {
*drag = 0
if math.Abs(at-*high) < math.Abs(at-*low) || at > *high {
*drag = 1
}
}
if *drag == 0 {
*low = min(at, *high)
} else {
*high = max(at, *low)
}
knobs[*drag].Focus()
}
changed := *low != oldLow || *high != oldHigh
if changed {
slog.Debug("mujica: range slider changed", "label", opts.Label, "low", *low, "high", *high)
}
lo, hi, which := *low, *high, *drag
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)
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)
}
}
})
return ChoiceResult{e, changed}
}
// rangeSliderDrag keys which thumb the pointer drags, -1 for none.
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})
shift := (w+12)/2 - sliderThumb/2
if i == 0 {
shift = -shift
}
sliderBubblePaint(p, k, x+shift, y, s)
}
+102
View File
@@ -0,0 +1,102 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func rangeTester(low, high *float64, opts RangeSliderOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Width(216).Children(func() {
if RangeSlider(c, low, high, opts).Changed() {
*changes++
}
})
}, 300, 120)
}
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") {
t.Fatal("first Tab did not focus the low thumb")
}
tt.Key(0, ui.KeyRight)
if low != 30 {
t.Fatalf("low %v", low)
}
tt.Key(0, ui.KeyEnd)
if low != 80 || high != 80 {
t.Fatalf("End took low to %v, high %v", low, high)
}
tt.Key(0, ui.KeyRight)
if low != 80 {
t.Fatalf("low crossed high: %v", low)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Price maximum, 80") {
t.Fatal("second Tab did not focus the high thumb")
}
tt.Key(0, ui.KeyLeft)
if high != 80 {
t.Fatalf("high crossed low: %v", high)
}
tt.Key(0, ui.KeyEnd)
if high != 100 || changes != 3 {
t.Fatalf("high %v changes %d", high, changes)
}
}
func TestRangeSliderDragMovesNearerThumb(t *testing.T) {
low, high, changes := 0.0, 100.0, 0
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 1}, &changes)
// The track runs from x=8 to x=208.
tt.Press(8+200*0.9, 18)
tt.Move(8+200*0.7, 18)
tt.Release(8+200*0.7, 18)
if low != 0 || high != 70 {
t.Fatalf("low %v high %v", low, high)
}
tt.Press(8+200*0.1, 18)
tt.Move(8+200*0.95, 18)
tt.Release(8+200*0.95, 18)
if low != 70 || high != 70 {
t.Fatalf("dragged low past high: low %v high %v", low, high)
}
}
func TestRangeSliderOrdersSwappedEnds(t *testing.T) {
low, high, changes := 60.0, 10.0, 0
tt := rangeTester(&low, &high, RangeSliderOptions{}, &changes)
tt.Frame()
if low != 10 || high != 60 {
t.Fatalf("low %v high %v", low, high)
}
}
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
d, e := 30.0, 60.0
marks := []SliderMark{{0, "0"}, {50, "50"}, {100, "100"}}
ui.Column(c).Padding(20).Gap(8).Width(380).Children(func() {
RangeSlider(c, &a, &b, RangeSliderOptions{Step: 5, Marks: marks, ShowValue: true, Label: "Price"})
RangeSlider(c, &d, &e, RangeSliderOptions{Disabled: true})
})
})
}
+238
View File
@@ -0,0 +1,238 @@
package mujica
import (
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// SelectOption is one choice of a Select or MultiSelect.
type SelectOption[T comparable] struct {
Value T
Label string
Disabled bool
}
// SelectOptions configure a Select.
type SelectOptions struct {
// Placeholder shows while no option holds the value; empty uses "Select…".
Placeholder string
// Clearable adds a clear button and Backspace/Delete, which set the zero value.
Clearable bool
Disabled bool
// Empty shows in the popup when there are no options; empty uses "No options".
Empty string
// Label names the select for assistive technology.
Label string
}
// selectKey keeps this file's state apart from SelectBase's own.
type selectKey struct{ name string }
// selectTypeahead is the prefix typed on a closed select, and when.
type selectTypeahead struct {
prefix string
pending bool
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.
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)
if opts.Label != "" {
tr.Label(opts.Label)
}
ta := ui.Local(tr, selectKey{"typeahead"}, func() selectTypeahead { return selectTypeahead{} })
tr.TextCaret(ui.Rect{W: 1, H: Density(c).ControlHeight()}).HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputText || strings.TrimSpace(ev.Text) == "" {
return false
}
if time.Since(ta.at) > time.Second {
ta.prefix = ""
}
ta.prefix += strings.ToLower(ev.Text)
ta.pending, ta.at = true, time.Now()
return true
})
if ta.pending {
ta.pending = false
if !s.Open() {
if i := selectFind(options, *value, ta.prefix); i >= 0 {
*value = options[i].Value
}
}
}
var zero T
label, has := selectLabel(options, *value)
if opts.Clearable && has && (tr.Shortcut(0, ui.KeyBackspace) || tr.Shortcut(0, ui.KeyDelete)) {
*value, has = zero, false
}
selectField(c, tr, s.Open(), func() {
if has {
ui.Text(c, label).SingleLine().Grow(1).Shrink(1)
} else {
ui.Text(c, selectOr(opts.Placeholder, msg(c, "select.placeholder"))).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1)
}
if opts.Clearable && has && !opts.Disabled && selectClear(c, k, msg(c, "select.clear")) {
*value = zero
}
})
lastHi := ui.Local(tr, selectKey{"shown"}, func() int { return -1 })
s.Popup(func(panel *ui.Element) {
selectPanel(c, panel)
ui.Scroll(c).MaxHeight(280).Children(func() {
if len(options) == 0 {
selectNote(c, selectOr(opts.Empty, msg(c, "select.empty")))
}
for i, o := range options {
if o.Disabled {
selectRow(c, ui.Row(c).Disabled(true), false, false, o.Label)
continue
}
it := s.Item(o.Value)
if it.Highlighted() && *lastHi != i {
*lastHi = i
it.ScrollIntoView()
}
selectRow(c, it, it.Highlighted(), o.Value == *value, o.Label)
}
})
})
if !s.Open() {
*lastHi = -1
}
changed := *value != old
if changed {
slog.Debug("mujica: select changed", "label", opts.Label, "value", *value)
}
return ChoiceResult{tr, changed}
}
// selectFind returns the enabled option matching typed prefix.
func selectFind[T comparable](options []SelectOption[T], current T, prefix string) int {
start := 0
// Repeating one letter cycles through the options starting with it.
if r := []rune(prefix); strings.Count(prefix, string(r[0])) == len(r) {
prefix = string(r[0])
for i, o := range options {
if o.Value == current {
start = i + 1
}
}
}
for n := range options {
i := (start + n) % len(options)
if !options[i].Disabled && strings.HasPrefix(strings.ToLower(options[i].Label), prefix) {
return i
}
}
return -1
}
func selectLabel[T comparable](options []SelectOption[T], v T) (string, bool) {
for _, o := range options {
if o.Value == v {
return o.Label, true
}
}
return "", false
}
func selectOr(s, fallback string) string {
if s != "" {
return s
}
return fallback
}
// selectField styles tr as a field that opens a popup, with content and a chevron.
func selectField(c *ui.Context, tr *ui.Element, open bool, content func()) {
k := Tokens(c)
d := Density(c)
tr.MinHeight(d.ControlHeight()).MinWidth(180).Padding(0, d.Unit()*2, 0, d.Unit()*3).Gap(d.Unit() * 2).
Justify(ui.Start).Radius(theme.ControlRadius).Background(k.Surface)
internal.Ring(tr, k.Focus, theme.ControlRadius)
border := k.ControlBorder
switch {
case open:
border = k.AccentText
case tr.Hovered():
border = k.Text
}
tr.Border(theme.BorderWidth, border)
tr.Children(func() {
content()
ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
if open {
// Mirrored: the chevron points up while the popup shows.
r.Y, r.H = r.Y+r.H, -r.H
}
internal.Chevron(p, r, false, k.TextMuted)
})
})
}
// selectClear draws a small clear button that takes no Tab stop; 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)
clicked := x.Clicked()
if x.Hovered() {
x.Background(k.SurfaceHover)
}
x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Cross(p, r, k.TextMuted) })
return clicked
}
// selectPanel styles a popup panel.
func selectPanel(c *ui.Context, panel *ui.Element) {
k := Tokens(c)
panel.Margin(4, 0, 0, 0).Padding(4).Radius(theme.PopupRadius).Background(k.Surface).
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.
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.Background(k.SurfaceHover)
}
if chosen {
row.TextColor(k.AccentText)
}
row.Children(func() {
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
if chosen {
var path ui.Path
path.MoveTo(r.X+r.W*0.15, r.Y+r.H*0.52).LineTo(r.X+r.W*0.4, r.Y+r.H*0.76).LineTo(r.X+r.W*0.85, r.Y+r.H*0.26)
p.StrokePath(&path, 1.75, k.AccentText)
}
})
ui.Text(c, label).SingleLine()
})
}
// selectNote shows a muted line in a popup, as an empty state.
func selectNote(c *ui.Context, s string) {
d := Density(c)
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).AlignItems(ui.Center).Children(func() {
ui.Text(c, s).TextColor(Tokens(c).TextMuted)
})
}
+148
View File
@@ -0,0 +1,148 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var selectTestOptions = []SelectOption[string]{
{Value: "apple", Label: "Apple"},
{Value: "banana", Label: "Banana"},
{Value: "blueberry", Label: "Blueberry"},
{Value: "cherry", Label: "Cherry", Disabled: true},
{Value: "date", Label: "Date"},
}
func selectTester(value *string, options []SelectOption[string], opts SelectOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Children(func() {
if Select(c, value, options, opts).Changed() {
*changes++
}
})
}, 400, 400)
}
func TestSelectClickChooses(t *testing.T) {
v, changes := "", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{Label: "Fruit"}, &changes)
if !tt.HasText("Select…") {
t.Fatal("placeholder missing")
}
if err := tt.Click("Select…"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Banana"); err != nil {
t.Fatal(err)
}
if v != "banana" || changes != 1 {
t.Fatalf("value %q changes %d", v, changes)
}
if tt.HasText("Apple") {
t.Fatal("popup stayed open")
}
}
func TestSelectDisabledOptionNotChosen(t *testing.T) {
v, changes := "apple", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
_ = tt.Click("Apple")
_ = tt.Click("Cherry")
if v != "apple" {
t.Fatalf("disabled option chosen: %q", v)
}
// The arrows skip it: Banana, Blueberry, then Date.
tt.Key(0, ui.KeyEscape)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
for range 3 {
tt.Key(0, ui.KeyDown)
}
tt.Key(0, ui.KeyEnter)
if v != "date" {
t.Fatalf("arrows chose %q, want date", v)
}
}
func TestSelectKeyboard(t *testing.T) {
v, changes := "apple", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if !tt.HasText("Banana") {
t.Fatal("Down did not open the popup")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if v != "banana" {
t.Fatalf("value %q", v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEscape)
if tt.HasText("Date") || v != "banana" {
t.Fatalf("Escape: open=%v value %q", tt.HasText("Date"), v)
}
}
func TestSelectTypeahead(t *testing.T) {
v, changes := "apple", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
tt.Key(0, ui.KeyTab)
tt.TypeKey(0, ui.KeyB, "b")
if v != "banana" {
t.Fatalf("b chose %q", v)
}
tt.TypeKey(0, ui.KeyB, "b")
if v != "blueberry" {
t.Fatalf("second b chose %q", v)
}
tt.TypeKey(0, ui.KeyC, "c")
if v != "blueberry" {
t.Fatalf("typed bbc chose %q", v)
}
}
func TestSelectClear(t *testing.T) {
v, changes := "date", 0
tt := selectTester(&v, selectTestOptions, SelectOptions{Clearable: true}, &changes)
if err := tt.Click("Clear"); err != nil {
t.Fatal(err)
}
if v != "" || tt.HasText("Banana") {
t.Fatalf("value %q after clear, popup open %v", v, tt.HasText("Banana"))
}
v = "apple"
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyBackspace)
if v != "" || changes != 2 {
t.Fatalf("Backspace left %q, changes %d", v, changes)
}
}
func TestSelectEmptyAndDisabled(t *testing.T) {
v, changes := "", 0
tt := selectTester(&v, nil, SelectOptions{}, &changes)
_ = tt.Click("Select…")
if !tt.HasText("No options") {
t.Fatal("empty state missing")
}
v2 := "apple"
tt = selectTester(&v2, selectTestOptions, SelectOptions{Disabled: true}, &changes)
_ = tt.Click("Apple")
if tt.HasText("Banana") {
t.Fatal("disabled select opened")
}
}
func TestSelectSnapshot(t *testing.T) {
snapshot(t, "select", 460, 110, func(c *ui.Context) {
a, b, d := "", "banana", "apple"
ui.Row(c).Padding(20).Gap(16).Wrap().Children(func() {
Select(c, &a, selectTestOptions, SelectOptions{})
Select(c, &b, selectTestOptions, SelectOptions{Clearable: true})
Select(c, &d, selectTestOptions, SelectOptions{Disabled: true})
})
})
}
+200
View File
@@ -0,0 +1,200 @@
package mujica
import (
"log/slog"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// SliderMark is a tick on a slider's track, with an optional caption.
type SliderMark struct {
Value float64
Label string
}
// SliderOptions configure a Slider. Min and Max both zero mean 0 to 100.
type SliderOptions struct {
Min, Max float64
// Step snaps the value to Min plus multiples of it; zero is continuous.
Step float64
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 func(float64) string
Disabled bool
// Label names the slider for assistive technology.
Label string
}
const (
sliderThumb float32 = 16
sliderTrack float32 = 4
sliderBubble float32 = 26
sliderMarkH float32 = 18
)
// sliderGeom maps values to x positions across a slider's box.
type sliderGeom struct {
lo, hi, step float64
trackY float32
}
func (g sliderGeom) x(r ui.Rect, v float64) float32 {
f := float32((v - g.lo) / (g.hi - g.lo))
return r.X + sliderThumb/2 + (r.W-sliderThumb)*max(0, min(1, f))
}
// snap rounds v to the nearest step inside the range.
func (g sliderGeom) snap(v float64) float64 {
v = max(g.lo, min(g.hi, v))
if g.step <= 0 {
return v
}
v = g.lo + math.Round((v-g.lo)/g.step)*g.step
if v > g.hi {
v -= g.step
}
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))
}
return g.snap(old - g.step)
}
func sliderSetup(min, max, step float64, marks []SliderMark, showValue bool, c *ui.Context) (sliderGeom, float32) {
if min == 0 && max == 0 {
max = 100
}
if max <= min || step < 0 {
panic("mujica: slider needs Min < Max and Step >= 0")
}
g := sliderGeom{lo: min, hi: max, step: step}
h := Density(c).ControlHeight()
g.trackY = h / 2
if showValue {
g.trackY += sliderBubble
h += sliderBubble
}
for _, m := range marks {
if m.Label != "" {
return g, h + sliderMarkH
}
}
return g, h
}
func sliderFormat(format func(float64) string, step float64) 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
}
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.
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)
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)
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)
}
}
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))
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))
}
})
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) {
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)
p.Stroke(ui.Rect{X: left, Y: y - sliderTrack/2, W: right - left, H: sliderTrack}, k.ControlBorder, 0, 1)
p.Fill(ui.Rect{X: x0, Y: y - sliderTrack/2, W: x1 - x0, H: sliderTrack}, k.AccentText, 0)
for _, m := range marks {
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)
}
}
}
// sliderThumbPaint draws the diamond thumb at (x, y), with its focus ring.
func sliderThumbPaint(p *ui.Painter, k theme.Tokens, x, y float32, hot, focus bool) {
x, y = internal.Snap(p, x), internal.Snap(p, y)
size := sliderThumb
if hot {
size += 2
}
p.FillPath(internal.Diamond(x, y, size+3), k.Surface)
p.FillPath(internal.Diamond(x, y, size), k.AccentText)
if focus {
p.StrokePath(internal.Diamond(x, y, size+3+2*(theme.FocusGap+theme.FocusWidth)), theme.FocusWidth, k.Focus)
}
}
// 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})
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)
}
+109
View File
@@ -0,0 +1,109 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func sliderTester(value *float64, opts SliderOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(0, 0).Width(216).Children(func() {
if Slider(c, value, opts).Changed() {
*changes++
}
})
}, 300, 120)
}
func TestSliderKeysMoveOneStep(t *testing.T) {
v, changes := 40.0, 0
tt := sliderTester(&v, SliderOptions{Step: 10, Label: "Volume"}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if v != 50 {
t.Fatalf("Right: %v, want 50", v)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if v != 30 {
t.Fatalf("Down twice: %v, want 30", v)
}
tt.Key(0, ui.KeyEnd)
if v != 100 {
t.Fatalf("End: %v", v)
}
tt.Key(0, ui.KeyRight)
if v != 100 || changes != 4 {
t.Fatalf("past the end: %v, changes %d", v, changes)
}
tt.Key(0, ui.KeyHome)
if v != 0 {
t.Fatalf("Home: %v", v)
}
}
func TestSliderDragSnaps(t *testing.T) {
v, changes := 0.0, 0
tt := sliderTester(&v, SliderOptions{Min: 0, Max: 10, Step: 2}, &changes)
// The track runs from x=8 to x=208: 200 DIPs for 0 to 10.
tt.Press(8, 18)
tt.Move(8+200*0.55, 18)
if v != 6 {
t.Fatalf("drag to 5.5: %v, want 6", v)
}
tt.Move(400, 18)
tt.Release(400, 18)
if v != 10 {
t.Fatalf("drag past the end: %v", v)
}
}
func TestSliderClampsOutOfRangeValue(t *testing.T) {
v, changes := 250.0, 0
tt := sliderTester(&v, SliderOptions{}, &changes)
tt.Frame()
if v != 100 || changes != 0 {
t.Fatalf("value %v changes %d", v, changes)
}
}
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 {
t.Fatal("no panic")
}
}()
v := 0.0
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
Slider(c, &v, SliderOptions{Min: 5, Max: 1})
}, 100, 100, 1)
}
func TestSliderSnapshot(t *testing.T) {
snapshot(t, "slider", 420, 230, func(c *ui.Context) {
a, b, d := 30.0, 60.0, 50.0
marks := []SliderMark{{0, "0"}, {25, "25"}, {50, "50"}, {75, "75"}, {100, "100"}}
ui.Column(c).Padding(20).Gap(8).Width(380).Children(func() {
Slider(c, &a, SliderOptions{Step: 5})
s := Slider(c, &b, SliderOptions{Step: 25, Marks: marks, ShowValue: true, Format: func(v float64) string { return fmt.Sprintf("%.0f%%", v) }})
s.Focus()
Slider(c, &d, SliderOptions{Disabled: true})
})
})
}
+86
View File
@@ -0,0 +1,86 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// SwitchOptions configure a Switch.
type SwitchOptions struct {
Disabled bool
// Loading shows a pending result: the switch keeps its place and takes no input.
Loading bool
// Description is read out after the label.
Description string
}
const (
switchW float32 = 36
switchH float32 = 20
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.
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)
if opts.Description != "" {
sw.Description(opts.Description)
}
if sw.Changed() {
slog.Debug("mujica: switch changed", "label", label, "on", *on)
}
hover := sw.Hovered()
pos := sw.Animate("on", switchFloat(*on), Motion(c, theme.StateDuration))
sw.Children(func() {
track := ui.Box(c).Size(switchW, switchH).Shrink(0).Radius(theme.CardRadius)
bg, border := k.Surface, k.ControlBorder
thumb := k.ControlBorder
if *on {
bg, border, thumb = k.Accent, k.Accent, k.OnAccent
if hover {
bg, border = k.AccentHover, k.AccentHover
}
} else if hover {
bg, border, thumb = k.SurfaceHover, k.Text, k.Text
}
track.Background(bg).Border(theme.BorderWidth, border)
track.Draw(func(p *ui.Painter, r ui.Rect) {
if *on {
// A short upright bar marks "on" without relying on color.
p.Fill(ui.Rect{X: r.X + 9, Y: r.Y + 6, W: 1.5, H: r.H - 12}, k.OnAccent, 0)
}
})
track.DrawOver(func(p *ui.Painter, r ui.Rect) {
if sw.FocusVisible() {
internal.FocusRing(p, r, theme.CardRadius, k.Focus)
}
})
inset := (switchH - switchK) / 2
x := inset + pos*(switchW-switchK-2*inset)
track.Children(func() {
t := ui.Box(c).Absolute().Left(x).Top(inset).Size(switchK, switchK).Radius(theme.ControlRadius).Background(thumb).Center()
if opts.Loading {
t.Children(func() { Spinner(c, SpinnerOptions{Size: 10, Color: bg}) })
}
})
if label != "" {
ui.Text(c, label).SingleLine()
}
})
return sw
}
func switchFloat(b bool) float32 {
if b {
return 1
}
return 0
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestSwitchToggles(t *testing.T) {
on, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Switch(c, &on, "Wi-Fi", SwitchOptions{}).Changed() {
changes++
}
}, 300, 120)
if err := tt.Click("Wi-Fi"); err != nil {
t.Fatal(err)
}
if !on || changes != 1 {
t.Fatalf("on=%v changes=%d after click", on, changes)
}
tt.Key(0, ui.KeySpace)
if on || changes != 2 {
t.Fatalf("on=%v changes=%d after Space", on, changes)
}
}
func TestSwitchKeyboardFocus(t *testing.T) {
on := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Switch(c, &on, "Sync", SwitchOptions{})
}, 300, 120)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Sync") {
t.Fatal("Tab did not focus the switch")
}
tt.Key(0, ui.KeySpace)
if !on {
t.Fatal("Space did not turn it on")
}
}
func TestSwitchBlockedStates(t *testing.T) {
for _, opts := range []SwitchOptions{{Disabled: true}, {Loading: true}} {
on := true
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Switch(c, &on, "Backup", opts)
}, 300, 120)
_ = tt.Click("Backup")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !on {
t.Fatalf("%+v: value changed", opts)
}
if !tt.HasText("Backup") {
t.Fatalf("%+v: label lost", opts)
}
}
}
func TestSwitchSnapshot(t *testing.T) {
snapshot(t, "switch", 480, 120, func(c *ui.Context) {
on, off := true, false
ui.Column(c).Padding(20).Gap(12).Children(func() {
ui.Row(c).Gap(24).Children(func() {
Switch(c, &on, "On", SwitchOptions{})
Switch(c, &off, "Off", SwitchOptions{})
Switch(c, &on, "Saving", SwitchOptions{Loading: true})
})
ui.Row(c).Gap(24).Children(func() {
Switch(c, &on, "Disabled on", SwitchOptions{Disabled: true})
Switch(c, &off, "Disabled off", SwitchOptions{Disabled: true})
})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+72
View File
@@ -0,0 +1,72 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ToggleOptions configure a Toggle.
type ToggleOptions struct {
// Icon goes before the label.
Icon *ui.SVG
Disabled bool
// Label names an icon-only toggle for assistive technology.
Label string
}
// Toggle is a button that stays pressed while *on. Click or Space flips it; Changed reports it.
func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Element {
if label == "" && opts.Label == "" {
panic("mujica: Toggle needs a label or Label")
}
b := ui.ToggleBase(c, on).Disabled(opts.Disabled)
if b.Changed() {
slog.Debug("mujica: toggle changed", "label", label+opts.Label, "on", *on)
}
toggleLook(c, b, *on, label, opts.Icon)
if opts.Label != "" {
b.Label(opts.Label)
}
return b
}
// toggleLook styles a pressed-or-not button and builds its content.
func toggleLook(c *ui.Context, b *ui.Element, on bool, label string, icon *ui.SVG) {
k := Tokens(c)
d := Density(c)
b.MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).
Radius(theme.ControlRadius)
internal.Ring(b, k.Focus, theme.ControlRadius)
bg, fg, border := k.Surface, k.Text, k.ControlBorder
switch {
case on:
bg, fg, border = k.Selection, k.AccentText, k.AccentText
case b.Pressed():
bg = k.Selection
case b.Hovered():
bg = k.SurfaceHover
}
b.Background(bg).TextColor(fg).Border(theme.BorderWidth, border)
if on {
toggleMark(b, k)
}
b.Children(func() {
if icon != nil {
ui.Icon(c, icon).Size(16, 16)
}
if label != "" {
ui.Text(c, label).SingleLine()
}
})
}
// toggleMark draws a bar under the content: the pressed state beside the color.
func toggleMark(b *ui.Element, k theme.Tokens) {
b.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X + r.W*0.25, Y: r.Y + r.H - 4, W: r.W * 0.5, H: 2}, k.AccentText, 1)
})
}
+127
View File
@@ -0,0 +1,127 @@
package mujica
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ChoiceResult is the element of a choice control and whether its value
// changed in this frame.
type ChoiceResult struct {
*ui.Element
changed bool
}
// Changed reports whether the user changed the value in this frame.
func (r ChoiceResult) Changed() bool { return r.changed }
// ToggleItem is one button of a ToggleGroup.
type ToggleItem[T comparable] struct {
Value T
Label string
Icon *ui.SVG
// Name names an icon-only item for assistive technology.
Name string
Disabled bool
}
// ToggleGroupOptions configure a ToggleGroup.
type ToggleGroupOptions struct {
// Multiple lets several items be pressed at once.
Multiple bool
// AllowEmpty lets the user release the last pressed item.
AllowEmpty bool
Vertical bool
Disabled bool
// Label names the group for assistive technology.
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.
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")
}
k := Tokens(c)
g := ui.Row(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Stretch).Radius(theme.ControlRadius).
Border(theme.BorderWidth, k.ControlBorder).Background(k.Surface).Disabled(opts.Disabled).Role(ui.RoleGroup)
if opts.Vertical {
g.Column().FocusGroup(ui.Vertical)
} else {
g.FocusGroup(ui.Horizontal)
}
if opts.Label != "" {
g.Label(opts.Label)
}
g.Dividers(theme.BorderWidth, k.ControlBorder)
changed := false
g.Children(func() {
for _, it := range items {
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) {
changed = true
on = pressed
}
toggleGroupLook(c, k, b, on, it)
}
})
if changed {
slog.Debug("mujica: toggle group changed", "label", opts.Label, "value", *value)
}
return ChoiceResult{g, changed}
}
// toggleGroupApply presses or releases v by the group's rules and reports a change.
func toggleGroupApply[T comparable](value *[]T, v T, pressed bool, opts ToggleGroupOptions) bool {
switch {
case pressed && opts.Multiple:
*value = append(*value, v)
case pressed:
*value = []T{v}
case len(*value) == 1 && !opts.AllowEmpty:
return false
default:
i := slices.Index(*value, v)
*value = slices.Delete(slices.Clone(*value), i, i+1)
}
return true
}
func toggleGroupLook[T comparable](c *ui.Context, k theme.Tokens, b *ui.Element, on bool, it ToggleItem[T]) {
d := Density(c)
b.MinHeight(d.ControlHeight()-2).MinWidth(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2)
internal.Ring(b, k.Focus, theme.ControlRadius)
bg, fg := ui.Transparent, k.Text
switch {
case on:
bg, fg = k.Selection, k.AccentText
case b.Pressed():
bg = k.Selection
case b.Hovered():
bg = k.SurfaceHover
}
b.Background(bg).TextColor(fg)
if on {
toggleMark(b, k)
}
if it.Name != "" {
b.Label(it.Name)
}
b.Children(func() {
if it.Icon != nil {
ui.Icon(c, it.Icon).Size(16, 16)
}
if it.Label != "" {
ui.Text(c, it.Label).SingleLine()
}
})
}
+110
View File
@@ -0,0 +1,110 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
var toggleGroupTestItems = []ToggleItem[string]{
{Value: "left", Label: "Left"},
{Value: "center", Label: "Center"},
{Value: "right", Label: "Right"},
{Value: "justify", Label: "Justify", Disabled: true},
}
func toggleGroupTester(value *[]string, opts ToggleGroupOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ToggleGroup(c, value, toggleGroupTestItems, opts).Changed() {
*changes++
}
}, 500, 120)
}
func TestToggleGroupSingle(t *testing.T) {
value, changes := []string{"left"}, 0
tt := toggleGroupTester(&value, ToggleGroupOptions{}, &changes)
if err := tt.Click("Center"); err != nil {
t.Fatal(err)
}
if !slices.Equal(value, []string{"center"}) || changes != 1 {
t.Fatalf("value %v changes %d", value, changes)
}
// The pressed item stays without AllowEmpty.
_ = tt.Click("Center")
if !slices.Equal(value, []string{"center"}) || changes != 1 {
t.Fatalf("released without AllowEmpty: %v", value)
}
}
func TestToggleGroupAllowEmpty(t *testing.T) {
value, changes := []string{"left"}, 0
tt := toggleGroupTester(&value, ToggleGroupOptions{AllowEmpty: true}, &changes)
_ = tt.Click("Left")
if len(value) != 0 || changes != 1 {
t.Fatalf("value %v changes %d", value, changes)
}
}
func TestToggleGroupMultiple(t *testing.T) {
value, changes := []string{"left"}, 0
tt := toggleGroupTester(&value, ToggleGroupOptions{Multiple: true}, &changes)
_ = tt.Click("Right")
if !slices.Equal(value, []string{"left", "right"}) {
t.Fatalf("value %v", value)
}
_ = tt.Click("Left")
_ = tt.Click("Right")
if !slices.Equal(value, []string{"right"}) || changes != 2 {
t.Fatalf("last item released: %v, changes %d", value, changes)
}
_ = tt.Click("Justify")
if slices.Contains(value, "justify") {
t.Fatal("disabled item pressed")
}
}
func TestToggleGroupKeyboard(t *testing.T) {
value, changes := []string{"left"}, 0
tt := toggleGroupTester(&value, ToggleGroupOptions{}, &changes)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Left") {
t.Fatal("Tab did not enter at the pressed item")
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
if !slices.Equal(value, []string{"right"}) {
t.Fatalf("value %v after arrows and Space", value)
}
// The disabled item is skipped: Right wraps to the first.
tt.Key(0, ui.KeyRight)
if !tt.Focused("Left") {
t.Fatal("arrow did not skip the disabled item")
}
}
func TestToggleGroupRejectsTwoValuesInSingleMode(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
value := []string{"left", "right"}
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
ToggleGroup(c, &value, toggleGroupTestItems, ToggleGroupOptions{})
}, 300, 100, 1)
}
func TestToggleGroupSnapshot(t *testing.T) {
snapshot(t, "toggle_group", 520, 150, func(c *ui.Context) {
single, multi := []string{"center"}, []string{"left", "right"}
ui.Column(c).Padding(20).Gap(16).Children(func() {
ToggleGroup(c, &single, toggleGroupTestItems, ToggleGroupOptions{Label: "Align"})
ToggleGroup(c, &multi, toggleGroupTestItems, ToggleGroupOptions{Multiple: true})
})
})
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
func TestTogglePressAndRelease(t *testing.T) {
on, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Toggle(c, &on, "Bold", ToggleOptions{}).Changed() {
changes++
}
}, 300, 120)
if err := tt.Click("Bold"); err != nil {
t.Fatal(err)
}
if !on {
t.Fatal("click did not press it")
}
tt.Key(0, ui.KeySpace)
if on || changes != 2 {
t.Fatalf("on=%v changes=%d", on, changes)
}
}
func TestToggleIconOnlyNamed(t *testing.T) {
on := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Toggle(c, &on, "", ToggleOptions{Icon: icons.Must("bold"), Label: "Bold text"})
}, 300, 120)
if err := tt.Click("Bold text"); err != nil {
t.Fatal(err)
}
if !on {
t.Fatal("icon toggle not pressed")
}
}
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 {
t.Fatal("no panic for a nameless toggle")
}
}()
on := false
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
Toggle(c, &on, "", ToggleOptions{})
}, 100, 100, 1)
}
func TestToggleSnapshot(t *testing.T) {
snapshot(t, "toggle", 480, 90, func(c *ui.Context) {
on, off := true, false
ui.Row(c).Padding(20).Gap(12).Children(func() {
Toggle(c, &on, "Bold", ToggleOptions{Icon: icons.Must("bold")})
Toggle(c, &off, "Italic", ToggleOptions{Icon: icons.Must("italic")})
Toggle(c, &on, "", ToggleOptions{Icon: icons.Must("underline"), Label: "Underline"})
Toggle(c, &on, "Locked", ToggleOptions{Disabled: true})
})
})
}
+261
View File
@@ -0,0 +1,261 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// TreeSelectNode is one node of a TreeSelect. IDs must be unique in the tree.
type TreeSelectNode struct {
ID, Label string
Children []TreeSelectNode
Disabled bool
}
// TreeSelectRule says how checking a parent affects its children in multiple mode.
type TreeSelectRule int
const (
// TreeSelectCascade checks a parent's enabled leaves with it; the value holds leaf IDs.
TreeSelectCascade TreeSelectRule = iota
// TreeSelectIndependent checks each node on its own; the value holds any IDs.
TreeSelectIndependent
)
// TreeSelectOptions configure a TreeSelect.
type TreeSelectOptions struct {
Multiple bool
Rule TreeSelectRule
// Placeholder shows while nothing is chosen; empty uses "Select…".
Placeholder string
// MaxTags is how many chosen nodes show as tags before "+N"; zero is 3.
MaxTags int
Disabled bool
// Label names the field for assistive technology.
Label string
}
type treeSelectState struct {
open bool
query string
expanded map[string]bool
// focus is the ID of the row to focus as it is built; "\x00" is the first row.
focus string
}
type treeSelectKey struct{}
// treeSelectRow is a visible row: its node, depth and parent.
type treeSelectRow struct {
node *TreeSelectNode
depth int
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.
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")
}
k := Tokens(c)
before := slices.Clone(*selected)
tr := ui.ButtonBase(c).Disabled(opts.Disabled).Role(ui.RolePopUpButton)
if opts.Label != "" {
tr.Label(opts.Label)
}
st := *ui.Local(tr, treeSelectKey{}, func() *treeSelectState { return &treeSelectState{expanded: map[string]bool{}} })
if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) {
st.open, st.query = !st.open, ""
}
labels := map[string]string{}
treeSelectWalk(nodes, func(n *TreeSelectNode) { labels[n.ID] = n.Label })
selectField(c, tr, st.open, func() {
switch {
case len(*selected) == 0:
ui.Text(c, selectOr(opts.Placeholder, msg(c, "select.placeholder"))).SingleLine().TextColor(k.TextMuted).Grow(1)
case !opts.Multiple:
ui.Text(c, labels[(*selected)[0]]).SingleLine().Grow(1).Shrink(1)
default:
options := make([]SelectOption[string], 0, len(labels))
for _, id := range *selected {
options = append(options, SelectOption[string]{Value: id, Label: labels[id]})
}
multiSelectTags(c, selected, options, MultiSelectOptions{MaxTags: opts.MaxTags, Disabled: opts.Disabled})
}
})
ui.PopoverBase(c, tr, &st.open, func(panel *ui.Element) {
selectPanel(c, panel)
panel.MinWidth(max(tr.Bounds().W, 260))
ui.Column(c).Gap(4).Children(func() {
if selectSearch(c, &st.query, msg(c, "tree_select.search")).Shortcut(0, ui.KeyDown) {
st.focus = "\x00"
}
rows := treeSelectVisible(nodes, st)
if len(rows) == 0 {
selectNote(c, msg(c, "tree_select.no_results"))
return
}
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)
}
})
})
})
})
changed := !slices.Equal(before, *selected)
if changed {
slog.Debug("mujica: tree select changed", "label", opts.Label, "selected", *selected)
}
return ChoiceResult{tr, changed}
}
func treeSelectWalk(nodes []TreeSelectNode, fn func(n *TreeSelectNode)) {
for i := range nodes {
fn(&nodes[i])
treeSelectWalk(nodes[i].Children, fn)
}
}
// treeSelectVisible lists the rows to show: expanded ones, or while searching the matches and their ancestors.
func treeSelectVisible(nodes []TreeSelectNode, st *treeSelectState) []treeSelectRow {
q := strings.ToLower(strings.TrimSpace(st.query))
var rows []treeSelectRow
var visit func(ns []TreeSelectNode, depth int, parent string) bool
visit = func(ns []TreeSelectNode, depth int, parent string) bool {
found := false
for i := range ns {
n := &ns[i]
at := len(rows)
rows = append(rows, treeSelectRow{n, depth, parent})
self := q == "" || strings.Contains(strings.ToLower(n.Label), q)
below := false
if q != "" || st.expanded[n.ID] {
below = visit(n.Children, depth+1, n.ID)
}
if q != "" && !self && !below {
rows = rows[:at]
continue
}
found = true
}
return found
}
visit(nodes, 0, "")
return rows
}
// treeSelectCheck returns a node's check state under the rule.
func treeSelectCheck(n *TreeSelectNode, selected []string, opts TreeSelectOptions) internal.CheckState {
if opts.Rule == TreeSelectIndependent || len(n.Children) == 0 {
if slices.Contains(selected, n.ID) {
return internal.Checked
}
return internal.Unchecked
}
on, all := 0, 0
treeSelectWalk(n.Children, func(x *TreeSelectNode) {
if len(x.Children) == 0 && !x.Disabled {
all++
if slices.Contains(selected, x.ID) {
on++
}
}
})
switch {
case on == 0:
return internal.Unchecked
case on == all:
return internal.Checked
}
return internal.Mixed
}
// treeSelectToggle checks or unchecks n by the rule.
func treeSelectToggle(n *TreeSelectNode, selected []string, on bool, opts TreeSelectOptions) []string {
ids := []string{n.ID}
if opts.Rule == TreeSelectCascade && len(n.Children) > 0 {
ids = nil
treeSelectWalk(n.Children, func(x *TreeSelectNode) {
if len(x.Children) == 0 && !x.Disabled {
ids = append(ids, x.ID)
}
})
}
for _, id := range ids {
selected = multiSelectSet(selected, id, false)
if on {
selected = append(selected, id)
}
}
return selected
}
func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r treeSelectRow, first 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)
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() {
st.expanded[n.ID] = !st.expanded[n.ID]
}
if arrow.Hovered() {
arrow.Background(k.SurfaceHover)
}
arrow.Draw(func(p *ui.Painter, rc ui.Rect) { internal.Chevron(p, rc, !open, k.TextMuted) })
}
state := treeSelectCheck(n, *selected, opts)
var b *ui.Element
if opts.Multiple {
on := state == internal.Checked
b = multiSelectCheck(c, k, &on, state, n.Label, n.Disabled)
if b.Changed() {
*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)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Clicked() {
*selected, st.open = []string{n.ID}, false
}
switch {
case state == internal.Checked:
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() })
}
if st.focus == n.ID || first && st.focus == "\x00" {
b.Focus()
st.focus = ""
}
right, left := b.Shortcut(0, ui.KeyRight), b.Shortcut(0, ui.KeyLeft)
switch {
case right && branch && !open:
st.expanded[n.ID] = true
case right && branch:
st.focus = n.Children[0].ID
case left && open && st.expanded[n.ID]:
st.expanded[n.ID] = false
case left && r.parent != "":
st.focus = r.parent
}
})
}
+142
View File
@@ -0,0 +1,142 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
var treeSelectTestNodes = []TreeSelectNode{
{ID: "court", Label: "Court", Children: []TreeSelectNode{
{ID: "king", Label: "King"},
{ID: "queen", Label: "Queen"},
{ID: "jester", Label: "Jester", Disabled: true},
}},
{ID: "church", Label: "Church", Children: []TreeSelectNode{
{ID: "bishop", Label: "Bishop"},
}},
{ID: "guard", Label: "Guard"},
}
func treeSelectTester(sel *[]string, opts TreeSelectOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Children(func() {
if TreeSelect(c, sel, treeSelectTestNodes, opts).Changed() {
*changes++
}
})
}, 500, 500)
}
func TestTreeSelectSingle(t *testing.T) {
var sel []string
changes := 0
tt := treeSelectTester(&sel, TreeSelectOptions{}, &changes)
_ = tt.Click("Select…")
if tt.HasText("King") {
t.Fatal("collapsed children shown")
}
if err := tt.Click("Expand Court"); err != nil {
t.Fatal(err)
}
_ = tt.Click("Queen")
if !slices.Equal(sel, []string{"queen"}) || changes != 1 || tt.HasText("King") {
t.Fatalf("sel %v changes %d open %v", sel, changes, tt.HasText("King"))
}
}
func TestTreeSelectCascade(t *testing.T) {
var sel []string
changes := 0
tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true}, &changes)
_ = tt.Click("Select…")
_ = tt.Click("Expand Court")
_ = tt.Click("Court")
// The disabled Jester stays out.
if !slices.Equal(sel, []string{"king", "queen"}) {
t.Fatalf("cascade checked %v", sel)
}
// The chosen labels also show as tags: reach King with the keys.
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
if !slices.Equal(sel, []string{"queen"}) {
t.Fatalf("after unchecking King: %v", sel)
}
if got := treeSelectCheck(&treeSelectTestNodes[0], sel, TreeSelectOptions{Multiple: true}); got != 2 {
t.Fatalf("Court state %v, want mixed", got)
}
_ = tt.Click("Court")
if !slices.Equal(sel, []string{"king", "queen"}) || changes != 3 {
t.Fatalf("mixed parent click: %v changes %d", sel, changes)
}
}
func TestTreeSelectIndependent(t *testing.T) {
var sel []string
changes := 0
tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true, Rule: TreeSelectIndependent}, &changes)
_ = tt.Click("Select…")
_ = tt.Click("Court")
if !slices.Equal(sel, []string{"court"}) {
t.Fatalf("independent %v", sel)
}
}
func TestTreeSelectSearch(t *testing.T) {
var sel []string
changes := 0
tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true}, &changes)
_ = tt.Click("Select…")
tt.Type("bish")
if !tt.HasText("Church") || !tt.HasText("Bishop") || tt.HasText("Court") || tt.HasText("Guard") {
t.Fatalf("search shows %v", tt.Texts())
}
tt.Type("zz")
if !tt.HasText("No results") {
t.Fatal("no-results missing")
}
}
func TestTreeSelectKeyboard(t *testing.T) {
var sel []string
changes := 0
tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true, Label: "Roles"}, &changes)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
if !tt.Focused("Court") {
t.Fatal("Down from search did not reach the tree")
}
tt.Key(0, ui.KeyRight)
if !tt.HasText("King") {
t.Fatal("Right did not expand")
}
tt.Key(0, ui.KeyRight)
if !tt.Focused("King") {
t.Fatal("second Right did not enter the children")
}
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyLeft)
if !tt.Focused("Court") {
t.Fatal("Left did not go to the parent")
}
tt.Key(0, ui.KeyLeft)
if tt.HasText("Queen") {
t.Fatal("Left did not collapse")
}
if !slices.Equal(sel, []string{"king"}) {
t.Fatalf("sel %v", sel)
}
}
func TestTreeSelectSnapshot(t *testing.T) {
snapshot(t, "tree_select", 460, 110, func(c *ui.Context) {
single, multi := []string{"bishop"}, []string{"king", "queen", "bishop", "guard"}
ui.Row(c).Padding(20).Gap(16).Children(func() {
TreeSelect(c, &single, treeSelectTestNodes, TreeSelectOptions{})
TreeSelect(c, &multi, treeSelectTestNodes, TreeSelectOptions{Multiple: true, MaxTags: 2})
})
})
}