From af2fe8566636000eaeeb210632635f713374a56b Mon Sep 17 00:00:00 2001 From: diamondburned Date: Sun, 16 Aug 2020 17:13:47 -0700 Subject: [PATCH] Added partial member list support --- go.mod | 7 +- go.sum | 21 + internal/gts/httputil/httputil.go | 6 - .../messages/container/cozy/message_full.go | 14 +- internal/ui/messages/memberlist/memberlist.go | 384 ++++++++++++++++++ internal/ui/messages/message/message.go | 8 +- internal/ui/messages/view.go | 43 +- internal/ui/primitives/primitives.go | 14 +- .../ui/primitives/roundimage/roundimage.go | 70 +++- internal/ui/rich/image.go | 36 +- internal/ui/rich/labeluri/labeluri.go | 6 +- internal/ui/rich/parser/markup/markup.go | 6 + internal/ui/service/session/session.go | 7 +- 13 files changed, 568 insertions(+), 54 deletions(-) create mode 100644 internal/ui/messages/memberlist/memberlist.go diff --git a/go.mod b/go.mod index db546a3..7273f9a 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,13 @@ module github.com/diamondburned/cchat-gtk go 1.14 -replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d +replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a require ( github.com/Xuanwo/go-locale v0.2.0 github.com/alecthomas/chroma v0.7.3 - github.com/diamondburned/cchat v0.0.46 - github.com/diamondburned/cchat-discord v0.0.0-20200730000036-2c93cdc1974e + github.com/diamondburned/cchat v0.0.48 + github.com/diamondburned/cchat-discord v0.0.0-20200816234747-9647ad0709f0 github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 github.com/disintegration/imaging v1.6.2 @@ -21,5 +21,6 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/twmb/murmur3 v1.1.3 github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e gopkg.in/yaml.v2 v2.2.7 // indirect ) diff --git a/go.sum b/go.sum index a0031a2..fd87047 100644 --- a/go.sum +++ b/go.sum @@ -46,24 +46,44 @@ github.com/diamondburned/aqs v0.0.0-20200704043812-99b676ee44eb h1:Ja/niwykeFoSk github.com/diamondburned/aqs v0.0.0-20200704043812-99b676ee44eb/go.mod h1:q1MbMBfZrv7xqV8n7LgMwhHs3oBbNwWJes8exs2AmDs= github.com/diamondburned/arikawa v0.12.4 h1:lhWJqcGkIIMiOYWdsoEuGlri2UbMkzMeh+VfuJPkXt4= github.com/diamondburned/arikawa v0.12.4/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= +github.com/diamondburned/arikawa v1.1.6 h1:Y/ioTYipS2v/NXfcAEhCnMTzrpxDjWlkjLKKcX29n6o= +github.com/diamondburned/arikawa v1.1.6/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= github.com/diamondburned/cchat v0.0.43 h1:HetAujSaUSdnQgAUZgprNLARjf/MSWXpCfWdvX2wOCU= github.com/diamondburned/cchat v0.0.43/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.45 h1:HMVSKx1h6lh2OenWaBTvMSK531hWaXAW7I0tKZepYug= github.com/diamondburned/cchat v0.0.45/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.46 h1:fzm2XA9uGasX0uaic1AFfUMGA53PlO+GGmkYbx49A5k= github.com/diamondburned/cchat v0.0.46/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.48 h1:MAzGzKY20JBh/LnirOZVPwbMq07xfqu4Lb4XsV9/sXQ= +github.com/diamondburned/cchat v0.0.48/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat-discord v0.0.0-20200719175346-af912db55401 h1:llmx/8UiJoTcHUw+GE5/rESVVmmnLh1HEPx3wRj+oQY= github.com/diamondburned/cchat-discord v0.0.0-20200719175346-af912db55401/go.mod h1:+hSrIVYj5tIPLAorDsHj2Tbt2fWlZtOanzfEUHX53HM= github.com/diamondburned/cchat-discord v0.0.0-20200730000036-2c93cdc1974e h1:EA5Vg0x57qLURJP80XhABBW+X0sbQSh2gw5qvPbZTs4= github.com/diamondburned/cchat-discord v0.0.0-20200730000036-2c93cdc1974e/go.mod h1:+hSrIVYj5tIPLAorDsHj2Tbt2fWlZtOanzfEUHX53HM= +github.com/diamondburned/cchat-discord v0.0.0-20200815223744-cdd9b6804361 h1:vx3sMBscULTnvGqjAUVWbgIQj4HGOqHQxoIljrfHC9A= +github.com/diamondburned/cchat-discord v0.0.0-20200815223744-cdd9b6804361/go.mod h1:sW8tIqRcKux9bhMCtcqYI1fCMGCB23FoW67gcpr13fk= +github.com/diamondburned/cchat-discord v0.0.0-20200816234747-9647ad0709f0 h1:IVL4KUyLG9i5xPGwhIeKYttWHogenKiQLgfXEnYWNvU= +github.com/diamondburned/cchat-discord v0.0.0-20200816234747-9647ad0709f0/go.mod h1:Hl5AJstOnuP8rCdrL/Ns9W5MH09PRxQM4tG0BIMCjLw= github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b h1:sq0MXjJc3yAOZvuolRxOpKQNvpMLyTmsECxQqdYgF5E= github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b/go.mod h1:+bAf0m2o5qH54DmYJ/lR1HeITV53ol0JaoKyFFx3m3E= github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI= github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= +github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a h1:wEldljb421/Jp84RNb0zBfqmiWt/TTQzUE6R1ap6UuQ= +github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 h1:OWxllHbUptXzDias6YI4MM0R3o50q8MfhkkwVIlfiNo= github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ= github.com/diamondburned/ningen v0.1.1-0.20200717072304-e483f86c08e6 h1:YN0cj0aOCa+tKmx0aD5qsbSYaIJnyrA0/+eygMKP+/w= github.com/diamondburned/ningen v0.1.1-0.20200717072304-e483f86c08e6/go.mod h1:Sunqp1b9Tc0+DtWKslhf83Zepgj/TELB6h8J9HZCPqQ= +github.com/diamondburned/ningen v0.1.1-0.20200815214034-638820c48066 h1:TOLTl0zLJ+idYB7i6C4oGfmVF1Y0AFoNfVQWU3hmc4A= +github.com/diamondburned/ningen v0.1.1-0.20200815214034-638820c48066/go.mod h1:PIsJWdDhjgN9OiR+qrDPD8KGQ8UyFuRVrgs3Ewu6a3c= +github.com/diamondburned/ningen v0.1.1-0.20200816035718-70361fb41b6f h1:Wzns8I0VjMrcsH7N5lqHKTRkgBt2aCwx+7wAxvSFGjU= +github.com/diamondburned/ningen v0.1.1-0.20200816035718-70361fb41b6f/go.mod h1:PIsJWdDhjgN9OiR+qrDPD8KGQ8UyFuRVrgs3Ewu6a3c= +github.com/diamondburned/ningen v0.1.1-0.20200816040753-9595e584e6bc h1:pxFVcg7J7D9hYVVReXLdJurgv4uAo1a6jNHcYpdeZsE= +github.com/diamondburned/ningen v0.1.1-0.20200816040753-9595e584e6bc/go.mod h1:PIsJWdDhjgN9OiR+qrDPD8KGQ8UyFuRVrgs3Ewu6a3c= +github.com/diamondburned/ningen v0.1.1-0.20200816040956-857988325ce0 h1:c3G8NjcS7JZvQvY549r993C0P1ltEf7sFpfRD21HFhk= +github.com/diamondburned/ningen v0.1.1-0.20200816040956-857988325ce0/go.mod h1:PIsJWdDhjgN9OiR+qrDPD8KGQ8UyFuRVrgs3Ewu6a3c= +github.com/diamondburned/ningen v0.1.1-0.20200816192443-0b6a02d498d2 h1:PodX8lfv7ZffUHUsaQUdIjZ50ONY8uCCXXUL7yzoSMQ= +github.com/diamondburned/ningen v0.1.1-0.20200816192443-0b6a02d498d2/go.mod h1:PIsJWdDhjgN9OiR+qrDPD8KGQ8UyFuRVrgs3Ewu6a3c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= @@ -232,6 +252,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/internal/gts/httputil/httputil.go b/internal/gts/httputil/httputil.go index 4d0fab9..f9b4525 100644 --- a/internal/gts/httputil/httputil.go +++ b/internal/gts/httputil/httputil.go @@ -60,12 +60,6 @@ func AsyncStream(url string, fn func(r io.Reader)) { } func get(ctx context.Context, url string, cached bool) (r *http.Response, err error) { - // if cached { - // r, err = dskcached.Get(url) - // } else { - // r, err = memcached.Get(url) - // } - q, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, errors.Wrap(err, "Failed to make a request") diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index d3c95c4..1234bab 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -183,14 +183,20 @@ type Avatar struct { } func NewAvatar() *Avatar { - avatar, _ := roundimage.NewButton() + avatar, _ := roundimage.NewEmptyButton() avatar.SetSizeRequest(AvatarSize, AvatarSize) avatar.SetVAlign(gtk.ALIGN_START) + img, _ := roundimage.NewStaticImage(avatar, 0) + img.Show() + + avatar.SetImage(img.Image) + + // TODO + // Remove static image; make it internal; make static iamge bind to something else incl button and list + // Default icon. - primitives.SetImageIcon( - avatar.Image.Image, "user-available-symbolic", AvatarSize, - ) + primitives.SetImageIcon(img, "user-available-symbolic", AvatarSize) return &Avatar{*avatar, ""} } diff --git a/internal/ui/messages/memberlist/memberlist.go b/internal/ui/messages/memberlist/memberlist.go new file mode 100644 index 0000000..57ff254 --- /dev/null +++ b/internal/ui/messages/memberlist/memberlist.go @@ -0,0 +1,384 @@ +package memberlist + +import ( + "context" + "fmt" + "strings" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage" + "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" + "github.com/diamondburned/cchat/text" + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" + "github.com/gotk3/gotk3/pango" + "github.com/pkg/errors" +) + +var MemberListWidth = 250 + +type Container struct { + *gtk.ScrolledWindow + Main *gtk.Box + + // map id -> *Section + Sections map[string]*Section + + // states + stop func() +} + +var memberListCSS = primitives.PrepareClassCSS("member-list", ` + .member-list { + background-color: @theme_base_color; + } +`) + +func New() *Container { + main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2) + main.SetSizeRequest(250, -1) + main.Show() + memberListCSS(main) + + sw, _ := gtk.ScrolledWindowNew(nil, nil) + sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) + sw.Add(main) + + return &Container{ + ScrolledWindow: sw, + Main: main, + Sections: map[string]*Section{}, + } +} + +// Reset removes all old sections. +func (c *Container) Reset() { + if c.stop != nil { + c.stop() + c.stop = nil + } + + for _, section := range c.Sections { + c.Main.Remove(section) + } + + c.Sections = map[string]*Section{} +} + +// TryAsyncList tries to set the member list from the given server. It does type +// assertions and handles asynchronicity. Reset must be called before this. +func (c *Container) TryAsyncList(server cchat.ServerMessage) { + ls, ok := server.(cchat.ServerMessageMemberLister) + if !ok { + return + } + + gts.Async(func() (func(), error) { + f, err := ls.ListMembers(context.Background(), c) + if err != nil { + return nil, errors.Wrap(err, "Failed to list members") + } + + return func() { + c.stop = f + }, nil + }) +} + +func (c *Container) SetSections(sections []cchat.MemberListSection) { + gts.ExecAsync(func() { c.SetSectionsUnsafe(sections) }) +} + +func (c *Container) SetMember(sectionID string, member cchat.ListMember) { + gts.ExecAsync(func() { c.SetMemberUnsafe(sectionID, member) }) +} + +func (c *Container) RemoveMember(sectionID string, id string) { + gts.ExecAsync(func() { c.RemoveMemberUnsafe(sectionID, id) }) +} + +func (c *Container) SetSectionsUnsafe(sections []cchat.MemberListSection) { + var newSections = make([]*Section, len(sections)) + + for i, section := range sections { + sc, ok := c.Sections[section.ID()] + if !ok { + sc = NewSection(section) + } else { + sc.Update(section.Name(), section.Total()) + } + + newSections[i] = sc + } + + // Remove all old sections. + for id, section := range c.Sections { + c.Main.Remove(section) + delete(c.Sections, id) + } + + // Insert new sections. + for _, section := range newSections { + c.Main.Add(section) + c.Sections[section.ID] = section + } +} + +func (c *Container) SetMemberUnsafe(sectionID string, member cchat.ListMember) { + if s, ok := c.Sections[sectionID]; ok { + s.SetMember(member) + } +} + +func (c *Container) RemoveMemberUnsafe(sectionID string, id string) { + if s, ok := c.Sections[sectionID]; ok { + s.RemoveMember(id) + } +} + +type Section struct { + *gtk.Box + + ID string + + // state + name text.Rich + total int + + Header *rich.Label + Body *gtk.ListBox + + // map id -> *Member + Members map[string]*Member +} + +var sectionHeaderCSS = primitives.PrepareClassCSS("section-header", ` + .section-header { + margin: 8px 12px; + margin-bottom: 2px; + } +`) + +var sectionBodyCSS = primitives.PrepareClassCSS("section-body", ` + .section-body { + background: inherit; + } +`) + +func NewSection(sect cchat.MemberListSection) *Section { + header := rich.NewLabel(text.Rich{}) + header.Show() + sectionHeaderCSS(header) + + body, _ := gtk.ListBoxNew() + body.SetSelectionMode(gtk.SELECTION_NONE) + body.SetActivateOnSingleClick(true) + body.SetSortFunc(listSortNameAsc) // A-Z + body.Show() + sectionBodyCSS(body) + + box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + box.PackStart(header, false, false, 0) + box.PackStart(body, false, false, 0) + box.Show() + + var members = map[string]*Member{} + + // On row click, show the mention popup if any. + body.Connect("row-activated", func(_ *gtk.ListBox, r *gtk.ListBoxRow) { + var i = r.GetIndex() + // Cold path; we can afford searching in the map. + for _, member := range members { + if member.ListBoxRow.GetIndex() == i { + member.Popup() + return + } + } + }) + + section := &Section{ + ID: sect.ID(), + Box: box, + Header: header, + Body: body, + Members: members, + } + + section.Update(sect.Name(), sect.Total()) + + return section +} + +func (s *Section) Update(name text.Rich, total int) { + s.name = name + s.total = total + + var content = s.name.Content + if total > 0 { + content += fmt.Sprintf("—%d", total) + } + + s.Header.SetLabelUnsafe(text.Rich{ + Content: content, + Segments: s.name.Segments, + }) +} + +func (s *Section) SetMember(member cchat.ListMember) { + if m, ok := s.Members[member.ID()]; ok { + m.Update(member) + return + } + + m := NewMember(member) + m.Show() + + s.Members[member.ID()] = m + s.Body.Add(m) +} + +func (s *Section) RemoveMember(id string) { + if member, ok := s.Members[id]; ok { + s.Body.Remove(member) + delete(s.Members, id) + } +} + +func listSortNameAsc(r1, r2 *gtk.ListBoxRow, _ ...interface{}) int { + n1, _ := r1.GetName() + n2, _ := r2.GetName() + + switch { + case n1 < n2: + return -1 + case n1 > n2: + return 1 + default: + return 0 + } +} + +type Member struct { + *gtk.ListBoxRow + Main *gtk.Box + + Avatar *rich.Icon + Name *gtk.Label + output markup.RenderOutput +} + +const AvatarSize = 34 + +var memberRowCSS = primitives.PrepareClassCSS("member-row", ` + .member-row { + min-height: 42px; + } +`) + +var memberBoxCSS = primitives.PrepareClassCSS("member-box", ` + .member-box { + margin: 3px 10px; + } +`) + +var avatarMemberCSS = primitives.PrepareClassCSS("avatar-member", ` + .avatar-member { + padding-right: 10px; + } +`) + +func NewMember(member cchat.ListMember) *Member { + evb, _ := gtk.EventBoxNew() + evb.AddEvents(int(gdk.EVENT_ENTER_NOTIFY) | int(gdk.EVENT_LEAVE_NOTIFY)) + evb.Show() + + img, _ := roundimage.NewStaticImage(evb, 0) + img.Show() + + icon := rich.NewCustomIcon(img, AvatarSize) + icon.SetPlaceholderIcon("user-info-symbolic", AvatarSize) + icon.Show() + avatarMemberCSS(icon) + + lbl, _ := gtk.LabelNew("") + lbl.SetUseMarkup(true) + lbl.SetXAlign(0) + lbl.SetEllipsize(pango.ELLIPSIZE_END) + lbl.Show() + + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box.PackStart(icon, false, false, 0) + box.PackStart(lbl, true, true, 0) + box.Show() + memberBoxCSS(box) + + evb.Add(box) + + r, _ := gtk.ListBoxRowNew() + memberRowCSS(r) + r.Add(evb) + + m := &Member{ + ListBoxRow: r, + Main: box, + Avatar: icon, + Name: lbl, + } + + m.Update(member) + + return m +} + +func (m *Member) Update(member cchat.ListMember) { + m.ListBoxRow.SetName(member.Name().Content) + + if iconer, ok := member.(cchat.Icon); ok { + m.Avatar.AsyncSetIconer(iconer, "Failed to get member list icon") + } + + m.output = markup.RenderCmplxWithConfig(member.Name(), markup.NoMentionLinks) + txt := strings.Builder{} + txt.WriteString(fmt.Sprintf( + ` %s`, + statusColors(member.Status()), m.output.Markup, + )) + + if bot := member.Secondary(); !bot.Empty() { + txt.WriteByte('\n') + txt.WriteString(fmt.Sprintf( + `%s`, + markup.Render(bot), + )) + } + + m.Name.SetMarkup(txt.String()) +} + +// Popup pops up the mention popover if any. +func (m *Member) Popup() { + if len(m.output.Mentions) > 0 { + p := labeluri.NewPopoverMentioner(m, m.output.Input, m.output.Mentions[0]) + p.SetPosition(gtk.POS_LEFT) + p.Popup() + } +} + +func statusColors(status cchat.UserStatus) uint32 { + switch status { + case cchat.OnlineStatus: + return 0x43B581 + case cchat.BusyStatus: + return 0xF04747 + case cchat.IdleStatus: + return 0xFAA61A + case cchat.OfflineStatus: + fallthrough + default: + return 0x747F8D + } +} diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index 8eb17eb..a05818f 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -190,14 +190,8 @@ func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) { } } -// authorRenderCfg is the config to render author names. It disables author -// mention links, as there's no way to make normal names not appear blue. -var authorRenderCfg = markup.RenderConfig{ - NoMentionLinks: true, -} - func (m *GenericContainer) UpdateAuthorName(name text.Rich) { - var out = markup.RenderCmplxWithConfig(name, authorRenderCfg) + var out = markup.RenderCmplxWithConfig(name, markup.NoMentionLinks) m.Username.SetOutput(out) } diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index b752960..be3aa6c 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -12,6 +12,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/container/compact" "github.com/diamondburned/cchat-gtk/internal/ui/messages/container/cozy" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" + "github.com/diamondburned/cchat-gtk/internal/ui/messages/memberlist" "github.com/diamondburned/cchat-gtk/internal/ui/messages/sadface" "github.com/diamondburned/cchat-gtk/internal/ui/messages/typing" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" @@ -39,7 +40,7 @@ func init() { type View struct { *sadface.FaceView - Box *gtk.Box + Grid *gtk.Grid Scroller *autoscroll.ScrolledWindow InputView *input.InputView @@ -49,6 +50,8 @@ type View struct { Container container.Container contType int // msgIndex + MemberList *memberlist.Container + // Inherit some useful methods. state } @@ -58,6 +61,9 @@ func NewView() *View { view.Typing = typing.New() view.Typing.Show() + view.MemberList = memberlist.New() + view.MemberList.Show() + view.MsgBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2) view.MsgBox.PackEnd(view.Typing, false, false, 0) view.MsgBox.Show() @@ -68,31 +74,36 @@ func NewView() *View { view.Scroller = autoscroll.NewScrolledWindow() view.Scroller.Add(view.MsgBox) + view.Scroller.SetVExpand(true) + view.Scroller.SetHExpand(true) view.Scroller.Show() // A separator to go inbetween. sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL) + sep.SetHExpand(true) sep.Show() view.InputView = input.NewView(view) + view.InputView.SetHExpand(true) view.InputView.Show() - view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - view.Box.PackStart(view.Scroller, true, true, 0) - view.Box.PackStart(sep, false, false, 0) - view.Box.PackStart(view.InputView, false, false, 0) - view.Box.Show() + view.Grid, _ = gtk.GridNew() + view.Grid.Attach(view.Scroller, 0, 0, 1, 1) + view.Grid.Attach(sep, 0, 1, 1, 1) + view.Grid.Attach(view.InputView, 0, 2, 1, 1) + view.Grid.Attach(view.MemberList, 1, 0, 1, 3) + view.Grid.Show() - primitives.AddClass(view.Box, "message-view") + primitives.AddClass(view.Grid, "message-view") // Bind a file drag-and-drop box into the main view box. - drag.BindFileDest(view.Box, view.InputView.Attachments.AddFiles) + drag.BindFileDest(view.Grid, view.InputView.Attachments.AddFiles) // placeholder logo logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256Variant2(128)) logo.Show() - view.FaceView = sadface.New(view.Box, logo) + view.FaceView = sadface.New(view.Grid, logo) return view } @@ -117,11 +128,12 @@ func (v *View) createMessageContainer() { func (v *View) Bottomed() bool { return v.Scroller.Bottomed } func (v *View) Reset() { - v.state.Reset() // Reset the state variables. - v.Typing.Reset() // Reset the typing state. - v.InputView.Reset() // Reset the input. - v.Container.Reset() // Clean all messages. - v.FaceView.Reset() // Switch back to the main screen. + v.state.Reset() // Reset the state variables. + v.Typing.Reset() // Reset the typing state. + v.InputView.Reset() // Reset the input. + v.MemberList.Reset() // Reset the member list. + v.Container.Reset() // Clean all messages. + v.FaceView.Reset() // Switch back to the main screen. // Keep the scroller at the bottom. v.Scroller.Bottomed = true @@ -174,6 +186,9 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage, done func // Try setting the typing indicator if available. v.Typing.TrySubscribe(server) + + // Try and use the list. + v.MemberList.TryAsyncList(server) }, nil }) } diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index a543a29..aefec61 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -1,8 +1,7 @@ package primitives import ( - "path/filepath" - "runtime" + "runtime/debug" "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/log" @@ -110,7 +109,12 @@ func NewImageIconPx(icon string, sizepx int) *gtk.Image { return img } -func SetImageIcon(img *gtk.Image, icon string, sizepx int) { +type ImageIconSetter interface { + SetProperty(name string, value interface{}) error + SetSizeRequest(w, h int) +} + +func SetImageIcon(img ImageIconSetter, icon string, sizepx int) { img.SetProperty("icon-name", icon) img.SetProperty("pixel-size", sizepx) img.SetSizeRequest(sizepx, sizepx) @@ -244,9 +248,7 @@ func PrepareClassCSS(class, css string) (attach func(StyleContexter)) { func PrepareCSS(css string) *gtk.CssProvider { p, _ := gtk.CssProviderNew() if err := p.LoadFromData(css); err != nil { - _, fn, caller, _ := runtime.Caller(1) - fn = filepath.Base(fn) - log.Error(errors.Wrapf(err, "CSS fail at %s:%d", fn, caller)) + log.Error(errors.Wrapf(err, "CSS fail at %s", debug.Stack())) } return p } diff --git a/internal/ui/primitives/roundimage/roundimage.go b/internal/ui/primitives/roundimage/roundimage.go index 5841bf1..f0ea597 100644 --- a/internal/ui/primitives/roundimage/roundimage.go +++ b/internal/ui/primitives/roundimage/roundimage.go @@ -3,8 +3,10 @@ package roundimage import ( "math" + "github.com/diamondburned/cchat-gtk/internal/gts/httputil" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/gotk3/gotk3/cairo" + "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/gtk" ) @@ -31,12 +33,76 @@ func NewButton() (*Button, error) { image, _ := NewImage(0) image.Show() - b, _ := gtk.ButtonNew() + b, _ := NewEmptyButton() b.SetImage(image) + + return b, nil +} + +func NewEmptyButton() (*Button, error) { + b, _ := gtk.ButtonNew() b.SetRelief(gtk.RELIEF_NONE) roundButtonCSS(b) - return &Button{Button: b, Image: image}, nil + return &Button{Button: b}, nil +} + +func (b *Button) SetImage(img *Image) { + b.Image = img + b.Button.SetImage(img) +} + +type RadiusSetter interface { + SetRadius(float64) +} + +// StaticImage is an image that only plays a GIF if it's hovered on top of. +type StaticImage struct { + *Image + animation *gdk.PixbufAnimation +} + +var _ httputil.ImageContainer = (*StaticImage)(nil) + +func NewStaticImage(parent primitives.Connector, radius float64) (*StaticImage, error) { + i, err := NewImage(radius) + if err != nil { + return nil, err + } + + var s = &StaticImage{i, nil} + if parent != nil { + s.ConnectHandlers(parent) + } + + return s, nil +} + +func (s *StaticImage) ConnectHandlers(connector primitives.Connector) { + connector.Connect("enter-notify-event", func() { + if s.animation != nil { + s.Image.SetFromAnimation(s.animation) + } + }) + connector.Connect("leave-notify-event", func() { + if s.animation != nil { + s.Image.SetFromPixbuf(s.animation.GetStaticImage()) + } + }) +} + +func (s *StaticImage) SetFromPixbuf(pb *gdk.Pixbuf) { + s.animation = nil + s.Image.SetFromPixbuf(pb) +} + +func (s *StaticImage) SetFromAnimation(anim *gdk.PixbufAnimation) { + s.animation = anim + s.Image.SetFromPixbuf(anim.GetStaticImage()) +} + +func (s *StaticImage) GetAnimation() *gdk.PixbufAnimation { + return s.animation } type Image struct { diff --git a/internal/ui/rich/image.go b/internal/ui/rich/image.go index f503bfb..2dd4bc7 100644 --- a/internal/ui/rich/image.go +++ b/internal/ui/rich/image.go @@ -10,16 +10,33 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage" "github.com/diamondburned/cchat/text" "github.com/diamondburned/imgutil" + "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) type IconerFn = func(context.Context, cchat.IconContainer) (func(), error) +type RoundIconContainer interface { + gtk.IWidget + httputil.ImageContainer + primitives.ImageIconSetter + roundimage.RadiusSetter + + GetStorageType() gtk.ImageType + GetPixbuf() *gdk.Pixbuf + GetAnimation() *gdk.PixbufAnimation +} + +var ( + _ RoundIconContainer = (*roundimage.Image)(nil) + _ RoundIconContainer = (*roundimage.StaticImage)(nil) +) + // Icon represents a rounded image container. type Icon struct { *gtk.Revealer - Image *roundimage.Image + Image RoundIconContainer procs []imgutil.Processor size int @@ -34,13 +51,16 @@ const DefaultIconSize = 16 var _ cchat.IconContainer = (*Icon)(nil) func NewIcon(sizepx int, procs ...imgutil.Processor) *Icon { + img, _ := roundimage.NewImage(0) + img.Show() + return NewCustomIcon(img, sizepx, procs...) +} + +func NewCustomIcon(img RoundIconContainer, sizepx int, procs ...imgutil.Processor) *Icon { if sizepx == 0 { sizepx = DefaultIconSize } - img, _ := roundimage.NewImage(0) - img.Show() - rev, _ := gtk.RevealerNew() rev.Add(img) rev.SetRevealChild(false) @@ -94,7 +114,7 @@ func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) { i.SetRevealChild(true) if iconName != "" { - primitives.SetImageIcon(i.Image.Image, iconName, iconSzPx) + primitives.SetImageIcon(i.Image, iconName, iconSzPx) } } @@ -172,7 +192,9 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { l := NewLabel(content) l.Show() - i := NewIcon(0) + img, _ := roundimage.NewStaticImage(nil, 0) + img.Show() + i := NewCustomIcon(img, 0) i.Show() box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) @@ -183,6 +205,8 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { b, _ := gtk.ToggleButtonNew() b.Add(box) + img.ConnectHandlers(b) + return &ToggleButtonImage{ ToggleButton: *b, Labeler: l, // easy inheritance of methods diff --git a/internal/ui/rich/labeluri/labeluri.go b/internal/ui/rich/labeluri/labeluri.go index 0ac15da..ec3324d 100644 --- a/internal/ui/rich/labeluri/labeluri.go +++ b/internal/ui/rich/labeluri/labeluri.go @@ -91,7 +91,7 @@ func BindRichLabel(label Labeler) { var output = label.Output() if mention := output.IsMention(uri); mention != nil { - if p := popoverMentioner(label, output.Input, mention); p != nil { + if p := NewPopoverMentioner(label, output.Input, mention); p != nil { p.SetPointingTo(ptr) p.Popup() } @@ -104,12 +104,12 @@ func BindRichLabel(label Labeler) { } func PopoverMentioner(rel gtk.IWidget, input string, mention text.Mentioner) { - if p := popoverMentioner(rel, input, mention); p != nil { + if p := NewPopoverMentioner(rel, input, mention); p != nil { p.Popup() } } -func popoverMentioner(rel gtk.IWidget, input string, mention text.Mentioner) *gtk.Popover { +func NewPopoverMentioner(rel gtk.IWidget, input string, mention text.Mentioner) *gtk.Popover { var info = mention.MentionInfo() if info.Empty() { return nil diff --git a/internal/ui/rich/parser/markup/markup.go b/internal/ui/rich/parser/markup/markup.go index 1aa3a63..ec30757 100644 --- a/internal/ui/rich/parser/markup/markup.go +++ b/internal/ui/rich/parser/markup/markup.go @@ -61,6 +61,12 @@ type RenderConfig struct { NoMentionLinks bool } +// NoMentionLinks is the config to render author names. It disables author +// mention links, as there's no way to make normal names not appear blue. +var NoMentionLinks = RenderConfig{ + NoMentionLinks: true, +} + func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { // Fast path. if len(content.Segments) == 0 { diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index 7ccf0f3..0409eab 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -71,7 +71,9 @@ type Row struct { unreadClass primitives.ClassEnum } -var rowCSS = primitives.PrepareClassCSS("session-row", ` +var rowCSS = primitives.PrepareClassCSS("session-row", + button.UnreadColorDefs+` + .session-row:last-child { border-radius: 0 0 14px 14px; } @@ -103,8 +105,7 @@ var rowCSS = primitives.PrepareClassCSS("session-row", ` 0.65, ), 0.85); } - -`+button.UnreadColorDefs) +`) var rowIconCSS = primitives.PrepareClassCSS("session-icon", ` .session-icon {