Choices: components 031-040 (wip)
This commit is contained in:
54 files changed
+4253
-1
No files matched your search
+234
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user