Rewrote sidebar for new design; minor bug fixes
This commit is contained in:
parent
098593552d
commit
e65dbb20ed
2
go.mod
2
go.mod
|
@ -8,7 +8,7 @@ require (
|
|||
github.com/Xuanwo/go-locale v0.2.0
|
||||
github.com/alecthomas/chroma v0.7.3
|
||||
github.com/diamondburned/cchat v0.0.43
|
||||
github.com/diamondburned/cchat-discord v0.0.0-20200711221358-e50f72a01d0a
|
||||
github.com/diamondburned/cchat-discord v0.0.0-20200714063838-0a590627268b
|
||||
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b
|
||||
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
|
|
10
go.sum
10
go.sum
|
@ -72,6 +72,14 @@ github.com/diamondburned/cchat-discord v0.0.0-20200711215912-d1f5376e30b3 h1:uMr
|
|||
github.com/diamondburned/cchat-discord v0.0.0-20200711215912-d1f5376e30b3/go.mod h1:TP5/aE708Ae2quG1yAvCCoDJjKuBcjFZ1LYVw60FbTw=
|
||||
github.com/diamondburned/cchat-discord v0.0.0-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=
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
package throttler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
const TPS = 15 // tps
|
||||
|
||||
type State struct {
|
||||
throttling bool
|
||||
ticker <-chan time.Time
|
||||
settings *gtk.Settings
|
||||
}
|
||||
|
||||
type Connector interface {
|
||||
gtk.IWidget
|
||||
Connect(string, interface{}, ...interface{}) (glib.SignalHandle, error)
|
||||
}
|
||||
|
||||
func Bind(evc Connector) *State {
|
||||
var settings, _ = gtk.SettingsGetDefault()
|
||||
var s = State{
|
||||
settings: settings,
|
||||
ticker: time.Tick(time.Second / TPS),
|
||||
}
|
||||
|
||||
evc.Connect("focus-out-event", s.Start)
|
||||
evc.Connect("focus-in-event", s.Stop)
|
||||
|
||||
return &s
|
||||
}
|
||||
|
||||
func (s *State) Start() {
|
||||
if s.throttling {
|
||||
return
|
||||
}
|
||||
|
||||
s.throttling = true
|
||||
s.settings.SetProperty("gtk-enable-animations", false)
|
||||
|
||||
glib.IdleAdd(func() bool {
|
||||
// Throttle.
|
||||
<-s.ticker
|
||||
|
||||
// If we're no longer throttling, then stop the ticker and remove this
|
||||
// callback from the loop.
|
||||
if !s.throttling {
|
||||
return false
|
||||
}
|
||||
|
||||
// Keep calling this same callback.
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *State) Stop() {
|
||||
if !s.throttling {
|
||||
return
|
||||
}
|
||||
|
||||
s.throttling = false
|
||||
s.settings.SetProperty("gtk-enable-animations", true)
|
||||
}
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/diamondburned/cchat-gtk/internal/keyring/driver/keyring"
|
||||
"github.com/diamondburned/cchat-gtk/internal/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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
|
||||
"github.com/diamondburned/cchat/text"
|
||||
"github.com/diamondburned/imgutil"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
|
@ -74,7 +74,7 @@ func (v *View) Update(words []string, i int) []gtk.IWidget {
|
|||
s := rich.NewLabel(text.Rich{})
|
||||
s.SetMarkup(fmt.Sprintf(
|
||||
`<span alpha="50%%" size="small">%s</span>`,
|
||||
parser.RenderMarkup(entry.Secondary),
|
||||
markup.Render(entry.Secondary),
|
||||
))
|
||||
s.Show()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/gotk3/gotk3/pango"
|
||||
)
|
||||
|
@ -95,7 +95,7 @@ func render(typers []cchat.Typer) string {
|
|||
|
||||
for i, typer := range typers {
|
||||
builder.WriteString("<b>")
|
||||
builder.WriteString(parser.RenderMarkup(typer.Name()))
|
||||
builder.WriteString(markup.Render(typer.Name()))
|
||||
builder.WriteString("</b>")
|
||||
|
||||
switch i {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package singlestack
|
||||
|
||||
import (
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
type Stack struct {
|
||||
*gtk.Stack
|
||||
current gtk.IWidget
|
||||
}
|
||||
|
||||
func NewStack() *Stack {
|
||||
stack, _ := gtk.StackNew()
|
||||
return &Stack{stack, nil}
|
||||
}
|
||||
|
||||
func (s *Stack) Add(w gtk.IWidget) {
|
||||
if s.current == w {
|
||||
return
|
||||
}
|
||||
|
||||
if w != nil {
|
||||
s.Stack.Add(w)
|
||||
s.Stack.SetVisibleChild(w)
|
||||
}
|
||||
|
||||
if s.current != nil {
|
||||
s.Stack.Remove(s.current)
|
||||
}
|
||||
|
||||
s.current = w
|
||||
}
|
||||
|
||||
func (s *Stack) SetVisibleChild(w gtk.IWidget) {
|
||||
s.Add(w)
|
||||
}
|
||||
|
||||
func (s *Stack) GetChild() (gtk.IWidget, error) {
|
||||
return s.current, nil
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package spinner
|
||||
|
||||
import "github.com/gotk3/gotk3/gtk"
|
||||
|
||||
type Boxed struct {
|
||||
*gtk.Box
|
||||
Spinner *gtk.Spinner
|
||||
}
|
||||
|
||||
func New() *Boxed {
|
||||
spin, _ := gtk.SpinnerNew()
|
||||
spin.Show()
|
||||
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||
box.SetHAlign(gtk.ALIGN_CENTER)
|
||||
box.SetVAlign(gtk.ALIGN_CENTER)
|
||||
box.Add(spin)
|
||||
|
||||
return &Boxed{box, spin}
|
||||
}
|
||||
|
||||
func (b *Boxed) SetSizeRequest(w, h int) {
|
||||
b.Spinner.SetSizeRequest(w, h)
|
||||
}
|
||||
|
||||
func (b *Boxed) Start() {
|
||||
b.Spinner.Start()
|
||||
}
|
||||
|
||||
func (b *Boxed) Stop() {
|
||||
b.Spinner.Stop()
|
||||
}
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/diamondburned/cchat/text"
|
||||
"github.com/diamondburned/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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
})
|
|
@ -2,8 +2,11 @@ package attrmap
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/diamondburned/cchat/text"
|
||||
)
|
||||
|
||||
type AppendMap struct {
|
||||
|
@ -31,11 +34,52 @@ func (a *AppendMap) appendIndex(ind int) {
|
|||
a.indices = append(a.indices, ind)
|
||||
}
|
||||
|
||||
func (a *AppendMap) Anchor(start, end int, href string) {
|
||||
a.Openf(start, `<a href="%s">`, html.EscapeString(href))
|
||||
a.Close(end, "</a>")
|
||||
}
|
||||
|
||||
// AnchorNU makes a new <a> tag without underlines.
|
||||
func (a *AppendMap) AnchorNU(start, end int, href string) {
|
||||
a.Anchor(start, end, href)
|
||||
a.Span(start, end, `underline="none"`)
|
||||
}
|
||||
|
||||
func (a *AppendMap) Span(start, end int, attrs ...string) {
|
||||
a.Openf(start, "<span %s>", strings.Join(attrs, " "))
|
||||
a.Close(end, "</span>")
|
||||
}
|
||||
|
||||
// Pad inserts 2 spaces into start and end. It ensures that not more than 1
|
||||
// space is inserted.
|
||||
func (a *AppendMap) Pad(start, end int) {
|
||||
// Ensure that the starting point does not already have a space.
|
||||
if !posHaveSpace(a.appended, start) {
|
||||
a.Open(start, " ")
|
||||
}
|
||||
if !posHaveSpace(a.prepended, end) {
|
||||
a.Close(end, " ")
|
||||
}
|
||||
}
|
||||
|
||||
func posHaveSpace(tags map[int]string, pos int) bool {
|
||||
tg, ok := tags[pos]
|
||||
if !ok || len(tg) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check trailing spaces.
|
||||
if tg[0] == ' ' {
|
||||
return true
|
||||
}
|
||||
if tg[len(tg)-1] == ' ' {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check spaces mid-tag. This works because strings are always escaped.
|
||||
return strings.Contains(tg, "> <")
|
||||
}
|
||||
|
||||
func (a *AppendMap) Pair(start, end int, open, close string) {
|
||||
a.Open(start, open)
|
||||
a.Close(end, close)
|
||||
|
@ -82,3 +126,9 @@ func (a *AppendMap) Finalize(strlen int) []int {
|
|||
sort.Ints(a.indices)
|
||||
return a.indices
|
||||
}
|
||||
|
||||
// CoverAll returns true if the given start and end covers the entire text
|
||||
// segment.
|
||||
func CoverAll(txt text.Rich, start, end int) bool {
|
||||
return start == 0 && end == len(txt.Content)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
package parser
|
||||
package markup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/attrmap"
|
||||
|
@ -13,53 +14,66 @@ import (
|
|||
"github.com/diamondburned/imgutil"
|
||||
)
|
||||
|
||||
func markupAttr(attr text.Attribute) string {
|
||||
// meme fast path
|
||||
if attr == 0 {
|
||||
return ""
|
||||
}
|
||||
// Hyphenate controls whether or not texts should have hyphens on wrap.
|
||||
var Hyphenate = false
|
||||
|
||||
var attrs = make([]string, 0, 1)
|
||||
if attr.Has(text.AttrBold) {
|
||||
attrs = append(attrs, `weight="bold"`)
|
||||
}
|
||||
if attr.Has(text.AttrItalics) {
|
||||
attrs = append(attrs, `style="italic"`)
|
||||
}
|
||||
if attr.Has(text.AttrUnderline) {
|
||||
attrs = append(attrs, `underline="single"`)
|
||||
}
|
||||
if attr.Has(text.AttrStrikethrough) {
|
||||
attrs = append(attrs, `strikethrough="true"`)
|
||||
}
|
||||
if attr.Has(text.AttrSpoiler) {
|
||||
attrs = append(attrs, `foreground="#808080"`) // no fancy click here
|
||||
}
|
||||
if attr.Has(text.AttrMonospace) {
|
||||
attrs = append(attrs, `font_family="monospace"`)
|
||||
}
|
||||
return strings.Join(attrs, " ")
|
||||
func hyphenate(text string) string {
|
||||
return fmt.Sprintf(`<span insert_hyphens="%t">%s</span>`, Hyphenate, text)
|
||||
}
|
||||
|
||||
func RenderMarkup(content text.Rich) string {
|
||||
// RenderOutput is the output of a render.
|
||||
type RenderOutput struct {
|
||||
Markup string
|
||||
Mentions []text.Mentioner
|
||||
}
|
||||
|
||||
// f_Mention is used to print and parse mention URIs.
|
||||
const f_Mention = "cchat://mention:%d" // %d == Mentions[i]
|
||||
|
||||
// IsMention returns the mention if the URI is correct, or nil if none.
|
||||
func (r RenderOutput) IsMention(uri string) text.Mentioner {
|
||||
var i int
|
||||
|
||||
if _, err := fmt.Sscanf(uri, f_Mention, &i); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if i >= len(r.Mentions) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.Mentions[i]
|
||||
}
|
||||
|
||||
func Render(content text.Rich) string {
|
||||
return RenderCmplx(content).Markup
|
||||
}
|
||||
|
||||
// RenderCmplx renders content into a complete output.
|
||||
func RenderCmplx(content text.Rich) RenderOutput {
|
||||
// Fast path.
|
||||
if len(content.Segments) == 0 {
|
||||
return html.EscapeString(content.Content)
|
||||
return RenderOutput{
|
||||
Markup: hyphenate(html.EscapeString(content.Content)),
|
||||
}
|
||||
}
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
buf.Grow(len(content.Content))
|
||||
|
||||
// // Sort so that all starting points are sorted incrementally.
|
||||
// sort.Slice(content.Segments, func(i, j int) bool {
|
||||
// i, _ = content.Segments[i].Bounds()
|
||||
// j, _ = content.Segments[j].Bounds()
|
||||
// return i < j
|
||||
// })
|
||||
// Sort so that all starting points are sorted incrementally.
|
||||
sort.SliceStable(content.Segments, func(i, j int) bool {
|
||||
i, _ = content.Segments[i].Bounds()
|
||||
j, _ = content.Segments[j].Bounds()
|
||||
return i < j
|
||||
})
|
||||
|
||||
// map to append strings to indices
|
||||
var appended = attrmap.NewAppendedMap()
|
||||
|
||||
// map to store mentions
|
||||
var mentions []text.Mentioner
|
||||
|
||||
// Parse all segments.
|
||||
for _, segment := range content.Segments {
|
||||
start, end := segment.Bounds()
|
||||
|
@ -74,17 +88,27 @@ func RenderMarkup(content text.Rich) string {
|
|||
appended.Open(start, composeImageMarkup(segment))
|
||||
}
|
||||
|
||||
if segment, ok := segment.(text.Avatarer); ok {
|
||||
// Ends don't matter with images.
|
||||
appended.Open(start, composeAvatarMarkup(segment))
|
||||
}
|
||||
|
||||
// Mentioner needs to be before colorer, as we'd want the below color
|
||||
// segment to also highlight the full mention as well as make the
|
||||
// padding part of the hyperlink.
|
||||
if segment, ok := segment.(text.Mentioner); ok {
|
||||
// Render the mention into "cchat://mention:0" or such. Other
|
||||
// components will take care of showing the information.
|
||||
appended.AnchorNU(start, end, fmt.Sprintf(f_Mention, len(mentions)))
|
||||
mentions = append(mentions, segment)
|
||||
}
|
||||
|
||||
if segment, ok := segment.(text.Colorer); ok {
|
||||
var attrs = []string{fmt.Sprintf(`color="#%06X"`, segment.Color())}
|
||||
// If the color segment only covers a segment, then add some more
|
||||
// formatting.
|
||||
if start > 0 && end < len(content.Content) {
|
||||
attrs = append(attrs,
|
||||
`bgalpha="10%"`,
|
||||
fmt.Sprintf(`bgcolor="#%06X"`, segment.Color()),
|
||||
)
|
||||
var covered = attrmap.CoverAll(content, start, end)
|
||||
appended.Span(start, end, color(segment.Color(), !covered)...)
|
||||
if !covered { // add padding if doesn't cover all
|
||||
appended.Pad(start, end)
|
||||
}
|
||||
appended.Span(start, end, attrs...)
|
||||
}
|
||||
|
||||
if segment, ok := segment.(text.Attributor); ok {
|
||||
|
@ -114,7 +138,28 @@ func RenderMarkup(content text.Rich) string {
|
|||
lastIndex = index
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
return RenderOutput{
|
||||
Markup: hyphenate(buf.String()),
|
||||
Mentions: mentions,
|
||||
}
|
||||
}
|
||||
|
||||
func color(c uint32, bg bool) []string {
|
||||
var hex = fmt.Sprintf("#%06X", c)
|
||||
|
||||
var attrs = []string{
|
||||
fmt.Sprintf(`color="%s"`, hex),
|
||||
}
|
||||
|
||||
if bg {
|
||||
attrs = append(
|
||||
attrs,
|
||||
`bgalpha="10%"`,
|
||||
fmt.Sprintf(`bgcolor="%s"`, hex),
|
||||
)
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// string constant for formatting width and height in URL fragments
|
||||
|
@ -138,11 +183,29 @@ func composeImageMarkup(imager text.Imager) string {
|
|||
)
|
||||
}
|
||||
|
||||
func composeAvatarMarkup(avatarer text.Avatarer) string {
|
||||
u, err := url.Parse(avatarer.Avatar())
|
||||
if err != nil {
|
||||
// If the URL is invalid, then just write a normal text.
|
||||
return html.EscapeString(avatarer.AvatarText())
|
||||
}
|
||||
|
||||
// Override the URL fragment with our own.
|
||||
if size := avatarer.AvatarSize(); size > 0 {
|
||||
u.Fragment = fmt.Sprintf(f_FragmentSize, size, size) + ";round"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`<a href="%s">%s</a>`,
|
||||
html.EscapeString(u.String()), html.EscapeString(avatarer.AvatarText()),
|
||||
)
|
||||
}
|
||||
|
||||
// FragmentImageSize tries to parse the width and height encoded in the URL
|
||||
// fragment, which is inserted by the markup renderer. A pair of zero values are
|
||||
// returned if there is none. The returned width and height will be the minimum
|
||||
// of the given maxes and the encoded sizes.
|
||||
func FragmentImageSize(URL string, maxw, maxh int) (w, h int) {
|
||||
func FragmentImageSize(URL string, maxw, maxh int) (w, h int, round bool) {
|
||||
u, err := url.Parse(URL)
|
||||
if err != nil {
|
||||
return
|
||||
|
@ -150,14 +213,48 @@ func FragmentImageSize(URL string, maxw, maxh int) (w, h int) {
|
|||
|
||||
// Ignore the error, as we can check for the integers.
|
||||
fmt.Sscanf(u.Fragment, f_FragmentSize, &w, &h)
|
||||
round = strings.HasSuffix(u.Fragment, ";round")
|
||||
|
||||
if w > 0 && h > 0 {
|
||||
return imgutil.MaxSize(w, h, maxw, maxh)
|
||||
w, h = imgutil.MaxSize(w, h, maxw, maxh)
|
||||
return
|
||||
}
|
||||
|
||||
return maxw, maxh
|
||||
return maxw, maxh, round
|
||||
}
|
||||
|
||||
func span(key, value string) string {
|
||||
return "<span key=\"" + value + "\">"
|
||||
}
|
||||
|
||||
func markupAttr(attr text.Attribute) string {
|
||||
// meme fast path
|
||||
if attr == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var attrs = make([]string, 0, 1)
|
||||
if attr.Has(text.AttrBold) {
|
||||
attrs = append(attrs, `weight="bold"`)
|
||||
}
|
||||
if attr.Has(text.AttrItalics) {
|
||||
attrs = append(attrs, `style="italic"`)
|
||||
}
|
||||
if attr.Has(text.AttrUnderline) {
|
||||
attrs = append(attrs, `underline="single"`)
|
||||
}
|
||||
if attr.Has(text.AttrStrikethrough) {
|
||||
attrs = append(attrs, `strikethrough="true"`)
|
||||
}
|
||||
if attr.Has(text.AttrSpoiler) {
|
||||
attrs = append(attrs, `alpha="35%"`) // no fancy click here
|
||||
}
|
||||
if attr.Has(text.AttrMonospace) {
|
||||
attrs = append(attrs, `font_family="monospace"`)
|
||||
}
|
||||
if attr.Has(text.AttrDimmed) {
|
||||
attrs = append(attrs, `alpha="35%"`)
|
||||
}
|
||||
|
||||
return strings.Join(attrs, " ")
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package parser
|
||||
package markup
|
||||
|
||||
import (
|
||||
"testing"
|
|
@ -1,80 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
type children struct {
|
||||
*gtk.Box
|
||||
sessions map[string]*session.Row
|
||||
}
|
||||
|
||||
func newChildren() *children {
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
box.Show()
|
||||
|
||||
return &children{box, map[string]*session.Row{}}
|
||||
}
|
||||
|
||||
func (c *children) Sessions() []*session.Row {
|
||||
// We already know the size beforehand. Allocate it wisely.
|
||||
var rows = make([]*session.Row, 0, len(c.sessions))
|
||||
|
||||
// Loop over widget children.
|
||||
primitives.EachChildren(c.Box, func(i int, v interface{}) bool {
|
||||
var id = primitives.GetName(v.(primitives.Namer))
|
||||
|
||||
if row, ok := c.sessions[id]; ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func (c *children) AddSessionRow(id string, row *session.Row) {
|
||||
c.sessions[id] = row
|
||||
c.Box.Add(row)
|
||||
|
||||
// Bind the mover.
|
||||
row.BindMover(id)
|
||||
|
||||
// Assert that a name can be obtained.
|
||||
namer := primitives.Namer(row)
|
||||
namer.SetName(id) // set ID here, get it in Move
|
||||
}
|
||||
|
||||
func (c *children) RemoveSessionRow(sessionID string) bool {
|
||||
row, ok := c.sessions[sessionID]
|
||||
if ok {
|
||||
delete(c.sessions, sessionID)
|
||||
c.Box.Remove(row)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c *children) MoveSession(id, movingID string) {
|
||||
// Get the widget of the row that is moving.
|
||||
var moving = c.sessions[movingID]
|
||||
|
||||
// Find the current position of the row that we're moving the other one
|
||||
// underneath of.
|
||||
var rowix = -1
|
||||
|
||||
primitives.EachChildren(c.Box, func(i int, v interface{}) bool {
|
||||
// The obtained name will be the ID set in AddSessionRow.
|
||||
if primitives.GetName(v.(primitives.Namer)) == id {
|
||||
rowix = i
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Reorder the box.
|
||||
c.Box.ReorderChild(moving, rowix)
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/buttonoverlay"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/config"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
const IconSize = 32
|
||||
|
||||
type header struct {
|
||||
*rich.ToggleButtonImage
|
||||
Add *gtk.Button
|
||||
|
||||
Menu *menu.LazyMenu
|
||||
}
|
||||
|
||||
func newHeader(svc cchat.Service) *header {
|
||||
b := rich.NewToggleButtonImage(svc.Name())
|
||||
b.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
|
||||
b.SetRelief(gtk.RELIEF_NONE)
|
||||
b.SetMode(true)
|
||||
b.Show()
|
||||
|
||||
if iconer, ok := svc.(cchat.Icon); ok {
|
||||
b.Image.AsyncSetIconer(iconer, "Error getting session logo")
|
||||
}
|
||||
|
||||
add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON)
|
||||
add.Show()
|
||||
|
||||
// Add the button overlay into the main button.
|
||||
buttonoverlay.Take(b, add, IconSize)
|
||||
|
||||
// Construct a menu and its items.
|
||||
var menu = menu.NewLazyMenu(b)
|
||||
if configurator, ok := svc.(config.Configurator); ok {
|
||||
menu.AddItems(config.MenuItem(configurator))
|
||||
}
|
||||
|
||||
return &header{b, add, menu}
|
||||
}
|
|
@ -0,0 +1,301 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
type ViewController interface {
|
||||
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
||||
SessionSelected(*Service, *session.Row)
|
||||
AuthenticateSession(*List, *Service)
|
||||
}
|
||||
|
||||
// List is a list of services. Each service is a revealer that contains
|
||||
// sessions.
|
||||
type List struct {
|
||||
*gtk.ScrolledWindow
|
||||
|
||||
// same methods as ListController
|
||||
ViewController
|
||||
|
||||
ListBox *gtk.Box
|
||||
Services []*Service // TODO: collision check
|
||||
}
|
||||
|
||||
var _ ListController = (*List)(nil)
|
||||
|
||||
var listCSS = primitives.PrepareClassCSS("service-list", `
|
||||
.service-list {
|
||||
padding: 0;
|
||||
background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
|
||||
}
|
||||
`)
|
||||
|
||||
func NewList(vctl ViewController) *List {
|
||||
svlist := &List{ViewController: vctl}
|
||||
|
||||
// List box of buttons.
|
||||
svlist.ListBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
svlist.ListBox.Show()
|
||||
listCSS(svlist.ListBox)
|
||||
|
||||
svlist.ScrolledWindow, _ = gtk.ScrolledWindowNew(nil, nil)
|
||||
svlist.ScrolledWindow.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_EXTERNAL)
|
||||
svlist.ScrolledWindow.Add(svlist.ListBox)
|
||||
|
||||
return svlist
|
||||
}
|
||||
|
||||
func (sl *List) SetSizeRequest(w, h int) {
|
||||
sl.ScrolledWindow.SetSizeRequest(w, h)
|
||||
sl.ListBox.SetSizeRequest(w, h)
|
||||
}
|
||||
|
||||
func (sl *List) AuthenticateSession(svc *Service) {
|
||||
sl.ViewController.AuthenticateSession(sl, svc)
|
||||
}
|
||||
|
||||
func (sl *List) AddService(svc cchat.Service) {
|
||||
row := NewService(svc, sl)
|
||||
row.Show()
|
||||
|
||||
sl.ListBox.Add(row)
|
||||
sl.Services = append(sl.Services, row)
|
||||
|
||||
// Try and restore all sessions.
|
||||
row.restoreAll()
|
||||
|
||||
// TODO: drag-and-drop?
|
||||
}
|
||||
|
||||
/*
|
||||
type View struct {
|
||||
*gtk.ScrolledWindow
|
||||
Box *gtk.Box
|
||||
Services []*Container
|
||||
}
|
||||
|
||||
var servicesCSS = primitives.PrepareCSS(`
|
||||
.services {
|
||||
background-color: @theme_base_color;
|
||||
}
|
||||
`)
|
||||
|
||||
func NewView() *View {
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
box.Show()
|
||||
|
||||
primitives.AddClass(box, "services")
|
||||
primitives.AttachCSS(box, servicesCSS)
|
||||
|
||||
sw, _ := gtk.ScrolledWindowNew(nil, nil)
|
||||
sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
|
||||
sw.Add(box)
|
||||
|
||||
return &View{
|
||||
sw,
|
||||
box,
|
||||
nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *View) AddService(svc cchat.Service, ctrl Controller) *Container {
|
||||
s := NewContainer(svc, ctrl)
|
||||
v.Services = append(v.Services, s)
|
||||
v.Box.Add(s)
|
||||
|
||||
// Try and restore all sessions.
|
||||
s.restoreAllSessions()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
type Controller interface {
|
||||
// RowSelected is wrapped around session's MessageRowSelected.
|
||||
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
||||
// AuthenticateSession is called to spawn the authentication dialog.
|
||||
AuthenticateSession(*Container, cchat.Service)
|
||||
// OnSessionRemove is called to remove a session. This should also clear out
|
||||
// the message view in the parent package.
|
||||
OnSessionRemove(id string)
|
||||
// OnSessionDisconnect is here to satisfy session's controller.
|
||||
OnSessionDisconnect(id string)
|
||||
}
|
||||
|
||||
// Container represents a single service, including the button header and the
|
||||
// child containers.
|
||||
type Container struct {
|
||||
*gtk.Box
|
||||
Service cchat.Service
|
||||
|
||||
header *header
|
||||
revealer *gtk.Revealer
|
||||
children *children
|
||||
|
||||
// Embed controller and extend it to override RestoreSession.
|
||||
Controller
|
||||
}
|
||||
|
||||
// Guarantee that our interface is up-to-date with session's controller.
|
||||
var _ session.Controller = (*Container)(nil)
|
||||
|
||||
func NewContainer(svc cchat.Service, ctrl Controller) *Container {
|
||||
children := newChildren()
|
||||
|
||||
chrev, _ := gtk.RevealerNew()
|
||||
chrev.SetRevealChild(true)
|
||||
chrev.Add(children)
|
||||
chrev.Show()
|
||||
|
||||
header := newHeader(svc)
|
||||
header.SetActive(chrev.GetRevealChild())
|
||||
|
||||
box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
box.Show()
|
||||
box.PackStart(header, false, false, 0)
|
||||
box.PackStart(chrev, false, false, 0)
|
||||
|
||||
primitives.AddClass(box, "service")
|
||||
|
||||
container := &Container{
|
||||
Box: box,
|
||||
Service: svc,
|
||||
header: header,
|
||||
revealer: chrev,
|
||||
children: children,
|
||||
Controller: ctrl,
|
||||
}
|
||||
|
||||
// On click, toggle reveal.
|
||||
header.Connect("clicked", func() {
|
||||
revealed := !chrev.GetRevealChild()
|
||||
chrev.SetRevealChild(revealed)
|
||||
header.SetActive(revealed)
|
||||
})
|
||||
|
||||
// On click, show the auth dialog.
|
||||
header.Add.Connect("clicked", func() {
|
||||
ctrl.AuthenticateSession(container, svc)
|
||||
})
|
||||
|
||||
// Add more menu item(s).
|
||||
header.Menu.AddSimpleItem("Save Sessions", container.SaveAllSessions)
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
func (c *Container) GetService() cchat.Service {
|
||||
return c.Service
|
||||
}
|
||||
|
||||
func (c *Container) Sessions() []*session.Row {
|
||||
return c.children.Sessions()
|
||||
}
|
||||
|
||||
func (c *Container) AddSession(ses cchat.Session) *session.Row {
|
||||
srow := session.New(c, ses, c)
|
||||
c.children.AddSessionRow(ses.ID(), srow)
|
||||
c.SaveAllSessions()
|
||||
return srow
|
||||
}
|
||||
|
||||
func (c *Container) AddLoadingSession(id, name string) *session.Row {
|
||||
srow := session.NewLoading(c, id, name, c)
|
||||
c.children.AddSessionRow(id, srow)
|
||||
return srow
|
||||
}
|
||||
|
||||
func (c *Container) RemoveSession(row *session.Row) {
|
||||
var id = row.Session.ID()
|
||||
c.children.RemoveSessionRow(id)
|
||||
c.SaveAllSessions()
|
||||
// Call the parent's method.
|
||||
c.Controller.OnSessionRemove(id)
|
||||
}
|
||||
|
||||
func (c *Container) MoveSession(rowID, beneathRowID string) {
|
||||
c.children.MoveSession(rowID, beneathRowID)
|
||||
c.SaveAllSessions()
|
||||
}
|
||||
|
||||
func (c *Container) OnSessionDisconnect(ses *session.Row) {
|
||||
c.Controller.OnSessionDisconnect(ses.ID())
|
||||
}
|
||||
|
||||
// RestoreSession tries to restore sessions asynchronously. This satisfies
|
||||
// session.Controller.
|
||||
func (c *Container) RestoreSession(row *session.Row, id string) {
|
||||
// Can this session be restored? If not, exit.
|
||||
restorer, ok := c.Service.(cchat.SessionRestorer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Do we even have a session stored?
|
||||
krs := keyring.RestoreSession(c.Service.Name(), id)
|
||||
if krs == nil {
|
||||
log.Error(fmt.Errorf(
|
||||
"Missing keyring for service %s, session ID %s",
|
||||
c.Service.Name().Content, id,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.restoreSession(row, restorer, *krs)
|
||||
}
|
||||
|
||||
// internal method called on AddService.
|
||||
func (c *Container) restoreAllSessions() {
|
||||
// Can this session be restored? If not, exit.
|
||||
restorer, ok := c.Service.(cchat.SessionRestorer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var sessions = keyring.RestoreSessions(c.Service.Name())
|
||||
|
||||
for _, krs := range sessions {
|
||||
// Copy the session to avoid race conditions.
|
||||
krs := krs
|
||||
row := c.AddLoadingSession(krs.ID, krs.Name)
|
||||
|
||||
c.restoreSession(row, restorer, krs)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) restoreSession(r *session.Row, res cchat.SessionRestorer, k keyring.Session) {
|
||||
go func() {
|
||||
s, err := res.RestoreSession(k.Data)
|
||||
if err != nil {
|
||||
err = errors.Wrapf(err, "Failed to restore session %s (%s)", k.ID, k.Name)
|
||||
log.Error(err)
|
||||
|
||||
gts.ExecAsync(func() { r.SetFailed(err) })
|
||||
} else {
|
||||
gts.ExecAsync(func() { r.SetSession(s) })
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Container) SaveAllSessions() {
|
||||
var sessions = c.children.Sessions()
|
||||
var ksessions = make([]keyring.Session, 0, len(sessions))
|
||||
|
||||
for _, s := range sessions {
|
||||
if k := s.KeyringSession(); k != nil {
|
||||
ksessions = append(ksessions, *k)
|
||||
}
|
||||
}
|
||||
|
||||
keyring.SaveSessions(c.Service.Name(), ksessions)
|
||||
}
|
||||
|
||||
func (c *Container) Breadcrumb() breadcrumb.Breadcrumb {
|
||||
return breadcrumb.Try(nil, c.header.GetText())
|
||||
}
|
||||
*/
|
|
@ -4,239 +4,269 @@ import (
|
|||
"fmt"
|
||||
|
||||
"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}
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
// TODO rename file
|
||||
|
||||
// TODO: round buttons
|
||||
func NewAddButton() *gtk.ListBoxRow {
|
||||
img, _ := gtk.ImageNew()
|
||||
img.Show()
|
||||
primitives.SetImageIcon(img, "list-add-symbolic", IconSize/2)
|
||||
|
||||
row, _ := gtk.ListBoxRowNew()
|
||||
row.SetSizeRequest(IconSize, IconSize)
|
||||
row.SetSelectable(false) // activatable though
|
||||
row.Add(img)
|
||||
row.Show()
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
type ServiceController interface {
|
||||
SessionSelected(*Row)
|
||||
AuthenticateSession()
|
||||
}
|
||||
|
||||
type List struct {
|
||||
*gtk.ListBox
|
||||
// This map isn't ordered, as we rely on the order that the widget was added
|
||||
// into the ListBox.
|
||||
sessions map[string]*Row
|
||||
|
||||
svcctrl ServiceController
|
||||
}
|
||||
|
||||
var listCSS = primitives.PrepareClassCSS("session-list", `
|
||||
.session-list {
|
||||
border-radius: 0 0 14px 14px;
|
||||
background-color: mix(@theme_bg_color, @theme_fg_color, 0.1);
|
||||
box-shadow:
|
||||
inset 0 10px 2px -10px alpha(#121212, 0.6),
|
||||
inset 0 -10px 2px -10px alpha(#121212, 0.6);
|
||||
}
|
||||
`)
|
||||
|
||||
func NewList(svcctrl ServiceController) *List {
|
||||
list, _ := gtk.ListBoxNew()
|
||||
list.Add(NewAddButton()) // add button to LAST; keep it LAST.
|
||||
list.Show()
|
||||
listCSS(list)
|
||||
|
||||
// We can't do browse for the selection mode, as we need UnselectAll to
|
||||
// work.
|
||||
list.SetSelectionMode(gtk.SELECTION_SINGLE)
|
||||
|
||||
sl := &List{
|
||||
ListBox: list,
|
||||
sessions: map[string]*Row{},
|
||||
svcctrl: svcctrl,
|
||||
}
|
||||
|
||||
list.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
|
||||
switch i, length := r.GetIndex(), len(sl.sessions); {
|
||||
case i < 0:
|
||||
return // lulwut
|
||||
|
||||
// If the selection IS the last button.
|
||||
case i == length:
|
||||
svcctrl.AuthenticateSession()
|
||||
|
||||
// If the selection is within range and is not the last button.
|
||||
case i < length:
|
||||
if row, ok := sl.sessions[primitives.GetName(r)]; ok {
|
||||
row.Activate()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return sl
|
||||
}
|
||||
|
||||
func (sl *List) Sessions() []*Row {
|
||||
// We already know the size beforehand. Allocate it wisely.
|
||||
var rows = make([]*Row, 0, len(sl.sessions))
|
||||
|
||||
// Loop over widget children.
|
||||
primitives.EachChildren(sl.ListBox, func(i int, v interface{}) bool {
|
||||
var id = primitives.GetName(v.(primitives.Namer))
|
||||
|
||||
if row, ok := sl.sessions[id]; ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func (sl *List) AddSessionRow(id string, row *Row) {
|
||||
// Insert the row RIGHT BEFORE the add button.
|
||||
sl.ListBox.Insert(row, len(sl.sessions))
|
||||
// Set the map, which increases the length by 1.
|
||||
sl.sessions[id] = row
|
||||
|
||||
// Bind the mover.
|
||||
row.BindMover(id)
|
||||
|
||||
// Assert that a name can be obtained.
|
||||
namer := primitives.Namer(row)
|
||||
namer.SetName(id) // set ID here, get it in Move
|
||||
}
|
||||
|
||||
func (sl *List) RemoveSessionRow(sessionID string) bool {
|
||||
r, ok := sl.sessions[sessionID]
|
||||
if ok {
|
||||
delete(sl.sessions, sessionID)
|
||||
sl.ListBox.Remove(r)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// MoveSession moves sessions around. This function must not touch the add
|
||||
// button.
|
||||
func (sl *List) MoveSession(id, movingID string) {
|
||||
// Get the widget of the row that is moving.
|
||||
var moving = sl.sessions[movingID]
|
||||
|
||||
// Find the current position of the row that we're moving the other one
|
||||
// underneath of.
|
||||
var rowix = -1
|
||||
|
||||
primitives.EachChildren(sl.ListBox, func(i int, v interface{}) bool {
|
||||
// The obtained name will be the ID set in AddSessionRow.
|
||||
if primitives.GetName(v.(primitives.Namer)) == id {
|
||||
rowix = i
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Reorder the box.
|
||||
sl.ListBox.Remove(moving)
|
||||
sl.ListBox.Insert(moving, rowix)
|
||||
}
|
||||
|
||||
func (sl *List) UnselectAll() {
|
||||
sl.ListBox.UnselectAll()
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/loading"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
type Controller interface {
|
||||
RowSelected(*ServerRow, cchat.ServerMessage)
|
||||
}
|
||||
|
||||
// Children is a children server with a reference to the parent.
|
||||
type Children struct {
|
||||
*gtk.Box
|
||||
load *loading.Button // only not nil while loading
|
||||
|
||||
Rows []*ServerRow
|
||||
|
||||
Parent breadcrumb.Breadcrumber
|
||||
rowctrl Controller
|
||||
}
|
||||
|
||||
// reserved
|
||||
var childrenCSS = primitives.PrepareClassCSS("server-children", `
|
||||
.server-children {}
|
||||
`)
|
||||
|
||||
func NewChildren(p breadcrumb.Breadcrumber, ctrl Controller) *Children {
|
||||
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
|
||||
main.SetMarginStart(ChildrenMargin)
|
||||
childrenCSS(main)
|
||||
|
||||
return &Children{
|
||||
Box: main,
|
||||
Parent: p,
|
||||
rowctrl: ctrl,
|
||||
}
|
||||
}
|
||||
|
||||
// setLoading shows the loading circle as a list child.
|
||||
func (c *Children) setLoading() {
|
||||
// Exit if we're already loading.
|
||||
if c.load != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear everything.
|
||||
c.Reset()
|
||||
|
||||
// Set the loading circle and stuff.
|
||||
c.load = loading.NewButton()
|
||||
c.load.Show()
|
||||
c.Box.Add(c.load)
|
||||
}
|
||||
|
||||
func (c *Children) Reset() {
|
||||
// Remove old servers from the list.
|
||||
for _, row := range c.Rows {
|
||||
c.Box.Remove(row)
|
||||
}
|
||||
|
||||
// Wipe the list empty.
|
||||
c.Rows = nil
|
||||
}
|
||||
|
||||
// setNotLoading removes the loading circle, if any. This is not in Reset()
|
||||
// anymore, since the backend may not necessarily call SetServers.
|
||||
func (c *Children) setNotLoading() {
|
||||
// Do we have the spinning circle button? If yes, remove it.
|
||||
if c.load != nil {
|
||||
// Stop the loading mode. The reset function should do everything for us.
|
||||
c.Box.Remove(c.load)
|
||||
c.load = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetServers is reserved for cchat.ServersContainer.
|
||||
func (c *Children) SetServers(servers []cchat.Server) {
|
||||
gts.ExecAsync(func() {
|
||||
// Save the current state.
|
||||
var oldID string
|
||||
for _, row := range c.Rows {
|
||||
if row.GetActive() {
|
||||
oldID = row.Server.ID()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Reset before inserting new servers.
|
||||
c.Reset()
|
||||
|
||||
c.Rows = make([]*ServerRow, len(servers))
|
||||
|
||||
for i, server := range servers {
|
||||
row := NewServerRow(c, server, c.rowctrl)
|
||||
row.Show()
|
||||
// row.SetFocusHAdjustment(c.GetFocusHAdjustment()) // inherit
|
||||
// row.SetFocusVAdjustment(c.GetFocusVAdjustment())
|
||||
|
||||
c.Rows[i] = row
|
||||
c.Box.Add(row)
|
||||
}
|
||||
|
||||
// Update parent reference? Only if it's activated.
|
||||
if oldID != "" {
|
||||
for _, row := range c.Rows {
|
||||
if row.Server.ID() == oldID {
|
||||
row.Button.SetActive(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Children) Breadcrumb() breadcrumb.Breadcrumb {
|
||||
return breadcrumb.Try(c.Parent)
|
||||
}
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/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)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/gts"
|
||||
"github.com/diamondburned/cchat-gtk/internal/humanize"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
"github.com/gotk3/gotk3/pango"
|
||||
)
|
||||
|
||||
const FaceSize = 48 // gtk.ICON_SIZE_DIALOG
|
||||
|
||||
// Servers wraps around a list of servers inherited from Children. It's the
|
||||
// container that's displayed on the right of the service sidebar.
|
||||
type Servers struct {
|
||||
*gtk.Box
|
||||
Children *server.Children
|
||||
spinner *spinner.Boxed // non-nil if loading.
|
||||
|
||||
// state
|
||||
ServerList cchat.ServerList
|
||||
}
|
||||
|
||||
var toplevelCSS = primitives.PrepareClassCSS("top-level", `
|
||||
.top-level { margin: 0 3px }
|
||||
`)
|
||||
|
||||
func NewServers(p breadcrumb.Breadcrumber, ctrl server.Controller) *Servers {
|
||||
c := server.NewChildren(p, ctrl)
|
||||
c.SetMarginStart(0) // children is top level; there is no main row
|
||||
c.SetHExpand(true) // fill
|
||||
c.Show()
|
||||
toplevelCSS(c)
|
||||
|
||||
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||
|
||||
return &Servers{
|
||||
Box: b,
|
||||
Children: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Servers) Reset() {
|
||||
// Reset isn't necessarily called while loading, so we do a check.
|
||||
if s.spinner != nil {
|
||||
s.spinner.Stop()
|
||||
s.spinner = nil
|
||||
}
|
||||
|
||||
// Reset the state.
|
||||
s.ServerList = nil
|
||||
// Remove all children.
|
||||
primitives.RemoveChildren(s)
|
||||
// Reset the children container.
|
||||
s.Children.Reset()
|
||||
}
|
||||
|
||||
// IsLoading returns true if the servers container is loading.
|
||||
func (s *Servers) IsLoading() bool {
|
||||
return s.spinner != nil
|
||||
}
|
||||
|
||||
// SetList indicates that the server list has been loaded. Unlike
|
||||
// server.Children, this method will load immediately.
|
||||
func (s *Servers) SetList(slist cchat.ServerList) {
|
||||
primitives.RemoveChildren(s)
|
||||
s.ServerList = slist
|
||||
s.load()
|
||||
}
|
||||
|
||||
func (s *Servers) load() {
|
||||
// Return if we're loading.
|
||||
if s.IsLoading() {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark the servers list as loading.
|
||||
s.setLoading()
|
||||
|
||||
go func() {
|
||||
err := s.ServerList.Servers(s.Children)
|
||||
gts.ExecAsync(func() {
|
||||
if err != nil {
|
||||
s.setFailed(err)
|
||||
} else {
|
||||
s.setDone()
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// setDone changes the view to show the servers.
|
||||
func (s *Servers) setDone() {
|
||||
primitives.RemoveChildren(s)
|
||||
|
||||
// stop the spinner.
|
||||
s.spinner.Stop()
|
||||
s.spinner = nil
|
||||
|
||||
s.Add(s.Children)
|
||||
}
|
||||
|
||||
// setLoading shows a loading spinner. Use this after the session row is
|
||||
// connected.
|
||||
func (s *Servers) setLoading() {
|
||||
primitives.RemoveChildren(s)
|
||||
|
||||
s.spinner = spinner.New()
|
||||
s.spinner.SetSizeRequest(FaceSize, FaceSize)
|
||||
s.spinner.Show()
|
||||
s.spinner.Start()
|
||||
|
||||
s.Add(s.spinner)
|
||||
}
|
||||
|
||||
// setFailed shows a sad face with the error. Use this when the session row has
|
||||
// failed to load.
|
||||
func (s *Servers) setFailed(err error) {
|
||||
primitives.RemoveChildren(s)
|
||||
|
||||
// stop the spinner. Let this SEGFAULT if nil, as that's undefined behavior.
|
||||
s.spinner.Stop()
|
||||
s.spinner = nil
|
||||
|
||||
// Create a BLANK label for padding.
|
||||
ltop, _ := gtk.LabelNew("")
|
||||
ltop.Show()
|
||||
|
||||
// Create a retry button.
|
||||
btn, _ := gtk.ButtonNewFromIconName("view-refresh-symbolic", gtk.ICON_SIZE_DIALOG)
|
||||
btn.Show()
|
||||
btn.Connect("clicked", s.load)
|
||||
|
||||
// Create a bottom label for the error itself.
|
||||
lerr, _ := gtk.LabelNew("")
|
||||
lerr.SetSingleLineMode(true)
|
||||
lerr.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
|
||||
lerr.SetMarkup(fmt.Sprintf(
|
||||
`<span color="red"><b>Error:</b> %s</span>`,
|
||||
humanize.Error(err),
|
||||
))
|
||||
lerr.Show()
|
||||
|
||||
// Add these items into the box.
|
||||
s.PackStart(ltop, false, false, 0)
|
||||
s.PackStart(btn, false, false, 10) // pad
|
||||
s.PackStart(lerr, false, false, 0)
|
||||
}
|
|
@ -6,9 +6,10 @@ import (
|
|||
"github.com/diamondburned/cchat-gtk/internal/keyring"
|
||||
"github.com/diamondburned/cchat-gtk/internal/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()
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"github.com/diamondburned/cchat"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/singlestack"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session"
|
||||
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
/*
|
||||
|
||||
Design:
|
||||
|
||||
____________________________
|
||||
| # | | |
|
||||
|-----|-----------|--------|
|
||||
| D | nixhub | |
|
||||
| --- | #home | | <- shaded revealer
|
||||
| O | #dev... | | <- user accounts collapsed
|
||||
| --- | astolf... | |
|
||||
| | asdada... | |
|
||||
| M | | |
|
||||
|_____|___________|________|
|
||||
*/
|
||||
|
||||
type Controller interface {
|
||||
// SessionSelected is called when
|
||||
SessionSelected(svc *Service, srow *session.Row)
|
||||
// RowSelected is wrapped around session's MessageRowSelected.
|
||||
RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage)
|
||||
// AuthenticateSession is called to spawn the authentication dialog.
|
||||
AuthenticateSession(*List, *Service)
|
||||
// OnSessionRemove is called to remove a session. This should also clear out
|
||||
// the message view in the parent package.
|
||||
OnSessionRemove(id string)
|
||||
// OnSessionDisconnect is here to satisfy session's controller.
|
||||
OnSessionDisconnect(id string)
|
||||
}
|
||||
|
||||
// LeftWidth is the width of the left-most services panel.
|
||||
const LeftWidth = IconSize
|
||||
|
||||
type View struct {
|
||||
*gtk.Box // 2 panes, but left-most hard-coded
|
||||
Controller // inherit main controller
|
||||
|
||||
Services *List
|
||||
ServerView *gtk.ScrolledWindow
|
||||
|
||||
ServerStack *singlestack.Stack
|
||||
|
||||
// Servers *session.Servers // nil by default; use .Servers
|
||||
}
|
||||
|
||||
func NewView(ctrller Controller) *View {
|
||||
view := &View{Controller: ctrller}
|
||||
|
||||
view.Services = NewList(view)
|
||||
view.Services.SetSizeRequest(LeftWidth, -1)
|
||||
view.Services.Show()
|
||||
|
||||
// Make a separator.
|
||||
// sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_VERTICAL)
|
||||
// sep.Show()
|
||||
|
||||
// Make a stack for the middle panel.
|
||||
view.ServerStack = singlestack.NewStack()
|
||||
view.ServerStack.SetSizeRequest(150, -1) // min width
|
||||
view.ServerStack.SetTransitionDuration(50)
|
||||
view.ServerStack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
|
||||
view.ServerStack.SetHomogeneous(true)
|
||||
view.ServerStack.Show()
|
||||
|
||||
view.ServerView, _ = gtk.ScrolledWindowNew(nil, nil)
|
||||
view.ServerView.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
|
||||
view.ServerView.Add(view.ServerStack)
|
||||
view.ServerView.Show()
|
||||
|
||||
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
||||
view.Box.PackStart(view.Services, false, false, 0)
|
||||
// view.Box.PackStart(sep, false, false, 0)
|
||||
view.Box.PackStart(view.ServerView, true, true, 0)
|
||||
view.Box.Show()
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func (v *View) AddService(svc cchat.Service) {
|
||||
v.Services.AddService(svc)
|
||||
}
|
||||
|
||||
// SessionSelected calls the right-side server view to change.
|
||||
//
|
||||
// TODO: think of how to change. Maybe use a stack? Maybe use a box that we
|
||||
// remove and re-add? does animation matter?
|
||||
func (v *View) SessionSelected(svc *Service, srow *session.Row) {
|
||||
// Unselect every service boxes except this one.
|
||||
for _, service := range v.Services.Services {
|
||||
if service != svc {
|
||||
service.BodyList.UnselectAll()
|
||||
}
|
||||
}
|
||||
|
||||
// !!!: SHITTY HACK!!!
|
||||
// We can do this, as we're keeping all the server lists in memory by Go's
|
||||
// reference anyway. In fact, cchat REQUIRES us to do so.
|
||||
v.ServerStack.SetVisibleChild(srow.Servers)
|
||||
|
||||
// Call the controller's method.
|
||||
v.Controller.SessionSelected(svc, srow)
|
||||
}
|
|
@ -1,10 +1,7 @@
|
|||
/* Make CSS more consistent across themes */
|
||||
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; }
|
||||
|
|
|
@ -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()))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue