1
0
Fork 0
mirror of https://github.com/diamondburned/cchat-gtk.git synced 2024-11-17 19:52:45 +00:00

More loading circles, lots of bug fixes, added logo

This commit is contained in:
diamondburned (Forefront) 2020-06-13 00:29:32 -07:00
parent 6343e9888a
commit 7f0a6653ee
46 changed files with 1872 additions and 524 deletions

11
go.mod
View file

@ -2,16 +2,13 @@ module github.com/diamondburned/cchat-gtk
go 1.14
replace github.com/diamondburned/cchat-mock => ../cchat-mock/
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200612012846-9df87fea4f6d
require (
github.com/Xuanwo/go-locale v0.2.0
github.com/diamondburned/cchat v0.0.15
github.com/diamondburned/cchat-mock v0.0.0-20200605224934-31a53c555ea2
github.com/diamondburned/imgutil v0.0.0-20200606035324-63abbc0fdea6
github.com/die-net/lrucache v0.0.0-20190707192454-883874fe3947
github.com/diamondburned/cchat v0.0.25
github.com/diamondburned/cchat-mock v0.0.0-20200613003444-b36f8f47debe
github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64
github.com/goodsign/monday v1.0.0
github.com/google/btree v1.0.0 // indirect
github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194

15
go.sum
View file

@ -7,14 +7,12 @@ github.com/danieljoos/wincred v1.0.2/go.mod h1:SnuYRW9lp1oJrZX/dXJqr0cPK5gYXqx3E
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diamondburned/cchat v0.0.15 h1:1o4OX8zw/CdSv3Idaylz7vjHVOZKEi/xkg8BpEvtsHY=
github.com/diamondburned/cchat v0.0.15/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a h1:pH6WLWVhzzvpXjGwPQbPhhp1g0ZMLZFS5S8zLoRGYRg=
github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/diamondburned/imgutil v0.0.0-20200606035324-63abbc0fdea6 h1:APALM1hskCByjOVW9CoUwjg0TIJgKZ62dgFr/9soqss=
github.com/diamondburned/imgutil v0.0.0-20200606035324-63abbc0fdea6/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/die-net/lrucache v0.0.0-20190707192454-883874fe3947 h1:U/5Sq2nJQ0XDyks+8ATghtHSuquIGq7JYrqSrvtR2dg=
github.com/die-net/lrucache v0.0.0-20190707192454-883874fe3947/go.mod h1:KsMcjmY1UCGl7ozPbdVPDOvLaFeXnptSvtNRczhxNto=
github.com/diamondburned/cchat v0.0.25 h1:+kf2gQu5TQs1vD/gCaVlzKu5vOqZz/1Qw87xHdeFYj4=
github.com/diamondburned/cchat v0.0.25/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/gotk3 v0.0.0-20200612012846-9df87fea4f6d h1:NFTuwBU+CNZDB1iaGC3gDuBRf9FTd1h2WnIh6NF7elg=
github.com/diamondburned/gotk3 v0.0.0-20200612012846-9df87fea4f6d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64 h1:/ykUYHuYyj+NN/aaqe6lfaCZQc3EMZs93wAGVJTh5j0=
github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI=
@ -52,7 +50,6 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 h1:3M/uUZajYn/082wzUajekePxpUAZhMTfXvI9R+26SJ0=

BIN
icons/cchat-variant2.xcf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
icons/cchat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

1
icons/cchat.svg Normal file
View file

@ -0,0 +1 @@
<svg width="640" height="640" fill="#000000" version="1.1" viewBox="0 0 853 853" xmlns="http://www.w3.org/2000/svg" xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"><path d="m0 829h24v24h-24z" fill="none"/><path d="m107 113c-43.9 0-79.9 35.9-79.9 79.9v548l160-102h559c43.9 0 79.9-35.9 79.9-79.9v-366c0-43.9-35.9-79.9-79.9-79.9zm199 94.7c39.4 0 74.9 13 107 38.9 3.88 3.74 5.82 8.81 5.82 15.2 0 3.19-.555 6.1-1.66 8.74-1.11 2.64-2.7 4.99-4.78 7.07-1.94 1.94-4.3 3.47-7.07 4.58-2.63 1.11-5.48 1.67-8.53 1.67-5.41 0-10.5-2.08-15.2-6.24-10.5-8.32-22.2-14.7-34.9-19.1-12.6-4.44-26.1-6.66-40.4-6.66-33.8 0-62.7 12-86.7 36-23.7 23.7-35.6 52.5-35.6 86.3 0 34 11.9 62.8 35.6 86.5 23.8 23.8 52.8 35.8 86.7 35.8 29 0 54.5-8.94 76.5-26.8 3.61-2.5 7.9-3.81 12.9-3.95 3.05 0 5.89.623 8.53 1.87 2.63 1.11 4.92 2.64 6.86 4.58 2.08 1.94 3.67 4.3 4.78 7.07 1.25 2.64 1.87 5.48 1.87 8.53 0 5.82-2.29 11.1-6.86 15.8-30.9 24.7-65.8 37-105 37-46 0-85.3-16.2-118-48.7-32.4-32.4-48.7-71.7-48.7-118 0-45.9 16.2-85.1 48.7-118 32.6-32.6 71.8-48.9 118-48.9zm292 0c39.4 0 74.9 13 107 38.9 3.88 3.74 5.82 8.81 5.82 15.2 0 3.19-.555 6.1-1.66 8.74-1.11 2.64-2.7 4.99-4.78 7.07-1.94 1.94-4.3 3.47-7.07 4.58-2.63 1.11-5.48 1.67-8.53 1.67-5.41 0-10.5-2.08-15.2-6.24-10.5-8.32-22.2-14.7-34.9-19.1-12.6-4.44-26.1-6.66-40.3-6.66-33.8 0-62.7 12-86.7 36-23.7 23.7-35.6 52.5-35.6 86.3 0 34 11.9 62.8 35.6 86.5 23.8 23.8 52.8 35.8 86.7 35.8 29 0 54.5-8.94 76.5-26.8 3.61-2.5 7.9-3.81 12.9-3.95 3.05 0 5.89.623 8.53 1.87 2.63 1.11 4.92 2.64 6.86 4.58 2.08 1.94 3.67 4.3 4.78 7.07 1.25 2.64 1.87 5.48 1.87 8.53 0 5.82-2.29 11.1-6.86 15.8-30.9 24.7-65.8 37-105 37-46 0-85.3-16.2-118-48.7-32.4-32.4-48.7-71.7-48.7-118 0-45.9 16.2-85.1 48.7-118 32.6-32.6 71.8-48.9 118-48.9z" stroke-width="39.9"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
icons/cchat.symbolic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
icons/cchat.xcf Normal file

Binary file not shown.

BIN
icons/cchat_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

48
icons/icons.go Normal file
View file

@ -0,0 +1,48 @@
package icons
import (
"bytes"
"log"
"github.com/gotk3/gotk3/gdk"
"github.com/markbates/pkger"
)
// static assets
var logo256 *gdk.Pixbuf
func Logo256() *gdk.Pixbuf {
if logo256 == nil {
logo256 = loadPixbuf(pkger.Include("/icons/cchat-variant2_256.png"))
}
return logo256
}
func loadPixbuf(name string) *gdk.Pixbuf {
l, err := gdk.PixbufLoaderNew()
if err != nil {
log.Fatalln("Failed to create a pixbuf loader for icons:", err)
}
p, err := l.WriteAndReturnPixbuf(readFile(name))
if err != nil {
log.Fatalln("Failed to write and return pixbuf:", err)
}
return p
}
func readFile(name string) []byte {
f, err := pkger.Open(name)
if err != nil {
log.Fatalln("Failed to open pkger file:", err)
}
defer f.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(f); err != nil {
log.Fatalln("Failed to read from pkger file:", err)
}
return buf.Bytes()
}

View file

@ -75,10 +75,12 @@ func Async(fn func() (func(), error)) {
f, err := fn()
if err != nil {
log.Error(err)
return
}
glib.IdleAdd(f)
// Attempt to run the callback if it's there.
if f != nil {
glib.IdleAdd(f)
}
}()
}

View file

@ -1,6 +1,7 @@
package httputil
import (
"context"
"io"
"net/http"
"os"
@ -8,7 +9,6 @@ import (
"time"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/die-net/lrucache"
"github.com/gregjones/httpcache"
"github.com/gregjones/httpcache/diskcache"
"github.com/peterbourgon/diskv"
@ -16,7 +16,6 @@ import (
)
var dskcached *http.Client
var memcached *http.Client
func init() {
var basePath = filepath.Join(os.TempDir(), "cchat-gtk-pridemonth")
@ -34,12 +33,6 @@ func init() {
CacheSizeMax: 25 * 1024 * 1024, // 25 MiB in memory
})),
)
memcached = &(*http.DefaultClient)
memcached.Transport = httpcache.NewTransport(lrucache.New(
25*1024*1024, // 25 MiB in memory
secs(2*time.Hour), // 2 hours cache
))
}
func secs(dura time.Duration) int64 {
@ -48,7 +41,7 @@ func secs(dura time.Duration) int64 {
func AsyncStreamUncached(url string, fn func(r io.Reader)) {
gts.Async(func() (func(), error) {
r, err := get(url, false)
r, err := get(context.Background(), url, false)
if err != nil {
return nil, err
}
@ -62,7 +55,7 @@ func AsyncStreamUncached(url string, fn func(r io.Reader)) {
func AsyncStream(url string, fn func(r io.Reader)) {
gts.Async(func() (func(), error) {
r, err := get(url, true)
r, err := get(context.Background(), url, true)
if err != nil {
return nil, err
}
@ -74,13 +67,19 @@ func AsyncStream(url string, fn func(r io.Reader)) {
})
}
func get(url string, cached bool) (r *http.Response, err error) {
if cached {
r, err = dskcached.Get(url)
} else {
r, err = memcached.Get(url)
func get(ctx context.Context, url string, cached bool) (r *http.Response, err error) {
// if cached {
// r, err = dskcached.Get(url)
// } else {
// r, err = memcached.Get(url)
// }
q, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, errors.Wrap(err, "Failed to make a request")
}
r, err = dskcached.Do(q)
if err != nil {
return nil, err
}

View file

@ -1,6 +1,7 @@
package httputil
import (
"context"
"io"
"strings"
@ -8,12 +9,18 @@ import (
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
type ImageContainer interface {
SetFromPixbuf(*gdk.Pixbuf)
SetFromAnimation(*gdk.PixbufAnimation)
Connect(string, interface{}, ...interface{}) (glib.SignalHandle, error)
// for internal use
pbgetter
}
type ImageContainerSizer interface {
@ -23,27 +30,59 @@ type ImageContainerSizer interface {
// AsyncImage loads an image. This method uses the cache.
func AsyncImage(img ImageContainer, url string, procs ...imgutil.Processor) {
go syncImageFn(img, url, procs, func(l *gdk.PixbufLoader, gif bool) {
l.Connect("area-prepared", areaPreparedFn(img, gif))
if url == "" {
return
}
ctx, cancel := context.WithCancel(context.Background())
connectDestroyer(img, cancel)
go syncImageFn(ctx, img, url, procs, func(l *gdk.PixbufLoader, gif bool) {
l.Connect("area-prepared", areaPreparedFn(ctx, img, gif))
})
}
// AsyncImageSized resizes using GdkPixbuf. This method does not use the cache.
func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...imgutil.Processor) {
go syncImageFn(img, url, procs, func(l *gdk.PixbufLoader, gif bool) {
if url == "" {
return
}
// Add a processor to resize.
procs = imgutil.Prepend(imgutil.Resize(w, h), procs)
ctx, cancel := context.WithCancel(context.Background())
connectDestroyer(img, cancel)
go syncImageFn(ctx, img, url, procs, func(l *gdk.PixbufLoader, gif bool) {
l.Connect("size-prepared", func(l *gdk.PixbufLoader, imgW, imgH int) {
w, h = imgutil.MaxSize(imgW, imgH, w, h)
if w != imgW || h != imgH {
l.SetSize(w, h)
img.SetSizeRequest(w, h)
execIfCtx(ctx, func() { img.SetSizeRequest(w, h) })
}
})
l.Connect("area-prepared", areaPreparedFn(img, gif))
l.Connect("area-prepared", areaPreparedFn(ctx, img, gif))
})
}
func areaPreparedFn(img ImageContainer, gif bool) func(l *gdk.PixbufLoader) {
type pbgetter interface {
GetPixbuf() *gdk.Pixbuf
GetAnimation() *gdk.PixbufAnimation
GetStorageType() gtk.ImageType
}
var _ pbgetter = (*gtk.Image)(nil)
func connectDestroyer(img ImageContainer, cancel func()) {
img.Connect("destroy", func(img ImageContainer) {
cancel()
img.SetFromPixbuf(nil)
})
}
func areaPreparedFn(ctx context.Context, img ImageContainer, gif bool) func(l *gdk.PixbufLoader) {
return func(l *gdk.PixbufLoader) {
if !gif {
p, err := l.GetPixbuf()
@ -51,26 +90,35 @@ func areaPreparedFn(img ImageContainer, gif bool) func(l *gdk.PixbufLoader) {
log.Error(errors.Wrap(err, "Failed to get pixbuf"))
return
}
gts.ExecAsync(func() { img.SetFromPixbuf(p) })
execIfCtx(ctx, func() { img.SetFromPixbuf(p) })
} else {
p, err := l.GetAnimation()
if err != nil {
log.Error(errors.Wrap(err, "Failed to get animation"))
return
}
gts.ExecAsync(func() { img.SetFromAnimation(p) })
execIfCtx(ctx, func() { img.SetFromAnimation(p) })
}
}
}
func execIfCtx(ctx context.Context, fn func()) {
gts.ExecAsync(func() {
if ctx.Err() == nil {
fn()
}
})
}
func syncImageFn(
ctx context.Context,
img ImageContainer,
url string,
procs []imgutil.Processor,
middle func(l *gdk.PixbufLoader, gif bool),
) {
r, err := get(url, true)
r, err := get(ctx, url, true)
if err != nil {
log.Error(err)
return

View file

@ -12,10 +12,12 @@ const (
Year = 365 * Day
)
var truncators = []struct {
type truncator struct {
d time.Duration
s string
}{
}
var shortTruncators = []truncator{
{d: Day, s: "15:04"},
{d: Week, s: "Mon 15:04"},
{d: Year, s: "15:04 02/01"},
@ -28,7 +30,31 @@ func TimeAgo(t time.Time) string {
trunc := t
now := time.Now()
for _, truncator := range truncators {
for _, truncator := range shortTruncators {
trunc = trunc.Truncate(truncator.d)
now = now.Truncate(truncator.d)
if trunc.Equal(now) || truncator.d == -1 {
return monday.Format(t, truncator.s, Locale)
}
}
return ""
}
var longTruncators = []truncator{
{d: Day, s: "Today at 15:04"},
{d: Week, s: "Last Monday at 15:04"},
{d: -1, s: "15:04 02/01/2006"},
}
func TimeAgoLong(t time.Time) string {
ensureLocale()
trunc := t
now := time.Now()
for _, truncator := range longTruncators {
trunc = trunc.Truncate(truncator.d)
now = now.Truncate(truncator.d)

View file

@ -7,6 +7,7 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
"github.com/zalando/go-keyring"
)
@ -63,17 +64,17 @@ func GetSession(ses cchat.Session, name string) *Session {
}
}
func SaveSessions(serviceName string, sessions []Session) {
if err := set(serviceName, sessions); err != nil {
func SaveSessions(serviceName text.Rich, sessions []Session) {
if err := set(serviceName.Content, sessions); err != nil {
log.Warn(errors.Wrap(err, "Error saving session"))
}
}
// RestoreSessions restores all sessions of the service asynchronously, then
// calls the auth callback inside the Gtk main thread.
func RestoreSessions(serviceName string) (sessions []Session) {
func RestoreSessions(serviceName text.Rich) (sessions []Session) {
// Ignore the error, it's not important.
if err := get(serviceName, &sessions); err != nil {
if err := get(serviceName.Content, &sessions); err != nil {
log.Warn(err)
}
return

View file

@ -1,6 +1,8 @@
package log
import (
"context"
"errors"
"fmt"
"log"
"os"
@ -35,6 +37,16 @@ func AddEntryHandler(fn func(Entry)) {
}
func Error(err error) {
// Ignore nil errors.
if err == nil {
return
}
// Ignore context cancel errors.
if errors.Is(err, context.Canceled) {
return
}
Write("Error: " + err.Error())
}

View file

@ -4,16 +4,17 @@ import (
"github.com/diamondburned/cchat"
"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/primitives"
)
type Container struct {
*container.GridContainer
}
func NewContainer() *Container {
return &Container{
GridContainer: container.NewGridContainer(constructor{}),
}
func NewContainer(ctrl container.Controller) *Container {
c := container.NewGridContainer(constructor{}, ctrl)
primitives.AddClass(c, "compact-conatainer")
return &Container{c}
}
type constructor struct{}

View file

@ -5,6 +5,7 @@ import (
"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/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
@ -31,6 +32,11 @@ var _ container.GridMessage = (*Message)(nil)
func NewMessage(msg cchat.MessageCreate) Message {
msgc := message.NewContainer(msg)
message.FillContainer(msgc, msg)
primitives.AddClass(msgc.Timestamp, "compact-timestamp")
primitives.AddClass(msgc.Username, "compact-username")
primitives.AddClass(msgc.Content, "compact-content")
return Message{msgc}
}
@ -38,11 +44,6 @@ func NewEmptyMessage() Message {
return Message{message.NewEmptyContainer()}
}
// TODO: fix a bug here related to new messages overlapping
func (m Message) Attach(grid *gtk.Grid, row int) {
attachGenericContainer(m.GenericContainer, grid, row)
}
func attachGenericContainer(m *message.GenericContainer, grid *gtk.Grid, row int) {
container.AttachRow(grid, row, m.Timestamp, m.Username, m.Content)
}

View file

@ -3,17 +3,24 @@ package container
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/autoscroll"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
type GridMessage interface {
message.Container
// Attach should only be called once.
Attach(grid *gtk.Grid, row int)
// AttachMenu should override the stored constructor.
AttachMenu(constructor func() []gtk.IMenuItem) // save memory
}
func AttachRow(grid *gtk.Grid, row int, widgets ...gtk.IWidget) {
for i, w := range widgets {
grid.Attach(w, i, row, 1, 1)
}
}
type PresendGridMessage interface {
@ -21,6 +28,32 @@ type PresendGridMessage interface {
message.PresendContainer
}
// Container is a generic messages container for children messages for children
// packages.
type Container interface {
gtk.IWidget
// Thread-safe methods.
cchat.MessagesContainer
// Thread-unsafe methods.
CreateMessageUnsafe(cchat.MessageCreate)
UpdateMessageUnsafe(cchat.MessageUpdate)
DeleteMessageUnsafe(cchat.MessageDelete)
Reset()
ScrollToBottom()
// AddPresendMessage adds and displays an unsent message.
AddPresendMessage(msg input.PresendMessage) PresendGridMessage
}
// Controller is for menu actions.
type Controller interface {
// BindMenu expects the controller to add actioner into the message.
BindMenu(GridMessage)
}
// Constructor is an interface for making custom message implementations which
// allows GridContainer to generically work with.
type Constructor interface {
@ -28,37 +61,13 @@ type Constructor interface {
NewPresendMessage(input.PresendMessage) PresendGridMessage
}
// Container is a generic messages container.
type Container interface {
gtk.IWidget
cchat.MessagesContainer
Reset()
ScrollToBottom()
// PresendMessage is for unsent messages.
PresendMessage(input.PresendMessage) (done func(sendError error))
}
func AttachRow(grid *gtk.Grid, row int, widgets ...gtk.IWidget) {
for i, w := range widgets {
grid.Attach(w, i, row, 1, 1)
}
}
const ColumnSpacing = 10
// GridContainer is an implementation of Container, which allows flexible
// message grids.
type GridContainer struct {
*autoscroll.ScrolledWindow
Main *gtk.Grid
construct Constructor
messages []*gridMessage // sync w/ grid rows
messageIDs map[string]int
nonceMsgs map[string]int
*GridStore
}
// gridMessage w/ required internals
@ -67,175 +76,36 @@ type gridMessage struct {
presend message.PresendContainer // this shouldn't be here but i'm lazy
}
var (
_ Container = (*GridContainer)(nil)
_ cchat.MessagesContainer = (*GridContainer)(nil)
)
var _ Container = (*GridContainer)(nil)
func NewGridContainer(constr Constructor) *GridContainer {
grid, _ := gtk.GridNew()
grid.SetColumnSpacing(ColumnSpacing)
grid.SetRowSpacing(5)
grid.SetMarginStart(5)
grid.SetMarginEnd(5)
grid.SetMarginBottom(5)
grid.Show()
func NewGridContainer(constr Constructor, ctrl Controller) *GridContainer {
store := NewGridStore(constr, ctrl)
sw := autoscroll.NewScrolledWindow()
sw.Add(grid)
sw.Add(store.Grid)
sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
sw.Show()
container := GridContainer{
return &GridContainer{
ScrolledWindow: sw,
Main: grid,
construct: constr,
messageIDs: map[string]int{},
nonceMsgs: map[string]int{},
GridStore: store,
}
return &container
}
func (c *GridContainer) Reset() {
c.Main.GetChildren().Foreach(func(v interface{}) {
// Unsafe assertion ftw.
c.Main.Remove(v.(gtk.IWidget))
})
c.messages = nil
c.messageIDs = map[string]int{}
c.nonceMsgs = map[string]int{}
c.ScrolledWindow.Bottomed = true
}
// PresendMessage is not thread-safe.
func (c *GridContainer) PresendMessage(msg input.PresendMessage) func(error) {
presend := c.construct.NewPresendMessage(msg)
msgc := &gridMessage{
GridMessage: presend,
presend: presend,
}
// Grab index before appending, as that'll be where the added message is.
index := len(c.messages)
c.messages = append(c.messages, msgc)
c.nonceMsgs[presend.Nonce()] = index
msgc.Attach(c.Main, index)
return func(err error) {
if err != nil {
presend.SetSentError(err)
log.Error(errors.Wrap(err, "Failed to send message"))
}
}
}
// FindMessage iterates backwards and returns the message if isMessage() returns
// true on that message.
func (c *GridContainer) FindMessage(isMessage func(msg GridMessage) bool) GridMessage {
for i := len(c.messages) - 1; i >= 0; i-- {
if msg := c.messages[i].GridMessage; isMessage(msg) {
return msg
}
}
return nil
}
// Message finds the message state in the container. It is not thread-safe. This
// exists for backwards compatibility.
func (c *GridContainer) Message(msg cchat.MessageHeader) GridMessage {
if m := c.message(msg); m != nil {
return m.GridMessage
}
return nil
}
func (c *GridContainer) message(msg cchat.MessageHeader) *gridMessage {
// Search using the ID first.
i, ok := c.messageIDs[msg.ID()]
if ok {
return c.messages[i]
}
// Is this an existing message?
if noncer, ok := msg.(cchat.MessageNonce); ok {
var nonce = noncer.Nonce()
// Things in this map are guaranteed to have presend != nil.
i, ok := c.nonceMsgs[nonce]
if ok {
// Move the message outside nonceMsgs and into messageIDs.
delete(c.nonceMsgs, nonce)
c.messageIDs[msg.ID()] = i
// Get the message pointer.
m := c.messages[i]
// Set the right ID.
m.presend.SetID(msg.ID())
m.presend.SetDone()
// Destroy the presend struct.
m.presend = nil
return m
}
}
return nil
}
func (c *GridContainer) CreateMessage(msg cchat.MessageCreate) {
gts.ExecAsync(func() {
// Attempt to update before insertion (aka upsert).
if msgc := c.Message(msg); msgc != nil {
msgc.UpdateAuthor(msg.Author())
msgc.UpdateContent(msg.Content())
msgc.UpdateTimestamp(msg.Time())
return
}
msgc := &gridMessage{
GridMessage: c.construct.NewMessage(msg),
}
// Grab index before appending, as that'll be where the added message is.
index := len(c.messages)
c.messages = append(c.messages, msgc)
c.messageIDs[msgc.ID()] = index
msgc.Attach(c.Main, index)
})
gts.ExecAsync(func() { c.CreateMessageUnsafe(msg) })
}
func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) {
gts.ExecAsync(func() {
if msgc := c.Message(msg); msgc != nil {
if author := msg.Author(); author != nil {
msgc.UpdateAuthor(author)
}
if content := msg.Content(); !content.Empty() {
msgc.UpdateContent(content)
}
}
})
gts.ExecAsync(func() { c.UpdateMessageUnsafe(msg) })
}
func (c *GridContainer) DeleteMessage(msg cchat.MessageDelete) {
gts.ExecAsync(func() {
// TODO: add nonce check.
if i, ok := c.messageIDs[msg.ID()]; ok {
// Remove off the slice.
c.messages = append(c.messages[:i], c.messages[i+1:]...)
// Remove off the map.
delete(c.messageIDs, msg.ID())
c.Main.RemoveRow(i)
}
})
gts.ExecAsync(func() { c.DeleteMessageUnsafe(msg) })
}
// Reset is not thread-safe.
func (c *GridContainer) Reset() {
c.GridStore.Reset()
c.ScrolledWindow.Bottomed = true
}

View file

@ -2,15 +2,27 @@ package cozy
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/gts"
"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/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
type AvatarPixbufCopier interface {
CopyAvatarPixbuf(httputil.ImageContainer)
// Unwrapper provides an interface for messages to be unwrapped. This is used to
// convert between collapsed and full messages.
type Unwrapper interface {
Unwrap(grid *gtk.Grid) *message.GenericContainer
}
var (
_ Unwrapper = (*CollapsedMessage)(nil)
_ Unwrapper = (*CollapsedSendingMessage)(nil)
_ Unwrapper = (*FullMessage)(nil)
_ Unwrapper = (*FullSendingMessage)(nil)
)
const (
AvatarSize = 40
AvatarMargin = 10
@ -20,48 +32,127 @@ type Container struct {
*container.GridContainer
}
func NewContainer() *Container {
func NewContainer(ctrl container.Controller) *Container {
c := &Container{}
c.GridContainer = container.NewGridContainer(c)
c.GridContainer = container.NewGridContainer(c, ctrl)
// A not-so-generous row padding, as we will rely on margins per widget.
c.GridContainer.Grid.SetRowSpacing(2)
primitives.AddClass(c, "cozy-container")
return c
}
func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage {
var newmsg = NewFullMessage(msg)
// Try and reuse an existing avatar.
if author := msg.Author(); !c.reuseAvatar(author.ID(), newmsg.Avatar) {
// Fetch a new avatar if we can't reuse the old one.
newmsg.updateAuthorAvatar(author)
// 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)
}
return newmsg
full := NewFullMessage(msg)
author := msg.Author()
// Try and reuse an existing avatar if the author has one.
if avatarURL, ok := author.(cchat.MessageAuthorAvatar); ok {
// 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(), avatarURL.Avatar(), full)
}
return full
}
func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage {
var presend = NewFullSendingMessage(msg)
// Try and see if we can reuse the avatar, and fallback if possible.
if !c.reuseAvatar(msg.AuthorID(), presend.Avatar) {
presend.overrideAuthorAvatar(msg.AuthorAvatarURL())
if c.lastMessageIsAuthor(msg.AuthorID()) {
return NewCollapsedSendingMessage(msg)
}
return presend
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) reuseAvatar(authorID string, img httputil.ImageContainer) (reused bool) {
func (c *Container) lastMessageIsAuthor(id string) bool {
var last = c.GridStore.LastMessage()
return last != nil && last.AuthorID() == id
}
func (c *Container) findAuthorID(authorID string) container.GridMessage {
// Search the old author if we have any.
msgc := c.FindMessage(func(msgc container.GridMessage) bool {
return c.GridStore.FindMessage(func(msgc container.GridMessage) bool {
return msgc.AuthorID() == authorID
})
}
// reuseAvatar tries to search past messages with the same author ID and URL for
// the image. It will fetch anew if there's none.
func (c *Container) reuseAvatar(authorID, avatarURL string, full *FullMessage) {
// Is this a message that we can work with? We have to assert to
// FullSendingMessage because that's where our messages are.
copier, ok := msgc.(AvatarPixbufCopier)
if ok {
// Borrow the avatar URL.
copier.CopyAvatarPixbuf(img)
}
var lastAuthorMsg = c.findAuthorID(authorID)
return ok
// Borrow the avatar pixbuf, but only if the avatar URL is the same.
p, ok := lastAuthorMsg.(AvatarPixbufCopier)
if ok && lastAuthorMsg.AvatarURL() == avatarURL {
p.CopyAvatarPixbuf(full.Avatar)
full.Avatar.ManuallySetURL(avatarURL)
} else {
// We can't borrow, so we need to fetch it anew.
full.Avatar.SetURL(avatarURL)
}
}
func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
gts.ExecAsync(func() {
// Get the previous and next message before deleting. We'll need them to
// evaluate whether we need to change anything.
prev := c.GridStore.Before(msg.ID())
next := c.GridStore.After(msg.ID())
// The function doesn't actually try and re-collapse the bottom message
// when a sandwiched message is deleted. This is fine.
// Delete the message off of the parent's container.
msg := c.GridStore.PopMessage(msg.ID())
// Don't calculate if we don't have any messages, or no messages before
// and after.
if c.GridStore.MessagesLen() == 0 || prev == nil || next == nil {
return
}
// Check if the last message is the author's (relative to i):
if prev.AuthorID() == msg.AuthorID() {
// If the author is the same, then we don't need to uncollapse the
// message.
return
}
// If the next message (relative to i) is not the deleted message's
// author, then we don't need to uncollapse it.
if next.AuthorID() != msg.AuthorID() {
return
}
// Get the unwrapper method, which allows us to get the
// *message.GenericContainer.
uw, ok := next.(Unwrapper)
if !ok {
return
}
// Start the "lengthy" uncollapse process.
full := WrapFullMessage(uw.Unwrap(c.Grid))
// Update the container to reformat everything including the timestamps.
message.RefreshContainer(full, full.GenericContainer)
// Update the avatar if needed be, since we're now showing it.
c.reuseAvatar(next.AuthorID(), next.AvatarURL(), full)
// Swap the old next message out for a new one.
c.GridStore.SwapMessage(full)
})
}

View file

@ -1,11 +1,64 @@
package cozy
// CompactMessage is a message that follows after FullMessage. It does not show
// the header, and the avatar is invisible.
type CompactMessage struct {
// Essentially, CompactMessage is just a full message with some things
// hidden. Its Avatar and Timestamp will still be updated. This is a
// trade-off between performance, efficiency and code length.
import (
"github.com/diamondburned/cchat"
"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/message"
"github.com/gotk3/gotk3/gtk"
)
*FullMessage
// Collapsed is a message that follows after FullMessage. It does not show
// the header, and the avatar is invisible.
type CollapsedMessage struct {
// Author is still updated normally.
*message.GenericContainer
}
func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage {
msgc := WrapCollapsedMessage(message.NewContainer(msg))
msgc.Timestamp.SetXAlign(0.5) // middle align
message.FillContainer(msgc, msg)
return msgc
}
func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage {
// Set Timestamp's padding accordingly to Avatar's.
gc.Timestamp.SetSizeRequest(AvatarSize, -1)
gc.Timestamp.SetVAlign(gtk.ALIGN_START)
gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2)
// Set Content's padding accordingly to FullMessage's main box.
gc.Content.SetMarginEnd(container.ColumnSpacing * 2)
return &CollapsedMessage{
GenericContainer: gc,
}
}
func (c *CollapsedMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer {
// Remove GenericContainer's widgets from the containers.
grid.Remove(c.Timestamp)
grid.Remove(c.Content)
// Return after removing.
return c.GenericContainer
}
func (c *CollapsedMessage) Attach(grid *gtk.Grid, row int) {
container.AttachRow(grid, row, c.Timestamp, c.Content)
}
type CollapsedSendingMessage struct {
message.PresendContainer
CollapsedMessage
}
func NewCollapsedSendingMessage(msg input.PresendMessage) *CollapsedSendingMessage {
var msgc = message.NewPresendContainer(msg)
return &CollapsedSendingMessage{
PresendContainer: msgc,
CollapsedMessage: *WrapCollapsedMessage(msgc.GenericContainer),
}
}

View file

@ -1,26 +1,38 @@
package cozy
import (
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/humanize"
"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/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
)
// TopFullMargin is the margin on top of every full message.
const TopFullMargin = 12
type FullMessage struct {
*message.GenericContainer
// Grid widgets.
Avatar *gtk.Image
Avatar *Avatar
MainBox *gtk.Box // wraps header and content
// Header wraps author and timestamp.
HeaderBox *gtk.Box
}
type AvatarPixbufCopier interface {
CopyAvatarPixbuf(img httputil.ImageContainer)
}
var (
_ AvatarPixbufCopier = (*FullMessage)(nil)
_ message.Container = (*FullMessage)(nil)
@ -29,19 +41,27 @@ var (
func NewFullMessage(msg cchat.MessageCreate) *FullMessage {
msgc := WrapFullMessage(message.NewContainer(msg))
// Don't update the avatar.
msgc.UpdateContent(msg.Content())
// Don't update the avatar. NewMessage in controller will try and reuse the
// pixbuf if possible.
msgc.UpdateAuthorName(msg.Author().Name())
msgc.UpdateTimestamp(msg.Time())
msgc.UpdateContent(msg.Content(), false)
return msgc
}
func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
avatar, _ := gtk.ImageNew()
avatar.SetSizeRequest(AvatarSize, AvatarSize)
avatar.SetVAlign(gtk.ALIGN_START)
avatar := NewAvatar()
avatar.SetMarginTop(TopFullMargin)
avatar.SetMarginStart(container.ColumnSpacing * 2)
avatar.Show()
// We don't call avatar.Show(). That's called in Attach.
// 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
// Attach the class for the left avatar.
primitives.AddClass(avatar, "cozy-avatar")
header, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
header.PackStart(gc.Username, false, false, 0)
@ -51,10 +71,13 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
main.PackStart(header, false, false, 0)
main.PackStart(gc.Content, false, false, 2)
main.SetMarginBottom(2)
main.SetMarginTop(TopFullMargin)
main.SetMarginEnd(container.ColumnSpacing * 2)
main.Show()
// Also attach a class for the main box shown on the right.
primitives.AddClass(main, "cozy-main")
return &FullMessage{
GenericContainer: gc,
Avatar: avatar,
@ -63,25 +86,33 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
}
}
func (m *FullMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer {
// Remove GenericContainer's widgets from the containers.
m.HeaderBox.Remove(m.Username)
m.HeaderBox.Remove(m.Timestamp)
m.MainBox.Remove(m.Content)
// Return after removing.
return m.GenericContainer
}
func (m *FullMessage) UpdateTimestamp(t time.Time) {
m.GenericContainer.UpdateTimestamp(t)
m.Timestamp.SetMarkup(rich.Small(humanize.TimeAgoLong(t)))
}
func (m *FullMessage) UpdateAuthor(author cchat.MessageAuthor) {
// Call the parent's method to update the labels.
m.GenericContainer.UpdateAuthor(author)
m.updateAuthorAvatar(author)
}
func (m *FullMessage) updateAuthorAvatar(author cchat.MessageAuthor) {
// If the author has an avatar:
if avatarer, ok := author.(cchat.MessageAuthorAvatar); ok {
// Download the avatar asynchronously.
httputil.AsyncImageSized(
m.Avatar,
avatarer.Avatar(),
AvatarSize, AvatarSize,
imgutil.Round(true),
)
m.Avatar.SetURL(avatarer.Avatar())
}
}
// CopyAvatarPixbuf sets the pixbuf into the given container. This shares the
// same pixbuf, but gtk.Image should take its own reference from the pixbuf.
func (m *FullMessage) CopyAvatarPixbuf(dst httputil.ImageContainer) {
switch m.Avatar.GetStorageType() {
case gtk.IMAGE_PIXBUF:
@ -92,16 +123,25 @@ func (m *FullMessage) CopyAvatarPixbuf(dst httputil.ImageContainer) {
}
func (m *FullMessage) Attach(grid *gtk.Grid, row int) {
m.Avatar.Show()
container.AttachRow(grid, row, m.Avatar, m.MainBox)
}
func (m *FullMessage) AttachMenu(items func() []gtk.IMenuItem) {
// Bind to parent's container as well.
m.GenericContainer.AttachMenu(items)
// Bind to the box.
// TODO lol
}
type FullSendingMessage struct {
message.PresendContainer
FullMessage
}
var (
_ AvatarPixbufCopier = (*FullSendingMessage)(nil)
// _ AvatarPixbufCopier = (*FullSendingMessage)(nil)
_ message.Container = (*FullSendingMessage)(nil)
_ container.GridMessage = (*FullSendingMessage)(nil)
)
@ -115,17 +155,38 @@ func NewFullSendingMessage(msg input.PresendMessage) *FullSendingMessage {
}
}
// make an exception for sending messages.
func (m *FullSendingMessage) overrideAuthorAvatar(url string) {
if url == "" {
type Avatar struct {
gtk.Image
url string
}
func NewAvatar() *Avatar {
avatar, _ := gtk.ImageNew()
avatar.SetSizeRequest(AvatarSize, AvatarSize)
avatar.SetVAlign(gtk.ALIGN_START)
// Default icon.
primitives.SetImageIcon(avatar, "user-available-symbolic", AvatarSize)
return &Avatar{*avatar, ""}
}
// SetURL updates the Avatar to be that URL. It does nothing if URL is empty or
// matches the existing one.
func (a *Avatar) SetURL(url string) {
// Check if the URL is the same. This will save us quite a few requests, as
// some methods rely on the side-effects of other methods, and they may call
// UpdateAuthor multiple times.
if a.url == url || url == "" {
return
}
// TODO: put in fn
httputil.AsyncImageSized(
m.Avatar,
url,
AvatarSize, AvatarSize,
imgutil.Round(true),
)
a.url = url
httputil.AsyncImageSized(a, url, AvatarSize, AvatarSize, imgutil.Round(true))
}
// ManuallySetURL sets the URL without downloading the image. It assumes the
// pixbuf is borrowed elsewhere.
func (a *Avatar) ManuallySetURL(url string) {
a.url = url
}

View file

@ -0,0 +1,262 @@
package container
import (
"fmt"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
type GridStore struct {
Grid *gtk.Grid
Construct Constructor
Controller Controller
messages map[string]*gridMessage
messageIDs []string // ids or nonces
}
func NewGridStore(constr Constructor, ctrl Controller) *GridStore {
grid, _ := gtk.GridNew()
grid.SetColumnSpacing(ColumnSpacing)
grid.SetRowSpacing(5)
grid.SetMarginStart(5)
grid.SetMarginEnd(5)
grid.SetMarginBottom(5)
grid.Show()
primitives.AddClass(grid, "message-grid")
return &GridStore{
Grid: grid,
Construct: constr,
Controller: ctrl,
messages: map[string]*gridMessage{},
}
}
func (c *GridStore) Reset() {
c.Grid.GetChildren().Foreach(func(v interface{}) {
// Unsafe assertion ftw.
w := v.(gtk.IWidget).ToWidget()
c.Grid.Remove(w)
w.Destroy()
})
c.messages = map[string]*gridMessage{}
c.messageIDs = []string{}
}
func (c *GridStore) MessagesLen() int {
return len(c.messages)
}
// findIndex searches backwards for idnonce.
func (c *GridStore) findIndex(idnonce string) int {
for i := len(c.messageIDs) - 1; i >= 0; i-- {
if c.messageIDs[i] == idnonce {
return i
}
}
return -1
}
// Swap changes the message with the ID to the given message. This provides a
// low level API for edits that need a new Attach method.
//
// TODO: combine compact and full so they share the same attach method.
func (c *GridStore) SwapMessage(msg GridMessage) bool {
// Get the current message's index.
var ix = c.findIndex(msg.ID())
if ix == -1 {
return false
}
// Add a row at index. The actual row we want to delete will be shifted
// downwards.
c.Grid.InsertRow(ix)
// Let the new message be attached on top of the to-be-replaced message.
msg.Attach(c.Grid, ix)
// Delete the to-be-replaced message, which we have shifted downwards
// earlier, so we add 1.
c.Grid.RemoveRow(ix + 1)
return true
}
// Before returns the message before the given ID, or nil if none.
func (c *GridStore) Before(id string) GridMessage {
return c.getOffsetted(id, -1)
}
// After returns the message after the given ID, or nil if none.
func (c *GridStore) After(id string) GridMessage {
return c.getOffsetted(id, 1)
}
func (c *GridStore) getOffsetted(id string, offset int) GridMessage {
// Get the current index.
var ix = c.findIndex(id)
if ix == -1 {
return nil
}
ix += offset
if ix < 0 || ix >= len(c.messages) {
return nil
}
return c.messages[c.messageIDs[ix]].GridMessage
}
// FindMessage iterates backwards and returns the message if isMessage() returns
// true on that message.
func (c *GridStore) FindMessage(isMessage func(msg GridMessage) bool) GridMessage {
for i := len(c.messageIDs) - 1; i >= 0; i-- {
if msg := c.messages[c.messageIDs[i]].GridMessage; isMessage(msg) {
return msg
}
}
return nil
}
// LastMessage returns the latest message.
func (c *GridStore) LastMessage() GridMessage {
if l := len(c.messageIDs); l > 0 {
return c.messages[c.messageIDs[l-1]]
}
return nil
}
// Message finds the message state in the container. It is not thread-safe. This
// exists for backwards compatibility.
func (c *GridStore) Message(msg cchat.MessageHeader) GridMessage {
if m := c.message(msg); m != nil {
return m.GridMessage
}
return nil
}
func (c *GridStore) message(msg cchat.MessageHeader) *gridMessage {
// Search using the ID first.
m, ok := c.messages[msg.ID()]
if ok {
return m
}
// Is this an existing message?
if noncer, ok := msg.(cchat.MessageNonce); ok {
var nonce = noncer.Nonce()
// Things in this map are guaranteed to have presend != nil.
m, ok := c.messages[nonce]
if ok {
// Replace the nonce key with ID.
delete(c.messages, nonce)
c.messages[msg.ID()] = m
// Set the right ID.
m.presend.SetDone(msg.ID())
// Destroy the presend struct.
m.presend = nil
// Replace the nonce inside the ID slice with the actual ID.
if ix := c.findIndex(nonce); ix > -1 {
c.messageIDs[ix] = msg.ID()
} else {
log.Error(fmt.Errorf("Missed ID %s in slice index %d", msg.ID(), ix))
}
return m
}
}
return nil
}
// AddPresendMessage inserts an input.PresendMessage into the container and
// returning a wrapped widget interface.
func (c *GridStore) AddPresendMessage(msg input.PresendMessage) PresendGridMessage {
presend := c.Construct.NewPresendMessage(msg)
msgc := &gridMessage{
GridMessage: presend,
presend: presend,
}
// Set the message into the grid.
msgc.Attach(c.Grid, c.MessagesLen())
// Append the NONCE.
c.messageIDs = append(c.messageIDs, msgc.Nonce())
// Set the NONCE into the message map.
c.messages[msgc.Nonce()] = msgc
return presend
}
func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) {
// Attempt to update before insertion (aka upsert).
if msgc := c.Message(msg); msgc != nil {
msgc.UpdateAuthor(msg.Author())
msgc.UpdateContent(msg.Content(), false)
msgc.UpdateTimestamp(msg.Time())
c.Controller.BindMenu(msgc)
return
}
msgc := &gridMessage{
GridMessage: c.Construct.NewMessage(msg),
}
// Copy from PresendMessage.
msgc.Attach(c.Grid, c.MessagesLen())
c.messageIDs = append(c.messageIDs, msgc.ID())
c.messages[msgc.ID()] = msgc
c.Controller.BindMenu(msgc)
}
func (c *GridStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) {
if msgc := c.Message(msg); msgc != nil {
if author := msg.Author(); author != nil {
msgc.UpdateAuthor(author)
}
if content := msg.Content(); !content.Empty() {
msgc.UpdateContent(content, true)
}
}
return
}
func (c *GridStore) DeleteMessageUnsafe(msg cchat.MessageDelete) {
c.PopMessage(msg.ID())
}
// PopMessage deletes a message off of the list and return the deleted message.
func (c *GridStore) PopMessage(id string) (msg GridMessage) {
// Search for the index.
var ix = c.findIndex(id)
if ix < 0 {
return nil
}
// Grab the message before deleting.
msg = c.messages[id]
// Remove off of the Gtk grid.
c.Grid.RemoveRow(ix)
// Pop off the slice.
c.messageIDs = append(c.messageIDs[:ix], c.messageIDs[ix+1:]...)
// Delete off the map.
delete(c.messages, id)
return
}

View file

@ -0,0 +1,287 @@
package completion
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/utils/split"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
const (
ImageSize = 20
ImagePadding = 10
)
// var completionQueue chan func()
// func init() {
// completionQueue = make(chan func(), 1)
// go func() {
// for fn := range completionQueue {
// fn()
// }
// }()
// }
type View struct {
*gtk.Revealer
Scroll *gtk.ScrolledWindow
List *gtk.ListBox
entries []cchat.CompletionEntry
text *gtk.TextView
buffer *gtk.TextBuffer
// state
completer cchat.ServerMessageSendCompleter
offset int
}
func New(text *gtk.TextView) *View {
list, _ := gtk.ListBoxNew()
list.SetSelectionMode(gtk.SELECTION_BROWSE)
list.Show()
primitives.AddClass(list, "completer")
scroll, _ := gtk.ScrolledWindowNew(nil, nil)
scroll.Add(list)
scroll.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
scroll.SetProperty("propagate-natural-height", true)
scroll.SetProperty("max-content-height", 250)
scroll.Show()
// Bind scroll adjustments.
list.SetFocusHAdjustment(scroll.GetHAdjustment())
list.SetFocusVAdjustment(scroll.GetVAdjustment())
rev, _ := gtk.RevealerNew()
rev.SetRevealChild(false)
rev.SetTransitionDuration(50)
rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_UP)
rev.Add(scroll)
rev.Show()
buffer, _ := text.GetBuffer()
v := &View{
Revealer: rev,
Scroll: scroll,
List: list,
text: text,
buffer: buffer,
}
text.Connect("key-press-event", v.inputKeyDown)
buffer.Connect("changed", func() {
// Clear the list first.
v.Clear()
// Re-run the list.
v.Run()
})
list.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
// Get iter for word replacing.
start, end := getWordIters(v.buffer, v.offset)
// Get the selected word.
i := r.GetIndex()
entry := v.entries[i]
// Replace the word.
v.buffer.Delete(start, end)
v.buffer.Insert(start, entry.Raw+" ")
// Clear the list.
v.Clear()
// Reset the focus.
v.text.GrabFocus()
})
return v
}
// SetMarginStart sets the left margin but account for images as well.
func (v *View) SetMarginStart(pad int) {
pad = pad - (ImagePadding*2 + ImageSize - 2) // subtracting 2 for no reason
if pad < 0 {
pad = 0
}
v.Revealer.SetMarginStart(pad)
}
func (v *View) Reset() {
v.SetCompleter(nil)
}
func (v *View) SetCompleter(completer cchat.ServerMessageSendCompleter) {
v.Clear()
v.completer = completer
}
func (v *View) Clear() {
// Do we have anything in the slice? If not, then we don't need to run
// again. We do need to keep RevealChild consistent with this, however.
if v.entries == nil {
return
}
// Since we don't store the widgets inside the list, we'll manually iterate
// and remove.
v.List.GetChildren().Foreach(func(i interface{}) {
w := i.(gtk.IWidget).ToWidget()
v.List.Remove(w)
w.Destroy()
})
// Set entries to nil to free up the slice.
v.entries = nil
// Set offset to 0 to reset.
v.offset = 0
// Hide the list.
v.SetRevealChild(false)
}
func (v *View) Run() {
// If we don't have a completer, then don't run.
if v.completer == nil {
return
}
text, offset := v.getInputState()
words, index := split.SpaceIndexed(text, offset)
// If the input is empty.
if len(words) == 0 {
return
}
v.offset = offset
v.entries = v.completer.CompleteMessage(words, index)
if len(v.entries) == 0 {
return
}
// Reveal if needed be.
v.SetRevealChild(true)
// TODO: make entries reuse pixbuf.
for i, entry := range v.entries {
l := rich.NewLabel(entry.Text)
l.Show()
img, _ := gtk.ImageNew()
img.SetSizeRequest(ImageSize, ImageSize)
img.Show()
// Do we have an icon?
if entry.IconURL != "" {
httputil.AsyncImageSized(img, entry.IconURL, ImageSize, ImageSize, imgutil.Round(true))
}
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
b.PackStart(img, false, false, ImagePadding)
b.PackStart(l, true, true, 0)
b.Show()
r, _ := gtk.ListBoxRowNew()
r.Add(b)
r.Show()
v.List.Add(r)
// Select the first item.
if i == 0 {
v.List.SelectRow(r)
}
}
}
func (v *View) getInputState() (string, int) {
// obtain current state
mark := v.buffer.GetInsert()
iter := v.buffer.GetIterAtMark(mark)
// obtain the input string and the current cursor position
start, end := v.buffer.GetBounds()
text, _ := v.buffer.GetText(start, end, true)
offset := iter.GetOffset()
return text, offset
}
// inputKeyDown handles keypresses such as Enter and movements.
func (v *View) inputKeyDown(_ *gtk.TextView, ev *gdk.Event) (stop bool) {
// Do we have any entries? If not, don't bother.
if len(v.entries) == 0 {
// passthrough.
return false
}
var evKey = gdk.EventKeyNewFromEvent(ev)
var key = evKey.KeyVal()
switch key {
// Did we press an arrow key?
case gdk.KEY_Up, gdk.KEY_Down:
// Yes, start moving the list up and down.
i := v.List.GetSelectedRow().GetIndex()
switch key {
case gdk.KEY_Up:
if i--; i < 0 {
i = len(v.entries) - 1
}
case gdk.KEY_Down:
if i++; i >= len(v.entries) {
i = 0
}
}
row := v.List.GetRowAtIndex(i)
row.GrabFocus() // scroll
v.List.SelectRow(row) // select
v.text.GrabFocus() // unfocus
// Did we press the Enter or Tab key?
case gdk.KEY_Return, gdk.KEY_Tab:
// Activate the current row.
row := v.List.GetSelectedRow()
row.Activate()
default:
// don't passthrough events if none matches.
return false
}
return true
}
func getWordIters(buf *gtk.TextBuffer, offset int) (start, end *gtk.TextIter) {
iter := buf.GetIterAtOffset(offset)
var ok bool
// Seek backwards for space or start-of-line:
_, start, ok = iter.BackwardSearch(" ", gtk.TEXT_SEARCH_TEXT_ONLY, nil)
if !ok {
start = buf.GetStartIter()
}
// Seek forwards for space or end-of-line:
_, end, ok = iter.ForwardSearch(" ", gtk.TEXT_SEARCH_TEXT_ONLY, nil)
if !ok {
end = buf.GetEndIter()
}
return
}

View file

@ -1,16 +1,63 @@
package input
import (
"strconv"
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
// Controller is an interface to control message containers.
type Controller interface {
AddPresendMessage(msg PresendMessage) (onErr func(error))
}
type InputView struct {
*gtk.Box
*Field
Completer *completion.View
}
func NewView(ctrl Controller) *InputView {
text, _ := gtk.TextViewNew()
text.SetSensitive(false)
text.SetWrapMode(gtk.WRAP_WORD_CHAR)
text.SetProperty("top-margin", inputmargin)
text.SetProperty("left-margin", inputmargin)
text.SetProperty("right-margin", inputmargin)
text.SetProperty("bottom-margin", inputmargin)
text.Show()
// Bind the text event handler to text first.
c := completion.New(text)
c.Show()
// Bind the input callback later.
f := NewField(text, ctrl)
f.Show()
b, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
b.PackStart(c, false, true, 0)
b.PackStart(f, false, false, 0)
b.Show()
// Connect to the field's revealer. On resize, we want the autocompleter to
// have the right padding too.
f.username.Connect("size-allocate", func(w gtk.IWidget) {
// Set the autocompleter's left margin to be the same.
c.SetMarginStart(w.ToWidget().GetAllocatedWidth())
})
return &InputView{b, f, c}
}
func (v *InputView) SetSender(session cchat.Session, sender cchat.ServerMessageSender) {
v.Field.SetSender(session, sender)
// Ignore ok; completer can be nil.
completer, _ := sender.(cchat.ServerMessageSendCompleter)
v.Completer.SetCompleter(completer)
}
type Field struct {
*gtk.Box
username *usernameContainer
@ -20,30 +67,17 @@ type Field struct {
buffer *gtk.TextBuffer
UserID string
Sender cchat.ServerMessageSender
sender cchat.ServerMessageSender
ctrl Controller
}
type Controller interface {
PresendMessage(msg PresendMessage) (onErr func(error))
ctrl Controller
}
const inputmargin = 4
func NewField(ctrl Controller) *Field {
func NewField(text *gtk.TextView, ctrl Controller) *Field {
username := newUsernameContainer()
username.Show()
text, _ := gtk.TextViewNew()
text.SetSensitive(false)
text.SetWrapMode(gtk.WRAP_WORD_CHAR)
text.SetProperty("top-margin", inputmargin)
text.SetProperty("left-margin", inputmargin)
text.SetProperty("right-margin", inputmargin)
text.SetProperty("bottom-margin", inputmargin)
text.Show()
buf, _ := text.GetBuffer()
sw, _ := gtk.ScrolledWindowNew(nil, nil)
@ -80,7 +114,7 @@ func (f *Field) Reset() {
f.text.SetSensitive(false)
f.UserID = ""
f.sender = nil
f.Sender = nil
f.username.Reset()
// reset the input
@ -95,48 +129,11 @@ func (f *Field) SetSender(session cchat.Session, sender cchat.ServerMessageSende
// Set the sender.
if sender != nil {
f.sender = sender
f.Sender = sender
f.text.SetSensitive(true)
}
}
// SendMessage yanks the text from the input field and sends it to the backend.
// This function is not thread-safe.
func (f *Field) SendMessage() {
if f.sender == nil {
return
}
var text = f.yankText()
if text == "" {
return
}
var sender = f.sender
var data = SendMessageData{
content: text,
author: f.username.GetLabel(),
authorID: f.UserID,
authorURL: f.username.GetIconURL(),
nonce: "cchat-gtk_" + strconv.FormatInt(time.Now().UnixNano(), 10),
}
// presend message into the container through the controller
var done = f.ctrl.PresendMessage(data)
go func() {
err := sender.SendMessage(data)
gts.ExecAsync(func() {
done(err)
})
if err != nil {
log.Error(errors.Wrap(err, "Failed to send message"))
}
}()
}
// yankText cuts the text from the input field and returns it.
func (f *Field) yankText() string {
start, end := f.buffer.GetBounds()

View file

@ -31,7 +31,7 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
}
// Else, send the message.
f.SendMessage()
f.SendInput()
return true
}

View file

@ -1,11 +1,55 @@
package input
import (
"strconv"
"sync/atomic"
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
var globalID uint64
// SendInput yanks the text from the input field and sends it to the backend.
// This function is not thread-safe.
func (f *Field) SendInput() {
if f.Sender == nil {
return
}
var text = f.yankText()
if text == "" {
return
}
f.SendMessage(SendMessageData{
time: time.Now(),
content: text,
author: f.username.GetLabel(),
authorID: f.UserID,
authorURL: f.username.GetIconURL(),
nonce: "__cchat-gtk_" + strconv.FormatUint(atomic.AddUint64(&globalID, 1), 10),
})
}
func (f *Field) SendMessage(data PresendMessage) {
// presend message into the container through the controller
var onErr = f.ctrl.AddPresendMessage(data)
go func(sender cchat.ServerMessageSender) {
if err := sender.SendMessage(data); err != nil {
gts.ExecAsync(func() { onErr(err) })
log.Error(errors.Wrap(err, "Failed to send message"))
}
}(f.Sender)
}
type SendMessageData struct {
time time.Time
content string
author text.Rich
authorID string
@ -14,6 +58,7 @@ type SendMessageData struct {
}
type PresendMessage interface {
cchat.MessageHeader // returns nonce and time
cchat.SendableMessage
cchat.MessageNonce
@ -24,6 +69,15 @@ type PresendMessage interface {
var _ PresendMessage = (*SendMessageData)(nil)
// ID returns a pseudo ID for internal use.
func (s SendMessageData) ID() string {
return s.nonce
}
func (s SendMessageData) Time() time.Time {
return s.time
}
func (s SendMessageData) Content() string {
return s.content
}

View file

@ -3,12 +3,10 @@ package input
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
const AvatarSize = 20
@ -68,27 +66,19 @@ func (u *usernameContainer) Reset() {
// Update is not thread-safe.
func (u *usernameContainer) Update(session cchat.Session, sender cchat.ServerMessageSender) {
// Does sender (aka Server) implement ServerNickname? If not, we fallback to
// the username inside session.
var err error
if nicknamer, ok := sender.(cchat.ServerNickname); ok {
err = errors.Wrap(nicknamer.Nickname(u), "Failed to get nickname")
} else {
err = errors.Wrap(session.Name(u), "Failed to get username")
}
// Set the fallback username.
u.label.SetLabelUnsafe(session.Name())
// Reveal the name if it's not empty.
u.SetRevealChild(!u.label.GetLabel().Empty())
// Do a bit of trivial error handling.
if err != nil {
log.Warn(err)
// Does sender (aka Server) implement ServerNickname? If yes, use it.
if nicknamer, ok := sender.(cchat.ServerNickname); ok {
u.label.AsyncSetLabel(nicknamer.Nickname, "Error fetching server nickname")
}
// Does session implement an icon? Update if so.
if iconer, ok := session.(cchat.Icon); ok {
err = iconer.Icon(u)
}
if err != nil {
log.Warn(errors.Wrap(err, "Failed to get icon"))
u.avatar.AsyncSetIcon(iconer.Icon, "Error fetching session icon URL")
}
}

View file

@ -5,6 +5,8 @@ import (
"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/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
@ -14,30 +16,45 @@ import (
type Container interface {
ID() string
AuthorID() string
AvatarURL() string // avatar
Nonce() string
UpdateAuthor(cchat.MessageAuthor)
UpdateAuthorName(text.Rich)
UpdateContent(text.Rich)
UpdateContent(c text.Rich, edited bool)
UpdateTimestamp(time.Time)
}
// FillContainer sets the container's contents to the one from MessageCreate.
func FillContainer(c Container, msg cchat.MessageCreate) {
c.UpdateAuthor(msg.Author())
c.UpdateContent(msg.Content())
c.UpdateContent(msg.Content(), false)
c.UpdateTimestamp(msg.Time())
}
// RefreshContainer sets the container's contents to the one from
// GenericContainer. This is mainly used for transferring between different
// containers.
//
// Right now, this only works with Timestamp, as that's the only state tracked.
func RefreshContainer(c Container, gc *GenericContainer) {
c.UpdateTimestamp(gc.time)
}
// GenericContainer provides a single generic message container for subpackages
// to use.
type GenericContainer struct {
id string
authorID string
nonce string
id string
time time.Time
authorID string
avatarURL string // avatar
nonce string
Timestamp *gtk.Label
Username *gtk.Label
Content *gtk.Label
MenuItems func() []gtk.IMenuItem
}
var _ Container = (*GenericContainer)(nil)
@ -47,6 +64,7 @@ var _ Container = (*GenericContainer)(nil)
func NewContainer(msg cchat.MessageCreate) *GenericContainer {
c := NewEmptyContainer()
c.id = msg.ID()
c.time = msg.Time()
c.authorID = msg.Author().ID()
if noncer, ok := msg.(cchat.MessageNonce); ok {
@ -58,10 +76,9 @@ func NewContainer(msg cchat.MessageCreate) *GenericContainer {
func NewEmptyContainer() *GenericContainer {
ts, _ := gtk.LabelNew("")
ts.SetLineWrap(true)
ts.SetLineWrapMode(pango.WRAP_WORD)
ts.SetHAlign(gtk.ALIGN_END)
ts.SetVAlign(gtk.ALIGN_START)
ts.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
ts.SetXAlign(1) // right align
ts.SetVAlign(gtk.ALIGN_END)
ts.SetSelectable(true)
ts.Show()
@ -83,38 +100,84 @@ func NewEmptyContainer() *GenericContainer {
content.SetSelectable(true)
content.Show()
return &GenericContainer{
// Add CSS classes.
primitives.AddClass(ts, "message-time")
primitives.AddClass(user, "message-author")
primitives.AddClass(content, "message-content")
gc := &GenericContainer{
Timestamp: ts,
Username: user,
Content: content,
MenuItems: func() []gtk.IMenuItem { return nil },
}
gc.Content.Connect("populate-popup", func(l *gtk.Label, menu *gtk.Menu) {
// Add a menu separator before we add our custom stuff.
sep, _ := gtk.SeparatorMenuItemNew()
sep.Show()
menu.Append(sep)
// Append the new items after the separator.
for _, item := range gc.MenuItems() {
menu.Append(item)
}
})
return gc
}
func (m *GenericContainer) ID() string {
return m.id
}
func (m *GenericContainer) Time() time.Time {
return m.time
}
func (m *GenericContainer) AuthorID() string {
return m.authorID
}
func (m *GenericContainer) AvatarURL() string {
return m.avatarURL
}
func (m *GenericContainer) Nonce() string {
return m.nonce
}
func (m *GenericContainer) UpdateTimestamp(t time.Time) {
m.Timestamp.SetLabel(humanize.TimeAgo(t))
m.time = t
m.Timestamp.SetMarkup(rich.Small(humanize.TimeAgo(t)))
m.Timestamp.SetTooltipText(t.Format(time.Stamp))
}
func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) {
m.authorID = author.ID()
m.UpdateAuthorName(author.Name())
// Set the avatar URL for future access on-demand.
if avatarer, ok := author.(cchat.MessageAuthorAvatar); ok {
m.avatarURL = avatarer.Avatar()
}
}
func (m *GenericContainer) UpdateAuthorName(name text.Rich) {
m.Username.SetMarkup(parser.RenderMarkup(name))
}
func (m *GenericContainer) UpdateContent(content text.Rich) {
m.Content.SetMarkup(parser.RenderMarkup(content))
func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {
var markup = parser.RenderMarkup(content)
if edited {
markup += " " + rich.Small("(edited)")
}
m.Content.SetMarkup(markup)
}
// AttachMenu connects signal handlers to handle a list of menu items from
// the container.
func (m *GenericContainer) AttachMenu(newItems func() []gtk.IMenuItem) {
m.MenuItems = newItems
}

View file

@ -2,22 +2,21 @@ package message
import (
"html"
"time"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat/text"
)
type PresendContainer interface {
SetID(id string)
SetDone()
SetDone(id string)
SetLoading()
SetSentError(err error)
}
// PresendGenericContainer is the generic container with extra methods
// implemented for mutability of the generic message container.
// implemented for stateful mutability of the generic message container.
type GenericPresendContainer struct {
*GenericContainer
sendString string // to be cleared on SetDone()
}
var _ PresendContainer = (*GenericPresendContainer)(nil)
@ -29,33 +28,36 @@ func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer {
func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *GenericPresendContainer {
c.nonce = msg.Nonce()
c.authorID = msg.AuthorID()
c.UpdateContent(text.Rich{Content: msg.Content()})
c.UpdateTimestamp(time.Now())
c.UpdateTimestamp(msg.Time())
c.UpdateAuthorName(msg.Author())
p := &GenericPresendContainer{
GenericContainer: c,
sendString: msg.Content(),
}
p.SetSensitive(false)
p.SetLoading()
return p
}
func (m *GenericPresendContainer) SetID(id string) {
m.id = id
}
func (m *GenericPresendContainer) SetSensitive(sensitive bool) {
m.Content.SetSensitive(sensitive)
}
func (m *GenericPresendContainer) SetDone() {
func (m *GenericPresendContainer) SetDone(id string) {
m.id = id
m.SetSensitive(true)
m.sendString = ""
}
func (m *GenericPresendContainer) SetLoading() {
m.SetSensitive(false)
m.Content.SetText(m.sendString)
m.Content.SetTooltipText("")
}
func (m *GenericPresendContainer) SetSentError(err error) {
var content = html.EscapeString(m.Content.GetLabel())
m.Content.SetMarkup(`<span color="red">` + content + `</span>`)
m.SetSensitive(true) // allow events incl right clicks
m.Content.SetMarkup(`<span color="red">` + html.EscapeString(m.sendString) + `</span>`)
m.Content.SetTooltipText(err.Error())
}

View file

@ -0,0 +1,143 @@
// Package sadface provides different views for the message container.
package sadface
import (
"strings"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
const FaceSize = 56
type WidgetUnreferencer interface {
gtk.IWidget
Unref()
}
type FaceView struct {
gtk.Stack
placeholder WidgetUnreferencer
Face *Container
Loading *Spinner
}
func New(parent gtk.IWidget, placeholder WidgetUnreferencer) *FaceView {
c := NewContainer()
c.Show()
s := NewSpinner()
s.Show()
// make an empty box for an empty page.
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
stack, _ := gtk.StackNew()
stack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
stack.SetTransitionDuration(75)
stack.AddNamed(parent, "main")
stack.AddNamed(placeholder, "placeholder")
stack.AddNamed(c, "face")
stack.AddNamed(s, "loading")
stack.AddNamed(b, "empty")
// Show placeholder by default.
stack.SetVisibleChildName("placeholder")
return &FaceView{*stack, placeholder, c, s}
}
// Reset brings the view to an empty box.
func (v *FaceView) Reset() {
v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop()
v.Stack.SetVisibleChildName("empty")
}
func (v *FaceView) SetMain() {
v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop()
v.Stack.SetVisibleChildName("main")
}
func (v *FaceView) SetLoading() {
v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Start()
v.Stack.SetVisibleChildName("loading")
}
func (v *FaceView) SetError(err error) {
v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop()
v.Stack.SetVisibleChildName("face")
v.Face.SetError(err)
}
func (v *FaceView) ensurePlaceholderDestroyed() {
// If the placeholder is still there:
if v.placeholder != nil {
// Safely remove the placeholder from the stack.
if v.Stack.GetVisibleChildName() == "placeholder" {
v.Stack.SetVisibleChildName("main")
}
// Remove the placeholder widget.
v.Stack.Remove(v.placeholder)
v.placeholder = nil
}
}
type Spinner struct {
gtk.Box
Spinner *gtk.Spinner
}
func NewSpinner() *Spinner {
s, _ := gtk.SpinnerNew()
s.SetSizeRequest(FaceSize, FaceSize)
s.Start()
s.Show()
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
b.Add(s)
b.SetHAlign(gtk.ALIGN_CENTER)
b.SetVAlign(gtk.ALIGN_CENTER)
return &Spinner{*b, s}
}
type Container struct {
gtk.Box
Face *gtk.Image
Error *gtk.Label
}
func NewContainer() *Container {
face, _ := gtk.ImageNew()
face.SetSizeRequest(FaceSize, FaceSize)
face.Show()
primitives.SetImageIcon(face, "face-sad-symbolic", FaceSize)
errlabel, _ := gtk.LabelNew("")
errlabel.SetOpacity(0.75) // low contrast good because unreadable
errlabel.Show()
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 15)
box.SetVAlign(gtk.ALIGN_CENTER)
box.SetHAlign(gtk.ALIGN_CENTER)
box.PackStart(face, false, false, 0)
box.PackStart(errlabel, false, false, 0)
return &Container{*box, face, errlabel}
}
// SetError sets the view to display the error. Error must not be nil.
func (v *Container) SetError(err error) {
// Split the error.
parts := strings.Split(err.Error(), ": ")
v.Error.SetLabel("Error: " + parts[len(parts)-1])
// Use the full error for the tooltip.
v.Box.SetTooltipText(err.Error())
}

View file

@ -2,32 +2,78 @@ package messages
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/icons"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container/cozy"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/sadface"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
type Container interface {
gtk.IWidget
cchat.MessagesContainer
// ServerMessage combines Server and ServerMessage from cchat.
type ServerMessage interface {
cchat.Server
cchat.ServerMessage
}
Reset()
ScrollToBottom()
type state struct {
session cchat.Session
server cchat.Server
// PresendMessage is for unsent messages.
PresendMessage(input.PresendMessage) (done func(sendError error))
actioner cchat.ServerMessageActioner
actions []string
current func() // stop callback
author string
}
func (s *state) Reset() {
// If we still have the last server to leave, then leave it.
if s.current != nil {
s.current()
}
// Lazy way to reset the state.
*s = state{}
}
func (s *state) hasActions() bool {
return s.actioner != nil && len(s.actions) > 0
}
// SessionID returns the session ID, or an empty string if there's no session.
func (s *state) SessionID() string {
if s.session != nil {
return s.session.ID()
}
return ""
}
func (s *state) bind(session cchat.Session, server ServerMessage) {
s.session = session
s.server = server
if s.actioner, _ = server.(cchat.ServerMessageActioner); s.actioner != nil {
s.actions = s.actioner.MessageActions()
}
}
func (s *state) setcurrent(fn func()) {
s.current = fn
}
type View struct {
*gtk.Box
Container container.Container
SendInput *input.Field
*sadface.FaceView
Box *gtk.Box
current cchat.ServerMessage
author string
InputView *input.InputView
Container container.Container
// Inherit some useful methods.
state
}
func NewView() *View {
@ -35,53 +81,125 @@ func NewView() *View {
// TODO: change
// view.Container = compact.NewContainer()
view.Container = cozy.NewContainer()
view.SendInput = input.NewField(view)
view.InputView = input.NewView(view)
view.Container = cozy.NewContainer(view)
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
view.Box.PackStart(view.Container, true, true, 0)
view.Box.PackStart(view.SendInput, false, false, 0)
view.Box.PackStart(view.InputView, false, false, 0)
view.Box.Show()
// placeholder logo
logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256())
logo.Show()
view.FaceView = sadface.New(view.Box, logo)
view.FaceView.Show()
return view
}
func (v *View) Reset() {
// Leave the server if any.
if v.current != nil {
// Backend should handle synchronizing joins and leaves if it needs to.
go func() {
if err := v.current.LeaveServer(); err != nil {
log.Error(errors.Wrap(err, "Error leaving server"))
}
}()
}
// Clean all messages.
v.Container.Reset()
// Reset the input.
v.SendInput.Reset()
v.FaceView.Reset() // Switch back to the main screen.
v.Container.Reset() // Clean all messages.
v.InputView.Reset() // Reset the input.
v.state.Reset() // Reset the state variables.
}
// JoinServer is not thread-safe, but it calls backend functions asynchronously.
func (v *View) JoinServer(session cchat.Session, server cchat.ServerMessage) {
func (v *View) JoinServer(session cchat.Session, server ServerMessage) {
// Reset before setting.
v.Reset()
v.current = server
// Skipping ok check because sender can be nil. Without the empty check, Go
// will panic.
sender, _ := server.(cchat.ServerMessageSender)
v.SendInput.SetSender(session, sender)
// Set the screen to loading.
v.FaceView.SetLoading()
// Bind the state.
v.state.bind(session, server)
gts.Async(func() (func(), error) {
s, err := server.JoinServer(v.Container)
if err != nil {
err = errors.Wrap(err, "Failed to join server")
return func() { v.SetError(err) }, err
}
return func() {
// Set the screen to the main one.
v.FaceView.SetMain()
// Set the cancel handler.
v.state.setcurrent(s)
// Skipping ok check because sender can be nil. Without the empty check, Go
// will panic.
sender, _ := server.(cchat.ServerMessageSender)
v.InputView.SetSender(session, sender)
}, nil
})
}
func (v *View) AddPresendMessage(msg input.PresendMessage) func(error) {
var presend = v.Container.AddPresendMessage(msg)
return func(err error) {
// Set the retry message.
presend.SetSentError(err)
// Only attach the menu once. Further retries do not need to be
// reattached.
presend.AttachMenu(func() []gtk.IMenuItem {
return []gtk.IMenuItem{
primitives.MenuItem("Retry", func() {
presend.SetLoading()
v.retryMessage(msg, presend)
}),
}
})
}
}
// retryMessage sends the message.
func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendGridMessage) {
var sender = v.InputView.Sender
if sender == nil {
return
}
go func() {
if err := v.current.JoinServer(v.Container); err != nil {
log.Error(errors.Wrap(err, "Failed to join server"))
if err := sender.SendMessage(msg); err != nil {
// Set the message's state to errored again, but we don't need to
// rebind the menu.
gts.ExecAsync(func() { presend.SetSentError(err) })
}
}()
}
func (v *View) PresendMessage(msg input.PresendMessage) func(error) {
return v.Container.PresendMessage(msg)
// BindMenu attaches the menu constructor into the message with the needed
// states and callbacks.
func (v *View) BindMenu(msg container.GridMessage) {
// Don't bind anything if we don't have anything.
if !v.state.hasActions() {
return
}
msg.AttachMenu(func() []gtk.IMenuItem {
var mitems = make([]gtk.IMenuItem, len(v.state.actions))
for i, action := range v.state.actions {
mitems[i] = primitives.MenuItem(action, v.menuItemActivate(msg.ID()))
}
return mitems
})
}
// menuItemActivate creates a new callback that's called on menu item
// activation.
func (v *View) menuItemActivate(msgID string) func(m *gtk.MenuItem) {
return func(m *gtk.MenuItem) {
go func(action string) {
// Run, get the error, and try to log it. The logger will ignore nil
// errors.
err := v.state.actioner.DoMessageAction(v.Container, action, msgID)
log.Error(errors.Wrap(err, "Failed to do action "+action))
}(m.GetLabel())
}
}

View file

@ -7,6 +7,66 @@ import (
"github.com/gotk3/gotk3/gtk"
)
type Namer interface {
SetName(string)
GetName() (string, error)
}
func GetName(namer Namer) string {
nm, _ := namer.GetName()
return nm
}
func EachChildren(w interface{ GetChildren() *glib.List }, fn func(i int, v interface{}) bool) {
var cursor int = -1
for ptr := w.GetChildren(); ptr != nil; ptr = ptr.Next() {
cursor++
if fn(cursor, ptr.Data()) {
return
}
}
}
type DragSortable interface {
DragSourceSet(gdk.ModifierType, []gtk.TargetEntry, gdk.DragAction)
DragDestSet(gtk.DestDefaults, []gtk.TargetEntry, gdk.DragAction)
GetAllocation() *gtk.Allocation
Connector
}
func BindDragSortable(ds DragSortable, target, id string, fn func(id, target string)) {
var dragEntries = []gtk.TargetEntry{NewTargetEntry(target)}
var dragAtom = gdk.GdkAtomIntern(target, true)
// Drag source so you can drag the button away.
ds.DragSourceSet(gdk.BUTTON1_MASK, dragEntries, gdk.ACTION_MOVE)
// Drag destination so you can drag the button here.
ds.DragDestSet(gtk.DEST_DEFAULT_ALL, dragEntries, gdk.ACTION_MOVE)
ds.Connect("drag-data-get",
// TODO change ToggleButton.
func(ds DragSortable, ctx *gdk.DragContext, data *gtk.SelectionData) {
// Set the index-in-bytes.
data.SetData(dragAtom, []byte(id))
},
)
ds.Connect("drag-data-received",
func(ds DragSortable, ctx *gdk.DragContext, x, y uint, data *gtk.SelectionData) {
// Receive the incoming row's ID and call MoveSession.
fn(id, string(data.GetData()))
},
)
ds.Connect("drag-begin",
func(ds DragSortable, ctx *gdk.DragContext) {
gtk.DragSetIconName(ctx, "user-available-symbolic", 0, 0)
},
)
}
type StyleContexter interface {
GetStyleContext() (*gtk.StyleContext, error)
}
@ -52,13 +112,13 @@ func AppendMenuItems(menu interface{ Append(gtk.IMenuItem) }, items []*gtk.MenuI
}
}
func HiddenMenuItem(label string, fn func()) *gtk.MenuItem {
func HiddenMenuItem(label string, fn interface{}) *gtk.MenuItem {
mb, _ := gtk.MenuItemNewWithLabel(label)
mb.Connect("activate", fn)
return mb
}
func MenuItem(label string, fn func()) *gtk.MenuItem {
func MenuItem(label string, fn interface{}) *gtk.MenuItem {
menuitem := HiddenMenuItem(label, fn)
menuitem.Show()
return menuitem
@ -75,3 +135,10 @@ func BindMenu(menu *gtk.Menu, connector Connector) {
}
})
}
func NewTargetEntry(target string) gtk.TargetEntry {
e, _ := gtk.TargetEntryNew(target, gtk.TARGET_SAME_APP, 0)
return *e
}
// func 

View file

@ -53,6 +53,7 @@ func NewIcon(sizepx int, procs ...imgutil.Processor) *Icon {
func (i *Icon) Reset() {
i.url = ""
i.Revealer.SetRevealChild(false)
i.Image.SetFromPixbuf(nil) // destroy old pb
}
// URL is not thread-safe.
@ -99,6 +100,14 @@ func (i *Icon) SetIcon(url string) {
})
}
func (i *Icon) AsyncSetIcon(fn func(cchat.IconContainer) error, wrap string) {
go func() {
if err := fn(i); err != nil {
log.Error(errors.Wrap(err, wrap))
}
}()
}
// SetIconUnsafe is not thread-safe.
func (i *Icon) SetIconUnsafe(url string) {
i.SetRevealChild(true)
@ -151,21 +160,3 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
Box: box,
}
}
type Namer interface {
Name(cchat.LabelContainer) error
}
// Try tries to set the name from namer. It also tries Icon.
func (b *ToggleButtonImage) Try(namer Namer, desc string) {
if err := namer.Name(b); err != nil {
log.Error(errors.Wrap(err, "Failed to get name for "+desc))
b.SetLabel(text.Rich{Content: "Unknown"})
}
if iconer, ok := namer.(cchat.Icon); ok {
if err := iconer.Icon(b); err != nil {
log.Error(errors.Wrap(err, "Failed to get icon for "+desc))
}
}
}

View file

@ -44,6 +44,11 @@ func (a *attrAppendMap) finalize(strlen int) []int {
}
func RenderMarkup(content text.Rich) string {
// Fast path.
if len(content.Segments) == 0 {
return html.EscapeString(content.Content)
}
buf := bytes.Buffer{}
buf.Grow(len(content.Content))

View file

@ -5,13 +5,17 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
// TODO: parser
func Small(text string) string {
return `<span size="small" color="#808080">` + text + "</span>"
}
func MakeRed(content text.Rich) string {
return `<span color="red">` + html.EscapeString(content.Content) + `</span>`
@ -50,6 +54,14 @@ func (l *Label) Reset() {
l.Label.SetText("")
}
func (l *Label) AsyncSetLabel(fn func(cchat.LabelContainer) error, info string) {
go func() {
if err := fn(l); err != nil {
log.Error(errors.Wrap(err, info))
}
}()
}
// SetLabel is thread-safe.
func (l *Label) SetLabel(content text.Rich) {
gts.ExecAsync(func() {

View file

@ -6,6 +6,7 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/dialog"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
)
@ -25,7 +26,7 @@ type Dialog struct {
// NewDialog makes a new authentication dialog. Auth() is called when the user
// is authenticated successfully inside the Gtk main thread.
func NewDialog(name string, auther cchat.Authenticator, auth func(cchat.Session)) *Dialog {
func NewDialog(name text.Rich, auther cchat.Authenticator, auth func(cchat.Session)) *Dialog {
label, _ := gtk.LabelNew("")
label.Show()
@ -57,7 +58,7 @@ func NewDialog(name string, auther cchat.Authenticator, auth func(cchat.Session)
body: box,
label: label,
}
d.Dialog = dialog.NewModal(stack, "Log in to "+name, "Log in", d.ok)
d.Dialog = dialog.NewModal(stack, "Log in to "+name.Content, "Log in", d.ok)
d.Dialog.SetDefaultSize(400, 300)
d.spin(nil)
d.Show()

View file

@ -1,13 +1,14 @@
package service
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
"github.com/gotk3/gotk3/gtk"
)
type children struct {
*gtk.Box
Sessions map[string]*session.Row
sessions map[string]*session.Row
}
func newChildren() *children {
@ -17,14 +18,63 @@ func newChildren() *children {
return &children{box, map[string]*session.Row{}}
}
func (c *children) addSessionRow(id string, row *session.Row) {
c.Sessions[id] = row
c.Box.Add(row)
func (c *children) Sessions() []*session.Row {
// We already know the size beforehand. Allocate it wisely.
var rows = make([]*session.Row, 0, len(c.sessions))
// Loop over widget children.
primitives.EachChildren(c.Box, func(i int, v interface{}) bool {
var id = primitives.GetName(v.(primitives.Namer))
if row, ok := c.sessions[id]; ok {
rows = append(rows, row)
}
return false
})
return rows
}
func (c *children) removeSessionRow(id string) {
if row, ok := c.Sessions[id]; ok {
delete(c.Sessions, id)
func (c *children) AddSessionRow(id string, row *session.Row) {
c.sessions[id] = row
c.Box.Add(row)
// Bind the mover.
row.BindMover(id)
// Assert that a name can be obtained.
namer := primitives.Namer(row)
namer.SetName(id) // set ID here, get it in Move
}
func (c *children) RemoveSessionRow(sessionID string) bool {
row, ok := c.sessions[sessionID]
if ok {
delete(c.sessions, sessionID)
c.Box.Remove(row)
}
return ok
}
func (c *children) MoveSession(id, movingID string) {
// Get the widget of the row that is moving.
var moving = c.sessions[movingID]
// Find the current position of the row that we're moving the other one
// underneath of.
var rowix = -1
primitives.EachChildren(c.Box, func(i int, v interface{}) bool {
// The obtained name will be the ID set in AddSessionRow.
if primitives.GetName(v.(primitives.Namer)) == id {
rowix = i
return true
}
return false
})
// Reorder the box.
c.Box.ReorderChild(moving, rowix)
}

View file

@ -5,7 +5,6 @@ import (
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
@ -22,7 +21,7 @@ type header struct {
}
func newHeader(svc cchat.Service) *header {
reveal := rich.NewToggleButtonImage(text.Rich{Content: svc.Name()})
reveal := rich.NewToggleButtonImage(svc.Name())
reveal.Box.SetHAlign(gtk.ALIGN_START)
reveal.Image.AddProcessors(imgutil.Round(true))
reveal.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize)

View file

@ -0,0 +1,27 @@
package loading
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
type Button struct {
gtk.Button
Spinner gtk.Spinner
}
func NewButton() *Button {
s, _ := gtk.SpinnerNew()
s.SetHAlign(gtk.ALIGN_CENTER)
s.Start()
s.Show()
b, _ := gtk.ButtonNew()
b.Add(s)
b.SetSensitive(false) // unclickable
b.Show()
primitives.AddClass(b, "loading-button")
return &Button{*b, *s}
}

View file

@ -53,11 +53,13 @@ type Controller interface {
MessageRowSelected(*session.Row, *server.Row, cchat.ServerMessage)
// AuthenticateSession is called to spawn the authentication dialog.
AuthenticateSession(*Container, cchat.Service)
// RemoveSession is called to remove a session. This should also clear out
// OnSessionRemove is called to remove a session. This should also clear out
// the message view in the parent package.
RemoveSession(id string)
OnSessionRemove(id string)
}
// Container represents a single service, including the button header and the
// child containers.
type Container struct {
*gtk.Box
Service cchat.Service
@ -124,22 +126,28 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container {
func (c *Container) AddSession(ses cchat.Session) *session.Row {
srow := session.New(c, ses, c)
c.children.addSessionRow(ses.ID(), srow)
c.children.AddSessionRow(ses.ID(), srow)
c.SaveAllSessions()
return srow
}
func (c *Container) AddLoadingSession(id, name string) *session.Row {
srow := session.NewLoading(c, name, c)
c.children.addSessionRow(id, srow)
c.children.AddSessionRow(id, srow)
return srow
}
func (c *Container) RemoveSession(id string) {
c.children.removeSessionRow(id)
func (c *Container) RemoveSession(row *session.Row) {
var id = row.Session.ID()
c.children.RemoveSessionRow(id)
c.SaveAllSessions()
// Call the parent's method.
c.Controller.RemoveSession(id)
c.Controller.OnSessionRemove(id)
}
func (c *Container) MoveSession(rowID, beneathRowID string) {
c.children.MoveSession(rowID, beneathRowID)
c.SaveAllSessions()
}
// RestoreSession tries to restore sessions asynchronously. This satisfies
@ -187,19 +195,16 @@ func (c *Container) restoreSession(r *session.Row, res cchat.SessionRestorer, k
}
func (c *Container) SaveAllSessions() {
keyring.SaveSessions(c.Service.Name(), c.keyringSessions())
}
var sessions = c.children.Sessions()
var ksessions = make([]keyring.Session, 0, len(sessions))
// keyringSessions returns all known keyring sessions. Sessions that can't be
// saved will not be in the slice.
func (c *Container) keyringSessions() []keyring.Session {
var ksessions = make([]keyring.Session, 0, len(c.children.Sessions))
for _, s := range c.children.Sessions {
for _, s := range sessions {
if k := s.KeyringSession(); k != nil {
ksessions = append(ksessions, *k)
}
}
return ksessions
keyring.SaveSessions(c.Service.Name(), ksessions)
}
func (c *Container) Breadcrumb() breadcrumb.Breadcrumb {

View file

@ -7,7 +7,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/cchat-gtk/internal/ui/service/loading"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
@ -36,13 +36,16 @@ type Row struct {
}
func NewRow(parent breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *Row {
button := rich.NewToggleButtonImage(text.Rich{})
button := rich.NewToggleButtonImage(server.Name())
button.Box.SetHAlign(gtk.ALIGN_START)
button.Image.AddProcessors(imgutil.Round(true))
button.Image.SetSize(IconSize)
button.SetRelief(gtk.RELIEF_NONE)
button.Show()
button.Try(server, "server")
if iconer, ok := server.(cchat.Icon); ok {
button.Image.AsyncSetIcon(iconer.Icon, "Error getting server icon URL")
}
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
box.PackStart(button, false, false, 0)
@ -79,16 +82,23 @@ func NewRow(parent breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller
return row
}
// Deactivate calls the disconnect function then sets the button to false. This
// function is not thread-safe.
func (row *Row) Deactivate() {
row.Button.SetSensitive(true) // allow clicks again
row.Button.SetActive(false) // stop highlighting
}
func (row *Row) GetActive() bool {
return row.Button.GetActive()
}
func (row *Row) onClick() {
switch {
// If the server is a message server. We're only selected if the button is
// pressed.
case row.message != nil && row.GetActive():
row.Button.SetSensitive(false) // prevent clicks from deactivating
row.ctrl.MessageRowSelected(row, row.message)
// If the server is a list of smaller servers.
@ -105,6 +115,7 @@ func (r *Row) Breadcrumb() breadcrumb.Breadcrumb {
type Children struct {
*gtk.Revealer
Main *gtk.Box
load *loading.Button // nil after init
List cchat.ServerList
rowctrl Controller
@ -114,7 +125,11 @@ type Children struct {
}
func NewChildren(parent breadcrumb.Breadcrumber, ctrl Controller) *Children {
load := loading.NewButton()
load.Show()
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
main.Add(load)
main.SetMarginStart(ChildrenMargin)
main.Show()
@ -126,6 +141,7 @@ func NewChildren(parent breadcrumb.Breadcrumber, ctrl Controller) *Children {
return &Children{
Revealer: rev,
Main: main,
load: load,
rowctrl: ctrl,
Parent: parent,
}
@ -134,13 +150,21 @@ func NewChildren(parent breadcrumb.Breadcrumber, ctrl Controller) *Children {
func (c *Children) SetServerList(list cchat.ServerList) {
c.List = list
if err := list.Servers(c); err != nil {
log.Error(errors.Wrap(err, "Failed to get servers"))
}
go func() {
if err := list.Servers(c); err != nil {
log.Error(errors.Wrap(err, "Failed to get servers"))
}
}()
}
func (c *Children) SetServers(servers []cchat.Server) {
gts.ExecAsync(func() {
// Do we have the spinning circle button? If yes, remove it.
if c.load != nil {
c.Main.Remove(c.load)
c.load = nil
}
// Save the current state.
var oldID string
for _, row := range c.Rows {

View file

@ -9,6 +9,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
@ -18,9 +19,12 @@ const IconSize = 32
type Controller interface {
MessageRowSelected(*Row, *server.Row, cchat.ServerMessage)
RestoreSession(*Row, keyring.Session) // async
RemoveSession(id string)
RemoveSession(*Row)
MoveSession(id, movingID string)
}
// Row represents a single session, including the button header and the
// children servers.
type Row struct {
*gtk.Box
Button *rich.ToggleButtonImage
@ -51,6 +55,11 @@ func NewLoading(parent breadcrumb.Breadcrumber, name string, ctrl Controller) *R
return row
}
var dragEntries = []gtk.TargetEntry{
primitives.NewTargetEntry("GTK_TOGGLE_BUTTON"),
}
var dragAtom = gdk.GdkAtomIntern("GTK_TOGGLE_BUTTON", true)
func new(parent breadcrumb.Breadcrumber, ctrl Controller) *Row {
row := &Row{
ctrl: ctrl,
@ -63,13 +72,14 @@ func new(parent breadcrumb.Breadcrumber, ctrl Controller) *Row {
row.Button.Image.AddProcessors(imgutil.Round(true))
// Set the loading icon.
row.Button.SetRelief(gtk.RELIEF_NONE)
row.Button.Show()
// On click, toggle reveal.
row.Button.Connect("clicked", func() {
revealed := !row.Servers.GetRevealChild()
row.Servers.SetRevealChild(revealed)
row.Button.SetActive(revealed)
})
row.Button.Show()
row.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
row.Box.SetMarginStart(server.ChildrenMargin)
@ -93,7 +103,7 @@ func new(parent breadcrumb.Breadcrumber, ctrl Controller) *Row {
primitives.AppendMenuItems(row.menu, []*gtk.MenuItem{
row.retry,
primitives.MenuItem("Remove", func() {
ctrl.RemoveSession(row.Session.ID())
ctrl.RemoveSession(row)
}),
})
@ -122,12 +132,16 @@ func (r *Row) SetSession(ses cchat.Session) {
r.Session = ses
r.Servers.SetServerList(ses)
r.Button.SetLabelUnsafe(ses.Name())
r.Button.Image.SetPlaceholderIcon("user-available-symbolic", IconSize)
r.Box.PackStart(r.Servers, false, false, 0)
r.SetSensitive(true)
r.SetTooltipText("") // reset
// Set the session's name to the button.
r.Button.Try(ses, "session")
// Try and set the session's icon.
if iconer, ok := ses.(cchat.Icon); ok {
r.Button.Image.AsyncSetIcon(iconer.Icon, "Error fetching session icon URL")
}
// Wipe the keyring session off.
r.krs = keyring.Session{}
@ -154,3 +168,9 @@ func (r *Row) MessageRowSelected(server *server.Row, smsg cchat.ServerMessage) {
func (r *Row) Breadcrumb() breadcrumb.Breadcrumb {
return breadcrumb.Try(r.parent, r.Button.GetLabel().Content)
}
// BindMover binds with the ID stored in the parent container to be used in the
// method itself. The ID may or may not have to do with session.
func (r *Row) BindMover(id string) {
primitives.BindDragSortable(r.Button, "GTK_TOGGLE_BUTTON", id, r.ctrl.MoveSession)
}

View file

@ -3,6 +3,7 @@ package ui
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/messages"
"github.com/diamondburned/cchat-gtk/internal/ui/service"
"github.com/diamondburned/cchat-gtk/internal/ui/service/auth"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
@ -22,8 +23,8 @@ type App struct {
window *window
header *header
// used to keep track of what row to highlight and unhighlight
lastRowHighlighter func(bool)
// used to keep track of what row to disconnect before switching
lastDeactivator func()
}
var (
@ -45,25 +46,27 @@ func (app *App) AddService(svc cchat.Service) {
app.window.Services.AddService(svc, app)
}
func (app *App) RemoveSession(string) {
app.window.MessageView.Reset()
app.header.SetBreadcrumb(nil)
// OnSessionRemove resets things before the session is removed.
func (app *App) OnSessionRemove(id string) {
// Reset the message view if it's what we're showing.
if app.window.MessageView.SessionID() == id {
app.window.MessageView.Reset()
app.header.SetBreadcrumb(nil)
}
}
func (app *App) MessageRowSelected(ses *session.Row, srv *server.Row, smsg cchat.ServerMessage) {
// Is there an old row that we should unhighlight?
if app.lastRowHighlighter != nil {
app.lastRowHighlighter(false)
// Is there an old row that we should deactivate?
if app.lastDeactivator != nil {
app.lastDeactivator()
}
// Set the new row and highlight it.
app.lastRowHighlighter = srv.Button.SetActive
app.lastRowHighlighter(true)
// Set the new row.
app.lastDeactivator = srv.Deactivate
app.header.SetBreadcrumb(srv.Breadcrumb())
// Show the messages.
app.window.MessageView.JoinServer(ses.Session, smsg)
// Assert that server is also a list, then join the server.
app.window.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage))
}
func (app *App) AuthenticateSession(container *service.Container, svc cchat.Service) {

File diff suppressed because one or more lines are too long

20
profile.go Normal file
View file

@ -0,0 +1,20 @@
// +build prof
package main
import (
"net/http"
_ "net/http/pprof"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/pkg/errors"
)
func init() {
go func() {
if err := http.ListenAndServe("localhost:42069", nil); err != nil {
log.Error(errors.Wrap(err, "Failed to start profiling HTTP server"))
}
}()
}