239 lines
7.8 KiB
Go
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
|
|
}
|