From ae386daf5b121e233bd777432c2132caf9006506 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Thu, 26 Oct 2023 12:09:13 -0500 Subject: msgviewer: implement inline image viewing Implement inline image viewing for jpeg, png, bmp, tiff, and webp formats. When a user has no configured image filter and the image is supported and the terminal has either sixel or kitty image protocol support, the image will be displayed in the message viewer. Always clear the screen before each draw. This call is necessary in vaxis to allow for images to be cleared properly between renders. There is no performance impact: the call only resets each cell to a blank cell, and aerc will redraw each one already. Signed-off-by: Tim Culverhouse Acked-by: Robin Jarry --- app/msgviewer.go | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- go.mod | 2 +- lib/ui/ui.go | 1 + 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/app/msgviewer.go b/app/msgviewer.go index abed93fd..da28bd4a 100644 --- a/app/msgviewer.go +++ b/app/msgviewer.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "image" "io" "os" "os/exec" @@ -24,8 +25,28 @@ import ( "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/go-opt" + "git.sr.ht/~rockorager/vaxis" + "git.sr.ht/~rockorager/vaxis/widgets/align" + + // Image support + _ "image/jpeg" + _ "image/png" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" ) +// All imported image types need to be explicitly stated here. We want to check +// if we _can_ display something before we download it +var supportedImageTypes = []string{ + "image/jpeg", + "image/png", + "image/bmp", + "image/tiff", + "image/webp", +} + var _ ProvidesMessages = (*MessageViewer)(nil) type MessageViewer struct { @@ -405,6 +426,11 @@ type PartViewer struct { noFilter *ui.Grid uiConfig *config.UIConfig copying int32 + inlineImg bool + image image.Image + graphic *vaxis.Graphic + width int + height int links []string } @@ -540,7 +566,27 @@ func NewPartViewer( func (pv *PartViewer) SetSource(reader io.Reader) { pv.source = reader - pv.attemptCopy() + switch pv.inlineImg { + case true: + pv.decodeImage() + default: + pv.attemptCopy() + } +} + +func (pv *PartViewer) decodeImage() { + atomic.StoreInt32(&pv.copying, copying) + go func() { + defer log.PanicHandler() + defer pv.Invalidate() + defer atomic.StoreInt32(&pv.copying, 0) + img, _, err := image.Decode(pv.source) + if err != nil { + log.Errorf("error decoding image: %v", err) + return + } + pv.image = img + }() } func (pv *PartViewer) attemptCopy() { @@ -716,7 +762,13 @@ func (pv *PartViewer) Invalidate() { func (pv *PartViewer) Draw(ctx *ui.Context) { style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT) - if pv.filter == nil { + switch { + case pv.filter == nil && canInline(pv.part.FullMIMEType()) && pv.err == nil: + pv.inlineImg = true + case pv.filter == nil: + // No filter, can't inline, and/or we attempted to inline an image + // and resulted in an error (maybe because of a bad encoding or + // the terminal doesn't support any graphics protocol). ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) pv.noFilter.Draw(ctx) return @@ -733,12 +785,55 @@ func (pv *PartViewer) Draw(ctx *ui.Context) { if pv.term != nil { pv.term.Draw(ctx) } + if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) { + // This path should only occur on resizes or the first pass + // after the image is downloaded and could be slow due to + // encoding the image to either sixel or uploading via the kitty + // protocol. Generally it's pretty fast since we will only ever + // be downsizing images + vx := ctx.Window().Vx + img := vx.ResizeGraphic(pv.image, pv.width, pv.height) + var err error + if pv.graphic != nil { + // Remove the old graphic from memory + pv.graphic.Delete() + } + pv.graphic, err = vx.NewGraphic(img) + if err != nil { + // We nil the image to let the garbage collector clean + // it up + pv.image = nil + pv.err = err + log.Errorf("couldn't create graphic: %v", err) + pv.Invalidate() + return + } + } + if pv.graphic != nil { + w, h := pv.graphic.CellSize() + win := align.Center(ctx.Window(), w, h) + pv.graphic.Draw(win) + } } func (pv *PartViewer) Cleanup() { if pv.term != nil { pv.term.Close() } + if pv.graphic != nil { + pv.graphic.Delete() + } +} + +func (pv *PartViewer) resized(ctx *ui.Context) bool { + w := ctx.Width() + h := ctx.Height() + if pv.width != w || pv.height != h { + pv.width = w + pv.height = h + return true + } + return false } func (pv *PartViewer) Event(event tcell.Event) bool { @@ -784,3 +879,12 @@ func (hv *HeaderView) Draw(ctx *ui.Context) { func (hv *HeaderView) Invalidate() { ui.Invalidate() } + +func canInline(mime string) bool { + for _, ext := range supportedImageTypes { + if mime == ext { + return true + } + } + return false +} diff --git a/go.mod b/go.mod index 2803868a..43c5fe74 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/syndtr/goleveldb v1.0.0 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e + golang.org/x/image v0.13.0 golang.org/x/oauth2 v0.7.0 golang.org/x/sys v0.13.0 golang.org/x/tools v0.14.0 @@ -53,7 +54,6 @@ require ( github.com/soniakeys/quant v1.0.0 // indirect golang.org/x/crypto v0.14.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/image v0.13.0 // indirect golang.org/x/net v0.16.0 // indirect golang.org/x/term v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect diff --git a/lib/ui/ui.go b/lib/ui/ui.go index 6a28eb9c..8a4487ef 100644 --- a/lib/ui/ui.go +++ b/lib/ui/ui.go @@ -118,6 +118,7 @@ func Close() { func Render() { if atomic.SwapUint32(&state.dirty, 0) != 0 { + state.screen.Clear() // reset popover for the next Draw state.popover = nil state.content.Draw(state.ctx) -- cgit v1.2.3-54-g00ecf