diff --git a/internal/gtkcord/handler.go b/internal/gtkcord/handler.go
new file mode 100644
index 00000000..ff9f5f14
--- /dev/null
+++ b/internal/gtkcord/handler.go
@@ -0,0 +1,36 @@
+package gtkcord
+
+import (
+ "github.com/diamondburned/arikawa/v3/utils/handler"
+ "github.com/diamondburned/gotk4/pkg/core/glib"
+)
+
+// MainThreadHandler wraps a [handler.Handler] to run all events on the main
+// thread.
+type MainThreadHandler struct {
+ h *handler.Handler
+}
+
+// NewMainThreadHandler creates a new MainThreadHandler.
+func NewMainThreadHandler(h *handler.Handler) *MainThreadHandler {
+ hh := &MainThreadHandler{h: handler.New()}
+ h.AddSyncHandler(func(ev any) {
+ glib.IdleAddPriority(glib.PriorityDefault, func() {
+ hh.h.Call(ev)
+ })
+ })
+ return hh
+}
+
+// AddHandler adds a handler to the handler.Handler.
+// The given handler will be called on the main thread.
+// The returned function will remove the handler.
+func (h *MainThreadHandler) AddHandler(handler any) func() {
+ return h.h.AddSyncHandler(handler)
+}
+
+// AddSyncHandler is the same as [AddHandler].
+// It exists for compatibility.
+func (h *MainThreadHandler) AddSyncHandler(handler any) func() {
+ return h.h.AddSyncHandler(handler)
+}
diff --git a/internal/gtkcord/state.go b/internal/gtkcord/state.go
index 6be54f75..8b29a909 100644
--- a/internal/gtkcord/state.go
+++ b/internal/gtkcord/state.go
@@ -70,6 +70,7 @@ const (
// State extends the Discord state controller.
type State struct {
+ *MainThreadHandler
*ningen.State
}
@@ -110,9 +111,10 @@ func Wrap(state *state.State) *State {
}
// dumpRawEvents(state)
-
+ ningen := ningen.FromState(state)
return &State{
- State: ningen.FromState(state),
+ MainThreadHandler: NewMainThreadHandler(ningen.Handler),
+ State: ningen,
}
}
@@ -157,9 +159,9 @@ func InjectState(ctx context.Context, state *State) context.Context {
// WithContext creates a copy of State with a new context.
func (s *State) WithContext(ctx context.Context) *State {
- return &State{
- State: s.State.WithContext(ctx),
- }
+ s2 := *s
+ s2.State = s.State.WithContext(ctx)
+ return &s2
}
// BindHandler is similar to BindWidgetHandler, except the lifetime of the
diff --git a/internal/icons/gtkcord4.gresource b/internal/icons/gtkcord4.gresource
index 8f0107b5..f59df404 100644
Binary files a/internal/icons/gtkcord4.gresource and b/internal/icons/gtkcord4.gresource differ
diff --git a/internal/icons/gtkcord4.gresource.xml b/internal/icons/gtkcord4.gresource.xml
index 8b5515f7..2c834add 100644
--- a/internal/icons/gtkcord4.gresource.xml
+++ b/internal/icons/gtkcord4.gresource.xml
@@ -25,5 +25,9 @@
scalable/actions/user-invisible-symbolic.svg
scalable/actions/user-offline-symbolic.svg
scalable/actions/user-status-pending-symbolic.svg
+ scalable/actions/channel-symbolic.svg
+ scalable/actions/channel-voice-symbolic.svg
+ scalable/actions/channel-broadcast-symbolic.svg
+ scalable/actions/thread-branch-symbolic.svg
diff --git a/internal/icons/icons.go b/internal/icons/icons.go
index cf063dd3..682d7c15 100644
--- a/internal/icons/icons.go
+++ b/internal/icons/icons.go
@@ -19,6 +19,5 @@ func init() {
if err != nil {
log.Panicln("Failed to create resources: ", err)
}
-
gio.ResourcesRegister(resources)
}
diff --git a/internal/icons/scalable/actions/channel-broadcast-symbolic.svg b/internal/icons/scalable/actions/channel-broadcast-symbolic.svg
new file mode 100644
index 00000000..0cd313e5
--- /dev/null
+++ b/internal/icons/scalable/actions/channel-broadcast-symbolic.svg
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/internal/icons/scalable/actions/channel-symbolic.svg b/internal/icons/scalable/actions/channel-symbolic.svg
new file mode 100644
index 00000000..c3620820
--- /dev/null
+++ b/internal/icons/scalable/actions/channel-symbolic.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/internal/icons/scalable/actions/channel-voice-symbolic.svg b/internal/icons/scalable/actions/channel-voice-symbolic.svg
new file mode 100644
index 00000000..ed3b9e05
--- /dev/null
+++ b/internal/icons/scalable/actions/channel-voice-symbolic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/internal/icons/scalable/actions/thread-branch-symbolic.svg b/internal/icons/scalable/actions/thread-branch-symbolic.svg
new file mode 100644
index 00000000..e3fb7ee2
--- /dev/null
+++ b/internal/icons/scalable/actions/thread-branch-symbolic.svg
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/internal/sidebar/channels/channel_item.go b/internal/sidebar/channels/channel_item.go
new file mode 100644
index 00000000..5c600713
--- /dev/null
+++ b/internal/sidebar/channels/channel_item.go
@@ -0,0 +1,477 @@
+package channels
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/diamondburned/arikawa/v3/discord"
+ "github.com/diamondburned/arikawa/v3/gateway"
+ "github.com/diamondburned/chatkit/components/author"
+ "github.com/diamondburned/gotk4/pkg/core/glib"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "github.com/diamondburned/gotk4/pkg/pango"
+ "github.com/diamondburned/gotkit/gtkutil/cssutil"
+ "github.com/diamondburned/gotkit/gtkutil/imgutil"
+ "github.com/diamondburned/gtkcord4/internal/gtkcord"
+ "github.com/diamondburned/gtkcord4/internal/signaling"
+ "github.com/diamondburned/ningen/v3"
+ "github.com/diamondburned/ningen/v3/states/read"
+)
+
+func newChannelItemFactory(state *gtkcord.State, model *gtk.TreeListModel) *gtk.ListItemFactory {
+ factory := gtk.NewSignalListItemFactory()
+
+ unbindFns := make(map[uintptr]func())
+
+ factory.ConnectBind(func(item *gtk.ListItem) {
+ row := model.Row(item.Position())
+ unbind := bindChannelItem(state, item, row)
+ unbindFns[item.Native()] = unbind
+ })
+
+ factory.ConnectUnbind(func(item *gtk.ListItem) {
+ unbind := unbindFns[item.Native()]
+ unbind()
+ item.SetChild(nil)
+ })
+
+ return &factory.ListItemFactory
+}
+
+func channelIDFromListItem(item *gtk.ListItem) discord.ChannelID {
+ return channelIDFromItem(item.Item())
+}
+
+func channelIDFromItem(item *glib.Object) discord.ChannelID {
+ str := item.Cast().(*gtk.StringObject)
+
+ id, err := discord.ParseSnowflake(str.String())
+ if err != nil {
+ panic(fmt.Sprintf("channelIDFromListItem: failed to parse ID: %v", err))
+ }
+
+ return discord.ChannelID(id)
+}
+
+var _ = cssutil.WriteCSS(`
+ .channel-item {
+ padding: 0.35em 0;
+ }
+ .channel-item image {
+ margin: 0 0.65em;
+ }
+ .channel-item-muted {
+ opacity: 0.5;
+ }
+ .channel-unread-indicator {
+ font-size: 0.75em;
+ font-weight: 700;
+ }
+ .channel-item-unread .channel-unread-indicator,
+ .channel-item-mentioned .channel-unread-indicator {
+ font-size: 0.7em;
+ font-weight: 900;
+ font-family: monospace;
+
+ min-width: 1em;
+ min-height: 1em;
+ line-height: 1em;
+
+ padding: 0;
+ margin: 0 1em;
+
+ outline: 1.5px solid @theme_fg_color;
+ border-radius: 99px;
+ }
+ .channel-item-mentioned .channel-unread-indicator {
+ font-size: 0.8em;
+ outline-color: @mentioned;
+ background: @mentioned;
+ color: @theme_bg_color;
+ }
+`)
+
+type channelItem struct {
+ state *gtkcord.State
+ item *gtk.ListItem
+ row *gtk.TreeListRow
+
+ child struct {
+ *gtk.Box
+ content gtk.Widgetter
+ indicator *gtk.Label
+ }
+
+ chID discord.ChannelID
+}
+
+func bindChannelItem(state *gtkcord.State, item *gtk.ListItem, row *gtk.TreeListRow) func() {
+ i := &channelItem{
+ state: state,
+ item: item,
+ row: row,
+ chID: channelIDFromListItem(item),
+ }
+
+ i.child.indicator = gtk.NewLabel("")
+ i.child.indicator.AddCSSClass("channel-unread-indicator")
+ i.child.indicator.SetHExpand(true)
+ i.child.indicator.SetHAlign(gtk.AlignEnd)
+ i.child.indicator.SetVAlign(gtk.AlignCenter)
+
+ i.child.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)
+ i.child.Box.AddCSSClass("channel-item-outer")
+ i.child.Box.Append(i.child.indicator)
+
+ i.item.SetChild(i.child.Box)
+
+ var unbind signaling.DisconnectStack
+ unbind.Push(
+ state.AddHandler(func(ev *read.UpdateEvent) {
+ if ev.ChannelID == i.chID {
+ i.Invalidate()
+ }
+ }),
+ state.AddHandler(func(ev *gateway.ChannelUpdateEvent) {
+ if ev.ID == i.chID {
+ i.Invalidate()
+ }
+ }),
+ )
+
+ ch, _ := state.Offline().Channel(i.chID)
+ if ch != nil {
+ switch ch.Type {
+ case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
+ unbind.Push(state.AddHandler(func(ev *gateway.ThreadUpdateEvent) {
+ if ev.ID == i.chID {
+ i.Invalidate()
+ }
+ }))
+ }
+
+ guildID := ch.GuildID
+ switch ch.Type {
+ case discord.GuildVoice, discord.GuildStageVoice:
+ unbind.Push(state.AddHandler(func(ev *gateway.VoiceStateUpdateEvent) {
+ // The channel ID becomes null when the user leaves the channel,
+ // so we'll just update when any guild state changes.
+ if ev.GuildID == guildID {
+ i.Invalidate()
+ }
+ }))
+ }
+ }
+
+ i.Invalidate()
+ return unbind.Disconnect
+}
+
+var readCSSClasses = map[ningen.UnreadIndication]string{
+ ningen.ChannelUnread: "channel-item-unread",
+ ningen.ChannelMentioned: "channel-item-mention",
+}
+
+const channelMutedClass = "channel-item-muted"
+
+// Invalidate updates the channel item's contents.
+func (i *channelItem) Invalidate() {
+ if i.child.content != nil {
+ i.child.Box.Remove(i.child.content)
+ }
+
+ i.item.SetSelectable(true)
+ i.item.SetActivatable(false)
+
+ ch, _ := i.state.Offline().Channel(i.chID)
+ if ch == nil {
+ i.child.content = newUnknownChannelItem(i.chID.String())
+ i.item.SetSelectable(false)
+ } else {
+ switch ch.Type {
+ case
+ discord.GuildText, discord.GuildAnnouncement,
+ discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
+
+ i.child.content = newChannelItemText(ch)
+
+ case discord.GuildCategory, discord.GuildForum:
+ // allow double-clicking to expand/collapse categories
+ i.item.SetSelectable(false)
+ i.item.SetActivatable(true)
+
+ switch ch.Type {
+ case discord.GuildCategory:
+ i.child.content = newChannelItemCategory(ch, i.row)
+ case discord.GuildForum:
+ i.child.content = newChannelItemForum(ch, i.row)
+ }
+
+ case discord.GuildVoice, discord.GuildStageVoice:
+ i.child.content = newChannelItemVoice(i.state, ch)
+
+ default:
+ panic("unreachable")
+ }
+ }
+
+ i.child.Box.Prepend(i.child.content)
+
+ for _, cssClass := range readCSSClasses {
+ i.child.Box.RemoveCSSClass(cssClass)
+ }
+
+ unread := i.state.ChannelIsUnread(i.chID)
+ if unread != ningen.ChannelRead {
+ i.child.Box.AddCSSClass(readCSSClasses[unread])
+ }
+ i.updateIndicator(unread)
+
+ if i.state.ChannelIsMuted(i.chID, false) {
+ i.child.Box.AddCSSClass(channelMutedClass)
+ } else {
+ i.child.Box.RemoveCSSClass(channelMutedClass)
+ }
+}
+
+func (i *channelItem) updateIndicator(unread ningen.UnreadIndication) {
+ if unread == ningen.ChannelMentioned {
+ i.child.indicator.SetText("!")
+ } else {
+ i.child.indicator.SetText("")
+ }
+}
+
+var _ = cssutil.WriteCSS(`
+ .channel-item-unknown {
+ opacity: 0.5;
+ font-style: italic;
+ }
+`)
+
+func newUnknownChannelItem(name string) gtk.Widgetter {
+ icon := gtk.NewImageFromIconName("channel-symbolic")
+
+ label := gtk.NewLabel(name)
+ label.SetEllipsize(pango.EllipsizeEnd)
+ label.SetXAlign(0)
+
+ box := gtk.NewBox(gtk.OrientationHorizontal, 0)
+ box.AddCSSClass("channel-item")
+ box.AddCSSClass("channel-item-unknown")
+ box.Append(icon)
+ box.Append(label)
+
+ return box
+}
+
+var _ = cssutil.WriteCSS(`
+ .channel-item-thread {
+ padding: 0.25em 0;
+ opacity: 0.5;
+ }
+ .channel-item-unread .channel-item-thread,
+ .channel-item-mention .channel-item-thread {
+ opacity: 1;
+ }
+`)
+
+func newChannelItemText(ch *discord.Channel) gtk.Widgetter {
+ icon := gtk.NewImageFromIconName("")
+ switch ch.Type {
+ case discord.GuildText:
+ icon.SetFromIconName("channel-symbolic")
+ case discord.GuildAnnouncement:
+ icon.SetFromIconName("channel-broadcast-symbolic")
+ case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
+ icon.SetFromIconName("thread-branch-symbolic")
+ }
+
+ label := gtk.NewLabel(ch.Name)
+ label.SetEllipsize(pango.EllipsizeEnd)
+ label.SetXAlign(0)
+ bindLabelTooltip(label, false)
+
+ box := gtk.NewBox(gtk.OrientationHorizontal, 0)
+ box.AddCSSClass("channel-item")
+ box.Append(icon)
+ box.Append(label)
+
+ switch ch.Type {
+ case discord.GuildText:
+ box.AddCSSClass("channel-item-text")
+ case discord.GuildAnnouncement:
+ box.AddCSSClass("channel-item-announcement")
+ case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
+ box.AddCSSClass("channel-item-thread")
+ }
+
+ return box
+}
+
+var _ = cssutil.WriteCSS(`
+ .channel-item-forum {
+ padding: 0.35em 0;
+ }
+ .channel-item-forum expander {
+ margin-left: 0.65em;
+ margin-right: 0.35em;
+ }
+ .channel-item-forum label {
+ padding: 0;
+ }
+`)
+
+func newChannelItemForum(ch *discord.Channel, row *gtk.TreeListRow) gtk.Widgetter {
+ label := gtk.NewLabel(ch.Name)
+ label.SetEllipsize(pango.EllipsizeEnd)
+ label.SetXAlign(0)
+ bindLabelTooltip(label, false)
+
+ expander := gtk.NewTreeExpander()
+ expander.AddCSSClass("channel-item")
+ expander.AddCSSClass("channel-item-forum")
+ expander.SetListRow(row)
+ expander.SetChild(label)
+
+ // GTK 4.10 or later only.
+ expander.SetObjectProperty("indent-for-depth", false)
+
+ return expander
+}
+
+var _ = cssutil.WriteCSS(`
+ .channel-item-category {
+ margin-top: 0.5em;
+ padding: 0.4em;
+ }
+ .channel-item-category expander {
+ margin: 0 0.3em;
+ }
+ .channel-item-category label {
+ margin-bottom: -0.2em;
+ padding: 0;
+ font-size: 0.85em;
+ font-weight: 700;
+ text-transform: uppercase;
+ }
+`)
+
+func newChannelItemCategory(ch *discord.Channel, row *gtk.TreeListRow) gtk.Widgetter {
+ label := gtk.NewLabel(ch.Name)
+ label.SetEllipsize(pango.EllipsizeEnd)
+ label.SetXAlign(0)
+ bindLabelTooltip(label, false)
+
+ expander := gtk.NewTreeExpander()
+ expander.AddCSSClass("channel-item")
+ expander.AddCSSClass("channel-item-category")
+ expander.SetListRow(row)
+ expander.SetChild(label)
+
+ return expander
+}
+
+var _ = cssutil.WriteCSS(`
+ .channel-item-voice .mauthor-chip {
+ margin: 0.15em 0;
+ margin-left: 2.5em;
+ margin-right: 1em;
+ }
+ .channel-item-voice .mauthor-chip:nth-child(2) {
+ margin-top: 0;
+ }
+ .channel-item-voice .mauthor-chip:last-child {
+ margin-bottom: 0.3em;
+ }
+ .channel-item-voice-counter {
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+ font-size: 0.8em;
+ opacity: 0.5;
+ }
+`)
+
+func newChannelItemVoice(state *gtkcord.State, ch *discord.Channel) gtk.Widgetter {
+ icon := gtk.NewImageFromIconName("channel-voice-symbolic")
+
+ label := gtk.NewLabel(ch.Name)
+ label.SetEllipsize(pango.EllipsizeEnd)
+ label.SetXAlign(0)
+ label.SetTooltipText(ch.Name)
+
+ top := gtk.NewBox(gtk.OrientationHorizontal, 0)
+ top.AddCSSClass("channel-item")
+ top.Append(icon)
+ top.Append(label)
+
+ var voiceParticipants int
+ voiceStates, _ := state.VoiceStates(ch.GuildID)
+ for _, voiceState := range voiceStates {
+ if voiceState.ChannelID == ch.ID {
+ voiceParticipants++
+ }
+ }
+
+ if voiceParticipants > 0 {
+ counter := gtk.NewLabel(fmt.Sprintf("%d", voiceParticipants))
+ counter.AddCSSClass("channel-item-voice-counter")
+ counter.SetVExpand(true)
+ counter.SetXAlign(0)
+ counter.SetYAlign(1)
+ top.Append(counter)
+ }
+
+ return top
+
+ // TODO: fix read indicator alignment. This probably should be in a separate
+ // ListModel instead.
+
+ // box := gtk.NewBox(gtk.OrientationVertical, 0)
+ // box.AddCSSClass("channel-item-voice")
+ // box.Append(top)
+
+ // voiceStates, _ := state.VoiceStates(ch.GuildID)
+ // for _, voiceState := range voiceStates {
+ // if voiceState.ChannelID == ch.ID {
+ // box.Append(newVoiceParticipant(state, voiceState))
+ // }
+ // }
+
+ // return box
+}
+
+func newVoiceParticipant(state *gtkcord.State, voiceState discord.VoiceState) gtk.Widgetter {
+ chip := author.NewChip(context.Background(), imgutil.HTTPProvider)
+ chip.Unpad()
+
+ member := voiceState.Member
+ if member == nil {
+ member, _ = state.Member(voiceState.GuildID, voiceState.UserID)
+ }
+
+ if member != nil {
+ chip.SetName(member.User.DisplayOrUsername())
+ chip.SetAvatar(gtkcord.InjectAvatarSize(member.AvatarURL(voiceState.GuildID)))
+ if color, ok := state.MemberColor(voiceState.GuildID, voiceState.UserID); ok {
+ chip.SetColor(color.String())
+ }
+ } else {
+ chip.SetName(voiceState.UserID.String())
+ }
+
+ return chip
+}
+
+func bindLabelTooltip(label *gtk.Label, markup bool) {
+ ref := glib.NewWeakRef(label)
+ label.NotifyProperty("label", func() {
+ label := ref.Get()
+ inner := label.Label()
+ if markup {
+ label.SetTooltipMarkup(inner)
+ } else {
+ label.SetTooltipText(inner)
+ }
+ })
+}
diff --git a/internal/sidebar/channels/channels.go b/internal/sidebar/channels/channels.go
deleted file mode 100644
index 52c2eb96..00000000
--- a/internal/sidebar/channels/channels.go
+++ /dev/null
@@ -1,474 +0,0 @@
-package channels
-
-import (
- "context"
- "log"
-
- "github.com/diamondburned/adaptive"
- "github.com/diamondburned/arikawa/v3/discord"
- "github.com/diamondburned/arikawa/v3/gateway"
- "github.com/diamondburned/gotk4-adwaita/pkg/adw"
- "github.com/diamondburned/gotk4/pkg/glib/v2"
- "github.com/diamondburned/gotk4/pkg/gtk/v4"
- "github.com/diamondburned/gotk4/pkg/pango"
- "github.com/diamondburned/gotkit/gtkutil"
- "github.com/diamondburned/gotkit/gtkutil/cssutil"
- "github.com/diamondburned/gtkcord4/internal/gtkcord"
- "github.com/diamondburned/ningen/v3/states/read"
- "github.com/pkg/errors"
-)
-
-// Refactor notice
-//
-// We should probably settle for an API that's kind of like this:
-//
-// ch := NewView(ctx, ctrl, guildID)
-// var signal glib.SignalHandle
-// signal = ch.ConnectOnUpdate(func() bool {
-// if node := ch.Node(wantedChID); node != nil {
-// node.Select()
-// ch.HandlerDisconnect(signal)
-// }
-// })
-// ch.Invalidate()
-//
-
-const ChannelsWidth = bannerWidth
-
-// Opener is the parent controller that View controls.
-type Opener interface {
- OpenChannel(discord.ChannelID)
-}
-
-// View holds the entire channel sidebar containing all the categories, channels
-// and threads.
-type View struct {
- *adaptive.LoadablePage
- Overlay *adw.ToolbarView
-
- Header struct {
- *adw.HeaderBar
- Name *gtk.Label
- }
-
- Scroll *gtk.ScrolledWindow
- Child struct {
- *gtk.Box
- Banner *Banner
- Tree *gtk.TreeView
- }
-
- ctx gtkutil.Cancellable
- ctrl Opener
- tree *GuildTree
- cols []*gtk.TreeViewColumn
-
- guildID discord.GuildID
- selectID discord.ChannelID // delegate to select later
-}
-
-var viewCSS = cssutil.Applier("channels-view", `
- .channels-viewtree {
- background: none;
- }
- .channels-header {
- padding: 0 {$header_padding};
- border-radius: 0;
- }
- .channels-view-scroll {
- /* Space out the header, since it's in an overlay. */
- margin-top: {$header_height};
- }
- .channels-has-banner .channels-view-scroll {
- /* No need to space out here, since we have the banner. We do need to
- * turn the header opaque with the styling below though, so the user can
- * see it.
- */
- margin-top: 0;
- }
- .channels-has-banner .top-bar {
- background-color: transparent;
- box-shadow: none;
- }
- .channels-has-banner windowhandle,
- .channels-has-banner .channels-header {
- transition: linear 65ms all;
- }
- .channels-has-banner.channels-scrolled windowhandle {
- background-color: transparent;
- }
- .channels-has-banner.channels-scrolled headerbar {
- background-color: @theme_bg_color;
- }
- .channels-has-banner .channels-header {
- box-shadow: 0 0 6px 0px @theme_bg_color;
- }
- .channels-has-banner:not(.channels-scrolled) .channels-header {
- /* go run ./cmd/ease-in-out-gradient/ -max 0.25 -min 0 -steps 5 */
- background: linear-gradient(to bottom,
- alpha(black, 0.24),
- alpha(black, 0.19),
- alpha(black, 0.06),
- alpha(black, 0.01),
- alpha(black, 0.00) 100%
- );
- box-shadow: none;
- border: none;
- }
- .channels-has-banner .channels-banner-shadow {
- background: alpha(black, 0.75);
- }
- .channels-has-banner:not(.channels-scrolled) .channels-header * {
- color: white;
- text-shadow: 0px 0px 5px alpha(black, 0.75);
- }
- .channels-has-banner:not(.channels-scrolled) .channels-header *:backdrop {
- color: alpha(white, 0.75);
- text-shadow: 0px 0px 2px alpha(black, 0.35);
- }
- .channels-name {
- font-weight: 600;
- font-size: 1.1em;
- }
-`)
-
-// NewView creates a new View.
-func NewView(ctx context.Context, ctrl Opener, guildID discord.GuildID) *View {
- v := View{
- ctrl: ctrl,
- cols: newTreeColumns(),
- guildID: guildID,
- }
-
- v.LoadablePage = adaptive.NewLoadablePage()
- v.LoadablePage.SetLoading()
-
- // Bind the context to cancel when we're hidden.
- v.ctx = gtkutil.WithVisibility(ctx, v)
-
- v.Header.Name = gtk.NewLabel("")
- v.Header.Name.AddCSSClass("channels-name")
- v.Header.Name.SetHAlign(gtk.AlignStart)
- v.Header.Name.SetEllipsize(pango.EllipsizeEnd)
-
- // The header is placed on top of the overlay, kind of like the official
- // client.
- v.Header.HeaderBar = adw.NewHeaderBar()
- v.Header.HeaderBar.AddCSSClass("channels-header")
- v.Header.HeaderBar.SetShowTitle(false)
- v.Header.HeaderBar.PackStart(v.Header.Name)
-
- viewport := gtk.NewViewport(nil, nil)
-
- v.Scroll = gtk.NewScrolledWindow()
- v.Scroll.AddCSSClass("channels-view-scroll")
- v.Scroll.SetVExpand(true)
- v.Scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
- v.Scroll.SetChild(viewport)
- // v.Scroll.SetPropagateNaturalWidth(true)
- // v.Scroll.SetPropagateNaturalHeight(true)
-
- var headerScrolled bool
-
- vadj := v.Scroll.VAdjustment()
- vadj.ConnectValueChanged(func() {
- if scrolled := v.Child.Banner.SetScrollOpacity(vadj.Value()); scrolled {
- if !headerScrolled {
- headerScrolled = true
- v.Overlay.AddCSSClass("channels-scrolled")
- }
- } else {
- if headerScrolled {
- headerScrolled = false
- v.Overlay.RemoveCSSClass("channels-scrolled")
- }
- }
- })
-
- v.Child.Banner = NewBanner(ctx, guildID)
- v.Child.Banner.Invalidate()
-
- v.Child.Tree = gtk.NewTreeView()
- v.Child.Tree.AddCSSClass("channels-viewtree")
- v.Child.Tree.SetSizeRequest(bannerWidth, -1)
- v.Child.Tree.SetTooltipColumn(columnTooltip)
- v.Child.Tree.SetVExpand(true)
- v.Child.Tree.SetHExpand(true)
- v.Child.Tree.SetHeadersVisible(false)
- v.Child.Tree.SetLevelIndentation(4)
- v.Child.Tree.SetActivateOnSingleClick(true)
- v.Child.Tree.SetEnableSearch(false)
-
- for i, col := range v.cols {
- v.Child.Tree.InsertColumn(col, i)
- }
-
- v.Child.Tree.ConnectRowActivated(func(path *gtk.TreePath, column *gtk.TreeViewColumn) {
- node := v.tree.NodeFromPath(path)
- if node == nil {
- return
- }
-
- switch node.(type) {
- case *ChannelNode, *VoiceChannelNode:
- // These channels have messages, so we don't want to toggle the
- // children as the user clicks on them.
- v.Child.Tree.ExpandToPath(path)
- case *CategoryNode, *ForumNode:
- // These don't have messages, so you can't act on it, so we toggle
- // on a click.
- if v.Child.Tree.RowExpanded(path) {
- v.Child.Tree.CollapseRow(path)
- } else {
- v.Child.Tree.ExpandRow(path, false)
- }
- }
- })
-
- selection := v.Child.Tree.Selection()
- selection.SetMode(gtk.SelectionBrowse)
-
- // Hack to stop a weird infinite recursion bug.
- var selecting bool
-
- selection.ConnectChanged(func() {
- if selecting {
- log.Println("BUG: infinite recursion in selection.ConnectChanged detected")
- log.Println("BUG: ignoring selection change")
- return
- }
-
- selecting = true
- glib.IdleAdd(func() { selecting = false })
-
- // Note: never set v.selectID to 0 here, because we're in Browse mode,
- // so it should be impossible.
- if v.tree == nil {
- return
- }
-
- _, iter, ok := selection.Selected()
- if !ok {
- return
- }
-
- node := v.tree.NodeFromIter(iter)
- if node == nil {
- return
- }
-
- nodeID := node.ID()
- if v.selectID == nodeID {
- return
- }
-
- switch node.(type) {
- case *ChannelNode, *ThreadNode, *VoiceChannelNode:
- // Update the selectID in case we recreate the tree model.
- v.selectID = nodeID
-
- // We can open these channels.
- log.Println("opening channel", nodeID)
- ctrl.OpenChannel(nodeID)
- }
- })
-
- v.Child.Box = gtk.NewBox(gtk.OrientationVertical, 0)
- v.Child.Box.SetVExpand(true)
- // v.Child.Box.SetVAlign(gtk.AlignStart)
- v.Child.Box.Append(v.Child.Banner)
- v.Child.Box.Append(v.Child.Tree)
- v.Child.Box.SetFocusChild(v.Child.Tree)
-
- viewport.SetChild(v.Child)
- viewport.SetFocusChild(v.Child)
-
- v.Overlay = adw.NewToolbarView()
- v.Overlay.SetExtendContentToTopEdge(true) // basically act like an overlay
- v.Overlay.AddTopBar(v.Header)
- v.Overlay.SetContent(v.Scroll)
- v.Overlay.SetFocusChild(v.Scroll)
-
- state := gtkcord.FromContext(ctx)
- state.BindHandler(v.ctx, func(ev gateway.Event) {
- if v.tree == nil {
- return
- }
-
- switch ev := ev.(type) {
- case *read.UpdateEvent:
- v.tree.UpdateUnread(ev.ChannelID)
- case *gateway.GuildUpdateEvent:
- if ev.ID == v.guildID {
- v.InvalidateHeader()
- }
- case *gateway.ThreadListSyncEvent:
- if ev.GuildID == v.guildID {
- v.InvalidateChannels()
- }
- case *gateway.ChannelCreateEvent:
- if ev.GuildID == v.guildID {
- v.tree.Add([]discord.Channel{ev.Channel})
- }
- case *gateway.ChannelUpdateEvent:
- if ev.GuildID == v.guildID {
- v.tree.UpdateChannel(ev.ID)
- }
- case *gateway.ChannelDeleteEvent:
- if ev.GuildID == v.guildID {
- v.InvalidateChannels()
- }
- case *gateway.ThreadCreateEvent:
- if ev.GuildID == v.guildID {
- v.tree.Add([]discord.Channel{ev.Channel})
- }
- case *gateway.ThreadUpdateEvent:
- if ev.GuildID == v.guildID {
- v.tree.UpdateChannel(ev.ID)
- }
- case *gateway.ThreadDeleteEvent:
- if ev.GuildID == v.guildID {
- v.InvalidateChannels()
- }
- case *gateway.VoiceStateUpdateEvent:
- if ev.GuildID == v.guildID {
- v.tree.UpdateChannel(ev.ChannelID)
- }
- }
- },
- (*read.UpdateEvent)(nil),
- (*gateway.GuildUpdateEvent)(nil),
- (*gateway.ThreadListSyncEvent)(nil),
- (*gateway.ChannelCreateEvent)(nil),
- (*gateway.ChannelUpdateEvent)(nil),
- (*gateway.ChannelDeleteEvent)(nil),
- (*gateway.ThreadCreateEvent)(nil),
- (*gateway.ThreadUpdateEvent)(nil),
- (*gateway.ThreadDeleteEvent)(nil),
- (*gateway.VoiceStateUpdateEvent)(nil),
- )
-
- viewCSS(v)
- return &v
-}
-
-// SelectChannel selects a known channel. If none is known, then it is selected
-// later when the list is changed or never selected if the user selects
-// something else.
-func (v *View) SelectChannel(chID discord.ChannelID) {
- v.selectID = chID
- log.Println("selecting channel", chID)
-
- if v.tree != nil {
- node := v.tree.Node(chID)
- if node != nil {
- path := node.TreePath()
- selection := v.Child.Tree.Selection()
- if !selection.PathIsSelected(path) {
- selection.SelectPath(path)
- }
- return
- }
- }
-}
-
-// GuildID returns the view's guild ID.
-func (v *View) GuildID() discord.GuildID {
- return v.guildID
-}
-
-func (v *View) setDone() {
- v.LoadablePage.SetChild(v.Overlay)
-}
-
-// InvalidateHeader invalidates the guild name and banner.
-func (v *View) InvalidateHeader() {
- state := gtkcord.FromContext(v.ctx.Take())
-
- g, err := state.Cabinet.Guild(v.guildID)
- if err != nil {
- v.SetError(errors.Wrap(err, "cannot fetch guilds"))
- return
- }
-
- // TODO: Nitro boost level
- v.Header.Name.SetText(g.Name)
- v.invalidateBanner()
-}
-
-// InvalidateChannels invalidates the channels list.
-func (v *View) InvalidateChannels() {
- state := gtkcord.FromContext(v.ctx.Take())
- state.MemberState.Subscribe(v.guildID)
-
- chs, err := state.Offline().Channels(v.guildID, gtkcord.AllowedChannelTypes)
- if err != nil {
- v.SetError(errors.Wrap(err, "cannot fetch channels"))
- return
- }
-
- v.tree = NewGuildTree(v.ctx.Take())
- v.tree.Add(chs)
-
- v.Child.Tree.SetModel(v.tree)
- v.setDone()
-
- // Expand all categories by default.
- // TODO: add state.
- for _, node := range v.tree.nodes {
- switch node.(type) {
- case *CategoryNode:
- v.Child.Tree.ExpandToPath(node.TreePath())
- }
- }
-
- if node := v.tree.Node(v.selectID); node != nil {
- selection := v.Child.Tree.Selection()
- selection.SelectPath(node.TreePath())
- }
-}
-
-func (v *View) invalidateBanner() {
- v.Child.Banner.Invalidate()
-
- if v.Child.Banner.HasBanner() {
- v.Overlay.AddCSSClass("channels-has-banner")
- } else {
- v.Overlay.RemoveCSSClass("channels-has-banner")
- }
-}
-
-func newTreeColumns() []*gtk.TreeViewColumn {
- return []*gtk.TreeViewColumn{
- func() *gtk.TreeViewColumn {
- ren := gtk.NewCellRendererText()
- ren.SetPadding(0, 4)
- ren.SetObjectProperty("ellipsize", pango.EllipsizeEnd)
- ren.SetObjectProperty("ellipsize-set", true)
-
- col := gtk.NewTreeViewColumn()
- col.PackStart(ren, true)
- col.AddAttribute(ren, "markup", columnName)
- // col.AddAttribute(ren, "foreground", columnTextColor)
- // col.AddAttribute(ren, "foreground-set", columnTextColorSet)
- col.SetSizing(gtk.TreeViewColumnAutosize)
- col.SetExpand(true)
-
- return col
- }(),
- func() *gtk.TreeViewColumn {
- ren := gtk.NewCellRendererText()
- ren.SetAlignment(1, 0.5)
- ren.SetPadding(4, 0)
-
- col := gtk.NewTreeViewColumn()
- col.PackStart(ren, false)
- col.AddAttribute(ren, "text", columnUnread)
- // col.AddAttribute(ren, "foreground", columnTextColor)
- // col.AddAttribute(ren, "foreground-set", columnTextColorSet)
- col.SetSizing(gtk.TreeViewColumnAutosize)
-
- return col
- }(),
- }
-}
diff --git a/internal/sidebar/channels/channels_model.go b/internal/sidebar/channels/channels_model.go
new file mode 100644
index 00000000..50aef82b
--- /dev/null
+++ b/internal/sidebar/channels/channels_model.go
@@ -0,0 +1,301 @@
+package channels
+
+import (
+ "fmt"
+ "log"
+ "sort"
+
+ "github.com/diamondburned/arikawa/v3/discord"
+ "github.com/diamondburned/arikawa/v3/gateway"
+ "github.com/diamondburned/gotk4/pkg/core/glib"
+ "github.com/diamondburned/gotk4/pkg/gio/v2"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "github.com/diamondburned/gtkcord4/internal/gtkcord"
+ "github.com/diamondburned/gtkcord4/internal/signaling"
+)
+
+type modelManager struct {
+ *gtk.TreeListModel
+ state *gtkcord.State
+ guildID discord.GuildID
+}
+
+func newModelManager(state *gtkcord.State, guildID discord.GuildID) *modelManager {
+ m := &modelManager{
+ state: state,
+ guildID: guildID,
+ }
+ m.TreeListModel = gtk.NewTreeListModel(
+ m.Model(0), true, true,
+ func(item *glib.Object) *gio.ListModel {
+ chID := channelIDFromItem(item)
+
+ model := m.Model(chID)
+ if model == nil {
+ return nil
+ }
+
+ return &model.ListModel
+ })
+ return m
+}
+
+// Model returns the list model containing all channels within the given channel
+// ID. If chID is 0, then the guild's root channels will be returned. This
+// function may return nil, indicating that the channel will never have any
+// children.
+func (m *modelManager) Model(chID discord.ChannelID) *gtk.StringList {
+ model := gtk.NewStringList(nil)
+
+ list := newChannelList(m.state, glib.NewWeakRef(model))
+
+ if chID == 0 {
+ m.bindCategory(0, list)
+ m.addAllChannels(chID, list)
+ return model
+ }
+
+ ch, _ := m.state.Offline().Channel(chID)
+ if ch == nil {
+ // Uncertain? Just throw a warning for now.
+ log.Printf(
+ "channelsTreeManager: channel %d not found in state, assuming it won't have children",
+ chID)
+ return nil
+ }
+
+ if ch.GuildID != m.guildID {
+ log.Printf(
+ "channelsTreeManager: channel %d (guild %d) is not in guild %d, assuming it won't have children",
+ chID, ch.GuildID, m.guildID)
+ return nil
+ }
+
+ switch ch.Type {
+ case discord.GuildCategory:
+ m.bindCategory(chID, list)
+ case discord.GuildText, discord.GuildForum:
+ m.bindThreads(chID, list)
+ default:
+ return nil
+ }
+
+ m.addAllChannels(chID, list)
+ return model
+}
+
+func (m *modelManager) addAllChannels(parentID discord.ChannelID, list *channelList) {
+ for _, ch := range fetchSortedChannels(m.state, m.guildID, parentID) {
+ list.Append(ch)
+ }
+}
+
+func (m *modelManager) bindCategory(chID discord.ChannelID, list *channelList) {
+ var unbind signaling.DisconnectStack
+ list.ConnectDestroy(func() { unbind.Disconnect() })
+
+ unbind.Push(
+ m.state.AddHandler(func(ev *gateway.ChannelCreateEvent) {
+ if ev.GuildID != m.guildID {
+ return
+ }
+ if ev.Channel.ParentID == chID {
+ list.Append(ev.Channel)
+ }
+ }),
+ m.state.AddHandler(func(ev *gateway.ChannelUpdateEvent) {
+ if ev.GuildID != m.guildID {
+ return
+ }
+ // Handle channel position moves.
+ if ev.Channel.ParentID == chID {
+ list.Append(ev.Channel)
+ } else {
+ list.Remove(ev.Channel.ID)
+ }
+ }),
+ )
+}
+
+func (m *modelManager) bindThreads(chID discord.ChannelID, list *channelList) {
+ var unbind signaling.DisconnectStack
+ list.ConnectDestroy(func() { unbind.Disconnect() })
+
+ unbind.Push(
+ m.state.AddHandler(func(ev *gateway.ThreadListSyncEvent) {
+ if ev.GuildID != m.guildID {
+ return
+ }
+
+ for _, parentID := range ev.ChannelIDs {
+ if parentID != chID {
+ continue
+ }
+
+ // This sync event is for us.
+ list.Clear()
+ m.addAllChannels(chID, list)
+ break
+ }
+ }),
+ m.state.AddHandler(func(ev *gateway.ThreadCreateEvent) {
+ if ev.GuildID != m.guildID || ev.Channel.ParentID != chID {
+ return
+ }
+ list.Append(ev.Channel)
+ }),
+ m.state.AddHandler(func(ev *gateway.ThreadDeleteEvent) {
+ if ev.GuildID != m.guildID || ev.ParentID != chID {
+ return
+ }
+ list.Remove(ev.ID)
+ }),
+ )
+}
+
+// channelList wraps a StringList to maintain a set of channel IDs.
+// Because this is a set, each channel ID can only appear once.
+type channelList struct {
+ state *gtkcord.State
+ list *glib.WeakRef[*gtk.StringList]
+ set map[string]struct{}
+}
+
+func newChannelList(state *gtkcord.State, ref *glib.WeakRef[*gtk.StringList]) *channelList {
+ return &channelList{
+ state: state,
+ list: ref,
+ set: make(map[string]struct{}),
+ }
+}
+
+// CalculatePosition converts the position of a channel given by Discord to the
+// position relative to the list. If the channel is not found, then this
+// function returns the end of the list.
+func (l *channelList) CalculatePosition(target discord.Channel) uint {
+ list := l.list.Get()
+ end := list.NItems()
+
+ // Find this particular channel in the list.
+ for i, ch := range fetchSortedChannels(l.state, target.GuildID, target.ParentID) {
+ if ch.ID == target.ID {
+ // Sanity check.
+ if i > int(end) {
+ log.Printf("CalculatePosition: channel %d is out of bounds", target.ID)
+ return end
+ }
+ return uint(i)
+ }
+ }
+
+ return end
+}
+
+// Append appends a channel to the list. If the channel already exists, then
+// this function does nothing.
+func (l *channelList) Append(ch discord.Channel) {
+ str := ch.ID.String()
+ if _, exists := l.set[str]; exists {
+ return
+ }
+ l.set[str] = struct{}{}
+
+ pos := l.CalculatePosition(ch)
+ list := l.list.Get()
+ list.Splice(pos, 0, []string{str})
+}
+
+// Remove removes the channel ID from the list. If the channel ID is not in the
+// list, then this function does nothing.
+func (l *channelList) Remove(chID discord.ChannelID) {
+ str := chID.String()
+ if _, exists := l.set[str]; !exists {
+ return
+ }
+ if i := l.Index(chID); i != -1 {
+ list := l.list.Get()
+ list.Remove(uint(i))
+ }
+ delete(l.set, str)
+}
+
+// Contains returns whether the channel ID is in the list.
+func (l *channelList) Contains(chID discord.ChannelID) bool {
+ _, exists := l.set[chID.String()]
+ return exists
+}
+
+// Index returns the index of the channel ID in the list. If the channel ID is
+// not in the list, then this function returns -1.
+func (l *channelList) Index(chID discord.ChannelID) int {
+ ix := -1
+ iter := l.All()
+ iter(func(i int, id discord.ChannelID) bool {
+ if id == chID {
+ ix = i
+ return false
+ }
+ return true
+ })
+ return ix
+}
+
+// Clear clears the list.
+func (l *channelList) Clear() {
+ list := l.list.Get()
+ list.Splice(0, list.NItems(), nil)
+ l.set = make(map[string]struct{})
+}
+
+// All returns a function that iterates over all channel IDs in the list.
+func (l *channelList) All() func(yield func(i int, id discord.ChannelID) bool) {
+ list := l.list.Get()
+ n := list.NItems()
+ return func(yield func(int, discord.ChannelID) bool) {
+ for i := uint(0); i < n; i++ {
+ id, err := discord.ParseSnowflake(list.String(i))
+ if err != nil {
+ panic(fmt.Sprintf("channelList: invalid channel ID %q", list.String(i)))
+ }
+ if !yield(int(i), discord.ChannelID(id)) {
+ return
+ }
+ }
+ }
+}
+
+func (l *channelList) ConnectDestroy(f func()) {
+ // I think this is the only way to know if a ListModel is no longer
+ // being used? At least from reading the source code, which just calls
+ // g_clear_pointer.
+ list := l.list.Get()
+ glib.WeakRefObject(list, f)
+}
+
+func fetchSortedChannels(state *gtkcord.State, guildID discord.GuildID, parentID discord.ChannelID) []discord.Channel {
+ channels, err := state.Offline().Channels(guildID, gtkcord.AllowedChannelTypes)
+ if err != nil {
+ log.Printf("CalculatePosition: failed to get channels: %v", err)
+ return nil
+ }
+
+ // Filter out all channels that are not in the same parent channel.
+ filtered := channels[:0]
+ for i, ch := range channels {
+ if ch.ParentID == parentID {
+ filtered = append(filtered, channels[i])
+ }
+ }
+
+ // Sort so that the channels are in increasing order.
+ sort.Slice(filtered, func(i, j int) bool {
+ a := filtered[i]
+ b := filtered[j]
+ if a.Position == b.Position {
+ return a.ID < b.ID
+ }
+ return a.Position < b.Position
+ })
+
+ return filtered
+}
diff --git a/internal/sidebar/channels/drain.go b/internal/sidebar/channels/drain.go
deleted file mode 100644
index bb727adc..00000000
--- a/internal/sidebar/channels/drain.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package channels
-
-import (
- "sort"
-
- "github.com/diamondburned/arikawa/v3/discord"
-)
-
-// drainer provides a drain method that filters and removes channels that the
-// callback returns true on.
-type drainer []discord.Channel
-
-func (d *drainer) sort() {
- s := *d
- sort.Slice(s, func(i, j int) bool {
- return s[i].Position < s[j].Position
- })
-}
-
-func (d *drainer) drain(f func(ch discord.Channel) bool) {
- old := *d
- *d = (*d)[:0]
-
- for _, ch := range old {
- if !f(ch) {
- *d = append(*d, ch)
- }
- }
-}
diff --git a/internal/sidebar/channels/state.go b/internal/sidebar/channels/state.go
deleted file mode 100644
index adb8e384..00000000
--- a/internal/sidebar/channels/state.go
+++ /dev/null
@@ -1,318 +0,0 @@
-package channels
-
-import (
- "context"
- "log"
-
- "github.com/diamondburned/arikawa/v3/discord"
- "github.com/diamondburned/gotk4/pkg/glib/v2"
- "github.com/diamondburned/gotk4/pkg/gtk/v4"
- "github.com/diamondburned/gtkcord4/internal/gtkcord"
- "github.com/diamondburned/ningen/v3"
-)
-
-type any = interface{}
-
-type treeColumn = int
-
-const (
- columnName treeColumn = iota
- columnID
- columnUnread
- columnTooltip
-
- maxTreeColumn
-)
-
-var allTreeColumns = []treeColumn{
- columnName,
- columnID,
- columnUnread,
- columnTooltip,
-}
-
-var columnTypes = []glib.Type{
- glib.TypeString,
- glib.TypeUint64,
- glib.TypeString,
- glib.TypeString,
-}
-
-// GuildTree is the channel tree that holds the state of all channels.
-type GuildTree struct {
- *gtk.TreeStore
- nodes map[discord.ChannelID]Node
- ctx context.Context
-}
-
-// NewGuildTree creates a new GuildTree.
-func NewGuildTree(ctx context.Context) *GuildTree {
- return &GuildTree{
- TreeStore: gtk.NewTreeStore(columnTypes),
- nodes: make(map[discord.ChannelID]Node),
- ctx: ctx,
- }
-}
-
-var okChTypes = map[discord.ChannelType]bool{
- discord.GuildText: true,
- discord.GuildCategory: false, // handled separately
- discord.GuildPublicThread: true,
- discord.GuildPrivateThread: true,
- discord.GuildForum: true,
- discord.GuildAnnouncement: true,
- discord.GuildAnnouncementThread: true,
- discord.GuildVoice: true,
- discord.GuildStageVoice: true,
-}
-
-// Add adds the given list of channels into the guild tree.
-func (t *GuildTree) Add(channels []discord.Channel) {
- chs := drainer(channels)
- chs.sort()
-
- // Set channels without categories.
- chs.drain(func(ch discord.Channel) bool {
- if ch.ParentID.IsValid() || !okChTypes[ch.Type] {
- return false
- }
-
- base := t.append(&ch, nil)
- node := newChannelNode(base)
- node.Update(&ch)
-
- t.keep(node)
- return true
- })
-
- // Set categories.
- chs.drain(func(ch discord.Channel) bool {
- if ch.Type != discord.GuildCategory {
- return false
- }
-
- base := t.append(&ch, nil)
- node := newCategoryNode(base, &ch)
- node.Update(&ch)
-
- t.keep(node)
- return true
- })
-
- // Set nested text channels that are inside catagories.
- chs.drain(func(ch discord.Channel) bool {
- if !ch.ParentID.IsValid() {
- return false
- }
-
- if ch.Type != discord.GuildText && ch.Type != discord.GuildForum {
- // Other channel types are handled in the drain function below.
- return false
- }
-
- parent := t.nodes[ch.ParentID]
- if parent == nil {
- log.Println("channel", ch.Name, "has unknown parent ID")
- return false
- }
-
- parentIter, ok := t.Iter(parent.TreePath())
- if !ok {
- return false
- }
-
- base := t.append(&ch, parentIter)
-
- var node Node
- switch ch.Type {
- case discord.GuildForum:
- node = newForumNode(base)
- node.Update(&ch)
- default:
- node = newChannelNode(base)
- node.Update(&ch)
- }
-
- t.keep(node)
- return true
- })
-
- // Set nested threads that are inside channels.
- chs.drain(func(ch discord.Channel) bool {
- if !ch.ParentID.IsValid() || !okChTypes[ch.Type] {
- return false
- }
-
- parent := t.nodes[ch.ParentID]
- if parent == nil {
- log.Println("nested channel", ch.Name, "has unknown parent ID")
- return false
- }
-
- parentIter, ok := t.Iter(parent.TreePath())
- if !ok {
- return false
- }
-
- base := t.append(&ch, parentIter)
- var node Node
-
- switch ch.Type {
- case discord.GuildPrivateThread, discord.GuildPublicThread, discord.GuildAnnouncementThread:
- node = newThreadNode(base)
- node.Update(&ch)
- case discord.GuildVoice, discord.GuildStageVoice:
- node = newVoiceChannelNode(base)
- node.Update(&ch)
- default:
- node = newChannelNode(base)
- node.Update(&ch)
- }
-
- t.keep(node)
- return true
- })
-}
-
-// keep saves n into the internal registry.
-func (t *GuildTree) keep(n Node) {
- t.nodes[n.ID()] = n
- t.UpdateUnread(n.ID())
-}
-
-// append appends a new empty node and returns its iterator.
-func (t *GuildTree) append(ch *discord.Channel, parent *gtk.TreeIter) baseChannelNode {
- iter := t.TreeStore.Append(parent)
- base := baseChannelNode{
- path: t.Path(iter),
- head: t,
- id: ch.ID,
- }
- base.zeroInit(ch)
- return base
-}
-
-// Remove removes the channel node with the given ID.
-func (t *GuildTree) Remove(id discord.ChannelID) {
- // TODO: this doesn't handle removing categories.
- n, ok := t.nodes[id]
- if ok {
- it, ok := t.TreeStore.Iter(n.TreePath())
- if ok {
- t.TreeStore.Remove(it)
- }
-
- delete(t.nodes, id)
- }
-}
-
-func (t *GuildTree) state() *gtkcord.State {
- return gtkcord.FromContext(t.ctx)
-}
-
-// NodeFromIter returns the channel from the given TreeIter.
-func (t *GuildTree) NodeFromIter(iter *gtk.TreeIter) Node {
- gv := t.TreeStore.Value(iter, columnID)
- id := gv.GoValue().(uint64)
- return t.nodes[discord.ChannelID(id)]
-}
-
-// NodeFromPath quickly looks up the channel tree for a node from the given tree
-// path.
-func (t *GuildTree) NodeFromPath(path *gtk.TreePath) Node {
- it, ok := t.TreeStore.Iter(path)
- if !ok {
- return nil
- }
- return t.NodeFromIter(it)
-}
-
-// Has returns true if the guild tree has the given channel.
-func (t *GuildTree) Has(id discord.ChannelID) bool {
- _, ok := t.nodes[id]
- return ok
-}
-
-// Node quickly looks up the channel tree for a node.
-func (t *GuildTree) Node(id discord.ChannelID) Node {
- return t.nodes[id]
-}
-
-// UpdateChannel updates the channel node with the given ID, or if the node is
-// not known, then it does nothing.
-func (t *GuildTree) UpdateChannel(id discord.ChannelID) {
- node := t.Node(id)
- if node == nil {
- return
- }
-
- state := t.state()
-
- ch, err := state.Offline().Channel(id)
- if err != nil {
- return
- }
-
- node.Update(ch)
-}
-
-// UpdateUnread updates the unread state of the channel with the given ID.
-func (t *GuildTree) UpdateUnread(id discord.ChannelID) {
- node := t.Node(id)
- if node == nil {
- return
- }
-
- node.UpdateUnread()
-}
-
-func (t *GuildTree) set(path *gtk.TreePath, v [maxTreeColumn]any) {
- it, ok := t.Iter(path)
- if !ok {
- return
- }
-
- values := make([]glib.Value, len(v))
- for i, value := range v {
- if value == nil {
- panic("unexpected nil value given to set [maxTreeColumn]any")
- }
- values[i] = *glib.NewValue(value)
- }
-
- t.TreeStore.Set(it, allTreeColumns, values)
-}
-
-func (t *GuildTree) setValues(path *gtk.TreePath, values [maxTreeColumn]any) {
- it, ok := t.Iter(path)
- if !ok {
- return
- }
-
- for col, val := range values {
- if val == nil {
- continue
- }
- t.TreeStore.SetValue(it, col, glib.NewValue(val))
- }
-}
-
-// Node describes a channel node in the channel tree.
-type Node interface {
- // ID is the ID of the channel node.
- ID() discord.ChannelID
- // Update passes the new Channel object into the Node for it to update its
- // own information.
- Update(*discord.Channel)
- // UpdateUnread updates the unread state of the node.
- UpdateUnread()
- // TreePath is the tree path pointing to the channel node.
- TreePath() *gtk.TreePath
-
- nodeInternals
-}
-
-type nodeInternals interface {
- setUnread(ningen.UnreadIndication, bool)
- getUnread() ningen.UnreadIndication
-}
diff --git a/internal/sidebar/channels/state_base.go b/internal/sidebar/channels/state_base.go
deleted file mode 100644
index 95660044..00000000
--- a/internal/sidebar/channels/state_base.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package channels
-
-import (
- "html"
-
- "github.com/diamondburned/arikawa/v3/discord"
- "github.com/diamondburned/gotk4/pkg/gtk/v4"
- "github.com/diamondburned/ningen/v3"
-)
-
-const (
- valueUnread = "●"
- valueChildUnread = "○"
- valueMentioned = "! " + valueUnread
- valueChildMentioned = "! " + valueChildUnread
-)
-
-// baseChannelNode is the base of all channel nodes. It implements the Node
-// interface and contains common information that all channels have.
-type baseChannelNode struct {
- path *gtk.TreePath
- head *GuildTree
-
- id discord.ChannelID
- unread ningen.UnreadIndication
-}
-
-var _ Node = (*baseChannelNode)(nil)
-
-// ID implements Node.
-func (n *baseChannelNode) ID() discord.ChannelID { return n.id }
-
-// Update implements Node. It does nothing.
-func (n *baseChannelNode) Update(ch *discord.Channel) {
- n.UpdateUnread()
-}
-
-// TreePath implements Node.
-func (n *baseChannelNode) TreePath() *gtk.TreePath { return n.path }
-
-// EachChildren calls the given function for each child of the node. If f
-// returns false, then the iteration is stopped.
-func (n *baseChannelNode) EachChildren(f func(Node) bool) {
- iter, ok := n.head.TreeStore.Iter(n.path)
- if !ok {
- return
- }
-
- iter, ok = n.head.TreeStore.IterChildren(iter)
- if !ok {
- return
- }
-
- for ok {
- node := n.head.NodeFromIter(iter)
- if node != nil && !f(node) {
- break
- }
- ok = n.head.TreeStore.IterNext(iter)
- }
-}
-
-func (n *baseChannelNode) UpdateUnread() {
- // Update self's unread indicator.
- n.unread = n.head.state().ChannelIsUnread(n.id)
-
- var fromChild bool
- n.EachChildren(func(child Node) bool {
- unread := child.getUnread()
- if unread > n.unread {
- n.unread = unread
- fromChild = true
- }
- // Loop until we find the highest unread indicator.
- return n.unread != ningen.ChannelMentioned
- })
-
- n.setUnread(n.unread, fromChild)
-}
-
-func (n *baseChannelNode) getUnread() ningen.UnreadIndication {
- return n.unread
-}
-
-func (n *baseChannelNode) setUnread(unread ningen.UnreadIndication, fromChild bool) {
- var col string
- if !n.isMuted() {
- if fromChild {
- switch unread {
- case ningen.ChannelUnread:
- col = valueChildUnread
- case ningen.ChannelMentioned:
- col = valueChildMentioned
- }
- } else {
- switch unread {
- case ningen.ChannelUnread:
- col = valueUnread
- case ningen.ChannelMentioned:
- col = valueMentioned
- }
- }
- }
-
- n.head.setValues(n.path, [maxTreeColumn]any{
- columnUnread: col,
- })
-
- n.unread = unread
- n.updateParentUnreadIndicator()
-}
-
-func (n *baseChannelNode) isMuted() bool {
- return n.head.state().ChannelIsMuted(n.id, true)
-}
-
-// zeroInit initializes the row with a nil icon and a channel name.
-func (n *baseChannelNode) zeroInit(ch *discord.Channel) {
- n.head.set(n.path, [...]any{
- dimText(ch.Name, n.isMuted()),
- uint64(n.id),
- "",
- html.EscapeString(ch.Name),
- })
-}
-
-func (n *baseChannelNode) self() Node {
- return n.head.nodes[n.id]
-}
-
-func (n *baseChannelNode) parent() Node {
- iter, ok := n.head.TreeStore.Iter(n.TreePath())
- if !ok {
- return nil
- }
-
- iter, ok = n.head.TreeStore.IterParent(iter)
- if !ok {
- return nil
- }
-
- parent := n.head.NodeFromIter(iter)
- return parent
-}
-
-func (n *baseChannelNode) updateParentUnreadIndicator() {
- parent := n.parent()
- if parent == nil {
- return
- }
- parent.UpdateUnread()
-}
diff --git a/internal/sidebar/channels/state_nodes.go b/internal/sidebar/channels/state_nodes.go
deleted file mode 100644
index e2100b53..00000000
--- a/internal/sidebar/channels/state_nodes.go
+++ /dev/null
@@ -1,226 +0,0 @@
-package channels
-
-import (
- "fmt"
- "html"
- "sort"
-
- "github.com/diamondburned/arikawa/v3/discord"
- "github.com/diamondburned/chatkit/components/author"
-)
-
-// CategoryNode is a category node.
-type CategoryNode struct {
- baseChannelNode
- unreadMentioned map[discord.ChannelID]bool
-}
-
-func newCategoryNode(base baseChannelNode, ch *discord.Channel) *CategoryNode {
- return &CategoryNode{
- baseChannelNode: base,
- unreadMentioned: make(map[discord.ChannelID]bool),
- }
-}
-
-func (n *CategoryNode) Update(ch *discord.Channel) {
- n.baseChannelNode.Update(ch)
- n.head.setValues(n.path, [maxTreeColumn]any{
- columnName: dimText(ch.Name, n.isMuted()),
- columnTooltip: html.EscapeString(ch.Name),
- })
-}
-
-// ChannelNode is a regular text channel node.
-type ChannelNode struct {
- baseChannelNode
- parentID discord.ChannelID
-}
-
-func newChannelNode(base baseChannelNode) *ChannelNode {
- return &ChannelNode{
- baseChannelNode: base,
- }
-}
-
-const (
- chHash = `# `
- chNSFWHash = `#!`
-)
-
-func (n *ChannelNode) Update(ch *discord.Channel) {
- n.baseChannelNode.Update(ch)
- n.parentID = ch.ParentID
-
- hash := chHash
- if ch.NSFW {
- hash = chNSFWHash
- }
-
- n.head.setValues(n.path, [maxTreeColumn]any{
- // Add a space at the end because the channel's height is otherwise a
- // bit shorter.
- columnName: dimMarkup(hash+html.EscapeString(ch.Name)+" ", n.isMuted()),
- columnTooltip: "#" + html.EscapeString(ch.Name),
- })
-}
-
-// ForumNode is a node indicating a Discord forum.
-type ForumNode struct {
- baseChannelNode
-}
-
-func newForumNode(base baseChannelNode) *ForumNode {
- return &ForumNode{
- baseChannelNode: base,
- }
-}
-
-func (n *ForumNode) Update(ch *discord.Channel) {
- n.baseChannelNode.Update(ch)
- n.head.setValues(n.path, [maxTreeColumn]any{
- columnName: ch.Name,
- })
-}
-
-// ThreadNode is a node indicating a Discord thread.
-type ThreadNode struct {
- baseChannelNode
- parentID discord.ChannelID
-}
-
-func newThreadNode(base baseChannelNode) *ThreadNode {
- return &ThreadNode{
- baseChannelNode: base,
- }
-}
-
-func (n *ThreadNode) Update(ch *discord.Channel) {
- n.baseChannelNode.Update(ch)
- n.parentID = ch.ParentID
- n.head.setValues(n.path, [maxTreeColumn]any{
- columnName: dimMarkup(html.EscapeString(ch.Name)+" ", n.isMuted()),
- })
-}
-
-type VoiceChannelNode struct {
- baseChannelNode
- guildID discord.GuildID
-}
-
-func newVoiceChannelNode(base baseChannelNode) *VoiceChannelNode {
- return &VoiceChannelNode{
- baseChannelNode: base,
- }
-}
-
-const vcIcon = `🔊 `
-
-func (n *VoiceChannelNode) Update(ch *discord.Channel) {
- n.baseChannelNode.Update(ch)
- n.guildID = ch.GuildID
-
- states, _ := n.head.state().VoiceStates(ch.GuildID)
- if states == nil {
- n.setVoiceUsers(nil)
- n.head.setValues(n.path, [maxTreeColumn]any{
- columnName: vcIcon + ch.Name,
- })
- return
- }
-
- members := make([]discord.Member, 0, len(states))
- for _, state := range states {
- if state.ChannelID != ch.ID {
- continue
- }
-
- member := state.Member
- if member == nil {
- member, _ = n.head.state().Member(ch.GuildID, state.UserID)
- }
- if member == nil {
- continue
- }
- members = append(members, *member)
- }
-
- name := vcIcon + ch.Name
- if len(members) > 0 {
- name += fmt.Sprintf(" (%d)", len(members))
- }
-
- n.setVoiceUsers(members)
- n.head.setValues(n.path, [maxTreeColumn]any{
- columnName: name,
- })
-}
-
-func (n *VoiceChannelNode) setVoiceUsers(members []discord.Member) {
- // Defer clearing so GTK doesn't hide the node when we're replacing it.
- clear := n.deferClear()
- defer clear()
-
- if len(members) == 0 {
- return
- }
-
- parent, ok := n.head.TreeStore.Iter(n.path)
- if !ok {
- return
- }
-
- sort.SliceStable(members, func(i, j int) bool {
- return memberName(&members[i]) < memberName(&members[j])
- })
-
- for _, member := range members {
- iter := n.head.TreeStore.Append(parent)
- path := n.head.TreeStore.Path(iter)
-
- n.head.set(path, [...]any{
- n.head.state().MemberMarkup(
- n.guildID,
- &discord.GuildUser{User: member.User, Member: &member},
- author.WithMinimal(),
- author.WithColor(""), // no color for consistency
- ),
- uint64(discord.NullSnowflake),
- "",
- member.User.Tag(),
- })
- }
-}
-
-func memberName(member *discord.Member) string {
- if member.Nick != "" {
- return member.Nick
- }
- return member.User.Tag()
-}
-
-func (n *VoiceChannelNode) deferClear() func() {
- parent, ok := n.head.Iter(n.path)
- if !ok {
- return func() {}
- }
-
- len := n.head.TreeStore.IterNChildren(parent)
-
- return func() {
- it, ok := n.head.TreeStore.IterChildren(parent)
- for i := 0; ok && i < len; i++ {
- ok = n.head.TreeStore.Remove(it)
- }
- }
-}
-
-func dimMarkup(str string, dimmed bool) string {
- if dimmed {
- str = fmt.Sprintf(`%s`, str)
- }
- return str
-}
-
-func dimText(text string, dimmed bool) string {
- return dimMarkup(html.EscapeString(text), dimmed)
-}
diff --git a/internal/sidebar/channels/view.go b/internal/sidebar/channels/view.go
new file mode 100644
index 00000000..5454bbb7
--- /dev/null
+++ b/internal/sidebar/channels/view.go
@@ -0,0 +1,316 @@
+package channels
+
+import (
+ "context"
+ "log"
+
+ "github.com/diamondburned/arikawa/v3/discord"
+ "github.com/diamondburned/gotk4-adwaita/pkg/adw"
+ "github.com/diamondburned/gotk4/pkg/glib/v2"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "github.com/diamondburned/gotk4/pkg/pango"
+ "github.com/diamondburned/gotkit/gtkutil"
+ "github.com/diamondburned/gotkit/gtkutil/cssutil"
+ "github.com/diamondburned/gtkcord4/internal/gtkcord"
+)
+
+// Refactor notice
+//
+// We should probably settle for an API that's kind of like this:
+//
+// ch := NewView(ctx, ctrl, guildID)
+// var signal glib.SignalHandle
+// signal = ch.ConnectOnUpdate(func() bool {
+// if node := ch.Node(wantedChID); node != nil {
+// node.Select()
+// ch.HandlerDisconnect(signal)
+// }
+// })
+// ch.Invalidate()
+//
+
+const ChannelsWidth = bannerWidth
+
+// Opener is the parent controller that View controls.
+type Opener interface {
+ OpenChannel(discord.ChannelID)
+}
+
+// View holds the entire channel sidebar containing all the categories, channels
+// and threads.
+type View struct {
+ *adw.ToolbarView
+
+ Header struct {
+ *adw.HeaderBar
+ Name *gtk.Label
+ }
+
+ Scroll *gtk.ScrolledWindow
+ Child struct {
+ *gtk.Box
+ Banner *Banner
+ View *gtk.ListView
+ }
+
+ ctx gtkutil.Cancellable
+ ctrl Opener
+ model *modelManager
+
+ guildID discord.GuildID
+ selectID discord.ChannelID // delegate to select later
+}
+
+var viewCSS = cssutil.Applier("channels-view", `
+ .channels-viewtree {
+ background: none;
+ }
+ /* GTK is dumb. There's absolutely no way to get a ListItemWidget instance
+ * to style it, so we'll just unstyle everything and use the child instead.
+ */
+ .channels-viewtree > row {
+ margin: 0;
+ padding: 0;
+ }
+ .channels-viewtree > row:hover:not(:selected) {
+ background: @borders;
+ }
+ .channels-viewtree > row:hover:selected {
+ background: mix(@borders, @theme_selected_bg_color, 0.25);
+ }
+ .channels-header {
+ padding: 0 {$header_padding};
+ border-radius: 0;
+ }
+ .channels-view-scroll {
+ /* Space out the header, since it's in an overlay. */
+ margin-top: {$header_height};
+ }
+ .channels-has-banner .channels-view-scroll {
+ /* No need to space out here, since we have the banner. We do need to
+ * turn the header opaque with the styling below though, so the user can
+ * see it.
+ */
+ margin-top: 0;
+ }
+ .channels-has-banner .top-bar {
+ background-color: transparent;
+ box-shadow: none;
+ }
+ .channels-has-banner windowhandle,
+ .channels-has-banner .channels-header {
+ transition: linear 65ms all;
+ }
+ .channels-has-banner.channels-scrolled windowhandle {
+ background-color: transparent;
+ }
+ .channels-has-banner.channels-scrolled headerbar {
+ background-color: @theme_bg_color;
+ }
+ .channels-has-banner .channels-header {
+ box-shadow: 0 0 6px 0px @theme_bg_color;
+ }
+ .channels-has-banner:not(.channels-scrolled) .channels-header {
+ /* go run ./cmd/ease-in-out-gradient/ -max 0.25 -min 0 -steps 5 */
+ background: linear-gradient(to bottom,
+ alpha(black, 0.24),
+ alpha(black, 0.19),
+ alpha(black, 0.06),
+ alpha(black, 0.01),
+ alpha(black, 0.00) 100%
+ );
+ box-shadow: none;
+ border: none;
+ }
+ .channels-has-banner .channels-banner-shadow {
+ background: alpha(black, 0.75);
+ }
+ .channels-has-banner:not(.channels-scrolled) .channels-header * {
+ color: white;
+ text-shadow: 0px 0px 5px alpha(black, 0.75);
+ }
+ .channels-has-banner:not(.channels-scrolled) .channels-header *:backdrop {
+ color: alpha(white, 0.75);
+ text-shadow: 0px 0px 2px alpha(black, 0.35);
+ }
+ .channels-name {
+ font-weight: 600;
+ font-size: 1.1em;
+ }
+`)
+
+// NewView creates a new View.
+func NewView(ctx context.Context, ctrl Opener, guildID discord.GuildID) *View {
+ state := gtkcord.FromContext(ctx)
+ state.MemberState.Subscribe(guildID)
+
+ v := View{
+ ctrl: ctrl,
+ model: newModelManager(state, guildID),
+ guildID: guildID,
+ }
+
+ v.ToolbarView = adw.NewToolbarView()
+ v.ToolbarView.SetExtendContentToTopEdge(true) // basically act like an overlay
+
+ // Bind the context to cancel when we're hidden.
+ v.ctx = gtkutil.WithVisibility(ctx, v)
+
+ v.Header.Name = gtk.NewLabel("")
+ v.Header.Name.AddCSSClass("channels-name")
+ v.Header.Name.SetHAlign(gtk.AlignStart)
+ v.Header.Name.SetEllipsize(pango.EllipsizeEnd)
+
+ // The header is placed on top of the overlay, kind of like the official
+ // client.
+ v.Header.HeaderBar = adw.NewHeaderBar()
+ v.Header.HeaderBar.AddCSSClass("channels-header")
+ v.Header.HeaderBar.SetShowTitle(false)
+ v.Header.HeaderBar.PackStart(v.Header.Name)
+
+ viewport := gtk.NewViewport(nil, nil)
+
+ v.Scroll = gtk.NewScrolledWindow()
+ v.Scroll.AddCSSClass("channels-view-scroll")
+ v.Scroll.SetVExpand(true)
+ v.Scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
+ v.Scroll.SetChild(viewport)
+ // v.Scroll.SetPropagateNaturalWidth(true)
+ // v.Scroll.SetPropagateNaturalHeight(true)
+
+ var headerScrolled bool
+
+ vadj := v.Scroll.VAdjustment()
+ vadj.ConnectValueChanged(func() {
+ if scrolled := v.Child.Banner.SetScrollOpacity(vadj.Value()); scrolled {
+ if !headerScrolled {
+ headerScrolled = true
+ v.AddCSSClass("channels-scrolled")
+ }
+ } else {
+ if headerScrolled {
+ headerScrolled = false
+ v.RemoveCSSClass("channels-scrolled")
+ }
+ }
+ })
+
+ v.Child.Banner = NewBanner(ctx, guildID)
+ v.Child.Banner.Invalidate()
+
+ selection := gtk.NewSingleSelection(v.model)
+ selection.SetCanUnselect(false)
+
+ var selecting bool
+ selection.ConnectSelectionChanged(func(position, nItems uint) {
+ log.Printf("channels.View: selection changed: %d %d", position, nItems)
+
+ if selecting {
+ log.Println("BUG: infinite recursion in selection.ConnectChanged detected")
+ log.Println("BUG: ignoring selection change")
+ return
+ }
+
+ selecting = true
+ glib.IdleAdd(func() { selecting = false })
+
+ chID := channelIDFromItem(selection.SelectedItem())
+
+ ch, _ := state.Cabinet.Channel(chID)
+ if ch == nil {
+ log.Printf("channels.View: tried opening non-existent channel %d", chID)
+ return
+ }
+
+ switch ch.Type {
+ case discord.GuildCategory, discord.GuildForum:
+ // We cannot display these channel types.
+ // TODO: implement forum browsing
+ log.Printf("channels.View: ignoring channel %d of type %d", chID, ch.Type)
+ return
+ }
+
+ v.selectID = chID
+ ctrl.OpenChannel(chID)
+
+ row := v.model.Row(selection.Selected())
+ row.SetExpanded(true)
+ })
+
+ v.Child.View = gtk.NewListView(selection, newChannelItemFactory(state, v.model.TreeListModel))
+ v.Child.View.SetSizeRequest(bannerWidth, -1)
+ v.Child.View.AddCSSClass("channels-viewtree")
+ v.Child.View.SetVExpand(true)
+ v.Child.View.SetHExpand(true)
+ v.Child.View.ConnectActivate(func(position uint) {
+ row := v.model.Row(position)
+ row.SetExpanded(!row.Expanded())
+ })
+
+ v.Child.Box = gtk.NewBox(gtk.OrientationVertical, 0)
+ v.Child.Box.SetVExpand(true)
+ v.Child.Box.Append(v.Child.Banner)
+ v.Child.Box.Append(v.Child.View)
+ v.Child.Box.SetFocusChild(v.Child.View)
+
+ viewport.SetChild(v.Child)
+ viewport.SetFocusChild(v.Child)
+
+ v.ToolbarView.AddTopBar(v.Header)
+ v.ToolbarView.SetContent(v.Scroll)
+ v.ToolbarView.SetFocusChild(v.Scroll)
+
+ viewCSS(v)
+ return &v
+}
+
+// SelectChannel selects a known channel. If none is known, then it is selected
+// later when the list is changed or never selected if the user selects
+// something else.
+func (v *View) SelectChannel(selectID discord.ChannelID) bool {
+ v.selectID = selectID
+ log.Println("selecting channel", selectID)
+
+ n := v.model.NItems()
+ for i := uint(0); i < n; i++ {
+ item := v.model.Item(i)
+ chID := channelIDFromItem(item)
+ if chID != selectID {
+ continue
+ }
+ selectionModel := v.Child.View.Model()
+ return selectionModel.SelectItem(i, true)
+ }
+
+ return false
+}
+
+// GuildID returns the view's guild ID.
+func (v *View) GuildID() discord.GuildID {
+ return v.guildID
+}
+
+// InvalidateHeader invalidates the guild name and banner.
+func (v *View) InvalidateHeader() {
+ state := gtkcord.FromContext(v.ctx.Take())
+
+ g, err := state.Cabinet.Guild(v.guildID)
+ if err != nil {
+ log.Printf("channels.View: cannot fetch guild %d: %v", v.guildID, err)
+ return
+ }
+
+ // TODO: Nitro boost level
+ v.Header.Name.SetText(g.Name)
+ v.invalidateBanner()
+}
+
+func (v *View) invalidateBanner() {
+ v.Child.Banner.Invalidate()
+
+ if v.Child.Banner.HasBanner() {
+ v.AddCSSClass("channels-has-banner")
+ } else {
+ v.RemoveCSSClass("channels-has-banner")
+ }
+}
diff --git a/internal/sidebar/sidebar.go b/internal/sidebar/sidebar.go
index 5a8d394a..e66db050 100644
--- a/internal/sidebar/sidebar.go
+++ b/internal/sidebar/sidebar.go
@@ -207,7 +207,6 @@ func (s *Sidebar) openGuild(guildID discord.GuildID) *channels.View {
chs := channels.NewView(s.ctx, s.opener, guildID)
chs.SetVExpand(true)
chs.InvalidateHeader()
- chs.InvalidateChannels()
s.Right.AddChild(chs)
s.Right.SetVisibleChild(chs)
@@ -215,7 +214,7 @@ func (s *Sidebar) openGuild(guildID discord.GuildID) *channels.View {
s.removeCurrent()
s.current.w = chs
- chs.Child.Tree.GrabFocus()
+ chs.Child.View.GrabFocus()
return chs
}
@@ -247,8 +246,7 @@ func (s *Sidebar) SelectChannel(chID discord.ChannelID) {
type guildsSidebar Sidebar
func (s *guildsSidebar) OpenGuild(guildID discord.GuildID) {
- ch := (*Sidebar)(s).openGuild(guildID)
- ch.InvalidateChannels()
+ (*Sidebar)(s).openGuild(guildID)
}
// CloseGuild implements guilds.Controller.
diff --git a/internal/signaling/signaler.go b/internal/signaling/signaler.go
new file mode 100644
index 00000000..8593aab7
--- /dev/null
+++ b/internal/signaling/signaler.go
@@ -0,0 +1,74 @@
+package signaling
+
+type callback func()
+
+// Signaler manages signaling events to callbacks.
+// A zero-value Signaler is ready to use.
+type Signaler struct {
+ callbacks map[*callback]struct{}
+}
+
+// Connect connects a callback to the signaler. The returned function
+// disconnects the callback.
+func (s *Signaler) Connect(f func()) func() {
+ if s.callbacks == nil {
+ s.callbacks = make(map[*callback]struct{})
+ }
+
+ cb := (*callback)(&f)
+ s.callbacks[cb] = struct{}{}
+
+ return func() {
+ delete(s.callbacks, cb)
+ }
+}
+
+// Signal signals all callbacks.
+func (s *Signaler) Signal() {
+ for cb := range s.callbacks {
+ (*cb)()
+ }
+}
+
+// Disconnect disconnects all callbacks.
+func (s *Signaler) Disconnect() {
+ for cb := range s.callbacks {
+ delete(s.callbacks, cb)
+ }
+}
+
+// DisconnectStack is a stack of disconnect functions.
+// Use it to defer disconnecting callbacks.
+type DisconnectStack struct {
+ funcs []func()
+}
+
+// Push pushes a disconnect function to the stack.
+func (d *DisconnectStack) Push(funcs ...func()) {
+ d.funcs = append(d.funcs, funcs...)
+}
+
+// Connect connects a callback to the stack.
+func (d *DisconnectStack) Connect(s *Signaler, f func()) {
+ d.Push(s.Connect(f))
+}
+
+// Pop pops a disconnect function from the stack.
+func (d *DisconnectStack) Pop() {
+ if len(d.funcs) == 0 {
+ return
+ }
+
+ f := d.funcs[len(d.funcs)-1]
+ d.funcs[len(d.funcs)-1] = nil
+ d.funcs = d.funcs[:len(d.funcs)-1]
+ f()
+}
+
+// Disconnect disconnects all callbacks.
+func (d *DisconnectStack) Disconnect() {
+ for _, f := range d.funcs {
+ f()
+ }
+ d.funcs = nil
+}
diff --git a/main.go b/main.go
index 6b81f82d..31269d8d 100644
--- a/main.go
+++ b/main.go
@@ -13,11 +13,11 @@ import (
"github.com/diamondburned/gotkit/components/prefui"
"github.com/diamondburned/gotkit/gtkutil/cssutil"
"github.com/diamondburned/gtkcord4/internal/gtkcord"
- _ "github.com/diamondburned/gtkcord4/internal/icons"
"github.com/diamondburned/gtkcord4/internal/window"
"github.com/diamondburned/gtkcord4/internal/window/about"
_ "github.com/diamondburned/gotkit/gtkutil/aggressivegc"
+ _ "github.com/diamondburned/gtkcord4/internal/icons"
)
//go:embed po/*