20161fedeb
Composite mode now handles j/k/g/G/PgUp/PgDown/mouse wheel for scrolling. Text uses the configured default style. Images render inline via Vaxis. Emails without images still use the standard pager path.
271 lines
6.5 KiB
Go
271 lines
6.5 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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
|
|
|
|
// Default height in terminal rows for an image block.
|
|
defaultImageHeight = 15
|
|
)
|
|
|
|
type contentBlock struct {
|
|
blockType int
|
|
lines []string // for text blocks
|
|
imageIdx int // for image blocks — index into compositeContent.images
|
|
}
|
|
|
|
// compositeContent holds the parsed content model: an ordered sequence of
|
|
// text and image blocks derived from filter output after ExtractImages().
|
|
type compositeContent struct {
|
|
blocks []contentBlock
|
|
images []parse.ImageRef
|
|
width int // terminal columns available
|
|
|
|
// Rendering state (populated lazily during Draw)
|
|
decoded map[int]image.Image // imageIdx → decoded Go image
|
|
graphics map[int]vaxis.Image // imageIdx → Vaxis graphic handle
|
|
}
|
|
|
|
// splitContentBlocks splits text at \x00IMG:N\x00 placeholder lines into
|
|
// an ordered slice of text and image blocks.
|
|
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")
|
|
// Remove trailing empty line from Split if text ends with \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] {
|
|
// Flush accumulated text lines
|
|
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
|
|
}
|
|
|
|
// parsePlaceholder checks if a line is \x00IMG:N\x00 and returns N.
|
|
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) *compositeContent {
|
|
return &compositeContent{
|
|
blocks: splitContentBlocks(text, images),
|
|
images: images,
|
|
width: width,
|
|
decoded: make(map[int]image.Image),
|
|
graphics: make(map[int]vaxis.Image),
|
|
}
|
|
}
|
|
|
|
// totalHeight returns the total height in terminal rows.
|
|
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
|
|
}
|
|
|
|
// imageHeight returns the display height for an image block.
|
|
func (cc *compositeContent) imageHeight(idx int) int {
|
|
if img, ok := cc.decoded[idx]; ok {
|
|
bounds := img.Bounds()
|
|
if bounds.Dx() == 0 {
|
|
return defaultImageHeight
|
|
}
|
|
// Approximate: 2 terminal rows per pixel row (cells are ~2:1 aspect)
|
|
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
|
|
}
|
|
|
|
// decodeImage loads and decodes the image file for the given index.
|
|
func (cc *compositeContent) decodeImage(idx int) (image.Image, error) {
|
|
if img, ok := cc.decoded[idx]; ok {
|
|
return img, nil
|
|
}
|
|
if idx < 0 || idx >= len(cc.images) {
|
|
return nil, fmt.Errorf("image index %d out of range", idx)
|
|
}
|
|
path := cc.images[idx].Path
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
img, _, err := image.Decode(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode %s: %w", path, err)
|
|
}
|
|
cc.decoded[idx] = img
|
|
return img, nil
|
|
}
|
|
|
|
// Destroy releases all Vaxis graphics handles.
|
|
func (cc *compositeContent) Destroy() {
|
|
for _, g := range cc.graphics {
|
|
g.Destroy()
|
|
}
|
|
cc.graphics = make(map[int]vaxis.Image)
|
|
}
|
|
|
|
// CleanupFiles removes temp image files from disk.
|
|
func (cc *compositeContent) CleanupFiles() {
|
|
for _, img := range cc.images {
|
|
os.Remove(img.Path)
|
|
}
|
|
}
|
|
|
|
// Draw renders the composite content into the given UI context.
|
|
// scroll is the row offset from the top. style is the default text style.
|
|
func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Style) {
|
|
width := ctx.Width()
|
|
height := ctx.Height()
|
|
row := -scroll // start above viewport if scrolled
|
|
|
|
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)
|
|
}
|
|
row += imgH
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cc *compositeContent) drawImage(ctx *ui.Context, idx, row, width, height int) {
|
|
img, err := cc.decodeImage(idx)
|
|
if err != nil {
|
|
// Show alt text as fallback
|
|
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, vaxis.Style{}, "%s", alt)
|
|
}
|
|
log.Debugf("composite: image %d decode error: %v", idx, err)
|
|
return
|
|
}
|
|
|
|
vx := ctx.Window().Vx
|
|
graphic, ok := cc.graphics[idx]
|
|
if !ok {
|
|
var err error
|
|
graphic, err = vx.NewImage(img)
|
|
if err != nil {
|
|
log.Errorf("composite: vaxis image %d error: %v", idx, err)
|
|
return
|
|
}
|
|
cc.graphics[idx] = graphic
|
|
}
|
|
|
|
// Resize to fit
|
|
graphic.Resize(width, height)
|
|
|
|
// Draw within the region — use the sub-window from the current context
|
|
startRow := row
|
|
if startRow < 0 {
|
|
startRow = 0
|
|
}
|
|
win := ctx.Window().New(0, startRow, width, height)
|
|
graphic.Draw(win)
|
|
}
|
|
|
|
// truncateLine cuts a string to fit width.
|
|
func truncateLine(s string, width int) string {
|
|
// Use rune count for basic unicode support
|
|
runes := []rune(s)
|
|
if len(runes) <= width {
|
|
return s
|
|
}
|
|
return string(runes[:width])
|
|
}
|