package app import ( "fmt" "image" "image/color" "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" "golang.org/x/image/draw" _ "image/jpeg" _ "image/png" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" ) const ( blockText = 0 blockImage = 1 // Max image height in terminal rows maxImageHeight = 20 ) type contentBlock struct { blockType int lines []string imageIdx int } // halfBlockRow stores pre-computed cells for one row of a halfblock image. // Each cell uses upper-half-block "▀" with fg=top pixel, bg=bottom pixel. type halfBlockRow struct { cells []vaxis.Cell } type compositeContent struct { blocks []contentBlock images []parse.ImageRef width int mu sync.Mutex decoded map[int]image.Image decErrs map[int]bool rendered map[int][]halfBlockRow // pre-rendered halfblock rows per image invalidate func() } 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), rendered: make(map[int][]halfBlockRow), invalidate: invalidate, } go cc.decodeAndRender() return cc } // decodeAndRender decodes images and pre-renders them as halfblock cells. // All rendering happens here (background goroutine), not in Draw(). func (cc *compositeContent) decodeAndRender() { 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() // Pre-render as halfblock cells rows := renderHalfBlock(decoded, cc.width, maxImageHeight) cc.mu.Lock() cc.rendered[i] = rows cc.mu.Unlock() } if cc.invalidate != nil { cc.invalidate() } } // renderHalfBlock converts an image into halfblock character rows. // Each terminal row represents 2 pixel rows using "▀" (upper half block). // Top pixel → foreground color, bottom pixel → background color. func renderHalfBlock(img image.Image, maxWidth, maxHeight int) []halfBlockRow { bounds := img.Bounds() srcW := bounds.Dx() srcH := bounds.Dy() if srcW == 0 || srcH == 0 { return nil } // Calculate target dimensions in cells // Each cell = 1 char wide, 2 pixel rows tall targetW := maxWidth targetH := maxHeight * 2 // pixel rows (2 per cell row) // Maintain aspect ratio ratio := float64(srcH) / float64(srcW) fitH := int(float64(targetW) * ratio) if fitH < targetH { targetH = fitH } // Don't upscale if targetW > srcW { targetW = srcW } if targetH > srcH { targetH = srcH } // Ensure even height for halfblock pairing if targetH%2 != 0 { targetH++ } // Resize the image dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) draw.BiLinear.Scale(dst, dst.Rect, img, bounds, draw.Over, nil) // Convert to halfblock rows cellRows := targetH / 2 rows := make([]halfBlockRow, cellRows) for y := 0; y < cellRows; y++ { cells := make([]vaxis.Cell, targetW) for x := 0; x < targetW; x++ { topR, topG, topB, topA := toRGBA(dst.At(x, y*2)) botR, botG, botB, botA := toRGBA(dst.At(x, y*2+1)) switch { case topA < 50 && botA < 50: cells[x] = vaxis.Cell{ Character: vaxis.Character{Grapheme: " ", Width: 1}, } case topA < 50: cells[x] = vaxis.Cell{ Character: vaxis.Character{Grapheme: "▄", Width: 1}, Style: vaxis.Style{Foreground: vaxis.RGBColor(botR, botG, botB)}, } case botA < 50: cells[x] = vaxis.Cell{ Character: vaxis.Character{Grapheme: "▀", Width: 1}, Style: vaxis.Style{Foreground: vaxis.RGBColor(topR, topG, topB)}, } default: cells[x] = vaxis.Cell{ Character: vaxis.Character{Grapheme: "▀", Width: 1}, Style: vaxis.Style{ Foreground: vaxis.RGBColor(topR, topG, topB), Background: vaxis.RGBColor(botR, botG, botB), }, } } } rows[y] = halfBlockRow{cells: cells} } return rows } func toRGBA(c color.Color) (uint8, uint8, uint8, uint8) { pr, pg, pb, pa := c.RGBA() if pa == 0 { return uint8(pr), uint8(pg), uint8(pb), 0 } return uint8((pr * 255) / pa), uint8((pg * 255) / pa), uint8((pb * 255) / pa), uint8(pa >> 8) } 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() rows, ok := cc.rendered[idx] cc.mu.Unlock() if ok && len(rows) > 0 { return len(rows) } // Placeholder height before rendered return 1 } func (cc *compositeContent) Destroy() { // No vaxis resources to clean up — halfblock uses only cells } func (cc *compositeContent) CleanupFiles() { for _, img := range cc.images { os.Remove(img.Path) } } // Draw renders composite content into the UI context. 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, defaultStyle) } row += imgH } } } func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width int, style vaxis.Style) { cc.mu.Lock() rows, rendered := cc.rendered[idx] failed := cc.decErrs[idx] cc.mu.Unlock() if !rendered { 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 } win := ctx.Window() for i, hbRow := range rows { y := row + i if y < 0 { continue } if y >= ctx.Height() { break } for x, cell := range hbRow.cells { if x >= width { break } win.SetCell(x, y, cell) } } } func truncateLine(s string, width int) string { runes := []rune(s) if len(runes) <= width { return s } return string(runes[:width]) }