diff --git a/app/compositeview.go b/app/compositeview.go index 99a983a..01cdc6c 100644 --- a/app/compositeview.go +++ b/app/compositeview.go @@ -3,6 +3,7 @@ package app import ( "fmt" "image" + "image/color" "os" "strconv" "strings" @@ -13,6 +14,8 @@ import ( "git.sr.ht/~rjarry/aerc/lib/parse" "git.sr.ht/~rjarry/aerc/lib/ui" + "golang.org/x/image/draw" + _ "image/jpeg" _ "image/png" @@ -25,7 +28,8 @@ const ( blockText = 0 blockImage = 1 - defaultImageHeight = 15 + // Max image height in terminal rows + maxImageHeight = 20 ) type contentBlock struct { @@ -34,17 +38,22 @@ type contentBlock struct { 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 // true = decode attempted and failed - graphics map[int]vaxis.Image - - invalidate func() // callback to trigger redraw after async decode + 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 { @@ -106,17 +115,16 @@ func newCompositeContent(text string, images []parse.ImageRef, width int, invali width: width, decoded: make(map[int]image.Image), decErrs: make(map[int]bool), - graphics: make(map[int]vaxis.Image), + rendered: make(map[int][]halfBlockRow), invalidate: invalidate, } - // Decode all images in background - go cc.decodeAllImages() + go cc.decodeAndRender() 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() { +// 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) @@ -139,12 +147,103 @@ func (cc *compositeContent) decodeAllImages() { 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 { @@ -160,33 +259,17 @@ func (cc *compositeContent) totalHeight() int { func (cc *compositeContent) imageHeight(idx int) int { cc.mu.Lock() - img, ok := cc.decoded[idx] + rows, ok := cc.rendered[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 + if ok && len(rows) > 0 { + return len(rows) } - return defaultImageHeight + // Placeholder height before rendered + return 1 } 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) + // No vaxis resources to clean up — halfblock uses only cells } func (cc *compositeContent) CleanupFiles() { @@ -195,7 +278,7 @@ func (cc *compositeContent) CleanupFiles() { } } -// Draw renders composite content. Only renders images that are already decoded. +// 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() @@ -222,21 +305,20 @@ 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, defaultStyle) + cc.drawImage(ctx, b.imageIdx, row, width, defaultStyle) } row += imgH } } } -func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height int, style vaxis.Style) { +func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width int, style vaxis.Style) { cc.mu.Lock() - img, decoded := cc.decoded[idx] + rows, rendered := cc.rendered[idx] failed := cc.decErrs[idx] cc.mu.Unlock() - // Show alt text if not yet decoded or failed - if !decoded { + if !rendered { alt := "[loading image...]" if failed { alt = "[image]" @@ -250,46 +332,22 @@ func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height i return } - vx := ctx.Window().Vx - graphic, ok := cc.graphics[idx] - // Recover from panics in vaxis image encoding (e.g. sixel encoder bugs) - defer func() { - if r := recover(); r != nil { - log.Errorf("composite: panic rendering image %d: %v", idx, r) - alt := "[image: render error]" - 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) - } + win := ctx.Window() + for i, hbRow := range rows { + y := row + i + if y < 0 { + continue } - }() - 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 + if y >= ctx.Height() { + break + } + for x, cell := range hbRow.cells { + if x >= width { + break + } + win.SetCell(x, y, cell) } - 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 {