Final commit before partial rewrite

This commit is contained in:
diamondburned (Forefront) 2020-06-04 16:00:41 -07:00
parent ee657a8177
commit b852498ee4
25 changed files with 1152 additions and 365 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
cchat-gtk

8
go.mod
View File

@ -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
)

37
go.sum
View File

@ -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=

View File

@ -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()

View File

@ -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
})
}
}

View File

@ -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(),

View File

@ -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())
}

View File

@ -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(
`<span color="red">%s</span>`,
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)
}
})
}

View File

@ -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) {

View File

@ -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)
}
})
}

View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -0,0 +1 @@
package parser

111
internal/ui/rich/rich.go Normal file
View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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")