Procházet zdrojové kódy

refactor(ui/chat): use tview.ScrollList for messagesList (#708)

Ayyan před 3 měsíci
rodič
revize
2015c7d9fe
5 změnil soubory, kde provedl 170 přidání a 125 odebrání
  1. 2 2
      go.mod
  2. 4 4
      go.sum
  3. 1 1
      internal/ui/chat/message_input.go
  4. 117 109
      internal/ui/chat/messages_list.go
  5. 46 9
      internal/ui/chat/state.go

+ 2 - 2
go.mod

@@ -7,7 +7,7 @@ go 1.25.3
 require (
 	github.com/BurntSushi/toml v1.6.0
 	github.com/andybalholm/brotli v1.2.0
-	github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e
+	github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4
 	github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
 	github.com/diamondburned/arikawa/v3 v3.6.1-0.20250928004212-a891a653eb26
 	github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d
@@ -16,7 +16,7 @@ require (
 	github.com/google/go-cmp v0.6.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
-	github.com/klauspost/compress v1.18.2
+	github.com/klauspost/compress v1.18.3
 	github.com/ncruces/zenity v0.10.14
 	github.com/sahilm/fuzzy v0.1.1
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e

+ 4 - 4
go.sum

@@ -8,8 +8,8 @@ github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw=
 github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
 github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
 github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e h1:3BbJN6zXY3AskufYkkFerQF+n42MUyeYi6SLrqBaoJk=
-github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e/go.mod h1:i95d/64QBCxhpf8Q7ufI/YTNX8J0y/8sWTbwTH4tjkY=
+github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4 h1:+CTr6AUbwBPY7TcsDFFS9xkiX7W4RgV5/HYMP8bjeF8=
+github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4/go.mod h1:i95d/64QBCxhpf8Q7ufI/YTNX8J0y/8sWTbwTH4tjkY=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -50,8 +50,8 @@ github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8
 github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
 github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg=
 github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o=
-github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
-github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
+github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=

+ 1 - 1
internal/ui/chat/message_input.go

@@ -220,7 +220,7 @@ func (mi *messageInput) send() {
 		mi.typingTimer = nil
 	}
 	mi.reset()
-	mi.chatView.messagesList.Highlight()
+	mi.chatView.messagesList.clearSelection()
 	mi.chatView.messagesList.ScrollToEnd()
 }
 

+ 117 - 109
internal/ui/chat/messages_list.go

@@ -34,10 +34,10 @@ import (
 )
 
 type messagesList struct {
-	*tview.TextView
-	cfg               *config.Config
-	chatView          *View
-	selectedMessageID discord.MessageID
+	*tview.ScrollList
+	cfg      *config.Config
+	chatView *View
+	messages []discord.Message
 
 	renderer *markdown.Renderer
 
@@ -51,29 +51,25 @@ type messagesList struct {
 
 func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
 	ml := &messagesList{
-		TextView: tview.NewTextView(),
-		cfg:      cfg,
-		chatView: chatView,
-		renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
+		ScrollList: tview.NewScrollList(),
+		cfg:        cfg,
+		chatView:   chatView,
+		renderer:   markdown.NewRenderer(cfg.Theme.MessagesList),
 	}
 
 	ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme)
-	ml.
-		SetDynamicColors(true).
-		SetRegions(true).
-		SetWordWrap(true).
-		ScrollToEnd().
-		SetHighlightedFunc(ml.onHighlighted).
-		SetTitle("Messages").
-		SetInputCapture(ml.onInputCapture)
+	ml.SetTitle("Messages")
+	ml.SetBuilder(ml.buildItem)
+	ml.SetTrackEnd(true)
+	ml.SetInputCapture(ml.onInputCapture)
 	return ml
 }
 
 func (ml *messagesList) reset() {
-	ml.selectedMessageID = 0
+	ml.messages = nil
 	ml.
 		Clear().
-		Highlight().
+		SetBuilder(ml.buildItem).
 		SetTitle("")
 }
 
@@ -87,18 +83,60 @@ func (ml *messagesList) setTitle(channel discord.Channel) {
 }
 
 func (ml *messagesList) drawMessages(messages []discord.Message) {
-	writer := ml.BatchWriter()
-	defer writer.Close()
-	for _, m := range slices.Backward(messages) {
-		ml.drawMessage(writer, m)
+	ml.messages = slices.Grow(ml.messages[:0], len(messages))
+	ml.messages = ml.messages[:len(messages)]
+	copy(ml.messages, messages)
+	slices.Reverse(ml.messages)
+}
+
+func (ml *messagesList) addMessage(message discord.Message) {
+	ml.messages = append(ml.messages, message)
+}
+
+func (ml *messagesList) setMessage(index int, message discord.Message) {
+	if index < 0 || index >= len(ml.messages) {
+		return
 	}
+
+	ml.messages[index] = message
+}
+
+func (ml *messagesList) deleteMessage(index int) {
+	if index < 0 || index >= len(ml.messages) {
+		return
+	}
+
+	ml.messages = append(ml.messages[:index], ml.messages[index+1:]...)
 }
 
-func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) {
-	// Region tags are square brackets that contain a region ID in double quotes
-	// https://pkg.go.dev/github.com/ayn2op/tview#hdr-Regions_and_Highlights
-	fmt.Fprintf(writer, `["%s"]`, message.ID)
+func (ml *messagesList) clearSelection() {
+	ml.SetCursor(-1)
+}
 
+func (ml *messagesList) buildItem(index int, cursor int) tview.ScrollListItem {
+	if index < 0 || index >= len(ml.messages) {
+		return nil
+	}
+
+	message := ml.messages[index]
+	tv := tview.NewTextView().
+		SetWrap(true).
+		SetWordWrap(true).
+		SetDynamicColors(true).
+		SetText(ml.renderMessage(message))
+	if index == cursor {
+		tv.SetTextStyle(tcell.StyleDefault.Reverse(true))
+	}
+	return tv
+}
+
+func (ml *messagesList) renderMessage(message discord.Message) string {
+	var b strings.Builder
+	ml.writeMessage(&b, message)
+	return b.String()
+}
+
+func (ml *messagesList) writeMessage(writer io.Writer, message discord.Message) {
 	if ml.cfg.HideBlockedUsers {
 		isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID)
 		if isBlocked {
@@ -129,9 +167,6 @@ func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) {
 		ml.drawTimestamps(writer, message.Timestamp)
 		ml.drawAuthor(writer, message)
 	}
-
-	// Tags with no region ID ([""]) don't start new regions. They can therefore be used to mark the end of a region.
-	io.WriteString(writer, "[\"\"]\n")
 }
 
 func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
@@ -239,138 +274,117 @@ func (ml *messagesList) drawPinnedMessage(w io.Writer, message discord.Message)
 }
 
 func (ml *messagesList) selectedMessage() (*discord.Message, error) {
-	if !ml.selectedMessageID.IsValid() {
-		return nil, errors.New("no message is currently selected")
+	if len(ml.messages) == 0 {
+		return nil, errors.New("no messages available")
 	}
 
-	selected := ml.chatView.SelectedChannel()
-	if selected == nil {
-		return nil, errors.New("no channel is currently selected")
-	}
-
-	m, err := ml.chatView.state.Cabinet.Message(selected.ID, ml.selectedMessageID)
-	if err != nil {
-		return nil, fmt.Errorf("failed to retrieve selected message: %w", err)
+	cursor := ml.Cursor()
+	if cursor == -1 || cursor >= len(ml.messages) {
+		return nil, errors.New("no message is currently selected")
 	}
 
-	return m, nil
+	return &ml.messages[cursor], nil
 }
 
 func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	switch event.Name() {
 	case ml.cfg.Keys.MessagesList.ScrollUp:
-		return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
+		ml.ScrollUp()
+		return nil
 	case ml.cfg.Keys.MessagesList.ScrollDown:
-		return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
+		ml.ScrollDown()
+		return nil
 	case ml.cfg.Keys.MessagesList.ScrollTop:
-		return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)
+		ml.SetCursor(0)
+		return nil
 	case ml.cfg.Keys.MessagesList.ScrollBottom:
-		return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)
+		ml.ScrollToEnd()
+		return nil
 
 	case ml.cfg.Keys.MessagesList.Cancel:
-		ml.selectedMessageID = 0
-		ml.Highlight()
+		ml.clearSelection()
+		return nil
 
 	case ml.cfg.Keys.MessagesList.SelectPrevious, ml.cfg.Keys.MessagesList.SelectNext, ml.cfg.Keys.MessagesList.SelectFirst, ml.cfg.Keys.MessagesList.SelectLast, ml.cfg.Keys.MessagesList.SelectReply:
 		ml._select(event.Name())
+		return nil
 	case ml.cfg.Keys.MessagesList.YankID:
 		ml.yankID()
+		return nil
 	case ml.cfg.Keys.MessagesList.YankContent:
 		ml.yankContent()
+		return nil
 	case ml.cfg.Keys.MessagesList.YankURL:
 		ml.yankURL()
+		return nil
 	case ml.cfg.Keys.MessagesList.Open:
 		ml.open()
+		return nil
 	case ml.cfg.Keys.MessagesList.Reply:
 		ml.reply(false)
+		return nil
 	case ml.cfg.Keys.MessagesList.ReplyMention:
 		ml.reply(true)
+		return nil
 	case ml.cfg.Keys.MessagesList.Edit:
 		ml.edit()
+		return nil
 	case ml.cfg.Keys.MessagesList.Delete:
 		ml.delete()
+		return nil
 	case ml.cfg.Keys.MessagesList.DeleteConfirm:
 		ml.confirmDelete()
+		return nil
 	}
 
-	return nil
+	return event
 }
 
 func (ml *messagesList) _select(name string) {
-	selectedChannel := ml.chatView.SelectedChannel()
-	if selectedChannel == nil {
-		return
-	}
-
-	messages, err := ml.chatView.state.Cabinet.Messages(selectedChannel.ID)
-	if err != nil {
-		slog.Error("failed to get messages", "err", err, "channel_id", selectedChannel.ID)
-		return
-	}
+	messages := ml.messages
 	if len(messages) == 0 {
 		return
 	}
 
-	messageIdx := slices.IndexFunc(messages, func(m discord.Message) bool {
-		return m.ID == ml.selectedMessageID
-	})
-	// Allow "no highlight yet" to fall through and pick the latest message.
-	if len(ml.GetHighlights()) != 0 && messageIdx == -1 {
-		return
-	}
+	cursor := ml.Cursor()
 
 	switch name {
 	case ml.cfg.Keys.MessagesList.SelectPrevious:
-		// If no message is currently selected, select the latest message.
-		if len(ml.GetHighlights()) == 0 {
-			ml.selectedMessageID = messages[0].ID
-		} else if messageIdx < len(messages)-1 {
-			ml.selectedMessageID = messages[messageIdx+1].ID
-		} else {
-			return
+		switch {
+		case cursor == -1:
+			cursor = len(messages) - 1
+		case cursor > 0:
+			cursor--
 		}
 	case ml.cfg.Keys.MessagesList.SelectNext:
-		// If no message is currently selected, select the latest message.
-		if len(ml.GetHighlights()) == 0 {
-			ml.selectedMessageID = messages[0].ID
-		} else if messageIdx > 0 {
-			ml.selectedMessageID = messages[messageIdx-1].ID
-		} else {
-			return
+		switch {
+		case cursor == -1:
+			cursor = len(messages) - 1
+		case cursor < len(messages)-1:
+			cursor++
 		}
 	case ml.cfg.Keys.MessagesList.SelectFirst:
-		ml.selectedMessageID = messages[len(messages)-1].ID
+		cursor = 0
 	case ml.cfg.Keys.MessagesList.SelectLast:
-		ml.selectedMessageID = messages[0].ID
+		cursor = len(messages) - 1
 	case ml.cfg.Keys.MessagesList.SelectReply:
-		if ml.selectedMessageID == 0 {
+		if cursor == -1 || cursor >= len(messages) {
 			return
 		}
 
-		if ref := messages[messageIdx].ReferencedMessage; ref != nil {
+		if ref := messages[cursor].ReferencedMessage; ref != nil {
 			refIdx := slices.IndexFunc(messages, func(m discord.Message) bool {
 				return m.ID == ref.ID
 			})
 			if refIdx != -1 {
-				ml.selectedMessageID = messages[refIdx].ID
+				cursor = refIdx
 			}
-		}
-	}
-
-	ml.Highlight(ml.selectedMessageID.String())
-	ml.ScrollToHighlight()
-}
-
-func (ml *messagesList) onHighlighted(added, removed, remaining []string) {
-	if len(added) > 0 {
-		id, err := discord.ParseSnowflake(added[0])
-		if err != nil {
-			slog.Error("failed to parse region id as int to use as message id", "err", err)
+		} else {
 			return
 		}
-
-		ml.selectedMessageID = discord.MessageID(id)
 	}
+
+	ml.SetCursor(cursor)
 }
 
 func (ml *messagesList) yankID() {
@@ -545,17 +559,17 @@ func (ml *messagesList) openURL(url string) {
 }
 
 func (ml *messagesList) reply(mention bool) {
-	msg, err := ml.selectedMessage()
+	message, err := ml.selectedMessage()
 	if err != nil {
 		slog.Error("failed to get selected message", "err", err)
 		return
 	}
 
-	name := msg.Author.DisplayOrUsername()
-	if msg.GuildID.IsValid() {
-		member, err := ml.chatView.state.Cabinet.Member(msg.GuildID, msg.Author.ID)
+	name := message.Author.DisplayOrUsername()
+	if message.GuildID.IsValid() {
+		member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
 		if err != nil {
-			slog.Error("failed to get member from state", "guild_id", msg.GuildID, "member_id", msg.Author.ID, "err", err)
+			slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
 		} else {
 			if member.Nick != "" {
 				name = member.Nick
@@ -564,7 +578,7 @@ func (ml *messagesList) reply(mention bool) {
 	}
 
 	data := ml.chatView.messageInput.sendMessageData
-	data.Reference = &discord.MessageReference{MessageID: ml.selectedMessageID}
+	data.Reference = &discord.MessageReference{MessageID: message.ID}
 	data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
 
 	title := "Replying to "
@@ -646,16 +660,10 @@ func (ml *messagesList) delete() {
 		return
 	}
 
-	ml.selectedMessageID = 0
-	ml.Highlight()
-
 	if err := ml.chatView.state.MessageRemove(selected.ID, msg.ID); err != nil {
 		slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
 		return
 	}
-
-	// No need to redraw messages after deletion, onMessageDelete will do
-	// its work after the event returns
 }
 
 func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {

+ 46 - 9
internal/ui/chat/state.go

@@ -3,10 +3,12 @@ package chat
 import (
 	"context"
 	"log/slog"
+	"slices"
 
 	"github.com/ayn2op/discordo/internal/http"
 	"github.com/ayn2op/discordo/internal/notifications"
 	"github.com/ayn2op/tview"
+	"github.com/diamondburned/arikawa/v3/discord"
 	"github.com/diamondburned/arikawa/v3/gateway"
 	"github.com/diamondburned/arikawa/v3/session"
 	"github.com/diamondburned/arikawa/v3/state"
@@ -114,8 +116,9 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
 	selectedChannel := v.SelectedChannel()
 	if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
 		v.removeTyper(message.Author.ID)
-		v.messagesList.drawMessage(v.messagesList, message.Message)
-		v.app.Draw()
+		v.app.QueueUpdateDraw(func() {
+			v.messagesList.addMessage(message.Message)
+		})
 	} else {
 		if err := notifications.Notify(v.state, message, v.cfg); err != nil {
 			slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
@@ -125,21 +128,55 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
 
 func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) {
 	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
-		v.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID})
+		index := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
+			return m.ID == message.ID
+		})
+		if index < 0 {
+			return
+		}
+
+		v.app.QueueUpdateDraw(func() {
+			v.messagesList.setMessage(index, message.Message)
+		})
 	}
 }
 
 func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) {
 	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
-		messages, err := v.state.Cabinet.Messages(message.ChannelID)
-		if err != nil {
-			slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID)
+		prevCursor := v.messagesList.Cursor()
+		deletedIndex := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
+			return m.ID == message.ID
+		})
+		if deletedIndex < 0 {
 			return
 		}
 
-		v.messagesList.reset()
-		v.messagesList.drawMessages(messages)
-		v.app.Draw()
+		v.app.QueueUpdateDraw(func() {
+			v.messagesList.deleteMessage(deletedIndex)
+		})
+
+		// Keep cursor stable when possible after removal.
+		newCursor := prevCursor
+		if prevCursor == deletedIndex {
+			// Prefer previous item; fall forward if we deleted the first.
+			newCursor = deletedIndex - 1
+			if newCursor < 0 {
+				if deletedIndex < len(v.messagesList.messages) {
+					newCursor = deletedIndex
+				} else {
+					newCursor = -1
+				}
+			}
+		} else if prevCursor > deletedIndex {
+			// Shift back since the list shrank before the cursor.
+			newCursor = prevCursor - 1
+		}
+		if newCursor != prevCursor {
+			// Avoid redundant cursor updates if nothing changed.
+			v.app.QueueUpdateDraw(func() {
+				v.messagesList.SetCursor(newCursor)
+			})
+		}
 	}
 }