Added completion into commander

This commit is contained in:
diamondburned (Forefront) 2020-06-30 18:09:22 -07:00
parent 47e3e67b95
commit d1d7288879
8 changed files with 432 additions and 195 deletions

View File

@ -4,10 +4,10 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat/utils/split"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
@ -77,7 +77,7 @@ func New(text *gtk.TextView) *View {
buffer: buffer,
}
text.Connect("key-press-event", v.inputKeyDown)
text.Connect("key-press-event", completion.KeyDownHandler(list, text.GrabFocus))
buffer.Connect("changed", func() {
// Clear the list first.
v.Clear()
@ -86,22 +86,9 @@ func New(text *gtk.TextView) *View {
})
list.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
// Get iter for word replacing.
start, end := getWordIters(v.buffer, v.offset)
// Get the selected word.
i := r.GetIndex()
entry := v.entries[i]
// Replace the word.
v.buffer.Delete(start, end)
v.buffer.Insert(start, entry.Raw+" ")
// Clear the list.
completion.SwapWord(v.buffer, v.entries[r.GetIndex()].Raw, v.offset)
v.Clear()
// Reset the focus.
v.text.GrabFocus()
v.text.GrabFocus() // TODO: remove, maybe not needed
})
return v
@ -207,81 +194,5 @@ func (v *View) Run() {
}
func (v *View) getInputState() (string, int) {
// obtain current state
mark := v.buffer.GetInsert()
iter := v.buffer.GetIterAtMark(mark)
// obtain the input string and the current cursor position
start, end := v.buffer.GetBounds()
text, _ := v.buffer.GetText(start, end, true)
offset := iter.GetOffset()
return text, offset
}
// inputKeyDown handles keypresses such as Enter and movements.
func (v *View) inputKeyDown(_ *gtk.TextView, ev *gdk.Event) (stop bool) {
// Do we have any entries? If not, don't bother.
if len(v.entries) == 0 {
// passthrough.
return false
}
var evKey = gdk.EventKeyNewFromEvent(ev)
var key = evKey.KeyVal()
switch key {
// Did we press an arrow key?
case gdk.KEY_Up, gdk.KEY_Down:
// Yes, start moving the list up and down.
i := v.List.GetSelectedRow().GetIndex()
switch key {
case gdk.KEY_Up:
if i--; i < 0 {
i = len(v.entries) - 1
}
case gdk.KEY_Down:
if i++; i >= len(v.entries) {
i = 0
}
}
row := v.List.GetRowAtIndex(i)
row.GrabFocus() // scroll
v.List.SelectRow(row) // select
v.text.GrabFocus() // unfocus
// Did we press the Enter or Tab key?
case gdk.KEY_Return, gdk.KEY_Tab:
// Activate the current row.
row := v.List.GetSelectedRow()
row.Activate()
default:
// don't passthrough events if none matches.
return false
}
return true
}
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)
if !ok {
start = buf.GetStartIter()
}
// Seek forwards for space or end-of-line:
_, end, ok = iter.ForwardSearch(" ", gtk.TEXT_SEARCH_TEXT_ONLY, nil)
if !ok {
end = buf.GetEndIter()
}
return
return completion.State(v.buffer)
}

View File

@ -4,6 +4,7 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
@ -87,11 +88,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
buf, _ := text.GetBuffer()
sw, _ := gtk.ScrolledWindowNew(nil, nil)
sw.Add(text)
sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
sw.SetProperty("propagate-natural-height", true)
sw.SetProperty("max-content-height", 150)
sw := scrollinput.NewV(text, 150)
sw.Show()
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)

View File

@ -0,0 +1,102 @@
package completion
import (
"github.com/diamondburned/cchat/utils/split"
"github.com/gotk3/gotk3/gtk"
)
type Completeable interface {
Update([]string, int) []gtk.IWidget
Word(i int) string
}
type Completer struct {
ctrl Completeable
Input *gtk.TextView
List *gtk.ListBox
Popover *gtk.Popover
Words []string
Index int
Cursor int
}
func WrapCompleter(input *gtk.TextView, ctrl Completeable) {
NewCompleter(input, ctrl)
}
func NewCompleter(input *gtk.TextView, ctrl Completeable) *Completer {
l, _ := gtk.ListBoxNew()
l.Show()
p := NewPopover(input)
p.Add(l)
c := &Completer{
Input: input,
List: l,
Popover: p,
ctrl: ctrl,
}
input.Connect("key-press-event", KeyDownHandler(l, input.GrabFocus))
ibuf, _ := input.GetBuffer()
ibuf.Connect("changed", func() {
t, v := State(ibuf)
c.Cursor = v
c.Words, c.Index = split.SpaceIndexed(t, v)
c.complete()
})
l.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) {
SwapWord(ibuf, ctrl.Word(r.GetIndex()), c.Cursor)
c.clear()
c.Popover.Popdown()
input.GrabFocus()
})
return c
}
func (c *Completer) clear() {
var children = c.List.GetChildren()
if children.Length() == 0 {
return
}
children.Foreach(func(i interface{}) {
w := i.(gtk.IWidget).ToWidget()
c.List.Remove(w)
w.Destroy()
})
}
func (c *Completer) complete() {
c.clear()
var widgets []gtk.IWidget
if len(c.Words) > 0 {
widgets = c.ctrl.Update(c.Words, c.Index)
}
if len(widgets) > 0 {
c.Popover.SetPointingTo(CursorRect(c.Input))
c.Popover.Popup()
} else {
c.Popover.Popdown()
}
for i, widget := range widgets {
r, _ := gtk.ListBoxRowNew()
r.Add(widget)
r.Show()
c.List.Add(r)
if i == 0 {
c.List.SelectRow(r)
}
}
}

View File

@ -0,0 +1,125 @@
package completion
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
var popoverCSS = primitives.PrepareCSS(`
popover {
border-radius: 0;
}
`)
const (
MinPopoverWidth = 250
)
func NewPopover(relto gtk.IWidget) *gtk.Popover {
p, _ := gtk.PopoverNew(relto)
p.SetSizeRequest(MinPopoverWidth, -1)
p.SetModal(false)
p.SetPosition(gtk.POS_TOP)
primitives.AttachCSS(p, popoverCSS)
return p
}
type KeyDownHandlerFn = func(gtk.IWidget, *gdk.Event) bool
func KeyDownHandler(l *gtk.ListBox, focus func()) KeyDownHandlerFn {
return func(w gtk.IWidget, ev *gdk.Event) bool {
// Do we have any entries? If not, don't bother.
var length = int(l.GetChildren().Length())
if length == 0 {
// passthrough.
return false
}
var evKey = gdk.EventKeyNewFromEvent(ev)
var key = evKey.KeyVal()
switch key {
// Did we press an arrow key?
case gdk.KEY_Up, gdk.KEY_Down:
// Yes, start moving the list up and down.
i := l.GetSelectedRow().GetIndex()
switch key {
case gdk.KEY_Up:
if i--; i < 0 {
i = length - 1
}
case gdk.KEY_Down:
if i++; i >= length {
i = 0
}
}
row := l.GetRowAtIndex(i)
row.GrabFocus() // scroll
l.SelectRow(row) // select
focus() // unfocus
// Did we press the Enter or Tab key?
case gdk.KEY_Return, gdk.KEY_Tab:
// Activate the current row.
l.GetSelectedRow().Activate()
focus()
default:
// passthrough events if none matches.
return false
}
return true
}
}
func SwapWord(b *gtk.TextBuffer, word string, offset int) {
// Get iter for word replacing.
start, end := GetWordIters(b, offset)
b.Delete(start, end)
b.Insert(start, word+" ")
}
func CursorRect(i *gtk.TextView) gdk.Rectangle {
r, _ := i.GetCursorLocations(nil)
x, _ := i.BufferToWindowCoords(gtk.TEXT_WINDOW_WIDGET, r.GetX(), r.GetY())
r.SetX(x)
r.SetY(0)
return *r
}
func State(buf *gtk.TextBuffer) (string, int) {
// 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
}
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)
if !ok {
start = buf.GetStartIter()
}
// Seek forwards for space or end-of-line:
_, end, ok = iter.ForwardSearch(" ", gtk.TEXT_SEARCH_TEXT_ONLY, nil)
if !ok {
end = buf.GetEndIter()
}
return
}

View File

@ -0,0 +1,27 @@
package scrollinput
import "github.com/gotk3/gotk3/gtk"
func NewV(text *gtk.TextView, maxHeight int) *gtk.ScrolledWindow {
// Wrap mode needed since we're not doing horizontal scrolling.
text.SetWrapMode(gtk.WRAP_WORD_CHAR)
sw, _ := gtk.ScrolledWindowNew(nil, nil)
sw.Add(text)
sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
sw.SetProperty("propagate-natural-height", true)
sw.SetProperty("max-content-height", maxHeight)
return sw
}
func NewH(text *gtk.TextView) *gtk.ScrolledWindow {
text.SetHExpand(true)
sw, _ := gtk.ScrolledWindowNew(nil, nil)
sw.Add(text)
sw.SetPolicy(gtk.POLICY_EXTERNAL, gtk.POLICY_NEVER)
sw.SetProperty("propagate-natural-width", true)
return sw
}

View File

@ -0,0 +1,62 @@
package commander
import (
"fmt"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/gotk3/gotk3/gtk"
)
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",
})
b.CreateTag("system", map[string]interface{}{
"foreground": "#808080",
})
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")
}
// WriteSystem is not thread-safe.
func (b *Buffer) WriteSystem(bytes []byte) {
b.InsertWithTagByName(b.GetEndIter(), string(bytes), "system")
}
// Printlnf is not thread-safe.
func (b *Buffer) Printlnf(f string, v ...interface{}) {
b.WriteSystem([]byte(fmt.Sprintf(f+"\n", v...)))
}
// Write is thread-safe.
func (b *Buffer) Write(bytes []byte) (int, error) {
gts.ExecAsync(func() { b.Insert(b.GetEndIter(), string(bytes)) })
return len(bytes), nil
}
func (b *Buffer) ShowDialog() {
SpawnDialog(b)
}

View File

@ -9,62 +9,15 @@ import (
"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/diamondburned/cchat-gtk/internal/ui/primitives/completion"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
"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(`
var monospace = primitives.PrepareCSS(`
* {
font-family: monospace;
border-radius: 0;
@ -73,9 +26,15 @@ var entryCSS = primitives.PrepareCSS(`
type Session struct {
*gtk.Box
words []string
cmder cchat.Commander
buffer *Buffer
cmplt *completer
inputbuf *gtk.TextBuffer
// words []string
// index int
}
func SpawnDialog(buf *Buffer) {
@ -98,69 +57,73 @@ func SpawnDialog(buf *Buffer) {
}
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()
view, _ := gtk.TextViewNewWithBuffer(buf.TextBuffer)
view.SetEditable(false)
view.SetProperty("monospace", true)
view.SetPixelsAboveLines(1)
view.SetWrapMode(gtk.WRAP_WORD_CHAR)
view.Show()
s := autoscroll.NewScrolledWindow()
s.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
s.Add(v)
s.Show()
scroll := autoscroll.NewScrolledWindow()
scroll.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
scroll.Add(view)
scroll.Show()
i, _ := gtk.EntryNew()
primitives.AttachCSS(i, entryCSS)
i.Show()
input, _ := gtk.TextViewNew()
input.SetSizeRequest(-1, 35) // magic height 35px
primitives.AttachCSS(input, monospace)
input.Show()
inputbuf, _ := input.GetBuffer()
inputscroll := scrollinput.NewH(input)
inputscroll.Show()
sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL)
sep.Show()
b, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
b.PackStart(s, true, true, 0)
b.PackStart(i, false, false, 0)
b.PackStart(scroll, true, true, 0)
b.PackStart(sep, false, false, 0)
b.PackStart(inputscroll, false, false, 0)
session := &Session{
Box: b,
cmder: cmder,
buffer: buf,
Box: b,
cmder: cmder,
buffer: buf,
cmplt: newCompleter(input, cmder),
inputbuf: inputbuf,
}
i.Connect("activate", session.inputActivate)
// Split words on typing to provide live errors.
i.Connect("changed", func(i *gtk.Entry) {
t, _ := i.GetText()
input.Connect("key-press-event", session.inputActivate)
input.GrabFocus()
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()
primitives.AddClass(b, "commander")
primitives.AddClass(view, "command-buffer")
primitives.AddClass(input, "command-input")
return session
}
func (s *Session) inputActivate(e *gtk.Entry) {
// If the input is empty, then ignore.
if len(s.words) == 0 {
return
func (s *Session) inputActivate(v *gtk.TextView, ev *gdk.Event) bool {
// If the keypress is not enter, then ignore.
if kev := gdk.EventKeyNewFromEvent(ev); kev.KeyVal() != gdk.KEY_Return {
return false
}
r, err := s.cmder.RunCommand(s.words)
// If the input is empty, then ignore.
if len(s.cmplt.Words) == 0 {
return true
}
r, err := s.cmder.RunCommand(s.cmplt.Words)
if err != nil {
s.buffer.WriteError(err)
return
return true
}
// Clear the entry.
e.SetText("")
s.inputbuf.Delete(s.inputbuf.GetBounds())
var then = time.Now()
s.buffer.Printlnf("%s: Running command...", then.Format(time.Kitchen))
@ -182,4 +145,50 @@ func (s *Session) inputActivate(e *gtk.Entry) {
)
})
}()
return true
}
type completer struct {
*completion.Completer
completer cchat.CommandCompleter
choices []string
}
func newCompleter(input *gtk.TextView, v cchat.Commander) *completer {
completer := &completer{}
completer.Completer = completion.NewCompleter(input, completer)
c, ok := v.(cchat.CommandCompleter)
if ok {
completer.completer = c
}
return completer
}
func (c *completer) Update(words []string, offset int) []gtk.IWidget {
if c.completer == nil {
return nil
}
c.choices = c.completer.CompleteCommand(words, offset)
var widgets = make([]gtk.IWidget, 0, len(c.choices))
for _, choice := range c.choices {
l, _ := gtk.LabelNew(choice)
l.SetXAlign(0)
l.SetEllipsize(pango.ELLIPSIZE_END)
primitives.AttachCSS(l, monospace)
l.Show()
widgets = append(widgets, l)
}
return widgets
}
func (c *completer) Word(i int) string {
return c.choices[i]
}

View File

@ -11,3 +11,7 @@ headerbar { padding: 0; }
popover > box {
margin: 6px;
}
.command-buffer, .command-input {
padding: 8px 12px;
}