Path: blob/main/internal/sidebar/channels/view.go
366 views
package channels12import (3"context"4"log/slog"56"github.com/diamondburned/arikawa/v3/discord"7"github.com/diamondburned/gotk4-adwaita/pkg/adw"8"github.com/diamondburned/gotk4/pkg/gtk/v4"9"github.com/diamondburned/gotk4/pkg/pango"10"github.com/diamondburned/gotkit/gtkutil"11"github.com/diamondburned/gotkit/gtkutil/cssutil"12"libdb.so/dissent/internal/gtkcord"13)1415// Refactor notice16//17// We should probably settle for an API that's kind of like this:18//19// ch := NewView(ctx, ctrl, guildID)20// var signal glib.SignalHandle21// signal = ch.ConnectOnUpdate(func() bool {22// if node := ch.Node(wantedChID); node != nil {23// node.Select()24// ch.HandlerDisconnect(signal)25// }26// })27// ch.Invalidate()28//2930const ChannelsWidth = bannerWidth3132// View holds the entire channel sidebar containing all the categories, channels33// and threads.34type View struct {35*adw.ToolbarView3637HeaderView *gtk.Overlay38HeaderBar *adw.HeaderBar39GuildName *gtk.Label40Banner *Banner4142Scroll *gtk.ScrolledWindow43ChannelList *gtk.ListView4445ctx gtkutil.Cancellable4647model *modelManager48selection *gtk.SingleSelection4950guildID discord.GuildID51selectID discord.ChannelID // delegate to select later52}5354var viewCSS = cssutil.Applier("channels-view", `55.channels-viewtree {56background: none; /* adwaita reset */57}58/* GTK is dumb. There's absolutely no way to get a ListItemWidget instance59* to style it, so we'll just unstyle everything and use the child instead.60*/61.channels-viewtree > row {62margin: 0;63padding: 0;64}65.channels-name {66font-weight: 600;67font-size: 1.1em;68margin: 0.25em 0.5em;69}70.channels-header {71border-radius: 0;72}73.channels-has-banner .channels-header * {74color: white;75text-shadow: 0px 0px 6px alpha(black, 0.65);76}77.channels-has-banner .channels-header *:backdrop {78color: alpha(white, 0.75);79text-shadow: 0px 0px 3px alpha(black, 0.35);80}81`)8283// NewView creates a new View.84func NewView(ctx context.Context, guildID discord.GuildID) *View {85state := gtkcord.FromContext(ctx)86state.MemberState.Subscribe(guildID)8788v := View{89model: newModelManager(state, guildID),90guildID: guildID,91}9293v.ToolbarView = adw.NewToolbarView()94v.ToolbarView.SetTopBarStyle(adw.ToolbarFlat)9596// Bind the context to cancel when we're hidden.97v.ctx = gtkutil.WithVisibility(ctx, v)9899v.GuildName = gtk.NewLabel("")100v.GuildName.AddCSSClass("channels-name")101v.GuildName.SetHAlign(gtk.AlignStart)102v.GuildName.SetEllipsize(pango.EllipsizeEnd)103104// The header is placed on top of the overlay, kind of like the official105// client.106v.HeaderBar = adw.NewHeaderBar()107v.HeaderBar.AddCSSClass("titlebar")108v.HeaderBar.AddCSSClass("channels-header")109v.HeaderBar.SetShowTitle(false)110v.HeaderBar.PackStart(v.GuildName)111v.HeaderBar.SetShowStartTitleButtons(false)112v.HeaderBar.SetShowEndTitleButtons(false)113v.HeaderBar.SetShowBackButton(false)114v.HeaderBar.SetVAlign(gtk.AlignEnd)115v.HeaderBar.SetHAlign(gtk.AlignFill)116117v.Banner = NewBanner(ctx, guildID)118v.Banner.Invalidate()119120v.HeaderView = gtk.NewOverlay()121v.HeaderView.SetChild(v.Banner)122v.HeaderView.AddOverlay(v.HeaderBar)123v.HeaderView.SetMeasureOverlay(v.HeaderBar, true)124125viewport := gtk.NewViewport(nil, nil)126127v.Scroll = gtk.NewScrolledWindow()128v.Scroll.AddCSSClass("channels-view-scroll")129v.Scroll.SetVExpand(true)130v.Scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)131v.Scroll.SetChild(viewport)132// v.Scroll.SetPropagateNaturalWidth(true)133// v.Scroll.SetPropagateNaturalHeight(true)134135var headerScrolled bool136137vadj := v.Scroll.VAdjustment()138vadj.ConnectValueChanged(func() {139if scrolled := v.Banner.SetScrollOpacity(vadj.Value()); scrolled {140if !headerScrolled {141headerScrolled = true142v.AddCSSClass("channels-scrolled")143}144} else {145if headerScrolled {146headerScrolled = false147v.RemoveCSSClass("channels-scrolled")148}149}150})151152v.selection = gtk.NewSingleSelection(v.model)153v.selection.SetAutoselect(false)154v.selection.SetCanUnselect(true)155156v.ChannelList = gtk.NewListView(v.selection, newChannelItemFactory(ctx, v.model.TreeListModel))157v.ChannelList.SetSizeRequest(bannerWidth, -1)158v.ChannelList.AddCSSClass("channels-viewtree")159v.ChannelList.SetVExpand(true)160v.ChannelList.SetHExpand(true)161162viewport.SetChild(v.ChannelList)163viewport.SetFocusChild(v.ChannelList)164165v.ToolbarView.AddTopBar(v.HeaderView)166v.ToolbarView.SetContent(v.Scroll)167168var lastOpen discord.ChannelID169170v.selection.ConnectSelectionChanged(func(position, nItems uint) {171item := v.selection.SelectedItem()172if item == nil {173// ctrl.OpenChannel(0)174return175}176177chID := channelIDFromItem(item)178179if lastOpen == chID {180return181}182lastOpen = chID183184ch, _ := state.Cabinet.Channel(chID)185if ch == nil {186slog.Error(187"tried opening non-existent channel",188"channel_id", chID)189return190}191192switch ch.Type {193case discord.GuildCategory, discord.GuildForum:194// We cannot display these channel types.195// TODO: implement forum browsing196slog.Warn(197"category or forum channel selected, ignoring",198"channel_type", ch.Type,199"channel_id", chID)200return201}202203slog.Debug(204"selection change signal emitted, selecting channel and clearing selectID",205"channel_type", ch.Type,206"channel_id", chID)207v.selectID = 0208209row := v.model.Row(v.selection.Selected())210row.SetExpanded(true)211212parent := gtk.BaseWidget(v.ChannelList.Parent())213parent.ActivateAction("win.open-channel", gtkcord.NewChannelIDVariant(chID))214})215216// Bind to a signal that selects any channel that we need to be selected.217// This lets the channel be lazy-loaded.218v.selection.ConnectAfter("items-changed", func() {219if v.selectID == 0 {220return221}222223i, ok := v.findChannelItem(v.selectID)224if ok {225slog.Debug(226"items-changed signal emitted, re-selecting stored channel",227"channel_id", v.selectID,228"channel_index", i)229v.selection.SelectItem(i, true)230v.selectID = 0231} else {232slog.Debug(233"items-changed signal emitted but stored channel not found",234"channel_id", v.selectID)235}236})237238viewCSS(v)239return &v240}241242// SelectChannel selects a known channel. If none is known, then it is selected243// later when the list is changed or never selected if the user selects244// something else.245func (v *View) SelectChannel(selectID discord.ChannelID) bool {246i, ok := v.findChannelItem(selectID)247if ok && v.selection.SelectItem(i, true) {248slog.Debug(249"channel found and selected immediately",250"channel_id", selectID,251"channel_index", i)252v.selectID = 0253return true254}255256slog.Debug(257"channel not found, selecting later",258"channel_id", selectID)259v.selectID = selectID260return false261}262263// findChannelItem finds the channel item by ID.264// BUG: this function is not able to find channels within collapsed categories.265func (v *View) findChannelItem(id discord.ChannelID) (uint, bool) {266n := v.selection.NItems()267for i := uint(0); i < n; i++ {268item := v.selection.Item(i)269chID := channelIDFromItem(item)270if chID == id {271return i, true272}273}274// TODO: recursively search v.model so we can find collapsed channels.275return n, false276}277278// GuildID returns the view's guild ID.279func (v *View) GuildID() discord.GuildID {280return v.guildID281}282283// InvalidateHeader invalidates the guild name and banner.284func (v *View) InvalidateHeader() {285state := gtkcord.FromContext(v.ctx.Take())286287g, err := state.Cabinet.Guild(v.guildID)288if err != nil {289slog.Warn(290"cannot fetch guild to check banner",291"guild_id", v.guildID,292"err", err)293return294}295296// TODO: Nitro boost level297v.GuildName.SetText(g.Name)298v.invalidateBanner()299}300301func (v *View) invalidateBanner() {302v.Banner.Invalidate()303if v.Banner.HasBanner() {304v.AddCSSClass("channels-has-banner")305} else {306v.RemoveCSSClass("channels-has-banner")307}308}309310311