feat(messageslist): load previews at the terminal's cell size

This commit is contained in:
ayn2op 2026-10-04 18:50:20 -04:00
parent d1867ee14c
commit c947abdf0e
8 changed files with 57 additions and 27 deletions

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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":

View file

@ -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)}
}
}

View file

@ -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)
}
})

View file

@ -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) {

View file

@ -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)

View file

@ -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.