From b852498ee4dc265796749c6285ad71f3a5fc1c40 Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Thu, 4 Jun 2020 16:00:41 -0700 Subject: [PATCH] Final commit before partial rewrite --- .gitignore | 1 + go.mod | 8 +- go.sum | 37 ++++- internal/gts/gts.go | 4 +- internal/keyring/keyring.go | 89 ++++++++++ internal/log/log.go | 4 + internal/ui/message/autoscroll/autoscroll.go | 41 +++++ internal/ui/message/compact/compact.go | 156 ++++++++++++++++++ internal/ui/message/{ => compact}/message.go | 102 ++++++++---- internal/ui/message/container.go | 74 --------- internal/ui/message/input/input.go | 115 +++++++++++-- internal/ui/message/input/keydown.go | 40 +++++ internal/ui/message/input/send.go | 53 ++++++ internal/ui/message/view.go | 61 ++++--- internal/ui/primitives/primitives.go | 19 ++- internal/ui/rich/parser/parser.go | 1 + internal/ui/rich/rich.go | 111 +++++++++++++ internal/ui/service/auth/auth.go | 96 +++++++++-- internal/ui/service/service.go | 51 ++++-- internal/ui/service/session/server/server.go | 133 --------------- .../service/session/server/server_children.go | 87 ++++++++++ .../ui/service/session/server/server_row.go | 100 +++++++++++ internal/ui/service/session/session.go | 75 +++++---- internal/ui/ui.go | 58 +++++-- main.go | 1 - 25 files changed, 1152 insertions(+), 365 deletions(-) create mode 100644 .gitignore create mode 100644 internal/keyring/keyring.go create mode 100644 internal/ui/message/autoscroll/autoscroll.go create mode 100644 internal/ui/message/compact/compact.go rename internal/ui/message/{ => compact}/message.go (59%) delete mode 100644 internal/ui/message/container.go create mode 100644 internal/ui/message/input/keydown.go create mode 100644 internal/ui/message/input/send.go create mode 100644 internal/ui/rich/parser/parser.go create mode 100644 internal/ui/rich/rich.go delete mode 100644 internal/ui/service/session/server/server.go create mode 100644 internal/ui/service/session/server/server_children.go create mode 100644 internal/ui/service/session/server/server_row.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7eee852 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +cchat-gtk diff --git a/go.mod b/go.mod index 0b9c612..47958bf 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,16 @@ module github.com/diamondburned/cchat-gtk go 1.14 +replace github.com/diamondburned/cchat-mock => ../cchat-mock/ + require ( github.com/Xuanwo/go-locale v0.2.0 github.com/davecgh/go-spew v1.1.1 // indirect - github.com/diamondburned/cchat v0.0.9 - github.com/diamondburned/cchat-mock v0.0.0-20200525222906-807afeffb7d4 + github.com/diamondburned/cchat v0.0.15 + github.com/diamondburned/cchat-mock v0.0.0-20200604043646-de5384bd320d github.com/goodsign/monday v1.0.0 github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194 github.com/pkg/errors v0.9.1 + github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 + golang.org/x/tools v0.0.0-20200529172331-a64b76657301 // indirect ) diff --git a/go.sum b/go.sum index e764834..4a71b48 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,23 @@ github.com/Pallinder/go-randomdata v1.2.0 h1:DZ41wBchNRb/0GfsePLiSwb0PHZmT67XY00 github.com/Pallinder/go-randomdata v1.2.0/go.mod h1:yHmJgulpD2Nfrm0cR9tI/+oAgRqCQQixsA8HyRZfV9Y= github.com/Xuanwo/go-locale v0.2.0 h1:1N8SGG2VNpLl6VVa8ueZm3Nm+dxvk8ffY9aviKHl4IE= github.com/Xuanwo/go-locale v0.2.0/go.mod h1:6qbT9M726OJgyiGZro2YwPmx63wQzlH+VvtjJWQoftw= +github.com/danieljoos/wincred v1.0.2 h1:zf4bhty2iLuwgjgpraD2E9UbvO+fe54XXGJbOwe23fU= +github.com/danieljoos/wincred v1.0.2/go.mod h1:SnuYRW9lp1oJrZX/dXJqr0cPK5gYXqx3EJbmjhLdK9U= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/diamondburned/cchat v0.0.9 h1:+F96eDDuaOg4v4dz3GBDWbEW4dZ/k5uGrDp33/yeXR8= -github.com/diamondburned/cchat v0.0.9/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat-mock v0.0.0-20200525222906-807afeffb7d4 h1:k6vfDs6NR8yIi2V7YEpNp9Vujtl6mpDL5cWh7Pg09kk= -github.com/diamondburned/cchat-mock v0.0.0-20200525222906-807afeffb7d4/go.mod h1:mOf8RsTQUOf9qJ1Z/OKbsMKnCMtA2gRexH12fc9LxiQ= +github.com/diamondburned/cchat v0.0.10 h1:aiUVgGre5E/HV+Iw6tmBVbuGctQI+JndV9nIDoYuRPY= +github.com/diamondburned/cchat v0.0.10/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.13 h1:p8SyFjiRVCTjvwSJ4FsICGVYVZ3g0Iu02FrwmLuKiKE= +github.com/diamondburned/cchat v0.0.13/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.15 h1:1o4OX8zw/CdSv3Idaylz7vjHVOZKEi/xkg8BpEvtsHY= +github.com/diamondburned/cchat v0.0.15/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat-mock v0.0.0-20200529184140-47fa2491d2fc h1:xYSN3re1QOd5af5zG15pLKfBM+fergw7Rg62UHmE22g= +github.com/diamondburned/cchat-mock v0.0.0-20200529184140-47fa2491d2fc/go.mod h1:rQm5EKhNyBRYHKtirSkf+Db23nr3mTs2bnOThfTfzec= github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= +github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/goodsign/monday v1.0.0 h1:Yyk/s/WgudMbAJN6UWSU5xAs8jtNewfqtVblAlw0yoc= github.com/goodsign/monday v1.0.0/go.mod h1:r4T4breXpoFwspQNM+u2sLxJb2zyTaxVGqUfTBjWOu8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= @@ -27,17 +35,38 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/yuin/goldmark v1.1.27/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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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/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= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200529172331-a64b76657301 h1:G6CNEgFU8/XwexSnuFw+Jq/WePjRitgy6ofBcPnAIPo= +golang.org/x/tools v0.0.0-20200529172331-a64b76657301/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= diff --git a/internal/gts/gts.go b/internal/gts/gts.go index dbe5c46..eee4302 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -27,7 +27,7 @@ func init() { }, } - App.Application, _ = gtk.ApplicationNew("com.github.diamondburned.gtkcord3", 0) + App.Application, _ = gtk.ApplicationNew("com.github.diamondburned.cchat-gtk", 0) } type Windower interface { @@ -52,7 +52,7 @@ func Main(wfn func() WindowHeaderer) { App.Header.Show() App.Window, _ = gtk.ApplicationWindowNew(App.Application) - App.Window.SetDefaultSize(750, 400) + App.Window.SetDefaultSize(1000, 500) App.Window.SetTitlebar(App.Header) App.Window.Show() diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 0000000..2d86319 --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,89 @@ +package keyring + +import ( + "bytes" + "encoding/gob" + "strings" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/pkg/errors" + "github.com/zalando/go-keyring" +) + +func getThenDestroy(service string, v interface{}) error { + s, err := keyring.Get("cchat-gtk", service) + if err != nil { + return err + } + + // Deleting immediately does not work on a successful start-up. + // keyring.Delete("cchat-gtk", service) + + return gob.NewDecoder(strings.NewReader(s)).Decode(v) +} + +func set(service string, v interface{}) error { + var b bytes.Buffer + if err := gob.NewEncoder(&b).Encode(v); err != nil { + return err + } + + return keyring.Set("cchat-gtk", service, b.String()) +} + +func SaveSessions(service cchat.Service, sessions []cchat.Session) (saveErrs []error) { + var sessionData = make([]map[string]string, 0, len(sessions)) + + for _, session := range sessions { + sv, ok := session.(cchat.SessionSaver) + if !ok { + continue + } + + d, err := sv.Save() + if err != nil { + saveErrs = append(saveErrs, err) + continue + } + + sessionData = append(sessionData, d) + } + + if err := set(service.Name(), sessionData); err != nil { + log.Warn(errors.Wrap(err, "Error saving session")) + saveErrs = append(saveErrs, err) + } + + return +} + +// RestoreSessions restores all sessions of the service asynchronously, then +// calls the auth callback inside the Gtk main thread. +func RestoreSessions(service cchat.Service, auth func(cchat.Session)) { + // If the service doesn't support restoring, treat it as a non-error. + restorer, ok := service.(cchat.SessionRestorer) + if !ok { + return + } + + var sessionData []map[string]string + + // Ignore the error, it's not important. + if err := getThenDestroy(service.Name(), &sessionData); err != nil { + log.Warn(err) + return + } + + for _, data := range sessionData { + gts.Async(func() (func(), error) { + s, err := restorer.RestoreSession(data) + if err != nil { + return nil, errors.Wrap(err, "Failed to restore") + } + + return func() { auth(s) }, nil + }) + } +} diff --git a/internal/log/log.go b/internal/log/log.go index d45a915..e94c22e 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -37,6 +37,10 @@ func Error(err error) { Write("Error: " + err.Error()) } +func Warn(err error) { + Write("Warn: " + err.Error()) +} + func Write(msg string) { WriteEntry(Entry{ Time: time.Now(), diff --git a/internal/ui/message/autoscroll/autoscroll.go b/internal/ui/message/autoscroll/autoscroll.go new file mode 100644 index 0000000..1efbdc4 --- /dev/null +++ b/internal/ui/message/autoscroll/autoscroll.go @@ -0,0 +1,41 @@ +package autoscroll + +import "github.com/gotk3/gotk3/gtk" + +type ScrolledWindow struct { + gtk.ScrolledWindow + vadj gtk.Adjustment + bottomed bool // :floshed: +} + +func NewScrolledWindow() *ScrolledWindow { + gtksw, _ := gtk.ScrolledWindowNew(nil, nil) + gtksw.SetProperty("propagate-natural-height", true) + + sw := &ScrolledWindow{*gtksw, *gtksw.GetVAdjustment(), true} // bottomed by default + sw.Connect("size-allocate", func(_ *gtk.ScrolledWindow) { + // We can't really trust Gtk to be competent. + if sw.bottomed { + sw.ScrollToBottom() + } + }) + sw.vadj.Connect("value-changed", func(adj *gtk.Adjustment) { + // Manually check if we're anchored on scroll. + sw.bottomed = (adj.GetUpper() - adj.GetPageSize()) <= adj.GetValue() + }) + + return sw +} + +func (s *ScrolledWindow) Bottomed() bool { + return s.bottomed +} + +// GetVAdjustment overrides gtk.ScrolledWindow's. +func (s *ScrolledWindow) GetVAdjustment() *gtk.Adjustment { + return &s.vadj +} + +func (s *ScrolledWindow) ScrollToBottom() { + s.vadj.SetValue(s.vadj.GetUpper()) +} diff --git a/internal/ui/message/compact/compact.go b/internal/ui/message/compact/compact.go new file mode 100644 index 0000000..6ca8a41 --- /dev/null +++ b/internal/ui/message/compact/compact.go @@ -0,0 +1,156 @@ +package compact + +import ( + "fmt" + "html" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/ui/message/autoscroll" + "github.com/diamondburned/cchat-gtk/internal/ui/message/input" + "github.com/gotk3/gotk3/gtk" +) + +type Container struct { + *autoscroll.ScrolledWindow + main *gtk.Grid + messages map[string]*Message + nonceMsgs map[string]*Message + + bottomed bool +} + +func NewContainer() *Container { + grid, _ := gtk.GridNew() + grid.SetColumnSpacing(10) + grid.SetRowSpacing(5) + grid.SetMarginStart(5) + grid.SetMarginEnd(5) + grid.SetMarginBottom(5) + grid.Show() + + sw := autoscroll.NewScrolledWindow() + sw.Add(grid) + sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) + sw.Show() + + container := Container{ + ScrolledWindow: sw, + main: grid, + messages: map[string]*Message{}, + nonceMsgs: map[string]*Message{}, + bottomed: true, // bottomed by default. + } + + return &container +} + +func (c *Container) Reset() { + // does this actually work? + var rows = c.len() + for i := 0; i < rows; i++ { + c.main.RemoveRow(i) + } + + c.messages = map[string]*Message{} + c.nonceMsgs = map[string]*Message{} + + // default to being bottomed + c.bottomed = true +} + +func (c *Container) len() int { + return len(c.messages) + len(c.nonceMsgs) +} + +// PresendMessage is not thread-safe. +func (c *Container) PresendMessage(msg input.PresendMessage) func(error) { + msgc := NewPresendMessage(msg.Content(), msg.Author(), msg.AuthorID(), msg.Nonce()) + msgc.index = c.len() + + c.nonceMsgs[msgc.Nonce] = &msgc + msgc.Attach(c.main, msgc.index) + + return func(err error) { + msgc.SetSensitive(true) + + // Did we fail? + if err != nil { + msgc.Content.SetMarkup(fmt.Sprintf( + `%s`, + html.EscapeString(msgc.Content.GetLabel()), + )) + msgc.Content.SetTooltipText(err.Error()) + } + } +} + +// FindMessage is not thread-safe. +func (c *Container) FindMessage(msg cchat.MessageHeader) *Message { + // Search using the ID first. + m, ok := c.messages[msg.ID()] + if ok { + return m + } + + // Is this an existing message? + if noncer, ok := msg.(cchat.MessageNonce); ok { + var nonce = noncer.Nonce() + + m, ok := c.nonceMsgs[nonce] + if ok { + // Move the message outside nonceMsgs. + delete(c.nonceMsgs, nonce) + c.messages[msg.ID()] = m + + // Set the right ID. + m.ID = msg.ID() + + return m + } + } + + return nil +} + +func (c *Container) CreateMessage(msg cchat.MessageCreate) { + gts.ExecAsync(func() { + // Attempt update before insert (aka upsert). + if msgc := c.FindMessage(msg); msgc != nil { + msgc.SetSensitive(true) + msgc.UpdateAuthor(msg.Author()) + msgc.UpdateContent(msg.Content()) + msgc.UpdateTimestamp(msg.Time()) + return + } + + msgc := NewMessage(msg) + msgc.index = c.len() // unsure + + c.messages[msgc.ID] = &msgc + msgc.Attach(c.main, msgc.index) + }) +} + +func (c *Container) UpdateMessage(msg cchat.MessageUpdate) { + gts.ExecAsync(func() { + if msgc := c.FindMessage(msg); msgc != nil { + if author := msg.Author(); author != nil { + msgc.UpdateAuthor(author) + } + if content := msg.Content(); !content.Empty() { + msgc.UpdateContent(content) + } + } + }) +} + +func (c *Container) DeleteMessage(msg cchat.MessageDelete) { + gts.ExecAsync(func() { + // TODO: add nonce check. + if m, ok := c.messages[msg.ID()]; ok { + delete(c.messages, msg.ID()) + c.main.RemoveRow(m.index) + } + }) +} diff --git a/internal/ui/message/message.go b/internal/ui/message/compact/message.go similarity index 59% rename from internal/ui/message/message.go rename to internal/ui/message/compact/message.go index 0919477..b45cff8 100644 --- a/internal/ui/message/message.go +++ b/internal/ui/message/compact/message.go @@ -1,4 +1,4 @@ -package message +package compact import ( "time" @@ -11,9 +11,10 @@ import ( ) type Message struct { - index int - ID string - Nonce string + index int + ID string + AuthorID string + Nonce string Timestamp *gtk.Label Username *gtk.Label @@ -21,34 +22,8 @@ type Message struct { } func NewMessage(msg cchat.MessageCreate) Message { - ts, _ := gtk.LabelNew("") - ts.SetLineWrap(true) - ts.SetLineWrapMode(pango.WRAP_WORD) - ts.SetHAlign(gtk.ALIGN_END) - ts.SetVAlign(gtk.ALIGN_START) - ts.Show() - - user, _ := gtk.LabelNew("") - user.SetLineWrap(true) - user.SetLineWrapMode(pango.WRAP_WORD_CHAR) - user.SetHAlign(gtk.ALIGN_END) - user.SetVAlign(gtk.ALIGN_START) - user.Show() - - content, _ := gtk.LabelNew("") - content.SetHExpand(true) - content.SetXAlign(0) // left-align with size filled - content.SetVAlign(gtk.ALIGN_START) - content.SetLineWrap(true) - content.SetLineWrapMode(pango.WRAP_WORD_CHAR) - content.Show() - - m := Message{ - ID: msg.ID(), - Timestamp: ts, - Username: user, - Content: content, - } + m := NewEmptyMessage() + m.ID = msg.ID() m.UpdateTimestamp(msg.Time()) m.UpdateAuthor(msg.Author()) m.UpdateContent(msg.Content()) @@ -60,6 +35,58 @@ func NewMessage(msg cchat.MessageCreate) Message { return m } +func NewPresendMessage(content string, author text.Rich, authorID, nonce string) Message { + msgc := NewEmptyMessage() + msgc.Nonce = nonce + msgc.AuthorID = authorID + msgc.SetSensitive(false) + msgc.UpdateContent(text.Rich{Content: content}) + msgc.UpdateTimestamp(time.Now()) + msgc.updateAuthorName(author) + + return msgc +} + +func NewEmptyMessage() Message { + ts, _ := gtk.LabelNew("") + ts.SetLineWrap(true) + ts.SetLineWrapMode(pango.WRAP_WORD) + ts.SetHAlign(gtk.ALIGN_END) + ts.SetVAlign(gtk.ALIGN_START) + ts.SetSelectable(true) + ts.Show() + + user, _ := gtk.LabelNew("") + user.SetMaxWidthChars(35) + user.SetLineWrap(true) + user.SetLineWrapMode(pango.WRAP_WORD_CHAR) + user.SetHAlign(gtk.ALIGN_END) + user.SetVAlign(gtk.ALIGN_START) + user.SetSelectable(true) + user.Show() + + content, _ := gtk.LabelNew("") + content.SetHExpand(true) + content.SetXAlign(0) // left-align with size filled + content.SetVAlign(gtk.ALIGN_START) + content.SetLineWrap(true) + content.SetLineWrapMode(pango.WRAP_WORD_CHAR) + content.SetSelectable(true) + content.Show() + + return Message{ + Timestamp: ts, + Username: user, + Content: content, + } +} + +func (m *Message) SetSensitive(sensitive bool) { + m.Timestamp.SetSensitive(sensitive) + m.Username.SetSensitive(sensitive) + m.Content.SetSensitive(sensitive) +} + func (m *Message) Attach(grid *gtk.Grid, row int) { grid.Attach(m.Timestamp, 0, row, 1, 1) grid.Attach(m.Username, 1, row, 1, 1) @@ -68,10 +95,17 @@ func (m *Message) Attach(grid *gtk.Grid, row int) { func (m *Message) UpdateTimestamp(t time.Time) { m.Timestamp.SetLabel(humanize.TimeAgo(t)) + m.Timestamp.SetTooltipText(t.Format(time.Stamp)) } -func (m *Message) UpdateAuthor(author text.Rich) { - m.Username.SetLabel(author.Content) +func (m *Message) UpdateAuthor(author cchat.MessageAuthor) { + m.AuthorID = author.ID() + m.updateAuthorName(author.Name()) +} + +func (m *Message) updateAuthorName(name text.Rich) { + m.Username.SetLabel(name.Content) + m.Username.SetTooltipText(name.Content) } func (m *Message) UpdateContent(content text.Rich) { diff --git a/internal/ui/message/container.go b/internal/ui/message/container.go deleted file mode 100644 index f637220..0000000 --- a/internal/ui/message/container.go +++ /dev/null @@ -1,74 +0,0 @@ -package message - -import ( - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/gts" - "github.com/gotk3/gotk3/gtk" -) - -type Container struct { - *gtk.ScrolledWindow - main *gtk.Grid - messages map[string]Message -} - -func NewContainer() *Container { - grid, _ := gtk.GridNew() - grid.SetColumnSpacing(8) - grid.SetRowSpacing(5) - grid.SetMarginStart(5) - grid.SetMarginEnd(5) - grid.Show() - - sw, _ := gtk.ScrolledWindowNew(nil, nil) - sw.Add(grid) - sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) - sw.Show() - - return &Container{sw, grid, map[string]Message{}} -} - -func (c *Container) Reset() { - // does this actually work? - var rows = len(c.messages) - for i := 0; i < rows; i++ { - c.main.RemoveRow(i) - } - - c.messages = nil -} - -func (c *Container) CreateMessage(msg cchat.MessageCreate) { - gts.ExecAsync(func() { - msgc := NewMessage(msg) - msgc.index = len(c.messages) // unsure - - c.messages[msgc.ID] = msgc - msgc.Attach(c.main, msgc.index) - }) -} - -func (c *Container) UpdateMessage(msg cchat.MessageUpdate) { - gts.ExecAsync(func() { - mc, ok := c.messages[msg.ID()] - if !ok { - return - } - - if author := msg.Author(); !author.Empty() { - mc.UpdateAuthor(author) - } - if content := msg.Content(); !content.Empty() { - mc.UpdateContent(content) - } - }) -} - -func (c *Container) DeleteMessage(msg cchat.MessageDelete) { - gts.ExecAsync(func() { - if m, ok := c.messages[msg.ID()]; ok { - delete(c.messages, msg.ID()) - c.main.RemoveRow(m.index) - } - }) -} diff --git a/internal/ui/message/input/input.go b/internal/ui/message/input/input.go index 2ae2bd2..041a569 100644 --- a/internal/ui/message/input/input.go +++ b/internal/ui/message/input/input.go @@ -2,42 +2,115 @@ package input import ( "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) type Field struct { - *gtk.ScrolledWindow - text *gtk.TextView - buffer *gtk.TextBuffer + *gtk.Box + + namerev *gtk.Revealer + username *rich.Label // TODO + + TextScroll *gtk.ScrolledWindow + text *gtk.TextView + buffer *gtk.TextBuffer + + UserID string sender cchat.ServerMessageSender + ctrl Controller } -func NewField() *Field { +type Controller interface { + PresendMessage(msg PresendMessage) (onErr func(error)) +} + +const inputmargin = 3 + +func NewField(ctrl Controller) *Field { + username := rich.NewLabel(text.Rich{}) + username.SetMaxWidthChars(35) + username.SetVAlign(gtk.ALIGN_START) + username.SetMarginTop(inputmargin) + username.SetMarginBottom(inputmargin) + username.SetMarginStart(10) + username.SetMarginEnd(10) + username.Show() + + namerev, _ := gtk.RevealerNew() + namerev.SetRevealChild(false) + namerev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) + namerev.SetTransitionDuration(50) + namerev.Add(username) + namerev.Show() + text, _ := gtk.TextViewNew() + text.SetSensitive(false) + text.SetWrapMode(gtk.WRAP_WORD_CHAR) + text.SetProperty("top-margin", inputmargin) + text.SetProperty("left-margin", inputmargin) + text.SetProperty("right-margin", inputmargin) + text.SetProperty("bottom-margin", inputmargin) text.Show() buf, _ := text.GetBuffer() sw, _ := gtk.ScrolledWindowNew(nil, nil) - sw.Show() - sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) - sw.SetProperty("max-content-height", 150) sw.Add(text) + sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) + sw.SetProperty("propagate-natural-height", true) + sw.SetProperty("max-content-height", 150) + sw.Show() - return &Field{ - sw, - text, - buf, - nil, + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box.PackStart(namerev, false, false, 0) + box.PackStart(sw, true, true, 0) + box.Show() + + field := &Field{ + Box: box, + namerev: namerev, + username: username, + TextScroll: sw, + text: text, + buffer: buf, + ctrl: ctrl, } + + text.SetFocusHAdjustment(sw.GetHAdjustment()) + text.SetFocusVAdjustment(sw.GetVAdjustment()) + text.Connect("key-press-event", field.keyDown) + + return field } // SetSender changes the sender of the input field. If nil, the input will be // disabled. -func (f *Field) SetSender(sender cchat.ServerMessageSender) { +func (f *Field) SetSender(session cchat.Session, sender cchat.ServerMessageSender) { + f.UserID = session.ID() + + // Does sender (aka Server) implement ServerNickname? + var err error + if nicknamer, ok := sender.(cchat.ServerNickname); ok { + err = errors.Wrap(nicknamer.Nickname(f.username), "Failed to get nickname") + } else { + err = errors.Wrap(session.Name(f.username), "Failed to get username") + } + + // Do a bit of trivial error handling. + if err != nil { + log.Warn(err) + } + + // Reveal if the name is not empty. + f.namerev.SetRevealChild(!f.username.GetLabel().Empty()) + + // Set the sender. f.sender = sender f.text.SetSensitive(sender != nil) // grey if sender is nil @@ -58,18 +131,24 @@ func (f *Field) SendMessage() { } var sender = f.sender + var data = NewSendMessageData(text, f.username.GetLabel(), f.UserID) + + // presend message into the container through the controller + var done = f.ctrl.PresendMessage(data) go func() { - if err := sender.SendMessage(SendMessageData(text)); err != nil { + err := sender.SendMessage(data) + + gts.ExecAsync(func() { + done(err) + }) + + if err != nil { log.Error(errors.Wrap(err, "Failed to send message")) } }() } -type SendMessageData string - -func (s SendMessageData) Content() string { return string(s) } - // yankText cuts the text from the input field and returns it. func (f *Field) yankText() string { start, end := f.buffer.GetBounds() diff --git a/internal/ui/message/input/keydown.go b/internal/ui/message/input/keydown.go new file mode 100644 index 0000000..f86a565 --- /dev/null +++ b/internal/ui/message/input/keydown.go @@ -0,0 +1,40 @@ +package input + +import ( + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" +) + +const shiftMask = uint(gdk.SHIFT_MASK) +const cntrlMask = uint(gdk.CONTROL_MASK) + +func bithas(bit, has uint) bool { + return bit&has == has +} + +func convEvent(ev *gdk.Event) (key, mask uint) { + var keyEvent = gdk.EventKeyNewFromEvent(ev) + return keyEvent.KeyVal(), keyEvent.State() +} + +// connects to key-press-event +func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool { + var key, mask = convEvent(ev) + + switch key { + // If Enter is pressed. + case gdk.KEY_Return: + // If Shift is being held, insert a new line. + if bithas(mask, shiftMask) { + f.buffer.InsertAtCursor("\n") + return true + } + + // Else, send the message. + f.SendMessage() + return true + } + + // Passthrough. + return false +} diff --git a/internal/ui/message/input/send.go b/internal/ui/message/input/send.go new file mode 100644 index 0000000..e7ea574 --- /dev/null +++ b/internal/ui/message/input/send.go @@ -0,0 +1,53 @@ +package input + +import ( + "strconv" + "time" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat/text" +) + +type SendMessageData struct { + content string + author text.Rich + authorID string + nonce string +} + +type PresendMessage interface { + cchat.SendableMessage + cchat.MessageNonce + + Author() text.Rich + AuthorID() string +} + +var ( + _ cchat.SendableMessage = (*SendMessageData)(nil) + _ cchat.MessageNonce = (*SendMessageData)(nil) +) + +func NewSendMessageData(content string, author text.Rich, authorID string) SendMessageData { + return SendMessageData{ + content: content, + author: author, + nonce: "cchat-gtk_" + strconv.FormatInt(time.Now().UnixNano(), 10), + } +} + +func (s SendMessageData) Content() string { + return s.content +} + +func (s SendMessageData) Author() text.Rich { + return s.author +} + +func (s SendMessageData) AuthorID() string { + return s.authorID +} + +func (s SendMessageData) Nonce() string { + return s.nonce +} diff --git a/internal/ui/message/view.go b/internal/ui/message/view.go index df3bb1f..9ee1cf5 100644 --- a/internal/ui/message/view.go +++ b/internal/ui/message/view.go @@ -3,40 +3,55 @@ package message import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/ui/message/compact" "github.com/diamondburned/cchat-gtk/internal/ui/message/input" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) +type Container interface { + gtk.IWidget + cchat.MessagesContainer + + Reset() + ScrollToBottom() + + // PresendMessage is for unsent messages. + PresendMessage(input.PresendMessage) (done func(sendError error)) +} + type View struct { *gtk.Box - Container *Container + Container Container SendInput *input.Field current cchat.ServerMessage + author string } func NewView() *View { - container := NewContainer() - sendinput := input.NewField() + view := &View{} - box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - box.PackStart(container, true, true, 0) - box.PackStart(sendinput, false, false, 0) - box.Show() + view.Container = compact.NewContainer() + view.SendInput = input.NewField(view) - return &View{ - Box: box, - Container: container, - SendInput: sendinput, - } + view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + view.Box.PackStart(view.Container, true, true, 0) + view.Box.PackStart(view.SendInput, false, false, 0) + view.Box.Show() + + return view } -func (v *View) JoinServer(server cchat.ServerMessage) { +// JoinServer is not thread-safe, but it calls backend functions asynchronously. +func (v *View) JoinServer(session cchat.Session, server cchat.ServerMessage) { if v.current != nil { - if err := v.current.LeaveServer(); err != nil { - log.Error(errors.Wrap(err, "Error leaving server")) - } + // Backend should handle synchronizing joins and leaves if it needs to. + go func() { + if err := v.current.LeaveServer(); err != nil { + log.Error(errors.Wrap(err, "Error leaving server")) + } + }() // Clean all messages. v.Container.Reset() @@ -47,9 +62,15 @@ func (v *View) JoinServer(server cchat.ServerMessage) { // Skipping ok check because sender can be nil. Without the empty check, Go // will panic. sender, _ := server.(cchat.ServerMessageSender) - v.SendInput.SetSender(sender) + v.SendInput.SetSender(session, sender) - if err := v.current.JoinServer(v.Container); err != nil { - log.Error(errors.Wrap(err, "Failed to join server")) - } + go func() { + if err := v.current.JoinServer(v.Container); err != nil { + log.Error(errors.Wrap(err, "Failed to join server")) + } + }() +} + +func (v *View) PresendMessage(msg input.PresendMessage) func(error) { + return v.Container.PresendMessage(msg) } diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index 4b3c819..080625c 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -21,5 +21,22 @@ var _ Bin = (*gtk.Bin)(nil) func BinLeftAlignLabel(bin Bin) { widget, _ := bin.GetChild() - widget.(interface{ SetXAlign(float64) }).SetXAlign(0) + widget.(interface{ SetHAlign(gtk.Align) }).SetHAlign(gtk.ALIGN_START) +} + +func NewButtonIcon(icon string) *gtk.Image { + img, _ := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_BUTTON) + return img +} + +func NewImageIconPx(icon string, sizepx int) *gtk.Image { + img, _ := gtk.ImageNew() + SetImageIcon(img, icon, sizepx) + return img +} + +func SetImageIcon(img *gtk.Image, icon string, sizepx int) { + img.SetProperty("icon-name", icon) + img.SetProperty("pixel-size", sizepx) + img.SetSizeRequest(sizepx, sizepx) } diff --git a/internal/ui/rich/parser/parser.go b/internal/ui/rich/parser/parser.go new file mode 100644 index 0000000..0bfe2c2 --- /dev/null +++ b/internal/ui/rich/parser/parser.go @@ -0,0 +1 @@ +package parser diff --git a/internal/ui/rich/rich.go b/internal/ui/rich/rich.go new file mode 100644 index 0000000..e1eba80 --- /dev/null +++ b/internal/ui/rich/rich.go @@ -0,0 +1,111 @@ +package rich + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat/text" + "github.com/gotk3/gotk3/gtk" +) + +// TODO: parser + +type Labeler interface { + cchat.LabelContainer // thread-safe + GetLabel() text.Rich // not thread-safe +} + +type Label struct { + gtk.Label + current text.Rich +} + +var ( + _ gtk.IWidget = (*Label)(nil) + _ Labeler = (*Label)(nil) +) + +func NewLabel(content text.Rich) *Label { + label, _ := gtk.LabelNew(content.Content) + label.SetHAlign(gtk.ALIGN_START) + return &Label{*label, content} +} + +// SetLabel is thread-safe. +func (l *Label) SetLabel(content text.Rich) { + gts.ExecAsync(func() { + l.current = content + l.SetText(content.Content) + }) +} + +// GetLabel is NOT thread-safe. +func (l *Label) GetLabel() text.Rich { + return l.current +} + +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} +} + +type ToggleButtonImage struct { + gtk.ToggleButton + Labeler + + Label gtk.Label + Image gtk.Image + + Box gtk.Box +} + +var ( + _ gtk.IWidget = (*ToggleButton)(nil) + _ cchat.LabelContainer = (*ToggleButton)(nil) +) + +func NewToggleButtonImage(content text.Rich, iconName string) *ToggleButtonImage { + l := NewLabel(content) + l.Show() + + var i *gtk.Image + if iconName != "" { + i, _ = gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_BUTTON) + } else { + i, _ = gtk.ImageNew() + } + i.Show() + + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box.PackStart(i, false, false, 0) + box.PackStart(l, true, true, 5) + box.Show() + + b, _ := gtk.ToggleButtonNew() + b.Add(box) + + return &ToggleButtonImage{ + ToggleButton: *b, + Labeler: l, // easy inheritance of methods + Label: l.Label, + Image: *i, + Box: *box, + } +} diff --git a/internal/ui/service/auth/auth.go b/internal/ui/service/auth/auth.go index 52ea33b..37b6466 100644 --- a/internal/ui/service/auth/auth.go +++ b/internal/ui/service/auth/auth.go @@ -7,7 +7,6 @@ import ( "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/ui/dialog" "github.com/gotk3/gotk3/gtk" - "github.com/gotk3/gotk3/pango" ) type Dialog struct { @@ -116,19 +115,20 @@ func (d *Dialog) ok() { type Request struct { *gtk.Grid labels []*gtk.Label - entries []*gtk.Entry + entries []Texter } func NewRequest(authEntries []cchat.AuthenticateEntry) *Request { grid, _ := gtk.GridNew() grid.Show() - grid.SetRowHomogeneous(true) - grid.SetRowSpacing(2) + grid.SetRowSpacing(7) + grid.SetColumnHomogeneous(true) + grid.SetColumnSpacing(5) req := &Request{ Grid: grid, labels: make([]*gtk.Label, len(authEntries)), - entries: make([]*gtk.Entry, len(authEntries)), + entries: make([]Texter, len(authEntries)), } for i, authEntry := range authEntries { @@ -147,29 +147,91 @@ func NewRequest(authEntries []cchat.AuthenticateEntry) *Request { func (r *Request) values() []string { var values = make([]string, len(r.entries)) for i, entry := range r.entries { - values[i], _ = entry.GetText() + values[i] = entry.GetText() } return values } -func newEntry(authEntry cchat.AuthenticateEntry) (*gtk.Label, *gtk.Entry) { +func newEntry(authEntry cchat.AuthenticateEntry) (*gtk.Label, Texter) { label, _ := gtk.LabelNew(authEntry.Name) label.Show() label.SetXAlign(1) // right align - label.SetEllipsize(pango.ELLIPSIZE_END) + label.SetJustify(gtk.JUSTIFY_RIGHT) + label.SetLineWrap(true) - input, _ := gtk.EntryNew() - input.Show() + var texter Texter - if authEntry.Secret { - input.SetInputPurpose(gtk.INPUT_PURPOSE_PASSWORD) - input.SetVisibility(false) - input.SetInvisibleChar('●') + if authEntry.Multiline { + texter = NewMultilineInput() } else { - // usually; this is just an assumption - input.SetInputPurpose(gtk.INPUT_PURPOSE_EMAIL) + var input = NewEntryInput() + if authEntry.Secret { + input.SetInputPurpose(gtk.INPUT_PURPOSE_PASSWORD) + input.SetVisibility(false) + input.SetInvisibleChar('●') + } else { + // usually; this is just an assumption + input.SetInputPurpose(gtk.INPUT_PURPOSE_EMAIL) + } + + texter = input } - return label, input + return label, texter +} + +type Texter interface { + gtk.IWidget + GetText() string + SetText(string) +} + +type EntryInput struct { + *gtk.Entry +} + +var _ Texter = (*EntryInput)(nil) + +func NewEntryInput() EntryInput { + input, _ := gtk.EntryNew() + input.SetVAlign(gtk.ALIGN_CENTER) + input.Show() + + return EntryInput{ + input, + } +} + +func (i EntryInput) GetText() (text string) { + text, _ = i.Entry.GetText() + return +} + +type MultilineInput struct { + *gtk.TextView + Buffer *gtk.TextBuffer +} + +var _ Texter = (*MultilineInput)(nil) + +func NewMultilineInput() MultilineInput { + view, _ := gtk.TextViewNew() + view.SetWrapMode(gtk.WRAP_WORD_CHAR) + view.SetEditable(true) + view.Show() + + buf, _ := view.GetBuffer() + + return MultilineInput{view, buf} +} + +func (i MultilineInput) GetText() (text string) { + start, end := i.Buffer.GetBounds() + text, _ = i.Buffer.GetText(start, end, true) + return +} + +func (i MultilineInput) SetText(text string) { + i.Buffer.SetText(text) } diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 31364cd..72874eb 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -3,12 +3,19 @@ package service import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" - "github.com/diamondburned/cchat-gtk/internal/ui/service/auth" + "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/service/session" - "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" + "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" ) +const IconSize = 32 + +type Controller interface { + session.Controller + AuthenticateSession(*Container, cchat.Service) +} + type View struct { *gtk.ScrolledWindow Box *gtk.Box @@ -33,10 +40,11 @@ func NewView() *View { } } -func (v *View) AddService(svc cchat.Service, rowctrl server.RowController) { - s := NewContainer(svc, rowctrl) +func (v *View) AddService(svc cchat.Service, ctrl Controller) *Container { + s := NewContainer(svc, ctrl) v.Services = append(v.Services, s) v.Box.Add(s) + return s } type Container struct { @@ -44,19 +52,20 @@ type Container struct { header *header revealer *gtk.Revealer children *children - rowctrl server.RowController + rowctrl Controller } -func NewContainer(svc cchat.Service, rowctrl server.RowController) *Container { - header := newHeader(svc) - +func NewContainer(svc cchat.Service, ctrl Controller) *Container { children := newChildren() chrev, _ := gtk.RevealerNew() - chrev.SetRevealChild(false) + chrev.SetRevealChild(true) chrev.Add(children) chrev.Show() + header := newHeader(svc) + header.reveal.SetActive(chrev.GetRevealChild()) + box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) box.Show() box.PackStart(header, false, false, 0) @@ -64,7 +73,7 @@ func NewContainer(svc cchat.Service, rowctrl server.RowController) *Container { primitives.AddClass(box, "service") - var container = &Container{box, header, chrev, children, rowctrl} + var container = &Container{box, header, chrev, children, ctrl} // On click, toggle reveal. header.reveal.Connect("clicked", func() { @@ -75,31 +84,41 @@ func NewContainer(svc cchat.Service, rowctrl server.RowController) *Container { // On click, show the auth dialog. header.add.Connect("clicked", func() { - auth.NewDialog(svc.Name(), svc.Authenticate(), container.addSession) + ctrl.AuthenticateSession(container, svc) }) return container } -func (c *Container) addSession(ses cchat.Session) { +func (c *Container) AddSession(ses cchat.Session) { srow := session.New(ses, c.rowctrl) c.children.addSessionRow(srow) } +func (c *Container) Sessions() []cchat.Session { + var sessions = make([]cchat.Session, len(c.children.Sessions)) + for i, s := range c.children.Sessions { + sessions[i] = s.Session + } + return sessions +} + type header struct { *gtk.Box - reveal *gtk.ToggleButton + reveal *rich.ToggleButtonImage // no rich text here but it's left aligned add *gtk.Button } func newHeader(svc cchat.Service) *header { - reveal, _ := gtk.ToggleButtonNewWithLabel(svc.Name()) - primitives.BinLeftAlignLabel(reveal) // do this first - + reveal := rich.NewToggleButtonImage(text.Rich{Content: svc.Name()}, "") + reveal.Box.SetHAlign(gtk.ALIGN_START) reveal.SetRelief(gtk.RELIEF_NONE) reveal.SetMode(true) reveal.Show() + // Set a custom icon. + primitives.SetImageIcon(&reveal.Image, "folder-remote-symbolic", IconSize) + add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON) add.SetRelief(gtk.RELIEF_NONE) add.Show() diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go deleted file mode 100644 index 9ff8339..0000000 --- a/internal/ui/service/session/server/server.go +++ /dev/null @@ -1,133 +0,0 @@ -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/gotk3/gotk3/gtk" - "github.com/pkg/errors" -) - -const ChildrenMargin = 24 - -type RowController interface { - MessageRowSelected(*Row, cchat.ServerMessage) -} - -type Row struct { - *gtk.Box - Button *gtk.Button - Server cchat.Server - - ctrl RowController - - // enum 1 - message cchat.ServerMessage - - // enum 2 - children *Children -} - -func New(server cchat.Server, ctrl RowController) *Row { - name, err := server.Name() - if err != nil { - log.Error(errors.Wrap(err, "Failed to get the server name")) - name = "no name" - } - - button, _ := gtk.ButtonNewWithLabel(name) - primitives.BinLeftAlignLabel(button) - - button.SetRelief(gtk.RELIEF_NONE) - button.Show() - - box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - box.PackStart(button, false, false, 0) - box.Show() - - primitives.AddClass(box, "server") - - // TODO: images - - var row = &Row{ - Box: box, - Button: button, - Server: server, - ctrl: ctrl, - } - button.Connect("clicked", row.onClick) - - switch server := server.(type) { - case cchat.ServerList: - row.children = NewChildren(server, ctrl) - box.PackStart(row.children, false, false, 0) - - primitives.AddClass(box, "server-list") - - case cchat.ServerMessage: - row.message = server - - primitives.AddClass(box, "server-message") - } - - return row -} - -func (row *Row) onClick() { - switch { - case row.message != nil: - row.ctrl.MessageRowSelected(row, row.message) - case row.children != nil: - row.children.SetRevealChild(!row.children.GetRevealChild()) - } -} - -type Children struct { - *gtk.Revealer - Main *gtk.Box - List cchat.ServerList - - Rows []*Row - rowctrl RowController -} - -func NewChildren(list cchat.ServerList, ctrl RowController) *Children { - main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - main.SetMarginStart(ChildrenMargin) - main.Show() - - rev, _ := gtk.RevealerNew() - rev.SetRevealChild(false) - rev.Add(main) - rev.Show() - - children := &Children{ - Revealer: rev, - Main: main, - List: list, - rowctrl: ctrl, - } - - if err := list.Servers(children); err != nil { - log.Error(errors.Wrap(err, "Failed to get servers")) - } - - return children -} - -func (c *Children) SetServers(servers []cchat.Server) { - gts.ExecAsync(func() { - for _, row := range c.Rows { - c.Main.Remove(row) - } - - c.Rows = make([]*Row, len(servers)) - - for i, server := range servers { - row := New(server, c.rowctrl) - c.Rows[i] = row - c.Main.Add(row) - } - }) -} diff --git a/internal/ui/service/session/server/server_children.go b/internal/ui/service/session/server/server_children.go new file mode 100644 index 0000000..c89f981 --- /dev/null +++ b/internal/ui/service/session/server/server_children.go @@ -0,0 +1,87 @@ +package server + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" +) + +const ChildrenMargin = 24 + +type Controller interface { + MessageRowSelected(*Row, cchat.ServerMessage) +} + +// Children is a children server with a reference to the parent. +type Children struct { + *gtk.Revealer + Main *gtk.Box + List cchat.ServerList + + rowctrl Controller + + Rows []*Row + ParentRow *Row +} + +func NewChildren(parent *Row, list cchat.ServerList, 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() + + children := &Children{ + Revealer: rev, + Main: main, + List: list, + rowctrl: ctrl, + ParentRow: parent, + } + + if err := list.Servers(children); err != nil { + log.Error(errors.Wrap(err, "Failed to get servers")) + } + + return children +} + +func (c *Children) SetServers(servers []cchat.Server) { + gts.ExecAsync(func() { + // Save the current state. + var oldID string + for _, row := range c.Rows { + if row.GetActive() { + oldID = row.Server.ID() + break + } + } + + // Update the server list. + for _, row := range c.Rows { + c.Main.Remove(row) + } + + c.Rows = make([]*Row, len(servers)) + + for i, server := range servers { + row := NewRow(c, server, c.rowctrl) + c.Rows[i] = row + c.Main.Add(row) + } + + // Update parent reference? Only if it's activated. + if oldID != "" { + for _, row := range c.Rows { + if row.Server.ID() == oldID { + row.Button.SetActive(true) + } + } + } + }) +} diff --git a/internal/ui/service/session/server/server_row.go b/internal/ui/service/session/server/server_row.go new file mode 100644 index 0000000..e4f6703 --- /dev/null +++ b/internal/ui/service/session/server/server_row.go @@ -0,0 +1,100 @@ +package server + +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/cchat/text" + "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" +) + +type Row struct { + *gtk.Box + Button *rich.ToggleButtonImage + Server cchat.Server + Parent *Children + + ctrl Controller + + // enum 1 + message cchat.ServerMessage + + // enum 2 + children *Children +} + +func NewRow(parent *Children, server cchat.Server, ctrl Controller) *Row { + button := rich.NewToggleButtonImage(text.Rich{}, "") + button.Box.SetHAlign(gtk.ALIGN_START) + button.SetRelief(gtk.RELIEF_NONE) + button.Show() + + if err := server.Name(button); err != nil { + log.Error(errors.Wrap(err, "Failed to get the server name")) + button.SetLabel(text.Rich{Content: "Unknown"}) + } + + box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + 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, + } + + switch server := server.(type) { + case cchat.ServerList: + row.children = NewChildren(row, server, ctrl) + 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 +} + +func (row *Row) GetActive() bool { + return row.Button.GetActive() +} + +func (row *Row) onClick() { + switch { + + // If the server is a message server. We're only selected if the button is + // pressed. + case row.message != nil && row.GetActive(): + row.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) Breadcrumb() string { + var label = r.Button.GetLabel().Content + + // Does the row have a parent? + if r.Parent != nil { + return r.Parent.ParentRow.Breadcrumb() + "/" + label + } + + return label +} diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index 5288133..c985ffe 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -4,53 +4,66 @@ 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/cchat-gtk/internal/ui/service/session/server" + "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) +const IconSize = 32 + +// Controller extends server.RowController to add session. +type Controller interface { + MessageRowSelected(*Row, *server.Row, cchat.ServerMessage) +} + type Row struct { *gtk.Box - Button *gtk.ToggleButton + Button *rich.ToggleButtonImage Session cchat.Session Servers *server.Children + + ctrl Controller } -func New(ses cchat.Session, rowctrl server.RowController) *Row { - n, err := ses.Name() - if err != nil { - log.Error(errors.Wrap(err, "Failed to get the username")) - n = "no name" +func New(ses cchat.Session, ctrl Controller) *Row { + row := &Row{ + Session: ses, + ctrl: ctrl, } + row.Servers = server.NewChildren(ses, row) - button, _ := gtk.ToggleButtonNewWithLabel(n) - primitives.BinLeftAlignLabel(button) - - button.SetRelief(gtk.RELIEF_NONE) - button.Show() - - servers := server.NewChildren(ses, rowctrl) - - box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - box.Show() - box.SetMarginStart(server.ChildrenMargin) - box.PackStart(button, false, false, 0) - box.PackStart(servers, false, false, 0) - - primitives.AddClass(box, "session") - + row.Button = rich.NewToggleButtonImage(text.Rich{}, "") + row.Button.Box.SetHAlign(gtk.ALIGN_START) + row.Button.SetRelief(gtk.RELIEF_NONE) + row.Button.Show() // On click, toggle reveal. - button.Connect("clicked", func() { - revealed := !servers.GetRevealChild() - servers.SetRevealChild(revealed) - button.SetActive(revealed) + row.Button.Connect("clicked", func() { + revealed := !row.Servers.GetRevealChild() + row.Servers.SetRevealChild(revealed) + row.Button.SetActive(revealed) }) - return &Row{ - Box: box, - Button: button, - Session: ses, - Servers: servers, + primitives.SetImageIcon(&row.Button.Image, "user-available-symbolic", IconSize) + + if err := ses.Name(row.Button); err != nil { + log.Error(errors.Wrap(err, "Failed to get the username")) + row.Button.SetLabel(text.Rich{Content: "Unknown"}) } + + row.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + row.Box.SetMarginStart(server.ChildrenMargin) + row.Box.PackStart(row.Button, false, false, 0) + row.Box.PackStart(row.Servers, false, false, 0) + row.Box.Show() + + primitives.AddClass(row.Box, "session") + + return row +} + +func (r *Row) MessageRowSelected(server *server.Row, smsg cchat.ServerMessage) { + r.ctrl.MessageRowSelected(r, server, smsg) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 70cad76..3f9f454 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -3,25 +3,33 @@ package ui import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/keyring" + "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/ui/service" + "github.com/diamondburned/cchat-gtk/internal/ui/service/auth" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/gotk3/gotk3/gtk" ) const LeftWidth = 220 -type Application struct { +type App struct { window *window header *header + + // used to keep track of what row to highlight and unhighlight + lastRowHighlighter func(bool) } var ( - _ gts.Windower = (*Application)(nil) - _ gts.Headerer = (*Application)(nil) - _ server.RowController = (*Application)(nil) + _ gts.Windower = (*App)(nil) + _ gts.Headerer = (*App)(nil) + _ service.Controller = (*App)(nil) ) -func NewApplication() *Application { - app := &Application{ +func NewApplication() *App { + app := &App{ window: newWindow(), header: newHeader(), } @@ -29,18 +37,44 @@ func NewApplication() *Application { return app } -func (app *Application) AddService(svc cchat.Service) { - app.window.Services.AddService(svc, app) +func (app *App) AddService(svc cchat.Service) { + var container = app.window.Services.AddService(svc, app) + + // Attempt to restore sessions asynchronously. + keyring.RestoreSessions(svc, container.AddSession) } -func (app *Application) MessageRowSelected(_ *server.Row, smsg cchat.ServerMessage) { - app.window.MessageView.JoinServer(smsg) +func (app *App) MessageRowSelected(ses *session.Row, srv *server.Row, smsg cchat.ServerMessage) { + // Is there an old row that we should unhighlight? + if app.lastRowHighlighter != nil { + app.lastRowHighlighter(false) + } + + // Set the new row and highlight it. + app.lastRowHighlighter = srv.Button.SetActive + app.lastRowHighlighter(true) + + log.Println("Breadcrumb:") + + // Show the messages. + app.window.MessageView.JoinServer(ses.Session, smsg) } -func (app *Application) Header() gtk.IWidget { +func (app *App) AuthenticateSession(container *service.Container, svc cchat.Service) { + auth.NewDialog(svc.Name(), svc.Authenticate(), func(ses cchat.Session) { + container.AddSession(ses) + + // Save all sessions. + for _, err := range keyring.SaveSessions(svc, container.Sessions()) { + log.Error(err) + } + }) +} + +func (app *App) Header() gtk.IWidget { return app.header } -func (app *Application) Window() gtk.IWidget { +func (app *App) Window() gtk.IWidget { return app.window } diff --git a/main.go b/main.go index 029b764..ad2b157 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "github.com/diamondburned/cchat/services" _ "github.com/diamondburned/cchat-mock" - _ "github.com/diamondburned/cchat/services/plugins" ) func main() {