diff --git a/go.mod b/go.mod index 7cdbc66..0e7137f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4b8727d..328323c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index 214b0cd..ba1a045 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -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" diff --git a/internal/ui/messages/autoscroll/autoscroll.go b/internal/ui/primitives/autoscroll/autoscroll.go similarity index 100% rename from internal/ui/messages/autoscroll/autoscroll.go rename to internal/ui/primitives/autoscroll/autoscroll.go diff --git a/internal/ui/primitives/buttonoverlay/buttonoverlay.go b/internal/ui/primitives/buttonoverlay/buttonoverlay.go new file mode 100644 index 0000000..bbca90c --- /dev/null +++ b/internal/ui/primitives/buttonoverlay/buttonoverlay.go @@ -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)) +} diff --git a/internal/ui/service/header.go b/internal/ui/service/header.go index e9fc399..67a5591 100644 --- a/internal/ui/service/header.go +++ b/internal/ui/service/header.go @@ -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} } diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 09412f5..0d624aa 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -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() } diff --git a/internal/ui/service/session/commander/commander.go b/internal/ui/service/session/commander/commander.go new file mode 100644 index 0000000..eed3c58 --- /dev/null +++ b/internal/ui/service/session/commander/commander.go @@ -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(), + ) + }) + }() +} diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index 00d524c..77ca377 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -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. diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index 0ddc9a9..ccd60fc 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -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() +}