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