From d1d72888799c7ff5963840ec6ed3f10b4cddc775 Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Tue, 30 Jun 2020 18:09:22 -0700 Subject: [PATCH] Added completion into commander --- .../messages/input/completion/completion.go | 99 +-------- internal/ui/messages/input/input.go | 7 +- .../ui/primitives/completion/completer.go | 102 +++++++++ internal/ui/primitives/completion/utils.go | 125 +++++++++++ .../ui/primitives/scrollinput/scrollinput.go | 27 +++ .../ui/service/session/commander/buffer.go | 62 ++++++ .../ui/service/session/commander/commander.go | 201 +++++++++--------- internal/ui/style.css | 4 + 8 files changed, 432 insertions(+), 195 deletions(-) create mode 100644 internal/ui/primitives/completion/completer.go create mode 100644 internal/ui/primitives/completion/utils.go create mode 100644 internal/ui/primitives/scrollinput/scrollinput.go create mode 100644 internal/ui/service/session/commander/buffer.go diff --git a/internal/ui/messages/input/completion/completion.go b/internal/ui/messages/input/completion/completion.go index 2e8568b..7b693c0 100644 --- a/internal/ui/messages/input/completion/completion.go +++ b/internal/ui/messages/input/completion/completion.go @@ -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) } diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go index 7d89791..06d4674 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -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) diff --git a/internal/ui/primitives/completion/completer.go b/internal/ui/primitives/completion/completer.go new file mode 100644 index 0000000..9493c3a --- /dev/null +++ b/internal/ui/primitives/completion/completer.go @@ -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) + } + } +} diff --git a/internal/ui/primitives/completion/utils.go b/internal/ui/primitives/completion/utils.go new file mode 100644 index 0000000..057c973 --- /dev/null +++ b/internal/ui/primitives/completion/utils.go @@ -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 +} diff --git a/internal/ui/primitives/scrollinput/scrollinput.go b/internal/ui/primitives/scrollinput/scrollinput.go new file mode 100644 index 0000000..1b01bf4 --- /dev/null +++ b/internal/ui/primitives/scrollinput/scrollinput.go @@ -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 +} diff --git a/internal/ui/service/session/commander/buffer.go b/internal/ui/service/session/commander/buffer.go new file mode 100644 index 0000000..3fffded --- /dev/null +++ b/internal/ui/service/session/commander/buffer.go @@ -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) +} diff --git a/internal/ui/service/session/commander/commander.go b/internal/ui/service/session/commander/commander.go index eed3c58..778e17a 100644 --- a/internal/ui/service/session/commander/commander.go +++ b/internal/ui/service/session/commander/commander.go @@ -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] } diff --git a/internal/ui/style.css b/internal/ui/style.css index 8dd02c0..f126fd6 100644 --- a/internal/ui/style.css +++ b/internal/ui/style.css @@ -11,3 +11,7 @@ headerbar { padding: 0; } popover > box { margin: 6px; } + +.command-buffer, .command-input { + padding: 8px 12px; +}