feat(messageslist): draw previews with kitty graphics protocol

This commit is contained in:
ayn2op 2026-09-29 20:22:46 -04:00
parent 1e675f10cf
commit 151eb89a1a
8 changed files with 94 additions and 51 deletions

2
go.mod
View file

@ -15,7 +15,7 @@ require (
github.com/andybalholm/brotli v1.2.5
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-20260929205507-e9707583f12e
github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.5.0

6
go.sum
View file

@ -14,10 +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-20260928205525-fa36e0ece3f0 h1:mh5nA4XFDLYLmLs++EhN2X+6LODI0tTbRRQFp4Q4F/A=
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
github.com/ayn2op/tview v0.0.0-20260929205507-e9707583f12e h1:I5YDH5x+TzBinzFnupxdDUcHxQRjJoenCZffXDamn6g=
github.com/ayn2op/tview v0.0.0-20260929205507-e9707583f12e/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e h1:ahNO+CbxUf2KUFT0GjNZQf1yYf+FmqhDTDdv4U7cfkc=
github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
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

@ -66,6 +66,7 @@ type (
ShowLinks bool `toml:"show_links"`
AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"`
Preview bool `toml:"preview"`
Protocol string `toml:"protocol"`
}
DateSeparator struct {

View file

@ -26,6 +26,8 @@ show_links = true
allowed_mime_types = ["image/*", "video/*"]
# Whether to draw image attachments and embedded images below their messages.
preview = true
# How previews are drawn: "auto", "kitty" or "halfblocks".
protocol = "auto"
[composer]
# Maximum height (in newline-separated rows) the composer grows to before it starts scrolling internally.

View file

@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"image"
"io"
"log/slog"
"mime"
@ -29,6 +28,7 @@ import (
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/help"
tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/picker"
@ -50,8 +50,8 @@ type messageItem struct {
separator bool
timestamp discord.Timestamp
// previews holds the message's images by proxy URL, blank while loading and nil if they failed to.
previews map[discord.URL]image.Image
// A preview is blank while loading and empty if loading failed.
previews map[discord.URL]tviewimage.Widget
}
type Model struct {
@ -71,6 +71,11 @@ type Model struct {
// renderWidth is the width the message being rendered is laid out at.
renderWidth int
// kitty reports whether previews are drawn with kitty's graphics protocol.
// kittyImages counts the images sent to the terminal.
kitty bool
kittyImages int
}
var _ help.KeyMap = Model{}
@ -267,8 +272,8 @@ func (ml *Model) buildItem(index int) list.Item {
return ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style)
}}
for _, s := range previewSources(message) {
if img := item.previews[s.proxy]; img != nil {
view.images = append(view.images, img)
if preview, ok := item.previews[s.proxy]; ok {
view.images = append(view.images, preview)
}
}
item.view = view
@ -276,10 +281,10 @@ func (ml *Model) buildItem(index int) list.Item {
return item.view
}
// messageView renders a message for the width it is laid out at, keeping the wrapped lines until the width changes, followed by its image previews.
// messageView renders a message followed by its image previews, caching the wrapped lines until the width changes.
type messageView struct {
render func(width int) richtext.Text
images []image.Image
images []tviewimage.Widget
width int
rendered bool
lines richtext.Text
@ -298,8 +303,8 @@ func (v *messageView) at(width int) textview.Widget {
func (v *messageView) Rows(width int) int {
v.at(width)
rows := len(v.lines)
for _, img := range v.images {
_, height := preview(img, width).Size()
for _, preview := range v.images {
_, height := preview.Size()
rows += height.Cells()
}
return rows
@ -308,10 +313,9 @@ func (v *messageView) Rows(width int) int {
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)
for _, img := range v.images {
p := preview(img, area.Width)
_, height := p.Size()
p.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()})
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()
}
}
@ -942,9 +946,26 @@ func (ml *Model) update(msg tview.Msg) tview.Cmd {
if index < 0 || ml.items[index].previews == nil {
return nil
}
ml.items[index].previews[msg.proxy] = msg.image
// Drop the view, so it is laid out again with the preview.
preview, cmd := msg.preview, tview.Cmd(nil)
if ml.kitty {
ml.kittyImages++
preview = preview.Kitty(ml.kittyImages%63488 + 1)
cmd = preview.Transmit()
}
ml.items[index].previews[msg.proxy] = preview
// Invalidate the view so it is laid out with the preview.
ml.items[index].view = nil
return cmd
case tview.TerminalInfoMsg:
switch ml.cfg.Attachments.Protocol {
case "kitty":
ml.kitty = true
case "halfblocks":
ml.kitty = false
default:
// WezTerm and Konsole support kitty graphics but not Unicode placeholders.
ml.kitty = msg.Name == "kitty" || msg.Name == "ghostty"
}
case listMsg:
ml.selectionState.Apply(list.Change(msg))
ml.onRowCursorChanged(ml.cursor())

View file

@ -17,24 +17,28 @@ import (
_ "golang.org/x/image/webp"
)
// previewWidth is the widest a preview is drawn, in cells.
const previewWidth = 40
// Previews fit in a box of previewWidth by previewHeight cells, roughly square in pixels.
const (
previewWidth = 40
previewHeight = 20
)
// kittyPixels is the number of pixels loaded per column of a kitty preview, roughly the width of a cell.
const kittyPixels = 10
type previewLoadedMsg struct {
messageID discord.MessageID
proxy discord.URL
image image.Image
preview tviewimage.Widget
}
func (previewLoadedMsg) messagesList() {}
// previewSource is an image to preview.
type previewSource struct {
proxy discord.URL
width, height uint
}
// previewSources returns the images of message's attachments and embeds.
func previewSources(message discord.Message) []previewSource {
var sources []previewSource
for _, a := range message.Attachments {
@ -44,26 +48,28 @@ func previewSources(message discord.Message) []previewSource {
switch {
case e.Image != nil:
sources = append(sources, previewSource{e.Image.Proxy, e.Image.Width, e.Image.Height})
// Image links and GIFs have their media as the thumbnail.
// Image and GIF embeds carry the media itself as the thumbnail.
case e.Thumbnail != nil && (e.Type == discord.ImageEmbed || e.Type == discord.GIFVEmbed):
sources = append(sources, previewSource{e.Thumbnail.Proxy, e.Thumbnail.Width, e.Thumbnail.Height})
}
}
// Files that are not images have no size.
// Non-image files have no dimensions.
return slices.DeleteFunc(sources, func(s previewSource) bool { return s.width == 0 || s.height == 0 })
}
// bounds returns the size s is loaded at, a pixel per cell of its preview.
func (s previewSource) bounds() image.Rectangle {
width := min(s.width, previewWidth)
return image.Rect(0, 0, int(width), int(s.height*width/s.width))
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))
}
// loadPreviews loads the previews of the shown messages that are not loaded yet.
func (ml *Model) loadPreviews() tview.Cmd {
if !ml.cfg.Attachments.Preview {
return nil
}
pixels := 1
if ml.kitty {
pixels = kittyPixels
}
var cmds []tview.Cmd
for i := range ml.items {
item := &ml.items[i]
@ -72,34 +78,35 @@ func (ml *Model) loadPreviews() tview.Cmd {
continue
}
if item.previews == nil {
item.previews = make(map[discord.URL]image.Image)
item.previews = make(map[discord.URL]tviewimage.Widget)
}
// A transparent image of the preview's size reserves its rows while it loads, so the messages below do not jump.
item.previews[s.proxy] = image.NewAlpha(s.bounds())
cmds = append(cmds, loadPreview(item.message.ID, s))
width := min(s.width, uint(s.cols()*pixels))
bounds := image.Rect(0, 0, int(width), int(s.height*width/s.width))
// 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))
}
}
return tview.Batch(cmds...)
}
// loadPreview loads the preview of s, with a nil image if it fails to.
func loadPreview(messageID discord.MessageID, s previewSource) tview.Cmd {
// loadPreview yields an empty preview if loading fails.
func loadPreview(messageID discord.MessageID, s previewSource, bounds image.Rectangle) tview.Cmd {
return func() tview.Msg {
img, err := downloadPreview(s)
img, err := downloadPreview(s.proxy, bounds)
if err != nil {
slog.Error("failed to load preview", "err", err)
img = &image.Alpha{}
}
return previewLoadedMsg{messageID: messageID, proxy: s.proxy, image: img}
return previewLoadedMsg{messageID: messageID, proxy: s.proxy, preview: tviewimage.New(img).Width(s.cols())}
}
}
// downloadPreview downloads s from the media proxy, scaled down to its bounds.
func downloadPreview(s previewSource) (image.Image, error) {
u, err := url.Parse(s.proxy)
func downloadPreview(proxy discord.URL, bounds image.Rectangle) (image.Image, error) {
u, err := url.Parse(proxy)
if err != nil {
return nil, err
}
bounds := s.bounds()
query := u.Query()
query.Set("width", strconv.Itoa(bounds.Dx()))
query.Set("height", strconv.Itoa(bounds.Dy()))
@ -113,8 +120,3 @@ func downloadPreview(s previewSource) (image.Image, error) {
img, _, err := image.Decode(resp.Body)
return img, err
}
// preview returns the widget that draws img within width cells.
func preview(img image.Image, width int) tviewimage.Widget {
return tviewimage.New(img).Width(min(width, previewWidth, img.Bounds().Dx()))
}

View file

@ -35,3 +35,22 @@ func TestPreviewSources(t *testing.T) {
})
}
}
func TestPreviewSourceCols(t *testing.T) {
for _, tt := range []struct {
name string
width, height uint
want int
}{
{"landscape", 1600, 900, 40},
{"portrait", 900, 1600, 22},
{"small", 10, 10, 10},
{"very tall", 1, 10000, 1},
} {
t.Run(tt.name, func(t *testing.T) {
if got := (previewSource{width: tt.width, height: tt.height}).cols(); got != tt.want {
t.Fatalf("cols() = %d, want %d", got, tt.want)
}
})
}
}

View file

@ -207,7 +207,7 @@ func (m *Model) cycleFocus(step pane) {
}
func (m Model) Init() tview.Cmd {
return tview.Batch(openState(m.state), listen(m.events))
return tview.Batch(openState(m.state), listen(m.events), tview.RequestTerminalInfo())
}
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
@ -241,7 +241,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:
case messageslist.Msg, tview.TerminalInfoMsg:
return m.updatePane(messagesListPane, msg)
case typingExpiredMsg:
if until, ok := m.typers[msg.userID]; ok && !time.Now().Before(until) {