8e663b6e63
Use vaxis NewImage/Resize/Draw which auto-selects Kitty graphics protocol. Images decode in background, vaxis handles async Kitty protocol encoding. Removed halfblock fallback — Kitty only.
291 lines
6.0 KiB
Go
291 lines
6.0 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
|
|
|
|
maxImageHeight = 20
|
|
)
|
|
|
|
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
|
|
graphics map[int]vaxis.Image // Kitty graphics handles
|
|
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),
|
|
graphics: make(map[int]vaxis.Image),
|
|
invalidate: invalidate,
|
|
}
|
|
go cc.decodeImages()
|
|
return cc
|
|
}
|
|
|
|
// decodeImages decodes all images in background. Vaxis graphic creation
|
|
// and rendering happen in Draw() on the main thread.
|
|
func (cc *compositeContent) decodeImages() {
|
|
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 maxImageHeight
|
|
}
|
|
ratio := float64(bounds.Dy()) / float64(bounds.Dx())
|
|
h := int(float64(cc.width) * ratio / 2.0)
|
|
if h < 3 {
|
|
h = 3
|
|
}
|
|
if h > maxImageHeight {
|
|
h = maxImageHeight
|
|
}
|
|
return h
|
|
}
|
|
return 1 // placeholder
|
|
}
|
|
|
|
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. Uses Kitty graphics protocol via vaxis.
|
|
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()
|
|
|
|
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
|
|
}
|
|
|
|
// Create vaxis graphic on first render (uses Kitty protocol)
|
|
graphic, ok := cc.graphics[idx]
|
|
if !ok {
|
|
vx := ctx.Window().Vx
|
|
var err error
|
|
graphic, err = vx.NewImage(img)
|
|
if err != nil {
|
|
log.Errorf("composite: NewImage %d: %v", idx, err)
|
|
if row >= 0 && row < ctx.Height() {
|
|
alt := "[image]"
|
|
if idx < len(cc.images) && cc.images[idx].Alt != "" {
|
|
alt = fmt.Sprintf("[%s]", cc.images[idx].Alt)
|
|
}
|
|
ctx.Printf(0, row, style, "%s", alt)
|
|
}
|
|
// Mark as failed so we don't retry every frame
|
|
cc.mu.Lock()
|
|
cc.decErrs[idx] = true
|
|
delete(cc.decoded, idx)
|
|
cc.mu.Unlock()
|
|
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])
|
|
}
|