diff --git a/go.mod b/go.mod index d96b7bd..8d6d667 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.14 replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20201230071527-a77c32eb3876 -// replace github.com/diamondburned/gotk3-tcmalloc => ../../gotk3-tcmalloc // replace github.com/diamondburned/cchat-discord => ../cchat-discord +// replace github.com/diamondburned/gotk3-tcmalloc => ../../gotk3-tcmalloc // replace github.com/diamondburned/ningen/v2 => ../../ningen // replace github.com/diamondburned/arikawa/v2 => ../../arikawa @@ -13,7 +13,7 @@ require ( github.com/Xuanwo/go-locale v1.0.0 github.com/alecthomas/chroma v0.7.3 github.com/diamondburned/cchat v0.3.15 - github.com/diamondburned/cchat-discord v0.0.0-20201227035212-6beff5225092 + github.com/diamondburned/cchat-discord v0.0.0-20201231025836-96e97aa11705 github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db github.com/diamondburned/gspell v0.0.0-20201229064336-e43698fd5828 github.com/diamondburned/handy v0.0.0-20201229063418-ec23c1370374 diff --git a/go.sum b/go.sum index e429954..f798526 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/diamondburned/cchat-discord v0.0.0-20201227023505-c4e360010fb8 h1:eyK github.com/diamondburned/cchat-discord v0.0.0-20201227023505-c4e360010fb8/go.mod h1:i3y8dyAFrtigpGOwunBdoJK/phwt9Gp/wfpVJb4imV0= github.com/diamondburned/cchat-discord v0.0.0-20201227035212-6beff5225092 h1:oxY7APUclLgaWjaTK++7kHBdl0GdVyqOvHQv68TcpHw= github.com/diamondburned/cchat-discord v0.0.0-20201227035212-6beff5225092/go.mod h1:rFBGZYLq0g6Pb/WGN/K0++kXrhCYlQQ1nc2FX4r8CO0= +github.com/diamondburned/cchat-discord v0.0.0-20201231025836-96e97aa11705 h1:g0hwnUpeJ3yo7WaVZjWBQw875tnKVjCz4YofnamE9Fg= +github.com/diamondburned/cchat-discord v0.0.0-20201231025836-96e97aa11705/go.mod h1:rFBGZYLq0g6Pb/WGN/K0++kXrhCYlQQ1nc2FX4r8CO0= github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db h1:VQI2PdbsdsRJ7d669kp35GbCUO44KZ0Xfqdu4o/oqVg= github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db/go.mod h1:M87kjNzWVPlkZycFNzpGPKQXzkHNnZphuwMf3E9ckgc= github.com/diamondburned/gotk3 v0.0.0-20201209182406-e7291341a091 h1:lQpSWzbi3rQf66aMSip/rIypasIFwqCqF0Wfn5og6gw= diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index ebdc47a..bc79a82 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -1,9 +1,6 @@ package cozy import ( - "context" - "runtime/pprof" - "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/ui/messages/container" @@ -85,7 +82,7 @@ func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage { // We can do the check here since we're never using NewPresendMessage for // backlog messages. - if c.lastMessageIsAuthor(msg.AuthorID(), 0) { + if c.lastMessageIsAuthor(msg.AuthorID(), msg.Author().String(), 0) { return NewCollapsedSendingMessage(msg) } @@ -123,53 +120,52 @@ func (c *Container) reuseAvatar(authorID, avatarURL string, full *FullMessage) { } } -func (c *Container) lastMessageIsAuthor(id string, offset int) bool { +func (c *Container) lastMessageIsAuthor(id cchat.ID, name string, offset int) bool { // Get the offfsetth message from last. var last = c.GridStore.NthMessage((c.GridStore.MessagesLen() - 1) + offset) - return last != nil && last.AuthorID() == id + return gridMessageIsAuthor(last, id, name) } -var createMessageLabel = pprof.Labels("cozy", "createMessage") +func gridMessageIsAuthor(gridMsg container.GridMessage, id cchat.ID, name string) bool { + return gridMsg != nil && + gridMsg.AuthorID() == id && + gridMsg.AuthorName() == name +} func (c *Container) CreateMessage(msg cchat.MessageCreate) { gts.ExecAsync(func() { - pprof.Do(context.Background(), createMessageLabel, func(context.Context) { + // Create the message in the parent's handler. This handler will also + // wipe old messages. + c.GridContainer.CreateMessageUnsafe(msg) - // Create the message in the parent's handler. This handler will also - // wipe old messages. - c.GridContainer.CreateMessageUnsafe(msg) + // Did the handler wipe old messages? It will only do so if the user is + // scrolled to the bottom. + if c.GridContainer.CleanMessages() { + // We need to uncollapse the first (top) message. No length check is + // needed here, as we just inserted a message. + c.uncompact(c.FirstMessage()) + } - // Did the handler wipe old messages? It will only do so if the user is - // scrolled to the bottom. - if c.GridContainer.CleanMessages() { - // We need to uncollapse the first (top) message. No length check is - // needed here, as we just inserted a message. - c.uncompact(c.FirstMessage()) + switch msg.ID() { + // Should we collapse this message? Yes, if the current message is + // inserted at the end and its author is the same as the last author. + case c.GridContainer.LastMessage().ID(): + author := msg.Author() + if c.lastMessageIsAuthor(author.ID(), author.Name().String(), -1) { + c.compact(c.GridContainer.LastMessage()) } - switch msg.ID() { - // Should we collapse this message? Yes, if the current message is - // inserted at the end and its author is the same as the last author. - case c.GridContainer.LastMessage().ID(): - if c.lastMessageIsAuthor(msg.Author().ID(), -1) { - c.compact(c.GridContainer.LastMessage()) - } - - // If we've prepended the message, then see if we need to collapse the - // second message. - case c.GridContainer.FirstMessage().ID(): - if sec := c.NthMessage(1); sec != nil { - // If the author isn't the same, then ignore. - if sec.AuthorID() != msg.Author().ID() { - return - } - - // The author is the same; collapse. + // If we've prepended the message, then see if we need to collapse the + // second message. + case c.GridContainer.FirstMessage().ID(): + if sec := c.NthMessage(1); sec != nil { + // The author is the same; collapse. + author := msg.Author() + if gridMessageIsAuthor(sec, author.ID(), author.Name().String()) { c.compact(sec) } } - - }) + } }) } diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go index 4cf14a5..8ef09ef 100644 --- a/internal/ui/messages/container/cozy/message_collapsed.go +++ b/internal/ui/messages/container/cozy/message_collapsed.go @@ -34,6 +34,8 @@ func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage { // Set Content's padding accordingly to FullMessage's main box. gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2) + gc.Username.SetMaxWidthChars(30) + return &CollapsedMessage{ GenericContainer: gc, } diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index f5ed3a5..c081e22 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -78,6 +78,8 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage { gc.Timestamp.SetVAlign(gtk.ALIGN_END) // bottom-align gc.Timestamp.SetMarginStart(0) // clear margins + gc.Username.SetMaxWidthChars(75) + // Attach the class and CSS for the left avatar. avatarCSS(avatar) diff --git a/internal/ui/messages/memberlist/memberlist.go b/internal/ui/messages/memberlist/memberlist.go index cf4d917..d0e9188 100644 --- a/internal/ui/messages/memberlist/memberlist.go +++ b/internal/ui/messages/memberlist/memberlist.go @@ -364,6 +364,10 @@ func NewMember(member cchat.ListMember) *Member { return m } +var noMentionLinks = markup.RenderConfig{ + NoMentionLinks: true, +} + func (m *Member) Update(member cchat.ListMember) { m.ListBoxRow.SetName(member.Name().Content) @@ -371,7 +375,7 @@ func (m *Member) Update(member cchat.ListMember) { m.Avatar.AsyncSetIconer(iconer, "Failed to get member list icon") } - m.output = markup.RenderCmplxWithConfig(member.Name(), markup.NoMentionLinks) + m.output = markup.RenderCmplxWithConfig(member.Name(), noMentionLinks) txt := strings.Builder{} txt.WriteString(fmt.Sprintf( ` %s`, diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index 03e9a73..7f20562 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -19,6 +19,7 @@ type Container interface { ID() string Time() time.Time AuthorID() string + AuthorName() string AvatarURL() string // avatar Nonce() string @@ -47,11 +48,12 @@ func RefreshContainer(c Container, gc *GenericContainer) { // GenericContainer provides a single generic message container for subpackages // to use. type GenericContainer struct { - id string - time time.Time - authorID string - avatarURL string // avatar - nonce string + id string + time time.Time + authorID string + authorName string + avatarURL string // avatar + nonce string Timestamp *gtk.Label Username *labeluri.Label @@ -94,7 +96,6 @@ func NewEmptyContainer() *GenericContainer { ts.Show() user := labeluri.NewLabel(text.Rich{}) - user.SetMaxWidthChars(35) user.SetLineWrap(true) user.SetLineWrapMode(pango.WRAP_WORD_CHAR) user.SetXAlign(1) // right align @@ -168,6 +169,10 @@ func (m *GenericContainer) AuthorID() string { return m.authorID } +func (m *GenericContainer) AuthorName() string { + return m.authorName +} + func (m *GenericContainer) AvatarURL() string { return m.avatarURL } @@ -189,8 +194,11 @@ func (m *GenericContainer) UpdateAuthor(author cchat.Author) { } func (m *GenericContainer) UpdateAuthorName(name text.Rich) { - var out = markup.RenderCmplxWithConfig(name, markup.NoMentionLinks) - m.Username.SetOutput(out) + cfg := markup.RenderConfig{} + cfg.SetForegroundAnchor(m.ContentBody) + + m.authorName = name.String() + m.Username.SetOutput(markup.RenderCmplxWithConfig(name, cfg)) } func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) { diff --git a/internal/ui/rich/parser/attrmap/attrmap.go b/internal/ui/rich/parser/attrmap/attrmap.go index 70208b7..d7e205c 100644 --- a/internal/ui/rich/parser/attrmap/attrmap.go +++ b/internal/ui/rich/parser/attrmap/attrmap.go @@ -1,6 +1,7 @@ package attrmap import ( + "bytes" "fmt" "html" "sort" @@ -10,15 +11,15 @@ import ( ) type AppendMap struct { - appended map[int]string // for opening tags - prepended map[int]string // for closing tags + appended map[int][]byte // for opening tags + prepended map[int][]byte // for closing tags indices []int } func NewAppendedMap() AppendMap { return AppendMap{ - appended: map[int]string{}, - prepended: map[int]string{}, + appended: map[int][]byte{}, + prepended: map[int][]byte{}, indices: []int{}, } } @@ -40,7 +41,7 @@ func (a *AppendMap) Anchor(start, end int, href string) { // AnchorNU makes a new tag without underlines and colors. func (a *AppendMap) AnchorNU(start, end int, href string) { - a.Openf(start, ``) + a.Openf(start, ``) a.Close(end, "") // a.Anchor(start, end, href) a.Span(start, end, `underline="none"`) @@ -63,7 +64,7 @@ func (a *AppendMap) Pad(start, end int) { } } -func posHaveSpace(tags map[int]string, pos int) bool { +func posHaveSpace(tags map[int][]byte, pos int) bool { tg, ok := tags[pos] if !ok || len(tg) == 0 { return false @@ -78,7 +79,7 @@ func posHaveSpace(tags map[int]string, pos int) bool { } // Check spaces mid-tag. This works because strings are always escaped. - return strings.Contains(tg, "> <") + return bytes.Contains(tg, []byte("> <")) } func (a *AppendMap) Pair(start, end int, open, close string) { @@ -92,32 +93,31 @@ func (a *AppendMap) Openf(ind int, f string, argv ...interface{}) { func (a *AppendMap) Open(ind int, attr string) { if str, ok := a.appended[ind]; ok { - a.appended[ind] = str + attr // append + a.appended[ind] = append(str, []byte(attr)...) // append return } - a.appended[ind] = attr + a.appended[ind] = []byte(attr) a.appendIndex(ind) } func (a *AppendMap) Close(ind int, attr string) { if str, ok := a.prepended[ind]; ok { - a.prepended[ind] = attr + str // prepend + a.prepended[ind] = append([]byte(attr), str...) // prepend return } - a.prepended[ind] = attr + a.prepended[ind] = []byte(attr) a.appendIndex(ind) } func (a AppendMap) Get(ind int) (tags string) { - if t, ok := a.appended[ind]; ok { - tags += t - } - if t, ok := a.prepended[ind]; ok { - tags += t - } - return + appended := a.appended[ind] + prepended := a.prepended[ind] + + // Borrowing appended's backing array to add prepended is probably fine, as + // the length of the actual appended slice is going to stay the same. + return string(append(appended, prepended...)) } func (a *AppendMap) Finalize(strlen int) []int { diff --git a/internal/ui/rich/parser/hl/hl.go b/internal/ui/rich/parser/hl/hl.go index 893a085..f7fbe20 100644 --- a/internal/ui/rich/parser/hl/hl.go +++ b/internal/ui/rich/parser/hl/hl.go @@ -1,3 +1,4 @@ +// Package hl provides a syntax highlighted renderer for the markup API. package hl import ( diff --git a/internal/ui/rich/parser/markup/markup.go b/internal/ui/rich/parser/markup/markup.go index 0fc6bd0..f68d287 100644 --- a/internal/ui/rich/parser/markup/markup.go +++ b/internal/ui/rich/parser/markup/markup.go @@ -9,10 +9,12 @@ import ( "strconv" "strings" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/attrmap" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/hl" "github.com/diamondburned/cchat/text" "github.com/diamondburned/imgutil" + "github.com/gotk3/gotk3/gtk" ) // Hyphenate controls whether or not texts should have hyphens on wrap. @@ -24,9 +26,10 @@ func hyphenate(text string) string { // RenderOutput is the output of a render. type RenderOutput struct { - Markup string - Input string // useless to keep parts, as Go will keep all alive anyway - Mentions []MentionSegment + Markup string + Input string // useless to keep parts, as Go will keep all alive anyway + Mentions []MentionSegment + References []ReferenceSegment } // MentionSegment is a type that satisfies both Segment and Mentioner. @@ -35,24 +38,41 @@ type MentionSegment struct { text.Mentioner } -// f_Mention is used to print and parse mention URIs. -const f_Mention = "cchat://mention/%d" // %d == Mentions[i] +// ReferenceSegment is a type that satisfies both Segment and MessageReferencer. +type ReferenceSegment struct { + text.Segment + text.MessageReferencer +} + +const ( + // f_Mention is used to print and parse mention URIs. + f_Mention = "cchat://mention/%d" // %d == Mentions[i] + f_Reference = "cchat://reference/%d" // %d == References[i] +) // IsMention returns the mention if the URI is correct, or nil if none. func (r RenderOutput) IsMention(uri string) text.Segment { var i int - if _, err := fmt.Sscanf(uri, f_Mention, &i); err != nil { - return nil - } - - if i >= len(r.Mentions) { + _, err := fmt.Sscanf(uri, f_Mention, &i) + if err != nil || i >= len(r.Mentions) { return nil } return r.Mentions[i] } +func (r RenderOutput) IsReference(uri string) text.Segment { + var i int + + _, err := fmt.Sscanf(uri, f_Reference, &i) + if err != nil || i >= len(r.References) { + return nil + } + + return r.References[i] +} + func Render(content text.Rich) string { return RenderCmplx(content).Markup } @@ -63,15 +83,32 @@ func RenderCmplx(content text.Rich) RenderOutput { } type RenderConfig struct { - // NoMentionLinks prevents the renderer from wrapping mentions with a - // hyperlink. This prevents invalid colors. + // NoMentionLinks, if true, will not render any mentions. NoMentionLinks bool + + // AnchorColor forces all anchors to be of a certain color. This is used if + // the boolean is true. Else, all mention links will not work and regular + // links will be of the default color. + AnchorColor struct { + uint32 + bool + } } -// NoMentionLinks is the config to render author names. It disables author -// mention links, as there's no way to make normal names not appear blue. -var NoMentionLinks = RenderConfig{ - NoMentionLinks: true, +// SetForegroundAnchor sets the AnchorColor of the render config to be that of +// the regular text foreground color. +func (c *RenderConfig) SetForegroundAnchor(styler primitives.StyleContexter) { + styleCtx, _ := styler.GetStyleContext() + + if rgba := styleCtx.GetColor(gtk.STATE_FLAG_NORMAL); rgba != nil { + var color uint32 + for _, v := range rgba.Floats() { // [0.0, 1.0] + color = (color << 8) + uint32(v*0xFF) + } + + c.AnchorColor.bool = true + c.AnchorColor.uint32 = color + } } func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { @@ -104,15 +141,21 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { // map to append strings to indices var appended = attrmap.NewAppendedMap() - // map to store mentions + // map to store mentions and references var mentions []MentionSegment + var references []ReferenceSegment // Parse all segments. for _, segment := range content.Segments { start, end := segment.Bounds() + // hasAnchor is used to determine if the current segment has inserted + // any anchor tags; it is used for AnchorColor. + var hasAnchor bool + if linker := segment.AsLinker(); linker != nil { appended.Anchor(start, end, linker.Link()) + hasAnchor = true } // Only inline images if start == end per specification. @@ -127,19 +170,14 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { } } - if colorer := segment.AsColorer(); colorer != nil { - appended.Span(start, end, colorAttrs(colorer.Color(), false)...) - } - // Mentioner needs to be before colorer, as we'd want the below color // segment to also highlight the full mention as well as make the // padding part of the hyperlink. - if mentioner := segment.AsMentioner(); mentioner != nil { + if mentioner := segment.AsMentioner(); mentioner != nil && !cfg.NoMentionLinks { // Render the mention into "cchat://mention:0" or such. Other // components will take care of showing the information. - if !cfg.NoMentionLinks { - appended.AnchorNU(start, end, fmt.Sprintf(f_Mention, len(mentions))) - } + appended.AnchorNU(start, end, fmt.Sprintf(f_Mention, len(mentions))) + hasAnchor = true // Add the mention segment into the list regardless of hyperlinks. mentions = append(mentions, MentionSegment{ @@ -147,15 +185,44 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { Mentioner: mentioner, }) - if colorer := segment.AsColorer(); colorer != nil { - // Only pad the name and add a dimmed background if the bounds - // do not cover the whole segment. - var cover = (start == 0) && (end == len(content.Content)) - appended.Span(start, end, colorAttrs(colorer.Color(), !cover)...) - if !cover { - appended.Pad(start, end) - } - } + // TODO: figure out a way to readd Pad. Right now, backend + // implementations can arbitrarily add multiple mentions onto the + // author for overloading, which we don't want to break. + + // // Determine if the mention segment covers the entire label. + // // Only pad the name and add a dimmed background if the bounds do + // // not cover the whole segment. + // var cover = (start == 0) && (end == len(content.Content)) + // if !cover { + // appended.Pad(start, end) + // } + + // // If we don't have a mention color for this segment, then try to + // // use our own AnchorColor. + // if !hasColor && cfg.AnchorColor.bool { + // appended.Span(start, end, colorAttrs(cfg.AnchorColor.uint32, false)...) + // } + } + + if colorer := segment.AsColorer(); colorer != nil { + appended.Span(start, end, colorAttrs(colorer.Color(), false)...) + } else if hasAnchor && cfg.AnchorColor.bool { + appended.Span(start, end, colorAttrs(cfg.AnchorColor.uint32, false)...) + } + + // Don't use AnchorColor for the link, as we're technically just + // borrowing the anchor tag for its use. We should also prefer the + // username popover (Mention) over this. + if reference := segment.AsMessageReferencer(); !hasAnchor && reference != nil { + // Render the mention into "cchat://reference:0" or such. Other + // components will take care of showing the information. + appended.AnchorNU(start, end, fmt.Sprintf(f_Reference, len(references))) + + // Add the mention segment into the list regardless of hyperlinks. + references = append(references, ReferenceSegment{ + Segment: segment, + MessageReferencer: reference, + }) } if attributor := segment.AsAttributor(); attributor != nil { @@ -165,7 +232,12 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { if codeblocker := segment.AsCodeblocker(); codeblocker != nil { start, end := segment.Bounds() // Syntax highlight the codeblock. - hl.Segments(&appended, content.Content, start, end, codeblocker.CodeblockLanguage()) + hl.Segments( + &appended, + content.Content, + start, end, + codeblocker.CodeblockLanguage(), + ) } // TODO: make this not shit. Maybe make it somehow not rely on green @@ -187,9 +259,10 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { } return RenderOutput{ - Markup: hyphenate(buf.String()), - Input: content.Content, - Mentions: mentions, + Markup: hyphenate(buf.String()), + Input: content.Content, + Mentions: mentions, + References: references, } }