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:
+85
-67
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.sr.ht/~rockorager/vaxis"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
@@ -24,30 +25,28 @@ const (
|
||||
blockText = 0
|
||||
blockImage = 1
|
||||
|
||||
// Default height in terminal rows for an image block.
|
||||
defaultImageHeight = 15
|
||||
)
|
||||
|
||||
type contentBlock struct {
|
||||
blockType int
|
||||
lines []string // for text blocks
|
||||
imageIdx int // for image blocks — index into compositeContent.images
|
||||
lines []string
|
||||
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 {
|
||||
blocks []contentBlock
|
||||
images []parse.ImageRef
|
||||
width int // terminal columns available
|
||||
width int
|
||||
|
||||
// Rendering state (populated lazily during Draw)
|
||||
decoded map[int]image.Image // imageIdx → decoded Go image
|
||||
graphics map[int]vaxis.Image // imageIdx → Vaxis graphic handle
|
||||
mu sync.Mutex
|
||||
decoded map[int]image.Image
|
||||
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 {
|
||||
imageSet := make(map[int]bool, len(images))
|
||||
for _, img := range images {
|
||||
@@ -58,14 +57,12 @@ func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
|
||||
var currentLines []string
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
// Remove trailing empty line from Split if text ends with \n
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
if idx, ok := parsePlaceholder(line); ok && imageSet[idx] {
|
||||
// Flush accumulated text lines
|
||||
if len(currentLines) > 0 {
|
||||
blocks = append(blocks, contentBlock{
|
||||
blockType: blockText,
|
||||
@@ -90,7 +87,6 @@ func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
|
||||
return blocks
|
||||
}
|
||||
|
||||
// parsePlaceholder checks if a line is \x00IMG:N\x00 and returns N.
|
||||
func parsePlaceholder(line string) (int, bool) {
|
||||
if !strings.HasPrefix(line, "\x00IMG:") || !strings.HasSuffix(line, "\x00") {
|
||||
return 0, false
|
||||
@@ -103,17 +99,52 @@ func parsePlaceholder(line string) (int, bool) {
|
||||
return idx, true
|
||||
}
|
||||
|
||||
func newCompositeContent(text string, images []parse.ImageRef, width int) *compositeContent {
|
||||
return &compositeContent{
|
||||
blocks: splitContentBlocks(text, images),
|
||||
images: images,
|
||||
width: width,
|
||||
decoded: make(map[int]image.Image),
|
||||
graphics: make(map[int]vaxis.Image),
|
||||
func newCompositeContent(text string, images []parse.ImageRef, width int, invalidate func()) *compositeContent {
|
||||
cc := &compositeContent{
|
||||
blocks: splitContentBlocks(text, images),
|
||||
images: images,
|
||||
width: width,
|
||||
decoded: make(map[int]image.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 {
|
||||
h := 0
|
||||
for _, b := range cc.blocks {
|
||||
@@ -127,14 +158,15 @@ func (cc *compositeContent) totalHeight() int {
|
||||
return h
|
||||
}
|
||||
|
||||
// imageHeight returns the display height for an image block.
|
||||
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()
|
||||
if bounds.Dx() == 0 {
|
||||
return defaultImageHeight
|
||||
}
|
||||
// Approximate: 2 terminal rows per pixel row (cells are ~2:1 aspect)
|
||||
ratio := float64(bounds.Dy()) / float64(bounds.Dx())
|
||||
h := int(float64(cc.width) * ratio / 2.0)
|
||||
if h < 3 {
|
||||
@@ -148,49 +180,26 @@ func (cc *compositeContent) imageHeight(idx int) int {
|
||||
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() {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
for _, g := range cc.graphics {
|
||||
g.Destroy()
|
||||
}
|
||||
cc.graphics = make(map[int]vaxis.Image)
|
||||
}
|
||||
|
||||
// CleanupFiles removes temp image files from disk.
|
||||
func (cc *compositeContent) CleanupFiles() {
|
||||
for _, img := range cc.images {
|
||||
os.Remove(img.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw renders the composite content into the given UI context.
|
||||
// scroll is the row offset from the top. style is the default text style.
|
||||
// Draw renders composite content. Only renders images that are already decoded.
|
||||
func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Style) {
|
||||
width := ctx.Width()
|
||||
height := ctx.Height()
|
||||
row := -scroll // start above viewport if scrolled
|
||||
row := -scroll
|
||||
|
||||
defaultStyle := vaxis.Style{}
|
||||
if len(style) > 0 {
|
||||
@@ -213,25 +222,31 @@ func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Sty
|
||||
case blockImage:
|
||||
imgH := cc.imageHeight(b.imageIdx)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height int) {
|
||||
img, err := cc.decodeImage(idx)
|
||||
if err != nil {
|
||||
// Show alt text as fallback
|
||||
alt := "[image]"
|
||||
if idx < len(cc.images) && cc.images[idx].Alt != "" {
|
||||
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt)
|
||||
func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height int, style vaxis.Style) {
|
||||
cc.mu.Lock()
|
||||
img, decoded := cc.decoded[idx]
|
||||
failed := cc.decErrs[idx]
|
||||
cc.mu.Unlock()
|
||||
|
||||
// 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() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -241,16 +256,21 @@ func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height i
|
||||
var err error
|
||||
graphic, err = vx.NewImage(img)
|
||||
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
|
||||
}
|
||||
cc.graphics[idx] = graphic
|
||||
}
|
||||
|
||||
// Resize to fit
|
||||
graphic.Resize(width, height)
|
||||
|
||||
// Draw within the region — use the sub-window from the current context
|
||||
startRow := row
|
||||
if startRow < 0 {
|
||||
startRow = 0
|
||||
@@ -259,9 +279,7 @@ func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height i
|
||||
graphic.Draw(win)
|
||||
}
|
||||
|
||||
// truncateLine cuts a string to fit width.
|
||||
func truncateLine(s string, width int) string {
|
||||
// Use rune count for basic unicode support
|
||||
runes := []rune(s)
|
||||
if len(runes) <= width {
|
||||
return s
|
||||
|
||||
+6
-4
@@ -636,7 +636,8 @@ func (pv *PartViewer) attemptCopy() {
|
||||
if len(images) > 0 {
|
||||
pv.imageRefs = images
|
||||
pv.composite = newCompositeContent(
|
||||
string(cleanedBytes), images, 80)
|
||||
string(cleanedBytes), images, 80,
|
||||
pv.Invalidate)
|
||||
pv.Invalidate()
|
||||
} else {
|
||||
// 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())
|
||||
return
|
||||
}
|
||||
if pv.term != nil {
|
||||
pv.term.Draw(ctx)
|
||||
}
|
||||
// Composite mode: text + inline images from filter output
|
||||
// Skip term.Draw() — pager got no input in composite mode
|
||||
if pv.composite != nil {
|
||||
pv.composite.width = ctx.Width()
|
||||
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)
|
||||
return
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user