From eda2b575f06e72040ebf82d24a7ec1ac84b7948c Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 9 Apr 2018 23:45:54 +0300 Subject: Refactor UI to use interfaces everywhere --- ui/widget/message-view.go | 354 ---------------------------------------------- ui/widget/room-list.go | 140 ------------------ ui/widget/room-view.go | 275 ----------------------------------- ui/widget/util.go | 14 +- 4 files changed, 7 insertions(+), 776 deletions(-) delete mode 100644 ui/widget/message-view.go delete mode 100644 ui/widget/room-list.go delete mode 100644 ui/widget/room-view.go (limited to 'ui/widget') diff --git a/ui/widget/message-view.go b/ui/widget/message-view.go deleted file mode 100644 index f0bdbad..0000000 --- a/ui/widget/message-view.go +++ /dev/null @@ -1,354 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2018 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -package widget - -import ( - "encoding/gob" - "fmt" - "math" - "os" - "time" - - "github.com/gdamore/tcell" - "maunium.net/go/gomuks/ui/debug" - "maunium.net/go/gomuks/ui/types" - "maunium.net/go/tview" -) - -type MessageView struct { - *tview.Box - - ScrollOffset int - MaxSenderWidth int - DateFormat string - TimestampFormat string - TimestampWidth int - LoadingMessages bool - - widestSender int - prevWidth int - prevHeight int - prevMsgCount int - - messageIDs map[string]*types.Message - messages []*types.Message - - textBuffer []string - metaBuffer []types.MessageMeta -} - -func NewMessageView() *MessageView { - return &MessageView{ - Box: tview.NewBox(), - MaxSenderWidth: 15, - DateFormat: "January _2, 2006", - TimestampFormat: "15:04:05", - TimestampWidth: 8, - ScrollOffset: 0, - - messages: make([]*types.Message, 0), - messageIDs: make(map[string]*types.Message), - textBuffer: make([]string, 0), - metaBuffer: make([]types.MessageMeta, 0), - - widestSender: 5, - prevWidth: -1, - prevHeight: -1, - prevMsgCount: -1, - } -} - -func (view *MessageView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) *types.Message { - return types.NewMessage(id, sender, msgtype, text, - timestamp.Format(view.TimestampFormat), - timestamp.Format(view.DateFormat), - GetHashColor(sender)) -} - -func (view *MessageView) SaveHistory(path string) error { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600) - if err != nil { - return err - } - defer file.Close() - - enc := gob.NewEncoder(file) - err = enc.Encode(view.messages) - if err != nil { - return err - } - - return nil -} - -func (view *MessageView) LoadHistory(path string) (int, error) { - file, err := os.OpenFile(path, os.O_RDONLY, 0600) - if err != nil { - if os.IsNotExist(err) { - return 0, nil - } - return -1, err - } - defer file.Close() - - dec := gob.NewDecoder(file) - err = dec.Decode(&view.messages) - if err != nil { - return -1, err - } - - for _, message := range view.messages { - view.updateWidestSender(message.Sender) - } - - return len(view.messages), nil -} - -func (view *MessageView) updateWidestSender(sender string) { - if len(sender) > view.widestSender { - view.widestSender = len(sender) - if view.widestSender > view.MaxSenderWidth { - view.widestSender = view.MaxSenderWidth - } - } -} - -type MessageDirection int - -const ( - AppendMessage MessageDirection = iota - PrependMessage - IgnoreMessage -) - -func (view *MessageView) UpdateMessageID(message *types.Message, newID string) { - delete(view.messageIDs, message.ID) - message.ID = newID - view.messageIDs[message.ID] = message -} - -func (view *MessageView) AddMessage(message *types.Message, direction MessageDirection) { - if message == nil { - return - } - - msg, messageExists := view.messageIDs[message.ID] - if msg != nil && messageExists { - message.CopyTo(msg) - message = msg - direction = IgnoreMessage - } - - view.updateWidestSender(message.Sender) - - _, _, width, _ := view.GetInnerRect() - width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap - message.CalculateBuffer(width) - - if direction == AppendMessage { - if view.ScrollOffset > 0 { - view.ScrollOffset += message.Height() - } - view.messages = append(view.messages, message) - view.appendBuffer(message) - } else if direction == PrependMessage { - view.messages = append([]*types.Message{message}, view.messages...) - } - - view.messageIDs[message.ID] = message -} - -func (view *MessageView) appendBuffer(message *types.Message) { - if len(view.metaBuffer) > 0 { - prevMeta := view.metaBuffer[len(view.metaBuffer)-1] - if prevMeta != nil && prevMeta.GetDate() != message.Date { - view.textBuffer = append(view.textBuffer, fmt.Sprintf("Date changed to %s", message.Date)) - view.metaBuffer = append(view.metaBuffer, &types.BasicMeta{TextColor: tcell.ColorGreen}) - } - } - - view.textBuffer = append(view.textBuffer, message.Buffer()...) - for range message.Buffer() { - view.metaBuffer = append(view.metaBuffer, message) - } - view.prevMsgCount++ -} - -func (view *MessageView) recalculateBuffers() { - _, _, width, height := view.GetInnerRect() - - width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap - recalculateMessageBuffers := width != view.prevWidth - if height != view.prevHeight || recalculateMessageBuffers || len(view.messages) != view.prevMsgCount { - view.textBuffer = []string{} - view.metaBuffer = []types.MessageMeta{} - view.prevMsgCount = 0 - for _, message := range view.messages { - if recalculateMessageBuffers { - message.CalculateBuffer(width) - } - view.appendBuffer(message) - } - view.prevHeight = height - view.prevWidth = width - } -} - -const PaddingAtTop = 5 - -func (view *MessageView) AddScrollOffset(diff int) { - _, _, _, height := view.GetInnerRect() - - totalHeight := len(view.textBuffer) - if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop { - view.ScrollOffset = totalHeight - height + PaddingAtTop - } else { - view.ScrollOffset += diff - } - - if view.ScrollOffset > totalHeight-height+PaddingAtTop { - view.ScrollOffset = totalHeight - height + PaddingAtTop - } - if view.ScrollOffset < 0 { - view.ScrollOffset = 0 - } -} - -func (view *MessageView) Height() int { - _, _, _, height := view.GetInnerRect() - return height -} - -func (view *MessageView) TotalHeight() int { - return len(view.textBuffer) -} - -func (view *MessageView) IsAtTop() bool { - _, _, _, height := view.GetInnerRect() - totalHeight := len(view.textBuffer) - return view.ScrollOffset >= totalHeight-height+PaddingAtTop -} - -const ( - TimestampSenderGap = 1 - SenderSeparatorGap = 1 - SenderMessageGap = 3 -) - -func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) { - char = '│' - style = tcell.StyleDefault - if scrollbarHere { - style = style.Foreground(tcell.ColorGreen) - } - if isTop { - if scrollbarHere { - char = '╥' - } else { - char = '┬' - } - } else if isBottom { - if scrollbarHere { - char = '╨' - } else { - char = '┴' - } - } else if scrollbarHere { - char = '║' - } - return -} - -func (view *MessageView) Draw(screen tcell.Screen) { - view.Box.Draw(screen) - - x, y, _, height := view.GetInnerRect() - view.recalculateBuffers() - - if len(view.textBuffer) == 0 { - writeLineSimple(screen, "It's quite empty in here.", x, y+height) - return - } - - usernameX := x + view.TimestampWidth + TimestampSenderGap - messageX := usernameX + view.widestSender + SenderMessageGap - separatorX := usernameX + view.widestSender + SenderSeparatorGap - - indexOffset := len(view.textBuffer) - view.ScrollOffset - height - if indexOffset <= -PaddingAtTop { - message := "Scroll up to load more messages." - if view.LoadingMessages { - message = "Loading more messages..." - } - writeLineSimpleColor(screen, message, messageX, y, tcell.ColorGreen) - } - - if len(view.textBuffer) != len(view.metaBuffer) { - debug.ExtPrintf("Unexpected text/meta buffer length mismatch: %d != %d.", len(view.textBuffer), len(view.metaBuffer)) - return - } - - var scrollBarHeight, scrollBarPos int - // Black magic (aka math) used to figure out where the scroll bar should be put. - { - viewportHeight := float64(height) - contentHeight := float64(len(view.textBuffer)) - - scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight))) - - scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight)) - } - - var prevMeta types.MessageMeta - firstLine := true - skippedLines := 0 - - for line := 0; line < height; line++ { - index := indexOffset + line - if index < 0 { - skippedLines++ - continue - } else if index >= len(view.textBuffer) { - break - } - - showScrollbar := line-skippedLines >= scrollBarPos-scrollBarHeight && line-skippedLines < scrollBarPos - isTop := firstLine && view.ScrollOffset+height >= len(view.textBuffer) - isBottom := line == height-1 && view.ScrollOffset == 0 - - borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom) - - firstLine = false - - screen.SetContent(separatorX, y+line, borderChar, nil, borderStyle) - - text, meta := view.textBuffer[index], view.metaBuffer[index] - if meta != prevMeta { - if len(meta.GetTimestamp()) > 0 { - writeLineSimpleColor(screen, meta.GetTimestamp(), x, y+line, meta.GetTimestampColor()) - } - if prevMeta == nil || meta.GetSender() != prevMeta.GetSender() { - writeLineColor( - screen, tview.AlignRight, meta.GetSender(), - usernameX, y+line, view.widestSender, - meta.GetSenderColor()) - } - prevMeta = meta - } - writeLineSimpleColor(screen, text, messageX, y+line, meta.GetTextColor()) - } -} diff --git a/ui/widget/room-list.go b/ui/widget/room-list.go deleted file mode 100644 index d2fb543..0000000 --- a/ui/widget/room-list.go +++ /dev/null @@ -1,140 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2018 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -package widget - -import ( - "fmt" - "strconv" - - "github.com/gdamore/tcell" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/tview" -) - -type RoomList struct { - *tview.Box - - indices map[*rooms.Room]int - items []*rooms.Room - selected *rooms.Room - - // The item main text color. - mainTextColor tcell.Color - // The text color for selected items. - selectedTextColor tcell.Color - // The background color for selected items. - selectedBackgroundColor tcell.Color -} - -func NewRoomList() *RoomList { - return &RoomList{ - Box: tview.NewBox(), - indices: make(map[*rooms.Room]int), - items: []*rooms.Room{}, - - mainTextColor: tcell.ColorWhite, - selectedTextColor: tcell.ColorWhite, - selectedBackgroundColor: tcell.ColorDarkGreen, - } -} - -func (list *RoomList) Add(room *rooms.Room) { - list.indices[room] = len(list.items) - list.items = append(list.items, room) - if list.selected == nil { - list.selected = room - } -} - -func (list *RoomList) Remove(room *rooms.Room) { - index, ok := list.indices[room] - if !ok { - return - } - delete(list.indices, room) - list.items = append(list.items[0:index], list.items[index+1:]...) - if len(list.items) == 0 { - list.selected = nil - } -} - -func (list *RoomList) Clear() { - list.indices = make(map[*rooms.Room]int) - list.items = []*rooms.Room{} - list.selected = nil -} - -func (list *RoomList) SetSelected(room *rooms.Room) { - list.selected = room -} - -// Draw draws this primitive onto the screen. -func (list *RoomList) Draw(screen tcell.Screen) { - list.Box.Draw(screen) - - x, y, width, height := list.GetInnerRect() - bottomLimit := y + height - - var offset int - currentItemIndex, hasSelected := list.indices[list.selected] - if hasSelected && currentItemIndex >= height { - offset = currentItemIndex + 1 - height - } - - // Draw the list items. - for index, item := range list.items { - if index < offset { - continue - } - - if y >= bottomLimit { - break - } - - text := item.GetTitle() - - lineWidth := width - - style := tcell.StyleDefault.Foreground(list.mainTextColor) - if item == list.selected { - style = style.Foreground(list.selectedTextColor).Background(list.selectedBackgroundColor) - } - if item.HasNewMessages { - style = style.Bold(true) - } - - if item.UnreadMessages > 0 { - unreadMessageCount := "99+" - if item.UnreadMessages < 100 { - unreadMessageCount = strconv.Itoa(item.UnreadMessages) - } - if item.Highlighted { - unreadMessageCount += "!" - } - unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount) - writeLine(screen, tview.AlignRight, unreadMessageCount, x+lineWidth-6, y, 6, style) - lineWidth -= len(unreadMessageCount) + 1 - } - - writeLine(screen, tview.AlignLeft, text, x, y, lineWidth, style) - - y++ - if y >= bottomLimit { - break - } - } -} diff --git a/ui/widget/room-view.go b/ui/widget/room-view.go deleted file mode 100644 index 4bab779..0000000 --- a/ui/widget/room-view.go +++ /dev/null @@ -1,275 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2018 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -package widget - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/gdamore/tcell" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/types" - "maunium.net/go/tview" -) - -type RoomView struct { - *tview.Box - - topic *tview.TextView - content *MessageView - status *tview.TextView - userList *tview.TextView - ulBorder *Border - input *AdvancedInputField - Room *rooms.Room -} - -func NewRoomView(room *rooms.Room) *RoomView { - view := &RoomView{ - Box: tview.NewBox(), - topic: tview.NewTextView(), - content: NewMessageView(), - status: tview.NewTextView(), - userList: tview.NewTextView(), - ulBorder: NewBorder(), - input: NewAdvancedInputField(), - Room: room, - } - - view.input. - SetFieldBackgroundColor(tcell.ColorDefault). - SetPlaceholder("Send a message..."). - SetPlaceholderExtColor(tcell.ColorGray) - - view.topic. - SetText(strings.Replace(room.GetTopic(), "\n", " ", -1)). - SetBackgroundColor(tcell.ColorDarkGreen) - - view.status.SetBackgroundColor(tcell.ColorDimGray) - - view.userList. - SetDynamicColors(true). - SetWrap(false) - - return view -} - -func (view *RoomView) logPath(dir string) string { - return filepath.Join(dir, fmt.Sprintf("%s.gmxlog", view.Room.ID)) -} - -func (view *RoomView) SaveHistory(dir string) error { - return view.MessageView().SaveHistory(view.logPath(dir)) -} - -func (view *RoomView) LoadHistory(dir string) (int, error) { - return view.MessageView().LoadHistory(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) - }) - return view -} - -func (view *RoomView) SetMouseCapture(fn func(room *RoomView, event *tcell.EventMouse) *tcell.EventMouse) *RoomView { - view.input.SetMouseCapture(func(event *tcell.EventMouse) *tcell.EventMouse { - return fn(view, event) - }) - return view -} - -func (view *RoomView) SetInputSubmitFunc(fn func(room *RoomView, text string)) *RoomView { - view.input.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEnter { - fn(view, view.input.GetText()) - } - }) - return view -} - -func (view *RoomView) SetInputChangedFunc(fn func(room *RoomView, text string)) *RoomView { - view.input.SetChangedFunc(func(text string) { - fn(view, text) - }) - return view -} - -func (view *RoomView) SetInputText(newText string) *RoomView { - view.input.SetText(newText) - return view -} - -func (view *RoomView) GetInputText() string { - return view.input.GetText() -} - -func (view *RoomView) GetInputField() *AdvancedInputField { - return view.input -} - -func (view *RoomView) Focus(delegate func(p tview.Primitive)) { - delegate(view.input) -} - -// Constants defining the size of the room view grid. -const ( - UserListBorderWidth = 1 - UserListWidth = 20 - StaticHorizontalSpace = UserListBorderWidth + UserListWidth - - TopicBarHeight = 1 - StatusBarHeight = 1 - InputBarHeight = 1 - StaticVerticalSpace = TopicBarHeight + StatusBarHeight + InputBarHeight -) - -func (view *RoomView) Draw(screen tcell.Screen) { - x, y, width, height := view.GetInnerRect() - if width <= 0 || height <= 0 { - return - } - - // Calculate actual grid based on view rectangle and constants defined above. - var ( - contentHeight = height - StaticVerticalSpace - contentWidth = width - StaticHorizontalSpace - - userListBorderColumn = x + contentWidth - userListColumn = userListBorderColumn + UserListBorderWidth - - topicRow = y - contentRow = topicRow + TopicBarHeight - statusRow = contentRow + contentHeight - inputRow = statusRow + StatusBarHeight - ) - - // Update the rectangles of all the children. - view.topic.SetRect(x, topicRow, width, TopicBarHeight) - view.content.SetRect(x, contentRow, contentWidth, contentHeight) - view.status.SetRect(x, statusRow, width, StatusBarHeight) - if userListColumn > x { - view.userList.SetRect(userListColumn, contentRow, UserListWidth, contentHeight) - view.ulBorder.SetRect(userListBorderColumn, contentRow, UserListBorderWidth, contentHeight) - } - view.input.SetRect(x, inputRow, width, InputBarHeight) - - // Draw everything - view.Box.Draw(screen) - view.topic.Draw(screen) - view.content.Draw(screen) - 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) SetTyping(users []string) { - for index, user := range users { - member := view.Room.GetMember(user) - if member != nil { - 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])) - } -} - -func (view *RoomView) AutocompleteUser(existingText string) (completions []string) { - textWithoutPrefix := existingText - if strings.HasPrefix(existingText, "@") { - textWithoutPrefix = existingText[1:] - } - for _, user := range view.Room.GetMembers() { - if strings.HasPrefix(user.DisplayName, textWithoutPrefix) { - completions = append(completions, user.DisplayName) - } else if strings.HasPrefix(user.UserID, existingText) { - completions = append(completions, user.UserID) - } - } - return -} - -func (view *RoomView) MessageView() *MessageView { - return view.content -} - -func (view *RoomView) UpdateUserList() { - var joined strings.Builder - var invited strings.Builder - for _, user := range view.Room.GetMembers() { - if user.Membership == "join" { - joined.WriteString(AddHashColor(user.DisplayName)) - joined.WriteRune('\n') - } else if user.Membership == "invite" { - invited.WriteString(AddHashColor(user.DisplayName)) - invited.WriteRune('\n') - } - } - view.userList.Clear() - fmt.Fprintf(view.userList, "%s\n", joined.String()) - if invited.Len() > 0 { - fmt.Fprintf(view.userList, "\nInvited:\n%s", invited.String()) - } -} - -func (view *RoomView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) *types.Message { - member := view.Room.GetMember(sender) - if member != nil { - sender = member.DisplayName - } - return view.content.NewMessage(id, sender, msgtype, text, timestamp) -} - -func (view *RoomView) NewTempMessage(msgtype, text string) *types.Message { - now := time.Now() - id := strconv.FormatInt(now.UnixNano(), 10) - sender := "" - if ownerMember := view.Room.GetSessionOwner(); ownerMember != nil { - sender = ownerMember.DisplayName - } - message := view.NewMessage(id, sender, msgtype, text, now) - message.State = types.MessageStateSending - view.AddMessage(message, AppendMessage) - return message -} - -func (view *RoomView) AddMessage(message *types.Message, direction MessageDirection) { - view.content.AddMessage(message, direction) -} diff --git a/ui/widget/util.go b/ui/widget/util.go index 0888210..bd80903 100644 --- a/ui/widget/util.go +++ b/ui/widget/util.go @@ -22,19 +22,19 @@ import ( "maunium.net/go/tview" ) -func writeLineSimple(screen tcell.Screen, line string, x, y int) { - writeLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault) +func WriteLineSimple(screen tcell.Screen, line string, x, y int) { + WriteLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault) } -func writeLineSimpleColor(screen tcell.Screen, line string, x, y int, color tcell.Color) { - writeLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color)) +func WriteLineSimpleColor(screen tcell.Screen, line string, x, y int, color tcell.Color) { + WriteLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color)) } -func writeLineColor(screen tcell.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) { - writeLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color)) +func WriteLineColor(screen tcell.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) { + WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color)) } -func writeLine(screen tcell.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { +func WriteLine(screen tcell.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { offsetX := 0 if align == tview.AlignRight { offsetX = maxWidth - runewidth.StringWidth(line) -- cgit v1.2.3