diff --git a/go.mod b/go.mod index f18aaf7..e120039 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 2768127..0c95f6d 100644 --- a/go.sum +++ b/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= diff --git a/internal/config/config.go b/internal/config/config.go index 3e930b1..eafb934 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/config.toml b/internal/config/config.toml index 10d40d2..52593a1 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -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. diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index f9fc685..da83d74 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -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()) diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go index 41e1be8..b34e553 100644 --- a/internal/ui/chat/messageslist/preview.go +++ b/internal/ui/chat/messageslist/preview.go @@ -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())) -} diff --git a/internal/ui/chat/messageslist/preview_test.go b/internal/ui/chat/messageslist/preview_test.go index 6290903..a09337f 100644 --- a/internal/ui/chat/messageslist/preview_test.go +++ b/internal/ui/chat/messageslist/preview_test.go @@ -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) + } + }) + } +} diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index 26a551f..89f0397 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -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) {