Rewrote sidebar for new design; minor bug fixes

This commit is contained in:
diamondburned 2020-07-14 00:24:55 -07:00 committed by diamondburned
parent 098593552d
commit e65dbb20ed
36 changed files with 2043 additions and 610 deletions

2
go.mod
View File

@ -8,7 +8,7 @@ require (
github.com/Xuanwo/go-locale v0.2.0
github.com/alecthomas/chroma v0.7.3
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/imgutil v0.0.0-20200710174014-8a3be144a972
github.com/disintegration/imaging v1.6.2

10
go.sum
View File

@ -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-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-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/go.mod h1:SRu3OOeggELFr2Wd3/+SpYV1eNcvSk2LBhM70NOZSG8=
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.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.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/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=

View File

@ -6,6 +6,7 @@ import (
"os"
"time"
"github.com/diamondburned/cchat-gtk/internal/gts/throttler"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/disintegration/imaging"
"github.com/gotk3/gotk3/gdk"
@ -125,6 +126,9 @@ func Main(wfn func() WindowHeaderer) {
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

View File

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

View File

@ -7,7 +7,6 @@ import (
"github.com/diamondburned/cchat-gtk/internal/keyring/driver/keyring"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/config"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
)
@ -23,7 +22,11 @@ type Session struct {
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)
if !ok {
return nil
@ -48,24 +51,24 @@ func ConvertSession(ses cchat.Session, name string) *Session {
}
}
func SaveSessions(serviceName text.Rich, sessions []Session) {
if err := store.Set(serviceName.Content, sessions); err != nil {
func SaveSessions(service cchat.Service, sessions []Session) {
if err := store.Set(service.Name().Content, sessions); err != nil {
log.Warn(errors.Wrap(err, "Error saving session"))
}
}
// RestoreSessions restores all sessions of the service asynchronously, then
// 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.
if err := store.Get(serviceName.Content, &sessions); err != nil {
if err := store.Get(service.Name().Content, &sessions); err != nil {
log.Warn(err)
}
return
}
func RestoreSession(serviceName text.Rich, id string) *Session {
var sessions = RestoreSessions(serviceName)
func RestoreSession(service cchat.Service, id string) *Session {
var sessions = RestoreSessions(service)
for _, session := range sessions {
if session.ID == id {
return &session

View File

@ -2,6 +2,7 @@ package dialog
import (
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
)
@ -13,6 +14,12 @@ type Modal struct {
Header *gtk.HeaderBar
}
var headerCSS = primitives.PrepareCSS(`
.modal-header {
padding: 0 5px;
}
`)
func ShowModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) {
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.Show()
header.SetMarginStart(5)
header.SetMarginEnd(5)
header.SetTitle(title)
header.PackStart(cancel)
header.PackEnd(action)
primitives.AddClass(header, "modal-header")
primitives.AttachCSS(header, headerCSS)
dialog := newCSD(body, header)
modald := &Modal{
dialog,

View File

@ -7,7 +7,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"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/parser"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
"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.SetMarkup(fmt.Sprintf(
`<span alpha="50%%" size="small">%s</span>`,
parser.RenderMarkup(entry.Secondary),
markup.Render(entry.Secondary),
))
s.Show()

View File

@ -5,10 +5,9 @@ import (
"github.com/diamondburned/cchat"
"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/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/text"
"github.com/gotk3/gotk3/gtk"
@ -53,11 +52,11 @@ type GenericContainer struct {
nonce string
Timestamp *gtk.Label
Username *gtk.Label
Username *labeluri.Label
Content gtk.IWidget // conceal widget implementation
contentBox *gtk.Box // basically what is in Content
ContentBody *gtk.Label
ContentBody *labeluri.Label
MenuItems []menu.Item
}
@ -95,7 +94,7 @@ func NewEmptyContainer() *GenericContainer {
ts.SetVAlign(gtk.ALIGN_END)
ts.Show()
user, _ := gtk.LabelNew("")
user := labeluri.NewLabel(text.Rich{})
user.SetMaxWidthChars(35)
user.SetLineWrap(true)
user.SetLineWrapMode(pango.WRAP_WORD_CHAR)
@ -103,7 +102,8 @@ func NewEmptyContainer() *GenericContainer {
user.SetVAlign(gtk.ALIGN_START)
user.Show()
ctbody, _ := gtk.LabelNew("")
ctbody := labeluri.NewLabel(text.Rich{})
ctbody.SetEllipsize(pango.ELLIPSIZE_NONE)
ctbody.SetLineWrap(true)
ctbody.SetLineWrapMode(pango.WRAP_WORD_CHAR)
ctbody.SetXAlign(0) // left align
@ -148,10 +148,6 @@ func NewEmptyContainer() *GenericContainer {
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
}
@ -192,16 +188,17 @@ func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) {
}
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) {
var markup = parser.RenderMarkup(content)
if edited {
markup += " " + rich.Small("(edited)")
}
m.ContentBody.SetLabelUnsafe(content)
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

View File

@ -50,15 +50,15 @@ func New(parent gtk.IWidget, placeholder WidgetUnreferencer) *FaceView {
// Reset brings the view to an empty box.
func (v *FaceView) Reset() {
v.Stack.SetVisibleChildName("empty")
v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop()
v.Stack.SetVisibleChildName("empty")
}
func (v *FaceView) SetMain() {
v.Stack.SetVisibleChildName("main")
v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop()
v.Stack.SetVisibleChildName("main")
}
func (v *FaceView) SetLoading() {
@ -79,7 +79,7 @@ func (v *FaceView) ensurePlaceholderDestroyed() {
if v.placeholder != nil {
// Safely remove the placeholder from the stack.
if v.Stack.GetVisibleChildName() == "placeholder" {
v.Stack.SetVisibleChildName("main")
v.Stack.SetVisibleChildName("empty")
}
// Remove the placeholder widget.

View File

@ -5,7 +5,7 @@ import (
"github.com/diamondburned/cchat"
"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/pango"
)
@ -95,7 +95,7 @@ func render(typers []cchat.Typer) string {
for i, typer := range typers {
builder.WriteString("<b>")
builder.WriteString(parser.RenderMarkup(typer.Name()))
builder.WriteString(markup.Render(typer.Name()))
builder.WriteString("</b>")
switch i {

View File

@ -115,9 +115,9 @@ func (v *View) Bottomed() bool { return v.Scroller.Bottomed }
func (v *View) Reset() {
v.state.Reset() // Reset the state variables.
v.Typing.Reset() // Reset the typing state.
v.FaceView.Reset() // Switch back to the main screen.
v.InputView.Reset() // Reset the input.
v.Container.Reset() // Clean all messages.
v.FaceView.Reset() // Switch back to the main screen.
// Keep the scroller at the bottom.
v.Scroller.Bottomed = true

View File

@ -15,6 +15,7 @@ type Completer struct {
ctrl Completeable
Input *gtk.TextView
Buffer *gtk.TextBuffer
List *gtk.ListBox
Popover *gtk.Popover
@ -38,22 +39,21 @@ func NewCompleter(input *gtk.TextView, ctrl Completeable) *Completer {
p := NewPopover(input)
p.Add(s)
input.Connect("key-press-event", KeyDownHandler(l, input.GrabFocus))
ibuf, _ := input.GetBuffer()
c := &Completer{
Input: input,
Buffer: ibuf,
List: l,
Popover: p,
ctrl: ctrl,
}
input.Connect("key-press-event", KeyDownHandler(l, input.GrabFocus))
ibuf, _ := input.GetBuffer()
ibuf.Connect("end-user-action", func() {
t, v := State(ibuf)
c.Cursor = v
c.Words, c.Index = split.SpaceIndexed(t, v)
c.complete()
})
// This one is for buffer modification.
ibuf.Connect("end-user-action", c.onChange)
// This one is for when the cursor moves.
input.Connect("move-cursor", c.onChange)
l.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
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() {
c.Clear()
@ -95,6 +111,7 @@ func (c *Completer) complete() {
c.Popover.Popup()
} else {
c.Hide()
return
}
for i, widget := range widgets {

View File

@ -1,6 +1,8 @@
package completion
import (
"unicode"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
@ -91,32 +93,44 @@ func CursorRect(i *gtk.TextView) gdk.Rectangle {
return *r
}
func State(buf *gtk.TextBuffer) (string, int) {
func State(buf *gtk.TextBuffer) (text string, offset int, blank bool) {
// obtain current state
mark := buf.GetInsert()
iter := buf.GetIterAtMark(mark)
// obtain the input string and the current cursor position
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) {
iter := buf.GetIterAtOffset(offset)
var ok bool
// 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 {
start = buf.GetStartIter()
}
// 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 {
end = buf.GetEndIter()
}

View File

@ -12,6 +12,19 @@ import (
"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 {
SetName(string)
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 {
StyleContexter
GrabFocus()
@ -242,6 +262,16 @@ func ActionPopover(p *gtk.Popover, actions [][2]string) {
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 {
p, _ := gtk.CssProviderNew()
if err := p.LoadFromData(css); err != nil {

View File

@ -48,7 +48,7 @@ func NewImage(radius float64) (*Image, error) {
switch {
// If radius is less than 0, then don't round.
case r < 0:
break
return false
// If radius is 0, then we have to calculate our own radius.:This only
// works if the image is a square.

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import (
"github.com/diamondburned/cchat/text"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
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.
func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) {
i.Image.SetRadius(-1) // square
i.SetRevealChild(true)
i.SetSize(iconSzPx)
@ -114,16 +116,17 @@ func (i *Icon) SetIcon(url string) {
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) {
ni := &nullIcon{}
f, err := iconer.Icon(ctx, ni)
return ni, f, err
return ni, f, errors.Wrap(err, errwrap)
})
}
// SetIconUnsafe is not thread-safe.
func (i *Icon) SetIconUnsafe(url string) {
i.Image.SetRadius(0) // round
i.SetRevealChild(true)
i.url = url
i.updateAsync()

View File

@ -6,7 +6,7 @@ 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/rich/parser"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
@ -23,14 +23,23 @@ type Labeler interface {
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 Label struct {
gtk.Label
current text.Rich
Current text.Rich
// Reusable primitive.
r *Reusable
// super unexported field for inheritance
super SuperLabeler
}
var (
@ -40,13 +49,13 @@ var (
func NewLabel(content text.Rich) *Label {
label, _ := gtk.LabelNew("")
label.SetMarkup(parser.RenderMarkup(content))
label.SetMarkup(markup.Render(content))
label.SetXAlign(0) // left align
label.SetEllipsize(pango.ELLIPSIZE_END)
l := &Label{
Label: *label,
current: content,
Current: content,
}
// reusable primitive
@ -57,11 +66,30 @@ func NewLabel(content text.Rich) *Label {
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() {
l.current = text.Rich{}
l.Current = text.Rich{}
l.r.Invalidate()
l.Label.SetText("")
if l.validsuper() {
l.super.Reset()
}
}
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
// 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) {
l.current = content
l.SetMarkup(parser.RenderMarkup(content))
l.Current = content
if l.validsuper() {
l.super.SetLabelUnsafe(content)
} else {
l.SetMarkup(markup.Render(content))
}
}
// GetLabel is NOT thread-safe.
func (l *Label) GetLabel() text.Rich {
return l.current
return l.Current
}
// GetText is NOT thread-safe.
func (l *Label) GetText() string {
return l.current.Content
return l.Current.Content
}
type ToggleButton struct {

View File

@ -1,4 +1,4 @@
package imgview
package labeluri
import (
"fmt"
@ -11,7 +11,10 @@ import (
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/dialog"
"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/gtk"
"github.com/gotk3/gotk3/pango"
@ -31,7 +34,84 @@ type WidgetConnector interface {
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
// 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
@ -44,24 +124,33 @@ func BindTooltip(connector WidgetConnector) {
})
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) {
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.
// This allows us to make it clickable.
img, _ := gtk.ImageNewFromIconName("image-loading", gtk.ICON_SIZE_BUTTON)
img.SetMarginStart(5)
img.SetMarginEnd(5)
img.SetMarginTop(5)
img.SetMarginBottom(5)
// Cap the width and height if requested.
var w, h, round = markup.FragmentImageSize(uri, MaxWidth, MaxHeight)
var img *gtk.Image
if !round {
img, _ = gtk.ImageNew()
} else {
r, _ := roundimage.NewImage(0)
img = r.Image
}
img.SetFromIconName("image-loading", gtk.ICON_SIZE_BUTTON)
img.Show()
// Cap the width and height if requested.
var w, h = parser.FragmentImageSize(uri, MaxWidth, MaxHeight)
// Asynchronously fetch the image.
httputil.AsyncImageSized(img, uri, w, h)
btn, _ := gtk.ButtonNew()
@ -76,10 +165,11 @@ func BindTooltip(connector WidgetConnector) {
p.Add(btn)
p.Popup()
default:
PromptOpen(uri)
return true
}
PromptOpen(uri)
// Never let Gtk open the dialog.
return true
})

View File

@ -2,8 +2,11 @@ package attrmap
import (
"fmt"
"html"
"sort"
"strings"
"github.com/diamondburned/cchat/text"
)
type AppendMap struct {
@ -31,11 +34,52 @@ func (a *AppendMap) appendIndex(ind int) {
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) {
a.Openf(start, "<span %s>", strings.Join(attrs, " "))
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) {
a.Open(start, open)
a.Close(end, close)
@ -82,3 +126,9 @@ func (a *AppendMap) Finalize(strlen int) []int {
sort.Ints(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)
}

View File

@ -1,10 +1,11 @@
package parser
package markup
import (
"bytes"
"fmt"
"html"
"net/url"
"sort"
"strings"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/attrmap"
@ -13,53 +14,66 @@ import (
"github.com/diamondburned/imgutil"
)
func markupAttr(attr text.Attribute) string {
// meme fast path
if attr == 0 {
return ""
}
// Hyphenate controls whether or not texts should have hyphens on wrap.
var Hyphenate = false
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, `foreground="#808080"`) // no fancy click here
}
if attr.Has(text.AttrMonospace) {
attrs = append(attrs, `font_family="monospace"`)
}
return strings.Join(attrs, " ")
func hyphenate(text string) string {
return fmt.Sprintf(`<span insert_hyphens="%t">%s</span>`, Hyphenate, text)
}
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.
if len(content.Segments) == 0 {
return html.EscapeString(content.Content)
return RenderOutput{
Markup: hyphenate(html.EscapeString(content.Content)),
}
}
buf := bytes.Buffer{}
buf.Grow(len(content.Content))
// // Sort so that all starting points are sorted incrementally.
// sort.Slice(content.Segments, func(i, j int) bool {
// i, _ = content.Segments[i].Bounds()
// j, _ = content.Segments[j].Bounds()
// return i < j
// })
// Sort so that all starting points are sorted incrementally.
sort.SliceStable(content.Segments, func(i, j int) bool {
i, _ = content.Segments[i].Bounds()
j, _ = content.Segments[j].Bounds()
return i < j
})
// map to append strings to indices
var appended = attrmap.NewAppendedMap()
// map to store mentions
var mentions []text.Mentioner
// Parse all segments.
for _, segment := range content.Segments {
start, end := segment.Bounds()
@ -74,17 +88,27 @@ func RenderMarkup(content text.Rich) string {
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 {
var attrs = []string{fmt.Sprintf(`color="#%06X"`, segment.Color())}
// If the color segment only covers a segment, then add some more
// formatting.
if start > 0 && end < len(content.Content) {
attrs = append(attrs,
`bgalpha="10%"`,
fmt.Sprintf(`bgcolor="#%06X"`, segment.Color()),
)
var covered = attrmap.CoverAll(content, start, end)
appended.Span(start, end, color(segment.Color(), !covered)...)
if !covered { // add padding if doesn't cover all
appended.Pad(start, end)
}
appended.Span(start, end, attrs...)
}
if segment, ok := segment.(text.Attributor); ok {
@ -114,7 +138,28 @@ func RenderMarkup(content text.Rich) string {
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
@ -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
// 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
// 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)
if err != nil {
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.
fmt.Sscanf(u.Fragment, f_FragmentSize, &w, &h)
round = strings.HasSuffix(u.Fragment, ";round")
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 {
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, " ")
}

View File

@ -1,4 +1,4 @@
package parser
package markup
import (
"testing"

View File

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

View File

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

301
internal/ui/service/list.go Normal file
View File

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

View File

@ -4,239 +4,269 @@ import (
"fmt"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/keyring"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/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/session"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
type View struct {
*gtk.ScrolledWindow
Box *gtk.Box
Services []*Container
const IconSize = 48
type ListController interface {
// 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(`
.services {
background-color: @theme_base_color;
// Service holds everything that a single service has.
type Service struct {
*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 {
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
box.Show()
var serviceIconCSS = primitives.PrepareClassCSS("service-icon", `
.service-icon { padding: 4px }
`)
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,
func NewService(svc cchat.Service, svclctrl ListController) *Service {
service := &Service{
service: svc,
svclctrl: svclctrl,
}
// On click, toggle reveal.
header.Connect("clicked", func() {
revealed := !chrev.GetRevealChild()
chrev.SetRevealChild(revealed)
header.SetActive(revealed)
service.BodyList = session.NewList(service)
service.BodyList.Show()
service.BodyRev, _ = gtk.RevealerNew()
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.
header.Add.Connect("clicked", func() {
ctrl.AuthenticateSession(container, svc)
})
// Intermediary box to contain both the icon and the revealer.
service.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
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).
header.Menu.AddSimpleItem("Save Sessions", container.SaveAllSessions)
return container
return service
}
func (c *Container) GetService() cchat.Service {
return c.Service
// SetRevealChild sets whether or not the service should reveal all sessions.
func (s *Service) SetRevealChild(reveal bool) {
s.BodyRev.SetRevealChild(reveal)
}
func (c *Container) Sessions() []*session.Row {
return c.children.Sessions()
// GetRevealChild gets whether or not the service is revealing all sessions.
func (s *Service) GetRevealChild() bool {
return s.BodyRev.GetRevealChild()
}
func (c *Container) AddSession(ses cchat.Session) *session.Row {
srow := session.New(c, ses, c)
c.children.AddSessionRow(ses.ID(), srow)
c.SaveAllSessions()
func (s *Service) SessionSelected(srow *session.Row) {
s.svclctrl.SessionSelected(s, srow)
}
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
}
func (c *Container) AddLoadingSession(id, name string) *session.Row {
srow := session.NewLoading(c, id, name, c)
c.children.AddSessionRow(id, srow)
func (s *Service) AddSession(ses cchat.Session) *session.Row {
srow := session.New(s, ses, s)
srow.Show()
s.BodyList.AddSessionRow(ses.ID(), srow)
s.SaveAllSessions()
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 (s *Service) Service() cchat.Service {
return s.service
}
func (c *Container) MoveSession(rowID, beneathRowID string) {
c.children.MoveSession(rowID, beneathRowID)
c.SaveAllSessions()
func (s *Service) OnSessionDisconnect(row *session.Row) {
s.BodyList.RemoveSessionRow(row.Session.ID())
s.SaveAllSessions()
}
func (c *Container) OnSessionDisconnect(ses *session.Row) {
c.Controller.OnSessionDisconnect(ses.ID())
func (s *Service) RowSelected(r *session.Row, sv *server.ServerRow, m cchat.ServerMessage) {
s.svclctrl.RowSelected(r, sv, m)
}
// 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)
func (s *Service) RemoveSession(row *session.Row) {
s.BodyList.RemoveSessionRow(row.Session.ID())
s.SaveAllSessions()
}
// 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 (s *Service) MoveSession(id, movingID string) {
s.BodyList.MoveSession(id, movingID)
s.SaveAllSessions()
}
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 (s *Service) Breadcrumb() breadcrumb.Breadcrumb {
return breadcrumb.Try(nil, s.service.Name().Content)
}
func (c *Container) SaveAllSessions() {
var sessions = c.children.Sessions()
var ksessions = make([]keyring.Session, 0, len(sessions))
func (s *Service) SaveAllSessions() {
var sessions = s.BodyList.Sessions()
var keyrings = make([]keyring.Session, 0, len(sessions))
for _, s := range sessions {
if k := s.KeyringSession(); k != nil {
ksessions = append(ksessions, *k)
if k := keyring.ConvertSession(s.Session); k != nil {
keyrings = append(keyrings, *k)
}
}
keyring.SaveSessions(c.Service.Name(), ksessions)
keyring.SaveSessions(s.service, keyrings)
}
func (c *Container) Breadcrumb() breadcrumb.Breadcrumb {
return breadcrumb.Try(nil, c.header.GetText())
func (s *Service) RestoreSession(row *session.Row, id string) {
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}
}
*/

View File

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

View File

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

View File

@ -7,7 +7,6 @@ import (
"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/button"
"github.com/diamondburned/cchat-gtk/internal/ui/service/loading"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
@ -17,8 +16,37 @@ import (
const ChildrenMargin = 24
const IconSize = 32
type Controller interface {
RowSelected(*ServerRow, cchat.ServerMessage)
type ServerRow struct {
*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 {
@ -27,6 +55,7 @@ type Row struct {
parentcrumb breadcrumb.Breadcrumber
childrev *gtk.Revealer
children *Children
serverList cchat.ServerList
loaded bool
@ -39,7 +68,6 @@ func NewRow(parent breadcrumb.Breadcrumber, name text.Rich) *Row {
button.Show()
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
box.SetMarginStart(ChildrenMargin)
box.PackStart(button, false, false, 0)
row := &Row{
@ -75,7 +103,12 @@ func (r *Row) SetServerList(list cchat.ServerList, ctrl Controller) {
r.children = NewChildren(r, ctrl)
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
}
@ -161,7 +194,7 @@ func (r *Row) SetRevealChild(reveal bool) {
}
// 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 !reveal {
@ -207,137 +240,8 @@ func (r *Row) Load() {
// GetRevealChild returns whether or not the server list is expanded, or always
// false if there is no server list.
func (r *Row) GetRevealChild() bool {
if r.children != nil {
return r.children.GetRevealChild()
if r.childrev != nil {
return r.childrev.GetRevealChild()
}
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)
}

View File

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

View File

@ -6,9 +6,10 @@ import (
"github.com/diamondburned/cchat-gtk/internal/keyring"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/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/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/commander"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
"github.com/diamondburned/cchat/text"
@ -16,16 +17,19 @@ import (
"github.com/pkg/errors"
)
const IconSize = 32
const IconSize = 48
const IconName = "face-plain-symbolic"
// Controller extends server.RowController to add session.
type Controller interface {
// GetService asks the controller for its service.
GetService() cchat.Service
// Servicer extends server.RowController to add session.
type Servicer interface {
// Service asks the controller for its service.
Service() cchat.Service
// OnSessionDisconnect is called before a session is disconnected. This
// function is used for cleanups.
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
// implements ServerMessage) is called.
RowSelected(*Row, *server.ServerRow, cchat.ServerMessage)
@ -40,6 +44,286 @@ type Controller interface {
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
// children servers.
type Row struct {
@ -47,26 +331,26 @@ type Row struct {
Session cchat.Session
sessionID string // used for reconnection
ctrl Controller
ctrl Servicer
cmder *commander.Buffer
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.SetSession(ses)
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.sessionID = id
row.Row.SetLoading()
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.Button.SetPlaceholderIcon(IconName, IconSize)
srow.Show()
@ -220,12 +504,6 @@ func (r *Row) RowSelected(server *server.ServerRow, smsg cchat.ServerMessage) {
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
// not implement commander.
func (r *Row) ShowCommander() {
@ -234,3 +512,4 @@ func (r *Row) ShowCommander() {
}
r.cmder.ShowDialog()
}
*/

112
internal/ui/service/view.go Normal file
View File

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

View File

@ -1,10 +1,7 @@
/* Make CSS more consistent across themes */
headerbar { padding: 0; }
/* Consistent design */
.services button { border-radius: 0; }
popover > box { margin: 6px; }
popover > *:not(stack):not(button) { margin: 6px; }
/* Hack to fix the input bar being high in Adwaita */
.input-field * { min-height: 0; }

View File

@ -23,7 +23,7 @@ func init() {
// constraints for the left panel
const (
leftMinWidth = 200
leftCurrentWidth = 250
leftCurrentWidth = 275
leftMaxWidth = 400
)
@ -52,10 +52,9 @@ var (
)
func NewApplication() *App {
app := &App{
window: newWindow(),
header: newHeader(),
}
app := &App{}
app.window = newWindow(app)
app.header = newHeader()
// Resize the left-side header w/ the left-side pane.
app.window.Services.Connect("size-allocate", func(wv gtk.IWidget) {
@ -73,7 +72,7 @@ func NewApplication() *App {
}
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.
@ -91,6 +90,12 @@ func (app *App) OnSessionDisconnect(id string) {
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) {
// Is there an old row that we should deactivate?
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) {
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
// done, the application would exit immediately. There's no need to update
// the GUI.
for _, s := range app.window.Services.Services {
for _, session := range s.Sessions() {
for _, s := range app.window.AllServices() {
var service = s.Service().Name()
for _, session := range s.BodyList.Sessions() {
if session.Session == nil {
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 {
log.Error(errors.Wrap(err, "Failed to disconnect "+session.ID()))

View File

@ -12,8 +12,8 @@ type window struct {
MessageView *messages.View
}
func newWindow() *window {
services := service.NewView()
func newWindow(mainctl service.Controller) *window {
services := service.NewView(mainctl)
services.SetSizeRequest(leftMinWidth, -1)
services.Show()
@ -28,3 +28,7 @@ func newWindow() *window {
return &window{pane, services, mesgview}
}
func (w *window) AllServices() []*service.Service {
return w.Services.Services.Services
}