diff --git a/docs/config.md b/docs/config.md index 33654f3..b89d7e3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -41,31 +41,6 @@ The number of messages to fetch when a text-based channel is selected, from 1 to Default: `50` -# attachments - -## attachments.show_links - -Default: `true` - -## attachments.allowed_mime_types - -Attachment MIME types that can be opened or downloaded without confirmation. -Exact types and wildcards such as "image/*" are supported. - -Default: `["image/*", "video/*"]` - -## attachments.preview - -Whether to draw image attachments and embedded images below their messages. - -Default: `true` - -## attachments.protocol - -How previews are drawn: "auto", "kitty" or "halfblocks". - -Default: `"auto"` - # markdown ## markdown.enabled @@ -388,7 +363,7 @@ Default: `false` ### ui.messages_list.gap -Empty rows between messages. +The number of empty rows between messages. Default: `1` @@ -400,6 +375,43 @@ Default: `">"` Default: `"<"` +### ui.messages_list.attachments + +#### ui.messages_list.attachments.show_links + +Default: `true` + +#### ui.messages_list.attachments.allowed_mime_types + +Attachment MIME types that can be opened or downloaded without confirmation. +Exact types and wildcards such as "image/*" are supported. + +Default: `["image/*", "video/*"]` + +#### ui.messages_list.attachments.preview + +Whether to draw image attachments and embedded images below their messages. + +Default: `true` + +#### ui.messages_list.attachments.protocol + +How previews are drawn: "auto", "kitty" or "halfblocks". + +Default: `"auto"` + +### ui.messages_list.avatars + +#### ui.messages_list.avatars.preview + +Default: `true` + +#### ui.messages_list.avatars.height + +The number of rows an avatar takes. It is recommended to use `1` with compact. + +Default: `2` + ## ui.mentions_list The list of mention suggestions above the composer. Its width and height are capped to the available space. diff --git a/go.mod b/go.mod index dd4646a..55dc4e3 100644 --- a/go.mod +++ b/go.mod @@ -15,13 +15,13 @@ require ( github.com/andybalholm/brotli v1.2.6 github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e - github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e + github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874 github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.6.0 github.com/gen2brain/malgo v0.11.26 github.com/gorilla/websocket v1.5.3 - github.com/klauspost/compress v1.20.1 + github.com/klauspost/compress v1.20.3 github.com/mattn/go-shellwords v1.0.16 github.com/ncruces/zenity v0.10.15 github.com/rivo/uniseg v0.4.7 diff --git a/go.sum b/go.sum index bf49586..85e2e57 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlG github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw= -github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e h1:uCt61XkJ0DqRiBd5ozXZVd2xWlNFcA0rR2hew9lAZx0= -github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= +github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874 h1:zXM8mKeDfXx+dzGEVTTZ0Xfo9vCXNBiX2qEWz/6KMV0= +github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= @@ -62,8 +62,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/josephspurrier/goversioninfo v1.7.0 h1:LQzXOlVm/CtbwJ9/UHl5a2HT0BjcLAwid5gqGd7ZUJ8= github.com/josephspurrier/goversioninfo v1.7.0/go.mod h1:z9y0r2G6g5jwSJaFE0cxW9to0aeIibK7UYeLx53aQRU= -github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= -github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= 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.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= diff --git a/internal/config/config.go b/internal/config/config.go index c64325d..f975799 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,17 +62,6 @@ type ( Format string `toml:"format"` } - AttachmentsConfig struct { - ShowLinks bool `toml:"show_links"` - // Attachment MIME types that can be opened or downloaded without confirmation. - // Exact types and wildcards such as "image/*" are supported. - AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"` - // Whether to draw image attachments and embedded images below their messages. - Preview bool `toml:"preview"` - // How previews are drawn: "auto", "kitty" or "halfblocks". - Protocol string `toml:"protocol"` - } - DateSeparator struct { Enabled bool `toml:"enabled"` // https://pkg.go.dev/time#Layout @@ -198,8 +187,6 @@ type ( // The number of messages to fetch when a text-based channel is selected, from 1 to 100. MessagesLimit uint8 `toml:"messages_limit"` - Attachments AttachmentsConfig `toml:"attachments"` - Markdown MarkdownConfig `toml:"markdown"` Help HelpConfig `toml:"help"` Picker PickerConfig `toml:"picker"` @@ -242,13 +229,6 @@ func Default() Config { AutocompleteLimit: 20, MessagesLimit: 50, - Attachments: AttachmentsConfig{ - ShowLinks: true, - AllowedMIMETypes: MIMETypes{"image/*", "video/*"}, - Preview: true, - Protocol: "auto", - }, - Markdown: MarkdownConfig{Enabled: true, Theme: "monokai"}, Help: HelpConfig{Enabled: true, CompactModifiers: true, Padding: [2]int{1, 1}, Separator: " • "}, Picker: PickerConfig{Width: 80, Height: 25}, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b2cf1a6..ac2b898 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -60,7 +60,7 @@ func TestLoad(t *testing.T) { t.Run("invalid allowed MIME type returns error", func(t *testing.T) { path := filepath.Join(t.TempDir(), "bad-mime.toml") - if err := os.WriteFile(path, []byte("[attachments]\nallowed_mime_types = [\"image/\"]"), os.ModePerm); err != nil { + if err := os.WriteFile(path, []byte("[ui.messages_list.attachments]\nallowed_mime_types = [\"image/\"]"), os.ModePerm); err != nil { t.Fatal(err) } diff --git a/internal/config/ui.go b/internal/config/ui.go index 1e41ef0..47d4ffd 100644 --- a/internal/config/ui.go +++ b/internal/config/ui.go @@ -173,6 +173,26 @@ type ( ReplyIndicator string `toml:"reply_indicator"` ForwardedIndicator string `toml:"forwarded_indicator"` + + Attachments AttachmentsUIConfig `toml:"attachments"` + Avatars AvatarsUIConfig `toml:"avatars"` + } + + AvatarsUIConfig struct { + Preview bool `toml:"preview"` + // The number of rows an avatar takes. It is recommended to use `1` with compact. + Height uint8 `toml:"height"` + } + + AttachmentsUIConfig struct { + ShowLinks bool `toml:"show_links"` + // Attachment MIME types that can be opened or downloaded without confirmation. + // Exact types and wildcards such as "image/*" are supported. + AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"` + // Whether to draw image attachments and embedded images below their messages. + Preview bool `toml:"preview"` + // How previews are drawn: "auto", "kitty" or "halfblocks". + Protocol string `toml:"protocol"` } MentionsListUIConfig struct { @@ -186,6 +206,7 @@ type ( func defaultUI() UIConfig { left := TitleUIConfig{AlignmentWrapper{tview.AlignmentLeft}} round := BorderSetWrapper{tview.BorderSetRound()} + attachments := AttachmentsUIConfig{ShowLinks: true, AllowedMIMETypes: MIMETypes{"image/*", "video/*"}, Preview: true, Protocol: "auto"} return UIConfig{ Tabs: TabsUIConfig{ Alignment: AlignmentWrapper{tview.AlignmentCenter}, @@ -200,7 +221,7 @@ func defaultUI() UIConfig { GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true}, MembersTree: TreeUIConfig{Graphics: true}, ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}, Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic}}, - MessagesList: MessagesListUIConfig{Gap: 1, ReplyIndicator: ">", ForwardedIndicator: "<"}, + MessagesList: MessagesListUIConfig{Gap: 1, ReplyIndicator: ">", ForwardedIndicator: "<", Attachments: attachments, Avatars: AvatarsUIConfig{Preview: true, Height: 2}}, MentionsList: MentionsListUIConfig{MinWidth: 20}, } } diff --git a/internal/ui/chat/composer/model.go b/internal/ui/chat/composer/model.go index df8f7d4..65714a4 100644 --- a/internal/ui/chat/composer/model.go +++ b/internal/ui/chat/composer/model.go @@ -93,12 +93,6 @@ func (m *Model) SetChannel(channel *discord.Channel) { m.disabled = !isDM && !m.state.HasPermissions(channel.ID, discord.PermissionSendMessages) } -// Height returns the number of rows the composer takes, including its border. -func (m *Model) Height() int { - _, frame := m.frame() - return min(strings.Count(m.editState.Value(), "\n")+1, max(m.cfg.Composer.MaxHeight, 1)) + frame -} - func (m Model) Disabled() bool { return m.disabled } @@ -148,8 +142,9 @@ func (m *Model) box(focused bool) box.Widget { Placeholder(m.placeholder()). Keybind(m.editAction). Focused(focused && !m.disabled). - OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) }) - return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer()) + OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) }). + Height(layout.Shrink.Max(m.cfg.Composer.MaxHeight)) + return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer()).Height(layout.Shrink) } // title returns what the composer is doing: editing, replying, or nothing. @@ -202,13 +197,6 @@ func (m *Model) pasteText() { m.insert(string(data)) } -// frame returns the cells the composer's border and padding take on each side. -func (m *Model) frame() (left, vertical int) { - const size = 100 - inner := m.box(false).InnerArea(tview.Rectangle{Width: size, Height: size}) - return inner.X, size - inner.Height -} - func (m *Model) reset() { m.editing = nil m.sendMessageData = &api.SendMessageData{} @@ -487,7 +475,7 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { } } -// MentionsListView places the mentions list just above the composer, near the cursor, or returns nil if it is empty or the composer is not focused. +// MentionsListView places the mentions list at the bottom of the area above the composer, near the cursor, or returns nil if it is empty or the composer is not focused. func (m Model) MentionsListView(focused bool) tview.Widget { if !focused || !m.mentionsListVisible() { return nil @@ -495,7 +483,7 @@ func (m Model) MentionsListView(focused bool) tview.Widget { return mentionsPopup{m} } -// mentionsPopup lays out the mentions list within the area above the composer, where it is drawn over the messages. +// mentionsPopup places the mentions list within the area of the messages, which it is drawn over. type mentionsPopup struct { c Model } @@ -516,29 +504,14 @@ func (p mentionsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return p.c.mentionsList.View().Handle(msg, p.area(area)) } -// area returns where the list goes in area, the messages and composer together. +// area returns where the list goes in area: at its bottom, at the column of the cursor where it fits. func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle { c := p.c - borders := 0 - if c.cfg.UI.Border.Enabled { - borders = 1 - } - x, _ := c.frame() - maxW, maxH := area.Width, area.Height-c.Height() - if t := int(c.cfg.UI.MentionsList.MaxHeight); t != 0 { - maxH = min(maxH, t) - } - h := min(c.mentionsList.ItemCount()+borders, maxH) + borders + c.cfg.UI.Border.Padding[1] - w := int(c.cfg.UI.MentionsList.MinWidth) - if w == 0 { - w = maxW - } else { - w = min(max(w, c.mentionsList.MaxDisplayWidth())+borders*2, maxW) - value, cursor := c.editState.Value(), c.editState.Cursor() - col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor]) - x += min(col, maxW-w) - } - return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - c.Height() - h, Width: w, Height: h} + size := c.mentionsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}}) + value, cursor := c.editState.Value(), c.editState.Cursor() + col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor]) + x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width) + return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height} } func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool { diff --git a/internal/ui/chat/mentionslist/model.go b/internal/ui/chat/mentionslist/model.go index fa9e2dd..0e4edba 100644 --- a/internal/ui/chat/mentionslist/model.go +++ b/internal/ui/chat/mentionslist/model.go @@ -27,7 +27,13 @@ func NewModel(cfg *config.Config) Model { func (m Model) View() tview.Widget { set := m.cfg.UI.Border.NormalSet.BorderSet set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT - return ui.Box(m.listView(), m.cfg, false).Title("Mentions").BorderSet(set) + cfg := m.cfg.UI.MentionsList + list, width := m.listView().Height(layout.Shrink.Max(int(cfg.MaxHeight))), layout.Fill + if cfg.MinWidth != 0 { + list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink + } + + return ui.Box(list, m.cfg, false).Title("Mentions").BorderSet(set).Width(width).Height(layout.Shrink) } func (Model) Init() tview.Cmd { return nil } @@ -80,7 +86,7 @@ func (m Model) SelectedInsertText() (string, bool) { return m.items[index].InsertText, true } -func (m Model) MaxDisplayWidth() int { +func (m Model) maxDisplayWidth() int { width := 0 for _, item := range m.items { width = max(width, uniseg.StringWidth(item.DisplayText)) diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index b4aef57..8ec0e06 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -26,6 +26,7 @@ import ( "github.com/ayn2op/discordo/internal/voice" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/column" tviewimage "github.com/ayn2op/tview/image" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/list" @@ -44,7 +45,8 @@ import ( type messageItem struct { message discord.Message - view tview.Widget + // text and reply are the message and what it replies to as wrapped, or nil until they are shown. + text, reply *messageView // A separator shows the date of the message after it. separator bool @@ -63,6 +65,7 @@ type Model struct { items []messageItem renderer *markdown.Renderer + avatars map[discord.URL]tviewimage.Widget // voicePlayer plays the voice message played last, and voiceStatus is what it last said of it. voicePlayer *voice.Player voiceStatus voice.PlayerStatus @@ -81,6 +84,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model { cfg: cfg, state: state, renderer: markdown.NewRenderer(cfg), + avatars: map[discord.URL]tviewimage.Widget{}, voicePlayer: voice.NewPlayer(), } @@ -182,6 +186,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int { func (m *Model) reset() { m.items = nil + clear(m.avatars) m.selectionState = list.NewSelectionState() m.scrollState = viewport.ScrollState{} m.scrollState.ScrollToEnd() @@ -247,31 +252,45 @@ func (m Model) buildItem(index int) tview.Widget { return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Style.Dim(true)} } - if item.view == nil { - message := item.message - view := &messageView{render: func(width int) richtext.Text { - return m.renderMessage(message, m.cfg.Theme.MessagesList.MessageStyle.Style, width) + message, style := item.message, m.cfg.Theme.MessagesList.MessageStyle.Style + if item.text == nil { + item.text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width) }} + item.reply = &messageView{render: func(int) richtext.Text { + builder := new(richtext.Builder) + m.drawReply(builder, message, style) + return builder.Finish() }} - if isVoiceMessage(message) { - view.player = m.voiceMessageView(message.Attachments[0]) - } - - for _, s := range previewSources(message) { - if preview, ok := item.previews[s.proxy]; ok { - view.images = append(view.images, preview) - } - } - item.view = view } - return item.view + + body := column.New(item.text).Height(layout.Shrink) + if isVoiceMessage(message) { + body = body.Push(m.voiceMessageView(message.Attachments[0])) + } + + for _, s := range previewSources(message) { + if preview, ok := item.previews[s.proxy]; ok { + body = body.Push(preview) + } + } + + if avatar, ok := m.avatars[avatarURL(message.Author)]; ok { + body = row.New(avatar, body).Spacing(1).Height(layout.Shrink) + } + + if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) { + body = column.New(item.reply, body).Height(layout.Shrink) + } + + return body } -// messageView renders a message followed by its voice message player, if it is one, and its image previews, caching the wrapped lines until the width changes. +func (m Model) blocked(user discord.User) bool { + return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID) +} + +// messageView is the text of a message. It keeps the wrapped lines until the width changes, as the list measures every message when it draws. type messageView struct { - render func(width int) richtext.Text - // player is a row high, or nil. - player tview.Widget - images []tviewimage.Widget + render func(width int) richtext.Text width int rendered bool lines richtext.Text @@ -287,49 +306,20 @@ func (v *messageView) at(width int) textview.Widget { return textview.New(v.lines).Wrap(false) } -// Size returns Fill and Shrink, as the message is as tall as its content. func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink } -// Layout returns the size of the message within limits, as tall as the rows it takes at the width of limits. func (v *messageView) Layout(limits layout.Limits) layout.Size { return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size { v.at(limits.Max.Width) - rows := len(v.lines) - if v.player != nil { - rows++ - } - - for _, preview := range v.images { - _, height := preview.Size() - rows += height.Cells() - } - return layout.Size{Height: rows} + return layout.Size{Height: len(v.lines)} }) } func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { - v.at(area.Width).Draw(screen, tview.Rectangle{X: area.X, Y: area.Y, Width: area.Width, Height: len(v.lines)}) - y := area.Y + len(v.lines) - if v.player != nil { - v.player.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: 1}) - y++ - } - - for _, preview := range v.images { - _, height := preview.Size() - preview.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()}) - y += height.Cells() - } + v.at(area.Width).Draw(screen, area) } -// Handle passes msg to the player, for its waveform to be clicked. -func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { - if v.player == nil { - return msg - } - - return v.player.Handle(msg, tview.Rectangle{X: area.X, Y: area.Y + len(v.lines), Width: area.Width, Height: 1}) -} +func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg } // dateSeparator is a line with the date in the middle, filled to the width it is drawn at. type dateSeparator struct { @@ -413,12 +403,9 @@ func (m *Model) onRowCursorChanged(index int) { } func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) { - if m.cfg.HideBlockedUsers { - isBlocked := m.state.UserIsBlocked(message.Author.ID) - if isBlocked { - builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) - return - } + if m.blocked(message.Author) { + builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) + return } switch message.Type { @@ -431,7 +418,7 @@ func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, case discord.GuildMemberJoinMessage: m.drawLayout(builder, message, baseStyle, func() { builder.Write("joined the server.", baseStyle) }) case discord.InlinedReplyMessage: - m.drawReplyMessage(builder, message, baseStyle, width) + m.drawDefaultMessage(builder, message, baseStyle, width) case discord.ChannelPinnedMessage: m.drawPinnedMessage(builder, message, baseStyle) default: @@ -631,7 +618,7 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes for _, a := range message.Attachments { builder.NewLine() - if m.cfg.Attachments.ShowLinks { + if m.cfg.UI.MessagesList.Attachments.ShowLinks { builder.Write(a.Filename+":", attachmentStyle) builder.NewLine() builder.Write(a.URL, attachmentStyle.Url(a.URL)) @@ -704,7 +691,7 @@ func (m Model) voiceMessageView(a discord.Attachment) tview.Widget { return rail[cell], style.Dim(true) }) - return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1) + return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1)) } func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) { @@ -938,7 +925,7 @@ func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.M }) } -func (m Model) drawReplyMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) { +func (m Model) drawReply(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) { dimStyle := baseStyle.Dim(true) builder.Write(m.cfg.UI.MessagesList.ReplyIndicator+" ", dimStyle) @@ -950,9 +937,6 @@ func (m Model) drawReplyMessage(builder *richtext.Builder, message discord.Messa } else { builder.Write("Original message was deleted", dimStyle) } - - builder.NewLine() - m.drawDefaultMessage(builder, message, baseStyle, width) } func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) { @@ -1035,30 +1019,29 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } return m, m.loadPreviews() case previewLoadedMsg: - index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID }) - if index < 0 || m.items[index].previews == nil { - return m, nil - } preview, cmd := msg.preview, tview.Cmd(nil) if m.kitty { m.kittyImages++ preview = preview.Kitty(m.kittyImages%63488 + 1) cmd = preview.Transmit() } + + if _, ok := m.avatars[msg.proxy]; ok { + m.avatars[msg.proxy] = preview + return m, cmd + } + + index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID }) + if index < 0 || m.items[index].previews == nil { + return m, nil + } + m.items[index].previews[msg.proxy] = preview - // Invalidate the view so it is laid out with the preview. - m.items[index].view = nil return m, cmd case seekMsg: return m, m.seek(time.Duration(msg)) case voiceMsg: m.voiceStatus = m.voicePlayer.Status() - for i := range m.items { - if isVoiceMessage(m.items[i].message) { - m.items[i].view = nil - } - } - return m, m.Init() case tview.ResizeMsg: cols, rows := msg.Size() @@ -1067,7 +1050,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { m.cellWidth, m.cellHeight = uint(width/cols), uint(height/rows) } case tview.TerminalInfoMsg: - switch m.cfg.Attachments.Protocol { + switch m.cfg.UI.MessagesList.Attachments.Protocol { case "kitty": m.kitty = true case "halfblocks": @@ -1267,6 +1250,6 @@ func (m Model) canDeleteMessage(message discord.Message) bool { func (m Model) InvalidateRendered() { for i := range m.items { - m.items[i].view = nil + m.items[i].text = nil } } diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index 9bb8afe..6f3c130 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -12,6 +12,7 @@ import ( "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/layout" ) func testConfig(t *testing.T) *config.Config { @@ -40,9 +41,15 @@ func TestModelBuildItem(t *testing.T) { if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 { t.Fatal("messages are not oldest first") } - view := ml.buildItem(ml.messageIndex(len(ml.items), -1)) - if ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view { - t.Fatal("message view was not reused") + // text returns the wrapped text kept for the newest message once it is shown. + text := func() *messageView { + index := ml.messageIndex(len(ml.items), -1) + ml.buildItem(index) + return ml.items[index].text + } + view := text() + if text() != view { + t.Fatal("message text was not reused") } if ml.selectTop() == nil || ml.selectTop() != nil || ml.selectUp() != nil { t.Fatal("older messages were not fetched once on reaching the top") @@ -54,21 +61,21 @@ func TestModelBuildItem(t *testing.T) { ml.selectBottom() *ml, _ = ml.Update(olderMessagesLoadedMsg{ChannelID: 1, Older: []discord.Message{{ID: 1}}}) selected, ok := ml.selectedMessage() - if !ok || selected.ID != 3 || ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view { - t.Fatal("prepend lost selection or cached view") + if !ok || selected.ID != 3 || text() != view { + t.Fatal("prepend lost selection or cached text") } ml.deleteMessage(ml.messageIndex(-1, 1)) - if ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view { - t.Fatal("deletion discarded another message's view") + if text() != view { + t.Fatal("deletion discarded another message's text") } ml.setMessage(ml.messageIndex(len(ml.items), -1), discord.Message{ID: 3, Content: "edited"}) - edited := ml.buildItem(ml.messageIndex(len(ml.items), -1)) + edited := text() if edited == view { - t.Fatal("edit reused a stale view") + t.Fatal("edit reused stale text") } ml.InvalidateRendered() - if ml.buildItem(ml.messageIndex(len(ml.items), -1)) == edited { - t.Fatal("global invalidation reused a stale view") + if text() == edited { + t.Fatal("global invalidation reused stale text") } ml.reset() if len(ml.items) != 0 { @@ -224,3 +231,18 @@ func TestModelDrawLayout(t *testing.T) { } } } + +func TestMessageViewLayout(t *testing.T) { + cfg := testConfig(t) + cfg.UI.MessagesList.Compact = true + ml := newTestModel(t, cfg) + limits := layout.Limits{Max: layout.Size{Width: 80}, Infinite: layout.Axes{Height: true}} + + // The avatar is 2 rows, below the row of a reply. + for want, messageType := range map[int]discord.MessageType{2: discord.DefaultMessage, 3: discord.InlinedReplyMessage} { + ml.SetChannel(ml.channel, []discord.Message{{ID: 1, Type: messageType, Content: "hi"}}) + if got := ml.buildItem(ml.messageIndex(-1, 1)).Layout(limits).Height; got != want { + t.Fatalf("height = %d, want %d", got, want) + } + } +} diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index c099242..c5cdf08 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -272,7 +272,7 @@ func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd { } func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { - if !m.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) { + if !m.cfg.UI.MessagesList.Attachments.AllowedMIMETypes.Has(attachment.ContentType) { return ui.ShowDialog( "This attachment type is not allowed and may be unsafe. Continue anyway?", ui.DialogButton{Label: "No"}, diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go index f477fbd..2314bf6 100644 --- a/internal/ui/chat/messageslist/preview.go +++ b/internal/ui/chat/messageslist/preview.go @@ -73,13 +73,19 @@ func (m Model) previewCell() (width, height uint) { } func (m Model) loadPreviews() tview.Cmd { - if !m.cfg.Attachments.Preview { - return nil - } cellWidth, cellHeight := m.previewCell() var cmds []tview.Cmd for i := range m.items { item := &m.items[i] + if item.separator { + continue + } + + cmds = append(cmds, m.loadAvatar(item.message.Author)) + if !m.cfg.UI.MessagesList.Attachments.Preview { + continue + } + for _, s := range previewSources(item.message) { if _, ok := item.previews[s.proxy]; ok { continue @@ -101,6 +107,26 @@ func (m Model) loadPreviews() tview.Cmd { return tview.Batch(cmds...) } +func avatarURL(user discord.User) discord.URL { + return user.AvatarURLWithType(discord.PNGImage) + "?size=128" +} + +func (m Model) loadAvatar(user discord.User) tview.Cmd { + url, cfg := avatarURL(user), m.cfg.UI.MessagesList.Avatars + if _, ok := m.avatars[url]; ok || !cfg.Preview || cfg.Height == 0 || m.blocked(user) { + return nil + } + + cellWidth, cellHeight := m.previewCell() + cols := uint(cfg.Height) * cellHeight / cellWidth + bounds := image.Rect(0, 0, int(cols*cellWidth), int(cols*cellWidth)) + widget := func(img image.Image) tviewimage.Widget { + return tviewimage.New(img).Width(int(cols)).CellSize(int(cellWidth), int(cellHeight)) + } + m.avatars[url] = widget(image.NewAlpha(bounds)) + return m.loadPreview(0, url, bounds, widget) +} + // loadPreview yields an empty preview if loading fails. func (m Model) loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd { return func() tview.Msg { diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index 72ca6b1..39df738 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -427,14 +427,13 @@ func (m Model) canFocus(p pane) bool { func (m Model) View() tview.Widget { picker := m.pickerView() - var right tview.Widget = column.New( - m.paneView(paneMessagesList), - column.New(m.paneView(paneComposer)).Height(layout.Fixed(m.composer.Height())), - ) + messages := m.paneView(paneMessagesList) if mentions := m.composer.MentionsListView(m.focused == paneComposer); mentions != nil { - right = stack.New(right, mentions) + messages = stack.New(messages, mentions) } + var right tview.Widget = column.New(messages, m.paneView(paneComposer)) + main := right if m.membersTreeShown() { width := m.cfg.MembersTree.WidthPercent diff --git a/internal/ui/chat/model_test.go b/internal/ui/chat/model_test.go index 2cbeeb5..75d42fd 100644 --- a/internal/ui/chat/model_test.go +++ b/internal/ui/chat/model_test.go @@ -70,6 +70,8 @@ func TestModelAddMessageOrNotify(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := newTestModel(t) m.cfg.Notifications.WhenUnfocused = tt.whenUnfocused + // Loading an avatar is a command too. + m.cfg.UI.MessagesList.Avatars.Preview = false m.setSelectedChannel(&discord.Channel{ID: 1}) *m, _ = m.Update(tcell.NewEventFocus(tt.focused)) cmd := m.addMessageOrNotify(&gateway.MessageCreateEvent{Message: discord.Message{ID: 1, ChannelID: 1}}) diff --git a/internal/ui/login/model.go b/internal/ui/login/model.go index 21e2485..b92fb23 100644 --- a/internal/ui/login/model.go +++ b/internal/ui/login/model.go @@ -9,6 +9,7 @@ import ( "github.com/ayn2op/discordo/internal/ui/login/qr" "github.com/ayn2op/discordo/internal/ui/login/token" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/column" ) // tab is a login method shown as a tab. @@ -62,10 +63,9 @@ func (m Model) View() tview.Widget { } t := m.tabs(). Active(int(m.active)). - Content(content). Keybind(tabAction). OnSelect(func(i int) tview.Msg { return selectTabMsg(i) }) - return ui.Box(t, m.cfg, false) + return ui.Box(column.New(t, content), m.cfg, false) } // tabs returns the login methods as tabs set up by the tabs config. diff --git a/internal/ui/login/password/model.go b/internal/ui/login/password/model.go index 08d04b5..75d3ffe 100644 --- a/internal/ui/login/password/model.go +++ b/internal/ui/login/password/model.go @@ -78,8 +78,8 @@ func (m Model) View() tview.Widget { Focused(m.focus == focusButton). OnClick(submitMsg{}) return column.New( - row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Fixed(1)), - row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Fixed(1)), + row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Shrink), + row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Shrink), submit, - ).Spacing(1) + ).Spacing(1).Align(tview.AlignmentCenter) } diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index fde7738..2cc9ca3 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -133,7 +133,7 @@ func (m Model) View() tview.Widget { code := m.code() return center.New( viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState). - Height(layout.Fixed(len(code))). + Height(layout.Shrink). Focused(true). OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }), ) diff --git a/internal/ui/login/token/model.go b/internal/ui/login/token/model.go index d506ca2..245f3a1 100644 --- a/internal/ui/login/token/model.go +++ b/internal/ui/login/token/model.go @@ -59,7 +59,7 @@ func (m Model) View() tview.Widget { Focused(m.buttonFocused). OnClick(submitMsg{}) return column.New( - row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Fixed(1)), + row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink), submit, - ).Spacing(1) + ).Spacing(1).Align(tview.AlignmentCenter) } diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index a08ab34..6918dec 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -144,11 +144,6 @@ func (m *Model) updateScreen(msg tview.Msg) tview.Cmd { return cmd } -// helpHeight returns the rows the help takes at an unlimited width, as the width of the screen is not known in View. -func (m Model) helpHeight() int { - return m.helpView().Layout(layout.Limits{Infinite: layout.Axes{Height: true}}).Height -} - // View shows the inner model above help, with the dialog on top of both when one is open. func (m Model) View() tview.Widget { var innerView, helpView tview.Widget @@ -159,8 +154,7 @@ func (m Model) View() tview.Widget { innerView = m.chat.View() } if m.helpVisible { - padded := box.New(m.helpView()).Padding(0, 0, m.cfg.Help.Padding[0], m.cfg.Help.Padding[1]) - helpView = column.New(padded).Height(layout.Fixed(max(m.helpHeight(), 1))) + helpView = box.New(m.helpView()).Padding(0, 0, m.cfg.Help.Padding[0], m.cfg.Help.Padding[1]).Height(layout.Shrink) } content := column.New(innerView, helpView) if m.dialog == nil { diff --git a/internal/ui/root/model_test.go b/internal/ui/root/model_test.go index a4a3f1c..f1da765 100644 --- a/internal/ui/root/model_test.go +++ b/internal/ui/root/model_test.go @@ -21,10 +21,6 @@ func TestNewModel(t *testing.T) { if !m.helpVisible { t.Fatal("help is hidden") } - if height := m.helpHeight(); height != 1 { - t.Fatalf("height = %d, want 1", height) - } - m, _ = m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModCtrl)) if !m.helpShowAll { t.Fatal("toggle_full_help did not enable full help")