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