diff --git a/app/compositeview.go b/app/compositeview.go new file mode 100644 index 0000000..4e58cad --- /dev/null +++ b/app/compositeview.go @@ -0,0 +1,267 @@ +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]) +}