235 lines
7.0 KiB
Go
235 lines
7.0 KiB
Go
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.Mark(p, r, "chevron-right", k.TextMuted)
|
|
})
|
|
}
|
|
})
|
|
}
|