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}