package app import ( "fmt" "image" "os" "strconv" "strings" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rjarry/aerc/lib/log" "git.sr.ht/~rjarry/aerc/lib/parse" "git.sr.ht/~rjarry/aerc/lib/ui" _ "image/jpeg" _ "image/png" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" ) 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 } // 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 // Rendering state (populated lazily during Draw) decoded map[int]image.Image // imageIdx → decoded Go image graphics map[int]vaxis.Image // imageIdx → Vaxis graphic handle } // 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 { imageSet[img.Index] = true } var blocks []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, lines: currentLines, }) currentLines = nil } blocks = append(blocks, contentBlock{ blockType: blockImage, imageIdx: idx, }) } else { currentLines = append(currentLines, line) } } if len(currentLines) > 0 { blocks = append(blocks, contentBlock{ blockType: blockText, lines: currentLines, }) } 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 } numStr := line[len("\x00IMG:") : len(line)-1] idx, err := strconv.Atoi(numStr) if err != nil { return 0, false } 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), } } // totalHeight returns the total height in terminal rows. func (cc *compositeContent) totalHeight() int { h := 0 for _, b := range cc.blocks { switch b.blockType { case blockText: h += len(b.lines) case blockImage: h += cc.imageHeight(b.imageIdx) } } 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 { 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 { h = 3 } if h > defaultImageHeight { h = defaultImageHeight } return h } 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() { 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. func (cc *compositeContent) Draw(ctx *ui.Context, scroll int) { width := ctx.Width() height := ctx.Height() row := -scroll // start above viewport if scrolled defaultStyle := vaxis.Style{} for _, b := range cc.blocks { switch b.blockType { case blockText: for _, line := range b.lines { if row >= height { return } if row >= 0 { ctx.Printf(0, row, defaultStyle, "%s", truncateLine(line, width)) } row++ } case blockImage: imgH := cc.imageHeight(b.imageIdx) if row+imgH > 0 && row < height { cc.drawImage(ctx, b.imageIdx, row, width, imgH) } 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) } if row >= 0 && row < ctx.Height() { ctx.Printf(0, row, vaxis.Style{}, "%s", alt) } log.Debugf("composite: image %d decode error: %v", idx, err) return } vx := ctx.Window().Vx graphic, ok := cc.graphics[idx] if !ok { var err error graphic, err = vx.NewImage(img) if err != nil { log.Errorf("composite: vaxis image %d error: %v", idx, err) 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 } win := ctx.Window().New(0, startRow, width, height) 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 } return string(runes[:width]) }