mirror of
https://github.com/diamondburned/cchat-gtk.git
synced 2025-05-23 07:31:14 +00:00
Session restore and icons added
This commit is contained in:
parent
2d628fbbb3
commit
43aa4dcef3
2
go.mod
2
go.mod
|
@ -4,6 +4,8 @@ go 1.14
|
|||
|
||||
replace github.com/diamondburned/cchat-mock => ../cchat-mock/
|
||||
|
||||
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a
|
||||
|
||||
require (
|
||||
github.com/Xuanwo/go-locale v0.2.0
|
||||
github.com/diamondburned/cchat v0.0.15
|
||||
|
|
2
go.sum
2
go.sum
|
@ -9,6 +9,8 @@ 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.15 h1:1o4OX8zw/CdSv3Idaylz7vjHVOZKEi/xkg8BpEvtsHY=
|
||||
github.com/diamondburned/cchat v0.0.15/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
|
||||
github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a h1:pH6WLWVhzzvpXjGwPQbPhhp1g0ZMLZFS5S8zLoRGYRg=
|
||||
github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
|
||||
github.com/diamondburned/imgutil v0.0.0-20200606035324-63abbc0fdea6 h1:APALM1hskCByjOVW9CoUwjg0TIJgKZ62dgFr/9soqss=
|
||||
github.com/diamondburned/imgutil v0.0.0-20200606035324-63abbc0fdea6/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
|
||||
github.com/die-net/lrucache v0.0.0-20190707192454-883874fe3947 h1:U/5Sq2nJQ0XDyks+8ATghtHSuquIGq7JYrqSrvtR2dg=
|
||||
|
|
|
@ -2,9 +2,9 @@ package gts
|
|||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||
"github.com/gotk3/gotk3/gdk"
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
@ -12,7 +12,6 @@ import (
|
|||
const AppID = "com.github.diamondburned.cchat-gtk"
|
||||
|
||||
var Args = append([]string{}, os.Args...)
|
||||
var recvPool *sync.Pool
|
||||
|
||||
var App struct {
|
||||
*gtk.Application
|
||||
|
@ -22,13 +21,6 @@ var App struct {
|
|||
|
||||
func init() {
|
||||
gtk.Init(&Args)
|
||||
|
||||
recvPool = &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return make(chan struct{})
|
||||
},
|
||||
}
|
||||
|
||||
App.Application, _ = gtk.ApplicationNew(AppID, 0)
|
||||
}
|
||||
|
||||
|
@ -98,8 +90,7 @@ func ExecAsync(fn func()) {
|
|||
// ExecSync executes the function asynchronously, but returns a channel that
|
||||
// indicates when the job is done.
|
||||
func ExecSync(fn func()) <-chan struct{} {
|
||||
ch := recvPool.Get().(chan struct{})
|
||||
defer recvPool.Put(ch)
|
||||
var ch = make(chan struct{})
|
||||
|
||||
glib.IdleAdd(func() {
|
||||
fn()
|
||||
|
@ -108,3 +99,8 @@ func ExecSync(fn func()) <-chan struct{} {
|
|||
|
||||
return ch
|
||||
}
|
||||
|
||||
func EventIsRightClick(ev *gdk.Event) bool {
|
||||
keyev := gdk.EventButtonNewFromEvent(ev)
|
||||
return keyev.Type() == gdk.EVENT_BUTTON_PRESS && keyev.Button() == gdk.BUTTON_SECONDARY
|
||||
}
|
||||
|
|
|
@ -8,16 +8,68 @@ import (
|
|||
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||
"github.com/diamondburned/imgutil"
|
||||
"github.com/gotk3/gotk3/gdk"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// AsyncImage loads an image. This method uses the cache.
|
||||
func AsyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) {
|
||||
go asyncImage(img, url, procs...)
|
||||
type ImageContainer interface {
|
||||
SetFromPixbuf(*gdk.Pixbuf)
|
||||
SetFromAnimation(*gdk.PixbufAnimation)
|
||||
}
|
||||
|
||||
func asyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) {
|
||||
type ImageContainerSizer interface {
|
||||
ImageContainer
|
||||
SetSizeRequest(w, h int)
|
||||
}
|
||||
|
||||
// AsyncImage loads an image. This method uses the cache.
|
||||
func AsyncImage(img ImageContainer, url string, procs ...imgutil.Processor) {
|
||||
go syncImageFn(img, url, procs, func(l *gdk.PixbufLoader, gif bool) {
|
||||
l.Connect("area-prepared", areaPreparedFn(img, gif))
|
||||
})
|
||||
}
|
||||
|
||||
// AsyncImageSized resizes using GdkPixbuf. This method does not use the cache.
|
||||
func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...imgutil.Processor) {
|
||||
go syncImageFn(img, url, procs, func(l *gdk.PixbufLoader, gif bool) {
|
||||
l.Connect("size-prepared", func(l *gdk.PixbufLoader, imgW, imgH int) {
|
||||
w, h = imgutil.MaxSize(imgW, imgH, w, h)
|
||||
if w != imgW || h != imgH {
|
||||
l.SetSize(w, h)
|
||||
img.SetSizeRequest(w, h)
|
||||
}
|
||||
})
|
||||
|
||||
l.Connect("area-prepared", areaPreparedFn(img, gif))
|
||||
})
|
||||
}
|
||||
|
||||
func areaPreparedFn(img ImageContainer, gif bool) func(l *gdk.PixbufLoader) {
|
||||
return func(l *gdk.PixbufLoader) {
|
||||
if !gif {
|
||||
p, err := l.GetPixbuf()
|
||||
if err != nil {
|
||||
log.Error(errors.Wrap(err, "Failed to get pixbuf"))
|
||||
return
|
||||
}
|
||||
gts.ExecAsync(func() { img.SetFromPixbuf(p) })
|
||||
} else {
|
||||
p, err := l.GetAnimation()
|
||||
if err != nil {
|
||||
log.Error(errors.Wrap(err, "Failed to get animation"))
|
||||
return
|
||||
}
|
||||
gts.ExecAsync(func() { img.SetFromAnimation(p) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncImageFn(
|
||||
img ImageContainer,
|
||||
url string,
|
||||
procs []imgutil.Processor,
|
||||
middle func(l *gdk.PixbufLoader, gif bool),
|
||||
) {
|
||||
|
||||
r, err := get(url, true)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
|
@ -36,24 +88,8 @@ func asyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) {
|
|||
// This is a very important signal, so we must do it synchronously. Gotk3's
|
||||
// callback implementation requires all connects to be synchronous to a
|
||||
// certain thread.
|
||||
gts.ExecSync(func() {
|
||||
l.Connect("area-prepared", func() {
|
||||
if gif {
|
||||
p, err := l.GetPixbuf()
|
||||
if err != nil {
|
||||
log.Error(errors.Wrap(err, "Failed to get pixbuf"))
|
||||
return
|
||||
}
|
||||
img.SetFromPixbuf(p)
|
||||
} else {
|
||||
p, err := l.GetAnimation()
|
||||
if err != nil {
|
||||
log.Error(errors.Wrap(err, "Failed to get animation"))
|
||||
return
|
||||
}
|
||||
img.SetFromAnimation(p)
|
||||
}
|
||||
})
|
||||
<-gts.ExecSync(func() {
|
||||
middle(l, gif)
|
||||
})
|
||||
|
||||
// If we have processors, then write directly in there.
|
||||
|
@ -77,9 +113,3 @@ func asyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) {
|
|||
log.Error(errors.Wrap(err, "Failed to close pixbuf"))
|
||||
}
|
||||
}
|
||||
|
||||
// AsyncImageSized resizes using GdkPixbuf. This method does not use the cache.
|
||||
func AsyncImageSized(img *gtk.Image, url string, w, h int, procs ...imgutil.Processor) {
|
||||
// TODO
|
||||
panic("TODO")
|
||||
}
|
||||
|
|
|
@ -5,14 +5,12 @@ import (
|
|||
"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 {
|
||||
func get(service string, v interface{}) error {
|
||||
s, err := keyring.Get("cchat-gtk", service)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -33,57 +31,24 @@ func set(service string, v interface{}) error {
|
|||
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))
|
||||
type Session struct {
|
||||
ID string
|
||||
Name string
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
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 {
|
||||
func SaveSessions(serviceName string, sessions []Session) {
|
||||
if err := set(serviceName, sessions); 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
|
||||
|
||||
func RestoreSessions(serviceName string) (sessions []Session) {
|
||||
// Ignore the error, it's not important.
|
||||
if err := getThenDestroy(service.Name(), &sessionData); err != nil {
|
||||
if err := get(serviceName, &sessions); 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
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -11,15 +11,17 @@ type Container struct {
|
|||
}
|
||||
|
||||
func NewContainer() *Container {
|
||||
c := &Container{}
|
||||
c.GridContainer = container.NewGridContainer(c)
|
||||
return c
|
||||
return &Container{
|
||||
GridContainer: container.NewGridContainer(constructor{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage {
|
||||
type constructor struct{}
|
||||
|
||||
func (constructor) NewMessage(msg cchat.MessageCreate) container.GridMessage {
|
||||
return NewMessage(msg)
|
||||
}
|
||||
|
||||
func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage {
|
||||
func (constructor) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage {
|
||||
return NewPresendMessage(msg)
|
||||
}
|
||||
|
|
|
@ -9,17 +9,17 @@ import (
|
|||
)
|
||||
|
||||
type PresendMessage struct {
|
||||
*message.GenericPresendContainer
|
||||
message.PresendContainer
|
||||
Message
|
||||
}
|
||||
|
||||
func NewPresendMessage(msg input.PresendMessage) PresendMessage {
|
||||
return PresendMessage{
|
||||
GenericPresendContainer: message.NewPresendContainer(msg),
|
||||
}
|
||||
}
|
||||
var msgc = message.NewPresendContainer(msg)
|
||||
|
||||
func (p PresendMessage) Attach(grid *gtk.Grid, row int) {
|
||||
attachGenericContainer(p.GenericContainer, grid, row)
|
||||
return PresendMessage{
|
||||
PresendContainer: msgc,
|
||||
Message: Message{msgc.GenericContainer},
|
||||
}
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
|
@ -29,15 +29,13 @@ type Message struct {
|
|||
var _ container.GridMessage = (*Message)(nil)
|
||||
|
||||
func NewMessage(msg cchat.MessageCreate) Message {
|
||||
return Message{
|
||||
GenericContainer: message.NewContainer(msg),
|
||||
}
|
||||
msgc := message.NewContainer(msg)
|
||||
message.FillContainer(msgc, msg)
|
||||
return Message{msgc}
|
||||
}
|
||||
|
||||
func NewEmptyMessage() Message {
|
||||
return Message{
|
||||
GenericContainer: message.NewEmptyContainer(),
|
||||
}
|
||||
return Message{message.NewEmptyContainer()}
|
||||
}
|
||||
|
||||
// TODO: fix a bug here related to new messages overlapping
|
||||
|
@ -46,7 +44,5 @@ func (m Message) Attach(grid *gtk.Grid, row int) {
|
|||
}
|
||||
|
||||
func attachGenericContainer(m *message.GenericContainer, grid *gtk.Grid, row int) {
|
||||
grid.Attach(m.Timestamp, 0, row, 1, 1)
|
||||
grid.Attach(m.Username, 1, row, 1, 1)
|
||||
grid.Attach(m.Content, 2, row, 1, 1)
|
||||
container.AttachRow(grid, row, m.Timestamp, m.Username, m.Content)
|
||||
}
|
||||
|
|
|
@ -21,13 +21,6 @@ type PresendGridMessage interface {
|
|||
message.PresendContainer
|
||||
}
|
||||
|
||||
// gridMessage w/ required internals
|
||||
type gridMessage struct {
|
||||
GridMessage
|
||||
presend message.PresendContainer // this shouldn't be here but i'm lazy
|
||||
index int
|
||||
}
|
||||
|
||||
// Constructor is an interface for making custom message implementations which
|
||||
// allows GridContainer to generically work with.
|
||||
type Constructor interface {
|
||||
|
@ -47,6 +40,14 @@ type Container interface {
|
|||
PresendMessage(input.PresendMessage) (done func(sendError error))
|
||||
}
|
||||
|
||||
func AttachRow(grid *gtk.Grid, row int, widgets ...gtk.IWidget) {
|
||||
for i, w := range widgets {
|
||||
grid.Attach(w, i, row, 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const ColumnSpacing = 10
|
||||
|
||||
// GridContainer is an implementation of Container, which allows flexible
|
||||
// message grids.
|
||||
type GridContainer struct {
|
||||
|
@ -55,8 +56,15 @@ type GridContainer struct {
|
|||
|
||||
construct Constructor
|
||||
|
||||
messages map[string]*gridMessage
|
||||
nonceMsgs map[string]*gridMessage
|
||||
messages []*gridMessage // sync w/ grid rows
|
||||
messageIDs map[string]int
|
||||
nonceMsgs map[string]int
|
||||
}
|
||||
|
||||
// gridMessage w/ required internals
|
||||
type gridMessage struct {
|
||||
GridMessage
|
||||
presend message.PresendContainer // this shouldn't be here but i'm lazy
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -66,7 +74,7 @@ var (
|
|||
|
||||
func NewGridContainer(constr Constructor) *GridContainer {
|
||||
grid, _ := gtk.GridNew()
|
||||
grid.SetColumnSpacing(10)
|
||||
grid.SetColumnSpacing(ColumnSpacing)
|
||||
grid.SetRowSpacing(5)
|
||||
grid.SetMarginStart(5)
|
||||
grid.SetMarginEnd(5)
|
||||
|
@ -82,42 +90,42 @@ func NewGridContainer(constr Constructor) *GridContainer {
|
|||
ScrolledWindow: sw,
|
||||
Main: grid,
|
||||
construct: constr,
|
||||
messages: map[string]*gridMessage{},
|
||||
nonceMsgs: map[string]*gridMessage{},
|
||||
messageIDs: map[string]int{},
|
||||
nonceMsgs: map[string]int{},
|
||||
}
|
||||
|
||||
return &container
|
||||
}
|
||||
|
||||
func (c *GridContainer) Reset() {
|
||||
// does this actually work?
|
||||
var rows = c.len()
|
||||
for i := 0; i < rows; i++ {
|
||||
c.Main.RemoveRow(i)
|
||||
}
|
||||
c.Main.GetChildren().Foreach(func(v interface{}) {
|
||||
// Unsafe assertion ftw.
|
||||
c.Main.Remove(v.(gtk.IWidget))
|
||||
})
|
||||
|
||||
c.messages = map[string]*gridMessage{}
|
||||
c.nonceMsgs = map[string]*gridMessage{}
|
||||
c.messages = nil
|
||||
c.messageIDs = map[string]int{}
|
||||
c.nonceMsgs = map[string]int{}
|
||||
|
||||
c.ScrolledWindow.Bottomed = true
|
||||
}
|
||||
|
||||
func (c *GridContainer) len() int {
|
||||
return len(c.messages) + len(c.nonceMsgs)
|
||||
}
|
||||
|
||||
// PresendMessage is not thread-safe.
|
||||
func (c *GridContainer) PresendMessage(msg input.PresendMessage) func(error) {
|
||||
presend := c.construct.NewPresendMessage(msg)
|
||||
|
||||
msgc := gridMessage{
|
||||
msgc := &gridMessage{
|
||||
GridMessage: presend,
|
||||
presend: presend,
|
||||
index: c.len(),
|
||||
}
|
||||
|
||||
c.nonceMsgs[presend.Nonce()] = &msgc
|
||||
msgc.Attach(c.Main, msgc.index)
|
||||
// Grab index before appending, as that'll be where the added message is.
|
||||
index := len(c.messages)
|
||||
|
||||
c.messages = append(c.messages, msgc)
|
||||
|
||||
c.nonceMsgs[presend.Nonce()] = index
|
||||
msgc.Attach(c.Main, index)
|
||||
|
||||
return func(err error) {
|
||||
if err != nil {
|
||||
|
@ -127,19 +135,31 @@ func (c *GridContainer) PresendMessage(msg input.PresendMessage) func(error) {
|
|||
}
|
||||
}
|
||||
|
||||
// FindMessage is not thread-safe. This exists for backwards compatibility.
|
||||
func (c *GridContainer) FindMessage(msg cchat.MessageHeader) GridMessage {
|
||||
if m := c.findMessage(msg); m != nil {
|
||||
// FindMessage iterates backwards and returns the message if isMessage() returns
|
||||
// true on that message.
|
||||
func (c *GridContainer) FindMessage(isMessage func(msg GridMessage) bool) GridMessage {
|
||||
for i := len(c.messages) - 1; i >= 0; i-- {
|
||||
if msg := c.messages[i].GridMessage; isMessage(msg) {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Message finds the message state in the container. It is not thread-safe. This
|
||||
// exists for backwards compatibility.
|
||||
func (c *GridContainer) Message(msg cchat.MessageHeader) GridMessage {
|
||||
if m := c.message(msg); m != nil {
|
||||
return m.GridMessage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GridContainer) findMessage(msg cchat.MessageHeader) *gridMessage {
|
||||
func (c *GridContainer) message(msg cchat.MessageHeader) *gridMessage {
|
||||
// Search using the ID first.
|
||||
m, ok := c.messages[msg.ID()]
|
||||
i, ok := c.messageIDs[msg.ID()]
|
||||
if ok {
|
||||
return m
|
||||
return c.messages[i]
|
||||
}
|
||||
|
||||
// Is this an existing message?
|
||||
|
@ -147,11 +167,14 @@ func (c *GridContainer) findMessage(msg cchat.MessageHeader) *gridMessage {
|
|||
var nonce = noncer.Nonce()
|
||||
|
||||
// Things in this map are guaranteed to have presend != nil.
|
||||
m, ok := c.nonceMsgs[nonce]
|
||||
i, ok := c.nonceMsgs[nonce]
|
||||
if ok {
|
||||
// Move the message outside nonceMsgs.
|
||||
// Move the message outside nonceMsgs and into messageIDs.
|
||||
delete(c.nonceMsgs, nonce)
|
||||
c.messages[msg.ID()] = m
|
||||
c.messageIDs[msg.ID()] = i
|
||||
|
||||
// Get the message pointer.
|
||||
m := c.messages[i]
|
||||
|
||||
// Set the right ID.
|
||||
m.presend.SetID(msg.ID())
|
||||
|
@ -168,27 +191,31 @@ func (c *GridContainer) findMessage(msg cchat.MessageHeader) *gridMessage {
|
|||
|
||||
func (c *GridContainer) CreateMessage(msg cchat.MessageCreate) {
|
||||
gts.ExecAsync(func() {
|
||||
// Attempt update before insert (aka upsert).
|
||||
if msgc := c.FindMessage(msg); msgc != nil {
|
||||
// Attempt to update before insertion (aka upsert).
|
||||
if msgc := c.Message(msg); msgc != nil {
|
||||
msgc.UpdateAuthor(msg.Author())
|
||||
msgc.UpdateContent(msg.Content())
|
||||
msgc.UpdateTimestamp(msg.Time())
|
||||
return
|
||||
}
|
||||
|
||||
msgc := gridMessage{
|
||||
msgc := &gridMessage{
|
||||
GridMessage: c.construct.NewMessage(msg),
|
||||
index: c.len(),
|
||||
}
|
||||
|
||||
c.messages[msgc.ID()] = &msgc
|
||||
msgc.Attach(c.Main, msgc.index)
|
||||
// Grab index before appending, as that'll be where the added message is.
|
||||
index := len(c.messages)
|
||||
|
||||
c.messages = append(c.messages, msgc)
|
||||
|
||||
c.messageIDs[msgc.ID()] = index
|
||||
msgc.Attach(c.Main, index)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) {
|
||||
gts.ExecAsync(func() {
|
||||
if msgc := c.FindMessage(msg); msgc != nil {
|
||||
if msgc := c.Message(msg); msgc != nil {
|
||||
if author := msg.Author(); author != nil {
|
||||
msgc.UpdateAuthor(author)
|
||||
}
|
||||
|
@ -202,9 +229,13 @@ func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) {
|
|||
func (c *GridContainer) 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)
|
||||
if i, ok := c.messageIDs[msg.ID()]; ok {
|
||||
// Remove off the slice.
|
||||
c.messages = append(c.messages[:i], c.messages[i+1:]...)
|
||||
|
||||
// Remove off the map.
|
||||
delete(c.messageIDs, msg.ID())
|
||||
c.Main.RemoveRow(i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,13 +1,62 @@
|
|||
package cozy
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/autoscroll"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
|
||||
)
|
||||
|
||||
type AvatarPixbufCopier interface {
|
||||
CopyAvatarPixbuf(httputil.ImageContainer)
|
||||
}
|
||||
|
||||
const (
|
||||
AvatarSize = 40
|
||||
AvatarMargin = 10
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
*autoscroll.ScrolledWindow
|
||||
main *gtk.Grid
|
||||
messages map[string]Message
|
||||
nonceMsgs map[string]Message
|
||||
*container.GridContainer
|
||||
}
|
||||
|
||||
func NewContainer() *Container {
|
||||
c := &Container{}
|
||||
c.GridContainer = container.NewGridContainer(c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage {
|
||||
var newmsg = NewFullMessage(msg)
|
||||
|
||||
// Try and reuse an existing avatar.
|
||||
if author := msg.Author(); !c.reuseAvatar(author.ID(), newmsg.Avatar) {
|
||||
// Fetch a new avatar if we can't reuse the old one.
|
||||
newmsg.updateAuthorAvatar(author)
|
||||
}
|
||||
|
||||
return newmsg
|
||||
}
|
||||
|
||||
func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage {
|
||||
var presend = NewFullSendingMessage(msg)
|
||||
c.reuseAvatar(msg.AuthorID(), presend.Avatar)
|
||||
return presend
|
||||
}
|
||||
|
||||
func (c *Container) reuseAvatar(authorID string, img httputil.ImageContainer) (reused bool) {
|
||||
// Search the old author if we have any.
|
||||
msgc := c.FindMessage(func(msgc container.GridMessage) bool {
|
||||
return msgc.AuthorID() == authorID
|
||||
})
|
||||
|
||||
// Is this a message that we can work with? We have to assert to
|
||||
// FullSendingMessage because that's where our messages are.
|
||||
copier, ok := msgc.(AvatarPixbufCopier)
|
||||
if ok {
|
||||
// Borrow the avatar URL.
|
||||
copier.CopyAvatarPixbuf(img)
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
package cozy
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
type Message interface {
|
||||
gtk.IWidget
|
||||
message.Container
|
||||
}
|
||||
|
||||
type FullMessage struct {
|
||||
*gtk.Box
|
||||
|
||||
Avatar *gtk.Image
|
||||
*message.GenericContainer
|
||||
}
|
||||
|
||||
func NewFullMessage()
|
11
internal/ui/messages/container/cozy/message_compact.go
Normal file
11
internal/ui/messages/container/cozy/message_compact.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
package cozy
|
||||
|
||||
// CompactMessage is a message that follows after FullMessage. It does not show
|
||||
// the header, and the avatar is invisible.
|
||||
type CompactMessage struct {
|
||||
// Essentially, CompactMessage is just a full message with some things
|
||||
// hidden. Its Avatar and Timestamp will still be updated. This is a
|
||||
// trade-off between performance, efficiency and code length.
|
||||
|
||||
*FullMessage
|
||||
}
|
116
internal/ui/messages/container/cozy/message_full.go
Normal file
116
internal/ui/messages/container/cozy/message_full.go
Normal file
|
@ -0,0 +1,116 @@
|
|||
package cozy
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
|
||||
"github.com/diamondburned/imgutil"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
type FullMessage struct {
|
||||
*message.GenericContainer
|
||||
|
||||
// Grid widgets.
|
||||
Avatar *gtk.Image
|
||||
MainBox *gtk.Box // wraps header and content
|
||||
|
||||
// Header wraps author and timestamp.
|
||||
HeaderBox *gtk.Box
|
||||
}
|
||||
|
||||
var (
|
||||
_ AvatarPixbufCopier = (*FullMessage)(nil)
|
||||
_ message.Container = (*FullMessage)(nil)
|
||||
_ container.GridMessage = (*FullMessage)(nil)
|
||||
)
|
||||
|
||||
func NewFullMessage(msg cchat.MessageCreate) *FullMessage {
|
||||
msgc := WrapFullMessage(message.NewContainer(msg))
|
||||
// Don't update the avatar.
|
||||
msgc.UpdateContent(msg.Content())
|
||||
msgc.UpdateAuthorName(msg.Author().Name())
|
||||
msgc.UpdateTimestamp(msg.Time())
|
||||
return msgc
|
||||
}
|
||||
|
||||
func WrapFullMessage(gc *message.GenericContainer) *FullMessage {
|
||||
avatar, _ := gtk.ImageNew()
|
||||
avatar.SetSizeRequest(AvatarSize, AvatarSize)
|
||||
avatar.SetVAlign(gtk.ALIGN_START)
|
||||
avatar.SetMarginStart(container.ColumnSpacing * 2)
|
||||
avatar.Show()
|
||||
|
||||
header, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||
header.PackStart(gc.Username, false, false, 0)
|
||||
header.PackStart(gc.Timestamp, false, false, 7) // padding
|
||||
header.Show()
|
||||
|
||||
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
main.PackStart(header, false, false, 0)
|
||||
main.PackStart(gc.Content, false, false, 2)
|
||||
main.SetMarginBottom(2)
|
||||
main.SetMarginEnd(container.ColumnSpacing * 2)
|
||||
main.Show()
|
||||
|
||||
return &FullMessage{
|
||||
GenericContainer: gc,
|
||||
Avatar: avatar,
|
||||
MainBox: main,
|
||||
HeaderBox: header,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FullMessage) UpdateAuthor(author cchat.MessageAuthor) {
|
||||
// Call the parent's method to update the labels.
|
||||
m.GenericContainer.UpdateAuthor(author)
|
||||
m.updateAuthorAvatar(author)
|
||||
}
|
||||
|
||||
func (m *FullMessage) updateAuthorAvatar(author cchat.MessageAuthor) {
|
||||
// If the author has an avatar:
|
||||
if avatarer, ok := author.(cchat.MessageAuthorAvatar); ok {
|
||||
// Download the avatar asynchronously.
|
||||
httputil.AsyncImageSized(
|
||||
m.Avatar,
|
||||
avatarer.Avatar(),
|
||||
AvatarSize, AvatarSize,
|
||||
imgutil.Round(true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FullMessage) CopyAvatarPixbuf(dst httputil.ImageContainer) {
|
||||
switch m.Avatar.GetStorageType() {
|
||||
case gtk.IMAGE_PIXBUF:
|
||||
dst.SetFromPixbuf(m.Avatar.GetPixbuf())
|
||||
case gtk.IMAGE_ANIMATION:
|
||||
dst.SetFromAnimation(m.Avatar.GetAnimation())
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FullMessage) Attach(grid *gtk.Grid, row int) {
|
||||
container.AttachRow(grid, row, m.Avatar, m.MainBox)
|
||||
}
|
||||
|
||||
type FullSendingMessage struct {
|
||||
message.PresendContainer
|
||||
FullMessage
|
||||
}
|
||||
|
||||
var (
|
||||
_ AvatarPixbufCopier = (*FullSendingMessage)(nil)
|
||||
_ message.Container = (*FullSendingMessage)(nil)
|
||||
_ container.GridMessage = (*FullSendingMessage)(nil)
|
||||
)
|
||||
|
||||
func NewFullSendingMessage(msg input.PresendMessage) *FullSendingMessage {
|
||||
var msgc = message.NewPresendContainer(msg)
|
||||
|
||||
return &FullSendingMessage{
|
||||
PresendContainer: msgc,
|
||||
FullMessage: *WrapFullMessage(msgc.GenericContainer),
|
||||
}
|
||||
}
|
|
@ -4,51 +4,10 @@ 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 usernameContainer struct {
|
||||
*gtk.Revealer
|
||||
label *rich.Label
|
||||
}
|
||||
|
||||
func newUsernameContainer() *usernameContainer {
|
||||
label := rich.NewLabel(text.Rich{})
|
||||
label.SetMaxWidthChars(35)
|
||||
label.SetVAlign(gtk.ALIGN_START)
|
||||
label.SetMarginTop(inputmargin)
|
||||
label.SetMarginBottom(inputmargin)
|
||||
label.SetMarginStart(10)
|
||||
label.SetMarginEnd(10)
|
||||
label.Show()
|
||||
|
||||
rev, _ := gtk.RevealerNew()
|
||||
rev.SetRevealChild(false)
|
||||
rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
|
||||
rev.SetTransitionDuration(50)
|
||||
rev.Add(label)
|
||||
|
||||
return &usernameContainer{rev, label}
|
||||
}
|
||||
|
||||
// GetLabel is not thread-safe.
|
||||
func (u *usernameContainer) GetLabel() text.Rich {
|
||||
return u.label.GetLabel()
|
||||
}
|
||||
|
||||
// SetLabel is thread-safe.
|
||||
func (u *usernameContainer) SetLabel(content text.Rich) {
|
||||
gts.ExecAsync(func() {
|
||||
u.label.SetLabelUnsafe(content)
|
||||
|
||||
// Reveal if the name is not empty.
|
||||
u.SetRevealChild(!u.label.GetLabel().Empty())
|
||||
})
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
*gtk.Box
|
||||
username *usernameContainer
|
||||
|
@ -117,18 +76,8 @@ func NewField(ctrl Controller) *Field {
|
|||
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)
|
||||
}
|
||||
// Update the left username container in the input.
|
||||
f.username.Update(session, sender)
|
||||
|
||||
// Set the sender.
|
||||
f.sender = sender
|
||||
|
|
115
internal/ui/messages/input/username.go
Normal file
115
internal/ui/messages/input/username.go
Normal file
|
@ -0,0 +1,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/diamondburned/imgutil"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const AvatarSize = 20
|
||||
|
||||
type usernameContainer struct {
|
||||
*gtk.Revealer
|
||||
|
||||
main *gtk.Box
|
||||
|
||||
avatar *rich.Icon
|
||||
label *rich.Label
|
||||
}
|
||||
|
||||
var (
|
||||
_ cchat.LabelContainer = (*usernameContainer)(nil)
|
||||
_ cchat.IconContainer = (*usernameContainer)(nil)
|
||||
)
|
||||
|
||||
func newUsernameContainer() *usernameContainer {
|
||||
avatar := rich.NewIcon(AvatarSize, imgutil.Round(true))
|
||||
avatar.SetPlaceholderIcon("user-available-symbolic", AvatarSize)
|
||||
avatar.Show()
|
||||
|
||||
label := rich.NewLabel(text.Rich{})
|
||||
label.SetMaxWidthChars(35)
|
||||
label.Show()
|
||||
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
|
||||
box.PackStart(avatar, false, false, 0)
|
||||
box.PackStart(label, false, false, 0)
|
||||
box.SetMarginStart(10)
|
||||
box.SetMarginEnd(10)
|
||||
box.SetMarginTop(inputmargin)
|
||||
box.SetMarginBottom(inputmargin)
|
||||
box.SetVAlign(gtk.ALIGN_START)
|
||||
box.Show()
|
||||
|
||||
rev, _ := gtk.RevealerNew()
|
||||
rev.SetRevealChild(false)
|
||||
rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
|
||||
rev.SetTransitionDuration(50)
|
||||
rev.Add(box)
|
||||
|
||||
return &usernameContainer{
|
||||
Revealer: rev,
|
||||
main: box,
|
||||
avatar: avatar,
|
||||
label: label,
|
||||
}
|
||||
}
|
||||
|
||||
// Update is not thread-safe.
|
||||
func (u *usernameContainer) Update(session cchat.Session, sender cchat.ServerMessageSender) {
|
||||
// Does sender (aka Server) implement ServerNickname? If not, we fallback to
|
||||
// the username inside session.
|
||||
var err error
|
||||
if nicknamer, ok := sender.(cchat.ServerNickname); ok {
|
||||
err = errors.Wrap(nicknamer.Nickname(u), "Failed to get nickname")
|
||||
} else {
|
||||
err = errors.Wrap(session.Name(u), "Failed to get username")
|
||||
}
|
||||
|
||||
// Do a bit of trivial error handling.
|
||||
if err != nil {
|
||||
log.Warn(err)
|
||||
}
|
||||
|
||||
// Does session implement an icon? Update if so.
|
||||
if iconer, ok := session.(cchat.Icon); ok {
|
||||
err = iconer.Icon(u)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warn(errors.Wrap(err, "Failed to get icon"))
|
||||
}
|
||||
}
|
||||
|
||||
// GetLabel is not thread-safe.
|
||||
func (u *usernameContainer) GetLabel() text.Rich {
|
||||
return u.label.GetLabel()
|
||||
}
|
||||
|
||||
// SetLabel is thread-safe.
|
||||
func (u *usernameContainer) SetLabel(content text.Rich) {
|
||||
gts.ExecAsync(func() {
|
||||
u.label.SetLabelUnsafe(content)
|
||||
|
||||
// Reveal if the name is not empty.
|
||||
u.SetRevealChild(!u.label.GetLabel().Empty())
|
||||
})
|
||||
}
|
||||
|
||||
// SetIcon is thread-safe.
|
||||
func (u *usernameContainer) SetIcon(url string) {
|
||||
gts.ExecAsync(func() {
|
||||
u.avatar.SetIconUnsafe(url)
|
||||
|
||||
// Reveal if the icon URL is not empty. We don't touch anything if the
|
||||
// URL is empty, as the name might not be.
|
||||
if url != "" {
|
||||
u.SetRevealChild(true)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -22,6 +22,12 @@ type Container interface {
|
|||
UpdateTimestamp(time.Time)
|
||||
}
|
||||
|
||||
func FillContainer(c Container, msg cchat.MessageCreate) {
|
||||
c.UpdateAuthor(msg.Author())
|
||||
c.UpdateContent(msg.Content())
|
||||
c.UpdateTimestamp(msg.Time())
|
||||
}
|
||||
|
||||
// GenericContainer provides a single generic message container for subpackages
|
||||
// to use.
|
||||
type GenericContainer struct {
|
||||
|
@ -36,12 +42,12 @@ type GenericContainer struct {
|
|||
|
||||
var _ Container = (*GenericContainer)(nil)
|
||||
|
||||
// NewContainer creates a new message container with the given ID and nonce. It
|
||||
// does not update the widgets, so FillContainer should be called afterwards.
|
||||
func NewContainer(msg cchat.MessageCreate) *GenericContainer {
|
||||
c := NewEmptyContainer()
|
||||
c.id = msg.ID()
|
||||
c.UpdateTimestamp(msg.Time())
|
||||
c.UpdateAuthor(msg.Author())
|
||||
c.UpdateContent(msg.Content())
|
||||
c.authorID = msg.Author().ID()
|
||||
|
||||
if noncer, ok := msg.(cchat.MessageNonce); ok {
|
||||
c.nonce = noncer.Nonce()
|
||||
|
@ -102,7 +108,6 @@ func (m *GenericContainer) UpdateTimestamp(t time.Time) {
|
|||
}
|
||||
|
||||
func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) {
|
||||
m.authorID = author.ID()
|
||||
m.UpdateAuthorName(author.Name())
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@ import (
|
|||
)
|
||||
|
||||
type PresendContainer interface {
|
||||
Container
|
||||
SetID(id string)
|
||||
SetDone()
|
||||
SetSentError(err error)
|
||||
|
@ -24,7 +23,10 @@ type GenericPresendContainer struct {
|
|||
var _ PresendContainer = (*GenericPresendContainer)(nil)
|
||||
|
||||
func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer {
|
||||
c := NewEmptyContainer()
|
||||
return WrapPresendContainer(NewEmptyContainer(), msg)
|
||||
}
|
||||
|
||||
func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *GenericPresendContainer {
|
||||
c.nonce = msg.Nonce()
|
||||
c.authorID = msg.AuthorID()
|
||||
c.UpdateContent(text.Rich{Content: msg.Content()})
|
||||
|
@ -52,6 +54,8 @@ func (m *GenericPresendContainer) SetDone() {
|
|||
}
|
||||
|
||||
func (m *GenericPresendContainer) SetSentError(err error) {
|
||||
m.Content.SetMarkup(`<span color="red">` + html.EscapeString(m.Content.GetLabel()) + `</span>`)
|
||||
var content = html.EscapeString(m.Content.GetLabel())
|
||||
|
||||
m.Content.SetMarkup(`<span color="red">` + content + `</span>`)
|
||||
m.Content.SetTooltipText(err.Error())
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
|
||||
"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/gotk3/gotk3/gtk"
|
||||
"github.com/pkg/errors"
|
||||
|
@ -34,7 +34,8 @@ func NewView() *View {
|
|||
view := &View{}
|
||||
|
||||
// TODO: change
|
||||
view.Container = compact.NewContainer()
|
||||
// view.Container = compact.NewContainer()
|
||||
view.Container = cozy.NewContainer()
|
||||
view.SendInput = input.NewField(view)
|
||||
|
||||
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
|
|
|
@ -40,3 +40,21 @@ func SetImageIcon(img *gtk.Image, icon string, sizepx int) {
|
|||
img.SetProperty("pixel-size", sizepx)
|
||||
img.SetSizeRequest(sizepx, sizepx)
|
||||
}
|
||||
|
||||
type MenuItem struct {
|
||||
Name string
|
||||
Fn func()
|
||||
}
|
||||
|
||||
func AppendMenuItems(menu interface{ Append(gtk.IMenuItem) }, items []MenuItem) {
|
||||
for _, item := range items {
|
||||
menu.Append(NewMenuItem(item.Name, item.Fn))
|
||||
}
|
||||
}
|
||||
|
||||
func NewMenuItem(label string, fn func()) *gtk.MenuItem {
|
||||
mb, _ := gtk.MenuItemNewWithLabel(label)
|
||||
mb.Show()
|
||||
mb.Connect("activate", fn)
|
||||
return mb
|
||||
}
|
||||
|
|
|
@ -48,7 +48,21 @@ func NewIcon(sizepx int, procs ...imgutil.Processor) *Icon {
|
|||
}
|
||||
}
|
||||
|
||||
// Thread-unsafe methods should only be called right after construction.
|
||||
// URL is not thread-safe.
|
||||
func (i *Icon) URL() string {
|
||||
return i.url
|
||||
}
|
||||
|
||||
func (i *Icon) CopyPixbuf(dst httputil.ImageContainer) {
|
||||
switch i.Image.GetStorageType() {
|
||||
case gtk.IMAGE_PIXBUF:
|
||||
dst.SetFromPixbuf(i.Image.GetPixbuf())
|
||||
case gtk.IMAGE_ANIMATION:
|
||||
dst.SetFromAnimation(i.Image.GetAnimation())
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-unsafe setter methods should only be called right after construction.
|
||||
|
||||
// SetPlaceholderIcon is not thread-safe.
|
||||
func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) {
|
||||
|
@ -73,7 +87,14 @@ func (i *Icon) AddProcessors(procs ...imgutil.Processor) {
|
|||
|
||||
// SetIcon is thread-safe.
|
||||
func (i *Icon) SetIcon(url string) {
|
||||
gts.ExecAsync(func() { i.SetRevealChild(true) })
|
||||
gts.ExecAsync(func() {
|
||||
i.SetIconUnsafe(url)
|
||||
})
|
||||
}
|
||||
|
||||
// SetIconUnsafe is not thread-safe.
|
||||
func (i *Icon) SetIconUnsafe(url string) {
|
||||
i.SetRevealChild(true)
|
||||
i.url = url
|
||||
i.updateAsync()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package rich
|
||||
|
||||
import (
|
||||
"html"
|
||||
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
|
@ -11,9 +13,17 @@ import (
|
|||
|
||||
// TODO: parser
|
||||
|
||||
func MakeRed(content text.Rich) string {
|
||||
return `<span color="red">` + html.EscapeString(content.Content) + `</span>`
|
||||
}
|
||||
|
||||
type Labeler interface {
|
||||
// thread-safe
|
||||
cchat.LabelContainer // thread-safe
|
||||
GetLabel() text.Rich // not thread-safe
|
||||
|
||||
// not thread-safe
|
||||
SetLabelUnsafe(text.Rich)
|
||||
GetLabel() text.Rich
|
||||
GetText() string
|
||||
}
|
||||
|
||||
|
|
30
internal/ui/service/children.go
Normal file
30
internal/ui/service/children.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
type children struct {
|
||||
*gtk.Box
|
||||
Sessions map[string]*session.Row
|
||||
}
|
||||
|
||||
func newChildren() *children {
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
box.Show()
|
||||
|
||||
return &children{box, map[string]*session.Row{}}
|
||||
}
|
||||
|
||||
func (c *children) addSessionRow(id string, row *session.Row) {
|
||||
c.Sessions[id] = row
|
||||
c.Box.Add(row)
|
||||
}
|
||||
|
||||
func (c *children) removeSessionRow(id string) {
|
||||
if row, ok := c.Sessions[id]; ok {
|
||||
delete(c.Sessions, id)
|
||||
c.Box.Remove(row)
|
||||
}
|
||||
}
|
60
internal/ui/service/header.go
Normal file
60
internal/ui/service/header.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
package service
|
||||
|
||||
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/diamondburned/imgutil"
|
||||
"github.com/gotk3/gotk3/gdk"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const IconSize = 32
|
||||
|
||||
type header struct {
|
||||
*gtk.Box
|
||||
reveal *rich.ToggleButtonImage // no rich text here but it's left aligned
|
||||
add *gtk.Button
|
||||
|
||||
Menu *gtk.Menu
|
||||
}
|
||||
|
||||
func newHeader(svc cchat.Service) *header {
|
||||
reveal := rich.NewToggleButtonImage(text.Rich{Content: svc.Name()})
|
||||
reveal.Box.SetHAlign(gtk.ALIGN_START)
|
||||
reveal.Image.AddProcessors(imgutil.Round(true))
|
||||
reveal.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
|
||||
reveal.SetRelief(gtk.RELIEF_NONE)
|
||||
reveal.SetMode(true)
|
||||
reveal.Show()
|
||||
|
||||
add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON)
|
||||
add.SetRelief(gtk.RELIEF_NONE)
|
||||
add.SetSizeRequest(IconSize, IconSize)
|
||||
add.Show()
|
||||
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||
box.PackStart(reveal, true, true, 0)
|
||||
box.PackStart(add, false, false, 0)
|
||||
box.Show()
|
||||
|
||||
if iconer, ok := svc.(cchat.Icon); ok {
|
||||
if err := iconer.Icon(reveal); err != nil {
|
||||
log.Error(errors.Wrap(err, "Error getting session logo"))
|
||||
}
|
||||
}
|
||||
|
||||
menu, _ := gtk.MenuNew()
|
||||
|
||||
// Spawn the menu on right click.
|
||||
reveal.Connect("event", func(_ *gtk.ToggleButton, ev *gdk.Event) {
|
||||
if gts.EventIsRightClick(ev) {
|
||||
menu.PopupAtPointer(ev)
|
||||
}
|
||||
})
|
||||
|
||||
return &header{box, reveal, add, menu}
|
||||
}
|
|
@ -2,21 +2,23 @@ package service
|
|||
|
||||
import (
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||
"github.com/diamondburned/cchat-gtk/internal/keyring"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||
"github.com/diamondburned/cchat/text"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const IconSize = 32
|
||||
|
||||
type Controller interface {
|
||||
session.Controller
|
||||
|
||||
// MessageRowSelected is wrapped around session's MessageRowSelected.
|
||||
MessageRowSelected(*session.Row, *server.Row, cchat.ServerMessage)
|
||||
// AuthenticateSession is called to spawn the authentication dialog.
|
||||
AuthenticateSession(*Container, cchat.Service)
|
||||
// SaveAllSessions is called to save all available sessions from the menu.
|
||||
SaveAllSessions(*Container)
|
||||
}
|
||||
|
||||
type View struct {
|
||||
|
@ -52,10 +54,14 @@ func (v *View) AddService(svc cchat.Service, ctrl Controller) *Container {
|
|||
|
||||
type Container struct {
|
||||
*gtk.Box
|
||||
Service cchat.Service
|
||||
|
||||
header *header
|
||||
revealer *gtk.Revealer
|
||||
children *children
|
||||
rowctrl Controller
|
||||
|
||||
// Embed controller and extend it to override RestoreSession.
|
||||
Controller
|
||||
}
|
||||
|
||||
func NewContainer(svc cchat.Service, ctrl Controller) *Container {
|
||||
|
@ -76,7 +82,14 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container {
|
|||
|
||||
primitives.AddClass(box, "service")
|
||||
|
||||
var container = &Container{box, header, chrev, children, ctrl}
|
||||
container := &Container{
|
||||
Box: box,
|
||||
Service: svc,
|
||||
header: header,
|
||||
revealer: chrev,
|
||||
children: children,
|
||||
Controller: ctrl,
|
||||
}
|
||||
|
||||
// On click, toggle reveal.
|
||||
header.reveal.Connect("clicked", func() {
|
||||
|
@ -90,71 +103,42 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container {
|
|||
ctrl.AuthenticateSession(container, svc)
|
||||
})
|
||||
|
||||
// Make menu items.
|
||||
primitives.AppendMenuItems(header.Menu, []primitives.MenuItem{
|
||||
{Name: "Save Sessions", Fn: func() {
|
||||
ctrl.SaveAllSessions(container)
|
||||
}},
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
func (c *Container) AddSession(ses cchat.Session) {
|
||||
srow := session.New(c, ses, c.rowctrl)
|
||||
c.children.addSessionRow(srow)
|
||||
func (c *Container) AddSession(ses cchat.Session) *session.Row {
|
||||
srow := session.New(c, ses, c)
|
||||
c.children.addSessionRow(ses.ID(), srow)
|
||||
|
||||
return 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
|
||||
func (c *Container) AddLoadingSession(id, name string) *session.Row {
|
||||
srow := session.NewLoading(c, name, c)
|
||||
c.children.addSessionRow(id, srow)
|
||||
|
||||
return srow
|
||||
}
|
||||
|
||||
// KeyringSessions returns all known keyring sessions. Sessions that can't be
|
||||
// saved will not be in the slice.
|
||||
func (c *Container) KeyringSessions() []keyring.Session {
|
||||
var ksessions = make([]keyring.Session, 0, len(c.children.Sessions))
|
||||
for _, s := range c.children.Sessions {
|
||||
if k := s.KeyringSession(); k != nil {
|
||||
ksessions = append(ksessions, *k)
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
return ksessions
|
||||
}
|
||||
|
||||
func (c *Container) Breadcrumb() breadcrumb.Breadcrumb {
|
||||
return breadcrumb.Try(nil, c.header.reveal.GetText())
|
||||
}
|
||||
|
||||
type header struct {
|
||||
*gtk.Box
|
||||
reveal *rich.ToggleButtonImage // no rich text here but it's left aligned
|
||||
add *gtk.Button
|
||||
}
|
||||
|
||||
func newHeader(svc cchat.Service) *header {
|
||||
reveal := rich.NewToggleButtonImage(text.Rich{Content: svc.Name()})
|
||||
reveal.Box.SetHAlign(gtk.ALIGN_START)
|
||||
reveal.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
|
||||
reveal.SetRelief(gtk.RELIEF_NONE)
|
||||
reveal.SetMode(true)
|
||||
reveal.Show()
|
||||
|
||||
if iconer, ok := svc.(cchat.Icon); ok {
|
||||
if err := iconer.Icon(reveal); err != nil {
|
||||
log.Error(errors.Wrap(err, "Error getting session logo"))
|
||||
}
|
||||
}
|
||||
|
||||
add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON)
|
||||
add.SetRelief(gtk.RELIEF_NONE)
|
||||
add.Show()
|
||||
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||
box.PackStart(reveal, true, true, 0)
|
||||
box.PackStart(add, false, false, 0)
|
||||
box.Show()
|
||||
|
||||
return &header{box, reveal, add}
|
||||
}
|
||||
|
||||
type children struct {
|
||||
*gtk.Box
|
||||
Sessions []*session.Row
|
||||
}
|
||||
|
||||
func newChildren() *children {
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
box.Show()
|
||||
|
||||
return &children{box, nil}
|
||||
}
|
||||
|
||||
func (c *children) addSessionRow(row *session.Row) {
|
||||
c.Sessions = append(c.Sessions, row)
|
||||
c.Box.Add(row)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||
"github.com/diamondburned/cchat/text"
|
||||
"github.com/diamondburned/imgutil"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
@ -37,6 +38,7 @@ type Row struct {
|
|||
func NewRow(parent breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *Row {
|
||||
button := rich.NewToggleButtonImage(text.Rich{})
|
||||
button.Box.SetHAlign(gtk.ALIGN_START)
|
||||
button.Image.AddProcessors(imgutil.Round(true))
|
||||
button.Image.SetSize(IconSize)
|
||||
button.SetRelief(gtk.RELIEF_NONE)
|
||||
button.Show()
|
||||
|
|
|
@ -2,12 +2,16 @@ package session
|
|||
|
||||
import (
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/keyring"
|
||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||
"github.com/diamondburned/cchat/text"
|
||||
"github.com/diamondburned/imgutil"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const IconSize = 32
|
||||
|
@ -15,6 +19,7 @@ const IconSize = 32
|
|||
// Controller extends server.RowController to add session.
|
||||
type Controller interface {
|
||||
MessageRowSelected(*Row, *server.Row, cchat.ServerMessage)
|
||||
RestoreSession(*Row, cchat.SessionRestorer) // async
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
|
@ -29,16 +34,30 @@ type Row struct {
|
|||
}
|
||||
|
||||
func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Row {
|
||||
row := new(parent, ctrl)
|
||||
row.SetSession(ses)
|
||||
return row
|
||||
}
|
||||
|
||||
func NewLoading(parent breadcrumb.Breadcrumber, name string, ctrl Controller) *Row {
|
||||
row := new(parent, ctrl)
|
||||
row.Button.SetLabelUnsafe(text.Rich{Content: name})
|
||||
row.Button.Image.SetPlaceholderIcon("content-loading-symbolic", IconSize)
|
||||
row.SetSensitive(false)
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
func new(parent breadcrumb.Breadcrumber, ctrl Controller) *Row {
|
||||
row := &Row{
|
||||
Session: ses,
|
||||
ctrl: ctrl,
|
||||
parent: parent,
|
||||
ctrl: ctrl,
|
||||
parent: parent,
|
||||
}
|
||||
row.Servers = server.NewChildren(row, ses, row)
|
||||
|
||||
row.Button = rich.NewToggleButtonImage(text.Rich{})
|
||||
row.Button.Box.SetHAlign(gtk.ALIGN_START)
|
||||
row.Button.Image.SetPlaceholderIcon("user-available-symbolic", IconSize)
|
||||
row.Button.Image.AddProcessors(imgutil.Round(true))
|
||||
// Set the loading icon.
|
||||
row.Button.SetRelief(gtk.RELIEF_NONE)
|
||||
// On click, toggle reveal.
|
||||
row.Button.Connect("clicked", func() {
|
||||
|
@ -47,12 +66,10 @@ func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Ro
|
|||
row.Button.SetActive(revealed)
|
||||
})
|
||||
row.Button.Show()
|
||||
row.Button.Try(ses, "session")
|
||||
|
||||
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")
|
||||
|
@ -60,6 +77,48 @@ func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Ro
|
|||
return row
|
||||
}
|
||||
|
||||
// KeyringSession returns a keyring session, or nil if the session cannot be
|
||||
// saved. This function is not cached, as I'd rather not keep the map in memory.
|
||||
func (r *Row) KeyringSession() *keyring.Session {
|
||||
// Is the session saveable?
|
||||
saver, ok := r.Session.(cchat.SessionSaver)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ks := keyring.Session{
|
||||
ID: r.Session.ID(),
|
||||
Name: r.Button.GetText(),
|
||||
}
|
||||
|
||||
s, err := saver.Save()
|
||||
if err != nil {
|
||||
log.Error(errors.Wrapf(err, "Failed to save session ID %s (%s)", ks.ID, ks.Name))
|
||||
return nil
|
||||
}
|
||||
ks.Data = s
|
||||
|
||||
return &ks
|
||||
}
|
||||
|
||||
func (r *Row) SetSession(ses cchat.Session) {
|
||||
r.Session = ses
|
||||
r.Servers = server.NewChildren(r, ses, r)
|
||||
r.Button.Image.SetPlaceholderIcon("user-available-symbolic", IconSize)
|
||||
r.Box.PackStart(r.Servers, false, false, 0)
|
||||
r.SetSensitive(true)
|
||||
|
||||
// Set the session's name to the button.
|
||||
r.Button.Try(ses, "session")
|
||||
}
|
||||
|
||||
func (r *Row) SetFailed(err error) {
|
||||
r.SetTooltipText(err.Error())
|
||||
// TODO: setting the label directly here is kind of shitty, as it screws up
|
||||
// the getter. Fix?
|
||||
r.Button.Label.SetMarkup(rich.MakeRed(r.Button.GetLabel()))
|
||||
}
|
||||
|
||||
func (r *Row) MessageRowSelected(server *server.Row, smsg cchat.ServerMessage) {
|
||||
r.ctrl.MessageRowSelected(r, server, smsg)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/markbates/pkger"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
@ -46,8 +47,45 @@ func NewApplication() *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)
|
||||
// Can this session be restored? If not, exit.
|
||||
restorer, ok := container.Service.(cchat.SessionRestorer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var sessions = keyring.RestoreSessions(container.Service.Name())
|
||||
|
||||
for _, krs := range sessions {
|
||||
// Copy the session to avoid race conditions.
|
||||
krs := krs
|
||||
row := container.AddLoadingSession(krs.ID, krs.Name)
|
||||
|
||||
go app.restoreSession(row, restorer, krs)
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreSession attempts to restore the session asynchronously.
|
||||
func (app *App) RestoreSession(row *session.Row, r cchat.SessionRestorer) {
|
||||
// Get the restore data.
|
||||
ks := row.KeyringSession()
|
||||
if ks == nil {
|
||||
log.Warn(errors.New("Attempted restore in ui.go"))
|
||||
return
|
||||
}
|
||||
go app.restoreSession(row, r, *ks)
|
||||
}
|
||||
|
||||
// synchronous op
|
||||
func (app *App) restoreSession(row *session.Row, r cchat.SessionRestorer, k keyring.Session) {
|
||||
s, err := r.RestoreSession(k.Data)
|
||||
if err != nil {
|
||||
err = errors.Wrapf(err, "Failed to restore session %s (%s)", k.ID, k.Name)
|
||||
log.Error(err)
|
||||
|
||||
gts.ExecAsync(func() { row.SetFailed(err) })
|
||||
} else {
|
||||
gts.ExecAsync(func() { row.SetSession(s) })
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) MessageRowSelected(ses *session.Row, srv *server.Row, smsg cchat.ServerMessage) {
|
||||
|
@ -70,13 +108,15 @@ func (app *App) AuthenticateSession(container *service.Container, svc cchat.Serv
|
|||
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)
|
||||
}
|
||||
// Try and save all keyring sessions.
|
||||
app.SaveAllSessions(container)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) SaveAllSessions(container *service.Container) {
|
||||
keyring.SaveSessions(container.Service.Name(), container.KeyringSessions())
|
||||
}
|
||||
|
||||
func (app *App) Header() gtk.IWidget {
|
||||
return app.header
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue