Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/markdown.go
366 views
1
package messages
2
3
import (
4
"context"
5
"log/slog"
6
7
"github.com/diamondburned/arikawa/v3/discord"
8
"github.com/diamondburned/chatkit/md"
9
"github.com/diamondburned/chatkit/md/block"
10
"github.com/diamondburned/chatkit/md/mdrender"
11
"github.com/diamondburned/gotk4/pkg/gtk/v4"
12
"github.com/diamondburned/gotk4/pkg/pango"
13
"github.com/diamondburned/gotkit/components/onlineimage"
14
"github.com/diamondburned/gotkit/gtkutil/cssutil"
15
"github.com/diamondburned/gotkit/gtkutil/imgutil"
16
"github.com/diamondburned/gotkit/gtkutil/textutil"
17
"github.com/diamondburned/ningen/v3/discordmd"
18
"github.com/yuin/goldmark/ast"
19
"libdb.so/ctxt"
20
"libdb.so/dissent/internal/gtkcord"
21
)
22
23
type markdownState struct {
24
bindedSpoilerBlocks map[*block.TextBlock]struct{}
25
}
26
27
func newMarkdownState() *markdownState {
28
return &markdownState{
29
bindedSpoilerBlocks: make(map[*block.TextBlock]struct{}),
30
}
31
}
32
33
func mustMarkdownState(ctx context.Context) *markdownState {
34
state, ok := ctxt.From[*markdownState](ctx)
35
if !ok {
36
panic("invalid markdown state")
37
}
38
return state
39
}
40
41
var renderers = []mdrender.OptionFunc{
42
mdrender.WithRenderer(discordmd.KindEmoji, renderEmoji),
43
mdrender.WithRenderer(discordmd.KindInline, renderInline),
44
mdrender.WithRenderer(discordmd.KindMention, renderMention),
45
}
46
47
var inlineEmojiTag = textutil.TextTag{
48
"rise": -5 * pango.SCALE,
49
"rise-set": true,
50
}
51
52
func renderEmoji(ctx context.Context, r *mdrender.Renderer, n ast.Node) ast.WalkStatus {
53
emoji := n.(*discordmd.Emoji)
54
text := r.State(ctx).TextBlock()
55
56
picture := onlineimage.NewPicture(ctx, imgutil.HTTPProvider)
57
picture.EnableAnimation().OnHover()
58
picture.SetContentFit(gtk.ContentFitContain)
59
picture.SetTooltipText(emoji.Name)
60
picture.SetURL(gtkcord.EmojiURL(emoji.ID, emoji.GIF))
61
62
var inlineImage *md.InlineImage
63
makeInlineImage := func(size int) {
64
inlineImage = md.InsertCustomImageWidget(text.TextView, text.Buffer.CreateChildAnchor(text.Iter), picture)
65
inlineImage.SetSizeRequest(size, size)
66
}
67
68
if emoji.Large {
69
makeInlineImage(gtkcord.LargeEmojiSize)
70
} else {
71
tag := inlineEmojiTag.FromTable(text.Buffer.TagTable(), "inline-emoji")
72
text.TagBounded(tag, func() { makeInlineImage(gtkcord.InlineEmojiSize) })
73
}
74
75
return ast.WalkContinue
76
}
77
78
var htmlTagMap = map[discordmd.Attribute]string{
79
discordmd.AttrBold: "b",
80
discordmd.AttrItalics: "i",
81
discordmd.AttrUnderline: "u",
82
discordmd.AttrStrikethrough: "strike",
83
discordmd.AttrMonospace: "code",
84
}
85
86
var _ = cssutil.WriteCSS(`
87
.md-spoiler {
88
color: mix(@theme_bg_color, black, 0.11);
89
}
90
.md-spoiler.dark {
91
color: mix(@theme_bg_color, black, 0.85);
92
}
93
`)
94
95
func getSpoilerColor(state *block.ContainerState, alpha float32) string {
96
l := gtk.NewLabel("")
97
l.AddCSSClass("md-spoiler")
98
99
c := state.Viewer.StyleContext().Color()
100
if !textutil.ColorIsDark(c.Red(), c.Green(), c.Blue()) {
101
l.AddCSSClass("dark")
102
}
103
104
c = l.StyleContext().Color()
105
c.SetAlpha(alpha)
106
return c.String()
107
}
108
109
func renderInline(ctx context.Context, r *mdrender.Renderer, n ast.Node) ast.WalkStatus {
110
inline := n.(*discordmd.Inline)
111
112
stateInternal := mustMarkdownState(ctx)
113
state := r.State(ctx)
114
text := state.TextBlock()
115
116
// Only bind our cursor position listener if we were not in a text block,
117
// since this text block is a new one.
118
if _, binded := stateInternal.bindedSpoilerBlocks[text]; !binded {
119
stateInternal.bindedSpoilerBlocks[text] = struct{}{}
120
slog.Debug(
121
"binding cursor-position handler for text block",
122
"native", text.TextView.Object.Native())
123
124
// Handle the spoiler being revealed.
125
text.Buffer.NotifyProperty("cursor-position", func() {
126
insert := text.Buffer.GetInsert()
127
insertIter := text.Buffer.IterAtMark(insert)
128
129
spoilerTag := state.Viewer.TagTable().Lookup("spoiler")
130
if spoilerTag != nil && insertIter.HasTag(spoilerTag) {
131
spoilerStart := insertIter.Copy()
132
spoilerStart.BackwardToTagToggle(spoilerTag)
133
134
spoilerEnd := insertIter.Copy()
135
spoilerEnd.ForwardToTagToggle(spoilerTag)
136
137
slog.Debug(
138
"clicked on spoiler tag",
139
"start", spoilerStart.Offset(),
140
"end", spoilerEnd.Offset(),
141
"native", text.TextView.Object.Native())
142
143
text.Buffer.RemoveTag(spoilerTag, spoilerStart, spoilerEnd)
144
145
revealedTagAttrs := textutil.TextTag{
146
"background": getSpoilerColor(state, 0.75),
147
}
148
revealedTag := revealedTagAttrs.FromTable(state.Viewer.TagTable(), "spoiler-revealed")
149
text.Buffer.ApplyTag(revealedTag, spoilerStart, spoilerEnd)
150
}
151
})
152
}
153
154
startIx := text.Iter.Offset()
155
156
if inline.Attr.Has(discordmd.AttrSpoiler) {
157
// Pad with a space.
158
text.Insert(" ")
159
}
160
161
// Render everything inside. We'll wrap the whole region with tags.
162
r.RenderChildren(ctx, n)
163
164
if inline.Attr.Has(discordmd.AttrSpoiler) {
165
text.Insert(" ")
166
}
167
168
start := text.Buffer.IterAtOffset(startIx)
169
end := text.Iter
170
171
for tag, htmltag := range htmlTagMap {
172
if inline.Attr.Has(tag) {
173
tag := text.Tag(htmltag)
174
text.Buffer.ApplyTag(tag, start, end)
175
}
176
}
177
178
if inline.Attr.Has(discordmd.AttrSpoiler) {
179
spoilerColor := getSpoilerColor(state, 1.0)
180
tagAttrs := textutil.TextTag{
181
"background": spoilerColor,
182
"foreground": spoilerColor,
183
}
184
185
tag := tagAttrs.FromTable(state.Viewer.TagTable(), "spoiler")
186
text.Buffer.ApplyTag(tag, start, end)
187
}
188
189
return ast.WalkSkipChildren
190
}
191
192
// rgba(111, 120, 219, 0.3)
193
const defaultMentionColor = "#6F78DB"
194
195
func mentionTag(ctx context.Context, r *mdrender.Renderer, color string) *gtk.TextTag {
196
tag := textutil.TextTag{"background": color + "76"}
197
return tag.FromTable(r.State(ctx).TagTable(), tag.Hash())
198
}
199
200
func renderMention(ctx context.Context, r *mdrender.Renderer, n ast.Node) ast.WalkStatus {
201
mention := n.(*discordmd.Mention)
202
203
text := r.State(ctx).TextBlock()
204
205
switch {
206
case mention.Channel != nil:
207
text.TagBounded(mentionTag(ctx, r, defaultMentionColor), func() {
208
text.Insert(" #" + mention.Channel.Name + " ")
209
})
210
211
case mention.GuildRole != nil:
212
roleColor := defaultMentionColor
213
if mention.GuildRole.Color != discord.NullColor {
214
roleColor = mention.GuildRole.Color.String()
215
}
216
217
text.TagBounded(mentionTag(ctx, r, roleColor), func() {
218
text.Insert(" @" + mention.GuildRole.Name + " ")
219
})
220
221
case mention.GuildUser != nil:
222
chip := newAuthorChip(ctx, mention.Message.GuildID, mention.GuildUser)
223
chip.InsertText(text.TextView, text.Iter)
224
}
225
226
return ast.WalkContinue
227
}
228
229