Files
MujicaUI/channel_list.go
T

217 lines
5.9 KiB
Go

package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Channel supplies channel or direct-conversation data.
type Channel struct {
ID, Name string
// Direct marks a private conversation, shown with the person's presence.
Direct bool
Presence Presence
Unread int
Muted bool
Pinned bool
}
// ChannelGroup is a titled, collapsible section of channels.
type ChannelGroup struct {
ID, Title string
Channels []Channel
}
// ChannelListOptions configure a ChannelList.
type ChannelListOptions struct {
// Height defaults to 360 DIP.
Height float32
// Revision invalidates grouped rows after in-place data edits.
Revision int
Disabled bool
}
type channelListKey struct{}
type channelIndex struct{ group, item int }
type channelListState struct {
list ui.ListState
folded map[string]bool
first *ChannelGroup
count, revision int
rows []channelIndex
}
// ChannelList chooses a channel from collapsible, virtualized groups.
func ChannelList(c *ui.Context, selected *string, groups []ChannelGroup, opts ChannelListOptions) ChoiceResult {
if opts.Height < 0 {
panic("mujica: ChannelList Height must be nonnegative")
}
k := Tokens(c)
d := Density(c)
col := ui.Column(c).Disabled(opts.Disabled)
st := ui.Local(col, channelListKey{}, func() channelListState {
return channelListState{folded: map[string]bool{}}
})
var first *ChannelGroup
if len(groups) > 0 {
first = &groups[0]
}
if st.rows == nil || st.first != first || st.count != len(groups) || st.revision != opts.Revision {
st.first, st.count, st.revision = first, len(groups), opts.Revision
channelRows(st, groups)
}
st.list.Key = func(i int) any {
r := st.rows[i]
if r.item < 0 {
return "group:" + groups[r.group].ID
}
return "channel:" + groups[r.group].Channels[r.item].ID
}
h := opts.Height
if h == 0 {
h = 360
}
h = min(h, float32(len(st.rows))*d.ControlHeight())
old := *selected
col.Children(func() {
ui.List(c, &st.list, len(st.rows), func(i int) {
r := st.rows[i]
g := groups[r.group]
if r.item < 0 {
open := !st.folded[g.ID]
if channelHeader(c, k, g, open) && !col.IsDisabled() {
st.folded[g.ID] = open
st.first = nil
st.count = -1
c.Invalidate()
slog.Debug("mujica: channel_list fold", "group", g.ID, "open", !open)
}
return
}
ch := g.Channels[r.item]
if channelRow(c, k, ch, ch.ID == *selected) && !col.IsDisabled() {
*selected = ch.ID
}
}).Height(h).FocusGroup(ui.Vertical).Role(ui.RoleTree).Label(msg(c, "channel_list.label"))
})
changed := *selected != old
if changed {
slog.Debug("mujica: channel_list select", "id", *selected)
}
return ChoiceResult{Element: col, changed: changed}
}
func channelRows(st *channelListState, groups []ChannelGroup) {
seen, groupIDs := map[string]bool{}, map[string]bool{}
rows := []channelIndex{}
for gi, g := range groups {
if g.ID == "" || g.Title == "" || groupIDs[g.ID] {
panic("mujica: ChannelList needs unique group IDs and titles")
}
groupIDs[g.ID] = true
rows = append(rows, channelIndex{gi, -1})
for _, ch := range g.Channels {
if ch.ID == "" || ch.Name == "" || seen[ch.ID] || ch.Unread < 0 {
panic("mujica: ChannelList needs unique channel IDs, names and nonnegative Unread")
}
ch.Presence.key()
seen[ch.ID] = true
}
if st.folded[g.ID] {
continue
}
for _, pinned := range []bool{true, false} {
for ci, ch := range g.Channels {
if ch.Pinned == pinned {
rows = append(rows, channelIndex{gi, ci})
}
}
}
}
st.rows = rows
}
func channelHeader(c *ui.Context, k theme.Tokens, g ChannelGroup, open bool) bool {
u := Density(c).Unit()
b := ui.ButtonBase(c).MinHeight(Density(c).ControlHeight()).Justify(ui.Start).Gap(u).Padding(0, u*2).Radius(theme.ControlRadius).
Label(g.Title).Role(ui.RoleTreeItem).TextColor(k.TextMuted).Transition(colorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
}
arrow := "chevron-down"
if !open {
arrow = "chevron-right"
}
b.Children(func() {
ui.Icon(c, icons.Must(arrow)).Size(14, 14)
ui.Text(c, g.Title).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).SingleLine()
})
if b.IsDisabled() {
return false
}
toggled := b.Clicked()
if open && b.Shortcut(0, ui.KeyLeft) || !open && b.Shortcut(0, ui.KeyRight) {
toggled = true
}
return toggled
}
// channelRow is one channel button; true means it was chosen.
func channelRow(c *ui.Context, k theme.Tokens, ch Channel, on bool) bool {
d := Density(c)
u := d.Unit()
label := ch.Name
if ch.Unread > 0 {
label = fmt.Sprintf(msg(c, "channel_list.unread"), ch.Name, ch.Unread)
}
b := ui.ButtonBase(c).Key(ch.ID).MinHeight(d.ControlHeight()).Justify(ui.Start).Gap(u*2).Padding(0, u*2, 0, u*5).
Radius(theme.ControlRadius).Label(label).Role(ui.RoleTreeItem).Selected(on).Background(ui.Transparent).
TextColor(k.Text).Transition(colorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case on:
b.Background(k.Selection).BorderWidth(0, 0, 0, 3).BorderColor(k.AccentText)
case b.Hovered():
b.Background(k.SurfaceHover)
}
if ch.Muted && !on {
b.TextColor(k.TextMuted)
}
b.Children(func() {
ui.Box(c).Width(16).Center().Shrink(0).Children(func() {
if ch.Direct {
OnlineStatus(c, ch.Presence, OnlineStatusOptions{HideText: true, Size: 9})
} else {
ui.Text(c, "#").TextColor(k.TextMuted)
}
})
name := ui.Text(c, ch.Name).SingleLine().Grow(1).MinWidth(0)
if ch.Unread > 0 && !ch.Muted {
name.FontWeight(600)
}
if ch.Pinned {
ui.Icon(c, msgPin).Size(13, 13).TextColor(k.TextMuted).Label(msg(c, "channel_list.pinned"))
}
if ch.Muted {
ui.Icon(c, msgBellOff).Size(13, 13).TextColor(k.TextMuted).Label(msg(c, "channel_list.muted"))
}
if ch.Unread > 0 {
tone := BadgeAccent
if ch.Muted {
tone = BadgeNeutral
}
Badge(c, BadgeOptions{Count: ch.Unread, Tone: tone}, nil)
}
})
return b.Clicked()
}