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