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}