feat(messageslist): draw previews with kitty graphics protocol
This commit is contained in:
parent
1e675f10cf
commit
151eb89a1a
8 changed files with 94 additions and 51 deletions
2
go.mod
2
go.mod
|
|
@ -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
6
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue