e6af1a6208
The go-sixel library panics on certain image dimensions during Resize(). Added panic recovery in drawImage() that falls back to alt text display. Cleaned up all debug logging from the investigation.
302 lines
6.5 KiB
Go
302 lines
6.5 KiB
Go
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]
|
|
// 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)
|
|
}
|
|
}
|
|
}()
|
|
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])
|
|
}
|