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 "d":
126 accountID := m.accountID
127 uid := m.email.UID
128 // Clear Kitty graphics before transitioning
129 clearKittyGraphics()
130 return m, func() tea.Msg {
131 return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
132 }
133 case "a":
134 accountID := m.accountID
135 uid := m.email.UID
136 // Clear Kitty graphics before transitioning
137 clearKittyGraphics()
138 return m, func() tea.Msg {
139 return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
140 }
141 case "tab":
142 if len(m.email.Attachments) > 0 {
143 m.focusOnAttachments = true
144 }
145 }
146 }
147 case tea.WindowSizeMsg:
148 header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
149 headerHeight := lipgloss.Height(header) + 2
150 attachmentHeight := 0
151 if len(m.email.Attachments) > 0 {
152 attachmentHeight = len(m.email.Attachments) + 2
153 }
154 // Update viewport dimensions
155 m.viewport.Width = msg.Width
156 m.viewport.Height = msg.Height - headerHeight - attachmentHeight
157
158 // When the window size changes, wrap and clear kitty images to keep placement stable
159 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
160 body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle)
161 if err != nil {
162 body = fmt.Sprintf("Error rendering body: %v", err)
163 }
164 wrapped := wrapBodyToWidth(body, m.viewport.Width)
165 m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
166 }
167
168 m.viewport, cmd = m.viewport.Update(msg)
169 cmds = append(cmds, cmd)
170
171 return m, tea.Batch(cmds...)
172}
173
174func (m *EmailView) View() string {
175 header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
176 styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header)
177
178 var help string
179 if m.focusOnAttachments {
180 help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
181 } else {
182 help = helpStyle.Render("\uf112 r: reply • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox")
183 }
184
185 var attachmentView string
186 if len(m.email.Attachments) > 0 {
187 var b strings.Builder
188 b.WriteString("Attachments:\n")
189 for i, attachment := range m.email.Attachments {
190 cursor := " "
191 style := itemStyle
192 if m.focusOnAttachments && i == m.attachmentCursor {
193 cursor = "> "
194 style = selectedItemStyle
195 }
196 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
197 b.WriteString("\n")
198 }
199 attachmentView = attachmentBoxStyle.Render(b.String())
200 }
201
202 return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)
203}
204
205// GetAccountID returns the account ID for this email
206func (m *EmailView) GetAccountID() string {
207 return m.accountID
208}
209
210func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
211 var imgs []view.InlineImage
212 for _, att := range atts {
213 if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
214 continue
215 }
216 imgs = append(imgs, view.InlineImage{
217 CID: att.ContentID,
218 Base64: base64.StdEncoding.EncodeToString(att.Data),
219 })
220 }
221 return imgs
222}
223
224func wrapBodyToWidth(body string, width int) string {
225 return BodyStyle.Width(width).Render(body)
226}
227
228// GetEmail returns the email being viewed
229func (m *EmailView) GetEmail() fetcher.Email {
230 return m.email
231}