1package tui
2
3import (
4 "encoding/base64"
5 "fmt"
6 "os"
7 "strings"
8
9 "charm.land/bubbles/v2/viewport"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/floatpane/matcha/fetcher"
13 "github.com/floatpane/matcha/view"
14)
15
16// clearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout
17func clearKittyGraphics() {
18 // Delete all images: a=d (action=delete), d=A (delete all)
19 os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
20 os.Stdout.Sync()
21}
22
23var (
24 emailHeaderStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
25 attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
26)
27
28type EmailView struct {
29 viewport viewport.Model
30 email fetcher.Email
31 emailIndex int
32 attachmentCursor int
33 focusOnAttachments bool
34 accountID string
35 mailbox MailboxKind
36 disableImages bool
37 showImages bool
38}
39
40func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
41 // Pass the styles from the tui package to the view package
42 inlineImages := inlineImagesFromAttachments(email.Attachments)
43
44 // Initial state for showImages matches config unless overridden later
45 showImages := !disableImages
46
47 body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
48 if err != nil {
49 body = fmt.Sprintf("Error rendering body: %v", err)
50 }
51
52 // Create header and compute heights that reduce viewport space.
53 header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
54 headerHeight := lipgloss.Height(header) + 2
55
56 attachmentHeight := 0
57 if len(email.Attachments) > 0 {
58 attachmentHeight = len(email.Attachments) + 2
59 }
60
61 // Build viewport with initial size and set wrapped content.
62 vp := viewport.New()
63 vp.SetWidth(width)
64 vp.SetHeight(height - headerHeight - attachmentHeight)
65 wrapped := wrapBodyToWidth(body, vp.Width())
66 vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
67
68 return &EmailView{
69 viewport: vp,
70 email: email,
71 emailIndex: emailIndex,
72 accountID: email.AccountID,
73 mailbox: mailbox,
74 disableImages: disableImages,
75 showImages: showImages,
76 }
77}
78
79func (m *EmailView) Init() tea.Cmd {
80 return nil
81}
82
83func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 var cmd tea.Cmd
85 var cmds []tea.Cmd
86
87 switch msg := msg.(type) {
88 case tea.KeyPressMsg:
89 // Handle 'esc' key locally
90 if msg.String() == "esc" {
91 if m.focusOnAttachments {
92 m.focusOnAttachments = false
93 return m, nil
94 }
95 // Clear Kitty graphics before returning to mailbox
96 clearKittyGraphics()
97 return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
98 }
99
100 if m.focusOnAttachments {
101 switch msg.String() {
102 case "up", "k":
103 if m.attachmentCursor > 0 {
104 m.attachmentCursor--
105 }
106 case "down", "j":
107 if m.attachmentCursor < len(m.email.Attachments)-1 {
108 m.attachmentCursor++
109 }
110 case "enter":
111 if len(m.email.Attachments) > 0 {
112 selected := m.email.Attachments[m.attachmentCursor]
113 idx := m.emailIndex
114 accountID := m.accountID
115 return m, func() tea.Msg {
116 return DownloadAttachmentMsg{
117 Index: idx,
118 Filename: selected.Filename,
119 PartID: selected.PartID,
120 Data: selected.Data,
121 AccountID: accountID,
122 Mailbox: m.mailbox,
123 }
124 }
125 }
126 case "tab":
127 m.focusOnAttachments = false
128 }
129 } else {
130 switch msg.String() {
131 case "i":
132 if view.ImageProtocolSupported() {
133 m.showImages = !m.showImages
134 clearKittyGraphics()
135
136 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
137 body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
138 if err != nil {
139 body = fmt.Sprintf("Error rendering body: %v", err)
140 }
141 wrapped := wrapBodyToWidth(body, m.viewport.Width())
142 m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
143 return m, nil
144 }
145 case "r":
146 // Clear Kitty graphics before opening composer
147 clearKittyGraphics()
148 return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
149 case "f":
150 // Clear Kitty graphics before opening composer
151 clearKittyGraphics()
152 return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
153 case "d":
154 accountID := m.accountID
155 uid := m.email.UID
156 // Clear Kitty graphics before transitioning
157 clearKittyGraphics()
158 return m, func() tea.Msg {
159 return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
160 }
161 case "a":
162 accountID := m.accountID
163 uid := m.email.UID
164 // Clear Kitty graphics before transitioning
165 clearKittyGraphics()
166 return m, func() tea.Msg {
167 return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
168 }
169 case "tab":
170 if len(m.email.Attachments) > 0 {
171 m.focusOnAttachments = true
172 }
173 }
174 }
175 case tea.WindowSizeMsg:
176 header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
177 headerHeight := lipgloss.Height(header) + 2
178 attachmentHeight := 0
179 if len(m.email.Attachments) > 0 {
180 attachmentHeight = len(m.email.Attachments) + 2
181 }
182 // Update viewport dimensions
183 m.viewport.SetWidth(msg.Width)
184 m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
185
186 // When the window size changes, wrap and clear kitty images to keep placement stable
187 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
188 body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
189 if err != nil {
190 body = fmt.Sprintf("Error rendering body: %v", err)
191 }
192 wrapped := wrapBodyToWidth(body, m.viewport.Width())
193 m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
194 }
195
196 m.viewport, cmd = m.viewport.Update(msg)
197 cmds = append(cmds, cmd)
198
199 return m, tea.Batch(cmds...)
200}
201
202func (m *EmailView) View() tea.View {
203 // Clear all Kitty graphics before rendering to prevent image stacking on scroll.
204 // This must be done synchronously via stdout before the frame is drawn,
205 // as escape sequences in the return string execute too late.
206 clearKittyGraphics()
207
208 header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
209 styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
210
211 var help string
212 if m.focusOnAttachments {
213 help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
214 } else {
215 shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
216 if view.ImageProtocolSupported() {
217 shortcuts = shortcuts + "• \uf03e i: toggle images"
218 }
219 help = helpStyle.Render(shortcuts)
220 }
221
222 var attachmentView string
223 if len(m.email.Attachments) > 0 {
224 var b strings.Builder
225 b.WriteString("Attachments:\n")
226 for i, attachment := range m.email.Attachments {
227 cursor := " "
228 style := itemStyle
229 if m.focusOnAttachments && i == m.attachmentCursor {
230 cursor = "> "
231 style = selectedItemStyle
232 }
233 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
234 b.WriteString("\n")
235 }
236 attachmentView = attachmentBoxStyle.Render(b.String())
237 }
238
239 // m.viewport.View() returns a string in Bubbles v2 viewport
240 return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
241}
242
243// GetAccountID returns the account ID for this email
244func (m *EmailView) GetAccountID() string {
245 return m.accountID
246}
247
248func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
249 var imgs []view.InlineImage
250 for _, att := range atts {
251 if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
252 continue
253 }
254 imgs = append(imgs, view.InlineImage{
255 CID: att.ContentID,
256 Base64: base64.StdEncoding.EncodeToString(att.Data),
257 })
258 }
259 return imgs
260}
261
262func wrapBodyToWidth(body string, width int) string {
263 return BodyStyle.Width(width).Render(body)
264}
265
266// GetEmail returns the email being viewed
267func (m *EmailView) GetEmail() fetcher.Email {
268 return m.email
269}