From d147fc7579bf77bf6f3ace669c8ade68be89d1ca Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 21 Apr 2018 19:41:19 +0300 Subject: Improve tab completion system --- interface/ui.go | 2 +- lib/util/lcp.go | 38 +++++++++++++ ui/room-view.go | 120 +++++++++++++++++++++++++++++++-------- ui/view-main.go | 26 +-------- ui/widget/advanced-inputfield.go | 25 +++++--- 5 files changed, 154 insertions(+), 57 deletions(-) create mode 100644 lib/util/lcp.go diff --git a/interface/ui.go b/interface/ui.go index cdd1927..83fa7da 100644 --- a/interface/ui.go +++ b/interface/ui.go @@ -77,7 +77,7 @@ type RoomView interface { SaveHistory(dir string) error LoadHistory(matrix MatrixContainer, dir string) (int, error) - SetStatus(status string) + SetCompletions(completions []string) SetTyping(users []string) UpdateUserList() diff --git a/lib/util/lcp.go b/lib/util/lcp.go new file mode 100644 index 0000000..2e2e690 --- /dev/null +++ b/lib/util/lcp.go @@ -0,0 +1,38 @@ +// Licensed under the GNU Free Documentation License 1.2 +// https://www.gnu.org/licenses/old-licenses/fdl-1.2.en.html +// +// Source: https://rosettacode.org/wiki/Longest_common_prefix#Go + +package util + +func LongestCommonPrefix(list []string) string { + // Special cases first + switch len(list) { + case 0: + return "" + case 1: + return list[0] + } + + // LCP of min and max (lexigraphically) + // is the LCP of the whole set. + min, max := list[0], list[0] + for _, s := range list[1:] { + switch { + case s < min: + min = s + case s > max: + max = s + } + } + + for i := 0; i < len(min) && i < len(max); i++ { + if min[i] != max[i] { + return min[:i] + } + } + + // In the case where lengths are not equal but all bytes + // are equal, min is the answer ("foo" < "foobar"). + return min +} diff --git a/ui/room-view.go b/ui/room-view.go index 5268682..332605b 100644 --- a/ui/room-view.go +++ b/ui/room-view.go @@ -19,11 +19,14 @@ package ui import ( "fmt" "path/filepath" + "sort" "strconv" "strings" "time" + "github.com/mattn/go-runewidth" "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/lib/util" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/messages" "maunium.net/go/gomuks/ui/widget" @@ -41,6 +44,13 @@ type RoomView struct { ulBorder *widget.Border input *widget.AdvancedInputField Room *rooms.Room + + typing []string + completions struct { + list []string + textCache string + time time.Time + } } func NewRoomView(room *rooms.Room) *RoomView { @@ -58,7 +68,8 @@ func NewRoomView(room *rooms.Room) *RoomView { view.input. SetFieldBackgroundColor(tcell.ColorDefault). SetPlaceholder("Send a message..."). - SetPlaceholderExtColor(tcell.ColorGray) + SetPlaceholderExtColor(tcell.ColorGray). + SetTabCompleteFunc(view.InputTabComplete) view.topic. SetText(strings.Replace(room.GetTopic(), "\n", " ", -1)). @@ -85,13 +96,6 @@ func (view *RoomView) LoadHistory(matrix ifc.MatrixContainer, dir string) (int, return view.MessageView().LoadHistory(matrix, view.logPath(dir)) } -func (view *RoomView) SetTabCompleteFunc(fn func(room *RoomView, text string, cursorOffset int) string) *RoomView { - view.input.SetTabCompleteFunc(func(text string, cursorOffset int) string { - return fn(view, text, cursorOffset) - }) - return view -} - func (view *RoomView) SetInputCapture(fn func(room *RoomView, event *tcell.EventKey) *tcell.EventKey) *RoomView { view.input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { return fn(view, event) @@ -151,6 +155,30 @@ const ( StaticVerticalSpace = TopicBarHeight + StatusBarHeight + InputBarHeight ) +func (view *RoomView) GetStatus() string { + var buf strings.Builder + + if len(view.completions.list) > 0 { + if view.completions.textCache != view.input.GetText() || view.completions.time.Add(10 * time.Second).Before(time.Now()) { + view.completions.list = []string{} + } else { + buf.WriteString(strings.Join(view.completions.list, ", ")) + buf.WriteString(" - ") + } + } + + if len(view.typing) == 1 { + buf.WriteString("Typing: " + view.typing[0]) + buf.WriteString(" - ") + } else if len(view.typing) > 1 { + fmt.Fprintf(&buf, + "Typing: %s and %s - ", + strings.Join(view.typing[:len(view.typing)-1], ", "), view.typing[len(view.typing)-1]) + } + + return strings.TrimSuffix(buf.String(), " - ") +} + func (view *RoomView) Draw(screen tcell.Screen) { x, y, width, height := view.GetInnerRect() if width <= 0 || height <= 0 { @@ -185,14 +213,17 @@ func (view *RoomView) Draw(screen tcell.Screen) { view.Box.Draw(screen) view.topic.Draw(screen) view.content.Draw(screen) + view.status.SetText(view.GetStatus()) view.status.Draw(screen) view.input.Draw(screen) view.ulBorder.Draw(screen) view.userList.Draw(screen) } -func (view *RoomView) SetStatus(status string) { - view.status.SetText(status) +func (view *RoomView) SetCompletions(completions []string) { + view.completions.list = completions + view.completions.textCache = view.input.GetText() + view.completions.time = time.Now() } func (view *RoomView) SetTyping(users []string) { @@ -202,31 +233,74 @@ func (view *RoomView) SetTyping(users []string) { users[index] = member.DisplayName } } - if len(users) == 0 { - view.status.SetText("") - } else if len(users) < 2 { - view.status.SetText("Typing: " + strings.Join(users, " and ")) - } else { - view.status.SetText(fmt.Sprintf( - "Typing: %s and %s", - strings.Join(users[:len(users)-1], ", "), users[len(users)-1])) - } + view.typing = users +} + +type completion struct { + displayName string + id string } -func (view *RoomView) AutocompleteUser(existingText string) (completions []*rooms.Member) { +func (view *RoomView) AutocompleteUser(existingText string) (completions []completion) { textWithoutPrefix := existingText if strings.HasPrefix(existingText, "@") { textWithoutPrefix = existingText[1:] } for _, user := range view.Room.GetMembers() { - if strings.HasPrefix(user.DisplayName, textWithoutPrefix) || - strings.HasPrefix(user.UserID, existingText) { - completions = append(completions, user) + if user.DisplayName == textWithoutPrefix || user.UserID == existingText { + // Exact match, return that. + return []completion{{user.DisplayName, user.UserID}} + } + + if strings.HasPrefix(user.DisplayName, textWithoutPrefix) || strings.HasPrefix(user.UserID, existingText) { + completions = append(completions, completion{user.DisplayName, user.UserID}) } } return } +func (view *RoomView) AutocompleteRoom(existingText string) (completions []completion) { + // TODO - This was harder than I expected. + + return []completion{} +} + +func (view *RoomView) InputTabComplete(text string, cursorOffset int) { + str := runewidth.Truncate(text, cursorOffset, "") + word := findWordToTabComplete(str) + startIndex := len(str) - len(word) + + var strCompletions []string + var strCompletion string + + completions := view.AutocompleteUser(word) + completions = append(completions, view.AutocompleteRoom(word)...) + + if len(completions) == 1 { + completion := completions[0] + strCompletion = fmt.Sprintf("[%s](https://matrix.to/#/%s)", completion.displayName, completion.id) + if startIndex == 0 { + strCompletion = strCompletion + ": " + } + } else if len(completions) > 1 { + for _, completion := range completions { + strCompletions = append(strCompletions, completion.displayName) + } + } + + if len(strCompletions) > 0 { + strCompletion = util.LongestCommonPrefix(strCompletions) + sort.Sort(sort.StringSlice(strCompletions)) + } + + if len(strCompletion) > 0 { + text = str[0:startIndex] + strCompletion + text[len(str):] + } + + view.input.SetTextAndMoveCursor(text) + view.SetCompletions(strCompletions) +} + func (view *RoomView) MessageView() *MessageView { return view.content } diff --git a/ui/view-main.go b/ui/view-main.go index c3e94a5..3034102 100644 --- a/ui/view-main.go +++ b/ui/view-main.go @@ -23,7 +23,6 @@ import ( "time" "unicode" - "github.com/mattn/go-runewidth" "maunium.net/go/gomatrix" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" @@ -101,28 +100,6 @@ func findWordToTabComplete(text string) string { return output } -func (view *MainView) InputTabComplete(roomView *RoomView, text string, cursorOffset int) string { - str := runewidth.Truncate(text, cursorOffset, "") - word := findWordToTabComplete(str) - - userCompletions := roomView.AutocompleteUser(word) - if len(userCompletions) == 1 { - startIndex := len(str) - len(word) - member := userCompletions[0] - completion := fmt.Sprintf("[%s](https://matrix.to/#/%s)", member.DisplayName, member.UserID) - if startIndex == 0 { - completion = completion + ": " - } - text = str[0:startIndex] + completion + text[len(str):] - } else if len(userCompletions) > 1 && len(userCompletions) <= 5 { - // roomView.SetStatus(fmt.Sprintf("Completions: %s", strings.Join(userCompletions, ", "))) - } else if len(userCompletions) > 5 { - roomView.SetStatus("Over 5 completion options.") - } - - return text -} - func (view *MainView) InputSubmit(roomView *RoomView, text string) { if len(text) == 0 { return @@ -147,7 +124,7 @@ func (view *MainView) sendTempMessage(roomView *RoomView, tempMessage ifc.Messag eventID, err := view.matrix.SendMarkdownMessage(roomView.Room.ID, tempMessage.Type(), text) if err != nil { tempMessage.SetState(ifc.MessageStateFailed) - roomView.SetStatus(fmt.Sprintf("Failed to send message: %s", err)) + roomView.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err)) } else { roomView.MessageView().UpdateMessageID(tempMessage, eventID) } @@ -314,7 +291,6 @@ func (view *MainView) addRoom(index int, room string) { roomView := NewRoomView(roomStore). SetInputSubmitFunc(view.InputSubmit). SetInputChangedFunc(view.InputChanged). - SetTabCompleteFunc(view.InputTabComplete). SetInputCapture(view.KeyEventHandler). SetMouseCapture(view.MouseEventHandler) view.rooms[room] = roomView diff --git a/ui/widget/advanced-inputfield.go b/ui/widget/advanced-inputfield.go index cbe9dcc..a084dcf 100644 --- a/ui/widget/advanced-inputfield.go +++ b/ui/widget/advanced-inputfield.go @@ -84,7 +84,7 @@ type AdvancedInputField struct { done func(tcell.Key) // An optional function which is called when the user presses tab. - tabComplete func(text string, cursorOffset int) string + tabComplete func(text string, cursorOffset int) } // NewAdvancedInputField returns a new input field. @@ -107,6 +107,20 @@ func (field *AdvancedInputField) SetText(text string) *AdvancedInputField { return field } +// SetTextAndMoveCursor sets the current text of the input field and moves the cursor with the width difference. +func (field *AdvancedInputField) SetTextAndMoveCursor(text string) *AdvancedInputField { + oldWidth := runewidth.StringWidth(field.text) + field.text = text + newWidth := runewidth.StringWidth(field.text) + if oldWidth != newWidth { + field.cursorOffset += newWidth - oldWidth + } + if field.changed != nil { + field.changed(field.text) + } + return field +} + // GetText returns the current text of the input field. func (field *AdvancedInputField) GetText() string { return field.text @@ -212,7 +226,7 @@ func (field *AdvancedInputField) SetDoneFunc(handler func(key tcell.Key)) *Advan return field } -func (field *AdvancedInputField) SetTabCompleteFunc(handler func(text string, cursorOffset int) string) *AdvancedInputField { +func (field *AdvancedInputField) SetTabCompleteFunc(handler func(text string, cursorOffset int)) *AdvancedInputField { field.tabComplete = handler return field } @@ -443,12 +457,7 @@ func (field *AdvancedInputField) RemovePreviousCharacter() { func (field *AdvancedInputField) TriggerTabComplete() bool { if field.tabComplete != nil { - oldWidth := runewidth.StringWidth(field.text) - field.text = field.tabComplete(field.text, field.cursorOffset) - newWidth := runewidth.StringWidth(field.text) - if oldWidth != newWidth { - field.cursorOffset += newWidth - oldWidth - } + field.tabComplete(field.text, field.cursorOffset) return true } return false -- cgit v1.2.3