From be88670bb66012301c18b1b8513c792438f48898 Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Wed, 17 Jun 2020 00:06:34 -0700 Subject: [PATCH] Refactored code for additional lazy loading and features --- go.mod | 8 +- go.sum | 23 ++ internal/gts/gts.go | 93 +++++- internal/gts/httputil/image.go | 75 +++-- internal/ui/messages/input/username.go | 6 +- internal/ui/messages/view.go | 25 +- internal/ui/primitives/primitives.go | 11 +- internal/ui/rich/image.go | 49 ++- internal/ui/rich/label.go | 114 +++++++ internal/ui/rich/rich.go | 98 +----- internal/ui/service/button/button.go | 113 +++++++ internal/ui/service/header.go | 6 +- internal/ui/service/menu/menu.go | 69 +++++ internal/ui/service/service.go | 4 +- internal/ui/service/session/server/server.go | 304 +++++++++++++------ internal/ui/service/session/session.go | 184 ++++------- internal/ui/ui.go | 21 +- main.go | 5 + profile.go | 30 +- 19 files changed, 834 insertions(+), 404 deletions(-) create mode 100644 internal/ui/rich/label.go create mode 100644 internal/ui/service/button/button.go create mode 100644 internal/ui/service/menu/menu.go diff --git a/go.mod b/go.mod index d4b7011..c1575b8 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3068904..4552edc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/gts/gts.go b/internal/gts/gts.go index 81f9df3..4f6bda1 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -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 +} diff --git a/internal/gts/httputil/image.go b/internal/gts/httputil/image.go index 9cc0180..72ad07a 100644 --- a/internal/gts/httputil/image.go +++ b/internal/gts/httputil/image.go @@ -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")) - } } diff --git a/internal/ui/messages/input/username.go b/internal/ui/messages/input/username.go index a85c7da..3cfab05 100644 --- a/internal/ui/messages/input/username.go +++ b/internal/ui/messages/input/username.go @@ -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") } diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index 2020e48..78422c6 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -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()) } diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index 66c5700..6ffc38e 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -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) + } } }) } diff --git a/internal/ui/rich/image.go b/internal/ui/rich/image.go index f6ad2cf..fc7a97c 100644 --- a/internal/ui/rich/image.go +++ b/internal/ui/rich/image.go @@ -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() +} diff --git a/internal/ui/rich/label.go b/internal/ui/rich/label.go new file mode 100644 index 0000000..fa296f6 --- /dev/null +++ b/internal/ui/rich/label.go @@ -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} +} diff --git a/internal/ui/rich/rich.go b/internal/ui/rich/rich.go index 1c01beb..f120668 100644 --- a/internal/ui/rich/rich.go +++ b/internal/ui/rich/rich.go @@ -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 `` + html.EscapeString(content.Content) + `` } -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 } diff --git a/internal/ui/service/button/button.go b/internal/ui/service/button/button.go new file mode 100644 index 0000000..a23d109 --- /dev/null +++ b/internal/ui/service/button/button.go @@ -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 +// } diff --git a/internal/ui/service/header.go b/internal/ui/service/header.go index ea92306..5ed03b9 100644 --- a/internal/ui/service/header.go +++ b/internal/ui/service/header.go @@ -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. diff --git a/internal/ui/service/menu/menu.go b/internal/ui/service/menu/menu.go new file mode 100644 index 0000000..f87220d --- /dev/null +++ b/internal/ui/service/menu/menu.go @@ -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} +} diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 34707d3..49048e3 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -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 diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index f2d6e64..840b95e 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -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) } diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index ea6722f..f43b7da 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -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 diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 396d2fb..e76224e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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) { diff --git a/main.go b/main.go index ad2b157..0bd62f7 100644 --- a/main.go +++ b/main.go @@ -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() } diff --git a/profile.go b/profile.go index 87f3162..0969549 100644 --- a/profile.go +++ b/profile.go @@ -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() + } }