fix(msgviewer): use pager for all text, replace image markers with readable placeholders

The composite renderer bypassed the pager (less) for any email with images,
losing colors, scrolling, and search. Now always forward to the pager with
[image: alt] text placeholders. Image refs are still extracted and stored
for future inline rendering.
This commit is contained in:
Mortdecai
2026-04-07 20:58:45 -04:00
parent abf8feb229
commit 9fd718b2ae
2 changed files with 43 additions and 17 deletions
+10 -14
View File
@@ -634,17 +634,17 @@ func (pv *PartViewer) attemptCopy() {
cleaned, images := parse.ExtractImages(&filterBuf) cleaned, images := parse.ExtractImages(&filterBuf)
if len(images) > 0 { if len(images) > 0 {
pv.imageRefs = images pv.imageRefs = images
log.Debugf("extracted %d image refs from filter output", len(images))
}
// Always forward to pager — placeholders are readable text
// Image markers become \x00IMG:N\x00 which we replace
// with human-readable [image: alt] text for the pager.
cleanedBytes, _ := io.ReadAll(cleaned) cleanedBytes, _ := io.ReadAll(cleaned)
pv.composite = newCompositeContent( output := parse.ReplacePlaceholders(cleanedBytes, images)
string(cleanedBytes), images, 80) _, copyErr := io.Copy(pv.pagerin, bytes.NewReader(output))
pv.Invalidate()
} else {
// No images — forward to pager as normal
_, copyErr := io.Copy(pv.pagerin, cleaned)
if copyErr != nil { if copyErr != nil {
log.Errorf("io.Copy: %s", copyErr) log.Errorf("io.Copy: %s", copyErr)
} }
}
err = pv.pagerin.Close() err = pv.pagerin.Close()
if err != nil { if err != nil {
log.Errorf("error closing pager pipe: %v", err) log.Errorf("error closing pager pipe: %v", err)
@@ -838,13 +838,9 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
if pv.term != nil { if pv.term != nil {
pv.term.Draw(ctx) pv.term.Draw(ctx)
} }
// Composite mode: text + images from filter output // NOTE: Composite image rendering (pv.composite) is reserved for
if pv.composite != nil { // future use. Currently all filter output goes through the pager
pv.composite.width = ctx.Width() // with [image: alt] text placeholders for extracted images.
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', pv.uiConfig.GetStyle(config.STYLE_DEFAULT))
pv.composite.Draw(ctx, pv.scroll)
return
}
if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) { if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) {
// This path should only occur on resizes or the first pass // This path should only occur on resizes or the first pass
// after the image is downloaded and could be slow due to // after the image is downloaded and could be slow due to
+30
View File
@@ -39,3 +39,33 @@ func ExtractImages(r io.Reader) (io.Reader, []ImageRef) {
} }
return buf, images return buf, images
} }
// ReplacePlaceholders converts \x00IMG:N\x00 placeholders into human-readable
// [image: alt] text suitable for display in a pager.
func ReplacePlaceholders(data []byte, images []ImageRef) []byte {
result := bytes.NewBuffer(nil)
for _, line := range bytes.Split(data, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) > 6 && trimmed[0] == 0 &&
bytes.HasPrefix(trimmed, []byte("\x00IMG:")) &&
trimmed[len(trimmed)-1] == 0 {
// Extract index
numStr := string(trimmed[5 : len(trimmed)-1])
idx := 0
for _, c := range numStr {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
}
}
if idx < len(images) && images[idx].Alt != "" {
fmt.Fprintf(result, "[image: %s]\n", images[idx].Alt)
} else {
result.WriteString("[image]\n")
}
} else {
result.Write(line)
result.WriteByte('\n')
}
}
return result.Bytes()
}