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