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