diff --git a/go.mod b/go.mod
index 749e77a..ff4935c 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 9735bef..139009e 100644
--- a/go.sum
+++ b/go.sum
@@ -72,6 +72,14 @@ github.com/diamondburned/cchat-discord v0.0.0-20200711215912-d1f5376e30b3 h1:uMr
github.com/diamondburned/cchat-discord v0.0.0-20200711215912-d1f5376e30b3/go.mod h1:TP5/aE708Ae2quG1yAvCCoDJjKuBcjFZ1LYVw60FbTw=
github.com/diamondburned/cchat-discord v0.0.0-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=
diff --git a/internal/gts/gts.go b/internal/gts/gts.go
index a020c59..c7f4b81 100644
--- a/internal/gts/gts.go
+++ b/internal/gts/gts.go
@@ -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
diff --git a/internal/gts/throttler/throttler.go b/internal/gts/throttler/throttler.go
new file mode 100644
index 0000000..2b326d7
--- /dev/null
+++ b/internal/gts/throttler/throttler.go
@@ -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)
+}
diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go
index ec12231..99aa994 100644
--- a/internal/keyring/keyring.go
+++ b/internal/keyring/keyring.go
@@ -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
diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go
index c980a79..ae41ade 100644
--- a/internal/ui/dialog/dialog.go
+++ b/internal/ui/dialog/dialog.go
@@ -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,
diff --git a/internal/ui/messages/input/completion/completion.go b/internal/ui/messages/input/completion/completion.go
index a8119d9..bb27f22 100644
--- a/internal/ui/messages/input/completion/completion.go
+++ b/internal/ui/messages/input/completion/completion.go
@@ -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(
`%s`,
- parser.RenderMarkup(entry.Secondary),
+ markup.Render(entry.Secondary),
))
s.Show()
diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go
index 12355f2..f03b20b 100644
--- a/internal/ui/messages/message/message.go
+++ b/internal/ui/messages/message/message.go
@@ -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
diff --git a/internal/ui/messages/sadface/sadface.go b/internal/ui/messages/sadface/sadface.go
index 2a6eb98..9f8f025 100644
--- a/internal/ui/messages/sadface/sadface.go
+++ b/internal/ui/messages/sadface/sadface.go
@@ -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.
diff --git a/internal/ui/messages/typing/typing.go b/internal/ui/messages/typing/typing.go
index 1dafd9f..5d4f6e7 100644
--- a/internal/ui/messages/typing/typing.go
+++ b/internal/ui/messages/typing/typing.go
@@ -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("")
- builder.WriteString(parser.RenderMarkup(typer.Name()))
+ builder.WriteString(markup.Render(typer.Name()))
builder.WriteString("")
switch i {
diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go
index 0abe2c2..12cc576 100644
--- a/internal/ui/messages/view.go
+++ b/internal/ui/messages/view.go
@@ -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
diff --git a/internal/ui/primitives/completion/completer.go b/internal/ui/primitives/completion/completer.go
index 6cfaff5..d642024 100644
--- a/internal/ui/primitives/completion/completer.go
+++ b/internal/ui/primitives/completion/completer.go
@@ -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 {
diff --git a/internal/ui/primitives/completion/utils.go b/internal/ui/primitives/completion/utils.go
index 37e0d0a..f0c5b9b 100644
--- a/internal/ui/primitives/completion/utils.go
+++ b/internal/ui/primitives/completion/utils.go
@@ -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()
}
diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go
index f234291..826e16e 100644
--- a/internal/ui/primitives/primitives.go
+++ b/internal/ui/primitives/primitives.go
@@ -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 {
diff --git a/internal/ui/primitives/roundimage/roundimage.go b/internal/ui/primitives/roundimage/roundimage.go
index fd71322..7b49b93 100644
--- a/internal/ui/primitives/roundimage/roundimage.go
+++ b/internal/ui/primitives/roundimage/roundimage.go
@@ -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.
diff --git a/internal/ui/primitives/singlestack/singlestack.go b/internal/ui/primitives/singlestack/singlestack.go
new file mode 100644
index 0000000..cd27534
--- /dev/null
+++ b/internal/ui/primitives/singlestack/singlestack.go
@@ -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
+}
diff --git a/internal/ui/primitives/spinner/spinner.go b/internal/ui/primitives/spinner/spinner.go
new file mode 100644
index 0000000..61f631f
--- /dev/null
+++ b/internal/ui/primitives/spinner/spinner.go
@@ -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()
+}
diff --git a/internal/ui/rich/image.go b/internal/ui/rich/image.go
index a454a52..91c0b98 100644
--- a/internal/ui/rich/image.go
+++ b/internal/ui/rich/image.go
@@ -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()
diff --git a/internal/ui/rich/label.go b/internal/ui/rich/label.go
index 100ae9a..05e2c88 100644
--- a/internal/ui/rich/label.go
+++ b/internal/ui/rich/label.go
@@ -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 {
diff --git a/internal/ui/imgview/imgview.go b/internal/ui/rich/labeluri/labeluri.go
similarity index 57%
rename from internal/ui/imgview/imgview.go
rename to internal/ui/rich/labeluri/labeluri.go
index 1e84b9c..b53b732 100644
--- a/internal/ui/imgview/imgview.go
+++ b/internal/ui/rich/labeluri/labeluri.go
@@ -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
})
diff --git a/internal/ui/rich/parser/attrmap/attrmap.go b/internal/ui/rich/parser/attrmap/attrmap.go
index efe2f47..3157269 100644
--- a/internal/ui/rich/parser/attrmap/attrmap.go
+++ b/internal/ui/rich/parser/attrmap/attrmap.go
@@ -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, ``, html.EscapeString(href))
+ a.Close(end, "")
+}
+
+// AnchorNU makes a new 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, "", strings.Join(attrs, " "))
a.Close(end, "")
}
+// 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)
+}
diff --git a/internal/ui/rich/parser/markup.go b/internal/ui/rich/parser/markup/markup.go
similarity index 50%
rename from internal/ui/rich/parser/markup.go
rename to internal/ui/rich/parser/markup/markup.go
index 8d34f47..856c137 100644
--- a/internal/ui/rich/parser/markup.go
+++ b/internal/ui/rich/parser/markup/markup.go
@@ -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(`%s`, 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(
+ `%s`,
+ 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 ""
}
+
+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, " ")
+}
diff --git a/internal/ui/rich/parser/markup_test.go b/internal/ui/rich/parser/markup/markup_test.go
similarity index 99%
rename from internal/ui/rich/parser/markup_test.go
rename to internal/ui/rich/parser/markup/markup_test.go
index c2a481b..92a3dd0 100644
--- a/internal/ui/rich/parser/markup_test.go
+++ b/internal/ui/rich/parser/markup/markup_test.go
@@ -1,4 +1,4 @@
-package parser
+package markup
import (
"testing"
diff --git a/internal/ui/service/children.go b/internal/ui/service/children.go
deleted file mode 100644
index dc872fa..0000000
--- a/internal/ui/service/children.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/ui/service/header.go b/internal/ui/service/header.go
deleted file mode 100644
index 32d6f5f..0000000
--- a/internal/ui/service/header.go
+++ /dev/null
@@ -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}
-}
diff --git a/internal/ui/service/list.go b/internal/ui/service/list.go
new file mode 100644
index 0000000..0a29a09
--- /dev/null
+++ b/internal/ui/service/list.go
@@ -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())
+}
+*/
diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go
index 0591743..0bb3509 100644
--- a/internal/ui/service/service.go
+++ b/internal/ui/service/service.go
@@ -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}
+}
+*/
diff --git a/internal/ui/service/session/list.go b/internal/ui/service/session/list.go
new file mode 100644
index 0000000..f340657
--- /dev/null
+++ b/internal/ui/service/session/list.go
@@ -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()
+}
diff --git a/internal/ui/service/session/server/children.go b/internal/ui/service/session/server/children.go
new file mode 100644
index 0000000..ed31383
--- /dev/null
+++ b/internal/ui/service/session/server/children.go
@@ -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)
+}
diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go
index 772eabb..c97fc75 100644
--- a/internal/ui/service/session/server/server.go
+++ b/internal/ui/service/session/server/server.go
@@ -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)
-}
diff --git a/internal/ui/service/session/servers.go b/internal/ui/service/session/servers.go
new file mode 100644
index 0000000..e639a6c
--- /dev/null
+++ b/internal/ui/service/session/servers.go
@@ -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(
+ `Error: %s`,
+ 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)
+}
diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go
index 8ba9d1d..ffab4fc 100644
--- a/internal/ui/service/session/session.go
+++ b/internal/ui/service/session/session.go
@@ -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()
}
+*/
diff --git a/internal/ui/service/view.go b/internal/ui/service/view.go
new file mode 100644
index 0000000..81300f7
--- /dev/null
+++ b/internal/ui/service/view.go
@@ -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)
+}
diff --git a/internal/ui/style.css b/internal/ui/style.css
index c9aa4ed..3ada9e9 100644
--- a/internal/ui/style.css
+++ b/internal/ui/style.css
@@ -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; }
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index a6c7c04..e4b2c8c 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -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()))
diff --git a/internal/ui/window.go b/internal/ui/window.go
index f07a686..35513ed 100644
--- a/internal/ui/window.go
+++ b/internal/ui/window.go
@@ -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
+}