1package tui
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/textarea"
8 "github.com/charmbracelet/bubbles/textinput"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/floatpane/matcha/config"
12)
13
14// Styles for the UI
15var (
16 focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
17 blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
18 cursorStyle = focusedStyle.Copy()
19 noStyle = lipgloss.NewStyle()
20 helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
21 focusedButton = focusedStyle.Copy().Render("[ Send ]")
22 blurredButton = blurredStyle.Copy().Render("[ Send ]")
23 emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
24 attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240"))
25 fromSelectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
26)
27
28const (
29 focusFrom = iota
30 focusTo
31 focusSubject
32 focusBody
33 focusAttachment
34 focusSend
35)
36
37// Composer model holds the state of the email composition UI.
38type Composer struct {
39 focusIndex int
40 toInput textinput.Model
41 subjectInput textinput.Model
42 bodyInput textarea.Model
43 attachmentPath string
44 width int
45 height int
46 confirmingExit bool
47
48 // Multi-account support
49 accounts []config.Account
50 selectedAccountIdx int
51 showAccountPicker bool
52}
53
54// NewComposer initializes a new composer model.
55func NewComposer(from, to, subject, body string) *Composer {
56 m := &Composer{}
57
58 m.toInput = textinput.New()
59 m.toInput.Cursor.Style = cursorStyle
60 m.toInput.Placeholder = "To"
61 m.toInput.SetValue(to)
62 m.toInput.Prompt = "> "
63 m.toInput.CharLimit = 256
64
65 m.subjectInput = textinput.New()
66 m.subjectInput.Cursor.Style = cursorStyle
67 m.subjectInput.Placeholder = "Subject"
68 m.subjectInput.SetValue(subject)
69 m.subjectInput.Prompt = "> "
70 m.subjectInput.CharLimit = 256
71
72 m.bodyInput = textarea.New()
73 m.bodyInput.Cursor.Style = cursorStyle
74 m.bodyInput.Placeholder = "Body (Markdown supported)..."
75 m.bodyInput.SetValue(body)
76 m.bodyInput.Prompt = "> "
77 m.bodyInput.SetHeight(10)
78 m.bodyInput.SetCursor(0)
79
80 // Start focus on To field (From is selectable but not a text input)
81 m.focusIndex = focusTo
82 m.toInput.Focus()
83
84 return m
85}
86
87// NewComposerWithAccounts initializes a composer with multiple account support.
88func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string) *Composer {
89 m := NewComposer("", to, subject, body)
90 m.accounts = accounts
91
92 // Find the selected account index
93 for i, acc := range accounts {
94 if acc.ID == selectedAccountID {
95 m.selectedAccountIdx = i
96 break
97 }
98 }
99
100 return m
101}
102
103// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt.
104func (m *Composer) ResetConfirmation() {
105 m.confirmingExit = false
106}
107
108func (m *Composer) Init() tea.Cmd {
109 return textinput.Blink
110}
111
112func (m *Composer) getFromAddress() string {
113 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
114 acc := m.accounts[m.selectedAccountIdx]
115 if acc.Name != "" {
116 return fmt.Sprintf("%s <%s>", acc.Name, acc.Email)
117 }
118 return acc.Email
119 }
120 return ""
121}
122
123func (m *Composer) getSelectedAccount() *config.Account {
124 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
125 return &m.accounts[m.selectedAccountIdx]
126 }
127 return nil
128}
129
130func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131 var cmds []tea.Cmd
132 var cmd tea.Cmd
133
134 switch msg := msg.(type) {
135 case tea.WindowSizeMsg:
136 m.width = msg.Width
137 m.height = msg.Height
138 inputWidth := msg.Width - 6
139 m.toInput.Width = inputWidth
140 m.subjectInput.Width = inputWidth
141 m.bodyInput.SetWidth(inputWidth)
142
143 case SetComposerCursorToStartMsg:
144 m.bodyInput.SetCursor(0)
145 return m, nil
146
147 case FileSelectedMsg:
148 m.attachmentPath = msg.Path
149 return m, nil
150
151 case tea.KeyMsg:
152 // Handle account picker mode
153 if m.showAccountPicker {
154 switch msg.String() {
155 case "up", "k":
156 if m.selectedAccountIdx > 0 {
157 m.selectedAccountIdx--
158 }
159 case "down", "j":
160 if m.selectedAccountIdx < len(m.accounts)-1 {
161 m.selectedAccountIdx++
162 }
163 case "enter":
164 m.showAccountPicker = false
165 case "esc":
166 m.showAccountPicker = false
167 }
168 return m, nil
169 }
170
171 if m.confirmingExit {
172 switch msg.String() {
173 case "y", "Y":
174 return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
175 case "n", "N", "esc":
176 m.confirmingExit = false
177 return m, nil
178 default:
179 return m, nil
180 }
181 }
182
183 switch msg.Type {
184 case tea.KeyCtrlC:
185 return m, tea.Quit
186 case tea.KeyEsc:
187 m.confirmingExit = true
188 return m, nil
189
190 case tea.KeyTab, tea.KeyShiftTab:
191 if msg.Type == tea.KeyShiftTab {
192 m.focusIndex--
193 } else {
194 m.focusIndex++
195 }
196
197 maxFocus := focusSend
198 if m.focusIndex > maxFocus {
199 m.focusIndex = focusFrom
200 } else if m.focusIndex < focusFrom {
201 m.focusIndex = maxFocus
202 }
203
204 m.toInput.Blur()
205 m.subjectInput.Blur()
206 m.bodyInput.Blur()
207
208 switch m.focusIndex {
209 case focusTo:
210 cmds = append(cmds, m.toInput.Focus())
211 case focusSubject:
212 cmds = append(cmds, m.subjectInput.Focus())
213 case focusBody:
214 cmds = append(cmds, m.bodyInput.Focus())
215 cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
216 }
217 return m, tea.Batch(cmds...)
218
219 case tea.KeyEnter:
220 switch m.focusIndex {
221 case focusFrom:
222 if len(m.accounts) > 1 {
223 m.showAccountPicker = true
224 }
225 return m, nil
226 case focusAttachment:
227 return m, func() tea.Msg { return GoToFilePickerMsg{} }
228 case focusSend:
229 acc := m.getSelectedAccount()
230 accountID := ""
231 if acc != nil {
232 accountID = acc.ID
233 }
234 return m, func() tea.Msg {
235 return SendEmailMsg{
236 To: m.toInput.Value(),
237 Subject: m.subjectInput.Value(),
238 Body: m.bodyInput.Value(),
239 AttachmentPath: m.attachmentPath,
240 AccountID: accountID,
241 }
242 }
243 }
244 }
245 }
246
247 switch m.focusIndex {
248 case focusTo:
249 m.toInput, cmd = m.toInput.Update(msg)
250 cmds = append(cmds, cmd)
251 case focusSubject:
252 m.subjectInput, cmd = m.subjectInput.Update(msg)
253 cmds = append(cmds, cmd)
254 case focusBody:
255 m.bodyInput, cmd = m.bodyInput.Update(msg)
256 cmds = append(cmds, cmd)
257 }
258
259 return m, tea.Batch(cmds...)
260}
261
262func (m *Composer) View() string {
263 var composerView strings.Builder
264 var button string
265
266 if m.focusIndex == focusSend {
267 button = focusedButton
268 } else {
269 button = blurredButton
270 }
271
272 // From field with account selector
273 fromAddr := m.getFromAddress()
274 var fromField string
275 if len(m.accounts) > 1 {
276 if m.focusIndex == focusFrom {
277 fromField = focusedStyle.Render(fmt.Sprintf("> From: %s (press Enter to change)", fromAddr))
278 } else {
279 fromField = blurredStyle.Render(fmt.Sprintf(" From: %s", fromAddr))
280 }
281 } else {
282 fromField = "From: " + emailRecipientStyle.Render(fromAddr)
283 }
284
285 var attachmentField string
286 attachmentText := "None (Press Enter to select)"
287 if m.attachmentPath != "" {
288 attachmentText = m.attachmentPath
289 }
290
291 if m.focusIndex == focusAttachment {
292 attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
293 } else {
294 attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachment: %s", attachmentText))
295 }
296
297 composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left,
298 "Compose New Email",
299 fromField,
300 m.toInput.View(),
301 m.subjectInput.View(),
302 m.bodyInput.View(),
303 attachmentStyle.Render(attachmentField),
304 button,
305 helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"),
306 ))
307
308 // Account picker overlay
309 if m.showAccountPicker {
310 var accountList strings.Builder
311 accountList.WriteString("Select Account:\n\n")
312 for i, acc := range m.accounts {
313 display := acc.Email
314 if acc.Name != "" {
315 display = fmt.Sprintf("%s (%s)", acc.Name, acc.Email)
316 }
317 if i == m.selectedAccountIdx {
318 accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
319 } else {
320 accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display)))
321 }
322 accountList.WriteString("\n")
323 }
324 accountList.WriteString("\n")
325 accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
326
327 dialog := DialogBoxStyle.Render(accountList.String())
328 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
329 }
330
331 if m.confirmingExit {
332 dialog := DialogBoxStyle.Render(
333 lipgloss.JoinVertical(lipgloss.Center,
334 "Discard draft?",
335 HelpStyle.Render("\n(y/n)"),
336 ),
337 )
338 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
339 }
340
341 return composerView.String()
342}
343
344// SetAccounts sets the available accounts for sending.
345func (m *Composer) SetAccounts(accounts []config.Account) {
346 m.accounts = accounts
347 if m.selectedAccountIdx >= len(accounts) {
348 m.selectedAccountIdx = 0
349 }
350}
351
352// SetSelectedAccount sets the selected account by ID.
353func (m *Composer) SetSelectedAccount(accountID string) {
354 for i, acc := range m.accounts {
355 if acc.ID == accountID {
356 m.selectedAccountIdx = i
357 return
358 }
359 }
360}
361
362// GetSelectedAccountID returns the ID of the currently selected account.
363func (m *Composer) GetSelectedAccountID() string {
364 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
365 return m.accounts[m.selectedAccountIdx].ID
366 }
367 return ""
368}