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