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