diff options
-rw-r--r-- | matrix.go | 2 | ||||
-rw-r--r-- | message-view.go | 260 | ||||
-rw-r--r-- | room-view.go | 40 | ||||
-rw-r--r-- | view-main.go | 32 |
4 files changed, 305 insertions, 29 deletions
@@ -160,7 +160,7 @@ func (c *MatrixContainer) HandleMessage(evt *gomatrix.Event) { timestamp = time.Unix(timestampInt64/1000, timestampInt64%1000*1000) } - c.ui.MainView().AddMessage(evt.RoomID, evt.Sender, message, timestamp) + c.ui.MainView().AddRealMessage(evt.RoomID, evt.ID, evt.Sender, message, timestamp) } func (c *MatrixContainer) HandleMembership(evt *gomatrix.Event) { diff --git a/message-view.go b/message-view.go new file mode 100644 index 0000000..633c551 --- /dev/null +++ b/message-view.go @@ -0,0 +1,260 @@ +// 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 <http://www.gnu.org/licenses/>. + +package main + +import ( + "regexp" + "strings" + "time" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + "maunium.net/go/tview" +) + +type Message struct { + ID string + Sender string + Text string + Timestamp string + RenderSender bool + + buffer []string + senderColor tcell.Color +} + +var ( + boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)") + spacePattern = regexp.MustCompile(`\s+`) +) + +func (message *Message) calculateBuffer(width int) { + if width < 1 { + return + } + message.buffer = []string{} + forcedLinebreaks := strings.Split(message.Text, "\n") + newlines := 0 + for _, str := range forcedLinebreaks { + if len(str) == 0 && newlines < 1 { + message.buffer = append(message.buffer, "") + newlines++ + } else { + newlines = 0 + } + // From tview/textview.go#reindexBuffer() + for len(str) > 0 { + extract := runewidth.Truncate(str, width, "") + if len(extract) < len(str) { + if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 { + extract = str[:len(extract)+spaces[1]] + } + + matches := boundaryPattern.FindAllStringIndex(extract, -1) + if len(matches) > 0 { + extract = extract[:matches[len(matches)-1][1]] + } + } + message.buffer = append(message.buffer, extract) + str = str[len(extract):] + } + } +} + +type MessageView struct { + *tview.Box + + ScrollOffset int + MaxSenderWidth int + TimestampFormat string + TimestampWidth int + Separator rune + + widestSender int + prevWidth int + prevHeight int + prevScrollOffset int + firstDisplayMessage int + lastDisplayMessage int + totalHeight int + + messages []*Message + + debug DebugPrinter +} + +func NewMessageView(debug DebugPrinter) *MessageView { + return &MessageView{ + Box: tview.NewBox(), + MaxSenderWidth: 20, + TimestampFormat: "15:04:05", + TimestampWidth: 8, + Separator: '|', + ScrollOffset: 0, + + widestSender: 5, + prevWidth: -1, + prevHeight: -1, + prevScrollOffset: -1, + firstDisplayMessage: -1, + lastDisplayMessage: -1, + totalHeight: -1, + + debug: debug, + } +} + +func (view *MessageView) recalculateBuffers(width int) { + width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap + for _, message := range view.messages { + message.calculateBuffer(width) + } + view.prevWidth = width +} + +func (view *MessageView) AddMessage(id, sender, text string, timestamp time.Time) { + if len(sender) > view.widestSender { + view.widestSender = len(sender) + if view.widestSender > view.MaxSenderWidth { + view.widestSender = view.MaxSenderWidth + } + } + message := &Message{ + ID: id, + Sender: sender, + RenderSender: true, + Text: text, + Timestamp: timestamp.Format(view.TimestampFormat), + senderColor: getColor(sender), + } + _, _, width, height := view.GetInnerRect() + width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap + message.calculateBuffer(width) + if view.ScrollOffset > 0 { + view.ScrollOffset += len(message.buffer) + } + if len(view.messages) > 0 && view.messages[len(view.messages)-1].Sender == message.Sender { + message.RenderSender = false + } + view.messages = append(view.messages, message) + view.recalculateHeight(height) +} + +func (view *MessageView) recalculateHeight(height int) { + view.firstDisplayMessage = -1 + view.lastDisplayMessage = -1 + view.totalHeight = 0 + for i := len(view.messages) - 1; i >= 0; i-- { + prevTotalHeight := view.totalHeight + view.totalHeight += len(view.messages[i].buffer) + + if view.totalHeight < view.ScrollOffset { + continue + } else if view.firstDisplayMessage == -1 { + view.lastDisplayMessage = i + view.firstDisplayMessage = i + } + + if prevTotalHeight < height+view.ScrollOffset { + view.lastDisplayMessage = i + } + } + view.prevScrollOffset = view.ScrollOffset +} + +func (view *MessageView) PageUp() { + _, _, _, height := view.GetInnerRect() + view.ScrollOffset += height / 2 + if view.ScrollOffset > view.totalHeight-height { + view.ScrollOffset = view.totalHeight - height + 5 + } +} + +func (view *MessageView) PageDown() { + _, _, _, height := view.GetInnerRect() + view.ScrollOffset -= height / 2 + if view.ScrollOffset < 0 { + view.ScrollOffset = 0 + } +} + +func (view *MessageView) writeLine(screen tcell.Screen, line string, x, y int, color tcell.Color) { + offsetX := 0 + for _, ch := range line { + chWidth := runewidth.RuneWidth(ch) + if chWidth == 0 { + continue + } + + for localOffset := 0; localOffset < chWidth; localOffset++ { + screen.SetContent(x+offsetX+localOffset, y, ch, nil, tcell.StyleDefault.Foreground(color)) + } + offsetX += chWidth + } +} + +const ( + TimestampSenderGap = 1 + SenderSeparatorGap = 1 + SenderMessageGap = 3 +) + +func (view *MessageView) Draw(screen tcell.Screen) { + view.Box.Draw(screen) + + x, y, width, height := view.GetInnerRect() + if width != view.prevWidth { + view.recalculateBuffers(width) + } + if height != view.prevHeight || width != view.prevWidth || view.ScrollOffset != view.prevScrollOffset { + view.recalculateHeight(height) + } + usernameOffsetX := view.TimestampWidth + TimestampSenderGap + messageOffsetX := usernameOffsetX + view.widestSender + SenderMessageGap + + separatorX := x + usernameOffsetX + view.widestSender + SenderSeparatorGap + for separatorY := y; separatorY < y+height; separatorY++ { + screen.SetContent(separatorX, separatorY, view.Separator, nil, tcell.StyleDefault) + } + + if view.firstDisplayMessage == -1 || view.lastDisplayMessage == -1 { + return + } + + writeOffset := 0 + for i := view.firstDisplayMessage; i >= view.lastDisplayMessage; i-- { + message := view.messages[i] + messageHeight := len(message.buffer) + + senderAtLine := y + height - writeOffset - messageHeight + if senderAtLine < y { + senderAtLine = y + } + view.writeLine(screen, message.Timestamp, x, senderAtLine, tcell.ColorDefault) + if message.RenderSender || i == view.lastDisplayMessage { + view.writeLine(screen, message.Sender, x+usernameOffsetX, senderAtLine, message.senderColor) + } + + for num, line := range message.buffer { + offsetY := height - messageHeight - writeOffset + num + if offsetY >= 0 { + view.writeLine(screen, line, x+messageOffsetX, y+offsetY, tcell.ColorDefault) + } + } + writeOffset += messageHeight + } +} diff --git a/room-view.go b/room-view.go index d5b9ca2..6764e85 100644 --- a/room-view.go +++ b/room-view.go @@ -19,9 +19,8 @@ package main import ( "fmt" "hash/fnv" - "regexp" + "sort" "strings" - "time" "github.com/gdamore/tcell" "maunium.net/go/gomatrix" @@ -32,10 +31,12 @@ type RoomView struct { *tview.Box topic *tview.TextView - content *tview.TextView + content *MessageView status *tview.TextView userList *tview.TextView room *gomatrix.Room + + debug DebugPrinter } var colorNames []string @@ -47,27 +48,30 @@ func init() { colorNames[i] = name i++ } + sort.Sort(sort.StringSlice(colorNames)) } -func NewRoomView(room *gomatrix.Room) *RoomView { +func NewRoomView(debug DebugPrinter, room *gomatrix.Room) *RoomView { view := &RoomView{ Box: tview.NewBox(), topic: tview.NewTextView(), - content: tview.NewTextView(), + content: NewMessageView(debug), status: tview.NewTextView(), userList: tview.NewTextView(), room: room, + debug: debug, } view.topic. SetText(strings.Replace(room.GetTopic(), "\n", " ", -1)). SetBackgroundColor(tcell.ColorDarkGreen) view.status.SetBackgroundColor(tcell.ColorDimGray) view.userList.SetDynamicColors(true) - view.content.SetDynamicColors(true) return view } func (view *RoomView) Draw(screen tcell.Screen) { + view.Box.Draw(screen) + x, y, width, height := view.GetRect() view.topic.SetRect(x, y, width, 1) view.content.SetRect(x, y+1, width-30, height-2) @@ -104,26 +108,24 @@ func (view *RoomView) SetTyping(users []string) { } } -var colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6})\]`) +func (view *RoomView) MessageView() *MessageView { + return view.content +} -func color(s string) string { +func getColorName(s string) string { h := fnv.New32a() h.Write([]byte(s)) - color := colorNames[int(h.Sum32())%len(colorNames)] - return fmt.Sprintf("[%s]%s[white]", color, s) + return colorNames[int(h.Sum32())%len(colorNames)] } -func escapeColor(s string) string { - return colorPattern.ReplaceAllString(s, "[$1[]") +func getColor(s string) tcell.Color { + h := fnv.New32a() + h.Write([]byte(s)) + return tcell.ColorNames[colorNames[int(h.Sum32())%len(colorNames)]] } -func (view *RoomView) AddMessage(sender, message string, timestamp time.Time) { - member := view.room.GetMember(sender) - if member != nil { - sender = member.DisplayName - } - fmt.Fprintf(view.content, "[%s] %s: %s\n", - timestamp.Format("15:04:05"), color(sender), escapeColor(message)) +func color(s string) string { + return fmt.Sprintf("[%s]%s[white]", getColorName(s), s) } func (view *RoomView) UpdateUserList() { diff --git a/view-main.go b/view-main.go index 58fbd80..d291754 100644 --- a/view-main.go +++ b/view-main.go @@ -126,28 +126,34 @@ func (view *MainView) HandleCommand(room, command string, args []string) { view.matrix.client.LeaveRoom(room) case "/join": if len(args) == 0 { - view.AddMessage(room, "*", "Usage: /join <room>", time.Now()) + view.AddMessage(room, "Usage: /join <room>") break } view.debug.Print(view.matrix.JoinRoom(args[0])) default: - view.AddMessage(room, "*", "Unknown command.", time.Now()) + view.AddMessage(room, "Unknown command.") } } func (view *MainView) InputCapture(key *tcell.EventKey) *tcell.EventKey { + k := key.Key() if key.Modifiers() == tcell.ModCtrl { - if key.Key() == tcell.KeyDown { + if k == tcell.KeyDown { view.SwitchRoom(view.currentRoomIndex + 1) view.roomList.SetCurrentItem(view.currentRoomIndex) - } else if key.Key() == tcell.KeyUp { + } else if k == tcell.KeyUp { view.SwitchRoom(view.currentRoomIndex - 1) view.roomList.SetCurrentItem(view.currentRoomIndex) } else { return key } - } else if key.Key() == tcell.KeyPgUp || key.Key() == tcell.KeyPgDn { - view.rooms[view.CurrentRoomID()].InputHandler()(key, nil) + } else if k == tcell.KeyPgUp || k == tcell.KeyPgDn { + msgView := view.rooms[view.CurrentRoomID()].MessageView() + if k == tcell.KeyPgUp { + msgView.PageUp() + } else { + msgView.PageDown() + } } else { return key } @@ -178,7 +184,7 @@ func (view *MainView) addRoom(index int, room string) { view.SwitchRoom(index) }) if !view.roomView.HasPage(room) { - roomView := NewRoomView(roomStore) + roomView := NewRoomView(view.debug, roomStore) view.rooms[room] = roomView view.roomView.AddPage(room, roomView, true, false) roomView.UpdateUserList() @@ -231,10 +237,18 @@ func (view *MainView) SetTyping(room string, users []string) { } } -func (view *MainView) AddMessage(room, sender, message string, timestamp time.Time) { +func (view *MainView) AddMessage(room, message string) { + view.AddRealMessage(room, "", "*", message, time.Now()) +} + +func (view *MainView) AddRealMessage(room, id, sender, message string, timestamp time.Time) { roomView, ok := view.rooms[room] if ok { - roomView.AddMessage(sender, message, timestamp) + member := roomView.room.GetMember(sender) + if member != nil { + sender = member.DisplayName + } + roomView.content.AddMessage(id, sender, message, timestamp) view.parent.Render() } } |