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:
parent
6343e9888a
commit
7f0a6653ee
11
go.mod
11
go.mod
|
@ -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
15
go.sum
|
@ -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
BIN
icons/cchat-variant2.xcf
Normal file
Binary file not shown.
BIN
icons/cchat-variant2_256.png
Normal file
BIN
icons/cchat-variant2_256.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
BIN
icons/cchat.png
Normal file
BIN
icons/cchat.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 191 KiB |
1
icons/cchat.svg
Normal file
1
icons/cchat.svg
Normal 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
BIN
icons/cchat.symbolic.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/cchat.xcf
Normal file
BIN
icons/cchat.xcf
Normal file
Binary file not shown.
BIN
icons/cchat_256.png
Normal file
BIN
icons/cchat_256.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
48
icons/icons.go
Normal file
48
icons/icons.go
Normal 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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
262
internal/ui/messages/container/grid.go
Normal file
262
internal/ui/messages/container/grid.go
Normal 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
|
||||
}
|
287
internal/ui/messages/input/completion/completion.go
Normal file
287
internal/ui/messages/input/completion/completion.go
Normal 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
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
143
internal/ui/messages/sadface/sadface.go
Normal file
143
internal/ui/messages/sadface/sadface.go
Normal 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())
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
27
internal/ui/service/loading/loading.go
Normal file
27
internal/ui/service/loading/loading.go
Normal 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}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
20
profile.go
Normal file
20
profile.go
Normal 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"))
|
||||
}
|
||||
}()
|
||||
}
|
Loading…
Reference in a new issue