diff --git a/app/compositeview.go b/app/compositeview.go index 317b14f..b9793eb 100644 --- a/app/compositeview.go +++ b/app/compositeview.go @@ -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 diff --git a/app/msgviewer.go b/app/msgviewer.go index 546c95d..a87e964 100644 --- a/app/msgviewer.go +++ b/app/msgviewer.go @@ -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