Choices: components 031-040 (wip)
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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` 由调用方根据自己的异步任务设置。
|
||||
@@ -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 报告。
|
||||
@@ -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` 在帧之间增删时焦点可能落到相邻项。
|
||||
@@ -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),禁用时组件改为让它操作副本,保证值不变。
|
||||
@@ -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”),屏幕阅读器读名称而非数值属性。
|
||||
@@ -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 清空。
|
||||
@@ -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 移除。
|
||||
- 列表未虚拟化,适合数百项以内。
|
||||
@@ -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 选择首个结果。
|
||||
@@ -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` 回到界面线程更新数据。
|
||||
- 列表未虚拟化,每列适合数百项以内。
|
||||
@@ -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 仅内部设置),屏幕阅读器读不到这两个状态;展开箭头带“展开/收起 + 节点名”的名称。
|
||||
- 列表未虚拟化,适合数千节点以内。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||