feat(composite): halfblock image renderer — crash-proof, cross-terminal
Replace vaxis NewImage/Resize (which spawns goroutines that panic in sixel encoder) with direct halfblock character rendering. Each terminal cell uses upper-half-block with RGB fg/bg colors for 2x vertical resolution. Pre-renders in background goroutine, Draw() just paints pre-computed cells. Works in any 24-bit color terminal.
This commit is contained in:
+136
-78
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,6 +14,8 @@ import (
|
|||||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
|
||||||
@@ -25,7 +28,8 @@ const (
|
|||||||
blockText = 0
|
blockText = 0
|
||||||
blockImage = 1
|
blockImage = 1
|
||||||
|
|
||||||
defaultImageHeight = 15
|
// Max image height in terminal rows
|
||||||
|
maxImageHeight = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
type contentBlock struct {
|
type contentBlock struct {
|
||||||
@@ -34,17 +38,22 @@ type contentBlock struct {
|
|||||||
imageIdx int
|
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 {
|
type compositeContent struct {
|
||||||
blocks []contentBlock
|
blocks []contentBlock
|
||||||
images []parse.ImageRef
|
images []parse.ImageRef
|
||||||
width int
|
width int
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
decoded map[int]image.Image
|
decoded map[int]image.Image
|
||||||
decErrs map[int]bool // true = decode attempted and failed
|
decErrs map[int]bool
|
||||||
graphics map[int]vaxis.Image
|
rendered map[int][]halfBlockRow // pre-rendered halfblock rows per image
|
||||||
|
invalidate func()
|
||||||
invalidate func() // callback to trigger redraw after async decode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
|
func splitContentBlocks(text string, images []parse.ImageRef) []contentBlock {
|
||||||
@@ -106,17 +115,16 @@ func newCompositeContent(text string, images []parse.ImageRef, width int, invali
|
|||||||
width: width,
|
width: width,
|
||||||
decoded: make(map[int]image.Image),
|
decoded: make(map[int]image.Image),
|
||||||
decErrs: make(map[int]bool),
|
decErrs: make(map[int]bool),
|
||||||
graphics: make(map[int]vaxis.Image),
|
rendered: make(map[int][]halfBlockRow),
|
||||||
invalidate: invalidate,
|
invalidate: invalidate,
|
||||||
}
|
}
|
||||||
// Decode all images in background
|
go cc.decodeAndRender()
|
||||||
go cc.decodeAllImages()
|
|
||||||
return cc
|
return cc
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeAllImages decodes all referenced images in a background goroutine.
|
// decodeAndRender decodes images and pre-renders them as halfblock cells.
|
||||||
// Calls invalidate() when done so the UI redraws with images.
|
// All rendering happens here (background goroutine), not in Draw().
|
||||||
func (cc *compositeContent) decodeAllImages() {
|
func (cc *compositeContent) decodeAndRender() {
|
||||||
defer log.PanicHandler()
|
defer log.PanicHandler()
|
||||||
for i, img := range cc.images {
|
for i, img := range cc.images {
|
||||||
f, err := os.Open(img.Path)
|
f, err := os.Open(img.Path)
|
||||||
@@ -139,12 +147,103 @@ func (cc *compositeContent) decodeAllImages() {
|
|||||||
cc.mu.Lock()
|
cc.mu.Lock()
|
||||||
cc.decoded[i] = decoded
|
cc.decoded[i] = decoded
|
||||||
cc.mu.Unlock()
|
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 {
|
if cc.invalidate != nil {
|
||||||
cc.invalidate()
|
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 {
|
func (cc *compositeContent) totalHeight() int {
|
||||||
h := 0
|
h := 0
|
||||||
for _, b := range cc.blocks {
|
for _, b := range cc.blocks {
|
||||||
@@ -160,33 +259,17 @@ func (cc *compositeContent) totalHeight() int {
|
|||||||
|
|
||||||
func (cc *compositeContent) imageHeight(idx int) int {
|
func (cc *compositeContent) imageHeight(idx int) int {
|
||||||
cc.mu.Lock()
|
cc.mu.Lock()
|
||||||
img, ok := cc.decoded[idx]
|
rows, ok := cc.rendered[idx]
|
||||||
cc.mu.Unlock()
|
cc.mu.Unlock()
|
||||||
if ok {
|
if ok && len(rows) > 0 {
|
||||||
bounds := img.Bounds()
|
return len(rows)
|
||||||
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
|
// Placeholder height before rendered
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cc *compositeContent) Destroy() {
|
func (cc *compositeContent) Destroy() {
|
||||||
cc.mu.Lock()
|
// No vaxis resources to clean up — halfblock uses only cells
|
||||||
defer cc.mu.Unlock()
|
|
||||||
for _, g := range cc.graphics {
|
|
||||||
g.Destroy()
|
|
||||||
}
|
|
||||||
cc.graphics = make(map[int]vaxis.Image)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cc *compositeContent) CleanupFiles() {
|
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) {
|
func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Style) {
|
||||||
width := ctx.Width()
|
width := ctx.Width()
|
||||||
height := ctx.Height()
|
height := ctx.Height()
|
||||||
@@ -222,21 +305,20 @@ func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Sty
|
|||||||
case blockImage:
|
case blockImage:
|
||||||
imgH := cc.imageHeight(b.imageIdx)
|
imgH := cc.imageHeight(b.imageIdx)
|
||||||
if row+imgH > 0 && row < height {
|
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
|
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()
|
cc.mu.Lock()
|
||||||
img, decoded := cc.decoded[idx]
|
rows, rendered := cc.rendered[idx]
|
||||||
failed := cc.decErrs[idx]
|
failed := cc.decErrs[idx]
|
||||||
cc.mu.Unlock()
|
cc.mu.Unlock()
|
||||||
|
|
||||||
// Show alt text if not yet decoded or failed
|
if !rendered {
|
||||||
if !decoded {
|
|
||||||
alt := "[loading image...]"
|
alt := "[loading image...]"
|
||||||
if failed {
|
if failed {
|
||||||
alt = "[image]"
|
alt = "[image]"
|
||||||
@@ -250,46 +332,22 @@ func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height i
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
vx := ctx.Window().Vx
|
win := ctx.Window()
|
||||||
graphic, ok := cc.graphics[idx]
|
for i, hbRow := range rows {
|
||||||
// Recover from panics in vaxis image encoding (e.g. sixel encoder bugs)
|
y := row + i
|
||||||
defer func() {
|
if y < 0 {
|
||||||
if r := recover(); r != nil {
|
continue
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
if y >= ctx.Height() {
|
||||||
if !ok {
|
break
|
||||||
var err error
|
}
|
||||||
graphic, err = vx.NewImage(img)
|
for x, cell := range hbRow.cells {
|
||||||
if err != nil {
|
if x >= width {
|
||||||
log.Errorf("composite: vaxis NewImage %d: %v", idx, err)
|
break
|
||||||
if row >= 0 && row < ctx.Height() {
|
}
|
||||||
alt := "[image: render error]"
|
win.SetCell(x, y, cell)
|
||||||
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 {
|
func truncateLine(s string, width int) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user