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/*