minor bug fixes and optimizations

This commit is contained in:
diamondburned 2021-01-04 18:05:33 -08:00
parent bcd2de2e49
commit ee041b3cc9
25 changed files with 669 additions and 474 deletions

View File

@ -22,6 +22,13 @@ var App struct {
*gtk.Application *gtk.Application
Window *handy.ApplicationWindow Window *handy.ApplicationWindow
Throttler *throttler.State Throttler *throttler.State
closing bool
}
// IsClosing returns true if the window is destroyed.
func IsClosing() bool {
return App.closing
} }
// Windower is the interface for a window. // Windower is the interface for a window.
@ -121,6 +128,7 @@ func Main(wfn func() MainApplication) {
App.Window.Window.Connect("destroy", func(window *handy.ApplicationWindow) { App.Window.Window.Connect("destroy", func(window *handy.ApplicationWindow) {
// Hide the application window. // Hide the application window.
window.Hide() window.Hide()
App.closing = true
// Let the main loop run once by queueing the stop loop afterwards. // Let the main loop run once by queueing the stop loop afterwards.
// This is to allow the main loop to properly hide the Gtk window // This is to allow the main loop to properly hide the Gtk window

View File

@ -13,22 +13,31 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
) )
var basePath = filepath.Join(os.TempDir(), "cchat-gtk-totally-not-node-modules") var basePath = filepath.Join(os.TempDir(), "cchat-gtk-caching-is-hard")
var dskcached = http.Client{ var dskcached = http.Client{
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
Transport: httpcache.NewTransport( Transport: &httpcache.Transport{
diskcache.NewWithDiskv(diskv.New(diskv.Options{ Transport: &http.Transport{
// Be generous: use a 128KB buffer instead of 4KB to hopefully
// reduce cgo calls.
WriteBufferSize: 128 * 1024,
ReadBufferSize: 128 * 1024,
},
Cache: diskcache.NewWithDiskv(diskv.New(diskv.Options{
BasePath: basePath, BasePath: basePath,
TempDir: filepath.Join(basePath, "tmp"), TempDir: filepath.Join(basePath, "tmp"),
PathPerm: 0750, PathPerm: 0750,
FilePerm: 0750, FilePerm: 0750,
Compression: diskv.NewZlibCompressionLevel(5), Compression: diskv.NewZlibCompressionLevel(4),
CacheSizeMax: 0, // 25 MiB in memory CacheSizeMax: 25 * 1024 * 1024, // 25 MiB in memory
})), })),
), MarkCachedResponses: true,
},
} }
// TODO: log cache misses with httpcache.XFromCache
func get(ctx context.Context, url string, cached bool) (r *http.Response, err error) { func get(ctx context.Context, url string, cached bool) (r *http.Response, err error) {
q, err := http.NewRequestWithContext(ctx, "GET", url, nil) q, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {

View File

@ -1,6 +1,7 @@
package httputil package httputil
import ( import (
"bufio"
"context" "context"
"io" "io"
"mime" "mime"
@ -8,6 +9,7 @@ import (
"net/url" "net/url"
"path" "path"
"strings" "strings"
"sync"
"github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/log"
@ -19,7 +21,31 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
) )
// TODO: // bufferPool provides a sync.Pool of *bufio.Writers. This is used to reduce the
// amount of cgo calls, by writing bytes in larger chunks.
//
// Technically, httpcache already wraps its cached reader around a bufio.Reader,
// but we have no control over the buffer size.
var bufferPool = sync.Pool{
New: func() interface{} {
// Allocate a 512KB buffer by default.
const defaultBufSz = 512 * 1024
return bufio.NewWriterSize(nil, defaultBufSz)
},
}
func bufferedWriter(w io.Writer) *bufio.Writer {
buf := bufferPool.Get().(*bufio.Writer)
buf.Reset(w)
return buf
}
func returnBufferedWriter(buf *bufio.Writer) {
// Unreference the internal reader.
buf.Reset(nil)
bufferPool.Put(buf)
}
type ImageContainer interface { type ImageContainer interface {
primitives.Connector primitives.Connector
@ -67,9 +93,9 @@ func AsyncImage(ctx context.Context,
scale = surfaceContainer.GetScaleFactor() scale = surfaceContainer.GetScaleFactor()
} }
go func() { ctx = primitives.HandleDestroyCtx(ctx, img)
ctx := primitives.HandleDestroyCtx(ctx, img)
go func() {
// Try and guess the MIME type from the URL. // Try and guess the MIME type from the URL.
mimeType := mime.TypeByExtension(urlExt(imageURL)) mimeType := mime.TypeByExtension(urlExt(imageURL))
@ -102,7 +128,7 @@ func AsyncImage(ctx context.Context,
l, err := gdk.PixbufLoaderNewWithType(fileType) l, err := gdk.PixbufLoaderNewWithType(fileType)
if err != nil { if err != nil {
log.Error(errors.Wrap(err, "failed to make pixbuf loader")) log.Error(errors.Wrapf(err, "failed to make PixbufLoader type %q", fileType))
return return
} }
@ -117,11 +143,20 @@ func AsyncImage(ctx context.Context,
l.Connect("area-prepared", load) l.Connect("area-prepared", load)
l.Connect("area-updated", load) l.Connect("area-updated", load)
if err := downloadImage(r.Body, l, procs, isGIF); err != nil { // Borrow a buffered writer and return it at the end.
bufWriter := bufferedWriter(l)
defer returnBufferedWriter(bufWriter)
if err := downloadImage(r.Body, bufWriter, procs, isGIF); err != nil {
log.Error(errors.Wrapf(err, "failed to download %q", imageURL)) log.Error(errors.Wrapf(err, "failed to download %q", imageURL))
// Force close after downloading. // Force close after downloading.
} }
if err := bufWriter.Flush(); err != nil {
log.Error(errors.Wrapf(err, "failed to flush writer for %q", imageURL))
// Force close after downloading.
}
if err := l.Close(); err != nil { if err := l.Close(); err != nil {
log.Error(errors.Wrapf(err, "failed to close pixbuf loader for %q", imageURL)) log.Error(errors.Wrapf(err, "failed to close pixbuf loader for %q", imageURL))
} }

View File

@ -13,8 +13,8 @@ type Container struct {
} }
func NewContainer(ctrl container.Controller) *Container { func NewContainer(ctrl container.Controller) *Container {
c := container.NewListContainer(constructor{}, ctrl) c := container.NewListContainer(ctrl, constructors)
primitives.AddClass(c, "compact-conatainer") primitives.AddClass(c, "compact-container")
return &Container{c} return &Container{c}
} }
@ -33,12 +33,19 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
gts.ExecAsync(func() { c.ListContainer.DeleteMessageUnsafe(msg) }) gts.ExecAsync(func() { c.ListContainer.DeleteMessageUnsafe(msg) })
} }
type constructor struct{} var constructors = container.Constructor{
NewMessage: newMessage,
NewPresendMessage: newPresendMessage,
}
func newMessage(
msg cchat.MessageCreate, _ container.MessageRow) container.MessageRow {
func (constructor) NewMessage(msg cchat.MessageCreate) container.MessageRow {
return NewMessage(msg) return NewMessage(msg)
} }
func (constructor) NewPresendMessage(msg input.PresendMessage) container.PresendMessageRow { func newPresendMessage(
msg input.PresendMessage, _ container.MessageRow) container.PresendMessageRow {
return NewPresendMessage(msg) return NewPresendMessage(msg)
} }

View File

@ -1,15 +1,33 @@
package compact package compact
import ( import (
"time"
"github.com/diamondburned/cchat" "github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/humanize"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container" "github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message" "github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango" "github.com/gotk3/gotk3/pango"
) )
var messageTimeCSS = primitives.PrepareClassCSS("", `
.message-time {
margin-left: 1em;
margin-right: 1em;
}
`)
var messageAuthorCSS = primitives.PrepareClassCSS("", `
.message-author {
margin-right: 0.5em;
}
`)
type PresendMessage struct { type PresendMessage struct {
message.PresendContainer message.PresendContainer
Message Message
@ -17,60 +35,76 @@ type PresendMessage struct {
func NewPresendMessage(msg input.PresendMessage) PresendMessage { func NewPresendMessage(msg input.PresendMessage) PresendMessage {
msgc := message.NewPresendContainer(msg) msgc := message.NewPresendContainer(msg)
attachCompact(msgc.GenericContainer)
return PresendMessage{ return PresendMessage{
PresendContainer: msgc, PresendContainer: msgc,
Message: Message{msgc.GenericContainer}, Message: wrapMessage(msgc.GenericContainer),
} }
} }
type Message struct { type Message struct {
*message.GenericContainer *message.GenericContainer
Timestamp *gtk.Label
Username *labeluri.Label
} }
var _ container.MessageRow = (*Message)(nil) var _ container.MessageRow = (*Message)(nil)
func NewMessage(msg cchat.MessageCreate) Message { func NewMessage(msg cchat.MessageCreate) Message {
msgc := message.NewContainer(msg) msgc := wrapMessage(message.NewContainer(msg))
attachCompact(msgc)
message.FillContainer(msgc, msg) message.FillContainer(msgc, msg)
return msgc
return Message{msgc}
} }
func NewEmptyMessage() Message { func NewEmptyMessage() Message {
ct := message.NewEmptyContainer() ct := message.NewEmptyContainer()
attachCompact(ct) return wrapMessage(ct)
return Message{ct}
} }
var messageTimeCSS = primitives.PrepareClassCSS("message-time", ` func wrapMessage(ct *message.GenericContainer) Message {
.message-time { ts := message.NewTimestamp()
margin-left: 1em; ts.SetVAlign(gtk.ALIGN_START)
margin-right: 1em; ts.Show()
messageTimeCSS(ts)
user := message.NewUsername()
user.SetMaxWidthChars(25)
user.SetEllipsize(pango.ELLIPSIZE_NONE)
user.SetLineWrap(true)
user.SetLineWrapMode(pango.WRAP_WORD_CHAR)
user.Show()
messageAuthorCSS(user)
ct.PackStart(ts, false, false, 0)
ct.PackStart(user, false, false, 0)
ct.PackStart(ct.Content, true, true, 0)
ct.SetClass("compact")
return Message{
GenericContainer: ct,
Timestamp: ts,
Username: user,
} }
`) }
var messageAuthorCSS = primitives.PrepareClassCSS("message-author", ` // SetReferenceHighlighter sets the reference highlighter into the message.
.message-author { func (m Message) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) {
margin-right: 0.5em; m.GenericContainer.SetReferenceHighlighter(r)
} m.Username.SetReferenceHighlighter(r)
`) }
func attachCompact(container *message.GenericContainer) { func (m Message) UpdateTimestamp(t time.Time) {
container.Timestamp.SetVAlign(gtk.ALIGN_START) m.GenericContainer.UpdateTimestamp(t)
container.Username.SetMaxWidthChars(25) m.Timestamp.SetText(humanize.TimeAgo(t))
container.Username.SetEllipsize(pango.ELLIPSIZE_NONE) m.Timestamp.SetTooltipText(t.Format(time.Stamp))
container.Username.SetLineWrap(true) }
container.Username.SetLineWrapMode(pango.WRAP_WORD_CHAR)
func (m Message) UpdateAuthor(author cchat.Author) {
messageTimeCSS(container.Timestamp) m.GenericContainer.UpdateAuthor(author)
messageAuthorCSS(container.Username)
cfg := markup.RenderConfig{}
container.PackStart(container.Timestamp, false, false, 0) cfg.NoReferencing = true
container.PackStart(container.Username, false, false, 0) cfg.SetForegroundAnchor(m.ContentBodyStyle)
container.PackStart(container.Content, true, true, 0)
container.SetClass("compact") m.Username.SetOutput(markup.RenderCmplxWithConfig(author.Name(), cfg))
} }

View File

@ -42,7 +42,7 @@ type Container interface {
// CreateMessageUnsafe creates a new message and returns the index that is // CreateMessageUnsafe creates a new message and returns the index that is
// the location the message is added to. // the location the message is added to.
CreateMessageUnsafe(cchat.MessageCreate) CreateMessageUnsafe(cchat.MessageCreate) MessageRow
UpdateMessageUnsafe(cchat.MessageUpdate) UpdateMessageUnsafe(cchat.MessageUpdate)
DeleteMessageUnsafe(cchat.MessageDelete) DeleteMessageUnsafe(cchat.MessageDelete)
@ -84,9 +84,9 @@ type Controller interface {
// Constructor is an interface for making custom message implementations which // Constructor is an interface for making custom message implementations which
// allows ListContainer to generically work with. // allows ListContainer to generically work with.
type Constructor interface { type Constructor struct {
NewMessage(cchat.MessageCreate) MessageRow NewMessage func(msg cchat.MessageCreate, before MessageRow) MessageRow
NewPresendMessage(input.PresendMessage) PresendMessageRow NewPresendMessage func(msg input.PresendMessage, before MessageRow) PresendMessageRow
} }
const ColumnSpacing = 8 const ColumnSpacing = 8
@ -107,10 +107,19 @@ type messageRow struct {
presend message.PresendContainer // this shouldn't be here but i'm lazy presend message.PresendContainer // this shouldn't be here but i'm lazy
} }
// unwrapRow is a helper that unwraps a messageRow if it's not nil. If it's nil,
// then a nil interface is returned.
func unwrapRow(msg *messageRow) MessageRow {
if msg == nil || msg.MessageRow == nil {
return nil
}
return msg.MessageRow
}
var _ Container = (*ListContainer)(nil) var _ Container = (*ListContainer)(nil)
func NewListContainer(constr Constructor, ctrl Controller) *ListContainer { func NewListContainer(ctrl Controller, constr Constructor) *ListContainer {
listStore := NewListStore(constr, ctrl) listStore := NewListStore(ctrl, constr)
listStore.ListBox.Show() listStore.ListBox.Show()
clamp := handy.ClampNew() clamp := handy.ClampNew()
@ -128,11 +137,12 @@ func NewListContainer(constr Constructor, ctrl Controller) *ListContainer {
} }
} }
// CreateMessageUnsafe inserts a message. It does not clean up old messages. // TODO: remove useless abstraction (this file).
func (c *ListContainer) CreateMessageUnsafe(msg cchat.MessageCreate) {
// Insert the message first. // // CreateMessageUnsafe inserts a message. It does not clean up old messages.
c.ListStore.CreateMessageUnsafe(msg) // func (c *ListContainer) CreateMessageUnsafe(msg cchat.MessageCreate) MessageRow {
} // return c.ListStore.CreateMessageUnsafe(msg)
// }
// CleanMessages cleans up the oldest messages if the user is scrolled to the // CleanMessages cleans up the oldest messages if the user is scrolled to the
// bottom. True is returned if there were changes. // bottom. True is returned if there were changes.

View File

@ -41,61 +41,45 @@ const (
AvatarMargin = 10 AvatarMargin = 10
) )
var messageConstructors = container.Constructor{
NewMessage: NewMessage,
NewPresendMessage: NewPresendMessage,
}
func NewMessage(
msg cchat.MessageCreate, before container.MessageRow) container.MessageRow {
if gridMessageIsAuthor(before, msg.Author()) {
return NewCollapsedMessage(msg)
}
return NewFullMessage(msg)
}
func NewPresendMessage(
msg input.PresendMessage, before container.MessageRow) container.PresendMessageRow {
if gridMessageIsAuthor(before, msg.Author()) {
return NewCollapsedSendingMessage(msg)
}
return NewFullSendingMessage(msg)
}
type Container struct { type Container struct {
*container.ListContainer *container.ListContainer
} }
func NewContainer(ctrl container.Controller) *Container { func NewContainer(ctrl container.Controller) *Container {
c := &Container{} c := container.NewListContainer(ctrl, messageConstructors)
c.ListContainer = container.NewListContainer(c, ctrl)
primitives.AddClass(c, "cozy-container") primitives.AddClass(c, "cozy-container")
return c return &Container{ListContainer: c}
}
func (c *Container) NewMessage(msg cchat.MessageCreate) container.MessageRow {
// We're not checking for a collapsed message here anymore, as the
// CreateMessage method will do that.
// // Is the latest message of the same author? If yes, display it as a
// // collapsed message.
// if c.lastMessageIsAuthor(msg.Author().ID()) {
// return NewCollapsedMessage(msg)
// }
full := NewFullMessage(msg)
author := msg.Author()
// Try and reuse an existing avatar if the author has one.
if avatarURL := author.Avatar(); avatarURL != "" {
// Try reusing the avatar, but fetch it from the interndet if we can't
// reuse. The reuse function does this for us.
c.reuseAvatar(author.ID(), author.Avatar(), full)
}
return full
}
func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendMessageRow {
// We can do the check here since we're never using NewPresendMessage for
// backlog messages.
if c.lastMessageIsAuthor(msg.AuthorID(), msg.Author().String(), 0) {
return NewCollapsedSendingMessage(msg)
}
full := NewFullSendingMessage(msg)
// Try and see if we can reuse the avatar, and fallback if possible. The
// avatar URL passed in here will always yield an equal.
c.reuseAvatar(msg.AuthorID(), msg.AuthorAvatarURL(), &full.FullMessage)
return full
} }
func (c *Container) findAuthorID(authorID string) container.MessageRow { func (c *Container) findAuthorID(authorID string) container.MessageRow {
// Search the old author if we have any. // Search the old author if we have any.
return c.ListStore.FindMessage(func(msgc container.MessageRow) bool { return c.ListStore.FindMessage(func(msgc container.MessageRow) bool {
return msgc.AuthorID() == authorID return msgc.Author().ID() == authorID
}) })
} }
@ -108,32 +92,48 @@ func (c *Container) reuseAvatar(authorID, avatarURL string, full *FullMessage) {
// Borrow the avatar pixbuf, but only if the avatar URL is the same. // Borrow the avatar pixbuf, but only if the avatar URL is the same.
p, ok := lastAuthorMsg.(AvatarPixbufCopier) p, ok := lastAuthorMsg.(AvatarPixbufCopier)
if ok && lastAuthorMsg.AvatarURL() == avatarURL { if ok && lastAuthorMsg.Author().Avatar() == avatarURL {
p.CopyAvatarPixbuf(full.Avatar.Image) if p.CopyAvatarPixbuf(full.Avatar.Image) {
full.Avatar.ManuallySetURL(avatarURL) full.Avatar.ManuallySetURL(avatarURL)
} else { return
// We can't borrow, so we need to fetch it anew. }
full.Avatar.SetURL(avatarURL)
} }
// We can't borrow, so we need to fetch it anew.
full.Avatar.SetURL(avatarURL)
} }
func (c *Container) lastMessageIsAuthor(id cchat.ID, name string, offset int) bool { // lastMessageIsAuthor removed - assuming index before insertion is harmful.
// Get the offfsetth message from last.
var last = c.ListStore.NthMessage((c.ListStore.MessagesLen() - 1) + offset)
return gridMessageIsAuthor(last, id, name)
}
func gridMessageIsAuthor(gridMsg container.MessageRow, id cchat.ID, name string) bool { func gridMessageIsAuthor(gridMsg container.MessageRow, author cchat.Author) bool {
return gridMsg != nil && if gridMsg == nil {
gridMsg.AuthorID() == id && return false
gridMsg.AuthorName() == name }
leftAuthor := gridMsg.Author()
return true &&
leftAuthor.ID() == author.ID() &&
leftAuthor.Name().String() == author.Name().String()
} }
func (c *Container) CreateMessage(msg cchat.MessageCreate) { func (c *Container) CreateMessage(msg cchat.MessageCreate) {
gts.ExecAsync(func() { gts.ExecAsync(func() {
// Create the message in the parent's handler. This handler will also // Create the message in the parent's handler. This handler will also
// wipe old messages. // wipe old messages.
c.ListContainer.CreateMessageUnsafe(msg) row := c.ListContainer.CreateMessageUnsafe(msg)
// Is this a full message? If so, then we should fetch the avatar when
// we can.
if full, ok := row.(*FullMessage); ok {
author := msg.Author()
avatarURL := author.Avatar()
// Try and reuse an existing avatar if the author has one.
if avatarURL != "" {
// Try reusing the avatar, but fetch it from the internet if we can't
// reuse. The reuse function does this for us.
c.reuseAvatar(author.ID(), avatarURL, full)
}
}
// Did the handler wipe old messages? It will only do so if the user is // Did the handler wipe old messages? It will only do so if the user is
// scrolled to the bottom. // scrolled to the bottom.
@ -143,24 +143,12 @@ func (c *Container) CreateMessage(msg cchat.MessageCreate) {
c.uncompact(c.FirstMessage()) 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.ListContainer.LastMessage().ID():
author := msg.Author()
if c.lastMessageIsAuthor(author.ID(), author.Name().String(), -1) {
c.compact(c.ListContainer.LastMessage())
}
// If we've prepended the message, then see if we need to collapse the // If we've prepended the message, then see if we need to collapse the
// second message. // second message.
case c.ListContainer.FirstMessage().ID(): if first := c.ListContainer.FirstMessage(); first != nil && first.ID() == msg.ID() {
if sec := c.NthMessage(1); sec != nil { // If the author is the same, then collapse.
// The author is the same; collapse. if sec := c.NthMessage(1); sec != nil && gridMessageIsAuthor(sec, msg.Author()) {
author := msg.Author() c.compact(sec)
if gridMessageIsAuthor(sec, author.ID(), author.Name().String()) {
c.compact(sec)
}
} }
} }
}) })
@ -174,15 +162,17 @@ func (c *Container) UpdateMessage(msg cchat.MessageUpdate) {
func (c *Container) DeleteMessage(msg cchat.MessageDelete) { func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
gts.ExecAsync(func() { gts.ExecAsync(func() {
msgID := msg.ID()
// Get the previous and next message before deleting. We'll need them to // Get the previous and next message before deleting. We'll need them to
// evaluate whether we need to change anything. // evaluate whether we need to change anything.
prev, next := c.ListStore.Around(msg.ID()) prev, next := c.ListStore.Around(msgID)
// The function doesn't actually try and re-collapse the bottom message // The function doesn't actually try and re-collapse the bottom message
// when a sandwiched message is deleted. This is fine. // when a sandwiched message is deleted. This is fine.
// Delete the message off of the parent's container. // Delete the message off of the parent's container.
msg := c.ListStore.PopMessage(msg.ID()) msg := c.ListStore.PopMessage(msgID)
// Don't calculate if we don't have any messages, or no messages before // Don't calculate if we don't have any messages, or no messages before
// and after. // and after.
@ -190,8 +180,10 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
return return
} }
msgAuthorID := msg.Author().ID()
// Check if the last message is the author's (relative to i): // Check if the last message is the author's (relative to i):
if prev.AuthorID() == msg.AuthorID() { if prev.Author().ID() == msgAuthorID {
// If the author is the same, then we don't need to uncollapse the // If the author is the same, then we don't need to uncollapse the
// message. // message.
return return
@ -199,7 +191,7 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
// If the next message (relative to i) is not the deleted message's // If the next message (relative to i) is not the deleted message's
// author, then we don't need to uncollapse it. // author, then we don't need to uncollapse it.
if next.AuthorID() != msg.AuthorID() { if next.Author().ID() != msgAuthorID {
return return
} }
@ -211,39 +203,30 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
func (c *Container) uncompact(msg container.MessageRow) { func (c *Container) uncompact(msg container.MessageRow) {
// We should only uncompact the message if it's compacted in the first // We should only uncompact the message if it's compacted in the first
// place. // place.
if collapse, ok := msg.(Collapsible); !ok || !collapse.Collapsed() { compact, ok := msg.(*CollapsedMessage)
return
}
// We can't unwrap if the message doesn't implement Unwrapper.
uw, ok := msg.(Unwrapper)
if !ok { if !ok {
return return
} }
// Start the "lengthy" uncollapse process. // Start the "lengthy" uncollapse process.
full := WrapFullMessage(uw.Unwrap()) full := WrapFullMessage(compact.Unwrap())
// Update the container to reformat everything including the timestamps. // Update the container to reformat everything including the timestamps.
message.RefreshContainer(full, full.GenericContainer) message.RefreshContainer(full, full.GenericContainer)
// Update the avatar if needed be, since we're now showing it. // Update the avatar if needed be, since we're now showing it.
c.reuseAvatar(msg.AuthorID(), msg.AvatarURL(), full) author := msg.Author()
c.reuseAvatar(author.ID(), author.Avatar(), full)
// Swap the old next message out for a new one. // Swap the old next message out for a new one.
c.ListStore.SwapMessage(full) c.ListStore.SwapMessage(full)
} }
func (c *Container) compact(msg container.MessageRow) { func (c *Container) compact(msg container.MessageRow) {
// Exit if the message is already collapsed. full, ok := msg.(*FullMessage)
if collapse, ok := msg.(Collapsible); !ok || collapse.Collapsed() {
return
}
uw, ok := msg.(Unwrapper)
if !ok { if !ok {
return return
} }
compact := WrapCollapsedMessage(uw.Unwrap()) compact := WrapCollapsedMessage(full.Unwrap())
message.RefreshContainer(compact, compact.GenericContainer) message.RefreshContainer(compact, compact.GenericContainer)
c.ListStore.SwapMessage(compact) c.ListStore.SwapMessage(compact)

View File

@ -16,6 +16,7 @@ import (
type CollapsedMessage struct { type CollapsedMessage struct {
// Author is still updated normally. // Author is still updated normally.
*message.GenericContainer *message.GenericContainer
Timestamp *gtk.Label
} }
func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage { func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage {
@ -26,21 +27,23 @@ func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage {
func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage { func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage {
// Set Timestamp's padding accordingly to Avatar's. // Set Timestamp's padding accordingly to Avatar's.
gc.Timestamp.SetSizeRequest(AvatarSize, -1) ts := message.NewTimestamp()
gc.Timestamp.SetVAlign(gtk.ALIGN_START) ts.SetSizeRequest(AvatarSize, -1)
gc.Timestamp.SetXAlign(0.5) // middle align ts.SetVAlign(gtk.ALIGN_START)
gc.Timestamp.SetMarginEnd(container.ColumnSpacing) ts.SetXAlign(0.5) // middle align
gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2) ts.SetMarginEnd(container.ColumnSpacing)
ts.SetMarginStart(container.ColumnSpacing * 2)
// Set Content's padding accordingly to FullMessage's main box. // Set Content's padding accordingly to FullMessage's main box.
gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2) gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2)
gc.PackStart(gc.Timestamp, false, false, 0) gc.PackStart(ts, false, false, 0)
gc.PackStart(gc.Content, true, true, 0) gc.PackStart(gc.Content, true, true, 0)
gc.SetClass("cozy-collapsed") gc.SetClass("cozy-collapsed")
return &CollapsedMessage{ return &CollapsedMessage{
GenericContainer: gc, GenericContainer: gc,
Timestamp: ts,
} }
} }

View File

@ -13,6 +13,8 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/cairo" "github.com/gotk3/gotk3/cairo"
"github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/gtk"
) )
@ -27,12 +29,12 @@ type FullMessage struct {
Avatar *Avatar Avatar *Avatar
MainBox *gtk.Box // wraps header and content MainBox *gtk.Box // wraps header and content
// Header wraps author and timestamp. Header *labeluri.Label
HeaderBox *gtk.Box timestamp string // markup
} }
type AvatarPixbufCopier interface { type AvatarPixbufCopier interface {
CopyAvatarPixbuf(img httputil.SurfaceContainer) CopyAvatarPixbuf(img httputil.SurfaceContainer) bool
} }
var ( var (
@ -41,10 +43,6 @@ var (
_ container.MessageRow = (*FullMessage)(nil) _ container.MessageRow = (*FullMessage)(nil)
) )
var boldCSS = primitives.PrepareCSS(`
* { font-weight: 600; }
`)
var avatarCSS = primitives.PrepareClassCSS("cozy-avatar", ` var avatarCSS = primitives.PrepareClassCSS("cozy-avatar", `
/* Slightly dip down on click */ /* Slightly dip down on click */
.cozy-avatar:active { .cozy-avatar:active {
@ -63,33 +61,26 @@ func NewFullMessage(msg cchat.MessageCreate) *FullMessage {
} }
func WrapFullMessage(gc *message.GenericContainer) *FullMessage { func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
header := labeluri.NewLabel(text.Rich{})
header.SetHAlign(gtk.ALIGN_START) // left-align
header.SetMaxWidthChars(100)
header.Show()
avatar := NewAvatar() avatar := NewAvatar()
avatar.SetMarginTop(TopFullMargin / 2) avatar.SetMarginTop(TopFullMargin / 2)
avatar.SetMarginStart(container.ColumnSpacing * 2) avatar.SetMarginStart(container.ColumnSpacing * 2)
avatar.Connect("clicked", func(w gtk.IWidget) { avatar.Connect("clicked", func(w gtk.IWidget) {
if output := gc.Username.Output(); len(output.Mentions) > 0 { if output := header.Output(); len(output.Mentions) > 0 {
labeluri.PopoverMentioner(w, output.Input, output.Mentions[0]) labeluri.PopoverMentioner(w, output.Input, output.Mentions[0])
} }
}) })
avatar.Show() avatar.Show()
// Style the timestamp accordingly.
gc.Timestamp.SetXAlign(0.0) // left-align
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. // Attach the class and CSS for the left avatar.
avatarCSS(avatar) avatarCSS(avatar)
// Attach the username style provider. // Attach the username style provider.
primitives.AttachCSS(gc.Username, boldCSS) // primitives.AttachCSS(gc.Username, boldCSS)
header, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
header.PackStart(gc.Username, false, false, 0)
header.PackStart(gc.Timestamp, false, false, 7) // padding
header.Show()
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
main.PackStart(header, false, false, 0) main.PackStart(header, false, false, 0)
@ -108,9 +99,10 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
return &FullMessage{ return &FullMessage{
GenericContainer: gc, GenericContainer: gc,
Avatar: avatar,
MainBox: main, Avatar: avatar,
HeaderBox: header, MainBox: main,
Header: header,
} }
} }
@ -118,17 +110,12 @@ func (m *FullMessage) Collapsed() bool { return false }
func (m *FullMessage) Unwrap() *message.GenericContainer { func (m *FullMessage) Unwrap() *message.GenericContainer {
// Remove GenericContainer's widgets from the containers. // Remove GenericContainer's widgets from the containers.
m.HeaderBox.Remove(m.Username) m.Header.Destroy()
m.HeaderBox.Remove(m.Timestamp) m.MainBox.Remove(m.Content) // not ours, so don't destroy.
m.MainBox.Remove(m.HeaderBox)
m.MainBox.Remove(m.Content)
// Hide the avatar.
m.Avatar.Hide()
// Remove the message from the grid. // Remove the message from the grid.
m.Remove(m.Avatar) m.Avatar.Destroy()
m.Remove(m.MainBox) m.MainBox.Destroy()
// Return after removing. // Return after removing.
return m.GenericContainer return m.GenericContainer
@ -136,18 +123,36 @@ func (m *FullMessage) Unwrap() *message.GenericContainer {
func (m *FullMessage) UpdateTimestamp(t time.Time) { func (m *FullMessage) UpdateTimestamp(t time.Time) {
m.GenericContainer.UpdateTimestamp(t) m.GenericContainer.UpdateTimestamp(t)
m.Timestamp.SetText(humanize.TimeAgoLong(t))
m.timestamp = " " +
`<span alpha="70%" size="small">` + humanize.TimeAgoLong(t) + `</span>`
// Update the timestamp.
m.Header.SetMarkup(m.Header.Output().Markup + m.timestamp)
} }
func (m *FullMessage) UpdateAuthor(author cchat.Author) { func (m *FullMessage) UpdateAuthor(author cchat.Author) {
// Call the parent's method to update the labels. // Call the parent's method to update the state.
m.GenericContainer.UpdateAuthor(author) m.GenericContainer.UpdateAuthor(author)
m.UpdateAuthorName(author.Name())
m.Avatar.SetURL(author.Avatar()) m.Avatar.SetURL(author.Avatar())
} }
func (m *FullMessage) UpdateAuthorName(name text.Rich) {
cfg := markup.RenderConfig{}
cfg.NoReferencing = true
cfg.SetForegroundAnchor(m.ContentBodyStyle)
output := markup.RenderCmplxWithConfig(name, cfg)
output.Markup = `<span font_weight="600">` + output.Markup + "</span>"
m.Header.SetMarkup(output.Markup + m.timestamp)
m.Header.SetUnderlyingOutput(output)
}
// CopyAvatarPixbuf sets the pixbuf into the given container. This shares the // CopyAvatarPixbuf sets the pixbuf into the given container. This shares the
// same pixbuf, but gtk.Image should take its own reference from the pixbuf. // same pixbuf, but gtk.Image should take its own reference from the pixbuf.
func (m *FullMessage) CopyAvatarPixbuf(dst httputil.SurfaceContainer) { func (m *FullMessage) CopyAvatarPixbuf(dst httputil.SurfaceContainer) bool {
switch img := m.Avatar.Image.GetImage(); img.GetStorageType() { switch img := m.Avatar.Image.GetImage(); img.GetStorageType() {
case gtk.IMAGE_PIXBUF: case gtk.IMAGE_PIXBUF:
dst.SetFromPixbuf(img.GetPixbuf()) dst.SetFromPixbuf(img.GetPixbuf())
@ -156,7 +161,10 @@ func (m *FullMessage) CopyAvatarPixbuf(dst httputil.SurfaceContainer) {
case gtk.IMAGE_SURFACE: case gtk.IMAGE_SURFACE:
v, _ := img.GetProperty("surface") v, _ := img.GetProperty("surface")
dst.SetFromSurface(v.(*cairo.Surface)) dst.SetFromSurface(v.(*cairo.Surface))
default:
return false
} }
return true
} }
func (m *FullMessage) AttachMenu(items []menu.Item) { func (m *FullMessage) AttachMenu(items []menu.Item) {

View File

@ -1,7 +1,6 @@
package container package container
import ( import (
"log"
"time" "time"
"github.com/diamondburned/cchat" "github.com/diamondburned/cchat"
@ -35,7 +34,7 @@ type ListStore struct {
messages map[messageKey]*messageRow messages map[messageKey]*messageRow
} }
func NewListStore(constr Constructor, ctrl Controller) *ListStore { func NewListStore(ctrl Controller, constr Constructor) *ListStore {
listBox, _ := gtk.ListBoxNew() listBox, _ := gtk.ListBoxNew()
listBox.SetSelectionMode(gtk.SELECTION_SINGLE) listBox.SetSelectionMode(gtk.SELECTION_SINGLE)
listBox.Show() listBox.Show()
@ -154,12 +153,9 @@ func (c *ListStore) around(aroundID cchat.ID) (before, after *messageRow) {
// LatestMessageFrom returns the latest message with the given user ID. This is // LatestMessageFrom returns the latest message with the given user ID. This is
// used for the input prompt. // used for the input prompt.
func (c *ListStore) LatestMessageFrom(userID string) (msgID string, ok bool) { func (c *ListStore) LatestMessageFrom(userID string) (msgID string, ok bool) {
log.Println("LatestMessageFrom called")
// FindMessage already looks from the latest messages. // FindMessage already looks from the latest messages.
var msg = c.FindMessage(func(msg MessageRow) bool { var msg = c.FindMessage(func(msg MessageRow) bool {
log.Println("Author:", msg.AuthorName()) return msg.Author().ID() == userID
return msg.AuthorID() == userID
}) })
if msg == nil { if msg == nil {
@ -229,25 +225,22 @@ func (c *ListStore) FindMessage(isMessage func(MessageRow) bool) MessageRow {
msg, _ := c.findMessage(false, func(row *messageRow) bool { msg, _ := c.findMessage(false, func(row *messageRow) bool {
return isMessage(row.MessageRow) return isMessage(row.MessageRow)
}) })
if msg != nil { return unwrapRow(msg)
return msg.MessageRow
}
return nil
} }
func (c *ListStore) nthMessage(n int) *messageRow { func (c *ListStore) nthMessage(n int) *messageRow {
v := primitives.NthChild(c.ListBox, n) v := primitives.NthChild(c.ListBox, n)
if v == nil {
return nil
}
id := primitives.GetName(v.(primitives.Namer)) id := primitives.GetName(v.(primitives.Namer))
return c.message(id, "") return c.message(id, "")
} }
// NthMessage returns the nth message. // NthMessage returns the nth message.
func (c *ListStore) NthMessage(n int) MessageRow { func (c *ListStore) NthMessage(n int) MessageRow {
msg := c.nthMessage(n) return unwrapRow(c.nthMessage(n))
if msg != nil {
return msg.MessageRow
}
return nil
} }
// FirstMessage returns the first message. // FirstMessage returns the first message.
@ -263,10 +256,7 @@ func (c *ListStore) LastMessage() MessageRow {
// Message finds the message state in the container. It is not thread-safe. This // Message finds the message state in the container. It is not thread-safe. This
// exists for backwards compatibility. // exists for backwards compatibility.
func (c *ListStore) Message(msgID cchat.ID, nonce string) MessageRow { func (c *ListStore) Message(msgID cchat.ID, nonce string) MessageRow {
if m := c.message(msgID, nonce); m != nil { return unwrapRow(c.message(msgID, nonce))
return m.MessageRow
}
return nil
} }
func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow { func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow {
@ -300,7 +290,8 @@ func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow {
// AddPresendMessage inserts an input.PresendMessage into the container and // AddPresendMessage inserts an input.PresendMessage into the container and
// returning a wrapped widget interface. // returning a wrapped widget interface.
func (c *ListStore) AddPresendMessage(msg input.PresendMessage) PresendMessageRow { func (c *ListStore) AddPresendMessage(msg input.PresendMessage) PresendMessageRow {
presend := c.Construct.NewPresendMessage(msg) before := c.LastMessage()
presend := c.Construct.NewPresendMessage(msg, before)
msgc := &messageRow{ msgc := &messageRow{
MessageRow: presend, MessageRow: presend,
@ -326,7 +317,7 @@ func (c *ListStore) bindMessage(msgc *messageRow) {
// unreliable. The index might be off if the message buffer is cleaned up. Don't // unreliable. The index might be off if the message buffer is cleaned up. Don't
// rely on it. // rely on it.
func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) { func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) MessageRow {
// Call the event handler last. // Call the event handler last.
defer c.Controller.AuthorEvent(msg.Author()) defer c.Controller.AuthorEvent(msg.Author())
@ -337,33 +328,45 @@ func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) {
msgc.UpdateTimestamp(msg.Time()) msgc.UpdateTimestamp(msg.Time())
c.bindMessage(msgc) c.bindMessage(msgc)
return return msgc.MessageRow
} }
msgc := &messageRow{
MessageRow: c.Construct.NewMessage(msg),
}
msgTime := msg.Time() msgTime := msg.Time()
// Iterate and compare timestamp to find where to insert a message. // Iterate and compare timestamp to find where to insert a message. Note
after, index := c.findMessage(true, func(after *messageRow) bool { // that "before" is the message that will go before the to-be-inserted
return msgTime.After(after.Time()) // method.
before, index := c.findMessage(true, func(before *messageRow) bool {
return msgTime.After(before.Time())
}) })
// Append the message. If after is nil, then that means the message is the msgc := &messageRow{
// oldest, so we add it to the front of the list. MessageRow: c.Construct.NewMessage(msg, unwrapRow(before)),
if after != nil { }
index++ // insert right after
c.ListBox.Insert(msgc.Row(), index) // Add the message. If before is nil, then the to-be-inserted message is the
} else { // earliest message, therefore we prepend it.
if before == nil {
index = 0 index = 0
c.ListBox.Add(msgc.Row()) c.ListBox.Prepend(msgc.Row())
} else {
index++ // insert right after
// Fast path: Insert did appear a lot on profiles, so we can try and use
// Add over Insert when we know.
if c.MessagesLen() == index {
c.ListBox.Add(msgc.Row())
} else {
c.ListBox.Insert(msgc.Row(), index)
}
} }
// Set the ID into the message map. // Set the ID into the message map.
c.messages[idKey(msgc.ID())] = msgc c.messages[idKey(msgc.ID())] = msgc
c.bindMessage(msgc) c.bindMessage(msgc)
return msgc.MessageRow
} }
func (c *ListStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { func (c *ListStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) {
@ -416,8 +419,6 @@ func (c *ListStore) DeleteEarliest(n int) {
id := primitives.GetName(v.(primitives.Namer)) id := primitives.GetName(v.(primitives.Namer))
gridMsg := c.message(id, "") gridMsg := c.message(id, "")
log.Println("Deleting overflowed message ID from", gridMsg.AuthorName())
if id := gridMsg.ID(); id != "" { if id := gridMsg.ID(); id != "" {
delete(c.messages, idKey(id)) delete(c.messages, idKey(id))
} }

View File

@ -11,6 +11,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/handy" "github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -20,7 +21,7 @@ import (
type Controller interface { type Controller interface {
AddPresendMessage(msg PresendMessage) (onErr func(error)) AddPresendMessage(msg PresendMessage) (onErr func(error))
LatestMessageFrom(userID cchat.ID) (messageID cchat.ID, ok bool) LatestMessageFrom(userID cchat.ID) (messageID cchat.ID, ok bool)
MessageAuthorMarkup(msgID cchat.ID) (markup string, ok bool) MessageAuthor(msgID cchat.ID) cchat.Author
} }
// LabelBorrower is an interface that allows the caller to borrow a label. // LabelBorrower is an interface that allows the caller to borrow a label.
@ -330,12 +331,22 @@ func (f *Field) StartReplyingTo(msgID cchat.ID) {
f.replyingID = msgID f.replyingID = msgID
f.sendIcon.SetFromIconName(replyButtonIcon, gtk.ICON_SIZE_BUTTON) f.sendIcon.SetFromIconName(replyButtonIcon, gtk.ICON_SIZE_BUTTON)
name, ok := f.ctrl.MessageAuthorMarkup(msgID) if author := f.ctrl.MessageAuthor(msgID); author != nil {
if !ok { // Extract the name from the author's rich text and only render the area
name = "message" // with the MessageReference.
name := author.Name()
for _, seg := range name.Segments {
if seg.AsMessageReferencer() != nil || seg.AsMentioner() != nil {
mention := markup.Render(markup.SubstringSegment(name, seg))
f.indicator.BorrowLabel("Replying to " + mention)
return
}
}
} }
f.indicator.BorrowLabel("Replying to " + name) f.indicator.BorrowLabel("Replying to message.")
return
} }
// Editable returns whether or not the input field can be edited. // Editable returns whether or not the input field can be edited.

View File

@ -64,14 +64,12 @@ func (f *Field) sendInput() {
} }
f.SendMessage(SendMessageData{ f.SendMessage(SendMessageData{
time: time.Now().UTC(), time: time.Now().UTC(),
content: text, content: text,
author: f.Username.GetLabel(), author: newAuthor(f),
authorID: f.UserID, nonce: f.generateNonce(),
authorURL: f.Username.GetIconURL(), replyID: f.replyingID,
nonce: f.generateNonce(), files: attachments,
replyID: f.replyingID,
files: attachments,
}) })
// Clear the input field after sending. // Clear the input field after sending.
@ -110,14 +108,12 @@ func (files Files) Attachments() []cchat.MessageAttachment {
// SendMessageData contains what is to be sent in a message. It behaves // SendMessageData contains what is to be sent in a message. It behaves
// similarly to a regular CreateMessage. // similarly to a regular CreateMessage.
type SendMessageData struct { type SendMessageData struct {
time time.Time time time.Time
content string content string
author text.Rich author cchat.Author
authorID cchat.ID nonce string
authorURL string // avatar replyID cchat.ID
nonce string files Files
replyID cchat.ID
files Files
} }
var _ cchat.SendableMessage = (*SendMessageData)(nil) var _ cchat.SendableMessage = (*SendMessageData)(nil)
@ -130,9 +126,7 @@ type PresendMessage interface {
// These methods are reserved for internal use. // These methods are reserved for internal use.
Author() text.Rich Author() cchat.Author
AuthorID() string
AuthorAvatarURL() string // may be empty
Files() []attachment.File Files() []attachment.File
} }
@ -142,12 +136,30 @@ var _ PresendMessage = (*SendMessageData)(nil)
func (s SendMessageData) ID() string { return s.nonce } func (s SendMessageData) ID() string { return s.nonce }
func (s SendMessageData) Time() time.Time { return s.time } func (s SendMessageData) Time() time.Time { return s.time }
func (s SendMessageData) Content() string { return s.content } func (s SendMessageData) Content() string { return s.content }
func (s SendMessageData) Author() text.Rich { return s.author } func (s SendMessageData) Author() cchat.Author { return s.author }
func (s SendMessageData) AuthorID() string { return s.authorID }
func (s SendMessageData) AuthorAvatarURL() string { return s.authorURL }
func (s SendMessageData) AsNoncer() cchat.Noncer { return s } func (s SendMessageData) AsNoncer() cchat.Noncer { return s }
func (s SendMessageData) Nonce() string { return s.nonce } func (s SendMessageData) Nonce() string { return s.nonce }
func (s SendMessageData) Files() []attachment.File { return s.files } func (s SendMessageData) Files() []attachment.File { return s.files }
func (s SendMessageData) AsAttacher() cchat.Attacher { return s.files } func (s SendMessageData) AsAttacher() cchat.Attacher { return s.files }
func (s SendMessageData) AsReplier() cchat.Replier { return s } func (s SendMessageData) AsReplier() cchat.Replier { return s }
func (s SendMessageData) ReplyingTo() cchat.ID { return s.replyID } func (s SendMessageData) ReplyingTo() cchat.ID { return s.replyID }
type sendableAuthor struct {
id cchat.ID
name text.Rich
avatarURL string
}
func newAuthor(f *Field) sendableAuthor {
return sendableAuthor{
f.UserID,
f.Username.GetLabel(),
f.Username.GetIconURL(),
}
}
var _ cchat.Author = (*sendableAuthor)(nil)
func (a sendableAuthor) ID() string { return a.id }
func (a sendableAuthor) Name() text.Rich { return a.name }
func (a sendableAuthor) Avatar() string { return a.avatarURL }

View File

@ -29,7 +29,7 @@ func (evq *eventQueue) Add(fn func()) {
if evq.activated { if evq.activated {
evq.idleQueue = append(evq.idleQueue, fn) evq.idleQueue = append(evq.idleQueue, fn)
} else { } else {
gts.ExecAsync(fn) gts.ExecLater(fn)
} }
} }
@ -54,18 +54,25 @@ func (evq *eventQueue) pop() []func() {
func (evq *eventQueue) Deactivate() { func (evq *eventQueue) Deactivate() {
var popped = evq.pop() var popped = evq.pop()
const chunkSz = 25
// We shouldn't try and run more than a certain amount of callbacks within a // We shouldn't try and run more than a certain amount of callbacks within a
// single loop, as it will freeze up the UI. // single loop, as it will freeze up the UI.
if len(popped) > 25 { for i := 0; i < len(popped); i += chunkSz {
for _, fn := range popped { // Calculate the bounds in chunks.
gts.ExecAsync(fn) start, end := i, min(i+chunkSz, len(popped))
}
return
}
gts.ExecAsync(func() { gts.ExecLater(func() {
for _, fn := range popped { for _, fn := range popped[start:end] {
fn() fn()
} }
}) })
}
}
func min(i, j int) int {
if i < j {
return i
}
return j
} }

View File

@ -366,6 +366,7 @@ func NewMember(member cchat.ListMember) *Member {
var noMentionLinks = markup.RenderConfig{ var noMentionLinks = markup.RenderConfig{
NoMentionLinks: true, NoMentionLinks: true,
NoReferencing: true,
} }
func (m *Member) Update(member cchat.ListMember) { func (m *Member) Update(member cchat.ListMember) {
@ -376,9 +377,10 @@ func (m *Member) Update(member cchat.ListMember) {
} }
m.output = markup.RenderCmplxWithConfig(member.Name(), noMentionLinks) m.output = markup.RenderCmplxWithConfig(member.Name(), noMentionLinks)
txt := strings.Builder{} txt := strings.Builder{}
txt.WriteString(fmt.Sprintf( txt.WriteString(fmt.Sprintf(
`<span color="#%06X">●</span> %s`, `<span color="#%06X" size="large">●</span> %s`,
statusColors(member.Status()), m.output.Markup, statusColors(member.Status()), m.output.Markup,
)) ))
@ -395,20 +397,22 @@ func (m *Member) Update(member cchat.ListMember) {
// Popup pops up the mention popover if any. // Popup pops up the mention popover if any.
func (m *Member) Popup(evq EventQueuer) { func (m *Member) Popup(evq EventQueuer) {
if len(m.output.Mentions) > 0 { if len(m.output.Mentions) == 0 {
p := labeluri.NewPopoverMentioner(m, m.output.Input, m.output.Mentions[0]) return
if p == nil {
return
}
// Unbounded concurrency is kind of bad. We should deal with
// this in the future.
evq.Activate()
p.Connect("closed", func(interface{}) { evq.Deactivate() })
p.SetPosition(gtk.POS_LEFT)
p.Popup()
} }
p := labeluri.NewPopoverMentioner(m, m.output.Input, m.output.Mentions[0])
if p == nil {
return
}
// Unbounded concurrency is kind of bad. We should deal with
// this in the future.
evq.Activate()
p.Connect("closed", func(interface{}) { evq.Deactivate() })
p.SetPosition(gtk.POS_LEFT)
p.Popup()
} }
func statusColors(status cchat.Status) uint32 { func statusColors(status cchat.Status) uint32 {

View File

@ -0,0 +1,50 @@
package message
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat/text"
)
// Author implements cchat.Author. It effectively contains a copy of
// cchat.Author.
type Author struct {
id cchat.ID
name text.Rich
avatarURL string
}
var _ cchat.Author = (*Author)(nil)
// NewAuthor creates a new Author that is a copy of the given author.
func NewAuthor(author cchat.Author) Author {
a := Author{}
a.Update(author)
return a
}
// NewCustomAuthor creates a new Author from the given parameters.
func NewCustomAuthor(id cchat.ID, name text.Rich, avatar string) Author {
return Author{
id,
name,
avatar,
}
}
func (a *Author) Update(author cchat.Author) {
a.id = author.ID()
a.name = author.Name()
a.avatarURL = author.Avatar()
}
func (a Author) ID() string {
return a.id
}
func (a Author) Name() text.Rich {
return a.name
}
func (a Author) Avatar() string {
return a.avatarURL
}

View File

@ -4,28 +4,22 @@ import (
"time" "time"
"github.com/diamondburned/cchat" "github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/humanize"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text" "github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango" "github.com/gotk3/gotk3/pango"
) )
type Container interface { type Container interface {
ID() string ID() cchat.ID
Time() time.Time Time() time.Time
AuthorID() string Author() cchat.Author
AuthorName() string
AuthorMarkup() string
AvatarURL() string // avatar
Nonce() string Nonce() string
UpdateAuthor(cchat.Author) UpdateAuthor(cchat.Author)
UpdateAuthorName(text.Rich)
UpdateContent(c text.Rich, edited bool) UpdateContent(c text.Rich, edited bool)
UpdateTimestamp(time.Time) UpdateTimestamp(time.Time)
} }
@ -40,8 +34,6 @@ func FillContainer(c Container, msg cchat.MessageCreate) {
// RefreshContainer sets the container's contents to the one from // RefreshContainer sets the container's contents to the one from
// GenericContainer. This is mainly used for transferring between different // GenericContainer. This is mainly used for transferring between different
// containers. // containers.
//
// Right now, this only works with Timestamp, as that's the only state tracked.
func RefreshContainer(c Container, gc *GenericContainer) { func RefreshContainer(c Container, gc *GenericContainer) {
c.UpdateTimestamp(gc.time) c.UpdateTimestamp(gc.time)
} }
@ -53,40 +45,20 @@ type GenericContainer struct {
row *gtk.ListBoxRow // contains Box row *gtk.ListBoxRow // contains Box
class string class string
id string id string
time time.Time time time.Time
authorID string author Author
authorName string nonce string
avatarURL string // avatar
nonce string
Timestamp *gtk.Label Content *gtk.Box
Username *labeluri.Label ContentBody *labeluri.Label
Content gtk.IWidget // conceal widget implementation ContentBodyStyle *gtk.StyleContext
contentBox *gtk.Box // basically what is in Content
ContentBody *labeluri.Label
menuItems []menu.Item menuItems []menu.Item
} }
var _ Container = (*GenericContainer)(nil) var _ Container = (*GenericContainer)(nil)
var timestampCSS = primitives.PrepareClassCSS("message-time", `
.message-time {
opacity: 0.3;
font-size: 0.8em;
margin-top: 0.2em;
margin-bottom: 0.2em;
}
`)
var authorCSS = primitives.PrepareClassCSS("message-author", `
.message-author {
color: mix(@theme_bg_color, @theme_fg_color, 0.8);
}
`)
// NewContainer creates a new message container with the given ID and nonce. It // NewContainer creates a new message container with the given ID and nonce. It
// does not update the widgets, so FillContainer should be called afterwards. // does not update the widgets, so FillContainer should be called afterwards.
func NewContainer(msg cchat.MessageCreate) *GenericContainer { func NewContainer(msg cchat.MessageCreate) *GenericContainer {
@ -94,24 +66,12 @@ func NewContainer(msg cchat.MessageCreate) *GenericContainer {
c.id = msg.ID() c.id = msg.ID()
c.time = msg.Time() c.time = msg.Time()
c.nonce = msg.Nonce() c.nonce = msg.Nonce()
c.authorID = msg.Author().ID() c.author.Update(msg.Author())
return c return c
} }
func NewEmptyContainer() *GenericContainer { func NewEmptyContainer() *GenericContainer {
ts, _ := gtk.LabelNew("")
ts.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
ts.SetXAlign(0.5) // centre align
ts.SetVAlign(gtk.ALIGN_END)
ts.Show()
user := labeluri.NewLabel(text.Rich{})
user.SetXAlign(0) // left align
user.SetVAlign(gtk.ALIGN_START)
user.SetTrackVisitedLinks(false)
user.Show()
ctbody := labeluri.NewLabel(text.Rich{}) ctbody := labeluri.NewLabel(text.Rich{})
ctbody.SetVExpand(true) ctbody.SetVExpand(true)
ctbody.SetHAlign(gtk.ALIGN_START) ctbody.SetHAlign(gtk.ALIGN_START)
@ -123,6 +83,9 @@ func NewEmptyContainer() *GenericContainer {
ctbody.SetTrackVisitedLinks(false) ctbody.SetTrackVisitedLinks(false)
ctbody.Show() ctbody.Show()
ctbodyStyle, _ := ctbody.GetStyleContext()
ctbodyStyle.AddClass("message-content")
// Wrap the content label inside a content box. // Wrap the content label inside a content box.
ctbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) ctbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
ctbox.SetHExpand(true) ctbox.SetHExpand(true)
@ -135,24 +98,15 @@ func NewEmptyContainer() *GenericContainer {
row, _ := gtk.ListBoxRowNew() row, _ := gtk.ListBoxRowNew()
row.Add(box) row.Add(box)
row.Show() row.Show()
// Add CSS classes.
primitives.AddClass(ts, "message-time")
primitives.AddClass(row, "message-row") primitives.AddClass(row, "message-row")
primitives.AddClass(user, "message-author")
primitives.AddClass(ctbody, "message-content")
timestampCSS(ts)
authorCSS(ts)
gc := &GenericContainer{ gc := &GenericContainer{
Box: box, Box: box,
row: row, row: row,
Timestamp: ts, Content: ctbox,
Username: user, ContentBody: ctbody,
Content: ctbox, ContentBodyStyle: ctbodyStyle,
contentBox: ctbox,
ContentBody: ctbody,
// Time is important, as it is used to sort messages, so we have to be // Time is important, as it is used to sort messages, so we have to be
// careful with this. // careful with this.
@ -183,7 +137,6 @@ func (m *GenericContainer) SetClass(class string) {
// SetReferenceHighlighter sets the reference highlighter into the message. // SetReferenceHighlighter sets the reference highlighter into the message.
func (m *GenericContainer) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) { func (m *GenericContainer) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) {
m.Username.SetReferenceHighlighter(r)
m.ContentBody.SetReferenceHighlighter(r) m.ContentBody.SetReferenceHighlighter(r)
} }
@ -195,20 +148,8 @@ func (m *GenericContainer) Time() time.Time {
return m.time return m.time
} }
func (m *GenericContainer) AuthorID() string { func (m *GenericContainer) Author() cchat.Author {
return m.authorID return m.author
}
func (m *GenericContainer) AuthorName() string {
return m.authorName
}
func (m *GenericContainer) AuthorMarkup() string {
return m.Username.Label.Label.GetLabel()
}
func (m *GenericContainer) AvatarURL() string {
return m.avatarURL
} }
func (m *GenericContainer) Nonce() string { func (m *GenericContainer) Nonce() string {
@ -217,23 +158,10 @@ func (m *GenericContainer) Nonce() string {
func (m *GenericContainer) UpdateTimestamp(t time.Time) { func (m *GenericContainer) UpdateTimestamp(t time.Time) {
m.time = t m.time = t
m.Timestamp.SetText(humanize.TimeAgo(t))
m.Timestamp.SetTooltipText(t.Format(time.Stamp))
} }
func (m *GenericContainer) UpdateAuthor(author cchat.Author) { func (m *GenericContainer) UpdateAuthor(author cchat.Author) {
m.authorID = author.ID() m.author.Update(author)
m.avatarURL = author.Avatar()
m.UpdateAuthorName(author.Name())
}
func (m *GenericContainer) UpdateAuthorName(name text.Rich) {
cfg := markup.RenderConfig{}
cfg.NoReferencing = true
cfg.SetForegroundAnchor(m.ContentBody)
m.authorName = name.String()
m.Username.SetOutput(markup.RenderCmplxWithConfig(name, cfg))
} }
func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) { func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {

View File

@ -35,14 +35,10 @@ type GenericPresendContainer struct {
var _ PresendContainer = (*GenericPresendContainer)(nil) var _ PresendContainer = (*GenericPresendContainer)(nil)
func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer { func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer {
return WrapPresendContainer(NewEmptyContainer(), msg) c := NewEmptyContainer()
}
func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *GenericPresendContainer {
c.nonce = msg.Nonce() c.nonce = msg.Nonce()
c.authorID = msg.AuthorID() c.UpdateAuthor(msg.Author())
c.UpdateTimestamp(msg.Time()) c.UpdateTimestamp(msg.Time())
c.UpdateAuthorName(msg.Author())
p := &GenericPresendContainer{ p := &GenericPresendContainer{
GenericContainer: c, GenericContainer: c,
@ -56,7 +52,7 @@ func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *Generi
} }
func (m *GenericPresendContainer) SetSensitive(sensitive bool) { func (m *GenericPresendContainer) SetSensitive(sensitive bool) {
m.contentBox.SetSensitive(sensitive) m.Content.SetSensitive(sensitive)
} }
func (m *GenericPresendContainer) SetDone(id string) { func (m *GenericPresendContainer) SetDone(id string) {
@ -68,13 +64,13 @@ func (m *GenericPresendContainer) SetDone(id string) {
// free it from memory. // free it from memory.
m.presend = nil m.presend = nil
m.uploads = nil m.uploads = nil
m.contentBox.SetTooltipText("") m.Content.SetTooltipText("")
// Remove everything in the content box. // Remove everything in the content box.
m.clearBox() m.clearBox()
// Re-add the content label. // Re-add the content label.
m.contentBox.Add(m.ContentBody) m.Content.Add(m.ContentBody)
// Set the sensitivity from false in SetLoading back to true. // Set the sensitivity from false in SetLoading back to true.
m.SetSensitive(true) m.SetSensitive(true)
@ -82,18 +78,18 @@ func (m *GenericPresendContainer) SetDone(id string) {
func (m *GenericPresendContainer) SetLoading() { func (m *GenericPresendContainer) SetLoading() {
m.SetSensitive(false) m.SetSensitive(false)
m.contentBox.SetTooltipText("") m.Content.SetTooltipText("")
// Clear everything inside the content container. // Clear everything inside the content container.
m.clearBox() m.clearBox()
// Add the content label. // Add the content label.
m.contentBox.Add(m.ContentBody) m.Content.Add(m.ContentBody)
// Add the attachment progress box back in, if any. // Add the attachment progress box back in, if any.
if m.uploads != nil { if m.uploads != nil {
m.uploads.Show() // show the bars m.uploads.Show() // show the bars
m.contentBox.Add(m.uploads) m.Content.Add(m.uploads)
} }
if content := m.presend.Content(); content != "" { if content := m.presend.Content(); content != "" {
@ -106,13 +102,13 @@ func (m *GenericPresendContainer) SetLoading() {
func (m *GenericPresendContainer) SetSentError(err error) { func (m *GenericPresendContainer) SetSentError(err error) {
m.SetSensitive(true) // allow events incl right clicks m.SetSensitive(true) // allow events incl right clicks
m.contentBox.SetTooltipText(err.Error()) m.Content.SetTooltipText(err.Error())
// Remove everything again. // Remove everything again.
m.clearBox() m.clearBox()
// Re-add the label. // Re-add the label.
m.contentBox.Add(m.ContentBody) m.Content.Add(m.ContentBody)
// Style the label appropriately by making it red. // Style the label appropriately by making it red.
var content = EmptyContentPlaceholder var content = EmptyContentPlaceholder
@ -132,13 +128,10 @@ func (m *GenericPresendContainer) SetSentError(err error) {
)) ))
errl.Show() errl.Show()
m.contentBox.Add(errl) m.Content.Add(errl)
} }
// clearBox clears everything inside the content container. // clearBox clears everything inside the content container.
func (m *GenericPresendContainer) clearBox() { func (m *GenericPresendContainer) clearBox() {
primitives.ForeachChild(m.contentBox, func(v interface{}) (stop bool) { primitives.RemoveChildren(m.Content)
m.contentBox.Remove(v.(gtk.IWidget))
return false
})
} }

View File

@ -0,0 +1,27 @@
package message
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
)
var timestampCSS = primitives.PrepareClassCSS("message-time", `
.message-time {
opacity: 0.3;
font-size: 0.8em;
margin-top: 0.2em;
margin-bottom: 0.2em;
}
`)
func NewTimestamp() *gtk.Label {
ts, _ := gtk.LabelNew("")
ts.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
ts.SetXAlign(0.5) // centre align
ts.SetVAlign(gtk.ALIGN_END)
ts.Show()
timestampCSS(ts)
return ts
}

View File

@ -0,0 +1,25 @@
package message
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
)
var authorCSS = primitives.PrepareClassCSS("message-author", `
.message-author {
color: mix(@theme_bg_color, @theme_fg_color, 0.8);
}
`)
func NewUsername() *labeluri.Label {
user := labeluri.NewLabel(text.Rich{})
user.SetXAlign(0) // left align
user.SetVAlign(gtk.ALIGN_START)
user.SetTrackVisitedLinks(false)
user.Show()
authorCSS(user)
return user
}

View File

@ -14,8 +14,10 @@ type FaceView struct {
gtk.Stack gtk.Stack
placeholder gtk.IWidget placeholder gtk.IWidget
Face *Container face *Container
Loading *Spinner loading *Spinner
parent gtk.IWidget
empty gtk.IWidget
} }
func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView { func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView {
@ -31,42 +33,50 @@ func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView {
stack, _ := gtk.StackNew() stack, _ := gtk.StackNew()
stack.SetTransitionDuration(55) stack.SetTransitionDuration(55)
stack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE) stack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
stack.AddNamed(parent, "main") stack.Add(parent)
stack.AddNamed(placeholder, "placeholder") stack.Add(c)
stack.AddNamed(c, "face") stack.Add(s)
stack.AddNamed(s, "loading") stack.Add(b)
stack.AddNamed(b, "empty")
// Show placeholder by default. // Show placeholder by default.
stack.SetVisibleChildName("placeholder") stack.AddNamed(placeholder, "placeholder")
stack.SetVisibleChild(placeholder)
return &FaceView{*stack, placeholder, c, s} return &FaceView{
Stack: *stack,
placeholder: placeholder,
face: c,
loading: s,
parent: parent,
empty: b,
}
} }
// Reset brings the view to an empty box. // Reset brings the view to an empty box.
func (v *FaceView) Reset() { func (v *FaceView) Reset() {
v.Loading.Spinner.Stop() v.loading.Spinner.Stop()
v.Stack.SetVisibleChildName("empty") v.Stack.SetVisibleChild(v.empty)
v.ensurePlaceholderDestroyed() v.ensurePlaceholderDestroyed()
} }
func (v *FaceView) SetMain() { func (v *FaceView) SetMain() {
v.Loading.Spinner.Stop() v.loading.Spinner.Stop()
v.Stack.SetVisibleChildName("main") v.Stack.SetVisibleChild(v.parent)
v.ensurePlaceholderDestroyed() v.ensurePlaceholderDestroyed()
} }
func (v *FaceView) SetLoading() { func (v *FaceView) SetLoading() {
v.Loading.Spinner.Start() v.loading.Spinner.Start()
v.Stack.SetVisibleChildName("loading") v.Stack.SetVisibleChild(v.loading)
v.ensurePlaceholderDestroyed() v.ensurePlaceholderDestroyed()
} }
func (v *FaceView) SetError(err error) { func (v *FaceView) SetError(err error) {
v.Face.SetError(err) v.face.SetError(err)
v.Stack.SetVisibleChildName("face") v.Stack.SetVisibleChild(v.face)
v.ensurePlaceholderDestroyed() v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop() v.loading.Spinner.Stop()
} }
func (v *FaceView) ensurePlaceholderDestroyed() { func (v *FaceView) ensurePlaceholderDestroyed() {
@ -74,7 +84,7 @@ func (v *FaceView) ensurePlaceholderDestroyed() {
if v.placeholder != nil { if v.placeholder != nil {
// Safely remove the placeholder from the stack. // Safely remove the placeholder from the stack.
if v.Stack.GetVisibleChildName() == "placeholder" { if v.Stack.GetVisibleChildName() == "placeholder" {
v.Stack.SetVisibleChildName("empty") v.Stack.SetVisibleChild(v.empty)
} }
// Remove the placeholder widget. // Remove the placeholder widget.

View File

@ -232,8 +232,8 @@ func (v *View) Reset() {
// reset resets the message view, but does not change visible containers. // reset resets the message view, but does not change visible containers.
func (v *View) reset() { func (v *View) reset() {
v.Header.Reset() // Reset the header.
v.state.Reset() // Reset the state variables. v.state.Reset() // Reset the state variables.
v.Header.Reset() // Reset the header.
v.Typing.Reset() // Reset the typing state. v.Typing.Reset() // Reset the typing state.
v.InputView.Reset() // Reset the input. v.InputView.Reset() // Reset the input.
v.MemberList.Reset() // Reset the member list. v.MemberList.Reset() // Reset the member list.
@ -397,13 +397,13 @@ func (v *View) AuthorEvent(author cchat.Author) {
} }
} }
func (v *View) MessageAuthorMarkup(msgID cchat.ID) (string, bool) { func (v *View) MessageAuthor(msgID cchat.ID) cchat.Author {
msg := v.Container.Message(msgID, "") msg := v.Container.Message(msgID, "")
if msg == nil { if msg == nil {
return "", false return nil
} }
return msg.AuthorMarkup(), true return msg.Author()
} }
// LatestMessageFrom returns the last message ID with that author. // LatestMessageFrom returns the last message ID with that author.

View File

@ -26,13 +26,13 @@ type Container interface {
var _ Container = (*gtk.Container)(nil) var _ Container = (*gtk.Container)(nil)
func RemoveChildren(w Container) { func RemoveChildren(w Container) {
type destroyer interface { // type destroyer interface {
Destroy() // Destroy()
} // }
children := w.GetChildren() w.GetChildren().FreeFull(func(child interface{}) {
children.Foreach(func(child interface{}) { w.Remove(child.(gtk.IWidget)) }) w.Remove(child.(gtk.IWidget))
children.Free() })
} }
// ChildrenLen gets the total count of children for the given container. // ChildrenLen gets the total count of children for the given container.
@ -47,9 +47,15 @@ func NthChild(w Container, n int) interface{} {
children := w.GetChildren() children := w.GetChildren()
defer children.Free() defer children.Free()
// Bound check!
if n < 0 || int(children.Length()) >= n {
return nil
}
if n == 0 { if n == 0 {
return children.Data() return children.Data()
} }
return children.NthData(uint(n)) return children.NthData(uint(n))
} }
@ -324,7 +330,9 @@ func PrepareClassCSS(class, css string) (attach func(StyleContexter)) {
return func(ctx StyleContexter) { return func(ctx StyleContexter) {
s, _ := ctx.GetStyleContext() s, _ := ctx.GetStyleContext()
s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
s.AddClass(class) if class != "" {
s.AddClass(class)
}
} }
} }

View File

@ -82,12 +82,20 @@ func (l *Label) Output() markup.RenderOutput {
return l.output return l.output
} }
// SetOutput sets the internal output and label. // SetOutput sets the internal output and label. It preserves the tail if
// any.
func (l *Label) SetOutput(o markup.RenderOutput) { func (l *Label) SetOutput(o markup.RenderOutput) {
l.output = o l.output = o
l.SetMarkup(o.Markup) l.SetMarkup(o.Markup)
} }
// SetUnderlyingOutput sets the output state without changing the label's
// markup. This is useful for internal use cases where the label is updated
// separately.
func (l *Label) SetUnderlyingOutput(o markup.RenderOutput) {
l.output = o
}
type ReferenceHighlighter interface { type ReferenceHighlighter interface {
HighlightReference(ref markup.ReferenceSegment) HighlightReference(ref markup.ReferenceSegment)
} }

View File

@ -9,7 +9,6 @@ import (
"strconv" "strconv"
"strings" "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/attrmap"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/hl" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/hl"
"github.com/diamondburned/cchat/text" "github.com/diamondburned/cchat/text"
@ -21,7 +20,30 @@ import (
var Hyphenate = false var Hyphenate = false
func hyphenate(text string) string { func hyphenate(text string) string {
return fmt.Sprintf(`<span insert_hyphens="%t">%s</span>`, Hyphenate, text) if !Hyphenate {
return text
}
return `<span insert_hyphens="true">` + text + `</span>`
}
// SubstringSegment slices the given rich text.
func SubstringSegment(rich text.Rich, seg text.Segment) text.Rich {
start, end := seg.Bounds()
substring := text.Rich{
Content: rich.Content[start:end],
Segments: make([]text.Segment, 0, len(rich.Segments)),
}
for _, seg := range rich.Segments {
i, j := seg.Bounds()
// Bound-check.
if start <= i && j <= end {
substring.Segments = append(substring.Segments, seg)
}
}
return substring
} }
// RenderOutput is the output of a render. // RenderOutput is the output of a render.
@ -79,8 +101,13 @@ func (r RenderOutput) URISegment(uri string) text.Segment {
} }
} }
var simpleConfig = RenderConfig{
NoMentionLinks: true,
NoReferencing: true,
}
func Render(content text.Rich) string { func Render(content text.Rich) string {
return RenderCmplx(content).Markup return RenderCmplxWithConfig(content, simpleConfig).Markup
} }
// RenderCmplx renders content into a complete output. // RenderCmplx renders content into a complete output.
@ -107,18 +134,19 @@ type RenderConfig struct {
// SetForegroundAnchor sets the AnchorColor of the render config to be that of // SetForegroundAnchor sets the AnchorColor of the render config to be that of
// the regular text foreground color. // the regular text foreground color.
func (c *RenderConfig) SetForegroundAnchor(styler primitives.StyleContexter) { func (c *RenderConfig) SetForegroundAnchor(ctx *gtk.StyleContext) {
styleCtx, _ := styler.GetStyleContext() rgba := ctx.GetColor(gtk.STATE_FLAG_NORMAL)
if rgba == nil {
if rgba := styleCtx.GetColor(gtk.STATE_FLAG_NORMAL); rgba != nil { return
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
} }
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 { func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput {
@ -183,35 +211,19 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput {
// Mentioner needs to be before colorer, as we'd want the below color // 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 // segment to also highlight the full mention as well as make the
// padding part of the hyperlink. // padding part of the hyperlink.
if mentioner := segment.AsMentioner(); mentioner != nil && !cfg.NoMentionLinks { if mentioner := segment.AsMentioner(); mentioner != nil {
// Render the mention into "cchat://mention:0" or such. Other // Render the mention into "cchat://mention:0" or such. Other
// components will take care of showing the information. // components will take care of showing the information.
appended.AnchorNU(start, end, fmtSegmentURI(MentionType, len(mentions))) if !cfg.NoMentionLinks {
hasAnchor = true appended.AnchorNU(start, end, fmtSegmentURI(MentionType, len(mentions)))
hasAnchor = true
}
// Add the mention segment into the list regardless of hyperlinks. // Add the mention segment into the list regardless of hyperlinks.
mentions = append(mentions, MentionSegment{ mentions = append(mentions, MentionSegment{
Segment: segment, Segment: segment,
Mentioner: mentioner, Mentioner: mentioner,
}) })
// 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 { if colorer := segment.AsColorer(); colorer != nil {
@ -223,18 +235,18 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput {
// Don't use AnchorColor for the link, as we're technically just // Don't use AnchorColor for the link, as we're technically just
// borrowing the anchor tag for its use. We should also prefer the // borrowing the anchor tag for its use. We should also prefer the
// username popover (Mention) over this. // username popover (Mention) over this.
if !cfg.NoReferencing && !hasAnchor { if reference := segment.AsMessageReferencer(); reference != nil {
if reference := segment.AsMessageReferencer(); reference != nil { if !cfg.NoReferencing && !hasAnchor {
// Render the mention into "cchat://reference:0" or such. Other // Render the mention into "cchat://reference:0" or such. Other
// components will take care of showing the information. // components will take care of showing the information.
appended.AnchorNU(start, end, fmtSegmentURI(ReferenceType, len(references))) appended.AnchorNU(start, end, fmtSegmentURI(ReferenceType, len(references)))
// Add the mention segment into the list regardless of hyperlinks.
references = append(references, ReferenceSegment{
Segment: segment,
MessageReferencer: reference,
})
} }
// Add the mention segment into the list regardless of hyperlinks.
references = append(references, ReferenceSegment{
Segment: segment,
MessageReferencer: reference,
})
} }
if attributor := segment.AsAttributor(); attributor != nil { if attributor := segment.AsAttributor(); attributor != nil {

View File

@ -159,7 +159,9 @@ func (app *App) SessionSelected(svc *service.Service, ses *session.Row) {
} }
func (app *App) ClearMessenger(ses *session.Row) { func (app *App) ClearMessenger(ses *session.Row) {
if app.MessageView.SessionID() == ses.Session.ID() { // No need to try if the window is destroyed already, since its children
// will also be destroyed.
if !gts.IsClosing() && app.MessageView.SessionID() == ses.Session.ID() {
app.MessageView.Reset() app.MessageView.Reset()
} }
} }