Added Leaflet and partial Avatar support

This commit is contained in:
diamondburned 2020-08-28 18:42:28 -07:00
parent 0b155d5b07
commit b48526aed3
23 changed files with 643 additions and 199 deletions

2
go.mod
View File

@ -10,7 +10,7 @@ require (
github.com/diamondburned/cchat v0.0.49
github.com/diamondburned/cchat-discord v0.0.0-20200821041521-647c854d7b5e
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b
github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526
github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
github.com/disintegration/imaging v1.6.2
github.com/goodsign/monday v1.0.0

2
go.sum
View File

@ -91,6 +91,8 @@ github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a h1:wEldljb421/
github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526 h1:GnTwaD+RKiCJiJ9yhcUb6M5o4VwFExTqmcl8Dg+EVdw=
github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526/go.mod h1:V0qyhW4v6KPFwtDpXdBm5aWH7zWEyrzZpcB6MPnKArQ=
github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4 h1:qF5VHC35+GyCjUmKz+1O94xpFc0JQd4Ui3h+I955pJw=
github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4/go.mod h1:V0qyhW4v6KPFwtDpXdBm5aWH7zWEyrzZpcB6MPnKArQ=
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 h1:OWxllHbUptXzDias6YI4MM0R3o50q8MfhkkwVIlfiNo=
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/diamondburned/ningen v0.1.1-0.20200717072304-e483f86c08e6 h1:YN0cj0aOCa+tKmx0aD5qsbSYaIJnyrA0/+eygMKP+/w=

View File

@ -8,6 +8,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
)
// const BreadcrumbSlash = `<span rise="-1024" size="x-large">❭</span>`
@ -16,30 +17,76 @@ const BreadcrumbSlash = " 〉"
type Header struct {
handy.HeaderBar
Breadcrumb *gtk.Label
ShowBackBtn *gtk.Revealer
BackButton *gtk.Button
Breadcrumb *gtk.Label
ShowMembers *gtk.ToggleButton
breadcrumbs []string
minicrumbs bool
}
var backButtonCSS = primitives.PrepareClassCSS("back-button", `
.back-button {
margin-left: 14px;
}
`)
var rightBreadcrumbCSS = primitives.PrepareClassCSS("right-breadcrumb", `
.right-breadcrumb {
margin: 0 14px;
margin-left: 14px;
}
`)
func NewHeader() *Header {
bk, _ := gtk.ButtonNewFromIconName("go-previous-symbolic", gtk.ICON_SIZE_BUTTON)
bk.SetVAlign(gtk.ALIGN_CENTER)
bk.Show()
backButtonCSS(bk)
rbk, _ := gtk.RevealerNew()
rbk.Add(bk)
rbk.SetRevealChild(false)
rbk.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_LEFT)
rbk.SetTransitionDuration(50)
rbk.Show()
bc, _ := gtk.LabelNew(BreadcrumbSlash)
bc.SetUseMarkup(true)
bc.SetXAlign(0.0)
bc.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
bc.SetSingleLineMode(true)
bc.SetHExpand(true)
bc.SetMaxWidthChars(75)
bc.Show()
rightBreadcrumbCSS(bc)
memberIcon, _ := gtk.ImageNewFromIconName("system-users-symbolic", gtk.ICON_SIZE_BUTTON)
memberIcon.Show()
mb, _ := gtk.ToggleButtonNew()
mb.SetVAlign(gtk.ALIGN_CENTER)
mb.SetImage(memberIcon)
mb.SetActive(false)
mb.SetSensitive(false)
header := handy.HeaderBarNew()
header.SetShowCloseButton(true)
header.PackStart(rbk)
header.PackStart(bc)
header.PackEnd(mb)
header.Show()
// Hack to hide the title.
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
header.SetCustomTitle(b)
return &Header{
HeaderBar: *header,
Breadcrumb: bc,
HeaderBar: *header,
ShowBackBtn: rbk,
BackButton: bk,
Breadcrumb: bc,
ShowMembers: mb,
}
}
@ -47,18 +94,71 @@ func (h *Header) Reset() {
h.SetBreadcrumber(nil)
}
func (h *Header) OnBackPressed(fn func()) {
h.BackButton.Connect("clicked", fn)
}
func (h *Header) OnShowMembersToggle(fn func(show bool)) {
h.ShowMembers.Connect("toggled", func() {
fn(h.ShowMembers.GetActive())
})
}
func (h *Header) SetShowBackButton(show bool) {
h.ShowBackBtn.SetRevealChild(show)
}
func (h *Header) SetCanShowMembers(canShow bool) {
if canShow {
h.ShowMembers.Show()
h.ShowMembers.SetSensitive(true)
} else {
h.ShowMembers.Hide()
h.ShowMembers.SetSensitive(false)
}
}
// SetMiniBreadcrumb sets whether or not the breadcrumb should display the full
// label.
func (h *Header) SetMiniBreadcrumb(mini bool) {
h.minicrumbs = mini
h.updateBreadcrumb()
}
// updateBreadcrumb updates the breadcrumb label from the local state.
func (h *Header) updateBreadcrumb() {
switch {
case len(h.breadcrumbs) == 0:
h.Breadcrumb.SetText("")
case h.minicrumbs:
h.Breadcrumb.SetMarkup(h.breadcrumbs[len(h.breadcrumbs)-1])
default:
h.Breadcrumb.SetMarkup(
BreadcrumbSlash + " " + strings.Join(h.breadcrumbs, " "+BreadcrumbSlash+" "),
)
}
}
func (h *Header) SetBreadcrumber(b traverse.Breadcrumber) {
if b == nil {
h.Breadcrumb.SetText("")
h.breadcrumbs = nil
h.updateBreadcrumb()
return
}
var crumb = b.Breadcrumb()
for i := range crumb {
crumb[i] = html.EscapeString(crumb[i])
h.breadcrumbs = b.Breadcrumb()
if len(h.breadcrumbs) < 2 {
return
}
h.Breadcrumb.SetMarkup(
BreadcrumbSlash + " " + strings.Join(crumb, " "+BreadcrumbSlash+" "),
)
// Skip the service name and username.
h.breadcrumbs = h.breadcrumbs[2:]
for i := range h.breadcrumbs {
h.breadcrumbs[i] = html.EscapeString(h.breadcrumbs[i])
}
h.updateBreadcrumb()
}

View File

@ -33,7 +33,7 @@ var textCSS = primitives.PrepareCSS(`
}
.message-input, .message-input * {
background-color: transparent;
background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
}
.message-input * {
@ -47,6 +47,12 @@ var textCSS = primitives.PrepareCSS(`
}
`)
var inputBoxCSS = primitives.PrepareClassCSS("input-box", `
.input-box {
background-color: @theme_bg_color;
}
`)
func NewView(ctrl Controller) *InputView {
text, _ := gtk.TextViewNew()
text.SetSensitive(false)
@ -68,8 +74,6 @@ func NewView(ctrl Controller) *InputView {
f := NewField(text, ctrl)
f.Show()
primitives.AddClass(f, "input-field")
return &InputView{f, c}
}
@ -120,8 +124,17 @@ func (s *fieldState) Reset() {
*s = fieldState{}
}
var inputFieldCSS = primitives.PrepareCSS(`
.input-field { margin: 3px 5px }
var inputFieldCSS = primitives.PrepareClassCSS("input-field", `
.input-field {
margin: 3px 5px;
margin-top: 1px;
}
`)
var scrolledInputCSS = primitives.PrepareClassCSS("scrolled-input", `
.scrolled-input {
margin: 0 5px;
}
`)
func NewField(text *gtk.TextView, ctrl Controller) *Field {
@ -133,7 +146,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.TextScroll = scrollinput.NewV(text, 150)
field.TextScroll.Show()
primitives.AddClass(field.TextScroll, "scrolled-input")
scrolledInputCSS(field.TextScroll)
field.attach, _ = gtk.ButtonNewFromIconName("mail-attachment-symbolic", gtk.ICON_SIZE_BUTTON)
field.attach.SetRelief(gtk.RELIEF_NONE)
@ -146,15 +159,13 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.send.Show()
primitives.AddClass(field.send, "send-button")
// Keep this number the same as size-allocate below -------v
field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
field.FieldBox.PackStart(field.Username, false, false, 0)
field.FieldBox.PackStart(field.attach, false, false, 0)
field.FieldBox.PackStart(field.TextScroll, true, true, 0)
field.FieldBox.PackStart(field.send, false, false, 0)
field.FieldBox.Show()
primitives.AddClass(field.FieldBox, "input-field")
primitives.AttachCSS(field.FieldBox, inputFieldCSS)
inputFieldCSS(field.FieldBox)
field.Attachments = attachment.New()
field.Attachments.Show()
@ -163,6 +174,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.Box.PackStart(field.Attachments, false, false, 0)
field.Box.PackStart(field.FieldBox, false, false, 0)
field.Box.Show()
inputBoxCSS(field.Box)
text.SetFocusHAdjustment(field.TextScroll.GetHAdjustment())
text.SetFocusVAdjustment(field.TextScroll.GetVAdjustment())
@ -178,7 +190,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.Username.Connect("size-allocate", func(w gtk.IWidget) {
// Calculate the left width: from the left of the message box to the
// right of the attach button, covering the username container.
var leftWidth = 5*2 + field.attach.GetAllocatedWidth() + w.ToWidget().GetAllocatedWidth()
var leftWidth = 5 + field.attach.GetAllocatedWidth() + w.ToWidget().GetAllocatedWidth()
// Set the autocompleter's left margin to be the same.
field.Attachments.SetMarginStart(leftWidth)
})

View File

@ -77,12 +77,12 @@ func NewContainer() *Container {
func (u *Container) SetRevealChild(reveal bool) {
// Only reveal if showUser is true.
u.Revealer.SetRevealChild(reveal && showUser)
u.Revealer.SetRevealChild(reveal && u.shouldReveal())
}
// shouldReveal returns whether or not the container should reveal.
func (u *Container) shouldReveal() bool {
return !u.label.GetLabel().Empty() && showUser
return (!u.label.GetLabel().Empty() || u.avatar.URL() != "") && showUser
}
func (u *Container) Reset() {
@ -96,7 +96,7 @@ func (u *Container) Update(session cchat.Session, sender cchat.ServerMessageSend
// Set the fallback username.
u.label.SetLabelUnsafe(session.Name())
// Reveal the name if it's not empty.
u.SetRevealChild(u.shouldReveal())
u.SetRevealChild(true)
// Does sender (aka Server) implement ServerNickname? If yes, use it.
if nicknamer, ok := sender.(cchat.ServerNickname); ok {
@ -120,7 +120,7 @@ func (u *Container) SetLabel(content text.Rich) {
u.label.SetLabelUnsafe(content)
// Reveal if the name is not empty.
u.SetRevealChild(u.shouldReveal())
u.SetRevealChild(true)
})
}
@ -131,9 +131,7 @@ func (u *Container) SetIcon(url string) {
// Reveal if the icon URL is not empty. We don't touch anything if the
// URL is empty, as the name might not be.
if url != "" {
u.SetRevealChild(true)
}
u.SetRevealChild(true)
})
}

View File

@ -21,16 +21,21 @@ import (
var MemberListWidth = 250
type Controller interface {
MemberListUpdated(c *Container)
}
type Container struct {
*gtk.Revealer
Scroll *gtk.ScrolledWindow
Main *gtk.Box
ctrl Controller
// states
// map id -> *Section
Sections map[string]*Section
// states
stop func()
stop func()
}
var memberListCSS = primitives.PrepareClassCSS("member-list", `
@ -39,7 +44,7 @@ var memberListCSS = primitives.PrepareClassCSS("member-list", `
}
`)
func New() *Container {
func New(ctrl Controller) *Container {
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
main.SetSizeRequest(250, -1)
main.Show()
@ -52,7 +57,7 @@ func New() *Container {
rev, _ := gtk.RevealerNew()
rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
rev.SetTransitionDuration(50)
rev.SetTransitionDuration(75)
rev.SetRevealChild(false)
rev.Add(sw)
@ -60,10 +65,16 @@ func New() *Container {
Revealer: rev,
Scroll: sw,
Main: main,
ctrl: ctrl,
Sections: map[string]*Section{},
}
}
// IsEmpty returns whether or not the member view container is empty.
func (c *Container) IsEmpty() bool {
return len(c.Sections) == 0
}
// Reset removes all old sections.
func (c *Container) Reset() {
if c.stop != nil {
@ -94,10 +105,7 @@ func (c *Container) TryAsyncList(server cchat.ServerMessage) {
return nil, errors.Wrap(err, "Failed to list members")
}
return func() {
c.stop = f
c.Revealer.SetRevealChild(true)
}, nil
return func() { c.stop = f }, nil
})
}
@ -138,6 +146,8 @@ func (c *Container) SetSectionsUnsafe(sections []cchat.MemberListSection) {
c.Main.Add(section)
c.Sections[section.ID] = section
}
c.ctrl.MemberListUpdated(c)
}
func (c *Container) SetMemberUnsafe(sectionID string, member cchat.ListMember) {

View File

@ -10,20 +10,15 @@ import (
const FaceSize = 56
type WidgetUnreferencer interface {
gtk.IWidget
Unref()
}
type FaceView struct {
gtk.Stack
placeholder WidgetUnreferencer
placeholder gtk.IWidget
Face *Container
Loading *Spinner
}
func New(parent gtk.IWidget, placeholder WidgetUnreferencer) *FaceView {
func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView {
c := NewContainer()
c.Show()
@ -55,14 +50,6 @@ func (v *FaceView) Reset() {
v.Stack.SetVisibleChildName("empty")
}
// func (v *FaceView) Disable() {
// v.Stack.SetSensitive(false)
// }
// func (v *FaceView) Enable() {
// v.Stack.SetSensitive(true)
// }
func (v *FaceView) SetMain() {
v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop()
@ -104,7 +91,6 @@ type Spinner struct {
func NewSpinner() *Spinner {
s, _ := gtk.SpinnerNew()
s.SetSizeRequest(FaceSize, FaceSize)
s.Start()
s.Show()
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)

View File

@ -20,6 +20,8 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/autoscroll"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
@ -40,6 +42,8 @@ func init() {
}
type Controller interface {
// GoBack tells the main leaflet to go back to the services list.
GoBack()
// OnMessageBusy is called when the message buffer is busy. This happens
// when it's loading messages.
OnMessageBusy()
@ -54,8 +58,9 @@ type View struct {
Header *Header
FaceView *sadface.FaceView
Grid *gtk.Grid
Leaflet *handy.Leaflet
LeftBox *gtk.Box
Scroller *autoscroll.ScrolledWindow
InputView *input.InputView
@ -64,20 +69,29 @@ type View struct {
Container container.Container
contType int // msgIndex
MemberList *memberlist.Container
MemberList *memberlist.Container // right box
// Inherit some useful methods.
state
ctrl Controller
ctrl Controller
parentFolded bool // folded state
}
var messageStack = primitives.PrepareClassCSS("message-stack", `
.message-stack {
background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
}
`)
var messageScroller = primitives.PrepareClassCSS("message-scroller", ``)
func NewView(c Controller) *View {
view := &View{ctrl: c}
view.Typing = typing.New()
view.Typing.Show()
view.MemberList = memberlist.New()
view.MemberList = memberlist.New(view)
view.MemberList.Show()
view.MsgBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
@ -89,6 +103,7 @@ func NewView(c Controller) *View {
view.Scroller.SetVExpand(true)
view.Scroller.SetHExpand(true)
view.Scroller.Show()
messageScroller(view.Scroller)
view.MsgBox.SetFocusHAdjustment(view.Scroller.GetHAdjustment())
view.MsgBox.SetFocusVAdjustment(view.Scroller.GetVAdjustment())
@ -113,27 +128,51 @@ func NewView(c Controller) *View {
view.InputView.SetHExpand(true)
view.InputView.Show()
view.Grid, _ = gtk.GridNew()
view.Grid.Attach(view.Scroller, 0, 0, 1, 1)
view.Grid.Attach(sep, 0, 1, 1, 1)
view.Grid.Attach(view.InputView, 0, 2, 1, 1)
view.Grid.Attach(view.MemberList, 1, 0, 1, 3)
view.Grid.Show()
view.LeftBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
view.LeftBox.PackStart(view.Scroller, true, true, 0)
view.LeftBox.PackStart(sep, false, false, 0)
view.LeftBox.PackStart(view.InputView, false, false, 0)
view.LeftBox.Show()
primitives.AddClass(view.Grid, "message-view")
view.Leaflet = handy.LeafletNew()
view.Leaflet.Add(view.LeftBox)
view.Leaflet.Add(view.MemberList)
view.Leaflet.SetVisibleChild(view.LeftBox)
view.Leaflet.Show()
primitives.AddClass(view.Leaflet, "message-view")
// Bind a file drag-and-drop box into the main view box.
drag.BindFileDest(view.Grid, view.InputView.Attachments.AddFiles)
drag.BindFileDest(view.LeftBox, view.InputView.Attachments.AddFiles)
// placeholder logo
logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256Variant2(128))
logo.Show()
view.FaceView = sadface.New(view.Grid, logo)
view.FaceView = sadface.New(view.Leaflet, logo)
view.FaceView.Show()
messageStack(view.FaceView)
view.Header = NewHeader()
view.Header.Show()
view.Header.OnBackPressed(view.ctrl.GoBack)
view.Header.OnShowMembersToggle(func(show bool) {
// If the leaflet is folded, then we should always reveal the child. Its
// visibility should be determined by the leaflet's state.
if view.parentFolded {
view.MemberList.SetRevealChild(true)
if show {
view.Leaflet.SetVisibleChild(view.MemberList)
} else {
view.Leaflet.SetVisibleChild(view.LeftBox)
}
} else {
// Leaflet's visible child does not matter if it's not folded,
// though we should still set the visible child to LeftBox in case
// that changes.
view.MemberList.SetRevealChild(show)
view.Leaflet.SetVisibleChild(view.LeftBox)
}
})
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
view.Box.PackStart(view.Header, false, false, 0)
@ -173,6 +212,9 @@ func (v *View) Reset() {
v.MemberList.Reset() // Reset the member list.
v.FaceView.Reset() // Switch back to the main screen.
// Bring the leaflet view back to the message.
v.Leaflet.SetVisibleChild(v.LeftBox)
// Keep the scroller at the bottom.
v.Scroller.Bottomed = true
@ -180,8 +222,45 @@ func (v *View) Reset() {
v.createMessageContainer()
}
func (v *View) SetFolded(folded bool) {
v.parentFolded = folded
// Change to a mini breadcrumb if we're collapsed.
v.Header.SetMiniBreadcrumb(folded)
// Show the right back button if we're collapsed.
v.Header.SetShowBackButton(folded)
// Hide the username in the input bar if we're collapsed.
v.InputView.Username.SetRevealChild(!folded)
// Hide the member list automatically on folded.
if folded {
v.Header.ShowMembers.SetActive(false)
}
}
// MemberListUpdated is called everytime the member list is updated.
func (v *View) MemberListUpdated(c *memberlist.Container) {
// We can show the members list if it's not empty.
var empty = c.IsEmpty()
v.Header.SetCanShowMembers(!empty)
// If the member list is now empty, then hide the entire thing.
if empty {
// We can set active to false, which would trigger the above callback
// and hide the member list.
v.Header.ShowMembers.SetActive(false)
} else {
// Restore visibility.
if !v.Leaflet.GetFolded() && v.Header.ShowMembers.GetActive() {
c.SetRevealChild(true)
}
}
}
// JoinServer is not thread-safe, but it calls backend functions asynchronously.
func (v *View) JoinServer(session cchat.Session, server ServerMessage) {
func (v *View) JoinServer(session cchat.Session, server ServerMessage, bc traverse.Breadcrumber) {
// Reset before setting.
v.Reset()
@ -221,6 +300,9 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage) {
// Set the cancel handler.
v.state.setcurrent(s)
// Set the headerbar's breadcrumb.
v.Header.SetBreadcrumber(bc)
// Try setting the typing indicator if available.
v.Typing.TrySubscribe(server)
@ -253,7 +335,10 @@ func (v *View) FetchBacklog() {
}
gts.Async(func() (func(), error) {
err := backlogger.MessagesBefore(context.Background(), firstMsg.ID(), v.Container)
ctx, cancel := context.WithTimeout(context.TODO(), 3*time.Second)
defer cancel()
err := backlogger.MessagesBefore(ctx, firstMsg.ID(), v.Container)
return done, errors.Wrap(err, "Failed to get messages before ID")
})
}
@ -386,6 +471,14 @@ func (s *state) SessionID() string {
return ""
}
// ServerID returns the server ID, or an empty string if there's no server.
func (s *state) ServerID() string {
if s.server != nil {
return s.server.ID()
}
return ""
}
const backloggingFreq = time.Second * 3
// Backlogger returns the backlogger instance if it's allowed to fetch more

View File

@ -2,9 +2,11 @@ package primitives
import (
"runtime/debug"
"time"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
@ -115,8 +117,20 @@ type ImageIconSetter interface {
}
func SetImageIcon(img ImageIconSetter, icon string, sizepx int) {
img.SetProperty("icon-name", icon)
img.SetProperty("pixel-size", sizepx)
// Prioritize SetSize()
if setter, ok := img.(interface{ SetSize(int) }); ok {
setter.SetSize(sizepx)
} else {
img.SetProperty("pixel-size", sizepx)
}
// Prioritize SetIconName().
if setter, ok := img.(interface{ SetIconName(string) }); ok {
setter.SetIconName(icon)
} else {
img.SetProperty("icon-name", icon)
}
img.SetSizeRequest(sizepx, sizepx)
}
@ -261,3 +275,28 @@ func AttachCSS(ctx StyleContexter, prov *gtk.CssProvider) {
func InlineCSS(ctx StyleContexter, css string) {
AttachCSS(ctx, PrepareCSS(css))
}
// LeafletOnFold binds a callback to a leaflet that would be called when the
// leaflet's folded state changes.
func LeafletOnFold(leaflet *handy.Leaflet, foldedFn func(folded bool)) {
var lastFold = leaflet.GetFolded()
foldedFn(lastFold)
// Give each callback a 500ms wait for animations to complete.
const dt = 500 * time.Millisecond
var last = time.Now()
leaflet.ConnectAfter("size-allocate", func() {
// Ignore if this event is too recent.
if now := time.Now(); now.Add(-dt).Before(last) {
return
} else {
last = now
}
if folded := leaflet.GetFolded(); folded != lastFold {
lastFold = folded
foldedFn(folded)
}
})
}

View File

@ -0,0 +1,89 @@
package roundimage
import (
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
// TODO: GIF support
// TextSetter is an interface for setting texts.
type TextSetter interface {
SetText(text string)
}
func TrySetText(imager Imager, text string) {
if setter, ok := imager.(TextSetter); ok {
setter.SetText(text)
}
}
// Avatar is a static HdyAvatar container.
type Avatar struct {
handy.Avatar
pixbuf *gdk.Pixbuf
}
var (
_ Imager = (*Avatar)(nil)
_ TextSetter = (*Avatar)(nil)
_ httputil.ImageContainer = (*Avatar)(nil)
)
func NewAvatar(size int) *Avatar {
a := handy.AvatarNew(size, "", true)
if a == nil {
return nil
}
return &Avatar{*a, nil}
}
// SetSizeRequest sets the avatar size. The actual size is min(w, h).
func (a *Avatar) SetSizeRequest(w, h int) {
var min = w
if w > h {
min = h
}
a.Avatar.SetSize(min)
a.Avatar.SetSizeRequest(w, h)
}
func (a *Avatar) loadFunc(int) *gdk.Pixbuf {
return a.pixbuf
}
// SetRadius is a no-op.
func (a *Avatar) SetRadius(float64) {}
// SetFromPixbuf sets the pixbuf.
func (a *Avatar) SetFromPixbuf(pb *gdk.Pixbuf) {
a.pixbuf = pb
a.Avatar.SetImageLoadFunc(a.loadFunc)
}
func (a *Avatar) SetFromAnimation(pa *gdk.PixbufAnimation) {
a.pixbuf = pa.GetStaticImage()
a.Avatar.SetImageLoadFunc(a.loadFunc)
}
func (a *Avatar) GetPixbuf() *gdk.Pixbuf {
return a.pixbuf
}
// GetAnimation returns nil.
func (a *Avatar) GetAnimation() *gdk.PixbufAnimation {
return nil
}
// GetImage returns nil.
func (a *Avatar) GetImage() *gtk.Image {
return nil
}
func (a *Avatar) GetStorageType() gtk.ImageType {
return gtk.IMAGE_PIXBUF
}

View File

@ -0,0 +1,57 @@
package roundimage
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
// Button implements a rounded button with a rounded image. This widget only
// supports a full circle for rounding.
type Button struct {
*gtk.Button
Image Imager
}
var roundButtonCSS = primitives.PrepareClassCSS("round-button", `
.round-button {
padding: 0;
border-radius: 50%;
}
`)
func NewButton() (*Button, error) {
image, _ := NewImage(0)
image.Show()
b, _ := NewEmptyButton()
b.SetImage(image)
return b, nil
}
func NewEmptyButton() (*Button, error) {
b, _ := gtk.ButtonNew()
b.SetRelief(gtk.RELIEF_NONE)
roundButtonCSS(b)
return &Button{Button: b}, nil
}
// NewCustomButton creates a new rounded button with the given Imager. If the
// given Imager implements the Connector interface (aka *StaticImage), then the
// function will implicitly connect its handlers to the button.
func NewCustomButton(img Imager) (*Button, error) {
b, _ := NewEmptyButton()
b.SetImage(img)
if connector, ok := img.(Connector); ok {
connector.ConnectHandlers(b)
}
return b, nil
}
func (b *Button) SetImage(img Imager) {
b.Image = img
b.Button.SetImage(img)
}

View File

@ -15,57 +15,6 @@ const (
circle = 2 * math.Pi
)
// Button implements a rounded button with a rounded image. This widget only
// supports a full circle for rounding.
type Button struct {
*gtk.Button
Image Imager
}
var roundButtonCSS = primitives.PrepareClassCSS("round-button", `
.round-button {
padding: 0;
border-radius: 50%;
}
`)
func NewButton() (*Button, error) {
image, _ := NewImage(0)
image.Show()
b, _ := NewEmptyButton()
b.SetImage(image)
return b, nil
}
func NewEmptyButton() (*Button, error) {
b, _ := gtk.ButtonNew()
b.SetRelief(gtk.RELIEF_NONE)
roundButtonCSS(b)
return &Button{Button: b}, nil
}
// NewCustomButton creates a new rounded button with the given Imager. If the
// given Imager implements the Connector interface (aka *StaticImage), then the
// function will implicitly connect its handlers to the button.
func NewCustomButton(img Imager) (*Button, error) {
b, _ := NewEmptyButton()
b.SetImage(img)
if connector, ok := img.(Connector); ok {
connector.ConnectHandlers(b)
}
return b, nil
}
func (b *Button) SetImage(img Imager) {
b.Image = img
b.Button.SetImage(img)
}
type RadiusSetter interface {
SetRadius(float64)
}
@ -87,59 +36,6 @@ type Imager interface {
GetImage() *gtk.Image
}
// StaticImage is an image that only plays a GIF if it's hovered on top of.
type StaticImage struct {
*Image
animation *gdk.PixbufAnimation
}
var (
_ Imager = (*StaticImage)(nil)
_ Connector = (*StaticImage)(nil)
_ httputil.ImageContainer = (*StaticImage)(nil)
)
func NewStaticImage(parent primitives.Connector, radius float64) (*StaticImage, error) {
i, err := NewImage(radius)
if err != nil {
return nil, err
}
var s = &StaticImage{i, nil}
if parent != nil {
s.ConnectHandlers(parent)
}
return s, nil
}
func (s *StaticImage) ConnectHandlers(connector primitives.Connector) {
connector.Connect("enter-notify-event", func() {
if s.animation != nil {
s.Image.SetFromAnimation(s.animation)
}
})
connector.Connect("leave-notify-event", func() {
if s.animation != nil {
s.Image.SetFromPixbuf(s.animation.GetStaticImage())
}
})
}
func (s *StaticImage) SetFromPixbuf(pb *gdk.Pixbuf) {
s.animation = nil
s.Image.SetFromPixbuf(pb)
}
func (s *StaticImage) SetFromAnimation(anim *gdk.PixbufAnimation) {
s.animation = anim
s.Image.SetFromPixbuf(anim.GetStaticImage())
}
func (s *StaticImage) GetAnimation() *gdk.PixbufAnimation {
return s.animation
}
type Image struct {
*gtk.Image
Radius float64

View File

@ -0,0 +1,60 @@
package roundimage
import (
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gdk"
)
// StaticImage is an image that only plays a GIF if it's hovered on top of.
type StaticImage struct {
*Image
animation *gdk.PixbufAnimation
}
var (
_ Imager = (*StaticImage)(nil)
_ Connector = (*StaticImage)(nil)
_ httputil.ImageContainer = (*StaticImage)(nil)
)
func NewStaticImage(parent primitives.Connector, radius float64) (*StaticImage, error) {
i, err := NewImage(radius)
if err != nil {
return nil, err
}
var s = &StaticImage{i, nil}
if parent != nil {
s.ConnectHandlers(parent)
}
return s, nil
}
func (s *StaticImage) ConnectHandlers(connector primitives.Connector) {
connector.Connect("enter-notify-event", func() {
if s.animation != nil {
s.Image.SetFromAnimation(s.animation)
}
})
connector.Connect("leave-notify-event", func() {
if s.animation != nil {
s.Image.SetFromPixbuf(s.animation.GetStaticImage())
}
})
}
func (s *StaticImage) SetFromPixbuf(pb *gdk.Pixbuf) {
s.animation = nil
s.Image.SetFromPixbuf(pb)
}
func (s *StaticImage) SetFromAnimation(anim *gdk.PixbufAnimation) {
s.animation = anim
s.Image.SetFromPixbuf(anim.GetStaticImage())
}
func (s *StaticImage) GetAnimation() *gdk.PixbufAnimation {
return s.animation
}

View File

@ -135,6 +135,9 @@ func (i *Icon) SetIcon(url string) {
}
func (i *Icon) AsyncSetIconer(iconer cchat.Icon, errwrap string) {
// Reveal to show the placeholder.
i.SetRevealChild(true)
AsyncUse(i.r, func(ctx context.Context) (interface{}, func(), error) {
ni := &nullIcon{}
f, err := iconer.Icon(ctx, ni)
@ -161,8 +164,11 @@ type EventIcon struct {
func NewEventIcon(sizepx int, pp ...imgutil.Processor) *EventIcon {
icn := NewIcon(sizepx, pp...)
icn.Show()
return WrapEventIcon(icn)
}
func WrapEventIcon(icn *Icon) *EventIcon {
icn.Show()
evb, _ := gtk.EventBoxNew()
evb.Add(icn)
@ -189,11 +195,15 @@ var (
)
func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
img, _ := roundimage.NewStaticImage(nil, 0)
img.Show()
return NewCustomToggleButtonImage(img, content)
}
func NewCustomToggleButtonImage(img RoundIconContainer, content text.Rich) *ToggleButtonImage {
l := NewLabel(content)
l.Show()
img, _ := roundimage.NewStaticImage(nil, 0)
img.Show()
i := NewCustomIcon(img, 0)
i.Show()
@ -205,7 +215,9 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
b, _ := gtk.ToggleButtonNew()
b.Add(box)
img.ConnectHandlers(b)
if connector, ok := img.(roundimage.Connector); ok {
connector.ConnectHandlers(b)
}
return &ToggleButtonImage{
ToggleButton: *b,

View File

@ -91,7 +91,7 @@ func NewHeader() *Header {
header.PackStart(appmenu)
header.PackStart(sep)
header.PackStart(svcname)
header.PackStart(sesmenu)
header.PackEnd(sesmenu)
// Hack to hide the title.
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)

View File

@ -8,6 +8,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"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-gtk/internal/ui/service/session"
@ -96,14 +97,16 @@ func NewService(svc cchat.Service, svclctrl ListController) *Service {
// TODO: have it so the button changes to the session avatar when collapsed
// TODO: libhandy avatar generation?
service.Icon = rich.NewIcon(IconSize)
avatar := roundimage.NewAvatar(IconSize)
avatar.SetText(svc.Name().String())
avatar.Show()
service.Icon = rich.NewCustomIcon(avatar, 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 {

View File

@ -14,7 +14,7 @@ const UnreadColorDefs = `
`
type ToggleButtonImage struct {
rich.ToggleButtonImage
*rich.ToggleButtonImage
extraMenu []menu.Item
menu *menu.LazyMenu
@ -57,10 +57,14 @@ var serverButtonCSS = primitives.PrepareClassCSS("server-button", `
func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
b := rich.NewToggleButtonImage(content)
return WrapToggleButtonImage(b)
}
func WrapToggleButtonImage(b *rich.ToggleButtonImage) *ToggleButtonImage {
b.Show()
tb := &ToggleButtonImage{
ToggleButtonImage: *b,
ToggleButtonImage: b,
clicked: func(bool) {},
menu: menu.NewLazyMenu(b.ToggleButton),
@ -73,7 +77,8 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
func (b *ToggleButtonImage) SetSelected(selected bool) {
// Set the clickability the opposite as the boolean.
b.SetSensitive(!selected)
// b.SetSensitive(!selected)
b.SetInconsistent(selected)
if selected {
primitives.AddClass(b, "selected-server")

View File

@ -69,6 +69,7 @@ func (c *Children) Init() {
if c.IsHollow() {
c.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
c.Box.SetMarginStart(ChildrenMargin)
c.Box.SetHExpand(true)
childrenCSS(c.Box)
// Check if we're still loading. This is effectively restoring the
@ -180,6 +181,24 @@ func (c *Children) LoadAll() {
c.Box.Add(row)
}
}
// Check if we have icons.
var hasIcon bool
for _, row := range c.Rows {
if row.HasIcon() {
hasIcon = true
break
}
}
// If we have an icon, then show all other possibly empty icons. HdyAvatar
// will generate a placeholder.
if hasIcon {
for _, row := range c.Rows {
row.UseEmptyIcon()
}
}
}
// saveSelectedRow saves the current selected row and returns a callback that
@ -198,6 +217,7 @@ func (c *Children) saveSelectedRow() (restore func()) {
if oldID != "" {
for _, row := range c.Rows {
if row.Server.ID() == oldID {
row.Init()
row.Button.SetActive(true)
}
}

View File

@ -5,6 +5,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
@ -109,7 +110,7 @@ func (r *ServerRow) Init() {
case cchat.ServerMessage:
primitives.AddClass(r, "server-message")
r.Button.SetClickedIfTrue(func() { r.ctrl.RowSelected(r, server) })
r.Button.SetClicked(func(bool) { r.ctrl.RowSelected(r, server) })
}
}
@ -152,6 +153,7 @@ func (r *ServerRow) SetUnreadUnsafe(unread, mentioned bool) {
type Row struct {
*gtk.Box
Avatar *roundimage.Avatar
Button *button.ToggleButtonImage
parentcrumb traverse.Breadcrumber
@ -181,7 +183,14 @@ func (r *Row) Init(name text.Rich) {
return
}
r.Button = button.NewToggleButtonImage(name)
r.Avatar = roundimage.NewAvatar(IconSize)
r.Avatar.SetText(name.Content)
r.Avatar.Show()
btn := rich.NewCustomToggleButtonImage(r.Avatar, name)
btn.Show()
r.Button = button.WrapToggleButtonImage(btn)
r.Button.Box.SetHAlign(gtk.ALIGN_START)
r.Button.SetRelief(gtk.RELIEF_NONE)
r.Button.Show()
@ -193,10 +202,6 @@ func (r *Row) Init(name text.Rich) {
r.childrenSetErr(r.childrenErr)
}
func (r *Row) Breadcrumb() traverse.Breadcrumb {
return traverse.TryBreadcrumb(r.parentcrumb, r.Button.GetText())
}
// SetHollowServerList sets the row to a hollow server list (children) and
// recursively create
func (r *Row) SetHollowServerList(list cchat.ServerList, ctrl Controller) {
@ -255,10 +260,31 @@ func (r *Row) childrenSetErr(err error) {
}
}
// UseEmptyIcon forces the row to show a placeholder icon.
func (r *ServerRow) UseEmptyIcon() {
AssertUnhollow(r)
r.Button.Image.SetSize(IconSize)
r.Button.Image.SetRevealChild(true)
}
// HasIcon returns true if the current row has an icon.
func (r *ServerRow) HasIcon() bool {
return !r.IsHollow() && r.Button.Image.GetRevealChild()
}
func (r *Row) Breadcrumb() traverse.Breadcrumb {
if r.IsHollow() {
return nil
}
return traverse.TryBreadcrumb(r.parentcrumb, r.Button.GetText())
}
func (r *Row) SetLabelUnsafe(name text.Rich) {
AssertUnhollow(r)
r.Button.SetLabelUnsafe(name)
r.Avatar.SetText(name.Content)
}
func (r *Row) SetIconer(v interface{}) {

View File

@ -29,7 +29,10 @@ type Servers struct {
}
var toplevelCSS = primitives.PrepareClassCSS("top-level", `
.top-level { margin: 0 3px }
.top-level {
margin: 0 3px;
margin-top: 3px;
}
`)
func NewServers(p traverse.Breadcrumber, ctrl server.Controller) *Servers {

View File

@ -8,6 +8,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"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"
@ -51,7 +52,8 @@ type Servicer interface {
// Row represents a session row entry in the session List.
type Row struct {
*gtk.ListBoxRow
icon *rich.EventIcon // nilable
avatar *roundimage.Avatar
icon *rich.EventIcon // nilable
parentcrumb traverse.Breadcrumber
@ -112,6 +114,7 @@ var rowIconCSS = primitives.PrepareClassCSS("session-icon", `
padding: 4px;
margin: 0;
}
.session-icon.failed {
background-color: alpha(red, 0.45);
}
@ -136,7 +139,14 @@ func newRow(parent traverse.Breadcrumber, name text.Rich, ctrl Servicer) *Row {
parentcrumb: parent,
}
row.icon = rich.NewEventIcon(IconSize)
row.avatar = roundimage.NewAvatar(IconSize)
row.avatar.SetText(name.Content)
row.avatar.Show()
icon := rich.NewCustomIcon(row.avatar, IconSize)
icon.Show()
row.icon = rich.WrapEventIcon(icon)
row.icon.Icon.SetPlaceholderIcon(IconName, IconSize)
row.icon.Show()
rowIconCSS(row.icon.Icon)
@ -295,6 +305,7 @@ func (r *Row) SetSession(ses cchat.Session) {
r.sessionID = ses.ID()
r.SetTooltipMarkup(markup.Render(ses.Name()))
r.icon.Icon.SetPlaceholderIcon(IconName, IconSize)
r.avatar.SetText(ses.Name().Content)
// If the session has an icon, then use it.
if iconer, ok := ses.(cchat.Icon); ok {

View File

@ -2,6 +2,7 @@ package service
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"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"
@ -55,6 +56,7 @@ func NewView(ctrller Controller) *View {
view.ServerStack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
view.ServerStack.SetHomogeneous(true)
view.ServerStack.Show()
primitives.AddClass(view.ServerStack, "server-stack")
view.ServerView, _ = gtk.ScrolledWindowNew(nil, nil)
view.ServerView.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)

View File

@ -7,6 +7,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/config/preferences"
"github.com/diamondburned/cchat-gtk/internal/ui/messages"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/service"
"github.com/diamondburned/cchat-gtk/internal/ui/service/auth"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
@ -29,6 +30,9 @@ func init() {
/* Hack to fix the input bar being high in Adwaita */
.input-field * { min-height: 0 }
/* Hide all scroll undershoots */
undershoot { background-size: 0 }
`)
}
@ -71,10 +75,12 @@ func NewApplication() *App {
app := &App{}
app.Services = service.NewView(app)
app.Services.SetSizeRequest(leftMinWidth, -1)
app.Services.SetSizeRequest(leftCurrentWidth, -1)
app.Services.SetHExpand(false)
app.Services.Show()
app.MessageView = messages.NewView(app)
app.MessageView.SetHExpand(true)
app.MessageView.Show()
app.HeaderGroup = handy.HeaderGroupNew()
@ -82,6 +88,7 @@ func NewApplication() *App {
app.HeaderGroup.AddHeaderBar(&app.MessageView.Header.HeaderBar)
app.Leaflet = *handy.LeafletNew()
app.Leaflet.SetTransitionType(handy.LeafletTransitionTypeUnder)
app.Leaflet.Add(app.Services)
app.Leaflet.Add(app.MessageView)
app.Leaflet.Show()
@ -90,6 +97,8 @@ func NewApplication() *App {
// The action name for this is "app.preferences".
gts.AddAppAction("preferences", preferences.SpawnPreferenceDialog)
primitives.LeafletOnFold(&app.Leaflet, app.MessageView.SetFolded)
return app
}
@ -128,6 +137,14 @@ func (app *App) SessionSelected(svc *service.Service, ses *session.Row) {
}
func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) {
// Change to the message view.
app.Leaflet.SetVisibleChild(app.MessageView)
// Assert that the new server is not the same one.
if app.MessageView.ServerID() == srv.Server.ID() {
return
}
// Is there an old row that we should deactivate?
if app.lastSelector != nil {
app.lastSelector(false)
@ -137,12 +154,15 @@ func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.
app.lastSelector = srv.SetSelected
app.lastSelector(true)
// Assert that server is also a list, then join the server.
app.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage))
app.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage), srv)
}
// MessageView methods.
func (app *App) GoBack() {
app.Leaflet.Navigate(handy.NavigationDirectionBack)
}
func (app *App) OnMessageBusy() {
// Disable the server list because we don't want the user to switch around
// while we're loading.