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() }