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