package app import ( "fmt" "image" "os" "strconv" "strings" "sync" "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 defaultImageHeight = 15 ) type contentBlock struct { blockType int lines []string imageIdx int } type compositeContent struct { blocks []contentBlock images []parse.ImageRef width int 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 } 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") 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] { 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 } 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, 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() } } 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 } func (cc *compositeContent) imageHeight(idx int) int { cc.mu.Lock() img, ok := cc.decoded[idx] cc.mu.Unlock() if ok { bounds := img.Bounds() if bounds.Dx() == 0 { return defaultImageHeight } 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 } 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) } func (cc *compositeContent) CleanupFiles() { for _, img := range cc.images { os.Remove(img.Path) } } // 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 defaultStyle := vaxis.Style{} if len(style) > 0 { defaultStyle = style[0] } 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, defaultStyle) } row += imgH } } } 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, style, "%s", alt) } 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 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 } graphic.Resize(width, height) startRow := row if startRow < 0 { startRow = 0 } win := ctx.Window().New(0, startRow, width, height) graphic.Draw(win) } func truncateLine(s string, width int) string { runes := []rune(s) if len(runes) <= width { return s } return string(runes[:width]) }