html.go

  1package view
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"io"
  7	"mime/quotedprintable"
  8	"net/http"
  9	"os"
 10	"regexp"
 11	"strings"
 12	"sync/atomic"
 13	"time"
 14
 15	"charm.land/lipgloss/v2"
 16	"github.com/floatpane/matcha/clib"
 17	"github.com/floatpane/matcha/theme"
 18	lru "github.com/hashicorp/golang-lru/v2"
 19)
 20
 21func linkStyle() lipgloss.Style {
 22	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
 23}
 24
 25// getTerminalCellSize returns the height of a terminal cell in pixels.
 26// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
 27// Falls back to a default of 18 pixels if the query fails.
 28func getTerminalCellSize() int {
 29	const defaultCellHeight = 18
 30
 31	// Try stdout, stdin, stderr, then /dev/tty as last resort
 32	fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
 33
 34	for _, fd := range fds {
 35		if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
 36			return cellHeight
 37		}
 38	}
 39
 40	// Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
 41	if tty, err := os.Open("/dev/tty"); err == nil {
 42		defer tty.Close()
 43		if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
 44			return cellHeight
 45		}
 46	}
 47
 48	debugImageProtocol("using default cell height: %d pixels", defaultCellHeight)
 49	return defaultCellHeight
 50}
 51
 52// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
 53func hyperlinkSupported() bool {
 54	term := strings.ToLower(os.Getenv("TERM"))
 55
 56	// Terminals known to support OSC 8 hyperlinks
 57	supportedTerms := []string{
 58		"kitty",
 59		"ghostty",
 60		"wezterm",
 61		"alacritty",
 62		"foot",
 63		"tmux",
 64		"screen",
 65	}
 66
 67	for _, supported := range supportedTerms {
 68		if strings.Contains(term, supported) {
 69			return true
 70		}
 71	}
 72
 73	// Check for specific terminal programs
 74	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 75	supportedPrograms := []string{
 76		"iterm.app",
 77		"hyper",
 78		"vscode",
 79		"ghostty",
 80		"wezterm",
 81	}
 82
 83	for _, supported := range supportedPrograms {
 84		if strings.Contains(termProgram, supported) {
 85			return true
 86		}
 87	}
 88
 89	// Check for VTE-based terminals (GNOME Terminal, etc.)
 90	if os.Getenv("VTE_VERSION") != "" {
 91		return true
 92	}
 93
 94	// Check for specific environment variables that indicate hyperlink support
 95	if os.Getenv("KITTY_WINDOW_ID") != "" ||
 96		os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
 97		os.Getenv("WEZTERM_EXECUTABLE") != "" ||
 98		os.Getenv("WT_SESSION") != "" {
 99		return true
100	}
101
102	return false
103}
104
105// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
106func hyperlink(url, text string) string {
107	if text == "" {
108		text = url
109	}
110
111	supported := hyperlinkSupported()
112
113	if supported {
114		// Use OSC 8 hyperlink sequence for supported terminals
115		return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
116	} else {
117		// Fallback to plain text format for unsupported terminals
118		if text == url {
119			return fmt.Sprintf("<%s>", linkStyle().Render(url))
120		}
121		return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url))
122	}
123}
124
125func decodeQuotedPrintable(s string) (string, error) {
126	reader := quotedprintable.NewReader(strings.NewReader(s))
127	body, err := io.ReadAll(reader)
128	if err != nil {
129		return "", err
130	}
131	return string(body), nil
132}
133
134// markdownToHTML converts a Markdown string to an HTML string using md4c (C).
135func markdownToHTML(md []byte) []byte {
136	return clib.MarkdownToHTML(md)
137}
138
139func kittySupported() bool {
140	term := strings.ToLower(os.Getenv("TERM"))
141	if strings.Contains(term, "kitty") {
142		return true
143	}
144	return os.Getenv("KITTY_WINDOW_ID") != ""
145}
146
147func ghosttySupported() bool {
148	// Check for TERM containing ghostty
149	term := strings.ToLower(os.Getenv("TERM"))
150	if strings.Contains(term, "ghostty") {
151		return true
152	}
153
154	// Check for Ghostty-specific environment variables
155	if os.Getenv("TERM_PROGRAM") == "ghostty" {
156		return true
157	}
158
159	// Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
160	return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
161}
162
163func iterm2Supported() bool {
164	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
165	if termProgram == "iterm.app" {
166		return true
167	}
168
169	// Check for iTerm2-specific environment variables
170	if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
171		return true
172	}
173
174	return false
175}
176
177func weztermSupported() bool {
178	// Check for WezTerm-specific environment variables
179	if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
180		return true
181	}
182
183	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
184	if termProgram == "wezterm" {
185		return true
186	}
187
188	term := strings.ToLower(os.Getenv("TERM"))
189	if strings.Contains(term, "wezterm") {
190		return true
191	}
192
193	return false
194}
195
196func waystSupported() bool {
197	term := strings.ToLower(os.Getenv("TERM"))
198	if strings.Contains(term, "wayst") {
199		return true
200	}
201
202	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
203	if termProgram == "wayst" {
204		return true
205	}
206
207	return false
208}
209
210func warpSupported() bool {
211	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
212	if termProgram == "warp" {
213		return true
214	}
215
216	// Check for Warp-specific environment variables
217	if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
218		return true
219	}
220
221	return false
222}
223
224func konsoleSupported() bool {
225	// Check for Konsole-specific environment variables
226	if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
227		return true
228	}
229
230	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
231	if termProgram == "konsole" {
232		return true
233	}
234
235	return false
236}
237
238// ImageProtocolSupported checks if any supported image protocol terminal is detected.
239func ImageProtocolSupported() bool {
240	return imageProtocolSupported()
241}
242
243// imageProtocolSupported checks if any supported image protocol terminal is detected.
244func imageProtocolSupported() bool {
245	return kittySupported() || ghosttySupported() || iterm2Supported() ||
246		weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
247}
248
249func debugImageProtocol(format string, args ...interface{}) {
250	if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
251		return
252	}
253	msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
254	fmt.Print(msg)
255	if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
256		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
257			_, _ = f.WriteString(msg)
258			_ = f.Close()
259		}
260	} else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
261		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
262			_, _ = f.WriteString(msg)
263			_ = f.Close()
264		}
265	}
266}
267
268const remoteImageCacheSize = 20
269
270// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
271var remoteImageCache *lru.Cache[string, string]
272
273func init() {
274	c, err := lru.New[string, string](remoteImageCacheSize)
275	if err != nil {
276		panic(err) // only fails on size <= 0
277	}
278	remoteImageCache = c
279}
280
281// nextImageID is an auto-incrementing counter for Kitty image IDs.
282var nextImageID uint32 = 1000
283
284// allocImageID returns a unique Kitty image ID.
285func allocImageID() uint32 {
286	return atomic.AddUint32(&nextImageID, 1)
287}
288
289func fetchRemoteBase64(url string) string {
290	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
291		return ""
292	}
293
294	// Check cache first
295	if cached, ok := remoteImageCache.Get(url); ok {
296		debugImageProtocol("remote cache hit url=%s", url)
297		return cached
298	}
299
300	client := &http.Client{Timeout: 5 * time.Second}
301	resp, err := client.Get(url)
302	if err != nil {
303		debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
304		return ""
305	}
306	defer resp.Body.Close()
307	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
308		debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
309		return ""
310	}
311	// Limit response body to 10 MB to prevent memory exhaustion from
312	// malicious or very large images.
313	const maxImageSize = 10 << 20 // 10 MB
314	data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize))
315	if err != nil {
316		debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
317		return ""
318	}
319
320	result, ok := clib.DecodeToPNG(data)
321	if !ok {
322		debugImageProtocol("remote decode failed url=%s", url)
323		return ""
324	}
325
326	encoded := base64.StdEncoding.EncodeToString(result.PNGData)
327	debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
328	remoteImageCache.Add(url, encoded)
329	return encoded
330}
331
332func dataURIBase64(uri string) string {
333	if !strings.HasPrefix(uri, "data:") {
334		return ""
335	}
336	comma := strings.Index(uri, ",")
337	if comma == -1 || comma+1 >= len(uri) {
338		return ""
339	}
340	return uri[comma+1:]
341}
342
343// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
344// This prevents the newline-collapsing regex from removing intentional spacing.
345// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
346const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
347const imageRowPlaceholderSuffix = "]]"
348
349func kittyInlineImage(payload string) string {
350	if payload == "" {
351		return ""
352	}
353
354	const chunkSize = 4096
355	var b strings.Builder
356
357	// Calculate how many terminal rows the image occupies to advance text after it.
358	rows := 1
359	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
360		if _, h, ok := clib.ImageDimensions(data); ok {
361			cellHeight := getTerminalCellSize()
362			rows = (h + cellHeight - 1) / cellHeight
363			if rows < 1 {
364				rows = 1
365			}
366			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
367		}
368	}
369
370	for offset := 0; offset < len(payload); offset += chunkSize {
371		end := offset + chunkSize
372		if end > len(payload) {
373			end = len(payload)
374		}
375		more := "0"
376		if end < len(payload) {
377			more = "1"
378		}
379
380		chunk := payload[offset:end]
381		if offset == 0 {
382			// C=1 means cursor does NOT move after image render (stays at top-left of image position)
383			// This is needed for proper TUI rendering, but we must add newlines to push text below
384			b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
385		} else {
386			b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
387		}
388	}
389
390	// Add newlines to push cursor below the image.
391	// Use a placeholder that won't be collapsed by the newline regex.
392	b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
393
394	return b.String()
395}
396
397// iterm2InlineImage renders an image using iTerm2's image protocol
398func iterm2InlineImage(payload string) string {
399	if payload == "" {
400		return ""
401	}
402
403	// Calculate rows for cursor positioning
404	rows := 1
405	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
406		if _, h, ok := clib.ImageDimensions(data); ok {
407			cellHeight := getTerminalCellSize()
408			rows = (h + cellHeight - 1) / cellHeight
409			if rows < 1 {
410				rows = 1
411			}
412			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
413		}
414	}
415
416	// iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
417	result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
418
419	// Add placeholder for row spacing
420	result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
421
422	return result
423}
424
425// renderInlineImage renders an image using the appropriate protocol for the detected terminal
426func renderInlineImage(payload string) string {
427	if payload == "" {
428		return ""
429	}
430
431	if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
432		// These terminals use the Kitty graphics protocol
433		return kittyInlineImage(payload)
434	} else if iterm2Supported() || warpSupported() {
435		// iTerm2 and Warp use the iTerm2 image protocol
436		return iterm2InlineImage(payload)
437	}
438
439	return ""
440}
441
442// imageRows calculates the number of terminal rows an image occupies.
443func imageRows(payload string) int {
444	rows := 1
445	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
446		if _, h, ok := clib.ImageDimensions(data); ok {
447			cellHeight := getTerminalCellSize()
448			rows = (h + cellHeight - 1) / cellHeight
449			if rows < 1 {
450				rows = 1
451			}
452			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
453		}
454	}
455	return rows
456}
457
458// kittyUploadImage uploads image data to the terminal with a unique ID using
459// the Kitty graphics protocol transmit action (a=t). The image is stored in
460// the terminal's memory and can be displayed later by ID without re-sending data.
461func kittyUploadImage(payload string, id uint32) {
462	if payload == "" {
463		return
464	}
465
466	const chunkSize = 4096
467	for offset := 0; offset < len(payload); offset += chunkSize {
468		end := offset + chunkSize
469		if end > len(payload) {
470			end = len(payload)
471		}
472		more := "0"
473		if end < len(payload) {
474			more = "1"
475		}
476
477		chunk := payload[offset:end]
478		if offset == 0 {
479			// a=t: transmit (upload) only, don't display yet
480			// i=ID: assign this image ID
481			fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
482		} else {
483			fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
484		}
485	}
486	os.Stdout.Sync()
487}
488
489// kittyDisplayImage displays a previously uploaded image by its ID at the
490// current cursor position. This is very fast since no image data is transmitted.
491func kittyDisplayImage(id uint32) string {
492	// a=p: put (display) an already-uploaded image by ID
493	// C=1: cursor does not move
494	return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
495}
496
497// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
498// without any row placeholders. Used for out-of-band rendering to stdout.
499func iterm2ImageEscapeOnly(payload string) string {
500	if payload == "" {
501		return ""
502	}
503	return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
504}
505
506// RenderImageToStdout writes an image directly to stdout at the given screen
507// row using cursor positioning. This bypasses bubbletea's cell-based renderer
508// which cannot handle graphics protocol escape sequences.
509//
510// For Kitty-protocol terminals, images are uploaded once and then displayed by
511// ID on subsequent calls, making scroll rendering nearly instant.
512func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ...int) {
513	if placement.Base64 == "" {
514		return
515	}
516
517	col := 1
518	if len(screenCol) > 0 && screenCol[0] > 0 {
519		col = screenCol[0]
520	}
521
522	useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
523	useIterm2 := iterm2Supported() || warpSupported()
524
525	if useKitty {
526		// Upload once, display by ID on subsequent renders
527		if !placement.Uploaded {
528			placement.ID = allocImageID()
529			kittyUploadImage(placement.Base64, placement.ID)
530			placement.Uploaded = true
531		}
532		seq := kittyDisplayImage(placement.ID)
533		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
534		os.Stdout.Sync()
535	} else if useIterm2 {
536		seq := iterm2ImageEscapeOnly(placement.Base64)
537		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
538		os.Stdout.Sync()
539	}
540}
541
542// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
543func expandImageRowPlaceholders(text string) string {
544	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
545	return re.ReplaceAllStringFunc(text, func(match string) string {
546		// Extract the number of rows from the placeholder
547		numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
548		numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
549		rows := 1
550		if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
551			rows = 1
552		}
553		// Return the newlines needed to push content below the image
554		return strings.Repeat("\n", rows)
555	})
556}
557
558type InlineImage struct {
559	CID    string
560	Base64 string
561}
562
563// ImagePlacement holds the data needed to render an image at a specific
564// line in the email body. Images are rendered directly to stdout (bypassing
565// bubbletea's cell-based renderer which cannot handle graphics protocols).
566type ImagePlacement struct {
567	Line     int    // Line number in the processed body text where the image starts
568	Base64   string // Base64-encoded image data (PNG)
569	Rows     int    // Number of terminal rows the image occupies
570	Uploaded bool   // Whether the image has been uploaded to the terminal via Kitty ID
571	ID       uint32 // Kitty image ID for display-by-reference
572}
573
574// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
575// Returns the rendered body text, image placements for out-of-band rendering, and any error.
576func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
577	inlineMap := make(map[string]string, len(inline))
578	for _, img := range inline {
579		cid := strings.TrimSpace(img.CID)
580		cid = strings.TrimPrefix(cid, "<")
581		cid = strings.TrimSuffix(cid, ">")
582		cid = strings.TrimPrefix(cid, "cid:")
583		if cid == "" || img.Base64 == "" {
584			continue
585		}
586		inlineMap[cid] = img.Base64
587	}
588	return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
589}
590
591// ProcessBody takes a raw email body, decodes it, and formats it as plain
592// text with terminal hyperlinks.
593func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
594	return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages)
595}
596
597func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
598	decodedBody, err := decodeQuotedPrintable(rawBody)
599	if err != nil {
600		decodedBody = rawBody
601	}
602
603	htmlBody := markdownToHTML([]byte(decodedBody))
604
605	// Parse HTML into structured elements using C parser.
606	elements, ok := clib.HTMLToElements(string(htmlBody))
607	if !ok {
608		return "", nil, fmt.Errorf("could not parse email body")
609	}
610
611	// Process elements: apply styles and collect image placements.
612	var text strings.Builder
613	var imgIndex int
614	var pendingImages []struct {
615		index   int
616		payload string
617		rows    int
618	}
619
620	onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
621
622	for _, elem := range elements {
623		switch elem.Type {
624		case clib.HElemText:
625			text.WriteString(elem.Text)
626
627		case clib.HElemH1:
628			text.WriteString(h1Style.Render(elem.Text))
629			text.WriteString("\n\n")
630
631		case clib.HElemH2:
632			text.WriteString(h2Style.Render(elem.Text))
633			text.WriteString("\n\n")
634
635		case clib.HElemLink:
636			text.WriteString(hyperlink(elem.Attr1, elem.Text))
637
638		case clib.HElemImage:
639			src := elem.Attr1
640			alt := elem.Attr2
641
642			if !disableImages && imageProtocolSupported() {
643				var payload string
644				if strings.HasPrefix(src, "data:image/") {
645					payload = dataURIBase64(src)
646				} else if strings.HasPrefix(src, "cid:") {
647					cid := strings.TrimPrefix(src, "cid:")
648					cid = strings.Trim(cid, "<>")
649					if inline != nil {
650						payload = inline[cid]
651						debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
652					} else {
653						debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
654					}
655				} else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
656					payload = fetchRemoteBase64(src)
657				}
658
659				if payload != "" {
660					rows := imageRows(payload)
661					debugImageProtocol("collected image placement src=%s rows=%d", src, rows)
662
663					idx := imgIndex
664					imgIndex++
665					pendingImages = append(pendingImages, struct {
666						index   int
667						payload string
668						rows    int
669					}{idx, payload, rows})
670
671					text.WriteString(fmt.Sprintf("\n[[MATCHA_IMG:%d]]", idx))
672					text.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
673					continue
674				}
675				debugImageProtocol("no payload for src=%s", src)
676			}
677			if hyperlinkSupported() {
678				text.WriteString(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
679			} else {
680				text.WriteString(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
681			}
682
683		case clib.HElemTable:
684			headerRows := 0
685			if elem.Attr1 != "" {
686				fmt.Sscanf(elem.Attr1, "%d", &headerRows)
687			}
688			text.WriteString("\n")
689			text.WriteString(renderTable(elem.Text, headerRows))
690			text.WriteString("\n")
691
692		case clib.HElemBlockquote:
693			var from, date string
694			prevText := elem.Attr2
695			cite := elem.Attr1
696
697			if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
698				date = parseDateForDisplay(matches[1])
699				from = matches[2]
700			} else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
701				date = parseDateForDisplay(matches[1])
702				from = matches[2]
703			}
704
705			text.WriteString(renderQuoteBox(from, date, strings.Split(elem.Text, "\n")))
706		}
707	}
708
709	result := text.String()
710
711	// Collapse excessive newlines, but not the image row placeholders
712	re := regexp.MustCompile(`\n{3,}`)
713	result = re.ReplaceAllString(result, "\n\n")
714
715	// Now expand the image row placeholders to actual newlines
716	result = expandImageRowPlaceholders(result)
717
718	// Build image placements by finding the line numbers of image markers.
719	var placements []ImagePlacement
720	if len(pendingImages) > 0 {
721		lines := strings.Split(result, "\n")
722		imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
723		for lineNum, line := range lines {
724			if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
725				var idx int
726				fmt.Sscanf(matches[1], "%d", &idx)
727				for _, pi := range pendingImages {
728					if pi.index == idx {
729						placements = append(placements, ImagePlacement{
730							Line:   lineNum,
731							Base64: pi.payload,
732							Rows:   pi.rows,
733						})
734						break
735					}
736				}
737			}
738		}
739
740		// Remove the image markers from the text (leave the spacing)
741		result = imgMarkerRegex.ReplaceAllString(result, "")
742	}
743
744	// Style quoted reply sections (for plain text > quotes)
745	result = styleQuotedReplies(result)
746
747	return bodyStyle.Render(result), placements, nil
748}
749
750func tableHeaderStyle() lipgloss.Style {
751	return lipgloss.NewStyle().Bold(true).Foreground(theme.ActiveTheme.Accent)
752}
753
754func tableBorderStyle() lipgloss.Style {
755	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Secondary)
756}
757
758// renderTable renders table data as a Unicode box-drawing table.
759// data is tab-separated cells, newline-separated rows.
760// headerRows is the number of header rows.
761func renderTable(data string, headerRows int) string {
762	rows := strings.Split(data, "\n")
763	if len(rows) == 0 {
764		return ""
765	}
766
767	// Parse into 2D grid and trim cell whitespace
768	var grid [][]string
769	maxCols := 0
770	for _, row := range rows {
771		cells := strings.Split(row, "\t")
772		trimmed := make([]string, len(cells))
773		for i, c := range cells {
774			trimmed[i] = strings.TrimSpace(c)
775		}
776		grid = append(grid, trimmed)
777		if len(trimmed) > maxCols {
778			maxCols = len(trimmed)
779		}
780	}
781
782	// Normalize: ensure all rows have the same number of columns
783	for i := range grid {
784		for len(grid[i]) < maxCols {
785			grid[i] = append(grid[i], "")
786		}
787	}
788
789	// Calculate column widths
790	colWidths := make([]int, maxCols)
791	for _, row := range grid {
792		for j, cell := range row {
793			if len(cell) > colWidths[j] {
794				colWidths[j] = len(cell)
795			}
796		}
797	}
798
799	// Minimum width per column
800	for i := range colWidths {
801		if colWidths[i] < 3 {
802			colWidths[i] = 3
803		}
804	}
805
806	bs := tableBorderStyle()
807	hs := tableHeaderStyle()
808
809	// Build horizontal borders
810	buildBorder := func(left, mid, right, fill string) string {
811		var b strings.Builder
812		b.WriteString(bs.Render(left))
813		for j, w := range colWidths {
814			b.WriteString(bs.Render(strings.Repeat(fill, w+2)))
815			if j < len(colWidths)-1 {
816				b.WriteString(bs.Render(mid))
817			}
818		}
819		b.WriteString(bs.Render(right))
820		return b.String()
821	}
822
823	topBorder := buildBorder("┌", "┬", "┐", "─")
824	midBorder := buildBorder("├", "┼", "┤", "─")
825	botBorder := buildBorder("└", "┴", "┘", "─")
826
827	var out strings.Builder
828	out.WriteString(topBorder)
829	out.WriteString("\n")
830
831	for i, row := range grid {
832		out.WriteString(bs.Render("│"))
833		for j, cell := range row {
834			padded := cell + strings.Repeat(" ", colWidths[j]-len(cell))
835			if i < headerRows {
836				out.WriteString(" " + hs.Render(padded) + " ")
837			} else {
838				out.WriteString(" " + padded + " ")
839			}
840			out.WriteString(bs.Render("│"))
841		}
842		out.WriteString("\n")
843
844		if i < headerRows && (i+1 == headerRows || i+1 == len(grid)) {
845			out.WriteString(midBorder)
846			out.WriteString("\n")
847		}
848	}
849
850	out.WriteString(botBorder)
851	return out.String()
852}
853
854func quoteBoxStyle() lipgloss.Style {
855	return lipgloss.NewStyle().
856		Border(lipgloss.RoundedBorder()).
857		BorderForeground(theme.ActiveTheme.Secondary).
858		Padding(0, 1).
859		Foreground(theme.ActiveTheme.Secondary)
860}
861
862func quoteHeaderStyle() lipgloss.Style {
863	return lipgloss.NewStyle().
864		Foreground(theme.ActiveTheme.Secondary)
865}
866
867// styleQuotedReplies detects quoted reply sections and styles them in a box
868func styleQuotedReplies(text string) string {
869	lines := strings.Split(text, "\n")
870	var result []string
871	var quoteBlock []string
872	var quoteFrom, quoteDate string
873	inQuote := false
874
875	// Regex to match "On DATE, EMAIL wrote:" pattern
876	// Matches various date formats
877	onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
878
879	for i := 0; i < len(lines); i++ {
880		line := lines[i]
881		trimmedLine := strings.TrimSpace(line)
882
883		// Check for "On DATE, EMAIL wrote:" header
884		if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
885			// If we were already in a quote block, render it first
886			if inQuote && len(quoteBlock) > 0 {
887				result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
888				quoteBlock = nil
889			}
890
891			// Parse the date and email from the match
892			dateStr := matches[1]
893			quoteFrom = matches[2]
894			quoteDate = parseDateForDisplay(dateStr)
895			inQuote = true
896			continue
897		}
898
899		// Check if line starts with ">" (quoted text)
900		if strings.HasPrefix(trimmedLine, ">") {
901			if !inQuote {
902				// Start a new quote block without header info
903				inQuote = true
904				quoteFrom = ""
905				quoteDate = ""
906			}
907			// Remove the leading "> " and add to quote block
908			quotedContent := strings.TrimPrefix(trimmedLine, ">")
909			quotedContent = strings.TrimPrefix(quotedContent, " ")
910			quoteBlock = append(quoteBlock, quotedContent)
911		} else if inQuote {
912			// End of quote block - check if it's just whitespace
913			if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") {
914				// Empty line within quote block, keep it
915				quoteBlock = append(quoteBlock, "")
916			} else if trimmedLine == "" && len(quoteBlock) == 0 {
917				// Empty line before any quoted content, skip
918				continue
919			} else {
920				// End of quote block
921				if len(quoteBlock) > 0 {
922					result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
923					quoteBlock = nil
924				}
925				inQuote = false
926				quoteFrom = ""
927				quoteDate = ""
928				result = append(result, line)
929			}
930		} else {
931			result = append(result, line)
932		}
933	}
934
935	// Handle any remaining quote block
936	if inQuote && len(quoteBlock) > 0 {
937		result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
938	}
939
940	return strings.Join(result, "\n")
941}
942
943// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
944func parseDateForDisplay(dateStr string) string {
945	// Common date formats to try
946	formats := []string{
947		"Jan 2, 2006 at 3:04 PM",
948		"02:01:06 15:04",
949		"2006-01-02 15:04:05",
950		"Mon, 02 Jan 2006 15:04:05 -0700",
951		"Mon, 2 Jan 2006 15:04:05 -0700",
952		"2 Jan 2006 15:04:05",
953		"January 2, 2006 at 3:04 PM",
954		"Jan 2, 2006 3:04 PM",
955		time.RFC1123Z,
956		time.RFC1123,
957		time.RFC822Z,
958		time.RFC822,
959	}
960
961	for _, format := range formats {
962		if t, err := time.Parse(format, dateStr); err == nil {
963			return t.Format("02:01:06 15:04")
964		}
965	}
966
967	// Return original if parsing fails
968	return dateStr
969}
970
971// renderQuoteBox renders a quoted section in a styled box
972func renderQuoteBox(from, date string, lines []string) string {
973	// Build header with email on left and date on right
974	var header string
975	if from != "" || date != "" {
976		if from != "" && date != "" {
977			header = quoteHeaderStyle().Render(from + "  " + date)
978		} else if from != "" {
979			header = quoteHeaderStyle().Render(from)
980		} else {
981			header = quoteHeaderStyle().Render(date)
982		}
983	}
984
985	// Join the quoted content
986	content := strings.Join(lines, "\n")
987
988	// Build the box content
989	var boxContent string
990	if header != "" {
991		boxContent = header + "\n\n" + content
992	} else {
993		boxContent = content
994	}
995
996	return quoteBoxStyle().Render(boxContent)
997}