feat(msgviewer): wire image extraction and composite rendering into PartViewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-04-07 20:10:45 -04:00
parent 9c7a770daa
commit b13bd1d71c
+55 -22
View File
@@ -439,7 +439,10 @@ type PartViewer struct {
width int
height int
links []string
links []string
imageRefs []parse.ImageRef // extracted image references from filter output
composite *compositeContent // composite renderer (non-nil when images present)
scroll int // scroll offset for composite mode
}
const copying int32 = 1
@@ -611,37 +614,56 @@ func (pv *PartViewer) attemptCopy() {
pv.source = parse.StripAnsi(pv.hyperlinks(pv.source))
}
if pv.filter != pv.pager {
// Filter is a separate process that needs to output to the pager.
pv.filter.Stdin = pv.source
pv.filter.Stdout = pv.pagerin
var filterBuf bytes.Buffer
pv.filter.Stdout = &filterBuf
pv.filter.Stderr = pv.pagerin
err := pv.filter.Start()
if err != nil {
log.Errorf("error running filter: %v", err)
return
}
}
go func() {
defer log.PanicHandler()
defer atomic.StoreInt32(&pv.copying, 0)
var err error
if pv.filter == pv.pager {
// Filter already implements its own paging.
_, err = io.Copy(pv.pagerin, pv.source)
if err != nil {
log.Errorf("io.Copy: %s", err)
}
} else {
err = pv.filter.Wait()
go func() {
defer log.PanicHandler()
defer atomic.StoreInt32(&pv.copying, 0)
err := pv.filter.Wait()
if err != nil {
log.Errorf("filter.Wait: %v", err)
}
}
err = pv.pagerin.Close()
if err != nil {
log.Errorf("error closing pager pipe: %v", err)
}
}()
// Extract image markers from filter output
cleaned, images := parse.ExtractImages(&filterBuf)
if len(images) > 0 {
pv.imageRefs = images
cleanedBytes, _ := io.ReadAll(cleaned)
pv.composite = newCompositeContent(
string(cleanedBytes), images, 80)
pv.Invalidate()
} else {
// No images — forward to pager as normal
_, copyErr := io.Copy(pv.pagerin, cleaned)
if copyErr != nil {
log.Errorf("io.Copy: %s", copyErr)
}
}
err = pv.pagerin.Close()
if err != nil {
log.Errorf("error closing pager pipe: %v", err)
}
}()
} else {
go func() {
defer log.PanicHandler()
defer atomic.StoreInt32(&pv.copying, 0)
_, err := io.Copy(pv.pagerin, pv.source)
if err != nil {
log.Errorf("io.Copy: %s", err)
}
err = pv.pagerin.Close()
if err != nil {
log.Errorf("error closing pager pipe: %v", err)
}
}()
}
}
func (pv *PartViewer) writeMailHeaders() {
@@ -816,6 +838,13 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
if pv.term != nil {
pv.term.Draw(ctx)
}
// Composite mode: text + images from filter output
if pv.composite != nil {
pv.composite.width = ctx.Width()
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) {
// This path should only occur on resizes or the first pass
// after the image is downloaded and could be slow due to
@@ -847,6 +876,10 @@ func (pv *PartViewer) Cleanup() {
if pv.graphic != nil {
pv.graphic.Destroy()
}
if pv.composite != nil {
pv.composite.Destroy()
pv.composite.CleanupFiles()
}
}
func (pv *PartViewer) resized(ctx *ui.Context) bool {