Rewrote sidebar for new design; minor bug fixes
This commit is contained in:
parent
098593552d
commit
e65dbb20ed
2
go.mod
2
go.mod
|
@ -8,7 +8,7 @@ require (
|
||||||
github.com/Xuanwo/go-locale v0.2.0
|
github.com/Xuanwo/go-locale v0.2.0
|
||||||
github.com/alecthomas/chroma v0.7.3
|
github.com/alecthomas/chroma v0.7.3
|
||||||
github.com/diamondburned/cchat v0.0.43
|
github.com/diamondburned/cchat v0.0.43
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200711221358-e50f72a01d0a
|
github.com/diamondburned/cchat-discord v0.0.0-20200714063838-0a590627268b
|
||||||
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b
|
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b
|
||||||
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
|
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
|
|
10
go.sum
10
go.sum
|
@ -72,6 +72,14 @@ github.com/diamondburned/cchat-discord v0.0.0-20200711215912-d1f5376e30b3 h1:uMr
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200711215912-d1f5376e30b3/go.mod h1:TP5/aE708Ae2quG1yAvCCoDJjKuBcjFZ1LYVw60FbTw=
|
github.com/diamondburned/cchat-discord v0.0.0-20200711215912-d1f5376e30b3/go.mod h1:TP5/aE708Ae2quG1yAvCCoDJjKuBcjFZ1LYVw60FbTw=
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200711221358-e50f72a01d0a h1:0/j7J0HTR3OuU0qaQ9HWLK0DlJdXPL72ziFVD1f6d8E=
|
github.com/diamondburned/cchat-discord v0.0.0-20200711221358-e50f72a01d0a h1:0/j7J0HTR3OuU0qaQ9HWLK0DlJdXPL72ziFVD1f6d8E=
|
||||||
github.com/diamondburned/cchat-discord v0.0.0-20200711221358-e50f72a01d0a/go.mod h1:TP5/aE708Ae2quG1yAvCCoDJjKuBcjFZ1LYVw60FbTw=
|
github.com/diamondburned/cchat-discord v0.0.0-20200711221358-e50f72a01d0a/go.mod h1:TP5/aE708Ae2quG1yAvCCoDJjKuBcjFZ1LYVw60FbTw=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200712063736-b8c92a56d89b h1:W5Mmf6GagIctMH5n2TYORSENMZdJm8th4JrEJubLb60=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200712063736-b8c92a56d89b/go.mod h1:KRdCNnJHsHIcQP6/fE90MKTIZJuGyTKW/aTx18eGuuI=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200714012913-3c1d6f1d3f17 h1:dUF7+pmzfAKS1gBr5SySg5xkSrAmzRGcghDRjXl5TDg=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200714012913-3c1d6f1d3f17/go.mod h1:KRdCNnJHsHIcQP6/fE90MKTIZJuGyTKW/aTx18eGuuI=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200714014557-cc97c2a69cc2 h1:2+XIy4DzLymP/X7LFH03WBdHpkVavb0MC0fQrPvRGxs=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200714014557-cc97c2a69cc2/go.mod h1:KRdCNnJHsHIcQP6/fE90MKTIZJuGyTKW/aTx18eGuuI=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200714063838-0a590627268b h1:dZ7fntEpmeh2xvwZ6jqyWjzk9Ikvx0o/O7pEDMR1PzU=
|
||||||
|
github.com/diamondburned/cchat-discord v0.0.0-20200714063838-0a590627268b/go.mod h1:KRdCNnJHsHIcQP6/fE90MKTIZJuGyTKW/aTx18eGuuI=
|
||||||
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3 h1:xr07/2cwINyrMqh92pQQJVDfQqG0u6gHAK+ZcGfpSew=
|
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3 h1:xr07/2cwINyrMqh92pQQJVDfQqG0u6gHAK+ZcGfpSew=
|
||||||
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3/go.mod h1:SRu3OOeggELFr2Wd3/+SpYV1eNcvSk2LBhM70NOZSG8=
|
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3/go.mod h1:SRu3OOeggELFr2Wd3/+SpYV1eNcvSk2LBhM70NOZSG8=
|
||||||
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b h1:sq0MXjJc3yAOZvuolRxOpKQNvpMLyTmsECxQqdYgF5E=
|
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b h1:sq0MXjJc3yAOZvuolRxOpKQNvpMLyTmsECxQqdYgF5E=
|
||||||
|
@ -92,6 +100,8 @@ github.com/diamondburned/ningen v0.1.1-0.20200708211706-57c712372ede h1:qRmfQCOS
|
||||||
github.com/diamondburned/ningen v0.1.1-0.20200708211706-57c712372ede/go.mod h1:FNezDLQIhoDS+RkXLSQ7dJNrt6BW/nVl1krzDgWMQwg=
|
github.com/diamondburned/ningen v0.1.1-0.20200708211706-57c712372ede/go.mod h1:FNezDLQIhoDS+RkXLSQ7dJNrt6BW/nVl1krzDgWMQwg=
|
||||||
github.com/diamondburned/ningen v0.1.1-0.20200711215126-d4b8a17e818d h1:XgG/KRbAwu8v2/YZWimjXo0dgdD69E38ollCfbFtU7s=
|
github.com/diamondburned/ningen v0.1.1-0.20200711215126-d4b8a17e818d h1:XgG/KRbAwu8v2/YZWimjXo0dgdD69E38ollCfbFtU7s=
|
||||||
github.com/diamondburned/ningen v0.1.1-0.20200711215126-d4b8a17e818d/go.mod h1:NVneOJDUDEIC3cnyeh2vpeAPVtBdC2Kcy+uwDy4o2qk=
|
github.com/diamondburned/ningen v0.1.1-0.20200711215126-d4b8a17e818d/go.mod h1:NVneOJDUDEIC3cnyeh2vpeAPVtBdC2Kcy+uwDy4o2qk=
|
||||||
|
github.com/diamondburned/ningen v0.1.1-0.20200712031630-349ee2c3f01c h1:CpYhIGiRzee7Jm0H4c0fLvRe/08QitDNo8KHYtrOmFE=
|
||||||
|
github.com/diamondburned/ningen v0.1.1-0.20200712031630-349ee2c3f01c/go.mod h1:NVneOJDUDEIC3cnyeh2vpeAPVtBdC2Kcy+uwDy4o2qk=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
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/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
|
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/gts/throttler"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gotk3/gotk3/gdk"
|
"github.com/gotk3/gotk3/gdk"
|
||||||
|
@ -125,6 +126,9 @@ func Main(wfn func() WindowHeaderer) {
|
||||||
w.Close()
|
w.Close()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Limit the TPS of the main loop on unfocus.
|
||||||
|
throttler.Bind(App.Window)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use a special function to run the application. Exit with the appropriate
|
// Use a special function to run the application. Exit with the appropriate
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
package throttler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gotk3/gotk3/glib"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TPS = 15 // tps
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
throttling bool
|
||||||
|
ticker <-chan time.Time
|
||||||
|
settings *gtk.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
type Connector interface {
|
||||||
|
gtk.IWidget
|
||||||
|
Connect(string, interface{}, ...interface{}) (glib.SignalHandle, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Bind(evc Connector) *State {
|
||||||
|
var settings, _ = gtk.SettingsGetDefault()
|
||||||
|
var s = State{
|
||||||
|
settings: settings,
|
||||||
|
ticker: time.Tick(time.Second / TPS),
|
||||||
|
}
|
||||||
|
|
||||||
|
evc.Connect("focus-out-event", s.Start)
|
||||||
|
evc.Connect("focus-in-event", s.Stop)
|
||||||
|
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) Start() {
|
||||||
|
if s.throttling {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.throttling = true
|
||||||
|
s.settings.SetProperty("gtk-enable-animations", false)
|
||||||
|
|
||||||
|
glib.IdleAdd(func() bool {
|
||||||
|
// Throttle.
|
||||||
|
<-s.ticker
|
||||||
|
|
||||||
|
// If we're no longer throttling, then stop the ticker and remove this
|
||||||
|
// callback from the loop.
|
||||||
|
if !s.throttling {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep calling this same callback.
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) Stop() {
|
||||||
|
if !s.throttling {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.throttling = false
|
||||||
|
s.settings.SetProperty("gtk-enable-animations", true)
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"github.com/diamondburned/cchat-gtk/internal/keyring/driver/keyring"
|
"github.com/diamondburned/cchat-gtk/internal/keyring/driver/keyring"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/config"
|
"github.com/diamondburned/cchat-gtk/internal/ui/config"
|
||||||
"github.com/diamondburned/cchat/text"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -23,7 +22,11 @@ type Session struct {
|
||||||
Data map[string]string
|
Data map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertSession(ses cchat.Session, name string) *Session {
|
// ConvertSession attempts to get the session data from the given cchat session.
|
||||||
|
// It returns nil if it can't do it.
|
||||||
|
func ConvertSession(ses cchat.Session) *Session {
|
||||||
|
var name = ses.Name().Content
|
||||||
|
|
||||||
saver, ok := ses.(cchat.SessionSaver)
|
saver, ok := ses.(cchat.SessionSaver)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
|
@ -48,24 +51,24 @@ func ConvertSession(ses cchat.Session, name string) *Session {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveSessions(serviceName text.Rich, sessions []Session) {
|
func SaveSessions(service cchat.Service, sessions []Session) {
|
||||||
if err := store.Set(serviceName.Content, sessions); err != nil {
|
if err := store.Set(service.Name().Content, sessions); err != nil {
|
||||||
log.Warn(errors.Wrap(err, "Error saving session"))
|
log.Warn(errors.Wrap(err, "Error saving session"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreSessions restores all sessions of the service asynchronously, then
|
// RestoreSessions restores all sessions of the service asynchronously, then
|
||||||
// calls the auth callback inside the Gtk main thread.
|
// calls the auth callback inside the Gtk main thread.
|
||||||
func RestoreSessions(serviceName text.Rich) (sessions []Session) {
|
func RestoreSessions(service cchat.Service) (sessions []Session) {
|
||||||
// Ignore the error, it's not important.
|
// Ignore the error, it's not important.
|
||||||
if err := store.Get(serviceName.Content, &sessions); err != nil {
|
if err := store.Get(service.Name().Content, &sessions); err != nil {
|
||||||
log.Warn(err)
|
log.Warn(err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func RestoreSession(serviceName text.Rich, id string) *Session {
|
func RestoreSession(service cchat.Service, id string) *Session {
|
||||||
var sessions = RestoreSessions(serviceName)
|
var sessions = RestoreSessions(service)
|
||||||
for _, session := range sessions {
|
for _, session := range sessions {
|
||||||
if session.ID == id {
|
if session.ID == id {
|
||||||
return &session
|
return &session
|
||||||
|
|
|
@ -2,6 +2,7 @@ package dialog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/diamondburned/cchat-gtk/internal/gts"
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
"github.com/gotk3/gotk3/glib"
|
"github.com/gotk3/gotk3/glib"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
)
|
)
|
||||||
|
@ -13,6 +14,12 @@ type Modal struct {
|
||||||
Header *gtk.HeaderBar
|
Header *gtk.HeaderBar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var headerCSS = primitives.PrepareCSS(`
|
||||||
|
.modal-header {
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
func ShowModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) {
|
func ShowModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) {
|
||||||
NewModal(body, title, title, clicked).Show()
|
NewModal(body, title, title, clicked).Show()
|
||||||
}
|
}
|
||||||
|
@ -30,12 +37,13 @@ func NewModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) *M
|
||||||
|
|
||||||
header, _ := gtk.HeaderBarNew()
|
header, _ := gtk.HeaderBarNew()
|
||||||
header.Show()
|
header.Show()
|
||||||
header.SetMarginStart(5)
|
|
||||||
header.SetMarginEnd(5)
|
|
||||||
header.SetTitle(title)
|
header.SetTitle(title)
|
||||||
header.PackStart(cancel)
|
header.PackStart(cancel)
|
||||||
header.PackEnd(action)
|
header.PackEnd(action)
|
||||||
|
|
||||||
|
primitives.AddClass(header, "modal-header")
|
||||||
|
primitives.AttachCSS(header, headerCSS)
|
||||||
|
|
||||||
dialog := newCSD(body, header)
|
dialog := newCSD(body, header)
|
||||||
modald := &Modal{
|
modald := &Modal{
|
||||||
dialog,
|
dialog,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
|
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
|
||||||
"github.com/diamondburned/cchat/text"
|
"github.com/diamondburned/cchat/text"
|
||||||
"github.com/diamondburned/imgutil"
|
"github.com/diamondburned/imgutil"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
@ -74,7 +74,7 @@ func (v *View) Update(words []string, i int) []gtk.IWidget {
|
||||||
s := rich.NewLabel(text.Rich{})
|
s := rich.NewLabel(text.Rich{})
|
||||||
s.SetMarkup(fmt.Sprintf(
|
s.SetMarkup(fmt.Sprintf(
|
||||||
`<span alpha="50%%" size="small">%s</span>`,
|
`<span alpha="50%%" size="small">%s</span>`,
|
||||||
parser.RenderMarkup(entry.Secondary),
|
markup.Render(entry.Secondary),
|
||||||
))
|
))
|
||||||
s.Show()
|
s.Show()
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,9 @@ import (
|
||||||
|
|
||||||
"github.com/diamondburned/cchat"
|
"github.com/diamondburned/cchat"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/humanize"
|
"github.com/diamondburned/cchat-gtk/internal/humanize"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/imgview"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
|
||||||
"github.com/diamondburned/cchat/text"
|
"github.com/diamondburned/cchat/text"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
@ -53,11 +52,11 @@ type GenericContainer struct {
|
||||||
nonce string
|
nonce string
|
||||||
|
|
||||||
Timestamp *gtk.Label
|
Timestamp *gtk.Label
|
||||||
Username *gtk.Label
|
Username *labeluri.Label
|
||||||
Content gtk.IWidget // conceal widget implementation
|
Content gtk.IWidget // conceal widget implementation
|
||||||
|
|
||||||
contentBox *gtk.Box // basically what is in Content
|
contentBox *gtk.Box // basically what is in Content
|
||||||
ContentBody *gtk.Label
|
ContentBody *labeluri.Label
|
||||||
|
|
||||||
MenuItems []menu.Item
|
MenuItems []menu.Item
|
||||||
}
|
}
|
||||||
|
@ -95,7 +94,7 @@ func NewEmptyContainer() *GenericContainer {
|
||||||
ts.SetVAlign(gtk.ALIGN_END)
|
ts.SetVAlign(gtk.ALIGN_END)
|
||||||
ts.Show()
|
ts.Show()
|
||||||
|
|
||||||
user, _ := gtk.LabelNew("")
|
user := labeluri.NewLabel(text.Rich{})
|
||||||
user.SetMaxWidthChars(35)
|
user.SetMaxWidthChars(35)
|
||||||
user.SetLineWrap(true)
|
user.SetLineWrap(true)
|
||||||
user.SetLineWrapMode(pango.WRAP_WORD_CHAR)
|
user.SetLineWrapMode(pango.WRAP_WORD_CHAR)
|
||||||
|
@ -103,7 +102,8 @@ func NewEmptyContainer() *GenericContainer {
|
||||||
user.SetVAlign(gtk.ALIGN_START)
|
user.SetVAlign(gtk.ALIGN_START)
|
||||||
user.Show()
|
user.Show()
|
||||||
|
|
||||||
ctbody, _ := gtk.LabelNew("")
|
ctbody := labeluri.NewLabel(text.Rich{})
|
||||||
|
ctbody.SetEllipsize(pango.ELLIPSIZE_NONE)
|
||||||
ctbody.SetLineWrap(true)
|
ctbody.SetLineWrap(true)
|
||||||
ctbody.SetLineWrapMode(pango.WRAP_WORD_CHAR)
|
ctbody.SetLineWrapMode(pango.WRAP_WORD_CHAR)
|
||||||
ctbody.SetXAlign(0) // left align
|
ctbody.SetXAlign(0) // left align
|
||||||
|
@ -148,10 +148,6 @@ func NewEmptyContainer() *GenericContainer {
|
||||||
menu.MenuItems(m, gc.MenuItems)
|
menu.MenuItems(m, gc.MenuItems)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Make up for the lack of inline images with an image popover that's shown
|
|
||||||
// when links are clicked.
|
|
||||||
imgview.BindTooltip(gc.ContentBody)
|
|
||||||
|
|
||||||
return gc
|
return gc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,16 +188,17 @@ func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *GenericContainer) UpdateAuthorName(name text.Rich) {
|
func (m *GenericContainer) UpdateAuthorName(name text.Rich) {
|
||||||
m.Username.SetMarkup(parser.RenderMarkup(name))
|
m.Username.SetLabelUnsafe(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {
|
func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {
|
||||||
var markup = parser.RenderMarkup(content)
|
m.ContentBody.SetLabelUnsafe(content)
|
||||||
if edited {
|
|
||||||
markup += " " + rich.Small("(edited)")
|
|
||||||
}
|
|
||||||
|
|
||||||
m.ContentBody.SetMarkup(markup)
|
if edited {
|
||||||
|
markup := m.ContentBody.Output().Markup
|
||||||
|
markup += " " + rich.Small("(edited)")
|
||||||
|
m.ContentBody.SetMarkup(markup)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachMenu connects signal handlers to handle a list of menu items from
|
// AttachMenu connects signal handlers to handle a list of menu items from
|
||||||
|
|
|
@ -50,15 +50,15 @@ func New(parent gtk.IWidget, placeholder WidgetUnreferencer) *FaceView {
|
||||||
|
|
||||||
// Reset brings the view to an empty box.
|
// Reset brings the view to an empty box.
|
||||||
func (v *FaceView) Reset() {
|
func (v *FaceView) Reset() {
|
||||||
v.Stack.SetVisibleChildName("empty")
|
|
||||||
v.ensurePlaceholderDestroyed()
|
v.ensurePlaceholderDestroyed()
|
||||||
v.Loading.Spinner.Stop()
|
v.Loading.Spinner.Stop()
|
||||||
|
v.Stack.SetVisibleChildName("empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *FaceView) SetMain() {
|
func (v *FaceView) SetMain() {
|
||||||
v.Stack.SetVisibleChildName("main")
|
|
||||||
v.ensurePlaceholderDestroyed()
|
v.ensurePlaceholderDestroyed()
|
||||||
v.Loading.Spinner.Stop()
|
v.Loading.Spinner.Stop()
|
||||||
|
v.Stack.SetVisibleChildName("main")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *FaceView) SetLoading() {
|
func (v *FaceView) SetLoading() {
|
||||||
|
@ -79,7 +79,7 @@ func (v *FaceView) ensurePlaceholderDestroyed() {
|
||||||
if v.placeholder != nil {
|
if v.placeholder != nil {
|
||||||
// Safely remove the placeholder from the stack.
|
// Safely remove the placeholder from the stack.
|
||||||
if v.Stack.GetVisibleChildName() == "placeholder" {
|
if v.Stack.GetVisibleChildName() == "placeholder" {
|
||||||
v.Stack.SetVisibleChildName("main")
|
v.Stack.SetVisibleChildName("empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the placeholder widget.
|
// Remove the placeholder widget.
|
||||||
|
|
|
@ -5,7 +5,7 @@ import (
|
||||||
|
|
||||||
"github.com/diamondburned/cchat"
|
"github.com/diamondburned/cchat"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
"github.com/gotk3/gotk3/pango"
|
"github.com/gotk3/gotk3/pango"
|
||||||
)
|
)
|
||||||
|
@ -95,7 +95,7 @@ func render(typers []cchat.Typer) string {
|
||||||
|
|
||||||
for i, typer := range typers {
|
for i, typer := range typers {
|
||||||
builder.WriteString("<b>")
|
builder.WriteString("<b>")
|
||||||
builder.WriteString(parser.RenderMarkup(typer.Name()))
|
builder.WriteString(markup.Render(typer.Name()))
|
||||||
builder.WriteString("</b>")
|
builder.WriteString("</b>")
|
||||||
|
|
||||||
switch i {
|
switch i {
|
||||||
|
|
|
@ -115,9 +115,9 @@ func (v *View) Bottomed() bool { return v.Scroller.Bottomed }
|
||||||
func (v *View) Reset() {
|
func (v *View) Reset() {
|
||||||
v.state.Reset() // Reset the state variables.
|
v.state.Reset() // Reset the state variables.
|
||||||
v.Typing.Reset() // Reset the typing state.
|
v.Typing.Reset() // Reset the typing state.
|
||||||
v.FaceView.Reset() // Switch back to the main screen.
|
|
||||||
v.InputView.Reset() // Reset the input.
|
v.InputView.Reset() // Reset the input.
|
||||||
v.Container.Reset() // Clean all messages.
|
v.Container.Reset() // Clean all messages.
|
||||||
|
v.FaceView.Reset() // Switch back to the main screen.
|
||||||
|
|
||||||
// Keep the scroller at the bottom.
|
// Keep the scroller at the bottom.
|
||||||
v.Scroller.Bottomed = true
|
v.Scroller.Bottomed = true
|
||||||
|
|
|
@ -15,6 +15,7 @@ type Completer struct {
|
||||||
ctrl Completeable
|
ctrl Completeable
|
||||||
|
|
||||||
Input *gtk.TextView
|
Input *gtk.TextView
|
||||||
|
Buffer *gtk.TextBuffer
|
||||||
List *gtk.ListBox
|
List *gtk.ListBox
|
||||||
Popover *gtk.Popover
|
Popover *gtk.Popover
|
||||||
|
|
||||||
|
@ -38,22 +39,21 @@ func NewCompleter(input *gtk.TextView, ctrl Completeable) *Completer {
|
||||||
p := NewPopover(input)
|
p := NewPopover(input)
|
||||||
p.Add(s)
|
p.Add(s)
|
||||||
|
|
||||||
|
input.Connect("key-press-event", KeyDownHandler(l, input.GrabFocus))
|
||||||
|
ibuf, _ := input.GetBuffer()
|
||||||
|
|
||||||
c := &Completer{
|
c := &Completer{
|
||||||
Input: input,
|
Input: input,
|
||||||
|
Buffer: ibuf,
|
||||||
List: l,
|
List: l,
|
||||||
Popover: p,
|
Popover: p,
|
||||||
ctrl: ctrl,
|
ctrl: ctrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
input.Connect("key-press-event", KeyDownHandler(l, input.GrabFocus))
|
// This one is for buffer modification.
|
||||||
|
ibuf.Connect("end-user-action", c.onChange)
|
||||||
ibuf, _ := input.GetBuffer()
|
// This one is for when the cursor moves.
|
||||||
ibuf.Connect("end-user-action", func() {
|
input.Connect("move-cursor", c.onChange)
|
||||||
t, v := State(ibuf)
|
|
||||||
c.Cursor = v
|
|
||||||
c.Words, c.Index = split.SpaceIndexed(t, v)
|
|
||||||
c.complete()
|
|
||||||
})
|
|
||||||
|
|
||||||
l.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
|
l.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
|
||||||
SwapWord(ibuf, ctrl.Word(r.GetIndex()), c.Cursor)
|
SwapWord(ibuf, ctrl.Word(r.GetIndex()), c.Cursor)
|
||||||
|
@ -82,6 +82,22 @@ func (c *Completer) Clear() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Completer) onChange() {
|
||||||
|
t, v, blank := State(c.Buffer)
|
||||||
|
c.Cursor = v
|
||||||
|
|
||||||
|
// If the curssor is on a blank character, then we should not
|
||||||
|
// autocomplete anything, so we set the states to nil.
|
||||||
|
if blank {
|
||||||
|
c.Words = nil
|
||||||
|
c.Index = -1
|
||||||
|
} else {
|
||||||
|
c.Words, c.Index = split.SpaceIndexed(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.complete()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Completer) complete() {
|
func (c *Completer) complete() {
|
||||||
c.Clear()
|
c.Clear()
|
||||||
|
|
||||||
|
@ -95,6 +111,7 @@ func (c *Completer) complete() {
|
||||||
c.Popover.Popup()
|
c.Popover.Popup()
|
||||||
} else {
|
} else {
|
||||||
c.Hide()
|
c.Hide()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, widget := range widgets {
|
for i, widget := range widgets {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package completion
|
package completion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
"github.com/gotk3/gotk3/gdk"
|
"github.com/gotk3/gotk3/gdk"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
@ -91,32 +93,44 @@ func CursorRect(i *gtk.TextView) gdk.Rectangle {
|
||||||
return *r
|
return *r
|
||||||
}
|
}
|
||||||
|
|
||||||
func State(buf *gtk.TextBuffer) (string, int) {
|
func State(buf *gtk.TextBuffer) (text string, offset int, blank bool) {
|
||||||
// obtain current state
|
// obtain current state
|
||||||
mark := buf.GetInsert()
|
mark := buf.GetInsert()
|
||||||
iter := buf.GetIterAtMark(mark)
|
iter := buf.GetIterAtMark(mark)
|
||||||
|
|
||||||
// obtain the input string and the current cursor position
|
// obtain the input string and the current cursor position
|
||||||
start, end := buf.GetBounds()
|
start, end := buf.GetBounds()
|
||||||
text, _ := buf.GetText(start, end, true)
|
|
||||||
offset := iter.GetOffset()
|
|
||||||
|
|
||||||
return text, offset
|
text, _ = buf.GetText(start, end, true)
|
||||||
|
offset = iter.GetOffset()
|
||||||
|
|
||||||
|
// We need the rune before the cursor.
|
||||||
|
iter.BackwardChar()
|
||||||
|
char := iter.GetChar()
|
||||||
|
|
||||||
|
// Treat NULs as blanks.
|
||||||
|
blank = unicode.IsSpace(char) || char == '\x00'
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchFlags = 0 |
|
||||||
|
gtk.TEXT_SEARCH_TEXT_ONLY |
|
||||||
|
gtk.TEXT_SEARCH_VISIBLE_ONLY
|
||||||
|
|
||||||
func GetWordIters(buf *gtk.TextBuffer, offset int) (start, end *gtk.TextIter) {
|
func GetWordIters(buf *gtk.TextBuffer, offset int) (start, end *gtk.TextIter) {
|
||||||
iter := buf.GetIterAtOffset(offset)
|
iter := buf.GetIterAtOffset(offset)
|
||||||
|
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|
||||||
// Seek backwards for space or start-of-line:
|
// Seek backwards for space or start-of-line:
|
||||||
_, start, ok = iter.BackwardSearch(" ", gtk.TEXT_SEARCH_TEXT_ONLY, nil)
|
_, start, ok = iter.BackwardSearch(" ", searchFlags, nil)
|
||||||
if !ok {
|
if !ok {
|
||||||
start = buf.GetStartIter()
|
start = buf.GetStartIter()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek forwards for space or end-of-line:
|
// Seek forwards for space or end-of-line:
|
||||||
_, end, ok = iter.ForwardSearch(" ", gtk.TEXT_SEARCH_TEXT_ONLY, nil)
|
_, end, ok = iter.ForwardSearch(" ", searchFlags, nil)
|
||||||
if !ok {
|
if !ok {
|
||||||
end = buf.GetEndIter()
|
end = buf.GetEndIter()
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,19 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Container interface {
|
||||||
|
Remove(gtk.IWidget)
|
||||||
|
GetChildren() *glib.List
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Container = (*gtk.Container)(nil)
|
||||||
|
|
||||||
|
func RemoveChildren(w Container) {
|
||||||
|
w.GetChildren().Foreach(func(child interface{}) {
|
||||||
|
w.Remove(child.(gtk.IWidget))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type Namer interface {
|
type Namer interface {
|
||||||
SetName(string)
|
SetName(string)
|
||||||
GetName() (string, error)
|
GetName() (string, error)
|
||||||
|
@ -83,6 +96,13 @@ func AddClass(styleCtx StyleContexter, classes ...string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveClass(styleCtx StyleContexter, classes ...string) {
|
||||||
|
var style, _ = styleCtx.GetStyleContext()
|
||||||
|
for _, class := range classes {
|
||||||
|
style.RemoveClass(class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type StyleContextFocuser interface {
|
type StyleContextFocuser interface {
|
||||||
StyleContexter
|
StyleContexter
|
||||||
GrabFocus()
|
GrabFocus()
|
||||||
|
@ -242,6 +262,16 @@ func ActionPopover(p *gtk.Popover, actions [][2]string) {
|
||||||
p.Add(box)
|
p.Add(box)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PrepareClassCSS(class, css string) (attach func(StyleContexter)) {
|
||||||
|
prov := PrepareCSS(css)
|
||||||
|
|
||||||
|
return func(ctx StyleContexter) {
|
||||||
|
s, _ := ctx.GetStyleContext()
|
||||||
|
s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||||
|
s.AddClass(class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func PrepareCSS(css string) *gtk.CssProvider {
|
func PrepareCSS(css string) *gtk.CssProvider {
|
||||||
p, _ := gtk.CssProviderNew()
|
p, _ := gtk.CssProviderNew()
|
||||||
if err := p.LoadFromData(css); err != nil {
|
if err := p.LoadFromData(css); err != nil {
|
||||||
|
|
|
@ -48,7 +48,7 @@ func NewImage(radius float64) (*Image, error) {
|
||||||
switch {
|
switch {
|
||||||
// If radius is less than 0, then don't round.
|
// If radius is less than 0, then don't round.
|
||||||
case r < 0:
|
case r < 0:
|
||||||
break
|
return false
|
||||||
|
|
||||||
// If radius is 0, then we have to calculate our own radius.:This only
|
// If radius is 0, then we have to calculate our own radius.:This only
|
||||||
// works if the image is a square.
|
// works if the image is a square.
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
package singlestack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stack struct {
|
||||||
|
*gtk.Stack
|
||||||
|
current gtk.IWidget
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStack() *Stack {
|
||||||
|
stack, _ := gtk.StackNew()
|
||||||
|
return &Stack{stack, nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stack) Add(w gtk.IWidget) {
|
||||||
|
if s.current == w {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if w != nil {
|
||||||
|
s.Stack.Add(w)
|
||||||
|
s.Stack.SetVisibleChild(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.current != nil {
|
||||||
|
s.Stack.Remove(s.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.current = w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stack) SetVisibleChild(w gtk.IWidget) {
|
||||||
|
s.Add(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stack) GetChild() (gtk.IWidget, error) {
|
||||||
|
return s.current, nil
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package spinner
|
||||||
|
|
||||||
|
import "github.com/gotk3/gotk3/gtk"
|
||||||
|
|
||||||
|
type Boxed struct {
|
||||||
|
*gtk.Box
|
||||||
|
Spinner *gtk.Spinner
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Boxed {
|
||||||
|
spin, _ := gtk.SpinnerNew()
|
||||||
|
spin.Show()
|
||||||
|
|
||||||
|
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||||
|
box.SetHAlign(gtk.ALIGN_CENTER)
|
||||||
|
box.SetVAlign(gtk.ALIGN_CENTER)
|
||||||
|
box.Add(spin)
|
||||||
|
|
||||||
|
return &Boxed{box, spin}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Boxed) SetSizeRequest(w, h int) {
|
||||||
|
b.Spinner.SetSizeRequest(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Boxed) Start() {
|
||||||
|
b.Spinner.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Boxed) Stop() {
|
||||||
|
b.Spinner.Stop()
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/diamondburned/cchat/text"
|
"github.com/diamondburned/cchat/text"
|
||||||
"github.com/diamondburned/imgutil"
|
"github.com/diamondburned/imgutil"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IconerFn = func(context.Context, cchat.IconContainer) (func(), error)
|
type IconerFn = func(context.Context, cchat.IconContainer) (func(), error)
|
||||||
|
@ -90,6 +91,7 @@ func (i *Icon) CopyPixbuf(dst httputil.ImageContainer) {
|
||||||
|
|
||||||
// SetPlaceholderIcon is not thread-safe.
|
// SetPlaceholderIcon is not thread-safe.
|
||||||
func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) {
|
func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) {
|
||||||
|
i.Image.SetRadius(-1) // square
|
||||||
i.SetRevealChild(true)
|
i.SetRevealChild(true)
|
||||||
i.SetSize(iconSzPx)
|
i.SetSize(iconSzPx)
|
||||||
|
|
||||||
|
@ -114,16 +116,17 @@ func (i *Icon) SetIcon(url string) {
|
||||||
gts.ExecAsync(func() { i.SetIconUnsafe(url) })
|
gts.ExecAsync(func() { i.SetIconUnsafe(url) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Icon) AsyncSetIconer(iconer cchat.Icon, wrap string) {
|
func (i *Icon) AsyncSetIconer(iconer cchat.Icon, errwrap string) {
|
||||||
AsyncUse(i.r, func(ctx context.Context) (interface{}, func(), error) {
|
AsyncUse(i.r, func(ctx context.Context) (interface{}, func(), error) {
|
||||||
ni := &nullIcon{}
|
ni := &nullIcon{}
|
||||||
f, err := iconer.Icon(ctx, ni)
|
f, err := iconer.Icon(ctx, ni)
|
||||||
return ni, f, err
|
return ni, f, errors.Wrap(err, errwrap)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetIconUnsafe is not thread-safe.
|
// SetIconUnsafe is not thread-safe.
|
||||||
func (i *Icon) SetIconUnsafe(url string) {
|
func (i *Icon) SetIconUnsafe(url string) {
|
||||||
|
i.Image.SetRadius(0) // round
|
||||||
i.SetRevealChild(true)
|
i.SetRevealChild(true)
|
||||||
i.url = url
|
i.url = url
|
||||||
i.updateAsync()
|
i.updateAsync()
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"github.com/diamondburned/cchat"
|
"github.com/diamondburned/cchat"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/gts"
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
|
||||||
"github.com/diamondburned/cchat/text"
|
"github.com/diamondburned/cchat/text"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
"github.com/gotk3/gotk3/pango"
|
"github.com/gotk3/gotk3/pango"
|
||||||
|
@ -23,14 +23,23 @@ type Labeler interface {
|
||||||
Reset()
|
Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SuperLabeler represents a label that inherits the current labeler.
|
||||||
|
type SuperLabeler interface {
|
||||||
|
SetLabelUnsafe(text.Rich)
|
||||||
|
Reset()
|
||||||
|
}
|
||||||
|
|
||||||
type LabelerFn = func(context.Context, cchat.LabelContainer) (func(), error)
|
type LabelerFn = func(context.Context, cchat.LabelContainer) (func(), error)
|
||||||
|
|
||||||
type Label struct {
|
type Label struct {
|
||||||
gtk.Label
|
gtk.Label
|
||||||
current text.Rich
|
Current text.Rich
|
||||||
|
|
||||||
// Reusable primitive.
|
// Reusable primitive.
|
||||||
r *Reusable
|
r *Reusable
|
||||||
|
|
||||||
|
// super unexported field for inheritance
|
||||||
|
super SuperLabeler
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -40,13 +49,13 @@ var (
|
||||||
|
|
||||||
func NewLabel(content text.Rich) *Label {
|
func NewLabel(content text.Rich) *Label {
|
||||||
label, _ := gtk.LabelNew("")
|
label, _ := gtk.LabelNew("")
|
||||||
label.SetMarkup(parser.RenderMarkup(content))
|
label.SetMarkup(markup.Render(content))
|
||||||
label.SetXAlign(0) // left align
|
label.SetXAlign(0) // left align
|
||||||
label.SetEllipsize(pango.ELLIPSIZE_END)
|
label.SetEllipsize(pango.ELLIPSIZE_END)
|
||||||
|
|
||||||
l := &Label{
|
l := &Label{
|
||||||
Label: *label,
|
Label: *label,
|
||||||
current: content,
|
Current: content,
|
||||||
}
|
}
|
||||||
|
|
||||||
// reusable primitive
|
// reusable primitive
|
||||||
|
@ -57,11 +66,30 @@ func NewLabel(content text.Rich) *Label {
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset wipes the state to be just after construction.
|
// NewInheritLabel creates a new label wrapper for structs that inherit this
|
||||||
|
// label.
|
||||||
|
func NewInheritLabel(super SuperLabeler) *Label {
|
||||||
|
l := NewLabel(text.Rich{})
|
||||||
|
l.super = super
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Label) validsuper() bool {
|
||||||
|
_, ok := l.super.(*Label)
|
||||||
|
// supers must not be the current struct and must not be nil.
|
||||||
|
return !ok && l.super != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset wipes the state to be just after construction. If super is not nil,
|
||||||
|
// then it's reset as well.
|
||||||
func (l *Label) Reset() {
|
func (l *Label) Reset() {
|
||||||
l.current = text.Rich{}
|
l.Current = text.Rich{}
|
||||||
l.r.Invalidate()
|
l.r.Invalidate()
|
||||||
l.Label.SetText("")
|
l.Label.SetText("")
|
||||||
|
|
||||||
|
if l.validsuper() {
|
||||||
|
l.super.Reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Label) AsyncSetLabel(fn LabelerFn, info string) {
|
func (l *Label) AsyncSetLabel(fn LabelerFn, info string) {
|
||||||
|
@ -78,20 +106,26 @@ func (l *Label) SetLabel(content text.Rich) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLabelUnsafe sets the label in the current thread, meaning it's not
|
// SetLabelUnsafe sets the label in the current thread, meaning it's not
|
||||||
// thread-safe.
|
// thread-safe. If this label has a super, then it will call that struct's
|
||||||
|
// SetLabelUnsafe instead of its own.
|
||||||
func (l *Label) SetLabelUnsafe(content text.Rich) {
|
func (l *Label) SetLabelUnsafe(content text.Rich) {
|
||||||
l.current = content
|
l.Current = content
|
||||||
l.SetMarkup(parser.RenderMarkup(content))
|
|
||||||
|
if l.validsuper() {
|
||||||
|
l.super.SetLabelUnsafe(content)
|
||||||
|
} else {
|
||||||
|
l.SetMarkup(markup.Render(content))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLabel is NOT thread-safe.
|
// GetLabel is NOT thread-safe.
|
||||||
func (l *Label) GetLabel() text.Rich {
|
func (l *Label) GetLabel() text.Rich {
|
||||||
return l.current
|
return l.Current
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetText is NOT thread-safe.
|
// GetText is NOT thread-safe.
|
||||||
func (l *Label) GetText() string {
|
func (l *Label) GetText() string {
|
||||||
return l.current.Content
|
return l.Current.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToggleButton struct {
|
type ToggleButton struct {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package imgview
|
package labeluri
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -11,7 +11,10 @@ import (
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/dialog"
|
"github.com/diamondburned/cchat-gtk/internal/ui/dialog"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
|
"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/parser/markup"
|
||||||
|
"github.com/diamondburned/cchat/text"
|
||||||
"github.com/gotk3/gotk3/gdk"
|
"github.com/gotk3/gotk3/gdk"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
"github.com/gotk3/gotk3/pango"
|
"github.com/gotk3/gotk3/pango"
|
||||||
|
@ -31,7 +34,84 @@ type WidgetConnector interface {
|
||||||
|
|
||||||
var _ WidgetConnector = (*gtk.Label)(nil)
|
var _ WidgetConnector = (*gtk.Label)(nil)
|
||||||
|
|
||||||
func BindTooltip(connector WidgetConnector) {
|
// Labeler implements a rich label that stores an output state.
|
||||||
|
type Labeler interface {
|
||||||
|
WidgetConnector
|
||||||
|
rich.Labeler
|
||||||
|
Output() markup.RenderOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label implements a label that's already bounded to the markup URI handlers.
|
||||||
|
type Label struct {
|
||||||
|
*rich.Label
|
||||||
|
output markup.RenderOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ Labeler = (*Label)(nil)
|
||||||
|
_ rich.SuperLabeler = (*Label)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewLabel(txt text.Rich) *Label {
|
||||||
|
l := &Label{}
|
||||||
|
l.Label = rich.NewInheritLabel(l)
|
||||||
|
l.Label.SetLabelUnsafe(txt) // test
|
||||||
|
|
||||||
|
// Bind and return.
|
||||||
|
BindRichLabel(l)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Label) Reset() {
|
||||||
|
l.output = markup.RenderOutput{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Label) SetLabelUnsafe(content text.Rich) {
|
||||||
|
l.output = markup.RenderCmplx(content)
|
||||||
|
l.SetMarkup(l.output.Markup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output returns the label's markup output. This function is NOT
|
||||||
|
// thread-safe.
|
||||||
|
func (l *Label) Output() markup.RenderOutput {
|
||||||
|
return l.output
|
||||||
|
}
|
||||||
|
|
||||||
|
func BindRichLabel(label Labeler) {
|
||||||
|
bind(label, func(uri string, ptr gdk.Rectangle) bool {
|
||||||
|
var output = label.Output()
|
||||||
|
|
||||||
|
if mention := output.IsMention(uri); mention != nil {
|
||||||
|
if info := mention.MentionInfo(); !info.Empty() {
|
||||||
|
l, _ := gtk.LabelNew(markup.Render(info))
|
||||||
|
l.SetUseMarkup(true)
|
||||||
|
l.SetXAlign(0)
|
||||||
|
l.Show()
|
||||||
|
|
||||||
|
// Enable images???
|
||||||
|
BindActivator(l)
|
||||||
|
|
||||||
|
p, _ := gtk.PopoverNew(label)
|
||||||
|
p.SetPointingTo(ptr)
|
||||||
|
p.Add(l)
|
||||||
|
p.Connect("destroy", l.Destroy)
|
||||||
|
p.Popup()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func BindActivator(connector WidgetConnector) {
|
||||||
|
bind(connector, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bind connects activate-link. If activator returns true, then nothing is done.
|
||||||
|
// Activator can be nil.
|
||||||
|
func bind(connector WidgetConnector, activator func(uri string, r gdk.Rectangle) bool) {
|
||||||
// This implementation doesn't seem like a good idea. First off, is the
|
// This implementation doesn't seem like a good idea. First off, is the
|
||||||
// closure really garbage collected? If it's not, then we have some huge
|
// closure really garbage collected? If it's not, then we have some huge
|
||||||
// issues. Second, if the closure is garbage collected, then when? If it's
|
// issues. Second, if the closure is garbage collected, then when? If it's
|
||||||
|
@ -44,24 +124,33 @@ func BindTooltip(connector WidgetConnector) {
|
||||||
})
|
})
|
||||||
|
|
||||||
connector.Connect("activate-link", func(c WidgetConnector, uri string) bool {
|
connector.Connect("activate-link", func(c WidgetConnector, uri string) bool {
|
||||||
|
// Make a new rectangle to use in the popover.
|
||||||
|
r := gdk.Rectangle{}
|
||||||
|
r.SetX(int(x))
|
||||||
|
r.SetY(int(y))
|
||||||
|
|
||||||
|
if activator != nil && activator(uri, r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
switch ext(uri) {
|
switch ext(uri) {
|
||||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
||||||
// Make a new rectangle to use in the popover.
|
|
||||||
r := gdk.Rectangle{}
|
|
||||||
r.SetX(int(x))
|
|
||||||
r.SetY(int(y))
|
|
||||||
|
|
||||||
// Make a new image that's asynchronously fetched inside a button.
|
// Make a new image that's asynchronously fetched inside a button.
|
||||||
// This allows us to make it clickable.
|
// Cap the width and height if requested.
|
||||||
img, _ := gtk.ImageNewFromIconName("image-loading", gtk.ICON_SIZE_BUTTON)
|
var w, h, round = markup.FragmentImageSize(uri, MaxWidth, MaxHeight)
|
||||||
img.SetMarginStart(5)
|
|
||||||
img.SetMarginEnd(5)
|
var img *gtk.Image
|
||||||
img.SetMarginTop(5)
|
if !round {
|
||||||
img.SetMarginBottom(5)
|
img, _ = gtk.ImageNew()
|
||||||
|
} else {
|
||||||
|
r, _ := roundimage.NewImage(0)
|
||||||
|
img = r.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
img.SetFromIconName("image-loading", gtk.ICON_SIZE_BUTTON)
|
||||||
img.Show()
|
img.Show()
|
||||||
|
|
||||||
// Cap the width and height if requested.
|
// Asynchronously fetch the image.
|
||||||
var w, h = parser.FragmentImageSize(uri, MaxWidth, MaxHeight)
|
|
||||||
httputil.AsyncImageSized(img, uri, w, h)
|
httputil.AsyncImageSized(img, uri, w, h)
|
||||||
|
|
||||||
btn, _ := gtk.ButtonNew()
|
btn, _ := gtk.ButtonNew()
|
||||||
|
@ -76,10 +165,11 @@ func BindTooltip(connector WidgetConnector) {
|
||||||
p.Add(btn)
|
p.Add(btn)
|
||||||
p.Popup()
|
p.Popup()
|
||||||
|
|
||||||
default:
|
return true
|
||||||
PromptOpen(uri)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PromptOpen(uri)
|
||||||
|
|
||||||
// Never let Gtk open the dialog.
|
// Never let Gtk open the dialog.
|
||||||
return true
|
return true
|
||||||
})
|
})
|
|
@ -2,8 +2,11 @@ package attrmap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/diamondburned/cchat/text"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppendMap struct {
|
type AppendMap struct {
|
||||||
|
@ -31,11 +34,52 @@ func (a *AppendMap) appendIndex(ind int) {
|
||||||
a.indices = append(a.indices, ind)
|
a.indices = append(a.indices, ind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *AppendMap) Anchor(start, end int, href string) {
|
||||||
|
a.Openf(start, `<a href="%s">`, html.EscapeString(href))
|
||||||
|
a.Close(end, "</a>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnchorNU makes a new <a> tag without underlines.
|
||||||
|
func (a *AppendMap) AnchorNU(start, end int, href string) {
|
||||||
|
a.Anchor(start, end, href)
|
||||||
|
a.Span(start, end, `underline="none"`)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *AppendMap) Span(start, end int, attrs ...string) {
|
func (a *AppendMap) Span(start, end int, attrs ...string) {
|
||||||
a.Openf(start, "<span %s>", strings.Join(attrs, " "))
|
a.Openf(start, "<span %s>", strings.Join(attrs, " "))
|
||||||
a.Close(end, "</span>")
|
a.Close(end, "</span>")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pad inserts 2 spaces into start and end. It ensures that not more than 1
|
||||||
|
// space is inserted.
|
||||||
|
func (a *AppendMap) Pad(start, end int) {
|
||||||
|
// Ensure that the starting point does not already have a space.
|
||||||
|
if !posHaveSpace(a.appended, start) {
|
||||||
|
a.Open(start, " ")
|
||||||
|
}
|
||||||
|
if !posHaveSpace(a.prepended, end) {
|
||||||
|
a.Close(end, " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func posHaveSpace(tags map[int]string, pos int) bool {
|
||||||
|
tg, ok := tags[pos]
|
||||||
|
if !ok || len(tg) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check trailing spaces.
|
||||||
|
if tg[0] == ' ' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if tg[len(tg)-1] == ' ' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check spaces mid-tag. This works because strings are always escaped.
|
||||||
|
return strings.Contains(tg, "> <")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *AppendMap) Pair(start, end int, open, close string) {
|
func (a *AppendMap) Pair(start, end int, open, close string) {
|
||||||
a.Open(start, open)
|
a.Open(start, open)
|
||||||
a.Close(end, close)
|
a.Close(end, close)
|
||||||
|
@ -82,3 +126,9 @@ func (a *AppendMap) Finalize(strlen int) []int {
|
||||||
sort.Ints(a.indices)
|
sort.Ints(a.indices)
|
||||||
return a.indices
|
return a.indices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoverAll returns true if the given start and end covers the entire text
|
||||||
|
// segment.
|
||||||
|
func CoverAll(txt text.Rich, start, end int) bool {
|
||||||
|
return start == 0 && end == len(txt.Content)
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
package parser
|
package markup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/attrmap"
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/attrmap"
|
||||||
|
@ -13,53 +14,66 @@ import (
|
||||||
"github.com/diamondburned/imgutil"
|
"github.com/diamondburned/imgutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func markupAttr(attr text.Attribute) string {
|
// Hyphenate controls whether or not texts should have hyphens on wrap.
|
||||||
// meme fast path
|
var Hyphenate = false
|
||||||
if attr == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var attrs = make([]string, 0, 1)
|
func hyphenate(text string) string {
|
||||||
if attr.Has(text.AttrBold) {
|
return fmt.Sprintf(`<span insert_hyphens="%t">%s</span>`, Hyphenate, text)
|
||||||
attrs = append(attrs, `weight="bold"`)
|
|
||||||
}
|
|
||||||
if attr.Has(text.AttrItalics) {
|
|
||||||
attrs = append(attrs, `style="italic"`)
|
|
||||||
}
|
|
||||||
if attr.Has(text.AttrUnderline) {
|
|
||||||
attrs = append(attrs, `underline="single"`)
|
|
||||||
}
|
|
||||||
if attr.Has(text.AttrStrikethrough) {
|
|
||||||
attrs = append(attrs, `strikethrough="true"`)
|
|
||||||
}
|
|
||||||
if attr.Has(text.AttrSpoiler) {
|
|
||||||
attrs = append(attrs, `foreground="#808080"`) // no fancy click here
|
|
||||||
}
|
|
||||||
if attr.Has(text.AttrMonospace) {
|
|
||||||
attrs = append(attrs, `font_family="monospace"`)
|
|
||||||
}
|
|
||||||
return strings.Join(attrs, " ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderMarkup(content text.Rich) string {
|
// RenderOutput is the output of a render.
|
||||||
|
type RenderOutput struct {
|
||||||
|
Markup string
|
||||||
|
Mentions []text.Mentioner
|
||||||
|
}
|
||||||
|
|
||||||
|
// f_Mention is used to print and parse mention URIs.
|
||||||
|
const f_Mention = "cchat://mention:%d" // %d == Mentions[i]
|
||||||
|
|
||||||
|
// IsMention returns the mention if the URI is correct, or nil if none.
|
||||||
|
func (r RenderOutput) IsMention(uri string) text.Mentioner {
|
||||||
|
var i int
|
||||||
|
|
||||||
|
if _, err := fmt.Sscanf(uri, f_Mention, &i); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if i >= len(r.Mentions) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Mentions[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(content text.Rich) string {
|
||||||
|
return RenderCmplx(content).Markup
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderCmplx renders content into a complete output.
|
||||||
|
func RenderCmplx(content text.Rich) RenderOutput {
|
||||||
// Fast path.
|
// Fast path.
|
||||||
if len(content.Segments) == 0 {
|
if len(content.Segments) == 0 {
|
||||||
return html.EscapeString(content.Content)
|
return RenderOutput{
|
||||||
|
Markup: hyphenate(html.EscapeString(content.Content)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := bytes.Buffer{}
|
buf := bytes.Buffer{}
|
||||||
buf.Grow(len(content.Content))
|
buf.Grow(len(content.Content))
|
||||||
|
|
||||||
// // Sort so that all starting points are sorted incrementally.
|
// Sort so that all starting points are sorted incrementally.
|
||||||
// sort.Slice(content.Segments, func(i, j int) bool {
|
sort.SliceStable(content.Segments, func(i, j int) bool {
|
||||||
// i, _ = content.Segments[i].Bounds()
|
i, _ = content.Segments[i].Bounds()
|
||||||
// j, _ = content.Segments[j].Bounds()
|
j, _ = content.Segments[j].Bounds()
|
||||||
// return i < j
|
return i < j
|
||||||
// })
|
})
|
||||||
|
|
||||||
// map to append strings to indices
|
// map to append strings to indices
|
||||||
var appended = attrmap.NewAppendedMap()
|
var appended = attrmap.NewAppendedMap()
|
||||||
|
|
||||||
|
// map to store mentions
|
||||||
|
var mentions []text.Mentioner
|
||||||
|
|
||||||
// Parse all segments.
|
// Parse all segments.
|
||||||
for _, segment := range content.Segments {
|
for _, segment := range content.Segments {
|
||||||
start, end := segment.Bounds()
|
start, end := segment.Bounds()
|
||||||
|
@ -74,17 +88,27 @@ func RenderMarkup(content text.Rich) string {
|
||||||
appended.Open(start, composeImageMarkup(segment))
|
appended.Open(start, composeImageMarkup(segment))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if segment, ok := segment.(text.Avatarer); ok {
|
||||||
|
// Ends don't matter with images.
|
||||||
|
appended.Open(start, composeAvatarMarkup(segment))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mentioner needs to be before colorer, as we'd want the below color
|
||||||
|
// segment to also highlight the full mention as well as make the
|
||||||
|
// padding part of the hyperlink.
|
||||||
|
if segment, ok := segment.(text.Mentioner); ok {
|
||||||
|
// Render the mention into "cchat://mention:0" or such. Other
|
||||||
|
// components will take care of showing the information.
|
||||||
|
appended.AnchorNU(start, end, fmt.Sprintf(f_Mention, len(mentions)))
|
||||||
|
mentions = append(mentions, segment)
|
||||||
|
}
|
||||||
|
|
||||||
if segment, ok := segment.(text.Colorer); ok {
|
if segment, ok := segment.(text.Colorer); ok {
|
||||||
var attrs = []string{fmt.Sprintf(`color="#%06X"`, segment.Color())}
|
var covered = attrmap.CoverAll(content, start, end)
|
||||||
// If the color segment only covers a segment, then add some more
|
appended.Span(start, end, color(segment.Color(), !covered)...)
|
||||||
// formatting.
|
if !covered { // add padding if doesn't cover all
|
||||||
if start > 0 && end < len(content.Content) {
|
appended.Pad(start, end)
|
||||||
attrs = append(attrs,
|
|
||||||
`bgalpha="10%"`,
|
|
||||||
fmt.Sprintf(`bgcolor="#%06X"`, segment.Color()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
appended.Span(start, end, attrs...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if segment, ok := segment.(text.Attributor); ok {
|
if segment, ok := segment.(text.Attributor); ok {
|
||||||
|
@ -114,7 +138,28 @@ func RenderMarkup(content text.Rich) string {
|
||||||
lastIndex = index
|
lastIndex = index
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.String()
|
return RenderOutput{
|
||||||
|
Markup: hyphenate(buf.String()),
|
||||||
|
Mentions: mentions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func color(c uint32, bg bool) []string {
|
||||||
|
var hex = fmt.Sprintf("#%06X", c)
|
||||||
|
|
||||||
|
var attrs = []string{
|
||||||
|
fmt.Sprintf(`color="%s"`, hex),
|
||||||
|
}
|
||||||
|
|
||||||
|
if bg {
|
||||||
|
attrs = append(
|
||||||
|
attrs,
|
||||||
|
`bgalpha="10%"`,
|
||||||
|
fmt.Sprintf(`bgcolor="%s"`, hex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
// string constant for formatting width and height in URL fragments
|
// string constant for formatting width and height in URL fragments
|
||||||
|
@ -138,11 +183,29 @@ func composeImageMarkup(imager text.Imager) string {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func composeAvatarMarkup(avatarer text.Avatarer) string {
|
||||||
|
u, err := url.Parse(avatarer.Avatar())
|
||||||
|
if err != nil {
|
||||||
|
// If the URL is invalid, then just write a normal text.
|
||||||
|
return html.EscapeString(avatarer.AvatarText())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the URL fragment with our own.
|
||||||
|
if size := avatarer.AvatarSize(); size > 0 {
|
||||||
|
u.Fragment = fmt.Sprintf(f_FragmentSize, size, size) + ";round"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`<a href="%s">%s</a>`,
|
||||||
|
html.EscapeString(u.String()), html.EscapeString(avatarer.AvatarText()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// FragmentImageSize tries to parse the width and height encoded in the URL
|
// FragmentImageSize tries to parse the width and height encoded in the URL
|
||||||
// fragment, which is inserted by the markup renderer. A pair of zero values are
|
// fragment, which is inserted by the markup renderer. A pair of zero values are
|
||||||
// returned if there is none. The returned width and height will be the minimum
|
// returned if there is none. The returned width and height will be the minimum
|
||||||
// of the given maxes and the encoded sizes.
|
// of the given maxes and the encoded sizes.
|
||||||
func FragmentImageSize(URL string, maxw, maxh int) (w, h int) {
|
func FragmentImageSize(URL string, maxw, maxh int) (w, h int, round bool) {
|
||||||
u, err := url.Parse(URL)
|
u, err := url.Parse(URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
@ -150,14 +213,48 @@ func FragmentImageSize(URL string, maxw, maxh int) (w, h int) {
|
||||||
|
|
||||||
// Ignore the error, as we can check for the integers.
|
// Ignore the error, as we can check for the integers.
|
||||||
fmt.Sscanf(u.Fragment, f_FragmentSize, &w, &h)
|
fmt.Sscanf(u.Fragment, f_FragmentSize, &w, &h)
|
||||||
|
round = strings.HasSuffix(u.Fragment, ";round")
|
||||||
|
|
||||||
if w > 0 && h > 0 {
|
if w > 0 && h > 0 {
|
||||||
return imgutil.MaxSize(w, h, maxw, maxh)
|
w, h = imgutil.MaxSize(w, h, maxw, maxh)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return maxw, maxh
|
return maxw, maxh, round
|
||||||
}
|
}
|
||||||
|
|
||||||
func span(key, value string) string {
|
func span(key, value string) string {
|
||||||
return "<span key=\"" + value + "\">"
|
return "<span key=\"" + value + "\">"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markupAttr(attr text.Attribute) string {
|
||||||
|
// meme fast path
|
||||||
|
if attr == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var attrs = make([]string, 0, 1)
|
||||||
|
if attr.Has(text.AttrBold) {
|
||||||
|
attrs = append(attrs, `weight="bold"`)
|
||||||
|
}
|
||||||
|
if attr.Has(text.AttrItalics) {
|
||||||
|
attrs = append(attrs, `style="italic"`)
|
||||||
|
}
|
||||||
|
if attr.Has(text.AttrUnderline) {
|
||||||
|
attrs = append(attrs, `underline="single"`)
|
||||||
|
}
|
||||||
|
if attr.Has(text.AttrStrikethrough) {
|
||||||
|
attrs = append(attrs, `strikethrough="true"`)
|
||||||
|
}
|
||||||
|
if attr.Has(text.AttrSpoiler) {
|
||||||
|
attrs = append(attrs, `alpha="35%"`) // no fancy click here
|
||||||
|
}
|
||||||
|
if attr.Has(text.AttrMonospace) {
|
||||||
|
attrs = append(attrs, `font_family="monospace"`)
|
||||||
|
}
|
||||||
|
if attr.Has(text.AttrDimmed) {
|
||||||
|
attrs = append(attrs, `alpha="35%"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(attrs, " ")
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package parser
|
package markup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
|
@ -1,80 +0,0 @@
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
|
||||||
"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) Sessions() []*session.Row {
|
|
||||||
// We already know the size beforehand. Allocate it wisely.
|
|
||||||
var rows = make([]*session.Row, 0, len(c.sessions))
|
|
||||||
|
|
||||||
// Loop over widget children.
|
|
||||||
primitives.EachChildren(c.Box, func(i int, v interface{}) bool {
|
|
||||||
var id = primitives.GetName(v.(primitives.Namer))
|
|
||||||
|
|
||||||
if row, ok := c.sessions[id]; ok {
|
|
||||||
rows = append(rows, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *children) AddSessionRow(id string, row *session.Row) {
|
|
||||||
c.sessions[id] = row
|
|
||||||
c.Box.Add(row)
|
|
||||||
|
|
||||||
// Bind the mover.
|
|
||||||
row.BindMover(id)
|
|
||||||
|
|
||||||
// Assert that a name can be obtained.
|
|
||||||
namer := primitives.Namer(row)
|
|
||||||
namer.SetName(id) // set ID here, get it in Move
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *children) RemoveSessionRow(sessionID string) bool {
|
|
||||||
row, ok := c.sessions[sessionID]
|
|
||||||
if ok {
|
|
||||||
delete(c.sessions, sessionID)
|
|
||||||
c.Box.Remove(row)
|
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *children) MoveSession(id, movingID string) {
|
|
||||||
// Get the widget of the row that is moving.
|
|
||||||
var moving = c.sessions[movingID]
|
|
||||||
|
|
||||||
// Find the current position of the row that we're moving the other one
|
|
||||||
// underneath of.
|
|
||||||
var rowix = -1
|
|
||||||
|
|
||||||
primitives.EachChildren(c.Box, func(i int, v interface{}) bool {
|
|
||||||
// The obtained name will be the ID set in AddSessionRow.
|
|
||||||
if primitives.GetName(v.(primitives.Namer)) == id {
|
|
||||||
rowix = i
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reorder the box.
|
|
||||||
c.Box.ReorderChild(moving, rowix)
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/diamondburned/cchat"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/buttonoverlay"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/config"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
|
|
||||||
"github.com/gotk3/gotk3/gtk"
|
|
||||||
)
|
|
||||||
|
|
||||||
const IconSize = 32
|
|
||||||
|
|
||||||
type header struct {
|
|
||||||
*rich.ToggleButtonImage
|
|
||||||
Add *gtk.Button
|
|
||||||
|
|
||||||
Menu *menu.LazyMenu
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHeader(svc cchat.Service) *header {
|
|
||||||
b := rich.NewToggleButtonImage(svc.Name())
|
|
||||||
b.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
|
|
||||||
b.SetRelief(gtk.RELIEF_NONE)
|
|
||||||
b.SetMode(true)
|
|
||||||
b.Show()
|
|
||||||
|
|
||||||
if iconer, ok := svc.(cchat.Icon); ok {
|
|
||||||
b.Image.AsyncSetIconer(iconer, "Error getting session logo")
|
|
||||||
}
|
|
||||||
|
|
||||||
add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON)
|
|
||||||
add.Show()
|
|
||||||
|
|
||||||
// Add the button overlay into the main button.
|
|
||||||
buttonoverlay.Take(b, add, IconSize)
|
|
||||||
|
|
||||||
// Construct a menu and its items.
|
|
||||||
var menu = menu.NewLazyMenu(b)
|
|
||||||
if configurator, ok := svc.(config.Configurator); ok {
|
|
||||||
menu.AddItems(config.MenuItem(configurator))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &header{b, add, menu}
|
|
||||||
}
|
|
|
@ -0,0 +1,301 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/diamondburned/cchat"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ViewController interface {
|
||||||
|
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
||||||
|
SessionSelected(*Service, *session.Row)
|
||||||
|
AuthenticateSession(*List, *Service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List is a list of services. Each service is a revealer that contains
|
||||||
|
// sessions.
|
||||||
|
type List struct {
|
||||||
|
*gtk.ScrolledWindow
|
||||||
|
|
||||||
|
// same methods as ListController
|
||||||
|
ViewController
|
||||||
|
|
||||||
|
ListBox *gtk.Box
|
||||||
|
Services []*Service // TODO: collision check
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ListController = (*List)(nil)
|
||||||
|
|
||||||
|
var listCSS = primitives.PrepareClassCSS("service-list", `
|
||||||
|
.service-list {
|
||||||
|
padding: 0;
|
||||||
|
background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
func NewList(vctl ViewController) *List {
|
||||||
|
svlist := &List{ViewController: vctl}
|
||||||
|
|
||||||
|
// List box of buttons.
|
||||||
|
svlist.ListBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||||
|
svlist.ListBox.Show()
|
||||||
|
listCSS(svlist.ListBox)
|
||||||
|
|
||||||
|
svlist.ScrolledWindow, _ = gtk.ScrolledWindowNew(nil, nil)
|
||||||
|
svlist.ScrolledWindow.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_EXTERNAL)
|
||||||
|
svlist.ScrolledWindow.Add(svlist.ListBox)
|
||||||
|
|
||||||
|
return svlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *List) SetSizeRequest(w, h int) {
|
||||||
|
sl.ScrolledWindow.SetSizeRequest(w, h)
|
||||||
|
sl.ListBox.SetSizeRequest(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *List) AuthenticateSession(svc *Service) {
|
||||||
|
sl.ViewController.AuthenticateSession(sl, svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *List) AddService(svc cchat.Service) {
|
||||||
|
row := NewService(svc, sl)
|
||||||
|
row.Show()
|
||||||
|
|
||||||
|
sl.ListBox.Add(row)
|
||||||
|
sl.Services = append(sl.Services, row)
|
||||||
|
|
||||||
|
// Try and restore all sessions.
|
||||||
|
row.restoreAll()
|
||||||
|
|
||||||
|
// TODO: drag-and-drop?
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
type View struct {
|
||||||
|
*gtk.ScrolledWindow
|
||||||
|
Box *gtk.Box
|
||||||
|
Services []*Container
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesCSS = primitives.PrepareCSS(`
|
||||||
|
.services {
|
||||||
|
background-color: @theme_base_color;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
func NewView() *View {
|
||||||
|
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||||
|
box.Show()
|
||||||
|
|
||||||
|
primitives.AddClass(box, "services")
|
||||||
|
primitives.AttachCSS(box, servicesCSS)
|
||||||
|
|
||||||
|
sw, _ := gtk.ScrolledWindowNew(nil, nil)
|
||||||
|
sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
|
||||||
|
sw.Add(box)
|
||||||
|
|
||||||
|
return &View{
|
||||||
|
sw,
|
||||||
|
box,
|
||||||
|
nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *View) AddService(svc cchat.Service, ctrl Controller) *Container {
|
||||||
|
s := NewContainer(svc, ctrl)
|
||||||
|
v.Services = append(v.Services, s)
|
||||||
|
v.Box.Add(s)
|
||||||
|
|
||||||
|
// Try and restore all sessions.
|
||||||
|
s.restoreAllSessions()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
type Controller interface {
|
||||||
|
// RowSelected is wrapped around session's MessageRowSelected.
|
||||||
|
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
||||||
|
// AuthenticateSession is called to spawn the authentication dialog.
|
||||||
|
AuthenticateSession(*Container, cchat.Service)
|
||||||
|
// OnSessionRemove is called to remove a session. This should also clear out
|
||||||
|
// the message view in the parent package.
|
||||||
|
OnSessionRemove(id string)
|
||||||
|
// OnSessionDisconnect is here to satisfy session's controller.
|
||||||
|
OnSessionDisconnect(id string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container represents a single service, including the button header and the
|
||||||
|
// child containers.
|
||||||
|
type Container struct {
|
||||||
|
*gtk.Box
|
||||||
|
Service cchat.Service
|
||||||
|
|
||||||
|
header *header
|
||||||
|
revealer *gtk.Revealer
|
||||||
|
children *children
|
||||||
|
|
||||||
|
// Embed controller and extend it to override RestoreSession.
|
||||||
|
Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee that our interface is up-to-date with session's controller.
|
||||||
|
var _ session.Controller = (*Container)(nil)
|
||||||
|
|
||||||
|
func NewContainer(svc cchat.Service, ctrl Controller) *Container {
|
||||||
|
children := newChildren()
|
||||||
|
|
||||||
|
chrev, _ := gtk.RevealerNew()
|
||||||
|
chrev.SetRevealChild(true)
|
||||||
|
chrev.Add(children)
|
||||||
|
chrev.Show()
|
||||||
|
|
||||||
|
header := newHeader(svc)
|
||||||
|
header.SetActive(chrev.GetRevealChild())
|
||||||
|
|
||||||
|
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||||
|
box.Show()
|
||||||
|
box.PackStart(header, false, false, 0)
|
||||||
|
box.PackStart(chrev, false, false, 0)
|
||||||
|
|
||||||
|
primitives.AddClass(box, "service")
|
||||||
|
|
||||||
|
container := &Container{
|
||||||
|
Box: box,
|
||||||
|
Service: svc,
|
||||||
|
header: header,
|
||||||
|
revealer: chrev,
|
||||||
|
children: children,
|
||||||
|
Controller: ctrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
// On click, toggle reveal.
|
||||||
|
header.Connect("clicked", func() {
|
||||||
|
revealed := !chrev.GetRevealChild()
|
||||||
|
chrev.SetRevealChild(revealed)
|
||||||
|
header.SetActive(revealed)
|
||||||
|
})
|
||||||
|
|
||||||
|
// On click, show the auth dialog.
|
||||||
|
header.Add.Connect("clicked", func() {
|
||||||
|
ctrl.AuthenticateSession(container, svc)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add more menu item(s).
|
||||||
|
header.Menu.AddSimpleItem("Save Sessions", container.SaveAllSessions)
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) GetService() cchat.Service {
|
||||||
|
return c.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) Sessions() []*session.Row {
|
||||||
|
return c.children.Sessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) AddSession(ses cchat.Session) *session.Row {
|
||||||
|
srow := session.New(c, ses, c)
|
||||||
|
c.children.AddSessionRow(ses.ID(), srow)
|
||||||
|
c.SaveAllSessions()
|
||||||
|
return srow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) AddLoadingSession(id, name string) *session.Row {
|
||||||
|
srow := session.NewLoading(c, id, name, c)
|
||||||
|
c.children.AddSessionRow(id, srow)
|
||||||
|
return srow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) RemoveSession(row *session.Row) {
|
||||||
|
var id = row.Session.ID()
|
||||||
|
c.children.RemoveSessionRow(id)
|
||||||
|
c.SaveAllSessions()
|
||||||
|
// Call the parent's method.
|
||||||
|
c.Controller.OnSessionRemove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) MoveSession(rowID, beneathRowID string) {
|
||||||
|
c.children.MoveSession(rowID, beneathRowID)
|
||||||
|
c.SaveAllSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) OnSessionDisconnect(ses *session.Row) {
|
||||||
|
c.Controller.OnSessionDisconnect(ses.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreSession tries to restore sessions asynchronously. This satisfies
|
||||||
|
// session.Controller.
|
||||||
|
func (c *Container) RestoreSession(row *session.Row, id string) {
|
||||||
|
// Can this session be restored? If not, exit.
|
||||||
|
restorer, ok := c.Service.(cchat.SessionRestorer)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do we even have a session stored?
|
||||||
|
krs := keyring.RestoreSession(c.Service.Name(), id)
|
||||||
|
if krs == nil {
|
||||||
|
log.Error(fmt.Errorf(
|
||||||
|
"Missing keyring for service %s, session ID %s",
|
||||||
|
c.Service.Name().Content, id,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.restoreSession(row, restorer, *krs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal method called on AddService.
|
||||||
|
func (c *Container) restoreAllSessions() {
|
||||||
|
// Can this session be restored? If not, exit.
|
||||||
|
restorer, ok := c.Service.(cchat.SessionRestorer)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions = keyring.RestoreSessions(c.Service.Name())
|
||||||
|
|
||||||
|
for _, krs := range sessions {
|
||||||
|
// Copy the session to avoid race conditions.
|
||||||
|
krs := krs
|
||||||
|
row := c.AddLoadingSession(krs.ID, krs.Name)
|
||||||
|
|
||||||
|
c.restoreSession(row, restorer, krs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) restoreSession(r *session.Row, res cchat.SessionRestorer, k keyring.Session) {
|
||||||
|
go func() {
|
||||||
|
s, err := res.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() { r.SetFailed(err) })
|
||||||
|
} else {
|
||||||
|
gts.ExecAsync(func() { r.SetSession(s) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) SaveAllSessions() {
|
||||||
|
var sessions = c.children.Sessions()
|
||||||
|
var ksessions = make([]keyring.Session, 0, len(sessions))
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
if k := s.KeyringSession(); k != nil {
|
||||||
|
ksessions = append(ksessions, *k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyring.SaveSessions(c.Service.Name(), ksessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) Breadcrumb() breadcrumb.Breadcrumb {
|
||||||
|
return breadcrumb.Try(nil, c.header.GetText())
|
||||||
|
}
|
||||||
|
*/
|
|
@ -4,239 +4,269 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/diamondburned/cchat"
|
"github.com/diamondburned/cchat"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/gts"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/keyring"
|
"github.com/diamondburned/cchat-gtk/internal/keyring"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type View struct {
|
const IconSize = 48
|
||||||
*gtk.ScrolledWindow
|
|
||||||
Box *gtk.Box
|
type ListController interface {
|
||||||
Services []*Container
|
// RowSelected is called when a server message row is clicked.
|
||||||
|
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
||||||
|
// SessionSelected tells the view to change the session view.
|
||||||
|
SessionSelected(*Service, *session.Row)
|
||||||
|
// AuthenticateSession tells View to call to the parent's authenticator.
|
||||||
|
AuthenticateSession(*Service)
|
||||||
}
|
}
|
||||||
|
|
||||||
var servicesCSS = primitives.PrepareCSS(`
|
// Service holds everything that a single service has.
|
||||||
.services {
|
type Service struct {
|
||||||
background-color: @theme_base_color;
|
*gtk.Box
|
||||||
|
Button *gtk.ToggleButton
|
||||||
|
Icon *rich.Icon
|
||||||
|
|
||||||
|
BodyRev *gtk.Revealer // revealed
|
||||||
|
BodyList *session.List // not really supposed to be here
|
||||||
|
|
||||||
|
svclctrl ListController
|
||||||
|
service cchat.Service // state
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceCSS = primitives.PrepareClassCSS("service", `
|
||||||
|
.service {
|
||||||
|
box-shadow: 0 0 2px 0 alpha(@theme_bg_color, 0.75);
|
||||||
|
margin: 6px 8px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service:first-child { margin-top: 8px; }
|
||||||
|
.service:last-child { margin-bottom: 8px; }
|
||||||
|
`)
|
||||||
|
|
||||||
|
var serviceButtonCSS = primitives.PrepareClassCSS("service-button", `
|
||||||
|
.service-button {
|
||||||
|
padding: 2px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-button:not(:checked) {
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: linear 80ms border-radius; /* TODO add delay */
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-button:checked {
|
||||||
|
border-radius: 14px 14px 0 0;
|
||||||
|
background-color: alpha(@theme_fg_color, 0.2);
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
func NewView() *View {
|
var serviceIconCSS = primitives.PrepareClassCSS("service-icon", `
|
||||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
.service-icon { padding: 4px }
|
||||||
box.Show()
|
`)
|
||||||
|
|
||||||
primitives.AddClass(box, "services")
|
func NewService(svc cchat.Service, svclctrl ListController) *Service {
|
||||||
primitives.AttachCSS(box, servicesCSS)
|
service := &Service{
|
||||||
|
service: svc,
|
||||||
sw, _ := gtk.ScrolledWindowNew(nil, nil)
|
svclctrl: svclctrl,
|
||||||
sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
|
|
||||||
sw.Add(box)
|
|
||||||
|
|
||||||
return &View{
|
|
||||||
sw,
|
|
||||||
box,
|
|
||||||
nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *View) AddService(svc cchat.Service, ctrl Controller) *Container {
|
|
||||||
s := NewContainer(svc, ctrl)
|
|
||||||
v.Services = append(v.Services, s)
|
|
||||||
v.Box.Add(s)
|
|
||||||
|
|
||||||
// Try and restore all sessions.
|
|
||||||
s.restoreAllSessions()
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
type Controller interface {
|
|
||||||
// RowSelected is wrapped around session's MessageRowSelected.
|
|
||||||
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
|
||||||
// AuthenticateSession is called to spawn the authentication dialog.
|
|
||||||
AuthenticateSession(*Container, cchat.Service)
|
|
||||||
// OnSessionRemove is called to remove a session. This should also clear out
|
|
||||||
// the message view in the parent package.
|
|
||||||
OnSessionRemove(id string)
|
|
||||||
// OnSessionDisconnect is here to satisfy session's controller.
|
|
||||||
OnSessionDisconnect(id string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container represents a single service, including the button header and the
|
|
||||||
// child containers.
|
|
||||||
type Container struct {
|
|
||||||
*gtk.Box
|
|
||||||
Service cchat.Service
|
|
||||||
|
|
||||||
header *header
|
|
||||||
revealer *gtk.Revealer
|
|
||||||
children *children
|
|
||||||
|
|
||||||
// Embed controller and extend it to override RestoreSession.
|
|
||||||
Controller
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee that our interface is up-to-date with session's controller.
|
|
||||||
var _ session.Controller = (*Container)(nil)
|
|
||||||
|
|
||||||
func NewContainer(svc cchat.Service, ctrl Controller) *Container {
|
|
||||||
children := newChildren()
|
|
||||||
|
|
||||||
chrev, _ := gtk.RevealerNew()
|
|
||||||
chrev.SetRevealChild(true)
|
|
||||||
chrev.Add(children)
|
|
||||||
chrev.Show()
|
|
||||||
|
|
||||||
header := newHeader(svc)
|
|
||||||
header.SetActive(chrev.GetRevealChild())
|
|
||||||
|
|
||||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
|
||||||
box.Show()
|
|
||||||
box.PackStart(header, false, false, 0)
|
|
||||||
box.PackStart(chrev, false, false, 0)
|
|
||||||
|
|
||||||
primitives.AddClass(box, "service")
|
|
||||||
|
|
||||||
container := &Container{
|
|
||||||
Box: box,
|
|
||||||
Service: svc,
|
|
||||||
header: header,
|
|
||||||
revealer: chrev,
|
|
||||||
children: children,
|
|
||||||
Controller: ctrl,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// On click, toggle reveal.
|
service.BodyList = session.NewList(service)
|
||||||
header.Connect("clicked", func() {
|
service.BodyList.Show()
|
||||||
revealed := !chrev.GetRevealChild()
|
|
||||||
chrev.SetRevealChild(revealed)
|
service.BodyRev, _ = gtk.RevealerNew()
|
||||||
header.SetActive(revealed)
|
service.BodyRev.SetRevealChild(false) // TODO persistent state
|
||||||
|
service.BodyRev.SetTransitionDuration(50)
|
||||||
|
service.BodyRev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_DOWN)
|
||||||
|
service.BodyRev.Add(service.BodyList)
|
||||||
|
service.BodyRev.Show()
|
||||||
|
|
||||||
|
// TODO: have it so the button changes to the session avatar when collapsed
|
||||||
|
|
||||||
|
// TODO: libhandy avatar generation?
|
||||||
|
service.Icon = rich.NewIcon(IconSize)
|
||||||
|
service.Icon.Show()
|
||||||
|
// potentially nonstandard
|
||||||
|
service.Icon.SetPlaceholderIcon("text-html-symbolic", IconSize)
|
||||||
|
// TODO: hover for name. We use tooltip for now.
|
||||||
|
service.Icon.SetTooltipMarkup(markup.Render(svc.Name()))
|
||||||
|
// TODO: add a padding
|
||||||
|
serviceIconCSS(service.Icon)
|
||||||
|
|
||||||
|
if iconer, ok := svc.(cchat.Icon); ok {
|
||||||
|
service.Icon.AsyncSetIconer(iconer, "Failed to set service icon")
|
||||||
|
}
|
||||||
|
|
||||||
|
service.Button, _ = gtk.ToggleButtonNew()
|
||||||
|
service.Button.Add(service.Icon)
|
||||||
|
service.Button.SetRelief(gtk.RELIEF_NONE)
|
||||||
|
service.Button.Show()
|
||||||
|
service.Button.Connect("clicked", func(tb *gtk.ToggleButton) {
|
||||||
|
revealed := !service.GetRevealChild()
|
||||||
|
service.SetRevealChild(revealed)
|
||||||
|
tb.SetActive(revealed)
|
||||||
})
|
})
|
||||||
|
serviceButtonCSS(service.Button)
|
||||||
|
|
||||||
// On click, show the auth dialog.
|
// Intermediary box to contain both the icon and the revealer.
|
||||||
header.Add.Connect("clicked", func() {
|
service.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||||
ctrl.AuthenticateSession(container, svc)
|
service.Box.PackStart(service.Button, false, false, 0)
|
||||||
})
|
service.Box.PackStart(service.BodyRev, false, false, 0)
|
||||||
|
service.Box.Show()
|
||||||
|
serviceCSS(service.Box)
|
||||||
|
|
||||||
// Add more menu item(s).
|
return service
|
||||||
header.Menu.AddSimpleItem("Save Sessions", container.SaveAllSessions)
|
|
||||||
|
|
||||||
return container
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) GetService() cchat.Service {
|
// SetRevealChild sets whether or not the service should reveal all sessions.
|
||||||
return c.Service
|
func (s *Service) SetRevealChild(reveal bool) {
|
||||||
|
s.BodyRev.SetRevealChild(reveal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) Sessions() []*session.Row {
|
// GetRevealChild gets whether or not the service is revealing all sessions.
|
||||||
return c.children.Sessions()
|
func (s *Service) GetRevealChild() bool {
|
||||||
|
return s.BodyRev.GetRevealChild()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) AddSession(ses cchat.Session) *session.Row {
|
func (s *Service) SessionSelected(srow *session.Row) {
|
||||||
srow := session.New(c, ses, c)
|
s.svclctrl.SessionSelected(s, srow)
|
||||||
c.children.AddSessionRow(ses.ID(), srow)
|
}
|
||||||
c.SaveAllSessions()
|
|
||||||
|
func (s *Service) AuthenticateSession() {
|
||||||
|
s.svclctrl.AuthenticateSession(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AddLoadingSession(id, name string) *session.Row {
|
||||||
|
srow := session.NewLoading(s, id, name, s)
|
||||||
|
srow.Show()
|
||||||
|
|
||||||
|
s.BodyList.AddSessionRow(id, srow)
|
||||||
return srow
|
return srow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) AddLoadingSession(id, name string) *session.Row {
|
func (s *Service) AddSession(ses cchat.Session) *session.Row {
|
||||||
srow := session.NewLoading(c, id, name, c)
|
srow := session.New(s, ses, s)
|
||||||
c.children.AddSessionRow(id, srow)
|
srow.Show()
|
||||||
|
|
||||||
|
s.BodyList.AddSessionRow(ses.ID(), srow)
|
||||||
|
s.SaveAllSessions()
|
||||||
return srow
|
return srow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) RemoveSession(row *session.Row) {
|
func (s *Service) Service() cchat.Service {
|
||||||
var id = row.Session.ID()
|
return s.service
|
||||||
c.children.RemoveSessionRow(id)
|
|
||||||
c.SaveAllSessions()
|
|
||||||
// Call the parent's method.
|
|
||||||
c.Controller.OnSessionRemove(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) MoveSession(rowID, beneathRowID string) {
|
func (s *Service) OnSessionDisconnect(row *session.Row) {
|
||||||
c.children.MoveSession(rowID, beneathRowID)
|
s.BodyList.RemoveSessionRow(row.Session.ID())
|
||||||
c.SaveAllSessions()
|
s.SaveAllSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) OnSessionDisconnect(ses *session.Row) {
|
func (s *Service) RowSelected(r *session.Row, sv *server.ServerRow, m cchat.ServerMessage) {
|
||||||
c.Controller.OnSessionDisconnect(ses.ID())
|
s.svclctrl.RowSelected(r, sv, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreSession tries to restore sessions asynchronously. This satisfies
|
func (s *Service) RemoveSession(row *session.Row) {
|
||||||
// session.Controller.
|
s.BodyList.RemoveSessionRow(row.Session.ID())
|
||||||
func (c *Container) RestoreSession(row *session.Row, id string) {
|
s.SaveAllSessions()
|
||||||
// Can this session be restored? If not, exit.
|
|
||||||
restorer, ok := c.Service.(cchat.SessionRestorer)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do we even have a session stored?
|
|
||||||
krs := keyring.RestoreSession(c.Service.Name(), id)
|
|
||||||
if krs == nil {
|
|
||||||
log.Error(fmt.Errorf(
|
|
||||||
"Missing keyring for service %s, session ID %s",
|
|
||||||
c.Service.Name().Content, id,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.restoreSession(row, restorer, *krs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// internal method called on AddService.
|
func (s *Service) MoveSession(id, movingID string) {
|
||||||
func (c *Container) restoreAllSessions() {
|
s.BodyList.MoveSession(id, movingID)
|
||||||
// Can this session be restored? If not, exit.
|
s.SaveAllSessions()
|
||||||
restorer, ok := c.Service.(cchat.SessionRestorer)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessions = keyring.RestoreSessions(c.Service.Name())
|
|
||||||
|
|
||||||
for _, krs := range sessions {
|
|
||||||
// Copy the session to avoid race conditions.
|
|
||||||
krs := krs
|
|
||||||
row := c.AddLoadingSession(krs.ID, krs.Name)
|
|
||||||
|
|
||||||
c.restoreSession(row, restorer, krs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) restoreSession(r *session.Row, res cchat.SessionRestorer, k keyring.Session) {
|
func (s *Service) Breadcrumb() breadcrumb.Breadcrumb {
|
||||||
go func() {
|
return breadcrumb.Try(nil, s.service.Name().Content)
|
||||||
s, err := res.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() { r.SetFailed(err) })
|
|
||||||
} else {
|
|
||||||
gts.ExecAsync(func() { r.SetSession(s) })
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) SaveAllSessions() {
|
func (s *Service) SaveAllSessions() {
|
||||||
var sessions = c.children.Sessions()
|
var sessions = s.BodyList.Sessions()
|
||||||
var ksessions = make([]keyring.Session, 0, len(sessions))
|
var keyrings = make([]keyring.Session, 0, len(sessions))
|
||||||
|
|
||||||
for _, s := range sessions {
|
for _, s := range sessions {
|
||||||
if k := s.KeyringSession(); k != nil {
|
if k := keyring.ConvertSession(s.Session); k != nil {
|
||||||
ksessions = append(ksessions, *k)
|
keyrings = append(keyrings, *k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
keyring.SaveSessions(c.Service.Name(), ksessions)
|
keyring.SaveSessions(s.service, keyrings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) Breadcrumb() breadcrumb.Breadcrumb {
|
func (s *Service) RestoreSession(row *session.Row, id string) {
|
||||||
return breadcrumb.Try(nil, c.header.GetText())
|
rs, ok := s.service.(cchat.SessionRestorer)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if k := keyring.RestoreSession(s.service, id); k != nil {
|
||||||
|
restoreAsync(row, rs, *k)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error(fmt.Errorf(
|
||||||
|
"Missing keyring for service %s, session ID %s",
|
||||||
|
s.service.Name().Content, id,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restoreAll restores all sessions.
|
||||||
|
func (s *Service) restoreAll() {
|
||||||
|
rs, ok := s.service.(cchat.SessionRestorer)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is not a pointer, so we can pass it into arguments safely.
|
||||||
|
for _, ses := range keyring.RestoreSessions(s.service) {
|
||||||
|
row := s.AddLoadingSession(ses.ID, ses.Name)
|
||||||
|
restoreAsync(row, rs, ses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreAsync asynchronously restores a single session.
|
||||||
|
func restoreAsync(r *session.Row, res cchat.SessionRestorer, k keyring.Session) {
|
||||||
|
r.RestoreSession(res, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
type header struct {
|
||||||
|
*rich.ToggleButtonImage
|
||||||
|
Add *gtk.Button
|
||||||
|
|
||||||
|
Menu *menu.LazyMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHeader(svc cchat.Service) *header {
|
||||||
|
b := rich.NewToggleButtonImage(svc.Name())
|
||||||
|
b.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
|
||||||
|
b.SetRelief(gtk.RELIEF_NONE)
|
||||||
|
b.SetMode(true)
|
||||||
|
b.Show()
|
||||||
|
|
||||||
|
if iconer, ok := svc.(cchat.Icon); ok {
|
||||||
|
b.Image.AsyncSetIconer(iconer, "Error getting session logo")
|
||||||
|
}
|
||||||
|
|
||||||
|
add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON)
|
||||||
|
add.Show()
|
||||||
|
|
||||||
|
// Add the button overlay into the main button.
|
||||||
|
buttonoverlay.Take(b, add, IconSize)
|
||||||
|
|
||||||
|
// Construct a menu and its items.
|
||||||
|
var menu = menu.NewLazyMenu(b)
|
||||||
|
if configurator, ok := svc.(config.Configurator); ok {
|
||||||
|
menu.AddItems(config.MenuItem(configurator))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &header{b, add, menu}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO rename file
|
||||||
|
|
||||||
|
// TODO: round buttons
|
||||||
|
func NewAddButton() *gtk.ListBoxRow {
|
||||||
|
img, _ := gtk.ImageNew()
|
||||||
|
img.Show()
|
||||||
|
primitives.SetImageIcon(img, "list-add-symbolic", IconSize/2)
|
||||||
|
|
||||||
|
row, _ := gtk.ListBoxRowNew()
|
||||||
|
row.SetSizeRequest(IconSize, IconSize)
|
||||||
|
row.SetSelectable(false) // activatable though
|
||||||
|
row.Add(img)
|
||||||
|
row.Show()
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceController interface {
|
||||||
|
SessionSelected(*Row)
|
||||||
|
AuthenticateSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
type List struct {
|
||||||
|
*gtk.ListBox
|
||||||
|
// This map isn't ordered, as we rely on the order that the widget was added
|
||||||
|
// into the ListBox.
|
||||||
|
sessions map[string]*Row
|
||||||
|
|
||||||
|
svcctrl ServiceController
|
||||||
|
}
|
||||||
|
|
||||||
|
var listCSS = primitives.PrepareClassCSS("session-list", `
|
||||||
|
.session-list {
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
background-color: mix(@theme_bg_color, @theme_fg_color, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 10px 2px -10px alpha(#121212, 0.6),
|
||||||
|
inset 0 -10px 2px -10px alpha(#121212, 0.6);
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
func NewList(svcctrl ServiceController) *List {
|
||||||
|
list, _ := gtk.ListBoxNew()
|
||||||
|
list.Add(NewAddButton()) // add button to LAST; keep it LAST.
|
||||||
|
list.Show()
|
||||||
|
listCSS(list)
|
||||||
|
|
||||||
|
// We can't do browse for the selection mode, as we need UnselectAll to
|
||||||
|
// work.
|
||||||
|
list.SetSelectionMode(gtk.SELECTION_SINGLE)
|
||||||
|
|
||||||
|
sl := &List{
|
||||||
|
ListBox: list,
|
||||||
|
sessions: map[string]*Row{},
|
||||||
|
svcctrl: svcctrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
|
||||||
|
switch i, length := r.GetIndex(), len(sl.sessions); {
|
||||||
|
case i < 0:
|
||||||
|
return // lulwut
|
||||||
|
|
||||||
|
// If the selection IS the last button.
|
||||||
|
case i == length:
|
||||||
|
svcctrl.AuthenticateSession()
|
||||||
|
|
||||||
|
// If the selection is within range and is not the last button.
|
||||||
|
case i < length:
|
||||||
|
if row, ok := sl.sessions[primitives.GetName(r)]; ok {
|
||||||
|
row.Activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *List) Sessions() []*Row {
|
||||||
|
// We already know the size beforehand. Allocate it wisely.
|
||||||
|
var rows = make([]*Row, 0, len(sl.sessions))
|
||||||
|
|
||||||
|
// Loop over widget children.
|
||||||
|
primitives.EachChildren(sl.ListBox, func(i int, v interface{}) bool {
|
||||||
|
var id = primitives.GetName(v.(primitives.Namer))
|
||||||
|
|
||||||
|
if row, ok := sl.sessions[id]; ok {
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *List) AddSessionRow(id string, row *Row) {
|
||||||
|
// Insert the row RIGHT BEFORE the add button.
|
||||||
|
sl.ListBox.Insert(row, len(sl.sessions))
|
||||||
|
// Set the map, which increases the length by 1.
|
||||||
|
sl.sessions[id] = row
|
||||||
|
|
||||||
|
// Bind the mover.
|
||||||
|
row.BindMover(id)
|
||||||
|
|
||||||
|
// Assert that a name can be obtained.
|
||||||
|
namer := primitives.Namer(row)
|
||||||
|
namer.SetName(id) // set ID here, get it in Move
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *List) RemoveSessionRow(sessionID string) bool {
|
||||||
|
r, ok := sl.sessions[sessionID]
|
||||||
|
if ok {
|
||||||
|
delete(sl.sessions, sessionID)
|
||||||
|
sl.ListBox.Remove(r)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveSession moves sessions around. This function must not touch the add
|
||||||
|
// button.
|
||||||
|
func (sl *List) MoveSession(id, movingID string) {
|
||||||
|
// Get the widget of the row that is moving.
|
||||||
|
var moving = sl.sessions[movingID]
|
||||||
|
|
||||||
|
// Find the current position of the row that we're moving the other one
|
||||||
|
// underneath of.
|
||||||
|
var rowix = -1
|
||||||
|
|
||||||
|
primitives.EachChildren(sl.ListBox, func(i int, v interface{}) bool {
|
||||||
|
// The obtained name will be the ID set in AddSessionRow.
|
||||||
|
if primitives.GetName(v.(primitives.Namer)) == id {
|
||||||
|
rowix = i
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reorder the box.
|
||||||
|
sl.ListBox.Remove(moving)
|
||||||
|
sl.ListBox.Insert(moving, rowix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *List) UnselectAll() {
|
||||||
|
sl.ListBox.UnselectAll()
|
||||||
|
}
|
|
@ -0,0 +1,121 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/service/breadcrumb"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/loading"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Controller interface {
|
||||||
|
RowSelected(*ServerRow, cchat.ServerMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children is a children server with a reference to the parent.
|
||||||
|
type Children struct {
|
||||||
|
*gtk.Box
|
||||||
|
load *loading.Button // only not nil while loading
|
||||||
|
|
||||||
|
Rows []*ServerRow
|
||||||
|
|
||||||
|
Parent breadcrumb.Breadcrumber
|
||||||
|
rowctrl Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserved
|
||||||
|
var childrenCSS = primitives.PrepareClassCSS("server-children", `
|
||||||
|
.server-children {}
|
||||||
|
`)
|
||||||
|
|
||||||
|
func NewChildren(p breadcrumb.Breadcrumber, ctrl Controller) *Children {
|
||||||
|
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||||
|
main.SetMarginStart(ChildrenMargin)
|
||||||
|
childrenCSS(main)
|
||||||
|
|
||||||
|
return &Children{
|
||||||
|
Box: main,
|
||||||
|
Parent: p,
|
||||||
|
rowctrl: ctrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLoading shows the loading circle as a list child.
|
||||||
|
func (c *Children) setLoading() {
|
||||||
|
// Exit if we're already loading.
|
||||||
|
if c.load != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear everything.
|
||||||
|
c.Reset()
|
||||||
|
|
||||||
|
// Set the loading circle and stuff.
|
||||||
|
c.load = loading.NewButton()
|
||||||
|
c.load.Show()
|
||||||
|
c.Box.Add(c.load)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Children) Reset() {
|
||||||
|
// Remove old servers from the list.
|
||||||
|
for _, row := range c.Rows {
|
||||||
|
c.Box.Remove(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wipe the list empty.
|
||||||
|
c.Rows = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNotLoading removes the loading circle, if any. This is not in Reset()
|
||||||
|
// anymore, since the backend may not necessarily call SetServers.
|
||||||
|
func (c *Children) setNotLoading() {
|
||||||
|
// Do we have the spinning circle button? If yes, remove it.
|
||||||
|
if c.load != nil {
|
||||||
|
// Stop the loading mode. The reset function should do everything for us.
|
||||||
|
c.Box.Remove(c.load)
|
||||||
|
c.load = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetServers is reserved for cchat.ServersContainer.
|
||||||
|
func (c *Children) SetServers(servers []cchat.Server) {
|
||||||
|
gts.ExecAsync(func() {
|
||||||
|
// Save the current state.
|
||||||
|
var oldID string
|
||||||
|
for _, row := range c.Rows {
|
||||||
|
if row.GetActive() {
|
||||||
|
oldID = row.Server.ID()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset before inserting new servers.
|
||||||
|
c.Reset()
|
||||||
|
|
||||||
|
c.Rows = make([]*ServerRow, len(servers))
|
||||||
|
|
||||||
|
for i, server := range servers {
|
||||||
|
row := NewServerRow(c, server, c.rowctrl)
|
||||||
|
row.Show()
|
||||||
|
// row.SetFocusHAdjustment(c.GetFocusHAdjustment()) // inherit
|
||||||
|
// row.SetFocusVAdjustment(c.GetFocusVAdjustment())
|
||||||
|
|
||||||
|
c.Rows[i] = row
|
||||||
|
c.Box.Add(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parent reference? Only if it's activated.
|
||||||
|
if oldID != "" {
|
||||||
|
for _, row := range c.Rows {
|
||||||
|
if row.Server.ID() == oldID {
|
||||||
|
row.Button.SetActive(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Children) Breadcrumb() breadcrumb.Breadcrumb {
|
||||||
|
return breadcrumb.Try(c.Parent)
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
"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/breadcrumb"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/button"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/button"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/loading"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
|
||||||
"github.com/diamondburned/cchat/text"
|
"github.com/diamondburned/cchat/text"
|
||||||
"github.com/gotk3/gotk3/gtk"
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
@ -17,8 +16,37 @@ import (
|
||||||
const ChildrenMargin = 24
|
const ChildrenMargin = 24
|
||||||
const IconSize = 32
|
const IconSize = 32
|
||||||
|
|
||||||
type Controller interface {
|
type ServerRow struct {
|
||||||
RowSelected(*ServerRow, cchat.ServerMessage)
|
*Row
|
||||||
|
Server cchat.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverCSS = primitives.PrepareClassCSS("server", `
|
||||||
|
.server {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 3px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
func NewServerRow(p breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *ServerRow {
|
||||||
|
row := NewRow(p, server.Name())
|
||||||
|
row.SetIconer(server)
|
||||||
|
serverCSS(row)
|
||||||
|
|
||||||
|
var serverRow = &ServerRow{Row: row, Server: server}
|
||||||
|
|
||||||
|
switch server := server.(type) {
|
||||||
|
case cchat.ServerList:
|
||||||
|
row.SetServerList(server, ctrl)
|
||||||
|
primitives.AddClass(row, "server-list")
|
||||||
|
|
||||||
|
case cchat.ServerMessage:
|
||||||
|
row.Button.SetClickedIfTrue(func() { ctrl.RowSelected(serverRow, server) })
|
||||||
|
primitives.AddClass(row, "server-message")
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverRow
|
||||||
}
|
}
|
||||||
|
|
||||||
type Row struct {
|
type Row struct {
|
||||||
|
@ -27,6 +55,7 @@ type Row struct {
|
||||||
|
|
||||||
parentcrumb breadcrumb.Breadcrumber
|
parentcrumb breadcrumb.Breadcrumber
|
||||||
|
|
||||||
|
childrev *gtk.Revealer
|
||||||
children *Children
|
children *Children
|
||||||
serverList cchat.ServerList
|
serverList cchat.ServerList
|
||||||
loaded bool
|
loaded bool
|
||||||
|
@ -39,7 +68,6 @@ func NewRow(parent breadcrumb.Breadcrumber, name text.Rich) *Row {
|
||||||
button.Show()
|
button.Show()
|
||||||
|
|
||||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||||
box.SetMarginStart(ChildrenMargin)
|
|
||||||
box.PackStart(button, false, false, 0)
|
box.PackStart(button, false, false, 0)
|
||||||
|
|
||||||
row := &Row{
|
row := &Row{
|
||||||
|
@ -75,7 +103,12 @@ func (r *Row) SetServerList(list cchat.ServerList, ctrl Controller) {
|
||||||
r.children = NewChildren(r, ctrl)
|
r.children = NewChildren(r, ctrl)
|
||||||
r.children.Show()
|
r.children.Show()
|
||||||
|
|
||||||
r.Box.PackStart(r.children, false, false, 0)
|
r.childrev, _ = gtk.RevealerNew()
|
||||||
|
r.childrev.SetRevealChild(false)
|
||||||
|
r.childrev.Add(r.children)
|
||||||
|
r.childrev.Show()
|
||||||
|
|
||||||
|
r.Box.PackStart(r.childrev, false, false, 0)
|
||||||
r.serverList = list
|
r.serverList = list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,7 +194,7 @@ func (r *Row) SetRevealChild(reveal bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actually reveal the children.
|
// Actually reveal the children.
|
||||||
r.children.SetRevealChild(reveal)
|
r.childrev.SetRevealChild(reveal)
|
||||||
|
|
||||||
// If this isn't a reveal, then we don't need to load.
|
// If this isn't a reveal, then we don't need to load.
|
||||||
if !reveal {
|
if !reveal {
|
||||||
|
@ -207,137 +240,8 @@ func (r *Row) Load() {
|
||||||
// GetRevealChild returns whether or not the server list is expanded, or always
|
// GetRevealChild returns whether or not the server list is expanded, or always
|
||||||
// false if there is no server list.
|
// false if there is no server list.
|
||||||
func (r *Row) GetRevealChild() bool {
|
func (r *Row) GetRevealChild() bool {
|
||||||
if r.children != nil {
|
if r.childrev != nil {
|
||||||
return r.children.GetRevealChild()
|
return r.childrev.GetRevealChild()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerRow struct {
|
|
||||||
*Row
|
|
||||||
Server cchat.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServerRow(p breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *ServerRow {
|
|
||||||
row := NewRow(p, server.Name())
|
|
||||||
row.Show()
|
|
||||||
row.SetIconer(server)
|
|
||||||
primitives.AddClass(row, "server")
|
|
||||||
|
|
||||||
var serverRow = &ServerRow{Row: row, Server: server}
|
|
||||||
|
|
||||||
switch server := server.(type) {
|
|
||||||
case cchat.ServerList:
|
|
||||||
row.SetServerList(server, ctrl)
|
|
||||||
primitives.AddClass(row, "server-list")
|
|
||||||
|
|
||||||
case cchat.ServerMessage:
|
|
||||||
row.Button.SetClickedIfTrue(func() { ctrl.RowSelected(serverRow, server) })
|
|
||||||
primitives.AddClass(row, "server-message")
|
|
||||||
}
|
|
||||||
|
|
||||||
return serverRow
|
|
||||||
}
|
|
||||||
|
|
||||||
// Children is a children server with a reference to the parent.
|
|
||||||
type Children struct {
|
|
||||||
*gtk.Revealer
|
|
||||||
Main *gtk.Box
|
|
||||||
|
|
||||||
rowctrl Controller
|
|
||||||
|
|
||||||
load *loading.Button // only not nil while loading
|
|
||||||
|
|
||||||
Rows []*ServerRow
|
|
||||||
Parent breadcrumb.Breadcrumber
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewChildren(p breadcrumb.Breadcrumber, ctrl Controller) *Children {
|
|
||||||
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
|
||||||
main.Show()
|
|
||||||
|
|
||||||
rev, _ := gtk.RevealerNew()
|
|
||||||
rev.SetRevealChild(false)
|
|
||||||
rev.Add(main)
|
|
||||||
|
|
||||||
return &Children{
|
|
||||||
Revealer: rev,
|
|
||||||
Main: main,
|
|
||||||
rowctrl: ctrl,
|
|
||||||
Parent: p,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setLoading shows the loading circle as a list child.
|
|
||||||
func (c *Children) setLoading() {
|
|
||||||
// Exit if we're already loading.
|
|
||||||
if c.load != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear everything.
|
|
||||||
c.Reset()
|
|
||||||
|
|
||||||
// Set the loading circle and stuff.
|
|
||||||
c.load = loading.NewButton()
|
|
||||||
c.load.Show()
|
|
||||||
c.Main.Add(c.load)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Children) Reset() {
|
|
||||||
// Remove old servers from the list.
|
|
||||||
for _, row := range c.Rows {
|
|
||||||
c.Main.Remove(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wipe the list empty.
|
|
||||||
c.Rows = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setNotLoading removes the loading circle, if any. This is not in Reset()
|
|
||||||
// anymore, since the backend may not necessarily call SetServers.
|
|
||||||
func (c *Children) setNotLoading() {
|
|
||||||
// Do we have the spinning circle button? If yes, remove it.
|
|
||||||
if c.load != nil {
|
|
||||||
// Stop the loading mode. The reset function should do everything for us.
|
|
||||||
c.Main.Remove(c.load)
|
|
||||||
c.load = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Children) SetServers(servers []cchat.Server) {
|
|
||||||
gts.ExecAsync(func() {
|
|
||||||
// Save the current state.
|
|
||||||
var oldID string
|
|
||||||
for _, row := range c.Rows {
|
|
||||||
if row.GetActive() {
|
|
||||||
oldID = row.Server.ID()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset before inserting new servers.
|
|
||||||
c.Reset()
|
|
||||||
|
|
||||||
c.Rows = make([]*ServerRow, len(servers))
|
|
||||||
|
|
||||||
for i, server := range servers {
|
|
||||||
row := NewServerRow(c, server, c.rowctrl)
|
|
||||||
c.Rows[i] = row
|
|
||||||
c.Main.Add(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update parent reference? Only if it's activated.
|
|
||||||
if oldID != "" {
|
|
||||||
for _, row := range c.Rows {
|
|
||||||
if row.Server.ID() == oldID {
|
|
||||||
row.Button.SetActive(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Children) Breadcrumb() breadcrumb.Breadcrumb {
|
|
||||||
return breadcrumb.Try(c.Parent)
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,154 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/diamondburned/cchat"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/humanize"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
"github.com/gotk3/gotk3/pango"
|
||||||
|
)
|
||||||
|
|
||||||
|
const FaceSize = 48 // gtk.ICON_SIZE_DIALOG
|
||||||
|
|
||||||
|
// Servers wraps around a list of servers inherited from Children. It's the
|
||||||
|
// container that's displayed on the right of the service sidebar.
|
||||||
|
type Servers struct {
|
||||||
|
*gtk.Box
|
||||||
|
Children *server.Children
|
||||||
|
spinner *spinner.Boxed // non-nil if loading.
|
||||||
|
|
||||||
|
// state
|
||||||
|
ServerList cchat.ServerList
|
||||||
|
}
|
||||||
|
|
||||||
|
var toplevelCSS = primitives.PrepareClassCSS("top-level", `
|
||||||
|
.top-level { margin: 0 3px }
|
||||||
|
`)
|
||||||
|
|
||||||
|
func NewServers(p breadcrumb.Breadcrumber, ctrl server.Controller) *Servers {
|
||||||
|
c := server.NewChildren(p, ctrl)
|
||||||
|
c.SetMarginStart(0) // children is top level; there is no main row
|
||||||
|
c.SetHExpand(true) // fill
|
||||||
|
c.Show()
|
||||||
|
toplevelCSS(c)
|
||||||
|
|
||||||
|
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||||
|
|
||||||
|
return &Servers{
|
||||||
|
Box: b,
|
||||||
|
Children: c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Servers) Reset() {
|
||||||
|
// Reset isn't necessarily called while loading, so we do a check.
|
||||||
|
if s.spinner != nil {
|
||||||
|
s.spinner.Stop()
|
||||||
|
s.spinner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the state.
|
||||||
|
s.ServerList = nil
|
||||||
|
// Remove all children.
|
||||||
|
primitives.RemoveChildren(s)
|
||||||
|
// Reset the children container.
|
||||||
|
s.Children.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLoading returns true if the servers container is loading.
|
||||||
|
func (s *Servers) IsLoading() bool {
|
||||||
|
return s.spinner != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetList indicates that the server list has been loaded. Unlike
|
||||||
|
// server.Children, this method will load immediately.
|
||||||
|
func (s *Servers) SetList(slist cchat.ServerList) {
|
||||||
|
primitives.RemoveChildren(s)
|
||||||
|
s.ServerList = slist
|
||||||
|
s.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Servers) load() {
|
||||||
|
// Return if we're loading.
|
||||||
|
if s.IsLoading() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the servers list as loading.
|
||||||
|
s.setLoading()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := s.ServerList.Servers(s.Children)
|
||||||
|
gts.ExecAsync(func() {
|
||||||
|
if err != nil {
|
||||||
|
s.setFailed(err)
|
||||||
|
} else {
|
||||||
|
s.setDone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDone changes the view to show the servers.
|
||||||
|
func (s *Servers) setDone() {
|
||||||
|
primitives.RemoveChildren(s)
|
||||||
|
|
||||||
|
// stop the spinner.
|
||||||
|
s.spinner.Stop()
|
||||||
|
s.spinner = nil
|
||||||
|
|
||||||
|
s.Add(s.Children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLoading shows a loading spinner. Use this after the session row is
|
||||||
|
// connected.
|
||||||
|
func (s *Servers) setLoading() {
|
||||||
|
primitives.RemoveChildren(s)
|
||||||
|
|
||||||
|
s.spinner = spinner.New()
|
||||||
|
s.spinner.SetSizeRequest(FaceSize, FaceSize)
|
||||||
|
s.spinner.Show()
|
||||||
|
s.spinner.Start()
|
||||||
|
|
||||||
|
s.Add(s.spinner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFailed shows a sad face with the error. Use this when the session row has
|
||||||
|
// failed to load.
|
||||||
|
func (s *Servers) setFailed(err error) {
|
||||||
|
primitives.RemoveChildren(s)
|
||||||
|
|
||||||
|
// stop the spinner. Let this SEGFAULT if nil, as that's undefined behavior.
|
||||||
|
s.spinner.Stop()
|
||||||
|
s.spinner = nil
|
||||||
|
|
||||||
|
// Create a BLANK label for padding.
|
||||||
|
ltop, _ := gtk.LabelNew("")
|
||||||
|
ltop.Show()
|
||||||
|
|
||||||
|
// Create a retry button.
|
||||||
|
btn, _ := gtk.ButtonNewFromIconName("view-refresh-symbolic", gtk.ICON_SIZE_DIALOG)
|
||||||
|
btn.Show()
|
||||||
|
btn.Connect("clicked", s.load)
|
||||||
|
|
||||||
|
// Create a bottom label for the error itself.
|
||||||
|
lerr, _ := gtk.LabelNew("")
|
||||||
|
lerr.SetSingleLineMode(true)
|
||||||
|
lerr.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
|
||||||
|
lerr.SetMarkup(fmt.Sprintf(
|
||||||
|
`<span color="red"><b>Error:</b> %s</span>`,
|
||||||
|
humanize.Error(err),
|
||||||
|
))
|
||||||
|
lerr.Show()
|
||||||
|
|
||||||
|
// Add these items into the box.
|
||||||
|
s.PackStart(ltop, false, false, 0)
|
||||||
|
s.PackStart(btn, false, false, 10) // pad
|
||||||
|
s.PackStart(lerr, false, false, 0)
|
||||||
|
}
|
|
@ -6,9 +6,10 @@ import (
|
||||||
"github.com/diamondburned/cchat-gtk/internal/keyring"
|
"github.com/diamondburned/cchat-gtk/internal/keyring"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/log"
|
"github.com/diamondburned/cchat-gtk/internal/log"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/buttonoverlay"
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
|
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/commander"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/commander"
|
||||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||||
"github.com/diamondburned/cchat/text"
|
"github.com/diamondburned/cchat/text"
|
||||||
|
@ -16,16 +17,19 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const IconSize = 32
|
const IconSize = 48
|
||||||
const IconName = "face-plain-symbolic"
|
const IconName = "face-plain-symbolic"
|
||||||
|
|
||||||
// Controller extends server.RowController to add session.
|
// Servicer extends server.RowController to add session.
|
||||||
type Controller interface {
|
type Servicer interface {
|
||||||
// GetService asks the controller for its service.
|
// Service asks the controller for its service.
|
||||||
GetService() cchat.Service
|
Service() cchat.Service
|
||||||
// OnSessionDisconnect is called before a session is disconnected. This
|
// OnSessionDisconnect is called before a session is disconnected. This
|
||||||
// function is used for cleanups.
|
// function is used for cleanups.
|
||||||
OnSessionDisconnect(*Row)
|
OnSessionDisconnect(*Row)
|
||||||
|
// SessionSelected is called when the row is clicked. The parent container
|
||||||
|
// should change the views to show this session's *Servers.
|
||||||
|
SessionSelected(*Row)
|
||||||
// RowSelected is called when a server that can display messages (aka
|
// RowSelected is called when a server that can display messages (aka
|
||||||
// implements ServerMessage) is called.
|
// implements ServerMessage) is called.
|
||||||
RowSelected(*Row, *server.ServerRow, cchat.ServerMessage)
|
RowSelected(*Row, *server.ServerRow, cchat.ServerMessage)
|
||||||
|
@ -40,6 +44,286 @@ type Controller interface {
|
||||||
MoveSession(id, movingID string)
|
MoveSession(id, movingID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Row represents a session row entry in the session List.
|
||||||
|
type Row struct {
|
||||||
|
*gtk.ListBoxRow
|
||||||
|
icon *rich.Icon // nilable
|
||||||
|
|
||||||
|
Session cchat.Session // state; nilable
|
||||||
|
sessionID string
|
||||||
|
|
||||||
|
Servers *Servers // accessed by View for the right view
|
||||||
|
svcctrl Servicer
|
||||||
|
|
||||||
|
// TODO: enum class? having the button be red on fail would be good
|
||||||
|
|
||||||
|
// put commander in either a hover menu or a right click menu. maybe in the
|
||||||
|
// headerbar as well.
|
||||||
|
// TODO headerbar how? custom interface to get menu items and callbacks in
|
||||||
|
// controller?
|
||||||
|
cmder *commander.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowCSS = primitives.PrepareClassCSS("session-row", `
|
||||||
|
.session-row:last-child {
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
.session-row:selected {
|
||||||
|
background-color: alpha(@theme_selected_bg_color, 0.5);
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
var rowIconCSS = primitives.PrepareClassCSS("session-icon", `
|
||||||
|
.session-icon {
|
||||||
|
padding: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Servicer) *Row {
|
||||||
|
row := newRow(parent, text.Rich{}, ctrl)
|
||||||
|
row.SetSession(ses)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoading(parent breadcrumb.Breadcrumber, id, name string, ctrl Servicer) *Row {
|
||||||
|
row := newRow(parent, text.Rich{Content: name}, ctrl)
|
||||||
|
row.sessionID = id
|
||||||
|
row.SetLoading()
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRow(parent breadcrumb.Breadcrumber, name text.Rich, ctrl Servicer) *Row {
|
||||||
|
row := &Row{svcctrl: ctrl}
|
||||||
|
|
||||||
|
row.icon = rich.NewIcon(IconSize)
|
||||||
|
row.icon.SetPlaceholderIcon(IconName, IconSize)
|
||||||
|
row.icon.Show()
|
||||||
|
rowIconCSS(row.icon)
|
||||||
|
|
||||||
|
row.ListBoxRow, _ = gtk.ListBoxRowNew()
|
||||||
|
rowCSS(row.ListBoxRow)
|
||||||
|
|
||||||
|
// TODO: commander button
|
||||||
|
|
||||||
|
row.Servers = NewServers(parent, row)
|
||||||
|
row.Servers.Show()
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset extends the server row's Reset function and resets additional states.
|
||||||
|
// It resets all states back to nil, but the session ID stays.
|
||||||
|
func (r *Row) Reset() {
|
||||||
|
r.Servers.Reset() // wipe servers
|
||||||
|
// TODO: better visual clue
|
||||||
|
r.icon.Image.SetFromPixbuf(nil) // wipe image
|
||||||
|
r.Session = nil
|
||||||
|
r.cmder = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate executes whatever needs to be done. If the row has failed, then this
|
||||||
|
// method will reconnect. If the row is already loaded, then SessionSelected
|
||||||
|
// will be called.
|
||||||
|
func (r *Row) Activate() {
|
||||||
|
// If session is nil, then we've probably failed to load it. The row is
|
||||||
|
// deactivated while loading, so this wouldn't have happened.
|
||||||
|
if r.Session == nil {
|
||||||
|
r.ReconnectSession()
|
||||||
|
} else {
|
||||||
|
r.svcctrl.SessionSelected(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoading sets the session button to have a spinner circle. DO NOT CONFUSE
|
||||||
|
// THIS WITH THE SERVERS LOADING.
|
||||||
|
func (r *Row) SetLoading() {
|
||||||
|
// Reset the state.
|
||||||
|
r.Session = nil
|
||||||
|
|
||||||
|
// Reset the icon.
|
||||||
|
r.icon.Image.SetFromPixbuf(nil)
|
||||||
|
|
||||||
|
// Remove everything from the row, including the icon.
|
||||||
|
primitives.RemoveChildren(r)
|
||||||
|
|
||||||
|
// Add a loading circle.
|
||||||
|
spin := spinner.New()
|
||||||
|
spin.SetSizeRequest(IconSize, IconSize)
|
||||||
|
spin.Start()
|
||||||
|
spin.Show()
|
||||||
|
|
||||||
|
r.Add(spin)
|
||||||
|
r.SetSensitive(false) // no activate
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFailed sets the initial connect status to failed. Do note that session can
|
||||||
|
// have 2 types of loading: loading the session and loading the server list.
|
||||||
|
// This one sets the former.
|
||||||
|
func (r *Row) SetFailed(err error) {
|
||||||
|
// Make sure that Session is still nil.
|
||||||
|
r.Session = nil
|
||||||
|
// Re-enable the row.
|
||||||
|
r.SetSensitive(true)
|
||||||
|
// Remove everything off the row.
|
||||||
|
primitives.RemoveChildren(r)
|
||||||
|
// Add the icon.
|
||||||
|
r.Add(r.icon)
|
||||||
|
// Set the button to a retry icon.
|
||||||
|
r.icon.SetPlaceholderIcon("view-refresh-symbolic", IconSize)
|
||||||
|
|
||||||
|
// SetFailed, but also add the callback to retry.
|
||||||
|
// r.Row.SetFailed(err, r.ReconnectSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Row) RestoreSession(res cchat.SessionRestorer, k keyring.Session) {
|
||||||
|
go func() {
|
||||||
|
s, err := res.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() { r.SetFailed(err) })
|
||||||
|
} else {
|
||||||
|
gts.ExecAsync(func() { r.SetSession(s) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSession binds the session and marks the row as ready. It extends SetDone.
|
||||||
|
func (r *Row) SetSession(ses cchat.Session) {
|
||||||
|
// Set the states.
|
||||||
|
r.Session = ses
|
||||||
|
r.sessionID = ses.ID()
|
||||||
|
r.SetTooltipMarkup(markup.Render(ses.Name()))
|
||||||
|
r.icon.SetPlaceholderIcon(IconName, IconSize)
|
||||||
|
|
||||||
|
// If the session has an icon, then use it.
|
||||||
|
if iconer, ok := ses.(cchat.Icon); ok {
|
||||||
|
r.icon.AsyncSetIconer(iconer, "Failed to set session icon")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to indicate that we're done.
|
||||||
|
primitives.RemoveChildren(r)
|
||||||
|
r.SetSensitive(true)
|
||||||
|
r.Add(r.icon)
|
||||||
|
|
||||||
|
// Set the commander, if any. The function will return nil if the assertion
|
||||||
|
// returns nil. As such, we assert with an ignored ok bool, allowing cmd to
|
||||||
|
// be nil.
|
||||||
|
cmd, _ := ses.(commander.SessionCommander)
|
||||||
|
r.cmder = commander.NewBuffer(r.svcctrl.Service(), cmd)
|
||||||
|
|
||||||
|
// TODO commander button
|
||||||
|
|
||||||
|
// // Bind extra menu items before loading. These items won't be clickable
|
||||||
|
// // during loading.
|
||||||
|
// r.SetNormalExtraMenu([]menu.Item{
|
||||||
|
// menu.SimpleItem("Disconnect", r.DisconnectSession),
|
||||||
|
// menu.SimpleItem("Remove", r.RemoveSession),
|
||||||
|
// })
|
||||||
|
|
||||||
|
// Load all top-level servers now.
|
||||||
|
r.Servers.SetList(ses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindMover binds with the ID stored in the parent container to be used in the
|
||||||
|
// method itself. The ID may or may not have to do with session.
|
||||||
|
func (r *Row) BindMover(id string) {
|
||||||
|
// TODO: rows can be highlighted.
|
||||||
|
// primitives.BindDragSortable(r.Button, "GTK_TOGGLE_BUTTON", id, r.ctrl.MoveSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Row) RowSelected(sr *server.ServerRow, smsg cchat.ServerMessage) {
|
||||||
|
r.svcctrl.RowSelected(r, sr, smsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSession removes itself from the session list.
|
||||||
|
func (r *Row) RemoveSession() {
|
||||||
|
// Remove the session off the list.
|
||||||
|
r.svcctrl.RemoveSession(r)
|
||||||
|
|
||||||
|
// Asynchrously disconnect.
|
||||||
|
go func() {
|
||||||
|
if err := r.Session.Disconnect(); err != nil {
|
||||||
|
log.Error(errors.Wrap(err, "Non-fatal, failed to disconnect removed session"))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconnectSession tries to reconnect with the keyring data. This is a slow
|
||||||
|
// method but it's also a very cold path.
|
||||||
|
func (r *Row) ReconnectSession() {
|
||||||
|
// If we haven't ever connected, then don't run. In a legitimate case, this
|
||||||
|
// shouldn't happen.
|
||||||
|
if r.sessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the row as loading.
|
||||||
|
r.SetLoading()
|
||||||
|
// Try to restore the session.
|
||||||
|
r.svcctrl.RestoreSession(r, r.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisconnectSession disconnects the current session. It does nothing if the row
|
||||||
|
// does not have a session active.
|
||||||
|
func (r *Row) DisconnectSession() {
|
||||||
|
// No-op if no session.
|
||||||
|
if r.Session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the disconnect function from the controller first.
|
||||||
|
r.svcctrl.OnSessionDisconnect(r)
|
||||||
|
|
||||||
|
// Show visually that we're disconnected first by wiping all servers.
|
||||||
|
r.Reset()
|
||||||
|
|
||||||
|
// Disable the button because we're busy disconnecting. We'll re-enable them
|
||||||
|
// once we're done reconnecting.
|
||||||
|
r.SetSensitive(false)
|
||||||
|
|
||||||
|
// Try and disconnect asynchronously.
|
||||||
|
gts.Async(func() (func(), error) {
|
||||||
|
// Disconnect and wrap the error if any. Wrap works with a nil error.
|
||||||
|
err := errors.Wrap(r.Session.Disconnect(), "Failed to disconnect.")
|
||||||
|
return func() {
|
||||||
|
// Re-enable access to the menu.
|
||||||
|
r.SetSensitive(true)
|
||||||
|
|
||||||
|
// Set the menu to allow disconnection.
|
||||||
|
// r.Button.SetNormalExtraMenu([]menu.Item{
|
||||||
|
// menu.SimpleItem("Connect", r.ReconnectSession),
|
||||||
|
// menu.SimpleItem("Remove", r.RemoveSession),
|
||||||
|
// })
|
||||||
|
}, err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// // KeyringSession returns a keyring session, or nil if the session cannot be
|
||||||
|
// // saved.
|
||||||
|
// func (r *Row) KeyringSession() *keyring.Session {
|
||||||
|
// return keyring.ConvertSession(r.Session)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ID returns the session ID.
|
||||||
|
func (r *Row) ID() string {
|
||||||
|
return r.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowCommander shows the commander dialog, or it does nothing if session does
|
||||||
|
// not implement commander.
|
||||||
|
func (r *Row) ShowCommander() {
|
||||||
|
if r.cmder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.cmder.ShowDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
// deprecate server.Row inheritance since the structure is entirely different
|
||||||
|
|
||||||
|
/*
|
||||||
// Row represents a single session, including the button header and the
|
// Row represents a single session, including the button header and the
|
||||||
// children servers.
|
// children servers.
|
||||||
type Row struct {
|
type Row struct {
|
||||||
|
@ -47,26 +331,26 @@ type Row struct {
|
||||||
Session cchat.Session
|
Session cchat.Session
|
||||||
sessionID string // used for reconnection
|
sessionID string // used for reconnection
|
||||||
|
|
||||||
ctrl Controller
|
ctrl Servicer
|
||||||
|
|
||||||
cmder *commander.Buffer
|
cmder *commander.Buffer
|
||||||
cmdbtn *gtk.Button
|
cmdbtn *gtk.Button
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Row {
|
func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Servicer) *Row {
|
||||||
row := newRow(parent, text.Rich{}, ctrl)
|
row := newRow(parent, text.Rich{}, ctrl)
|
||||||
row.SetSession(ses)
|
row.SetSession(ses)
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLoading(parent breadcrumb.Breadcrumber, id, name string, ctrl Controller) *Row {
|
func NewLoading(parent breadcrumb.Breadcrumber, id, name string, ctrl Servicer) *Row {
|
||||||
row := newRow(parent, text.Rich{Content: name}, ctrl)
|
row := newRow(parent, text.Rich{Content: name}, ctrl)
|
||||||
row.sessionID = id
|
row.sessionID = id
|
||||||
row.Row.SetLoading()
|
row.Row.SetLoading()
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRow(parent breadcrumb.Breadcrumber, name text.Rich, ctrl Controller) *Row {
|
func newRow(parent breadcrumb.Breadcrumber, name text.Rich, ctrl Servicer) *Row {
|
||||||
srow := server.NewRow(parent, name)
|
srow := server.NewRow(parent, name)
|
||||||
srow.Button.SetPlaceholderIcon(IconName, IconSize)
|
srow.Button.SetPlaceholderIcon(IconName, IconSize)
|
||||||
srow.Show()
|
srow.Show()
|
||||||
|
@ -220,12 +504,6 @@ func (r *Row) RowSelected(server *server.ServerRow, smsg cchat.ServerMessage) {
|
||||||
r.ctrl.RowSelected(r, server, smsg)
|
r.ctrl.RowSelected(r, server, smsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindMover binds with the ID stored in the parent container to be used in the
|
|
||||||
// method itself. The ID may or may not have to do with session.
|
|
||||||
func (r *Row) BindMover(id string) {
|
|
||||||
primitives.BindDragSortable(r.Button, "GTK_TOGGLE_BUTTON", id, r.ctrl.MoveSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowCommander shows the commander dialog, or it does nothing if session does
|
// ShowCommander shows the commander dialog, or it does nothing if session does
|
||||||
// not implement commander.
|
// not implement commander.
|
||||||
func (r *Row) ShowCommander() {
|
func (r *Row) ShowCommander() {
|
||||||
|
@ -234,3 +512,4 @@ func (r *Row) ShowCommander() {
|
||||||
}
|
}
|
||||||
r.cmder.ShowDialog()
|
r.cmder.ShowDialog()
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/diamondburned/cchat"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/singlestack"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||||
|
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||||
|
"github.com/gotk3/gotk3/gtk"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
Design:
|
||||||
|
|
||||||
|
____________________________
|
||||||
|
| # | | |
|
||||||
|
|-----|-----------|--------|
|
||||||
|
| D | nixhub | |
|
||||||
|
| --- | #home | | <- shaded revealer
|
||||||
|
| O | #dev... | | <- user accounts collapsed
|
||||||
|
| --- | astolf... | |
|
||||||
|
| | asdada... | |
|
||||||
|
| M | | |
|
||||||
|
|_____|___________|________|
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Controller interface {
|
||||||
|
// SessionSelected is called when
|
||||||
|
SessionSelected(svc *Service, srow *session.Row)
|
||||||
|
// RowSelected is wrapped around session's MessageRowSelected.
|
||||||
|
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
||||||
|
// AuthenticateSession is called to spawn the authentication dialog.
|
||||||
|
AuthenticateSession(*List, *Service)
|
||||||
|
// OnSessionRemove is called to remove a session. This should also clear out
|
||||||
|
// the message view in the parent package.
|
||||||
|
OnSessionRemove(id string)
|
||||||
|
// OnSessionDisconnect is here to satisfy session's controller.
|
||||||
|
OnSessionDisconnect(id string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeftWidth is the width of the left-most services panel.
|
||||||
|
const LeftWidth = IconSize
|
||||||
|
|
||||||
|
type View struct {
|
||||||
|
*gtk.Box // 2 panes, but left-most hard-coded
|
||||||
|
Controller // inherit main controller
|
||||||
|
|
||||||
|
Services *List
|
||||||
|
ServerView *gtk.ScrolledWindow
|
||||||
|
|
||||||
|
ServerStack *singlestack.Stack
|
||||||
|
|
||||||
|
// Servers *session.Servers // nil by default; use .Servers
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewView(ctrller Controller) *View {
|
||||||
|
view := &View{Controller: ctrller}
|
||||||
|
|
||||||
|
view.Services = NewList(view)
|
||||||
|
view.Services.SetSizeRequest(LeftWidth, -1)
|
||||||
|
view.Services.Show()
|
||||||
|
|
||||||
|
// Make a separator.
|
||||||
|
// sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_VERTICAL)
|
||||||
|
// sep.Show()
|
||||||
|
|
||||||
|
// Make a stack for the middle panel.
|
||||||
|
view.ServerStack = singlestack.NewStack()
|
||||||
|
view.ServerStack.SetSizeRequest(150, -1) // min width
|
||||||
|
view.ServerStack.SetTransitionDuration(50)
|
||||||
|
view.ServerStack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
|
||||||
|
view.ServerStack.SetHomogeneous(true)
|
||||||
|
view.ServerStack.Show()
|
||||||
|
|
||||||
|
view.ServerView, _ = gtk.ScrolledWindowNew(nil, nil)
|
||||||
|
view.ServerView.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
|
||||||
|
view.ServerView.Add(view.ServerStack)
|
||||||
|
view.ServerView.Show()
|
||||||
|
|
||||||
|
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||||
|
view.Box.PackStart(view.Services, false, false, 0)
|
||||||
|
// view.Box.PackStart(sep, false, false, 0)
|
||||||
|
view.Box.PackStart(view.ServerView, true, true, 0)
|
||||||
|
view.Box.Show()
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *View) AddService(svc cchat.Service) {
|
||||||
|
v.Services.AddService(svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionSelected calls the right-side server view to change.
|
||||||
|
//
|
||||||
|
// TODO: think of how to change. Maybe use a stack? Maybe use a box that we
|
||||||
|
// remove and re-add? does animation matter?
|
||||||
|
func (v *View) SessionSelected(svc *Service, srow *session.Row) {
|
||||||
|
// Unselect every service boxes except this one.
|
||||||
|
for _, service := range v.Services.Services {
|
||||||
|
if service != svc {
|
||||||
|
service.BodyList.UnselectAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// !!!: SHITTY HACK!!!
|
||||||
|
// We can do this, as we're keeping all the server lists in memory by Go's
|
||||||
|
// reference anyway. In fact, cchat REQUIRES us to do so.
|
||||||
|
v.ServerStack.SetVisibleChild(srow.Servers)
|
||||||
|
|
||||||
|
// Call the controller's method.
|
||||||
|
v.Controller.SessionSelected(svc, srow)
|
||||||
|
}
|
|
@ -1,10 +1,7 @@
|
||||||
/* Make CSS more consistent across themes */
|
/* Make CSS more consistent across themes */
|
||||||
headerbar { padding: 0; }
|
headerbar { padding: 0; }
|
||||||
|
|
||||||
/* Consistent design */
|
popover > *:not(stack):not(button) { margin: 6px; }
|
||||||
.services button { border-radius: 0; }
|
|
||||||
|
|
||||||
popover > box { margin: 6px; }
|
|
||||||
|
|
||||||
/* Hack to fix the input bar being high in Adwaita */
|
/* Hack to fix the input bar being high in Adwaita */
|
||||||
.input-field * { min-height: 0; }
|
.input-field * { min-height: 0; }
|
||||||
|
|
|
@ -23,7 +23,7 @@ func init() {
|
||||||
// constraints for the left panel
|
// constraints for the left panel
|
||||||
const (
|
const (
|
||||||
leftMinWidth = 200
|
leftMinWidth = 200
|
||||||
leftCurrentWidth = 250
|
leftCurrentWidth = 275
|
||||||
leftMaxWidth = 400
|
leftMaxWidth = 400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -52,10 +52,9 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewApplication() *App {
|
func NewApplication() *App {
|
||||||
app := &App{
|
app := &App{}
|
||||||
window: newWindow(),
|
app.window = newWindow(app)
|
||||||
header: newHeader(),
|
app.header = newHeader()
|
||||||
}
|
|
||||||
|
|
||||||
// Resize the left-side header w/ the left-side pane.
|
// Resize the left-side header w/ the left-side pane.
|
||||||
app.window.Services.Connect("size-allocate", func(wv gtk.IWidget) {
|
app.window.Services.Connect("size-allocate", func(wv gtk.IWidget) {
|
||||||
|
@ -73,7 +72,7 @@ func NewApplication() *App {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) AddService(svc cchat.Service) {
|
func (app *App) AddService(svc cchat.Service) {
|
||||||
app.window.Services.AddService(svc, app)
|
app.window.Services.AddService(svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnSessionRemove resets things before the session is removed.
|
// OnSessionRemove resets things before the session is removed.
|
||||||
|
@ -91,6 +90,12 @@ func (app *App) OnSessionDisconnect(id string) {
|
||||||
app.OnSessionRemove(id)
|
app.OnSessionRemove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *App) SessionSelected(svc *service.Service, ses *session.Row) {
|
||||||
|
// TODO: restore last message box
|
||||||
|
app.window.MessageView.Reset()
|
||||||
|
app.header.SetBreadcrumb(nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) {
|
func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) {
|
||||||
// Is there an old row that we should deactivate?
|
// Is there an old row that we should deactivate?
|
||||||
if app.lastSelector != nil {
|
if app.lastSelector != nil {
|
||||||
|
@ -114,9 +119,10 @@ func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) AuthenticateSession(container *service.Container, svc cchat.Service) {
|
func (app *App) AuthenticateSession(list *service.List, ssvc *service.Service) {
|
||||||
|
var svc = ssvc.Service()
|
||||||
auth.NewDialog(svc.Name(), svc.Authenticate(), func(ses cchat.Session) {
|
auth.NewDialog(svc.Name(), svc.Authenticate(), func(ses cchat.Session) {
|
||||||
container.AddSession(ses)
|
ssvc.AddSession(ses)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,13 +131,15 @@ func (app *App) Close() {
|
||||||
// Disconnect everything. This blocks the main thread, so by the time we're
|
// Disconnect everything. This blocks the main thread, so by the time we're
|
||||||
// done, the application would exit immediately. There's no need to update
|
// done, the application would exit immediately. There's no need to update
|
||||||
// the GUI.
|
// the GUI.
|
||||||
for _, s := range app.window.Services.Services {
|
for _, s := range app.window.AllServices() {
|
||||||
for _, session := range s.Sessions() {
|
var service = s.Service().Name()
|
||||||
|
|
||||||
|
for _, session := range s.BodyList.Sessions() {
|
||||||
if session.Session == nil {
|
if session.Session == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printlnf("Disconnecting %s session %s", s.Service.Name(), session.ID())
|
log.Printlnf("Disconnecting %s session %s", service, session.ID())
|
||||||
|
|
||||||
if err := session.Session.Disconnect(); err != nil {
|
if err := session.Session.Disconnect(); err != nil {
|
||||||
log.Error(errors.Wrap(err, "Failed to disconnect "+session.ID()))
|
log.Error(errors.Wrap(err, "Failed to disconnect "+session.ID()))
|
||||||
|
|
|
@ -12,8 +12,8 @@ type window struct {
|
||||||
MessageView *messages.View
|
MessageView *messages.View
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWindow() *window {
|
func newWindow(mainctl service.Controller) *window {
|
||||||
services := service.NewView()
|
services := service.NewView(mainctl)
|
||||||
services.SetSizeRequest(leftMinWidth, -1)
|
services.SetSizeRequest(leftMinWidth, -1)
|
||||||
services.Show()
|
services.Show()
|
||||||
|
|
||||||
|
@ -28,3 +28,7 @@ func newWindow() *window {
|
||||||
|
|
||||||
return &window{pane, services, mesgview}
|
return &window{pane, services, mesgview}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *window) AllServices() []*service.Service {
|
||||||
|
return w.Services.Services.Services
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue