feat(msgviewer): re-enable composite renderer with scroll support and inline images

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.
This commit is contained in:
Mortdecai
2026-04-07 21:04:27 -04:00
parent 9fd718b2ae
commit 20161fedeb
2 changed files with 80 additions and 15 deletions
+5 -2
View File
@@ -186,13 +186,16 @@ func (cc *compositeContent) CleanupFiles() {
} }
// Draw renders the composite content into the given UI context. // Draw renders the composite content into the given UI context.
// scroll is the row offset from the top. // scroll is the row offset from the top. style is the default text style.
func (cc *compositeContent) Draw(ctx *ui.Context, scroll int) { func (cc *compositeContent) Draw(ctx *ui.Context, scroll int, style ...vaxis.Style) {
width := ctx.Width() width := ctx.Width()
height := ctx.Height() height := ctx.Height()
row := -scroll // start above viewport if scrolled row := -scroll // start above viewport if scrolled
defaultStyle := vaxis.Style{} defaultStyle := vaxis.Style{}
if len(style) > 0 {
defaultStyle = style[0]
}
for _, b := range cc.blocks { for _, b := range cc.blocks {
switch b.blockType { switch b.blockType {
+73 -11
View File
@@ -632,19 +632,19 @@ func (pv *PartViewer) attemptCopy() {
} }
// Extract image markers from filter output // Extract image markers from filter output
cleaned, images := parse.ExtractImages(&filterBuf) cleaned, images := parse.ExtractImages(&filterBuf)
cleanedBytes, _ := io.ReadAll(cleaned)
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)) pv.composite = newCompositeContent(
} string(cleanedBytes), images, 80)
// Always forward to pager — placeholders are readable text pv.Invalidate()
// Image markers become \x00IMG:N\x00 which we replace } else {
// with human-readable [image: alt] text for the pager. // No images — forward to pager as normal
cleanedBytes, _ := io.ReadAll(cleaned) _, copyErr := io.Copy(pv.pagerin, bytes.NewReader(cleanedBytes))
output := parse.ReplacePlaceholders(cleanedBytes, images)
_, copyErr := io.Copy(pv.pagerin, bytes.NewReader(output))
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,9 +838,14 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
if pv.term != nil { if pv.term != nil {
pv.term.Draw(ctx) pv.term.Draw(ctx)
} }
// NOTE: Composite image rendering (pv.composite) is reserved for // Composite mode: text + inline images from filter output
// future use. Currently all filter output goes through the pager if pv.composite != nil {
// with [image: alt] text placeholders for extracted images. pv.composite.width = ctx.Width()
style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT)
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
pv.composite.Draw(ctx, pv.scroll, style)
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
@@ -890,12 +895,69 @@ func (pv *PartViewer) resized(ctx *ui.Context) bool {
} }
func (pv *PartViewer) Event(event vaxis.Event) bool { func (pv *PartViewer) Event(event vaxis.Event) bool {
if pv.composite != nil {
switch ev := event.(type) {
case vaxis.Mouse:
switch ev.Button {
case vaxis.MouseWheelUp:
pv.scrollComposite(-3)
return true
case vaxis.MouseWheelDown:
pv.scrollComposite(3)
return true
}
case vaxis.Key:
switch {
case ev.Matches('j'), ev.Matches(vaxis.KeyDown):
pv.scrollComposite(1)
return true
case ev.Matches('k'), ev.Matches(vaxis.KeyUp):
pv.scrollComposite(-1)
return true
case ev.Matches(vaxis.KeyPgDown), ev.Matches(' '):
pv.scrollComposite(pv.height / 2)
return true
case ev.Matches(vaxis.KeyPgUp):
pv.scrollComposite(-pv.height / 2)
return true
case ev.Matches('g'), ev.Matches(vaxis.KeyHome):
pv.scroll = 0
pv.Invalidate()
return true
case ev.Matches('G'), ev.Matches(vaxis.KeyEnd):
maxScroll := pv.composite.totalHeight() - pv.height
if maxScroll > 0 {
pv.scroll = maxScroll
}
pv.Invalidate()
return true
}
}
return false
}
if pv.term != nil { if pv.term != nil {
return pv.term.Event(event) return pv.term.Event(event)
} }
return false return false
} }
func (pv *PartViewer) scrollComposite(delta int) {
pv.scroll += delta
if pv.scroll < 0 {
pv.scroll = 0
}
if pv.composite != nil {
maxScroll := pv.composite.totalHeight() - pv.height
if maxScroll < 0 {
maxScroll = 0
}
if pv.scroll > maxScroll {
pv.scroll = maxScroll
}
}
pv.Invalidate()
}
type HeaderView struct { type HeaderView struct {
Name string Name string
Value string Value string