Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/sidebar/channels/view.go
366 views
1
package channels
2
3
import (
4
"context"
5
"log/slog"
6
7
"github.com/diamondburned/arikawa/v3/discord"
8
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
9
"github.com/diamondburned/gotk4/pkg/gtk/v4"
10
"github.com/diamondburned/gotk4/pkg/pango"
11
"github.com/diamondburned/gotkit/gtkutil"
12
"github.com/diamondburned/gotkit/gtkutil/cssutil"
13
"libdb.so/dissent/internal/gtkcord"
14
)
15
16
// Refactor notice
17
//
18
// We should probably settle for an API that's kind of like this:
19
//
20
// ch := NewView(ctx, ctrl, guildID)
21
// var signal glib.SignalHandle
22
// signal = ch.ConnectOnUpdate(func() bool {
23
// if node := ch.Node(wantedChID); node != nil {
24
// node.Select()
25
// ch.HandlerDisconnect(signal)
26
// }
27
// })
28
// ch.Invalidate()
29
//
30
31
const ChannelsWidth = bannerWidth
32
33
// View holds the entire channel sidebar containing all the categories, channels
34
// and threads.
35
type View struct {
36
*adw.ToolbarView
37
38
HeaderView *gtk.Overlay
39
HeaderBar *adw.HeaderBar
40
GuildName *gtk.Label
41
Banner *Banner
42
43
Scroll *gtk.ScrolledWindow
44
ChannelList *gtk.ListView
45
46
ctx gtkutil.Cancellable
47
48
model *modelManager
49
selection *gtk.SingleSelection
50
51
guildID discord.GuildID
52
selectID discord.ChannelID // delegate to select later
53
}
54
55
var viewCSS = cssutil.Applier("channels-view", `
56
.channels-viewtree {
57
background: none; /* adwaita reset */
58
}
59
/* GTK is dumb. There's absolutely no way to get a ListItemWidget instance
60
* to style it, so we'll just unstyle everything and use the child instead.
61
*/
62
.channels-viewtree > row {
63
margin: 0;
64
padding: 0;
65
}
66
.channels-name {
67
font-weight: 600;
68
font-size: 1.1em;
69
margin: 0.25em 0.5em;
70
}
71
.channels-header {
72
border-radius: 0;
73
}
74
.channels-has-banner .channels-header * {
75
color: white;
76
text-shadow: 0px 0px 6px alpha(black, 0.65);
77
}
78
.channels-has-banner .channels-header *:backdrop {
79
color: alpha(white, 0.75);
80
text-shadow: 0px 0px 3px alpha(black, 0.35);
81
}
82
`)
83
84
// NewView creates a new View.
85
func NewView(ctx context.Context, guildID discord.GuildID) *View {
86
state := gtkcord.FromContext(ctx)
87
state.MemberState.Subscribe(guildID)
88
89
v := View{
90
model: newModelManager(state, guildID),
91
guildID: guildID,
92
}
93
94
v.ToolbarView = adw.NewToolbarView()
95
v.ToolbarView.SetTopBarStyle(adw.ToolbarFlat)
96
97
// Bind the context to cancel when we're hidden.
98
v.ctx = gtkutil.WithVisibility(ctx, v)
99
100
v.GuildName = gtk.NewLabel("")
101
v.GuildName.AddCSSClass("channels-name")
102
v.GuildName.SetHAlign(gtk.AlignStart)
103
v.GuildName.SetEllipsize(pango.EllipsizeEnd)
104
105
// The header is placed on top of the overlay, kind of like the official
106
// client.
107
v.HeaderBar = adw.NewHeaderBar()
108
v.HeaderBar.AddCSSClass("titlebar")
109
v.HeaderBar.AddCSSClass("channels-header")
110
v.HeaderBar.SetShowTitle(false)
111
v.HeaderBar.PackStart(v.GuildName)
112
v.HeaderBar.SetShowStartTitleButtons(false)
113
v.HeaderBar.SetShowEndTitleButtons(false)
114
v.HeaderBar.SetShowBackButton(false)
115
v.HeaderBar.SetVAlign(gtk.AlignEnd)
116
v.HeaderBar.SetHAlign(gtk.AlignFill)
117
118
v.Banner = NewBanner(ctx, guildID)
119
v.Banner.Invalidate()
120
121
v.HeaderView = gtk.NewOverlay()
122
v.HeaderView.SetChild(v.Banner)
123
v.HeaderView.AddOverlay(v.HeaderBar)
124
v.HeaderView.SetMeasureOverlay(v.HeaderBar, true)
125
126
viewport := gtk.NewViewport(nil, nil)
127
128
v.Scroll = gtk.NewScrolledWindow()
129
v.Scroll.AddCSSClass("channels-view-scroll")
130
v.Scroll.SetVExpand(true)
131
v.Scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
132
v.Scroll.SetChild(viewport)
133
// v.Scroll.SetPropagateNaturalWidth(true)
134
// v.Scroll.SetPropagateNaturalHeight(true)
135
136
var headerScrolled bool
137
138
vadj := v.Scroll.VAdjustment()
139
vadj.ConnectValueChanged(func() {
140
if scrolled := v.Banner.SetScrollOpacity(vadj.Value()); scrolled {
141
if !headerScrolled {
142
headerScrolled = true
143
v.AddCSSClass("channels-scrolled")
144
}
145
} else {
146
if headerScrolled {
147
headerScrolled = false
148
v.RemoveCSSClass("channels-scrolled")
149
}
150
}
151
})
152
153
v.selection = gtk.NewSingleSelection(v.model)
154
v.selection.SetAutoselect(false)
155
v.selection.SetCanUnselect(true)
156
157
v.ChannelList = gtk.NewListView(v.selection, newChannelItemFactory(ctx, v.model.TreeListModel))
158
v.ChannelList.SetSizeRequest(bannerWidth, -1)
159
v.ChannelList.AddCSSClass("channels-viewtree")
160
v.ChannelList.SetVExpand(true)
161
v.ChannelList.SetHExpand(true)
162
163
viewport.SetChild(v.ChannelList)
164
viewport.SetFocusChild(v.ChannelList)
165
166
v.ToolbarView.AddTopBar(v.HeaderView)
167
v.ToolbarView.SetContent(v.Scroll)
168
169
var lastOpen discord.ChannelID
170
171
v.selection.ConnectSelectionChanged(func(position, nItems uint) {
172
item := v.selection.SelectedItem()
173
if item == nil {
174
// ctrl.OpenChannel(0)
175
return
176
}
177
178
chID := channelIDFromItem(item)
179
180
if lastOpen == chID {
181
return
182
}
183
lastOpen = chID
184
185
ch, _ := state.Cabinet.Channel(chID)
186
if ch == nil {
187
slog.Error(
188
"tried opening non-existent channel",
189
"channel_id", chID)
190
return
191
}
192
193
switch ch.Type {
194
case discord.GuildCategory, discord.GuildForum:
195
// We cannot display these channel types.
196
// TODO: implement forum browsing
197
slog.Warn(
198
"category or forum channel selected, ignoring",
199
"channel_type", ch.Type,
200
"channel_id", chID)
201
return
202
}
203
204
slog.Debug(
205
"selection change signal emitted, selecting channel and clearing selectID",
206
"channel_type", ch.Type,
207
"channel_id", chID)
208
v.selectID = 0
209
210
row := v.model.Row(v.selection.Selected())
211
row.SetExpanded(true)
212
213
parent := gtk.BaseWidget(v.ChannelList.Parent())
214
parent.ActivateAction("win.open-channel", gtkcord.NewChannelIDVariant(chID))
215
})
216
217
// Bind to a signal that selects any channel that we need to be selected.
218
// This lets the channel be lazy-loaded.
219
v.selection.ConnectAfter("items-changed", func() {
220
if v.selectID == 0 {
221
return
222
}
223
224
i, ok := v.findChannelItem(v.selectID)
225
if ok {
226
slog.Debug(
227
"items-changed signal emitted, re-selecting stored channel",
228
"channel_id", v.selectID,
229
"channel_index", i)
230
v.selection.SelectItem(i, true)
231
v.selectID = 0
232
} else {
233
slog.Debug(
234
"items-changed signal emitted but stored channel not found",
235
"channel_id", v.selectID)
236
}
237
})
238
239
viewCSS(v)
240
return &v
241
}
242
243
// SelectChannel selects a known channel. If none is known, then it is selected
244
// later when the list is changed or never selected if the user selects
245
// something else.
246
func (v *View) SelectChannel(selectID discord.ChannelID) bool {
247
i, ok := v.findChannelItem(selectID)
248
if ok && v.selection.SelectItem(i, true) {
249
slog.Debug(
250
"channel found and selected immediately",
251
"channel_id", selectID,
252
"channel_index", i)
253
v.selectID = 0
254
return true
255
}
256
257
slog.Debug(
258
"channel not found, selecting later",
259
"channel_id", selectID)
260
v.selectID = selectID
261
return false
262
}
263
264
// findChannelItem finds the channel item by ID.
265
// BUG: this function is not able to find channels within collapsed categories.
266
func (v *View) findChannelItem(id discord.ChannelID) (uint, bool) {
267
n := v.selection.NItems()
268
for i := uint(0); i < n; i++ {
269
item := v.selection.Item(i)
270
chID := channelIDFromItem(item)
271
if chID == id {
272
return i, true
273
}
274
}
275
// TODO: recursively search v.model so we can find collapsed channels.
276
return n, false
277
}
278
279
// GuildID returns the view's guild ID.
280
func (v *View) GuildID() discord.GuildID {
281
return v.guildID
282
}
283
284
// InvalidateHeader invalidates the guild name and banner.
285
func (v *View) InvalidateHeader() {
286
state := gtkcord.FromContext(v.ctx.Take())
287
288
g, err := state.Cabinet.Guild(v.guildID)
289
if err != nil {
290
slog.Warn(
291
"cannot fetch guild to check banner",
292
"guild_id", v.guildID,
293
"err", err)
294
return
295
}
296
297
// TODO: Nitro boost level
298
v.GuildName.SetText(g.Name)
299
v.invalidateBanner()
300
}
301
302
func (v *View) invalidateBanner() {
303
v.Banner.Invalidate()
304
if v.Banner.HasBanner() {
305
v.AddCSSClass("channels-has-banner")
306
} else {
307
v.RemoveCSSClass("channels-has-banner")
308
}
309
}
310
311