From c947abdf0e821fab3aacec834f3b5e5c06a7fc21 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Sun, 4 Oct 2026 18:50:20 -0400 Subject: [PATCH] feat(messageslist): load previews at the terminal's cell size --- go.mod | 2 +- go.sum | 4 +- internal/ui/chat/messageslist/model.go | 8 ++++ internal/ui/chat/messageslist/preview.go | 40 ++++++++++++------- internal/ui/chat/messageslist/preview_test.go | 18 +++++---- internal/ui/chat/model.go | 2 +- internal/ui/root/model.go | 4 ++ internal/ui/root/msg.go | 6 ++- 8 files changed, 57 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 8b49df7..11e2bc1 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/andybalholm/brotli v1.2.6 github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e - github.com/ayn2op/tview v0.0.0-20261004194222-a506691173ce + github.com/ayn2op/tview v0.0.0-20261004231500-f79adb05b560 github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.5.0 diff --git a/go.sum b/go.sum index 1e5ddfc..45146dc 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c h1:Qkl3W5RXzUQUR github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE= 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-20261004194222-a506691173ce h1:QbE9slkt2GwIw9zmE8ubmn+mejNsvfFaUp/4IOB5eBY= -github.com/ayn2op/tview v0.0.0-20261004194222-a506691173ce/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY= +github.com/ayn2op/tview v0.0.0-20261004231500-f79adb05b560 h1:joRW9LhEPGkbhKgs8tUMgmoXPzH8v+5GQSe6geS0crg= +github.com/ayn2op/tview v0.0.0-20261004231500-f79adb05b560/go.mod h1:sMf5NqBlGJjsaGCUycTqTyNG5vewJU6jq/3BdJIzbCU= 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= diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index 87abcb8..73e1ca6 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -61,6 +61,8 @@ type Model struct { // kittyImages counts the images sent to the terminal. kitty bool kittyImages int + // cellWidth and cellHeight are the size of a terminal cell in pixels, or zero if the terminal does not report it. + cellWidth, cellHeight uint } var _ help.KeyMap = Model{} @@ -924,6 +926,12 @@ func (ml *Model) update(msg tview.Msg) tview.Cmd { // Invalidate the view so it is laid out with the preview. ml.items[index].view = nil return cmd + case tview.ResizeMsg: + cols, rows := msg.Size() + width, height := msg.PixelSize() + if cols > 0 && rows > 0 { + ml.cellWidth, ml.cellHeight = uint(width/cols), uint(height/rows) + } case tview.TerminalInfoMsg: switch ml.cfg.Attachments.Protocol { case "kitty": diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go index b34e553..0e9fe60 100644 --- a/internal/ui/chat/messageslist/preview.go +++ b/internal/ui/chat/messageslist/preview.go @@ -23,7 +23,7 @@ const ( previewHeight = 20 ) -// kittyPixels is the number of pixels loaded per column of a kitty preview, roughly the width of a cell. +// kittyPixels is the width of a cell in pixels assumed for kitty previews when the terminal does not report it. const kittyPixels = 10 type previewLoadedMsg struct { @@ -57,19 +57,27 @@ func previewSources(message discord.Message) []previewSource { return slices.DeleteFunc(sources, func(s previewSource) bool { return s.width == 0 || s.height == 0 }) } -func (s previewSource) cols() int { - // A cell is about twice as tall as wide. - return int(max(min(s.width, previewWidth, s.width*previewHeight*2/s.height), 1)) +// cols returns the preview's width in cells of cellWidth by cellHeight pixels: its own width at most, within the preview box. +func (s previewSource) cols(cellWidth, cellHeight uint) int { + return int(max(min((s.width+cellWidth-1)/cellWidth, previewWidth, s.width*previewHeight*cellHeight/(s.height*cellWidth)), 1)) +} + +// previewCell returns the size of a cell in image pixels: the terminal's for kitty, and one pixel by two for half blocks. +func (ml *Model) previewCell() (width, height uint) { + switch { + case !ml.kitty: + return 1, 2 + case ml.cellWidth == 0 || ml.cellHeight == 0: + return kittyPixels, kittyPixels * 2 + } + return ml.cellWidth, ml.cellHeight } func (ml *Model) loadPreviews() tview.Cmd { if !ml.cfg.Attachments.Preview { return nil } - pixels := 1 - if ml.kitty { - pixels = kittyPixels - } + cellWidth, cellHeight := ml.previewCell() var cmds []tview.Cmd for i := range ml.items { item := &ml.items[i] @@ -80,25 +88,29 @@ func (ml *Model) loadPreviews() tview.Cmd { if item.previews == nil { item.previews = make(map[discord.URL]tviewimage.Widget) } - width := min(s.width, uint(s.cols()*pixels)) + cols := s.cols(cellWidth, cellHeight) + width := min(s.width, uint(cols)*cellWidth) bounds := image.Rect(0, 0, int(width), int(s.height*width/s.width)) + widget := func(img image.Image) tviewimage.Widget { + return tviewimage.New(img).Width(cols).CellSize(int(cellWidth), int(cellHeight)) + } // Reserve the preview's rows with a blank image of the same size, so the messages below do not shift when it loads. - item.previews[s.proxy] = tviewimage.New(image.NewAlpha(bounds)).Width(s.cols()) - cmds = append(cmds, loadPreview(item.message.ID, s, bounds)) + item.previews[s.proxy] = widget(image.NewAlpha(bounds)) + cmds = append(cmds, loadPreview(item.message.ID, s.proxy, bounds, widget)) } } return tview.Batch(cmds...) } // loadPreview yields an empty preview if loading fails. -func loadPreview(messageID discord.MessageID, s previewSource, bounds image.Rectangle) tview.Cmd { +func loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd { return func() tview.Msg { - img, err := downloadPreview(s.proxy, bounds) + img, err := downloadPreview(proxy, bounds) if err != nil { slog.Error("failed to load preview", "err", err) img = &image.Alpha{} } - return previewLoadedMsg{messageID: messageID, proxy: s.proxy, preview: tviewimage.New(img).Width(s.cols())} + return previewLoadedMsg{messageID: messageID, proxy: proxy, preview: widget(img)} } } diff --git a/internal/ui/chat/messageslist/preview_test.go b/internal/ui/chat/messageslist/preview_test.go index a09337f..9c68420 100644 --- a/internal/ui/chat/messageslist/preview_test.go +++ b/internal/ui/chat/messageslist/preview_test.go @@ -38,17 +38,19 @@ func TestPreviewSources(t *testing.T) { func TestPreviewSourceCols(t *testing.T) { for _, tt := range []struct { - name string - width, height uint - want int + name string + width, height uint + cellWidth, cellHeight uint + want int }{ - {"landscape", 1600, 900, 40}, - {"portrait", 900, 1600, 22}, - {"small", 10, 10, 10}, - {"very tall", 1, 10000, 1}, + {"landscape", 1600, 900, 1, 2, 40}, + {"portrait", 900, 1600, 1, 2, 22}, + {"small", 10, 10, 1, 2, 10}, + {"very tall", 1, 10000, 1, 2, 1}, + {"own width in cells", 200, 100, 10, 20, 20}, } { t.Run(tt.name, func(t *testing.T) { - if got := (previewSource{width: tt.width, height: tt.height}).cols(); got != tt.want { + if got := (previewSource{width: tt.width, height: tt.height}).cols(tt.cellWidth, tt.cellHeight); got != tt.want { t.Fatalf("cols() = %d, want %d", got, tt.want) } }) diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index d1e36fd..da3111a 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -224,7 +224,7 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { } title := ui.ChannelToString(msg.Channel, m.cfg.Icons, m.state) + " - " + consts.Name return tview.Batch(tview.SetTitle(title), m.messagesList.SetChannel(&msg.Channel, msg.Messages), membersCmd) - case messageslist.Msg, tview.TerminalInfoMsg: + case messageslist.Msg, tview.TerminalInfoMsg, tview.ResizeMsg: return m.updatePane(paneMessagesList, msg) case typingExpiredMsg: if until, ok := m.typers[msg.userID]; ok && !time.Now().Before(until) { diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index c5a3550..e6cd61d 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -45,6 +45,8 @@ type Model struct { // modal is the request shown in a dialog, or nil when none is open, and dialogFocus its focused button. modal *ui.ModalMsg dialogFocus int + // resize is the last size of the window, or nil before the first, kept for the models shown later. + resize tview.ResizeMsg cfg *config.Config } @@ -128,6 +130,8 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { if m.modal != nil { return nil } + case tview.ResizeMsg: + m.resize = msg } return m.updateScreen(msg) diff --git a/internal/ui/root/msg.go b/internal/ui/root/msg.go index c50a096..f5102f8 100644 --- a/internal/ui/root/msg.go +++ b/internal/ui/root/msg.go @@ -71,7 +71,11 @@ func (m *Model) showLogin() tview.Cmd { func (m *Model) showChat(token string) tview.Cmd { m.screen, m.chat, m.login = screenChat, chat.NewModel(m.cfg, token), login.Model{} - return m.show(m.chat.Init()) + init := m.chat.Init() + if m.resize != nil { + init = tview.Batch(init, m.updateScreen(m.resize)) + } + return m.show(init) } // show closes any open modal after the shown model changed and returns what it needs done, with init its first command.