1
0
Fork 0
mirror of https://github.com/diamondburned/cchat-gtk.git synced 2025-01-24 19:06:43 +00:00

Refactored code for additional lazy loading and features

This commit is contained in:
diamondburned (Forefront) 2020-06-17 00:06:34 -07:00
parent 0d8d3609be
commit be88670bb6
19 changed files with 834 additions and 404 deletions

8
go.mod
View file

@ -3,16 +3,20 @@ module github.com/diamondburned/cchat-gtk
go 1.14
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200612012846-9df87fea4f6d
replace github.com/diamondburned/cchat-mock => ../cchat-mock/
replace github.com/diamondburned/cchat-discord => ../cchat-discord/
require (
github.com/Xuanwo/go-locale v0.2.0
github.com/diamondburned/cchat v0.0.26
github.com/diamondburned/cchat-mock v0.0.0-20200613233949-1e7651c8dd84
github.com/diamondburned/cchat v0.0.28
github.com/diamondburned/cchat-discord v0.0.0-00010101000000-000000000000
github.com/diamondburned/cchat-mock v0.0.0-20200615015702-8cac8b16378d
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
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
github.com/ianlancetaylor/cgosymbolizer v0.0.0-20200424224625-be1b05b0b279
github.com/markbates/pkger v0.17.0
github.com/peterbourgon/diskv v2.0.1+incompatible
github.com/pkg/errors v0.9.1

23
go.sum
View file

@ -7,18 +7,27 @@ 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/arikawa v0.8.7-0.20200522214036-530bff74a2c6/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660=
github.com/diamondburned/arikawa v0.9.4 h1:Mrp0Vz9R2afbvhWS6m/oLIQy22/uxXb459LUv7qrZPA=
github.com/diamondburned/arikawa v0.9.4/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660=
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/cchat v0.0.26 h1:QBt4d65uzUPJz3jF8b2pJ09Jz8LeBRyG2ol47FOy0g0=
github.com/diamondburned/cchat v0.0.26/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat v0.0.28 h1:+1VnltW0rl8/NZTUP+x89jVhi3YTTR+e6iLprZ7HcwM=
github.com/diamondburned/cchat v0.0.28/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat-mock v0.0.0-20200613003444-b36f8f47debe h1:OoTLxpryxB9iQyu3bjw5N9N/3Bvu6FwklJ85X9erCAY=
github.com/diamondburned/cchat-mock v0.0.0-20200613003444-b36f8f47debe/go.mod h1:vitBma+rd/ah+ujQsp6lPm/AfS2KtLKEh+Owxbv5BQM=
github.com/diamondburned/cchat-mock v0.0.0-20200613233949-1e7651c8dd84 h1:NSuksZ9HiLiau93qAz4yNba6Xd7ExOFc956dumONDQ0=
github.com/diamondburned/cchat-mock v0.0.0-20200613233949-1e7651c8dd84/go.mod h1:JxTay4MVEqmDisGqDGk8TG0UnKX7wDEImFywyoPfGjk=
github.com/diamondburned/cchat-mock v0.0.0-20200615015702-8cac8b16378d h1:LkzARyvdGRvAsaKEPTV3XcqMHENH6J+KRAI+3sq41Qs=
github.com/diamondburned/cchat-mock v0.0.0-20200615015702-8cac8b16378d/go.mod h1:SVTt5je4G+re8aSVJAFk/x8vvbRzXdpKgSKmVGoM1tg=
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/diamondburned/ningen v0.0.0-20200610212436-159f7105a2be h1:mUw8X/YzJGFSdL8y3Q/XqyzqPyIMNVSDyZGOP3JXgJA=
github.com/diamondburned/ningen v0.0.0-20200610212436-159f7105a2be/go.mod h1:B2hq2B4va1MlnMmXuv9vXmyu9gscxJLmwrmcSB1Les8=
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=
@ -33,8 +42,14 @@ github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/ianlancetaylor/cgosymbolizer v0.0.0-20200424224625-be1b05b0b279 h1:IpTHAzWv1pKDDWeJDY5VOHvqc2T9d3C8cPKEf2VPqHE=
github.com/ianlancetaylor/cgosymbolizer v0.0.0-20200424224625-be1b05b0b279/go.mod h1:a5aratAVTWyz+nJMmDsN8O4XTfaLfdAsB1ysCmZX5Bw=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@ -58,16 +73,24 @@ 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.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/twmb/murmur3 v1.1.3 h1:D83U0XYKcHRYwYIpBKf3Pks91Z0Byda/9SJ8B6EMRcA=
github.com/twmb/murmur3 v1.1.3/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 h1:3M/uUZajYn/082wzUajekePxpUAZhMTfXvI9R+26SJ0=
github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717/go.mod h1:RaxNwUITJaHVdQ0VC7pELPZ3tOWn13nr0gZMZEhpVU0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1P+/ZFC/IKythI5RzrnRg=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=

View file

@ -1,7 +1,9 @@
package gts
import (
"context"
"os"
"time"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/gotk3/gotk3/gdk"
@ -54,6 +56,7 @@ func Main(wfn func() WindowHeaderer) {
App.Window, _ = gtk.ApplicationWindowNew(App.Application)
App.Window.SetDefaultSize(1000, 500)
App.Window.SetTitlebar(App.Header)
App.Window.Connect("destroy", App.Application.Quit)
App.Window.Show()
// Execute the function later, because we need it to run after
@ -65,7 +68,9 @@ func Main(wfn func() WindowHeaderer) {
// Use a special function to run the application. Exit with the appropriate
// exit code.
os.Exit(App.Run(Args))
if code := App.Run(Args); code > 0 {
os.Exit(code)
}
}
// Async runs fn asynchronously, then runs the function it returns in the Gtk
@ -79,7 +84,7 @@ func Async(fn func() (func(), error)) {
// Attempt to run the callback if it's there.
if f != nil {
glib.IdleAdd(f)
ExecAsync(f)
}
}()
}
@ -106,3 +111,87 @@ func EventIsRightClick(ev *gdk.Event) bool {
keyev := gdk.EventButtonNewFromEvent(ev)
return keyev.Type() == gdk.EVENT_BUTTON_PRESS && keyev.Button() == gdk.BUTTON_SECONDARY
}
// Reuser is an interface for structs that inherit Reusable.
type Reuser interface {
Context() context.Context
Acquire() int64
Validate(int64) bool
}
// AsyncUse is a handler for structs that implement the Reuser primitive. The
// passed in function will be called asynchronously, but swap will be called in
// the Gtk main thread.
func AsyncUse(r Reuser, swap func(interface{}), fn func(context.Context) (interface{}, error)) {
// Acquire an ID.
id := r.Acquire()
ctx := r.Context()
Async(func() (func(), error) {
// Run the callback asynchronously.
v, err := fn(ctx)
if err != nil {
return nil, err
}
return func() {
// Validate the ID. Cancel if it's invalid.
if !r.Validate(id) {
log.Println("Async function value dropped for reusable primitive.")
return
}
// Update the resource.
swap(v)
}, nil
})
}
// Reusable is the synchronization primitive to provide a method for
// asynchronous cancellation and reusability.
//
// It works by copying the ID (time) for each asynchronous operation. The
// operation then completes, and the ID is then compared again before being
// used. It provides a cancellation abstraction around the Gtk main thread.
//
// This struct is not thread-safe, as it relies on the Gtk main thread
// synchronization.
type Reusable struct {
time int64 // creation time, used as ID
ctx context.Context
cancel func()
}
func NewReusable() *Reusable {
r := &Reusable{}
r.Invalidate()
return r
}
// Invalidate generates a new ID for the primitive, which would render
// asynchronously updating elements invalid.
func (r *Reusable) Invalidate() {
// Cancel the old context.
if r.cancel != nil {
r.cancel()
}
// Reset.
r.time = time.Now().UnixNano()
r.ctx, r.cancel = context.WithCancel(context.Background())
}
// Context returns the reusable's cancellable context. It never returns nil.
func (r *Reusable) Context() context.Context {
return r.ctx
}
// Reusable checks the acquired ID against the current one.
func (r *Reusable) Validate(acquired int64) (valid bool) {
return r.time == acquired
}
// Acquire lends the ID to be given to Reusable() after finishing.
func (r *Reusable) Acquire() int64 {
return r.time
}

View file

@ -37,9 +37,17 @@ func AsyncImage(img ImageContainer, url string, procs ...imgutil.Processor) {
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))
})
gif := strings.Contains(url, ".gif")
l, err := gdk.PixbufLoaderNew()
if err != nil {
log.Error(errors.Wrap(err, "Failed to make pixbuf loader"))
return
}
l.Connect("area-prepared", areaPreparedFn(ctx, img, gif))
go syncImage(ctx, l, url, procs, gif)
}
// AsyncImageSized resizes using GdkPixbuf. This method does not use the cache.
@ -54,17 +62,25 @@ func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...img
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)
execIfCtx(ctx, func() { img.SetSizeRequest(w, h) })
}
})
gif := strings.Contains(url, ".gif")
l.Connect("area-prepared", areaPreparedFn(ctx, img, gif))
l, err := gdk.PixbufLoaderNew()
if err != nil {
log.Error(errors.Wrap(err, "Failed to make pixbuf loader"))
return
}
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)
execIfCtx(ctx, func() { img.SetSizeRequest(w, h) })
}
})
l.Connect("area-prepared", areaPreparedFn(ctx, img, gif))
go syncImage(ctx, l, url, procs, gif)
}
type pbgetter interface {
@ -110,13 +126,9 @@ func execIfCtx(ctx context.Context, fn func()) {
})
}
func syncImageFn(
ctx context.Context,
img ImageContainer,
url string,
procs []imgutil.Processor,
middle func(l *gdk.PixbufLoader, gif bool),
) {
func syncImage(ctx context.Context, l io.WriteCloser, url string, p []imgutil.Processor, gif bool) {
// Close at the end when done.
defer l.Close()
r, err := get(ctx, url, true)
if err != nil {
@ -125,27 +137,12 @@ func syncImageFn(
}
defer r.Body.Close()
l, err := gdk.PixbufLoaderNew()
if err != nil {
log.Error(errors.Wrap(err, "Failed to make pixbuf loader"))
return
}
gif := strings.Contains(url, ".gif")
// This is a very important signal, so we must do it synchronously. Gotk3's
// callback implementation requires all connects to be synchronous to a
// certain thread.
<-gts.ExecSync(func() {
middle(l, gif)
})
// If we have processors, then write directly in there.
if len(procs) > 0 {
if len(p) > 0 {
if !gif {
err = imgutil.ProcessStream(l, r.Body, procs)
err = imgutil.ProcessStream(l, r.Body, p)
} else {
err = imgutil.ProcessAnimationStream(l, r.Body, procs)
err = imgutil.ProcessAnimationStream(l, r.Body, p)
}
} else {
// Else, directly copy the body over.
@ -156,8 +153,4 @@ func syncImageFn(
log.Error(errors.Wrap(err, "Error processing image"))
return
}
if err := l.Close(); err != nil {
log.Error(errors.Wrap(err, "Failed to close pixbuf"))
}
}

View file

@ -13,9 +13,7 @@ const AvatarSize = 20
type usernameContainer struct {
*gtk.Revealer
main *gtk.Box
main *gtk.Box
avatar *rich.Icon
label *rich.Label
}
@ -76,7 +74,7 @@ func (u *usernameContainer) Update(session cchat.Session, sender cchat.ServerMes
u.label.AsyncSetLabel(nicknamer.Nickname, "Error fetching server nickname")
}
// Does session implement an icon? Update if so.
// Does session implement an icon? Update if yes.
if iconer, ok := session.(cchat.Icon); ok {
u.avatar.AsyncSetIcon(iconer.Icon, "Error fetching session icon URL")
}

View file

@ -1,6 +1,8 @@
package messages
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/icons"
"github.com/diamondburned/cchat-gtk/internal/gts"
@ -80,8 +82,8 @@ func NewView() *View {
view := &View{}
// TODO: change
// view.Container = compact.NewContainer()
view.InputView = input.NewView(view)
// view.Container = compact.NewContainer(view)
view.Container = cozy.NewContainer(view)
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
@ -98,14 +100,14 @@ func NewView() *View {
}
func (v *View) Reset() {
v.state.Reset() // Reset the state variables.
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 ServerMessage) {
func (v *View) JoinServer(session cchat.Session, server ServerMessage, done func()) {
// Reset before setting.
v.Reset()
@ -116,21 +118,28 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage) {
v.state.bind(session, server)
gts.Async(func() (func(), error) {
s, err := server.JoinServer(v.Container)
// We can use a background context here, as the user can't go anywhere
// that would require cancellation anyway. This is done in ui.go.
s, err := server.JoinServer(context.Background(), v.Container)
if err != nil {
err = errors.Wrap(err, "Failed to join server")
return func() { v.SetError(err) }, err
// Even if we're erroring out, we're running the done() callback
// anyway.
return func() { done(); v.SetError(err) }, err
}
return func() {
// Run the done() callback.
done()
// 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.
// 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
@ -196,7 +205,7 @@ func (v *View) menuItemActivate(msgID string) 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)
err := v.state.actioner.DoMessageAction(action, msgID)
log.Error(errors.Wrap(err, "Failed to do action "+action))
}(m.GetLabel())
}

View file

@ -141,7 +141,7 @@ type Connector interface {
}
func BindMenu(connector Connector, menu *gtk.Menu) {
connector.Connect("event", func(_ *gtk.ToggleButton, ev *gdk.Event) {
connector.Connect("button-press-event", func(_ *gtk.ToggleButton, ev *gdk.Event) {
if gts.EventIsRightClick(ev) {
menu.PopupAtPointer(ev)
}
@ -149,11 +149,16 @@ func BindMenu(connector Connector, menu *gtk.Menu) {
}
func BindDynamicMenu(connector Connector, constr func(menu *gtk.Menu)) {
connector.Connect("event", func(_ *gtk.ToggleButton, ev *gdk.Event) {
connector.Connect("button-press-event", func(_ *gtk.ToggleButton, ev *gdk.Event) {
if gts.EventIsRightClick(ev) {
menu, _ := gtk.MenuNew()
constr(menu)
menu.PopupAtPointer(ev)
// Only show the menu if the callback added any children into the
// list.
if menu.GetChildren().Length() > 0 {
menu.PopupAtPointer(ev)
}
}
})
}

View file

@ -1,15 +1,15 @@
package rich
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
type Icon struct {
@ -17,6 +17,9 @@ type Icon struct {
Image *gtk.Image
resizer imgutil.Processor
procs []imgutil.Processor
size int
r gts.Reusable
// state
url string
@ -41,12 +44,16 @@ func NewIcon(sizepx int, procs ...imgutil.Processor) *Icon {
rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
rev.SetTransitionDuration(50)
return &Icon{
i := &Icon{
Revealer: rev,
Image: img,
resizer: imgutil.Resize(sizepx, sizepx),
procs: procs,
r: *gts.NewReusable(),
}
i.SetSize(sizepx)
return i
}
// Reset wipes the state to be just after construction.
@ -61,6 +68,10 @@ func (i *Icon) URL() string {
return i.url
}
func (i *Icon) Size() int {
return i.size
}
func (i *Icon) CopyPixbuf(dst httputil.ImageContainer) {
switch i.Image.GetStorageType() {
case gtk.IMAGE_PIXBUF:
@ -84,6 +95,7 @@ func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) {
// SetSize is not thread-safe.
func (i *Icon) SetSize(szpx int) {
i.size = szpx
i.Image.SetSizeRequest(szpx, szpx)
i.resizer = imgutil.Resize(szpx, szpx)
}
@ -95,17 +107,25 @@ func (i *Icon) AddProcessors(procs ...imgutil.Processor) {
// SetIcon is thread-safe.
func (i *Icon) SetIcon(url string) {
gts.ExecAsync(func() {
i.SetIconUnsafe(url)
gts.ExecAsync(func() { i.SetIconUnsafe(url) })
}
func (i *Icon) swapResource(v interface{}) {
i.SetIconUnsafe(v.(*nullIcon).url)
}
func (i *Icon) AsyncSetIcon(fn func(context.Context, cchat.IconContainer) error, wrap string) {
gts.AsyncUse(&i.r, i.swapResource, func(ctx context.Context) (interface{}, error) {
var ni = &nullIcon{}
return ni, fn(ctx, ni)
})
}
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))
}
}()
func (i *Icon) AsyncSetIconer(iconer cchat.Icon, wrap string) {
gts.AsyncUse(&i.r, i.swapResource, func(ctx context.Context) (interface{}, error) {
var ni = &nullIcon{}
return ni, iconer.Icon(ctx, ni)
})
}
// SetIconUnsafe is not thread-safe.
@ -160,3 +180,8 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
Box: box,
}
}
func (t *ToggleButtonImage) Reset() {
t.Labeler.Reset()
t.Image.Reset()
}

114
internal/ui/rich/label.go Normal file
View file

@ -0,0 +1,114 @@
package rich
import (
"context"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"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/gotk3/gotk3/pango"
)
type Labeler interface {
// thread-safe
cchat.LabelContainer // thread-safe
// not thread-safe
SetLabelUnsafe(text.Rich)
GetLabel() text.Rich
GetText() string
Reset()
}
type Label struct {
gtk.Label
current text.Rich
// Reusable primitive.
r gts.Reusable
}
var (
_ gtk.IWidget = (*Label)(nil)
_ Labeler = (*Label)(nil)
)
func NewLabel(content text.Rich) *Label {
label, _ := gtk.LabelNew("")
label.SetMarkup(parser.RenderMarkup(content))
label.SetXAlign(0) // left align
label.SetEllipsize(pango.ELLIPSIZE_END)
return &Label{
Label: *label,
current: content,
// reusable primitive, take reference
r: *gts.NewReusable(),
}
}
// Reset wipes the state to be just after construction.
func (l *Label) Reset() {
l.current = text.Rich{}
l.r.Invalidate()
l.Label.SetText("")
}
// swapResource is reserved for internal use only.
func (l *Label) swapResource(v interface{}) {
l.SetLabelUnsafe(v.(*nullLabel).Rich)
}
func (l *Label) AsyncSetLabel(fn func(context.Context, cchat.LabelContainer) error, info string) {
gts.AsyncUse(&l.r, l.swapResource, func(ctx context.Context) (interface{}, error) {
var nl = &nullLabel{}
return nl, fn(ctx, nl)
})
}
// SetLabel is thread-safe.
func (l *Label) SetLabel(content text.Rich) {
gts.ExecAsync(func() { l.SetLabelUnsafe(content) })
}
// SetLabelUnsafe sets the label in the current thread, meaning it's not
// thread-safe.
func (l *Label) SetLabelUnsafe(content text.Rich) {
l.current = content
l.SetMarkup(parser.RenderMarkup(content))
}
// GetLabel is NOT thread-safe.
func (l *Label) GetLabel() text.Rich {
return l.current
}
// GetText is NOT thread-safe.
func (l *Label) GetText() string {
return l.current.Content
}
type ToggleButton struct {
gtk.ToggleButton
Label
}
var (
_ gtk.IWidget = (*ToggleButton)(nil)
_ cchat.LabelContainer = (*ToggleButton)(nil)
)
func NewToggleButton(content text.Rich) *ToggleButton {
l := NewLabel(content)
l.Show()
b, _ := gtk.ToggleButtonNew()
primitives.BinLeftAlignLabel(b)
b.Add(l)
return &ToggleButton{*b, *l}
}

View file

@ -3,15 +3,7 @@ package rich
import (
"html"
"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/gotk3/gotk3/pango"
"github.com/pkg/errors"
)
func Small(text string) string {
@ -22,90 +14,12 @@ func MakeRed(content text.Rich) string {
return `<span color="red">` + html.EscapeString(content.Content) + `</span>`
}
type Labeler interface {
// thread-safe
cchat.LabelContainer // thread-safe
// used for grabbing text without changing state
type nullLabel struct{ text.Rich }
// not thread-safe
SetLabelUnsafe(text.Rich)
GetLabel() text.Rich
GetText() string
}
func (n *nullLabel) SetLabel(t text.Rich) { n.Rich = t }
type Label struct {
gtk.Label
current text.Rich
}
// used for grabbing url without changing state
type nullIcon struct{ url string }
var (
_ gtk.IWidget = (*Label)(nil)
_ Labeler = (*Label)(nil)
)
func NewLabel(content text.Rich) *Label {
label, _ := gtk.LabelNew("")
label.SetMarkup(parser.RenderMarkup(content))
label.SetXAlign(0) // left align
label.SetEllipsize(pango.ELLIPSIZE_END)
return &Label{*label, content}
}
// Reset wipes the state to be just after construction.
func (l *Label) Reset() {
l.current = text.Rich{}
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() {
l.SetLabelUnsafe(content)
})
}
// SetLabelUnsafe sets the label in the current thread, meaning it's not
// thread-safe.
func (l *Label) SetLabelUnsafe(content text.Rich) {
l.current = content
l.SetMarkup(parser.RenderMarkup(content))
}
// GetLabel is NOT thread-safe.
func (l *Label) GetLabel() text.Rich {
return l.current
}
// GetText is NOT thread-safe.
func (l *Label) GetText() string {
return l.current.Content
}
type ToggleButton struct {
gtk.ToggleButton
Label
}
var (
_ gtk.IWidget = (*ToggleButton)(nil)
_ cchat.LabelContainer = (*ToggleButton)(nil)
)
func NewToggleButton(content text.Rich) *ToggleButton {
l := NewLabel(content)
l.Show()
b, _ := gtk.ToggleButtonNew()
primitives.BinLeftAlignLabel(b)
b.Add(l)
return &ToggleButton{*b, *l}
}
func (i *nullIcon) SetIcon(url string) { i.url = url }

View file

@ -0,0 +1,113 @@
package button
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
"github.com/diamondburned/cchat/text"
)
type ToggleButtonImage struct {
rich.ToggleButtonImage
extraMenu []menu.Item
menu *menu.LazyMenu
clicked func(bool)
err error
icon bool // whether or not the button has an icon
iconSz int
}
var _ cchat.IconContainer = (*ToggleButtonImage)(nil)
func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
b := rich.NewToggleButtonImage(content)
b.Show()
tb := &ToggleButtonImage{
ToggleButtonImage: *b,
clicked: func(bool) {},
menu: menu.NewLazyMenu(b.ToggleButton),
}
tb.Connect("clicked", func() {
tb.clicked(tb.GetActive())
})
return tb
}
func (b *ToggleButtonImage) SetClicked(clicked func(bool)) {
b.clicked = clicked
}
func (b *ToggleButtonImage) SetClickedIfTrue(clickedIfTrue func()) {
b.clicked = func(clicked bool) {
if clicked {
clickedIfTrue()
}
}
}
func (b *ToggleButtonImage) SetNormalExtraMenu(items []menu.Item) {
b.extraMenu = items
b.SetNormal()
}
func (b *ToggleButtonImage) SetNormal() {
b.SetLabelUnsafe(b.GetLabel())
b.menu.SetItems(b.extraMenu)
if b.icon {
b.Image.SetPlaceholderIcon("user-available-symbolic", b.Image.Size())
}
}
func (b *ToggleButtonImage) SetLoading() {
b.SetLabelUnsafe(b.GetLabel())
// Reset the menu.
b.menu.SetItems(b.extraMenu)
if b.icon {
b.Image.SetPlaceholderIcon("content-loading-symbolic", b.Image.Size())
}
}
func (b *ToggleButtonImage) SetFailed(err error, retry func()) {
b.Label.SetMarkup(rich.MakeRed(b.GetLabel()))
// Add a retry button, if any.
b.menu.Reset()
b.menu.AddItems(menu.SimpleItem("Retry", retry))
b.menu.AddItems(b.extraMenu...)
// If we have an icon set, then we can use the failed icon.
if b.icon {
b.Image.SetPlaceholderIcon("computer-fail-symbolic", b.Image.Size())
}
}
func (b *ToggleButtonImage) SetPlaceholderIcon(iconName string, iconSzPx int) {
b.icon = true
b.Image.SetPlaceholderIcon(iconName, iconSzPx)
}
func (b *ToggleButtonImage) SetIcon(url string) {
gts.ExecAsync(func() { b.SetIconUnsafe(url) })
}
func (b *ToggleButtonImage) SetIconUnsafe(url string) {
b.icon = true
b.Image.SetIconUnsafe(url)
}
// type Row struct {
// gtk.Box
// Button *ToggleButtonImage
// Children *gtk.Box
// }

View file

@ -2,12 +2,10 @@ package service
import (
"github.com/diamondburned/cchat"
"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/imgutil"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
const IconSize = 32
@ -40,9 +38,7 @@ func newHeader(svc cchat.Service) *header {
box.Show()
if iconer, ok := svc.(cchat.Icon); ok {
if err := iconer.Icon(reveal); err != nil {
log.Error(errors.Wrap(err, "Error getting session logo"))
}
reveal.Image.AsyncSetIconer(iconer, "Error getting session logo")
}
// Spawn the menu on right click.

View file

@ -0,0 +1,69 @@
package menu
import (
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
// LazyMenu is a menu with lazy-loaded capabilities.
type LazyMenu struct {
items []Item
}
func NewLazyMenu(bindTo primitives.Connector) *LazyMenu {
l := &LazyMenu{}
bindTo.Connect("button-press-event", l.popup)
return l
}
func (m *LazyMenu) SetItems(items []Item) {
m.items = items
}
func (m *LazyMenu) AddItems(items ...Item) {
m.items = append(m.items, items...)
}
func (m *LazyMenu) Reset() {
m.items = nil
}
func (m *LazyMenu) popup(w gtk.IWidget, ev *gdk.Event) {
// Is this a right click? Exit if not.
if !gts.EventIsRightClick(ev) {
return
}
// Do nothing if there are no menu items.
if len(m.items) == 0 {
return
}
var menu, _ = gtk.MenuNew()
for _, item := range m.items {
mb, _ := gtk.MenuItemNewWithLabel(item.Name)
mb.Connect("activate", item.Func)
mb.Show()
if item.Extra != nil {
item.Extra(mb)
}
menu.Append(mb)
}
menu.PopupAtPointer(ev)
}
type Item struct {
Name string
Func func()
Extra func(*gtk.MenuItem)
}
func SimpleItem(name string, fn func()) Item {
return Item{Name: name, Func: fn}
}

View file

@ -50,8 +50,8 @@ func (v *View) AddService(svc cchat.Service, ctrl Controller) *Container {
}
type Controller interface {
// MessageRowSelected is wrapped around session's MessageRowSelected.
MessageRowSelected(*session.Row, *server.Row, cchat.ServerMessage)
// RowSelected is wrapped around session's MessageRowSelected.
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
// AuthenticateSession is called to spawn the authentication dialog.
AuthenticateSession(*Container, cchat.Service)
// OnSessionRemove is called to remove a session. This should also clear out

View file

@ -3,11 +3,13 @@ package server
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"
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
"github.com/diamondburned/cchat-gtk/internal/ui/service/button"
"github.com/diamondburned/cchat-gtk/internal/ui/service/loading"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
@ -17,149 +19,272 @@ const ChildrenMargin = 24
const IconSize = 18
type Controller interface {
MessageRowSelected(*Row, cchat.ServerMessage)
RowSelected(*ServerRow, cchat.ServerMessage)
}
type Row struct {
*gtk.Box
Button *rich.ToggleButtonImage
Server cchat.Server
Parent breadcrumb.Breadcrumber
Button *button.ToggleButtonImage
ctrl Controller
parentcrumb breadcrumb.Breadcrumber
// enum 1
message cchat.ServerMessage
// enum 2
children *Children
children *Children
serverList cchat.ServerList
loaded bool
}
func NewRow(parent breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *Row {
button := rich.NewToggleButtonImage(server.Name())
func NewRow(parent breadcrumb.Breadcrumber, name text.Rich) *Row {
button := button.NewToggleButtonImage(name)
button.Box.SetHAlign(gtk.ALIGN_START)
button.Image.AddProcessors(imgutil.Round(true))
button.Image.SetSize(IconSize)
button.SetRelief(gtk.RELIEF_NONE)
button.Show()
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.SetMarginStart(ChildrenMargin)
box.PackStart(button, false, false, 0)
box.Show()
primitives.AddClass(box, "server")
// TODO: images
var row = &Row{
Box: box,
Button: button,
Server: server,
Parent: parent,
ctrl: ctrl,
row := &Row{
Box: box,
Button: button,
parentcrumb: parent,
}
switch server := server.(type) {
case cchat.ServerList:
row.children = NewChildren(row, ctrl)
row.children.SetServerList(server)
box.PackStart(row.children, false, false, 0)
primitives.AddClass(box, "server-list")
case cchat.ServerMessage:
row.message = server
primitives.AddClass(box, "server-message")
}
button.Connect("clicked", row.onClick)
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 (r *Row) Breadcrumb() breadcrumb.Breadcrumb {
return breadcrumb.Try(r.parentcrumb, r.Button.GetText())
}
func (row *Row) GetActive() bool {
return row.Button.GetActive()
func (r *Row) SetLabelUnsafe(name text.Rich) {
r.Button.SetLabelUnsafe(name)
}
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.
case row.children != nil:
row.children.SetRevealChild(!row.children.GetRevealChild())
func (r *Row) SetIconer(v interface{}) {
if iconer, ok := v.(cchat.Icon); ok {
r.Button.Image.AsyncSetIconer(iconer, "Error getting server icon URL")
}
}
func (r *Row) Breadcrumb() breadcrumb.Breadcrumb {
return breadcrumb.Try(r.Parent, r.Button.GetText())
// SetServerList sets the row to a server list.
func (r *Row) SetServerList(list cchat.ServerList, ctrl Controller) {
r.Button.SetClicked(func(active bool) {
r.SetRevealChild(active)
})
r.children = NewChildren(r, ctrl)
r.children.Show()
r.Box.PackStart(r.children, false, false, 0)
r.serverList = list
}
// Reset clears off all children servers. It's a no-op if there are none.
func (r *Row) Reset() {
if r.children != nil {
// Remove everything from the children container.
r.children.Reset()
// Remove the children container itself.
r.Box.Remove(r.children)
// Reset the state.
r.loaded = false
r.serverList = nil
r.children = nil
}
}
// SetLoading is called by the parent struct.
func (r *Row) SetLoading() {
r.Button.SetLoading()
r.SetSensitive(false)
}
// SetFailed is shared between the parent struct and the children list. This is
// because both of those errors share the same appearance, just different
// callbacks.
func (r *Row) SetFailed(err error, retry func()) {
r.SetSensitive(true)
r.SetTooltipText(err.Error())
r.Button.SetFailed(err, retry)
r.Button.Label.SetMarkup(rich.MakeRed(r.Button.GetLabel()))
}
// SetDone is shared between the parent struct and the children list. This is
// because both will use the same SetFailed.
func (r *Row) SetDone() {
r.Button.SetNormal()
r.SetSensitive(true)
r.SetTooltipText("")
}
func (r *Row) SetNormalExtraMenu(items []menu.Item) {
r.Button.SetNormalExtraMenu(items)
r.SetSensitive(true)
r.SetTooltipText("")
}
func (r *Row) childrenFailed(err error) {
// If the user chooses to retry, the list will automatically expand.
r.SetFailed(err, func() { r.SetRevealChild(true) })
}
func (r *Row) childrenDone() {
r.loaded = true
r.SetDone()
}
// SetSelected is used for highlighting the current message server.
func (r *Row) SetSelected(selected bool) {
// Set the clickability the opposite as the boolean.
r.Button.SetSensitive(!selected)
// Some special edge case that I forgot.
if !selected {
r.Button.SetActive(false)
}
}
func (r *Row) GetActive() bool {
return r.Button.GetActive()
}
// SetRevealChild reveals the list of servers. It does nothing if there are no
// servers, meaning if Row does not represent a ServerList.
func (r *Row) SetRevealChild(reveal bool) {
// Do the above noop check.
if r.children == nil {
return
}
// Actually reveal the children.
r.children.SetRevealChild(reveal)
// If this isn't a reveal, then we don't need to load.
if !reveal {
return
}
// If we haven't loaded yet and we're still not loading, then load.
if !r.loaded && r.children.load == nil {
r.Load()
}
}
// Load loads the row without uncollapsing it.
func (r *Row) Load() {
// Safeguard.
if r.children == nil || r.serverList == nil {
return
}
// Set that we're now loading.
r.children.setLoading()
r.SetSensitive(false)
// Load the list of servers if we're still in loading mode.
go func() {
err := r.serverList.Servers(r.children)
gts.ExecAsync(func() {
// We're not loading anymore, so remove the loading circle.
r.children.setNotLoading()
// Restore clickability.
r.SetSensitive(true)
// Use the childrenX method instead of SetX.
if err != nil {
r.childrenFailed(errors.Wrap(err, "Failed to get servers"))
} else {
r.childrenDone()
}
})
}()
}
// GetRevealChild returns whether or not the server list is expanded, or always
// false if there is no server list.
func (r *Row) GetRevealChild() bool {
if r.children != nil {
return r.children.GetRevealChild()
}
return false
}
type ServerRow struct {
*Row
Server cchat.Server
}
func NewServerRow(p breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *ServerRow {
row := NewRow(p, server.Name())
row.Show()
row.SetIconer(server)
primitives.AddClass(row, "server")
var serverRow = &ServerRow{Row: row, Server: server}
switch server := server.(type) {
case cchat.ServerList:
row.SetServerList(server, ctrl)
primitives.AddClass(row, "server-list")
case cchat.ServerMessage:
row.Button.SetClickedIfTrue(func() { ctrl.RowSelected(serverRow, server) })
primitives.AddClass(row, "server-message")
}
return serverRow
}
// Children is a children server with a reference to the parent.
type Children struct {
*gtk.Revealer
Main *gtk.Box
List cchat.ServerList
rowctrl Controller
load *loading.Button // nil after init
Rows []*Row
load *loading.Button // only not nil while loading
Rows []*ServerRow
Parent breadcrumb.Breadcrumber
}
func NewChildren(parent breadcrumb.Breadcrumber, ctrl Controller) *Children {
func NewChildren(p breadcrumb.Breadcrumber, ctrl Controller) *Children {
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
main.SetMarginStart(ChildrenMargin)
main.Show()
rev, _ := gtk.RevealerNew()
rev.SetRevealChild(false)
rev.Add(main)
rev.Show()
return &Children{
Revealer: rev,
Main: main,
rowctrl: ctrl,
Parent: parent,
Parent: p,
}
}
func (c *Children) SetLoading() {
// If we're already loading, then exit.
// setLoading shows the loading circle as a list child.
func (c *Children) setLoading() {
// Exit if we're already loading.
if c.load != nil {
return
}
// Clear everything.
c.Reset()
// Set the loading circle and stuff.
c.load = loading.NewButton()
c.load.Show()
c.Main.Add(c.load)
}
func (c *Children) Reset() {
// Do we have the spinning circle button? If yes, remove it.
if c.load != nil {
c.Main.Remove(c.load)
c.load = nil
}
// Remove old servers from the list.
for _, row := range c.Rows {
c.Main.Remove(row)
@ -169,14 +294,15 @@ func (c *Children) Reset() {
c.Rows = nil
}
func (c *Children) SetServerList(list cchat.ServerList) {
c.List = list
go func() {
if err := list.Servers(c); err != nil {
log.Error(errors.Wrap(err, "Failed to get servers"))
}
}()
// setNotLoading removes the loading circle, if any. This is not in Reset()
// anymore, since the backend may not necessarily call SetServers.
func (c *Children) setNotLoading() {
// Do we have the spinning circle button? If yes, remove it.
if c.load != nil {
// Stop the loading mode. The reset function should do everything for us.
c.Main.Remove(c.load)
c.load = nil
}
}
func (c *Children) SetServers(servers []cchat.Server) {
@ -193,10 +319,10 @@ func (c *Children) SetServers(servers []cchat.Server) {
// Reset before inserting new servers.
c.Reset()
c.Rows = make([]*Row, len(servers))
c.Rows = make([]*ServerRow, len(servers))
for i, server := range servers {
row := NewRow(c, server, c.rowctrl)
row := NewServerRow(c, server, c.rowctrl)
c.Rows[i] = row
c.Main.Add(row)
}

View file

@ -6,13 +6,10 @@ import (
"github.com/diamondburned/cchat-gtk/internal/keyring"
"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-gtk/internal/ui/service/breadcrumb"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
"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"
"github.com/pkg/errors"
)
@ -23,9 +20,9 @@ type Controller interface {
// OnSessionDisconnect is called before a session is disconnected. This
// function is used for cleanups.
OnSessionDisconnect(*Row)
// MessageRowSelected is called when a server that can display messages (aka
// RowSelected is called when a server that can display messages (aka
// implements ServerMessage) is called.
MessageRowSelected(*Row, *server.Row, cchat.ServerMessage)
RowSelected(*Row, *server.ServerRow, cchat.ServerMessage)
// RestoreSession is called with the session ID to ask the controller to
// restore it from keyring information.
RestoreSession(*Row, string) // ID string, async
@ -40,78 +37,35 @@ type Controller interface {
// Row represents a single session, including the button header and the
// children servers.
type Row struct {
*gtk.Box
Button *rich.ToggleButtonImage
Session cchat.Session
Servers *server.Children
*server.Row
Session cchat.Session
sessionID string // used for reconnection
ctrl Controller
parent breadcrumb.Breadcrumber
menuconstr func(*gtk.Menu)
sessionID string // used for reconnection
// nil after calling SetSession()
// krs keyring.Session
ctrl Controller
}
func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Row {
row := newRow(parent, ctrl)
row := newRow(parent, text.Rich{}, ctrl)
row.SetSession(ses)
return row
}
func NewLoading(parent breadcrumb.Breadcrumber, id, name string, ctrl Controller) *Row {
row := newRow(parent, ctrl)
row := newRow(parent, text.Rich{Content: name}, ctrl)
row.sessionID = id
row.Button.SetLabelUnsafe(text.Rich{Content: name})
row.setLoading()
row.Row.SetLoading()
return row
}
var dragEntries = []gtk.TargetEntry{
primitives.NewTargetEntry("GTK_TOGGLE_BUTTON"),
}
var dragAtom = gdk.GdkAtomIntern("GTK_TOGGLE_BUTTON", true)
func newRow(parent breadcrumb.Breadcrumber, name text.Rich, ctrl Controller) *Row {
// Bind the row to .session in CSS.
row := server.NewRow(parent, name)
row.Button.SetPlaceholderIcon("user-invisible-symbolic", IconSize)
row.Show()
primitives.AddClass(row, "session")
primitives.AddClass(row, "server-list")
func newRow(parent breadcrumb.Breadcrumber, ctrl Controller) *Row {
row := &Row{
ctrl: ctrl,
parent: parent,
}
row.Servers = server.NewChildren(parent, row)
row.Servers.SetLoading()
row.Button = rich.NewToggleButtonImage(text.Rich{})
row.Button.Box.SetHAlign(gtk.ALIGN_START)
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.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
row.Box.SetMarginStart(server.ChildrenMargin)
row.Box.PackStart(row.Button, false, false, 0)
row.Box.Show()
// Bind the box to .session in CSS.
primitives.AddClass(row.Box, "session")
// Bind the button to create a new menu.
primitives.BindDynamicMenu(row.Button, func(menu *gtk.Menu) {
row.menuconstr(menu)
})
// noop, empty menu
row.menuconstr = func(menu *gtk.Menu) {}
return row
return &Row{Row: row, ctrl: ctrl}
}
// RemoveSession removes itself from the session list.
@ -130,12 +84,15 @@ func (r *Row) RemoveSession() {
// ReconnectSession tries to reconnect with the keyring data. This is a slow
// method but it's also a very cold path.
func (r *Row) ReconnectSession() {
// If we haven't ever connected:
// If we haven't ever connected, then don't run. In a legitimate case, this
// shouldn't happen.
if r.sessionID == "" {
return
}
r.setLoading()
// Set the row as loading.
r.Row.SetLoading()
// Try to restore the session.
r.ctrl.RestoreSession(r, r.sessionID)
}
@ -145,8 +102,7 @@ func (r *Row) DisconnectSession() {
r.ctrl.OnSessionDisconnect(r)
// Show visually that we're disconnected first by wiping all servers.
r.Box.Remove(r.Servers)
r.Servers.Reset()
r.Reset()
// Set the offline icon to the button.
r.Button.Image.SetPlaceholderIcon("user-invisible-symbolic", IconSize)
@ -162,33 +118,18 @@ func (r *Row) DisconnectSession() {
// Disconnect and wrap the error if any. Wrap works with a nil error.
err := errors.Wrap(r.Session.Disconnect(), "Failed to disconnect.")
return func() {
// allow access to the menu
// Allow access to the menu
r.SetSensitive(true)
// set the menu to allow disconnection.
r.menuconstr = func(menu *gtk.Menu) {
primitives.AppendMenuItems(menu, []gtk.IMenuItem{
primitives.MenuItem("Connect", r.ReconnectSession),
primitives.MenuItem("Remove", r.RemoveSession),
})
}
// Set the menu to allow disconnection.
r.Button.SetNormalExtraMenu([]menu.Item{
menu.SimpleItem("Connect", r.ReconnectSession),
menu.SimpleItem("Remove", r.RemoveSession),
})
}, err
})
}
func (r *Row) setLoading() {
// set the loading icon
r.Button.Image.SetPlaceholderIcon("content-loading-symbolic", IconSize)
// set the loading icon in the servers list
r.Servers.SetLoading()
// restore the old label's color
r.Button.SetLabelUnsafe(r.Button.GetLabel())
// clear the tooltip
r.SetTooltipText("")
// blur - set the color darker
r.SetSensitive(false)
}
// KeyringSession returns a keyring session, or nil if the session cannot be
// saved.
func (r *Row) KeyringSession() *keyring.Session {
@ -200,56 +141,35 @@ func (r *Row) ID() string {
return r.sessionID
}
// SetFailed sets the initial connect status to failed. Do note that session can
// have 2 types of loading: loading the session and loading the server list.
// This one sets the former.
func (r *Row) SetFailed(err error) {
// SetFailed, but also add the callback to retry.
r.Row.SetFailed(err, r.ReconnectSession)
}
// SetSession binds the session and marks the row as ready. It extends SetDone.
func (r *Row) SetSession(ses cchat.Session) {
r.Session = ses
r.sessionID = ses.ID()
r.SetLabelUnsafe(ses.Name())
r.SetIconer(ses)
r.Servers.SetServerList(ses)
r.Box.PackStart(r.Servers, false, false, 0)
// Bind extra menu items before loading. These items won't be clickable
// during loading.
r.SetNormalExtraMenu([]menu.Item{
menu.SimpleItem("Disconnect", r.DisconnectSession),
menu.SimpleItem("Remove", r.RemoveSession),
})
r.Button.SetLabelUnsafe(ses.Name())
r.Button.Image.SetPlaceholderIcon("user-available-symbolic", IconSize)
r.SetSensitive(true)
r.SetTooltipText("") // reset
// 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")
}
// Set the menu with the disconnect button.
r.menuconstr = func(menu *gtk.Menu) {
primitives.AppendMenuItems(menu, []gtk.IMenuItem{
primitives.MenuItem("Disconnect", r.DisconnectSession),
primitives.MenuItem("Remove", r.RemoveSession),
})
}
// Preload now.
r.SetServerList(ses, r)
r.Load()
}
func (r *Row) SetFailed(err error) {
// Allow the retry button to be pressed.
r.menuconstr = func(menu *gtk.Menu) {
primitives.AppendMenuItems(menu, []gtk.IMenuItem{
primitives.MenuItem("Retry", r.ReconnectSession),
primitives.MenuItem("Remove", r.RemoveSession),
})
}
r.SetSensitive(true)
r.SetTooltipText(err.Error())
// Intentional side-effect of not changing the actual label state.
r.Button.Label.SetMarkup(rich.MakeRed(r.Button.GetLabel()))
// Set the icon to a failed one.
r.Button.Image.SetPlaceholderIcon("computer-fail-symbolic", IconSize)
}
func (r *Row) MessageRowSelected(server *server.Row, smsg cchat.ServerMessage) {
r.ctrl.MessageRowSelected(r, server, smsg)
}
func (r *Row) Breadcrumb() breadcrumb.Breadcrumb {
return breadcrumb.Try(r.parent, r.Button.GetLabel().Content)
func (r *Row) RowSelected(server *server.ServerRow, smsg cchat.ServerMessage) {
r.ctrl.RowSelected(r, server, smsg)
}
// BindMover binds with the ID stored in the parent container to be used in the

View file

@ -40,7 +40,7 @@ type App struct {
header *header
// used to keep track of what row to disconnect before switching
lastDeactivator func()
lastSelector func(bool)
}
var (
@ -85,18 +85,27 @@ func (app *App) OnSessionDisconnect(id string) {
app.OnSessionRemove(id)
}
func (app *App) MessageRowSelected(ses *session.Row, srv *server.Row, smsg cchat.ServerMessage) {
func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) {
// Is there an old row that we should deactivate?
if app.lastDeactivator != nil {
app.lastDeactivator()
if app.lastSelector != nil {
app.lastSelector(false)
}
// Set the new row.
app.lastDeactivator = srv.Deactivate
app.lastSelector = srv.SetSelected
app.lastSelector(true)
app.header.SetBreadcrumb(srv.Breadcrumb())
// Disable the server list because we don't want the user to switch around
// while we're loading.
app.window.Services.SetSensitive(false)
// Assert that server is also a list, then join the server.
app.window.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage))
app.window.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage), func() {
// Re-enable the server list.
app.window.Services.SetSensitive(true)
})
}
func (app *App) AuthenticateSession(container *service.Container, svc cchat.Service) {

View file

@ -6,9 +6,12 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui"
"github.com/diamondburned/cchat/services"
_ "github.com/diamondburned/cchat-discord"
_ "github.com/diamondburned/cchat-mock"
)
var destructor = func() {}
func main() {
gts.Main(func() gts.WindowHeaderer {
var app = ui.NewApplication()
@ -28,4 +31,6 @@ func main() {
return app
})
destructor()
}

View file

@ -3,18 +3,36 @@
package main
import (
"net/http"
"os"
"runtime"
"runtime/pprof"
_ "net/http/pprof"
_ "github.com/ianlancetaylor/cgosymbolizer"
"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"))
// go func() {
// if err := http.ListenAndServe("localhost:42069", nil); err != nil {
// log.Error(errors.Wrap(err, "Failed to start profiling HTTP server"))
// }
// }()
runtime.SetBlockProfileRate(1)
f, _ := os.Create("/tmp/cchat.pprof")
p := pprof.Lookup("block")
destructor = func() {
log.Println("==destructor==")
if err := p.WriteTo(f, 2); err != nil {
log.Println("Profile writeTo error:", err)
}
}()
f.Close()
}
}