Files
MujicaUI/project/assignee_picker.go
T

239 lines
7.8 KiB
Go

package project
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AssigneePickerOptions configure an AssigneePicker.
type AssigneePickerOptions struct {
// Max limits selections; zero allows any number.
Max int
Disabled bool
Label string
// Version changes when members or selection change in place.
Version int
}
type assigneeState struct {
first *ProjectMember
n, version int
ready, open bool
query, filtered string
options, shown []input.SelectOption[string]
members map[string]ProjectMember
selected map[string]bool
selectedFirst *string
selectedN, count int
list ui.ListState
focus string
}
// AssigneePicker searches and selects members, showing their avatars.
func AssigneePicker(c *ui.Context, ids *[]string, members []ProjectMember, opts AssigneePickerOptions) core.ChoiceResult {
if opts.Max < 0 {
panic("mujica: AssigneePicker Max must be non-negative")
}
d, k := core.Density(c), core.Tokens(c)
label := opts.Label
if label == "" {
label = core.Msg(c, "assignee_picker.label")
}
row := ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Disabled(opts.Disabled)
st := ui.Local(row, "assignees", func() assigneeState { return assigneeState{} })
var first *ProjectMember
var selectedFirst *string
if len(members) > 0 {
first = &members[0]
}
if len(*ids) > 0 {
selectedFirst = &(*ids)[0]
}
dataChanged := !st.ready || st.first != first || st.n != len(members) || st.version != opts.Version
if dataChanged {
st.first, st.n, st.version, st.ready = first, len(members), opts.Version, true
st.members = make(map[string]ProjectMember, len(members))
st.options = make([]input.SelectOption[string], len(members))
for i, m := range members {
if _, exists := st.members[m.ID]; exists || m.ID == "" {
panic("mujica: AssigneePicker needs unique non-empty IDs")
}
st.members[m.ID] = m
st.options[i] = input.SelectOption[string]{Value: m.ID, Label: m.Name}
}
st.shown = nil
}
before := *ids
var trigger *ui.Element
row.Children(func() {
if len(*ids) > 0 {
items := make([]display.AvatarItem, min(3, len(*ids)))
for i := range items {
items[i] = display.AvatarItem{Name: st.members[(*ids)[i]].Name}
}
display.AvatarGroup(c, items, display.AvatarGroupOptions{Max: 3, Size: 28})
}
trigger = ui.ButtonBase(c).Disabled(row.IsDisabled()).Role(ui.RolePopUpButton).Label(label)
if trigger.IsDisabled() {
st.open = false
}
if !trigger.IsDisabled() && (trigger.Clicked() || !st.open && trigger.Shortcut(0, ui.KeyDown)) {
st.open, st.query = !st.open, ""
}
if len(*ids) > 0 && !trigger.IsDisabled() && trigger.Shortcut(0, ui.KeyBackspace) {
*ids = slices.Clone((*ids)[:len(*ids)-1])
}
input.SelectField(c, trigger, st.open, func() {
options := make([]input.SelectOption[string], min(2, len(*ids)))
for i := range options {
options[i] = input.SelectOption[string]{Value: (*ids)[i], Label: st.members[(*ids)[i]].Name}
}
input.MultiSelectTags(c, ids, options, input.MultiSelectOptions{MaxTags: 2, Disabled: trigger.IsDisabled(), Placeholder: core.Msg(c, "assignee_picker.placeholder")})
})
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
input.SelectPanel(c, panel)
ww, wh := c.Size()
panel.MinWidth(min(ww-8, trigger.Bounds().W)).MaxWidth(max(1, ww-8)).MaxHeight(max(1, wh-8))
ui.Scroll(c).MaxHeight(max(1, wh-24)).Children(func() {
search := input.SelectSearch(c, &st.query, core.Msg(c, "multi_select.search"))
q := strings.ToLower(strings.TrimSpace(st.query))
if st.shown == nil || st.filtered != q {
st.shown = make([]input.SelectOption[string], 0)
for _, o := range st.options {
if strings.Contains(strings.ToLower(o.Label), q) {
st.shown = append(st.shown, o)
}
}
st.filtered = q
st.list.ScrollTo(0, ui.Start)
dataChanged = true
}
if dataChanged || st.selectedFirst != selectedFirst || st.selectedN != len(*ids) || st.selected == nil {
st.selected = make(map[string]bool, len(*ids))
for _, id := range *ids {
if _, ok := st.members[id]; !ok {
panic("mujica: AssigneePicker selection contains an unknown ID")
}
if st.selected[id] {
panic("mujica: AssigneePicker selection contains duplicate IDs")
}
st.selected[id] = true
}
st.selectedFirst, st.selectedN, st.count = selectedFirst, len(*ids), 0
for _, o := range st.shown {
if st.selected[o.Value] {
st.count++
}
}
}
if len(st.shown) == 0 {
input.SelectNote(c, core.Msg(c, "multi_select.no_results"))
return
}
on, full := st.count == len(st.shown), opts.Max > 0 && len(*ids) >= opts.Max
mark := internal.Unchecked
if on {
mark = internal.Checked
} else if st.count > 0 {
mark = internal.Mixed
}
var all *ui.Element
ui.Row(c).MinHeight(d.ControlHeight()).Children(func() {
all = input.MultiSelectCheck(c, k, &on, mark, core.Msg(c, "multi_select.all"), full && st.count == 0)
})
if search.Shortcut(0, ui.KeyDown) {
all.Focus()
}
if all.Changed() && !all.IsDisabled() {
*ids = input.MultiSelectAll(*ids, st.shown, on, opts.Max)
}
if all.Shortcut(0, ui.KeyDown) && !all.IsDisabled() {
for i, o := range st.shown {
if full && !st.selected[o.Value] {
continue
}
st.focus = o.Value
st.list.ScrollIntoView(i)
c.Invalidate()
break
}
}
st.list.Key = func(i int) any { return st.shown[i].Value }
ui.List(c, &st.list, len(st.shown), func(i int) {
o := st.shown[i]
selected := st.selected[o.Value]
state := internal.Unchecked
if selected {
state = internal.Checked
}
var cb *ui.Element
ui.Row(c).MinHeight(d.ControlHeight()).Children(func() { cb = input.MultiSelectCheck(c, k, &selected, state, o.Label, full && !selected) })
if st.focus == o.Value && !cb.IsDisabled() {
cb.Focus()
st.focus = ""
}
if cb.Changed() && !cb.IsDisabled() {
*ids = input.MultiSelectSet(*ids, o.Value, selected)
}
if cb.IsDisabled() {
return
}
to := i
switch {
case cb.Shortcut(0, ui.KeyDown):
to = assigneeNext(st, i, 1, full)
case cb.Shortcut(0, ui.KeyUp):
to = assigneeNext(st, i, -1, full)
case cb.Shortcut(0, ui.KeyHome):
to = assigneeNext(st, -1, 1, full)
case cb.Shortcut(0, ui.KeyEnd):
to = assigneeNext(st, len(st.shown), -1, full)
}
if to != i {
st.focus = st.shown[to].Value
st.list.ScrollIntoView(to)
c.Invalidate()
}
}).Height(min(240, min(float32(len(st.shown))*d.ControlHeight(), max(d.ControlHeight(), wh-140)))).Padding(4)
if opts.Max > 0 {
ui.Text(c, fmt.Sprintf(core.Msg(c, "multi_select.limit"), len(*ids), opts.Max)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
}
})
})
})
changed := !slices.Equal(before, *ids)
if dataChanged && !st.open {
for _, id := range *ids {
if _, ok := st.members[id]; !ok {
panic("mujica: AssigneePicker selection contains an unknown ID")
}
}
}
if changed {
st.selected = nil
slog.Debug("mujica: assignee_picker change", "count", len(*ids))
c.Invalidate()
}
return core.NewChoiceResult(row, changed)
}
// assigneeNext skips choices disabled by the selection limit.
func assigneeNext(st *assigneeState, from, direction int, full bool) int {
for i := from + direction; i >= 0 && i < len(st.shown); i += direction {
if !full || st.selected[st.shown[i].Value] {
return i
}
}
return from
}