fix(composite): async image decode, skip dead pager, prevent UI freeze

- Decode images in background goroutine, not in Draw()
- Show [loading image...] placeholder until decode completes
- Skip pv.term.Draw() when composite is active (pager got no input)
- Thread-safe access to decoded image cache via sync.Mutex
- Invalidate callback triggers redraw when images finish decoding
This commit is contained in:
Mortdecai
2026-04-07 21:11:44 -04:00
parent 20161fedeb
commit 046ff03f8b
2 changed files with 91 additions and 71 deletions
+85 -67
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync"
"git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rjarry/aerc/lib/log" "git.sr.ht/~rjarry/aerc/lib/log"
@@ -24,30 +25,28 @@ const (
blockText = 0 blockText = 0
blockImage = 1 blockImage = 1
// Default height in terminal rows for an image block.
defaultImageHeight = 15 defaultImageHeight = 15
) )
type contentBlock struct { type contentBlock struct {
blockType int blockType int
lines []string // for text blocks lines []string
imageIdx int // for image blocks — index into compositeContent.images imageIdx int
} }
// compositeContent holds the parsed content model: an ordered sequence of
// text and image blocks derived from filter output after ExtractImages().
type compositeContent struct { type compositeContent struct {
blocks []contentBlock blocks []contentBlock
images []parse.ImageRef images []parse.ImageRef
width int // terminal columns available width int
// Rendering state (populated lazily during Draw) mu sync.Mutex
decoded map[int]image.Image // imageIdx → decoded Go image decoded map[int]image.Image
graphics map[int]vaxis.Image // imageIdx → Vaxis graphic handle decErrs map[int]bool // true = decode attempted and failed
graphics map[int]vaxis.Image
invalidate func() // callback to trigger redraw after async decode
} }
// splitContentBlocks splits text at \x00IMG:N\x00 placeholder lines into
// an ordered slice of text and image blocks.
func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock { func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
imageSet := make(map[int]bool, len(images)) imageSet := make(map[int]bool, len(images))
for _, img := range images { for _, img := range images {
@@ -58,14 +57,12 @@ func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
var currentLines []string var currentLines []string
lines := strings.Split(text, "\n") lines := strings.Split(text, "\n")
// Remove trailing empty line from Split if text ends with \n
if len(lines) > 0 && lines[len(lines)-1] == "" { if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1] lines = lines[:len(lines)-1]
} }
for _, line := range lines { for _, line := range lines {
if idx, ok := parsePlaceholder(line); ok && imageSet[idx] { if idx, ok := parsePlaceholder(line); ok && imageSet[idx] {
// Flush accumulated text lines
if len(currentLines) > 0 { if len(currentLines) > 0 {
blocks = append(blocks, contentBlock{ blocks = append(blocks, contentBlock{
blockType: blockText, blockType: blockText,
@@ -90,7 +87,6 @@ func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
return blocks return blocks
} }
// parsePlaceholder checks if a line is \x00IMG:N\x00 and returns N.
func parsePlaceholder(line string) (int, bool) { func parsePlaceholder(line string) (int, bool) {
if !strings.HasPrefix(line, "\x00IMG:") || !strings.HasSuffix(line, "\x00") { if !strings.HasPrefix(line, "\x00IMG:") || !strings.HasSuffix(line, "\x00") {
return 0, false return 0, false
@@ -103,17 +99,52 @@ func parsePlaceholder(line string) (int, bool) {
return idx, true return idx, true
} }
func newCompositeContent(text string, images []parse.ImageRef, width int) *compositeContent { func newCompositeContent(text string, images []parse.ImageRef, width int, invalidate func()) *compositeContent {
return &compositeContent{ cc := &compositeContent{
blocks: splitContentBlocks(text, images), blocks: splitContentBlocks(text, images),
images: images, images: images,
width: width, width: width,
decoded: make(map[int]image.Image), decoded: make(map[int]image.Image),
graphics: make(map[int]vaxis.Image), decErrs: make(map[int]bool),
graphics: make(map[int]vaxis.Image),
invalidate: invalidate,
}
// Decode all images in background
go cc.decodeAllImages()
return cc
}
// decodeAllImages decodes all referenced images in a background goroutine.
// Calls invalidate() when done so the UI redraws with images.
func (cc *compositeContent) decodeAllImages() {
defer log.PanicHandler()
for i, img := range cc.images {
f, err := os.Open(img.Path)
if err != nil {
log.Debugf("composite: open image %d: %v", i, err)
cc.mu.Lock()
cc.decErrs[i] = true
cc.mu.Unlock()
continue
}
decoded, _, err := image.Decode(f)
f.Close()
if err != nil {
log.Debugf("composite: decode image %d: %v", i, err)
cc.mu.Lock()
cc.decErrs[i] = true
cc.mu.Unlock()
continue
}
cc.mu.Lock()
cc.decoded[i] = decoded
cc.mu.Unlock()
}
if cc.invalidate != nil {
cc.invalidate()
} }
} }
// totalHeight returns the total height in terminal rows.
func (cc *compositeContent) totalHeight() int { func (cc *compositeContent) totalHeight() int {
h := 0 h := 0
for _, b := range cc.blocks { for _, b := range cc.blocks {
@@ -127,14 +158,15 @@ func (cc *compositeContent) totalHeight() int {
return h return h
} }
// imageHeight returns the display height for an image block.
func (cc *compositeContent) imageHeight(idx int) int { func (cc *compositeContent) imageHeight(idx int) int {
if img, ok := cc.decoded[idx]; ok { cc.mu.Lock()
img, ok := cc.decoded[idx]
cc.mu.Unlock()
if ok {
bounds := img.Bounds() bounds := img.Bounds()
if bounds.Dx() == 0 { if bounds.Dx() == 0 {
return defaultImageHeight return defaultImageHeight
} }
// Approximate: 2 terminal rows per pixel row (cells are ~2:1 aspect)
ratio := float64(bounds.Dy()) / float64(bounds.Dx()) ratio := float64(bounds.Dy()) / float64(bounds.Dx())
h := int(float64(cc.width) * ratio / 2.0) h := int(float64(cc.width) * ratio / 2.0)
if h < 3 { if h < 3 {
@@ -148,49 +180,26 @@ func (cc *compositeContent) imageHeight(idx int) int {
return defaultImageHeight return defaultImageHeight
} }
// decodeImage loads and decodes the image file for the given index.
func (cc *compositeContent) decodeImage(idx int) (image.Image, error) {
if img, ok := cc.decoded[idx]; ok {
return img, nil
}
if idx < 0 || idx >= len(cc.images) {
return nil, fmt.Errorf("image index %d out of range", idx)
}
path := cc.images[idx].Path
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
cc.decoded[idx] = img
return img, nil
}
// Destroy releases all Vaxis graphics handles.
func (cc *compositeContent) Destroy() { func (cc *compositeContent) Destroy() {
cc.mu.Lock()
defer cc.mu.Unlock()
for _, g := range cc.graphics { for _, g := range cc.graphics {
g.Destroy() g.Destroy()
} }
cc.graphics = make(map[int]vaxis.Image) cc.graphics = make(map[int]vaxis.Image)
} }
// CleanupFiles removes temp image files from disk.
func (cc *compositeContent) CleanupFiles() { func (cc *compositeContent) CleanupFiles() {
for _, img := range cc.images { for _, img := range cc.images {
os.Remove(img.Path) os.Remove(img.Path)
} }
} }
// Draw renders the composite content into the given UI context. // Draw renders composite content. Only renders images that are already decoded.
// scroll is the row offset from the top. style is the default text style.
func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Style) { func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Style) {
width := ctx.Width() width := ctx.Width()
height := ctx.Height() height := ctx.Height()
row := -scroll // start above viewport if scrolled row := -scroll
defaultStyle := vaxis.Style{} defaultStyle := vaxis.Style{}
if len(style) > 0 { if len(style) > 0 {
@@ -213,25 +222,31 @@ func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Sty
case blockImage: case blockImage:
imgH := cc.imageHeight(b.imageIdx) imgH := cc.imageHeight(b.imageIdx)
if row+imgH > 0 && row < height { if row+imgH > 0 && row < height {
cc.drawImage(ctx, b.imageIdx, row, width, imgH) cc.drawImage(ctx, b.imageIdx, row, width, imgH, defaultStyle)
} }
row += imgH row += imgH
} }
} }
} }
func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height int) { func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height int, style vaxis.Style) {
img, err := cc.decodeImage(idx) cc.mu.Lock()
if err != nil { img, decoded := cc.decoded[idx]
// Show alt text as fallback failed := cc.decErrs[idx]
alt := "[image]" cc.mu.Unlock()
if idx < len(cc.images) && cc.images[idx].Alt != "" {
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt) // Show alt text if not yet decoded or failed
if !decoded {
alt := "[loading image...]"
if failed {
alt = "[image]"
if idx < len(cc.images) && cc.images[idx].Alt != "" {
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt)
}
} }
if row >= 0 && row < ctx.Height() { if row >= 0 && row < ctx.Height() {
ctx.Printf(0, row, vaxis.Style{}, "%s", alt) ctx.Printf(0, row, style, "%s", alt)
} }
log.Debugf("composite: image %d decode error: %v", idx, err)
return return
} }
@@ -241,16 +256,21 @@ func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height i
var err error var err error
graphic, err = vx.NewImage(img) graphic, err = vx.NewImage(img)
if err != nil { if err != nil {
log.Errorf("composite: vaxis image %d error: %v", idx, err) log.Errorf("composite: vaxis NewImage %d: %v", idx, err)
if row >= 0 && row < ctx.Height() {
alt := "[image: render error]"
if idx < len(cc.images) && cc.images[idx].Alt != "" {
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt)
}
ctx.Printf(0, row, style, "%s", alt)
}
return return
} }
cc.graphics[idx] = graphic cc.graphics[idx] = graphic
} }
// Resize to fit
graphic.Resize(width, height) graphic.Resize(width, height)
// Draw within the region — use the sub-window from the current context
startRow := row startRow := row
if startRow < 0 { if startRow < 0 {
startRow = 0 startRow = 0
@@ -259,9 +279,7 @@ func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height i
graphic.Draw(win) graphic.Draw(win)
} }
// truncateLine cuts a string to fit width.
func truncateLine(s string, width int) string { func truncateLine(s string, width int) string {
// Use rune count for basic unicode support
runes := []rune(s) runes := []rune(s)
if len(runes) <= width { if len(runes) <= width {
return s return s
+6 -4
View File
@@ -636,7 +636,8 @@ func (pv *PartViewer) attemptCopy() {
if len(images) > 0 { if len(images) > 0 {
pv.imageRefs = images pv.imageRefs = images
pv.composite = newCompositeContent( pv.composite = newCompositeContent(
string(cleanedBytes), images, 80) string(cleanedBytes), images, 80,
pv.Invalidate)
pv.Invalidate() pv.Invalidate()
} else { } else {
// No images — forward to pager as normal // No images — forward to pager as normal
@@ -835,10 +836,8 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
ctx.Printf(0, 0, style, "%s", pv.err.Error()) ctx.Printf(0, 0, style, "%s", pv.err.Error())
return return
} }
if pv.term != nil {
pv.term.Draw(ctx)
}
// Composite mode: text + inline images from filter output // Composite mode: text + inline images from filter output
// Skip term.Draw() — pager got no input in composite mode
if pv.composite != nil { if pv.composite != nil {
pv.composite.width = ctx.Width() pv.composite.width = ctx.Width()
style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT) style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT)
@@ -846,6 +845,9 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
pv.composite.Draw(ctx, pv.scroll, style) pv.composite.Draw(ctx, pv.scroll, style)
return return
} }
if pv.term != nil {
pv.term.Draw(ctx)
}
if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) { if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) {
// This path should only occur on resizes or the first pass // This path should only occur on resizes or the first pass
// after the image is downloaded and could be slow due to // after the image is downloaded and could be slow due to