package input import ( "fmt" "log/slog" "slices" "strings" "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme" ) // TransferItem is one entry of a Transfer; ID must be unique and stable. type TransferItem struct { ID string Label string Disabled bool } // TransferOptions configure a Transfer. type TransferOptions struct { // SourceTitle and TargetTitle replace the built-in "Available" and "Chosen". SourceTitle, TargetTitle string // Height of each list in DIPs; 240 when zero. Height float32 Disabled bool } type transferState struct { checked map[string]bool query [2]string } // Transfer moves items between a source list and *target, the chosen IDs in order. func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) core.ChoiceResult { byID := make(map[string]TransferItem, len(items)) for _, it := range items { if _, dup := byID[it.ID]; dup || it.ID == "" { panic(fmt.Sprintf("mujica: Transfer item ID %q is empty or repeated", it.ID)) } byID[it.ID] = it } for _, id := range *target { if _, ok := byID[id]; !ok { panic(fmt.Sprintf("mujica: Transfer target ID %q is not an item", id)) } } d := core.Density(c) parts := core.ChoiceResult{} row := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Disabled(opts.Disabled) parts.Element = row st := ui.Local(row, "transfer", func() transferState { return transferState{checked: map[string]bool{}} }) for id := range st.checked { if it, ok := byID[id]; !ok || it.Disabled { delete(st.checked, id) } } var source, chosen []TransferItem for _, it := range items { if !slices.Contains(*target, it.ID) { source = append(source, it) } } for _, id := range *target { chosen = append(chosen, byID[id]) } move := func(from []TransferItem, right bool) { var ids []string for _, it := range from { if st.checked[it.ID] && !it.Disabled { ids = append(ids, it.ID) delete(st.checked, it.ID) } } if len(ids) == 0 { return } if right { *target = append(slices.Clone(*target), ids...) } else { *target = slices.DeleteFunc(slices.Clone(*target), func(id string) bool { return slices.Contains(ids, id) }) } parts = core.NewChoiceResult(parts.Element, true) slog.Debug("mujica: transfer moved", "count", len(ids), "right", right) } height := opts.Height if height == 0 { height = 240 } srcTitle, dstTitle := opts.SourceTitle, opts.TargetTitle if srcTitle == "" { srcTitle = core.Msg(c, "transfer.source") } if dstTitle == "" { dstTitle = core.Msg(c, "transfer.target") } row.Children(func() { transferPanel(c, st, 0, srcTitle, source, height) ui.Column(c).Gap(d.Unit() * 2).Children(func() { right := transferCount(st, source) > 0 left := transferCount(st, chosen) > 0 if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-right"), Label: fmt.Sprintf(core.Msg(c, "transfer.to"), dstTitle), Disabled: !right}).Clicked() { move(source, true) } if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-left"), Label: fmt.Sprintf(core.Msg(c, "transfer.to"), srcTitle), Disabled: !left}).Clicked() { move(chosen, false) } }) transferPanel(c, st, 1, dstTitle, chosen, height) }) return parts } // transferCount counts the checked items that can move. func transferCount(st *transferState, items []TransferItem) int { n := 0 for _, it := range items { if st.checked[it.ID] && !it.Disabled { n++ } } return n } // transferPanel is one side: header with select-all, search, and the list. func transferPanel(c *ui.Context, st *transferState, side int, title string, items []TransferItem, height float32) { k := core.Tokens(c) d := core.Density(c) q := strings.ToLower(strings.TrimSpace(st.query[side])) var shown []TransferItem enabled := 0 for _, it := range items { if q == "" || strings.Contains(strings.ToLower(it.Label), q) { shown = append(shown, it) if !it.Disabled { enabled++ } } } checked := transferCount(st, shown) ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() { head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*3-(theme.MinHitSize-16)/2). BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border) head.Children(func() { all := checked > 0 && checked == enabled box := ui.CheckboxBase(c, &all).Size(theme.MinHitSize, theme.MinHitSize).Justify(ui.Center).Label(fmt.Sprintf(core.Msg(c, "transfer.all"), title)).Disabled(enabled == 0) transferMark(c, box, all, checked > 0 && !all) box.Description(transferPartial(c, checked, enabled)) if box.Changed() { for _, it := range shown { if !it.Disabled { st.checked[it.ID] = all } } } ui.Text(c, title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Grow(1) ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum") }) ui.Box(c).Padding(d.Unit() * 2).Children(func() { InputGroup(c, &st.query[side], InputGroupOptions{Placeholder: core.Msg(c, "transfer.search"), Label: fmt.Sprintf(core.Msg(c, "transfer.search_in"), title)}) }) list := ui.Scroll(c).Height(height).Padding(0, d.Unit(), d.Unit(), d.Unit()).FocusGroup(ui.Vertical).Role(ui.RoleList).Label(title) list.Children(func() { if len(shown) == 0 { empty := core.Msg(c, "transfer.empty") if q != "" { empty = core.Msg(c, "transfer.no_match") } ui.Text(c, empty).TextColor(k.TextMuted).Padding(d.Unit() * 3).TextAlign(ui.Center).FillWidth() return } for _, it := range shown { on := st.checked[it.ID] ui.Box(c).Key(it.ID).Children(func() { cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(max(d.ControlHeight()-d.Unit(), theme.MinHitSize)).Padding(0, d.Unit()*2). Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled) if cb.Changed() { st.checked[it.ID] = on } if on { cb.Background(k.Selection) } else if cb.Hovered() { cb.Background(k.SurfaceHover) } transferMark(c, cb, on, false) cb.Children(func() { ui.Text(c, it.Label).SingleLine().Ellipsis("…").MinWidth(0) }) }) } }) }) } // transferPartial describes a select-all box that is neither clear nor full. func transferPartial(c *ui.Context, checked, enabled int) string { if checked == 0 || checked == enabled { return "" } return fmt.Sprintf(core.Msg(c, "transfer.partial"), checked, enabled) } // transferMark draws a check box's square inside box, which takes the focus ring. func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) { k := core.Tokens(c) internal.Ring(box, k.Focus, theme.ControlRadius) box.Children(func() { sq := ui.Box(c).Size(16, 16).Shrink(0).Center().Radius(theme.ControlRadius).Role(ui.RoleNone) if on || mixed { sq.Background(k.Accent).TextColor(k.OnAccent) } else { sq.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) } sq.Children(func() { switch { case on: ui.Icon(c, icons.Must("check")).Size(12, 12) case mixed: ui.Icon(c, icons.Must("minus")).Size(12, 12) } }) }) }