Detailed changes
@@ -141,6 +141,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tui.BackToMailboxMsg:
+ // Ensure kitty graphics are cleared when leaving email view
+ tui.ClearKittyGraphics()
switch msg.Mailbox {
case tui.MailboxSent:
if m.sentInbox != nil {
@@ -822,6 +824,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.current.Init()
case tui.DeleteEmailMsg:
+ tui.ClearKittyGraphics()
m.previousModel = m.current
m.current = tui.NewStatus("Deleting email...")
@@ -838,6 +841,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
case tui.ArchiveEmailMsg:
+ tui.ClearKittyGraphics()
m.previousModel = m.current
m.current = tui.NewStatus("Archiving email...")
@@ -13,8 +13,8 @@ import (
"github.com/floatpane/matcha/view"
)
-// clearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout
-func clearKittyGraphics() {
+// ClearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout.
+func ClearKittyGraphics() {
// Delete all images: a=d (action=delete), d=A (delete all)
os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
os.Stdout.Sync()
@@ -38,6 +38,7 @@ type EmailView struct {
isSMIME bool
smimeTrusted bool
isEncrypted bool
+ imagePlacements []view.ImagePlacement
}
func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
@@ -65,7 +66,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
// Initial state for showImages matches config unless overridden later
showImages := !disableImages
- body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
+ body, placements, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
@@ -84,19 +85,20 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
vp.SetWidth(width)
vp.SetHeight(height - headerHeight - attachmentHeight)
wrapped := wrapBodyToWidth(body, vp.Width())
- vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
+ vp.SetContent(wrapped + "\n")
return &EmailView{
- viewport: vp,
- email: email,
- emailIndex: emailIndex,
- accountID: email.AccountID,
- mailbox: mailbox,
- disableImages: disableImages,
- showImages: showImages,
- isSMIME: isSMIME,
- smimeTrusted: smimeTrusted,
- isEncrypted: isEncrypted,
+ viewport: vp,
+ email: email,
+ emailIndex: emailIndex,
+ accountID: email.AccountID,
+ mailbox: mailbox,
+ disableImages: disableImages,
+ showImages: showImages,
+ isSMIME: isSMIME,
+ smimeTrusted: smimeTrusted,
+ isEncrypted: isEncrypted,
+ imagePlacements: placements,
}
}
@@ -117,7 +119,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// Clear Kitty graphics before returning to mailbox
- clearKittyGraphics()
+ ClearKittyGraphics()
return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
}
@@ -155,30 +157,31 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "i":
if view.ImageProtocolSupported() {
m.showImages = !m.showImages
- clearKittyGraphics()
+ ClearKittyGraphics()
inlineImages := inlineImagesFromAttachments(m.email.Attachments)
- body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
+ body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
+ m.imagePlacements = placements
wrapped := wrapBodyToWidth(body, m.viewport.Width())
- m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
+ m.viewport.SetContent(wrapped + "\n")
return m, nil
}
case "r":
// Clear Kitty graphics before opening composer
- clearKittyGraphics()
+ ClearKittyGraphics()
return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
case "f":
// Clear Kitty graphics before opening composer
- clearKittyGraphics()
+ ClearKittyGraphics()
return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
case "d":
accountID := m.accountID
uid := m.email.UID
// Clear Kitty graphics before transitioning
- clearKittyGraphics()
+ ClearKittyGraphics()
return m, func() tea.Msg {
return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
}
@@ -186,7 +189,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
accountID := m.accountID
uid := m.email.UID
// Clear Kitty graphics before transitioning
- clearKittyGraphics()
+ ClearKittyGraphics()
return m, func() tea.Msg {
return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
}
@@ -208,13 +211,15 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
// When the window size changes, wrap and clear kitty images to keep placement stable
+ ClearKittyGraphics()
inlineImages := inlineImagesFromAttachments(m.email.Attachments)
- body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
+ body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
+ m.imagePlacements = placements
wrapped := wrapBodyToWidth(body, m.viewport.Width())
- m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
+ m.viewport.SetContent(wrapped + "\n")
}
m.viewport, cmd = m.viewport.Update(msg)
@@ -224,10 +229,12 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *EmailView) View() tea.View {
- // Clear all Kitty graphics before rendering to prevent image stacking on scroll.
- // This must be done synchronously via stdout before the frame is drawn,
- // as escape sequences in the return string execute too late.
- clearKittyGraphics()
+ // Clear image placements (but keep uploaded image data in terminal memory)
+ // before re-rendering to prevent stacking on scroll. Uses d=a (delete all
+ // placements) instead of d=A (delete all including data) so that images
+ // can be re-displayed by ID without re-uploading.
+ os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\")
+ os.Stdout.Sync()
smimeStatus := ""
if m.isEncrypted {
@@ -271,6 +278,27 @@ func (m *EmailView) View() tea.View {
attachmentView = attachmentBoxStyle.Render(b.String())
}
+ // Render visible images directly to stdout. Bubbletea v2's ultraviolet
+ // renderer uses a cell-based model that cannot pass through graphics
+ // protocol escape sequences, so we write them out-of-band.
+ if m.showImages && len(m.imagePlacements) > 0 {
+ headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
+ yOffset := m.viewport.YOffset()
+ vpHeight := m.viewport.Height()
+
+ for i := range m.imagePlacements {
+ p := &m.imagePlacements[i]
+ // Only render if the image's top line is within the viewport.
+ // We can't partially clip images scrolled off the top (Kitty
+ // always renders from the top-left), so we hide them once
+ // their start line scrolls above the viewport.
+ if p.Line >= yOffset && p.Line < yOffset+vpHeight {
+ screenRow := headerLines + (p.Line - yOffset)
+ view.RenderImageToStdout(p, screenRow)
+ }
+ }
+ }
+
// m.viewport.View() returns a string in Bubbles v2 viewport
return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
}
@@ -12,6 +12,7 @@ import (
"os"
"regexp"
"strings"
+ "sync"
"time"
_ "image/gif"
@@ -305,10 +306,30 @@ func debugImageProtocol(format string, args ...interface{}) {
}
}
+// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
+var remoteImageCache sync.Map
+
+// nextImageID is an auto-incrementing counter for Kitty image IDs.
+var nextImageID uint32 = 1000
+
+// allocImageID returns a unique Kitty image ID.
+func allocImageID() uint32 {
+ id := nextImageID
+ nextImageID++
+ return id
+}
+
func fetchRemoteBase64(url string) string {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return ""
}
+
+ // Check cache first
+ if cached, ok := remoteImageCache.Load(url); ok {
+ debugImageProtocol("remote cache hit url=%s", url)
+ return cached.(string)
+ }
+
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
@@ -340,6 +361,7 @@ func fetchRemoteBase64(url string) string {
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
+ remoteImageCache.Store(url, encoded)
return encoded
}
@@ -455,6 +477,102 @@ func renderInlineImage(payload string) string {
return ""
}
+// imageRows calculates the number of terminal rows an image occupies.
+func imageRows(payload string) int {
+ rows := 1
+ if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
+ if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
+ cellHeight := getTerminalCellSize()
+ h := img.Bounds().Dy()
+ rows = (h + cellHeight - 1) / cellHeight
+ if rows < 1 {
+ rows = 1
+ }
+ debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
+ }
+ }
+ return rows
+}
+
+// kittyUploadImage uploads image data to the terminal with a unique ID using
+// the Kitty graphics protocol transmit action (a=t). The image is stored in
+// the terminal's memory and can be displayed later by ID without re-sending data.
+func kittyUploadImage(payload string, id uint32) {
+ if payload == "" {
+ return
+ }
+
+ const chunkSize = 4096
+ for offset := 0; offset < len(payload); offset += chunkSize {
+ end := offset + chunkSize
+ if end > len(payload) {
+ end = len(payload)
+ }
+ more := "0"
+ if end < len(payload) {
+ more = "1"
+ }
+
+ chunk := payload[offset:end]
+ if offset == 0 {
+ // a=t: transmit (upload) only, don't display yet
+ // i=ID: assign this image ID
+ fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
+ } else {
+ fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
+ }
+ }
+ os.Stdout.Sync()
+}
+
+// kittyDisplayImage displays a previously uploaded image by its ID at the
+// current cursor position. This is very fast since no image data is transmitted.
+func kittyDisplayImage(id uint32) string {
+ // a=p: put (display) an already-uploaded image by ID
+ // C=1: cursor does not move
+ return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
+}
+
+// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
+// without any row placeholders. Used for out-of-band rendering to stdout.
+func iterm2ImageEscapeOnly(payload string) string {
+ if payload == "" {
+ return ""
+ }
+ return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
+}
+
+// RenderImageToStdout writes an image directly to stdout at the given screen
+// row using cursor positioning. This bypasses bubbletea's cell-based renderer
+// which cannot handle graphics protocol escape sequences.
+//
+// For Kitty-protocol terminals, images are uploaded once and then displayed by
+// ID on subsequent calls, making scroll rendering nearly instant.
+func RenderImageToStdout(placement *ImagePlacement, screenRow int) {
+ if placement.Base64 == "" {
+ return
+ }
+
+ useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
+ useIterm2 := iterm2Supported() || warpSupported()
+
+ if useKitty {
+ // Upload once, display by ID on subsequent renders
+ if !placement.Uploaded {
+ placement.ID = allocImageID()
+ kittyUploadImage(placement.Base64, placement.ID)
+ placement.Uploaded = true
+ }
+ seq := kittyDisplayImage(placement.ID)
+ fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
+ os.Stdout.Sync()
+ } else if useIterm2 {
+ seq := iterm2ImageEscapeOnly(placement.Base64)
+ fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
+ os.Stdout.Sync()
+ }
+}
+
// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
func expandImageRowPlaceholders(text string) string {
re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
@@ -476,8 +594,20 @@ type InlineImage struct {
Base64 string
}
+// ImagePlacement holds the data needed to render an image at a specific
+// line in the email body. Images are rendered directly to stdout (bypassing
+// bubbletea's cell-based renderer which cannot handle graphics protocols).
+type ImagePlacement struct {
+ Line int // Line number in the processed body text where the image starts
+ Base64 string // Base64-encoded image data (PNG)
+ Rows int // Number of terminal rows the image occupies
+ Uploaded bool // Whether the image has been uploaded to the terminal via Kitty ID
+ ID uint32 // Kitty image ID for display-by-reference
+}
+
// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
-func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
+// Returns the rendered body text, image placements for out-of-band rendering, and any error.
+func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
inlineMap := make(map[string]string, len(inline))
for _, img := range inline {
cid := strings.TrimSpace(img.CID)
@@ -494,11 +624,11 @@ func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Styl
// ProcessBody takes a raw email body, decodes it, and formats it as plain
// text with terminal hyperlinks.
-func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
+func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages)
}
-func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) {
+func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
decodedBody, err := decodeQuotedPrintable(rawBody)
if err != nil {
decodedBody = rawBody
@@ -508,7 +638,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
if err != nil {
- return "", fmt.Errorf("could not parse email body: %w", err)
+ return "", nil, fmt.Errorf("could not parse email body: %w", err)
}
doc.Find("style, script").Remove()
@@ -571,7 +701,16 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
s.ReplaceWithHtml(placeholder)
})
- // Format links and images
+ // Format links and images.
+ // Collect image placements for out-of-band rendering (bubbletea v2's
+ // ultraviolet renderer cannot pass through graphics protocol sequences).
+ var imgIndex int
+ var pendingImages []struct {
+ index int
+ payload string
+ rows int
+ }
+
doc.Find("a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if !exists {
@@ -608,15 +747,24 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
}
if payload != "" {
- if rendered := renderInlineImage(payload); rendered != "" {
- debugImageProtocol("rendered inline image src=%s len=%d dataURI=%t cid=%t (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"), kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
- s.ReplaceWithHtml("\n" + rendered + "\n")
- return
- }
- debugImageProtocol("payload present but renderer returned empty src=%s len=%d", src, len(payload))
- } else {
- debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
+ rows := imageRows(payload)
+ debugImageProtocol("collected image placement src=%s rows=%d (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, rows, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
+
+ idx := imgIndex
+ imgIndex++
+ pendingImages = append(pendingImages, struct {
+ index int
+ payload string
+ rows int
+ }{idx, payload, rows})
+
+ // Insert a placeholder with blank lines for spacing.
+ // The image placement marker lets us find the line number later.
+ placeholder := fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
+ s.ReplaceWithHtml(fmt.Sprintf("\n[[MATCHA_IMG:%d]]%s", idx, placeholder))
+ return
}
+ debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
} else {
debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
}
@@ -636,6 +784,32 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
// Now expand the image row placeholders to actual newlines
text = expandImageRowPlaceholders(text)
+ // Build image placements by finding the line numbers of image markers.
+ var placements []ImagePlacement
+ if len(pendingImages) > 0 {
+ lines := strings.Split(text, "\n")
+ imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
+ for lineNum, line := range lines {
+ if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
+ var idx int
+ fmt.Sscanf(matches[1], "%d", &idx)
+ for _, pi := range pendingImages {
+ if pi.index == idx {
+ placements = append(placements, ImagePlacement{
+ Line: lineNum,
+ Base64: pi.payload,
+ Rows: pi.rows,
+ })
+ break
+ }
+ }
+ }
+ }
+
+ // Remove the image markers from the text (leave the spacing)
+ text = imgMarkerRegex.ReplaceAllString(text, "")
+ }
+
// Replace quote placeholders with styled quote boxes
quoteRegex := regexp.MustCompile(`\[\[MATCHA_QUOTE:(\d+)\]\]`)
text = quoteRegex.ReplaceAllStringFunc(text, func(match string) string {
@@ -652,7 +826,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
// Style quoted reply sections (for plain text > quotes)
text = styleQuotedReplies(text)
- return bodyStyle.Render(text), nil
+ return bodyStyle.Render(text), placements, nil
}
// quoteBoxStyle is the style for the quoted reply box border
@@ -517,7 +517,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
tc.setupHyperlinks()
- processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
+ processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
if err != nil {
t.Fatalf("ProcessBody() failed: %v", err)
}
@@ -568,9 +568,10 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
input string
expectedContains string
expectedNotContains string
+ expectPlacements bool
}{
{
- name: "Data URI image with Kitty support",
+ name: "Data URI image with Kitty support returns placement",
setupImageProtocol: func() {
os.Setenv("TERM", "xterm-kitty")
},
@@ -581,11 +582,11 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
os.Unsetenv("WEZTERM_EXECUTABLE")
},
input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
- expectedContains: "\x1b_Gf=100,a=T,q=2,C=1,m=0;",
expectedNotContains: "[Image: test image,",
+ expectPlacements: true,
},
{
- name: "Data URI image with iTerm2 support",
+ name: "Data URI image with iTerm2 support returns placement",
setupImageProtocol: func() {
os.Setenv("TERM", "xterm")
os.Setenv("TERM_PROGRAM", "iterm.app")
@@ -597,8 +598,8 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
os.Unsetenv("WEZTERM_EXECUTABLE")
},
input: `<img src="data:image/png;base64,` + testBase64PNG + `" alt="test image">`,
- expectedContains: "\x1b]1337;File=inline=1:",
expectedNotContains: "[Image: test image,",
+ expectPlacements: true,
},
{
name: "Data URI image without protocol support",
@@ -643,14 +644,27 @@ func TestProcessBodyWithImageProtocol(t *testing.T) {
tc.clearAllImageEnv()
tc.setupImageProtocol()
- processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
+ processed, placements, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
if err != nil {
t.Fatalf("ProcessBody() failed: %v", err)
}
+ if tc.expectPlacements {
+ if len(placements) == 0 {
+ t.Errorf("Expected image placements but got none")
+ } else {
+ if placements[0].Base64 == "" {
+ t.Errorf("Expected non-empty Base64 in placement")
+ }
+ if placements[0].Rows < 1 {
+ t.Errorf("Expected Rows >= 1, got %d", placements[0].Rows)
+ }
+ }
+ }
+
cleanProcessed := ansiEscapeRegex.ReplaceAllString(processed, "")
- if !strings.Contains(cleanProcessed, tc.expectedContains) {
+ if tc.expectedContains != "" && !strings.Contains(cleanProcessed, tc.expectedContains) {
t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", cleanProcessed, tc.expectedContains)
}
@@ -697,7 +711,7 @@ func TestProcessBody(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
+ processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false)
if err != nil {
t.Fatalf("ProcessBody() failed: %v", err)
}