Added partial support for the Commander API

This commit is contained in:
diamondburned (Forefront) 2020-06-30 00:20:13 -07:00
parent 0a353ff128
commit 47e3e67b95
10 changed files with 328 additions and 54 deletions

3
go.mod
View File

@ -2,7 +2,7 @@ module github.com/diamondburned/cchat-gtk
go 1.14
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200619213419-0533bcce0dd6
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d
require (
github.com/Xuanwo/go-locale v0.2.0
@ -13,6 +13,7 @@ require (
github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64
github.com/goodsign/monday v1.0.0
github.com/google/btree v1.0.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
github.com/ianlancetaylor/cgosymbolizer v0.0.0-20200424224625-be1b05b0b279

4
go.sum
View File

@ -69,6 +69,8 @@ github.com/diamondburned/cchat-mock v0.0.0-20200630025821-605d61d89288 h1:ApNV7D
github.com/diamondburned/cchat-mock v0.0.0-20200630025821-605d61d89288/go.mod h1:Tu+8b1iz9NGeQb2jmndXn+dQ9zBUa8a8ktK9hL5aaxw=
github.com/diamondburned/gotk3 v0.0.0-20200619213419-0533bcce0dd6 h1:ZzLrfQqszhzWI7zqwltzQIWtppfcL7m2aIEpB4kuqx0=
github.com/diamondburned/gotk3 v0.0.0-20200619213419-0533bcce0dd6/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI=
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64 h1:/ykUYHuYyj+NN/aaqe6lfaCZQc3EMZs93wAGVJTh5j0=
github.com/diamondburned/imgutil v0.0.0-20200611215339-650ac7cfaf64/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/diamondburned/ningen v0.1.0 h1:cTnRNrN0g2Wr/kgjLLpa3pqlbEd6JPNa1yGDer8uV4U=
@ -119,6 +121,8 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=

View File

@ -3,7 +3,7 @@ package container
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/autoscroll"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/autoscroll"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"

View File

@ -0,0 +1,57 @@
package buttonoverlay
import "github.com/gotk3/gotk3/gtk"
type Widget interface {
gtk.IWidget
SetMarginEnd(int)
SetSizeRequest(int, int)
SetHAlign(gtk.Align)
}
var _ Widget = (*gtk.Widget)(nil)
type Button interface {
Widget
// Bin
GetChild() (gtk.IWidget, error)
// Container
Add(gtk.IWidget)
Remove(gtk.IWidget)
// Button
SetRelief(gtk.ReliefStyle)
}
var _ Button = (*gtk.Button)(nil)
// Wrap wraps maincontent inside an overlay with smallbutton placed rightmost on
// top of the content. It will also set the margins and aligns widgets.
func Wrap(maincontent Widget, smallbutton Button, size int) *gtk.Overlay {
maincontent.SetMarginEnd(size)
smallbutton.SetSizeRequest(size, size)
smallbutton.SetHAlign(gtk.ALIGN_END)
smallbutton.SetRelief(gtk.RELIEF_NONE)
o, _ := gtk.OverlayNew()
o.Add(maincontent)
o.AddOverlay(smallbutton)
o.Show()
return o
}
// Take takes over the given button and replaces its content with the wrapped
// overlay, which has the old content as well as the smaller button on top.
func Take(b, smallbutton Button, size int) {
childv, _ := b.GetChild()
widget := childv.ToWidget()
// As GetChild doesn't reference, we'll want our own reference.
widget.Ref()
defer widget.Unref()
// This will unreference.
b.Remove(widget)
// Wrap will reference.
b.Add(Wrap(widget, smallbutton, size))
}

View File

@ -2,6 +2,7 @@ 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"
@ -12,62 +13,35 @@ import (
const IconSize = 32
type header struct {
*gtk.ToggleButton // no rich text here but it's left aligned
box *gtk.Box
label *rich.Label
icon *rich.Icon
Add *gtk.Button
*rich.ToggleButtonImage
Add *gtk.Button
Menu *menu.LazyMenu
}
func newHeader(svc cchat.Service) *header {
i := rich.NewIcon(0)
i.AddProcessors(imgutil.Round(true))
i.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
i.Show()
b := rich.NewToggleButtonImage(svc.Name())
b.Image.AddProcessors(imgutil.Round(true))
b.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize)
b.SetRelief(gtk.RELIEF_NONE)
b.SetMode(true)
b.Show()
if iconer, ok := svc.(cchat.Icon); ok {
i.AsyncSetIconer(iconer, "Error getting session logo")
b.Image.AsyncSetIconer(iconer, "Error getting session logo")
}
l := rich.NewLabel(svc.Name())
l.Show()
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
box.PackStart(i, false, false, 0)
box.PackStart(l, true, true, 5)
box.SetMarginEnd(IconSize) // spare space for the add button
box.Show()
add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON)
add.SetRelief(gtk.RELIEF_NONE)
add.SetSizeRequest(IconSize, IconSize)
add.SetHAlign(gtk.ALIGN_END)
add.Show()
// Do jank stuff to overlay the add button on top of our button.
overlay, _ := gtk.OverlayNew()
overlay.Add(box)
overlay.AddOverlay(add)
overlay.Show()
reveal, _ := gtk.ToggleButtonNew()
reveal.Add(overlay)
reveal.SetRelief(gtk.RELIEF_NONE)
reveal.SetMode(true)
reveal.Show()
// Add the button overlay into the main button.
buttonoverlay.Take(b, add, IconSize)
// Construct a menu and its items.
var menu = menu.NewLazyMenu(reveal)
var menu = menu.NewLazyMenu(b)
if configurator, ok := svc.(config.Configurator); ok {
menu.AddItems(config.MenuItem(configurator))
}
return &header{reveal, box, l, i, add, menu}
}
func (h *header) GetText() string {
return h.label.GetText()
return &header{b, add, menu}
}

View File

@ -123,6 +123,10 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container {
return container
}
func (c *Container) GetService() cchat.Service {
return c.Service
}
func (c *Container) Sessions() []*session.Row {
return c.children.Sessions()
}

View File

@ -0,0 +1,185 @@
package commander
import (
"fmt"
"io"
"time"
"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/primitives/autoscroll"
"github.com/google/shlex"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
type SessionCommander interface {
cchat.Session
cchat.Commander
}
type Buffer struct {
*gtk.TextBuffer
svcname string
cmder SessionCommander
}
// NewBuffer creates a new buffer with the given SessionCommander, or returns
// nil if cmder is nil.
func NewBuffer(svc cchat.Service, cmder SessionCommander) *Buffer {
if cmder == nil {
return nil
}
b, _ := gtk.TextBufferNew(nil)
b.CreateTag("error", map[string]interface{}{
"foreground": "#FF0000",
})
return &Buffer{b, svc.Name().Content, cmder}
}
// WriteError is not thread-safe.
func (b *Buffer) WriteError(err error) {
b.InsertWithTagByName(b.GetEndIter(), err.Error()+"\n", "error")
}
// WriteUnsafe is not thread-safe.
func (b *Buffer) WriteUnsafe(bytes []byte) {
b.Insert(b.GetEndIter(), string(bytes))
}
// Printlnf is not thread-safe.
func (b *Buffer) Printlnf(f string, v ...interface{}) {
b.WriteUnsafe([]byte(fmt.Sprintf(f+"\n", v...)))
}
// Write is thread-safe.
func (b *Buffer) Write(bytes []byte) (int, error) {
gts.ExecAsync(func() { b.WriteUnsafe(bytes) })
return len(bytes), nil
}
func (b *Buffer) ShowDialog() {
SpawnDialog(b)
}
var entryCSS = primitives.PrepareCSS(`
* {
font-family: monospace;
border-radius: 0;
}
`)
type Session struct {
*gtk.Box
words []string
cmder cchat.Commander
buffer *Buffer
}
func SpawnDialog(buf *Buffer) {
s := NewSession(buf.cmder, buf)
s.Show()
h, _ := gtk.HeaderBarNew()
h.SetTitle(fmt.Sprintf(
"Commander: %s on %s",
buf.cmder.Name().Content, buf.svcname,
))
h.SetShowCloseButton(true)
h.Show()
d, _ := gts.NewEmptyModalDialog()
d.SetDefaultSize(450, 250)
d.SetTitlebar(h)
d.Add(s)
d.Show()
}
func NewSession(cmder cchat.Commander, buf *Buffer) *Session {
v, _ := gtk.TextViewNewWithBuffer(buf.TextBuffer)
v.SetEditable(false)
v.SetProperty("monospace", true)
v.SetBorderWidth(8)
v.SetPixelsAboveLines(1)
v.SetWrapMode(gtk.WRAP_WORD_CHAR)
v.Show()
s := autoscroll.NewScrolledWindow()
s.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
s.Add(v)
s.Show()
i, _ := gtk.EntryNew()
primitives.AttachCSS(i, entryCSS)
i.Show()
b, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
b.PackStart(s, true, true, 0)
b.PackStart(i, false, false, 0)
session := &Session{
Box: b,
cmder: cmder,
buffer: buf,
}
i.Connect("activate", session.inputActivate)
// Split words on typing to provide live errors.
i.Connect("changed", func(i *gtk.Entry) {
t, _ := i.GetText()
w, err := shlex.Split(t)
if err != nil {
i.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "dialog-error")
i.SetIconTooltipText(gtk.ENTRY_ICON_SECONDARY, err.Error())
session.words = nil
} else {
i.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "")
session.words = w
}
})
// Focus on the input by default.
i.GrabFocus()
return session
}
func (s *Session) inputActivate(e *gtk.Entry) {
// If the input is empty, then ignore.
if len(s.words) == 0 {
return
}
r, err := s.cmder.RunCommand(s.words)
if err != nil {
s.buffer.WriteError(err)
return
}
// Clear the entry.
e.SetText("")
var then = time.Now()
s.buffer.Printlnf("%s: Running command...", then.Format(time.Kitchen))
go func() {
_, err := io.Copy(s.buffer, r)
r.Close()
gts.ExecAsync(func() {
if err != nil {
s.buffer.WriteError(errors.Wrap(err, "Internal error"))
}
var now = time.Now()
s.buffer.Printlnf(
"%s: Finished running command, took %s.",
now.Format(time.Kitchen),
now.Sub(then).String(),
)
})
}()
}

View File

@ -89,12 +89,12 @@ func (r *Row) Reset() {
// Remove the children container itself.
r.Box.Remove(r.children)
// Reset the state.
r.loaded = false
r.serverList = nil
r.children = nil
}
// Reset the state.
r.loaded = false
r.serverList = nil
r.children = nil
}
// SetLoading is called by the parent struct.

View File

@ -6,10 +6,13 @@ 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/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"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
@ -17,6 +20,8 @@ const IconSize = 32
// Controller extends server.RowController to add session.
type Controller interface {
// GetService asks the controller for its service.
GetService() cchat.Service
// OnSessionDisconnect is called before a session is disconnected. This
// function is used for cleanups.
OnSessionDisconnect(*Row)
@ -42,6 +47,9 @@ type Row struct {
sessionID string // used for reconnection
ctrl Controller
cmder *commander.Buffer
cmdbtn *gtk.Button
}
func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Row {
@ -58,14 +66,36 @@ func NewLoading(parent breadcrumb.Breadcrumber, id, name string, ctrl Controller
}
func newRow(parent breadcrumb.Breadcrumber, name text.Rich, ctrl Controller) *Row {
// Bind the row to .session in CSS.
row := server.NewRow(parent, name)
row.Button.SetPlaceholderIcon("user-invisible-symbolic", IconSize)
row.Show()
primitives.AddClass(row, "session")
primitives.AddClass(row, "server-list")
srow := server.NewRow(parent, name)
srow.Button.SetPlaceholderIcon("user-invisible-symbolic", IconSize)
srow.Show()
return &Row{Row: row, ctrl: ctrl}
// Bind the row to .session in CSS.
primitives.AddClass(srow, "session")
primitives.AddClass(srow, "server-list")
// Make a commander button that's hidden by default in case.
cmdbtn, _ := gtk.ButtonNewFromIconName("utilities-terminal-symbolic", gtk.ICON_SIZE_BUTTON)
buttonoverlay.Take(srow.Button, cmdbtn, server.IconSize)
row := &Row{
Row: srow,
ctrl: ctrl,
cmdbtn: cmdbtn,
}
cmdbtn.Connect("clicked", row.ShowCommander)
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.Row.Reset()
r.Session = nil
r.cmder = nil
r.cmdbtn.Hide()
}
// RemoveSession removes itself from the session list.
@ -162,6 +192,16 @@ func (r *Row) SetSession(ses cchat.Session) {
r.SetLabelUnsafe(ses.Name())
r.SetIconer(ses)
// 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.ctrl.GetService(), cmd)
// Show the command button if the session actually supports the commander.
if r.cmder != nil {
r.cmdbtn.Show()
}
// Bind extra menu items before loading. These items won't be clickable
// during loading.
r.SetNormalExtraMenu([]menu.Item{
@ -183,3 +223,12 @@ func (r *Row) RowSelected(server *server.ServerRow, smsg cchat.ServerMessage) {
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() {
if r.cmder == nil {
return
}
r.cmder.ShowDialog()
}