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 "github.com/google/uuid"
13)
14
15var (
16 suggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
17 selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
18 suggestionBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(0, 1)
19)
20
21// Styles for the UI
22var (
23 focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
24 blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
25 cursorStyle = focusedStyle.Copy()
26 noStyle = lipgloss.NewStyle()
27 helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
28 focusedButton = focusedStyle.Copy().Render("[ Send ]")
29 blurredButton = blurredStyle.Copy().Render("[ Send ]")
30 emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
31 attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240"))
32 fromSelectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
33)
34
35const (
36 focusFrom = iota
37 focusTo
38 focusSubject
39 focusBody
40 focusAttachment
41 focusSend
42)
43
44// Composer model holds the state of the email composition UI.
45type Composer struct {
46 focusIndex int
47 toInput textinput.Model
48 subjectInput textinput.Model
49 bodyInput textarea.Model
50 attachmentPath string
51 width int
52 height int
53 confirmingExit bool
54
55 // Multi-account support
56 accounts []config.Account
57 selectedAccountIdx int
58 showAccountPicker bool
59
60 // Contact suggestions
61 suggestions []config.Contact
62 selectedSuggestion int
63 showSuggestions bool
64 lastToValue string
65
66 // Draft persistence
67 draftID string
68
69 // Reply context
70 inReplyTo string
71 references []string
72
73 // Hidden quoted text (appended to body when sending, but not shown in editor)
74 quotedText string
75}
76
77// NewComposer initializes a new composer model.
78func NewComposer(from, to, subject, body string) *Composer {
79 m := &Composer{
80 draftID: uuid.New().String(),
81 }
82
83 m.toInput = textinput.New()
84 m.toInput.Cursor.Style = cursorStyle
85 m.toInput.Placeholder = "To"
86 m.toInput.SetValue(to)
87 m.toInput.Prompt = "> "
88 m.toInput.CharLimit = 256
89
90 m.subjectInput = textinput.New()
91 m.subjectInput.Cursor.Style = cursorStyle
92 m.subjectInput.Placeholder = "Subject"
93 m.subjectInput.SetValue(subject)
94 m.subjectInput.Prompt = "> "
95 m.subjectInput.CharLimit = 256
96
97 m.bodyInput = textarea.New()
98 m.bodyInput.Cursor.Style = cursorStyle
99 m.bodyInput.Placeholder = "Body (Markdown supported)..."
100 m.bodyInput.SetValue(body)
101 m.bodyInput.Prompt = "> "
102 m.bodyInput.SetHeight(10)
103 m.bodyInput.SetCursor(0)
104
105 // Start focus on To field (From is selectable but not a text input)
106 m.focusIndex = focusTo
107 m.toInput.Focus()
108
109 return m
110}
111
112// NewComposerWithAccounts initializes a composer with multiple account support.
113func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string) *Composer {
114 m := NewComposer("", to, subject, body)
115 m.accounts = accounts
116
117 // Find the selected account index
118 for i, acc := range accounts {
119 if acc.ID == selectedAccountID {
120 m.selectedAccountIdx = i
121 break
122 }
123 }
124
125 return m
126}
127
128// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt.
129func (m *Composer) ResetConfirmation() {
130 m.confirmingExit = false
131}
132
133func (m *Composer) Init() tea.Cmd {
134 return textinput.Blink
135}
136
137func (m *Composer) getFromAddress() string {
138 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
139 acc := m.accounts[m.selectedAccountIdx]
140 if acc.Name != "" {
141 return fmt.Sprintf("%s <%s>", acc.Name, acc.Email)
142 }
143 return acc.Email
144 }
145 return ""
146}
147
148func (m *Composer) getSelectedAccount() *config.Account {
149 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
150 return &m.accounts[m.selectedAccountIdx]
151 }
152 return nil
153}
154
155func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
156 var cmds []tea.Cmd
157 var cmd tea.Cmd
158
159 switch msg := msg.(type) {
160 case tea.WindowSizeMsg:
161 m.width = msg.Width
162 m.height = msg.Height
163 inputWidth := msg.Width - 6
164 m.toInput.Width = inputWidth
165 m.subjectInput.Width = inputWidth
166 m.bodyInput.SetWidth(inputWidth)
167
168 case SetComposerCursorToStartMsg:
169 m.bodyInput.SetCursor(0)
170 return m, nil
171
172 case FileSelectedMsg:
173 m.attachmentPath = msg.Path
174 return m, nil
175
176 case tea.KeyMsg:
177 // Handle contact suggestions mode
178 if m.showSuggestions && len(m.suggestions) > 0 {
179 switch msg.String() {
180 case "up", "ctrl+p":
181 if m.selectedSuggestion > 0 {
182 m.selectedSuggestion--
183 }
184 return m, nil
185 case "down", "ctrl+n":
186 if m.selectedSuggestion < len(m.suggestions)-1 {
187 m.selectedSuggestion++
188 }
189 return m, nil
190 case "tab", "enter":
191 // Select the suggestion
192 selected := m.suggestions[m.selectedSuggestion]
193 if selected.Name != "" && selected.Name != selected.Email {
194 m.toInput.SetValue(fmt.Sprintf("%s <%s>", selected.Name, selected.Email))
195 } else {
196 m.toInput.SetValue(selected.Email)
197 }
198 m.lastToValue = m.toInput.Value()
199 m.showSuggestions = false
200 m.suggestions = nil
201 return m, nil
202 case "esc":
203 m.showSuggestions = false
204 m.suggestions = nil
205 return m, nil
206 }
207 // For shift+tab, close suggestions and let it fall through to normal handling
208 if msg.Type == tea.KeyShiftTab {
209 m.showSuggestions = false
210 m.suggestions = nil
211 }
212 }
213
214 // Handle account picker mode
215 if m.showAccountPicker {
216 switch msg.String() {
217 case "up", "k":
218 if m.selectedAccountIdx > 0 {
219 m.selectedAccountIdx--
220 }
221 case "down", "j":
222 if m.selectedAccountIdx < len(m.accounts)-1 {
223 m.selectedAccountIdx++
224 }
225 case "enter":
226 m.showAccountPicker = false
227 case "esc":
228 m.showAccountPicker = false
229 }
230 return m, nil
231 }
232
233 if m.confirmingExit {
234 switch msg.String() {
235 case "y", "Y":
236 return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
237 case "n", "N", "esc":
238 m.confirmingExit = false
239 return m, nil
240 default:
241 return m, nil
242 }
243 }
244
245 switch msg.Type {
246 case tea.KeyCtrlC:
247 return m, tea.Quit
248 case tea.KeyEsc:
249 m.confirmingExit = true
250 return m, nil
251
252 case tea.KeyTab, tea.KeyShiftTab:
253 if msg.Type == tea.KeyShiftTab {
254 m.focusIndex--
255 } else {
256 m.focusIndex++
257 }
258
259 maxFocus := focusSend
260 minFocus := focusFrom
261 // Skip From field if only one account (nothing to switch)
262 if len(m.accounts) <= 1 {
263 minFocus = focusTo
264 }
265
266 if m.focusIndex > maxFocus {
267 m.focusIndex = minFocus
268 } else if m.focusIndex < minFocus {
269 m.focusIndex = maxFocus
270 }
271
272 m.toInput.Blur()
273 m.subjectInput.Blur()
274 m.bodyInput.Blur()
275
276 switch m.focusIndex {
277 case focusTo:
278 cmds = append(cmds, m.toInput.Focus())
279 case focusSubject:
280 cmds = append(cmds, m.subjectInput.Focus())
281 case focusBody:
282 cmds = append(cmds, m.bodyInput.Focus())
283 cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
284 }
285 return m, tea.Batch(cmds...)
286
287 case tea.KeyEnter:
288 switch m.focusIndex {
289 case focusFrom:
290 if len(m.accounts) > 1 {
291 m.showAccountPicker = true
292 }
293 return m, nil
294 case focusAttachment:
295 return m, func() tea.Msg { return GoToFilePickerMsg{} }
296 case focusSend:
297 acc := m.getSelectedAccount()
298 accountID := ""
299 if acc != nil {
300 accountID = acc.ID
301 }
302 return m, func() tea.Msg {
303 return SendEmailMsg{
304 To: m.toInput.Value(),
305 Subject: m.subjectInput.Value(),
306 Body: m.bodyInput.Value(),
307 AttachmentPath: m.attachmentPath,
308 AccountID: accountID,
309 QuotedText: m.quotedText,
310 InReplyTo: m.inReplyTo,
311 References: m.references,
312 }
313 }
314 }
315 }
316 }
317
318 switch m.focusIndex {
319 case focusTo:
320 m.toInput, cmd = m.toInput.Update(msg)
321 cmds = append(cmds, cmd)
322
323 // Check if To field value changed and update suggestions
324 currentValue := m.toInput.Value()
325 if currentValue != m.lastToValue {
326 m.lastToValue = currentValue
327 if len(currentValue) >= 2 {
328 m.suggestions = config.SearchContacts(currentValue)
329 m.showSuggestions = len(m.suggestions) > 0
330 m.selectedSuggestion = 0
331 } else {
332 m.showSuggestions = false
333 m.suggestions = nil
334 }
335 }
336 case focusSubject:
337 m.subjectInput, cmd = m.subjectInput.Update(msg)
338 cmds = append(cmds, cmd)
339 case focusBody:
340 m.bodyInput, cmd = m.bodyInput.Update(msg)
341 cmds = append(cmds, cmd)
342 }
343
344 return m, tea.Batch(cmds...)
345}
346
347func (m *Composer) View() string {
348 var composerView strings.Builder
349 var button string
350
351 if m.focusIndex == focusSend {
352 button = focusedButton
353 } else {
354 button = blurredButton
355 }
356
357 // From field with account selector
358 fromAddr := m.getFromAddress()
359 var fromField string
360 if len(m.accounts) > 1 {
361 if m.focusIndex == focusFrom {
362 fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
363 } else {
364 fromField = blurredStyle.Render(fmt.Sprintf(" From: %s [switchable]", fromAddr))
365 }
366 } else if fromAddr != "" {
367 fromField = " From: " + emailRecipientStyle.Render(fromAddr)
368 } else {
369 fromField = blurredStyle.Render(" From: (no account configured)")
370 }
371
372 var attachmentField string
373 attachmentText := "None (Press Enter to select)"
374 if m.attachmentPath != "" {
375 attachmentText = m.attachmentPath
376 }
377
378 if m.focusIndex == focusAttachment {
379 attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
380 } else {
381 attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachment: %s", attachmentText))
382 }
383
384 // Build To field with suggestions
385 toFieldView := m.toInput.View()
386 if m.showSuggestions && len(m.suggestions) > 0 {
387 var suggestionsBuilder strings.Builder
388 for i, s := range m.suggestions {
389 display := s.Email
390 if s.Name != "" && s.Name != s.Email {
391 display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
392 }
393 if i == m.selectedSuggestion {
394 suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
395 } else {
396 suggestionsBuilder.WriteString(suggestionStyle.Render(" "+display) + "\n")
397 }
398 }
399 toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
400 }
401
402 composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left,
403 "Compose New Email",
404 fromField,
405 toFieldView,
406 m.subjectInput.View(),
407 m.bodyInput.View(),
408 attachmentStyle.Render(attachmentField),
409 button,
410 helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"),
411 ))
412
413 // Account picker overlay
414 if m.showAccountPicker {
415 var accountList strings.Builder
416 accountList.WriteString("Select Account:\n\n")
417 for i, acc := range m.accounts {
418 display := acc.Email
419 if acc.Name != "" {
420 display = fmt.Sprintf("%s (%s)", acc.Name, acc.Email)
421 }
422 if i == m.selectedAccountIdx {
423 accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
424 } else {
425 accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display)))
426 }
427 accountList.WriteString("\n")
428 }
429 accountList.WriteString("\n")
430 accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
431
432 dialog := DialogBoxStyle.Render(accountList.String())
433 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
434 }
435
436 if m.confirmingExit {
437 dialog := DialogBoxStyle.Render(
438 lipgloss.JoinVertical(lipgloss.Center,
439 "Discard draft?",
440 HelpStyle.Render("\n(y/n)"),
441 ),
442 )
443 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
444 }
445
446 return composerView.String()
447}
448
449// SetAccounts sets the available accounts for sending.
450func (m *Composer) SetAccounts(accounts []config.Account) {
451 m.accounts = accounts
452 if m.selectedAccountIdx >= len(accounts) {
453 m.selectedAccountIdx = 0
454 }
455}
456
457// SetSelectedAccount sets the selected account by ID.
458func (m *Composer) SetSelectedAccount(accountID string) {
459 for i, acc := range m.accounts {
460 if acc.ID == accountID {
461 m.selectedAccountIdx = i
462 return
463 }
464 }
465}
466
467// GetSelectedAccountID returns the ID of the currently selected account.
468func (m *Composer) GetSelectedAccountID() string {
469 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
470 return m.accounts[m.selectedAccountIdx].ID
471 }
472 return ""
473}
474
475// GetDraftID returns the draft ID for this composer.
476func (m *Composer) GetDraftID() string {
477 return m.draftID
478}
479
480// SetDraftID sets the draft ID (for loading existing drafts).
481func (m *Composer) SetDraftID(id string) {
482 m.draftID = id
483}
484
485// GetTo returns the current To field value.
486func (m *Composer) GetTo() string {
487 return m.toInput.Value()
488}
489
490// GetSubject returns the current Subject field value.
491func (m *Composer) GetSubject() string {
492 return m.subjectInput.Value()
493}
494
495// GetBody returns the current Body field value.
496func (m *Composer) GetBody() string {
497 return m.bodyInput.Value()
498}
499
500// GetAttachmentPath returns the current attachment path.
501func (m *Composer) GetAttachmentPath() string {
502 return m.attachmentPath
503}
504
505// SetReplyContext sets the reply context for the draft.
506func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
507 m.inReplyTo = inReplyTo
508 m.references = references
509}
510
511// SetQuotedText sets the hidden quoted text that will be appended when sending.
512func (m *Composer) SetQuotedText(text string) {
513 m.quotedText = text
514}
515
516// GetQuotedText returns the hidden quoted text.
517func (m *Composer) GetQuotedText() string {
518 return m.quotedText
519}
520
521// GetInReplyTo returns the In-Reply-To header value.
522func (m *Composer) GetInReplyTo() string {
523 return m.inReplyTo
524}
525
526// GetReferences returns the References header values.
527func (m *Composer) GetReferences() []string {
528 return m.references
529}
530
531// ToDraft converts the composer state to a Draft for saving.
532func (m *Composer) ToDraft() config.Draft {
533 return config.Draft{
534 ID: m.draftID,
535 To: m.toInput.Value(),
536 Subject: m.subjectInput.Value(),
537 Body: m.bodyInput.Value(),
538 AttachmentPath: m.attachmentPath,
539 AccountID: m.GetSelectedAccountID(),
540 InReplyTo: m.inReplyTo,
541 References: m.references,
542 QuotedText: m.quotedText,
543 }
544}
545
546// NewComposerFromDraft creates a composer from an existing draft.
547func NewComposerFromDraft(draft config.Draft, accounts []config.Account) *Composer {
548 m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body)
549 m.draftID = draft.ID
550 m.attachmentPath = draft.AttachmentPath
551 m.inReplyTo = draft.InReplyTo
552 m.references = draft.References
553 m.quotedText = draft.QuotedText
554 return m
555}