From 20161fedeb79e3e627b8f8d253ab47c584d009a3 Mon Sep 17 00:00:00 2001 From: Mortdecai Date: Tue, 7 Apr 2026 21:04:27 -0400 Subject: [PATCH] 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. --- app/compositeview.go | 7 +++- app/msgviewer.go | 88 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/app/compositeview.go b/app/compositeview.go index 4e58cad..317b14f 100644 --- a/app/compositeview.go +++ b/app/compositeview.go @@ -186,13 +186,16 @@ func (cc *compositeContent) CleanupFiles() { } // Draw renders the composite content into the given UI context. -// scroll is the row offset from the top. -func (cc *compositeContent) Draw(ctx *ui.Context, scroll int) { +// 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 { diff --git a/app/msgviewer.go b/app/msgviewer.go index e62aae5..546c95d 100644 --- a/app/msgviewer.go +++ b/app/msgviewer.go @@ -632,18 +632,18 @@ func (pv *PartViewer) attemptCopy() { } // Extract image markers from filter output cleaned, images := parse.ExtractImages(&filterBuf) + cleanedBytes, _ := io.ReadAll(cleaned) if len(images) > 0 { 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) - output := parse.ReplacePlaceholders(cleanedBytes, images) - _, copyErr := io.Copy(pv.pagerin, bytes.NewReader(output)) - if copyErr != nil { - log.Errorf("io.Copy: %s", copyErr) + pv.composite = newCompositeContent( + string(cleanedBytes), images, 80) + pv.Invalidate() + } else { + // No images — forward to pager as normal + _, copyErr := io.Copy(pv.pagerin, bytes.NewReader(cleanedBytes)) + if copyErr != nil { + log.Errorf("io.Copy: %s", copyErr) + } } err = pv.pagerin.Close() if err != nil { @@ -838,9 +838,14 @@ func (pv *PartViewer) Draw(ctx *ui.Context) { if pv.term != nil { pv.term.Draw(ctx) } - // NOTE: Composite image rendering (pv.composite) is reserved for - // future use. Currently all filter output goes through the pager - // with [image: alt] text placeholders for extracted images. + // Composite mode: text + inline images from filter output + if pv.composite != nil { + 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) { // This path should only occur on resizes or the first pass // 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 { + 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 { return pv.term.Event(event) } 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 { Name string Value string