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 if selected.Name != "" && selected.Name != selected.Email {
215 m.toInput.SetValue(fmt.Sprintf("%s <%s>", selected.Name, selected.Email))
216 } else {
217 m.toInput.SetValue(selected.Email)
218 }
219 m.lastToValue = m.toInput.Value()
220 m.showSuggestions = false
221 m.suggestions = nil
222 return m, nil
223 case "esc":
224 m.showSuggestions = false
225 m.suggestions = nil
226 return m, nil
227 }
228 // For shift+tab, close suggestions and let it fall through to normal handling
229 if msg.String() == "shift+tab" {
230 m.showSuggestions = false
231 m.suggestions = nil
232 }
233 }
234
235 // Handle account picker mode
236 if m.showAccountPicker {
237 switch msg.String() {
238 case "up", "k":
239 if m.selectedAccountIdx > 0 {
240 m.selectedAccountIdx--
241 }
242 case "down", "j":
243 if m.selectedAccountIdx < len(m.accounts)-1 {
244 m.selectedAccountIdx++
245 }
246 case "enter":
247 m.showAccountPicker = false
248 case "esc":
249 m.showAccountPicker = false
250 }
251 return m, nil
252 }
253
254 if m.confirmingExit {
255 switch msg.String() {
256 case "y", "Y":
257 return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
258 case "n", "N", "esc":
259 m.confirmingExit = false
260 return m, nil
261 default:
262 return m, nil
263 }
264 }
265
266 switch msg.String() {
267 case "ctrl+c":
268 return m, tea.Quit
269 case "esc":
270 m.confirmingExit = true
271 return m, nil
272
273 case "tab", "shift+tab":
274 if msg.String() == "shift+tab" {
275 m.focusIndex--
276 } else {
277 m.focusIndex++
278 }
279
280 maxFocus := focusSend
281 minFocus := focusFrom
282 // Skip From field if only one account (nothing to switch)
283 if len(m.accounts) <= 1 {
284 minFocus = focusTo
285 }
286
287 if m.focusIndex > maxFocus {
288 m.focusIndex = minFocus
289 } else if m.focusIndex < minFocus {
290 m.focusIndex = maxFocus
291 }
292
293 m.toInput.Blur()
294 m.ccInput.Blur()
295 m.bccInput.Blur()
296 m.subjectInput.Blur()
297 m.bodyInput.Blur()
298 m.signatureInput.Blur()
299
300 switch m.focusIndex {
301 case focusTo:
302 cmds = append(cmds, m.toInput.Focus())
303 case focusCc:
304 cmds = append(cmds, m.ccInput.Focus())
305 case focusBcc:
306 cmds = append(cmds, m.bccInput.Focus())
307 case focusSubject:
308 cmds = append(cmds, m.subjectInput.Focus())
309 case focusBody:
310 cmds = append(cmds, m.bodyInput.Focus())
311 case focusSignature:
312 cmds = append(cmds, m.signatureInput.Focus())
313 }
314 return m, tea.Batch(cmds...)
315
316 case "enter":
317 switch m.focusIndex {
318 case focusFrom:
319 if len(m.accounts) > 1 {
320 m.showAccountPicker = true
321 }
322 return m, nil
323 case focusAttachment:
324 return m, func() tea.Msg { return GoToFilePickerMsg{} }
325 case focusSend:
326 acc := m.getSelectedAccount()
327 accountID := ""
328 if acc != nil {
329 accountID = acc.ID
330 }
331 return m, func() tea.Msg {
332 return SendEmailMsg{
333 To: m.toInput.Value(),
334 Cc: m.ccInput.Value(),
335 Bcc: m.bccInput.Value(),
336 Subject: m.subjectInput.Value(),
337 Body: m.bodyInput.Value(),
338 AttachmentPath: m.attachmentPath,
339 AccountID: accountID,
340 QuotedText: m.quotedText,
341 InReplyTo: m.inReplyTo,
342 References: m.references,
343 Signature: m.signatureInput.Value(),
344 }
345 }
346 }
347 }
348 }
349
350 switch m.focusIndex {
351 case focusTo:
352 m.toInput, cmd = m.toInput.Update(msg)
353 cmds = append(cmds, cmd)
354
355 // Check if To field value changed and update suggestions
356 currentValue := m.toInput.Value()
357 if currentValue != m.lastToValue {
358 m.lastToValue = currentValue
359 if len(currentValue) >= 2 {
360 m.suggestions = config.SearchContacts(currentValue)
361 m.showSuggestions = len(m.suggestions) > 0
362 m.selectedSuggestion = 0
363 } else {
364 m.showSuggestions = false
365 m.suggestions = nil
366 }
367 }
368 case focusCc:
369 m.ccInput, cmd = m.ccInput.Update(msg)
370 cmds = append(cmds, cmd)
371 case focusBcc:
372 m.bccInput, cmd = m.bccInput.Update(msg)
373 cmds = append(cmds, cmd)
374 case focusSubject:
375 m.subjectInput, cmd = m.subjectInput.Update(msg)
376 cmds = append(cmds, cmd)
377 case focusBody:
378 m.bodyInput, cmd = m.bodyInput.Update(msg)
379 cmds = append(cmds, cmd)
380 case focusSignature:
381 m.signatureInput, cmd = m.signatureInput.Update(msg)
382 cmds = append(cmds, cmd)
383 }
384
385 return m, tea.Batch(cmds...)
386}
387
388func (m *Composer) View() tea.View {
389 var composerView strings.Builder
390 var button string
391
392 if m.focusIndex == focusSend {
393 button = focusedButton
394 } else {
395 button = blurredButton
396 }
397
398 // From field with account selector
399 fromAddr := m.getFromAddress()
400 var fromField string
401 if len(m.accounts) > 1 {
402 if m.focusIndex == focusFrom {
403 fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
404 } else {
405 fromField = blurredStyle.Render(fmt.Sprintf(" From: %s [switchable]", fromAddr))
406 }
407 } else if fromAddr != "" {
408 fromField = " From: " + emailRecipientStyle.Render(fromAddr)
409 } else {
410 fromField = blurredStyle.Render(" From: (no account configured)")
411 }
412
413 var attachmentField string
414 attachmentText := "None (Press Enter to select)"
415 if m.attachmentPath != "" {
416 attachmentText = m.attachmentPath
417 }
418
419 if m.focusIndex == focusAttachment {
420 attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
421 } else {
422 attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachment: %s", attachmentText))
423 }
424
425 // Build To field with suggestions
426 toFieldView := m.toInput.View()
427 if m.showSuggestions && len(m.suggestions) > 0 {
428 var suggestionsBuilder strings.Builder
429 for i, s := range m.suggestions {
430 display := s.Email
431 if s.Name != "" && s.Name != s.Email {
432 display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
433 }
434 if i == m.selectedSuggestion {
435 suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
436 } else {
437 suggestionsBuilder.WriteString(suggestionStyle.Render(" "+display) + "\n")
438 }
439 }
440 toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
441 }
442
443 // Signature field label
444 var signatureLabel string
445 if m.focusIndex == focusSignature {
446 signatureLabel = focusedStyle.Render("Signature:")
447 } else {
448 signatureLabel = blurredStyle.Render("Signature:")
449 }
450
451 tip := ""
452 switch m.focusIndex {
453 case focusFrom:
454 tip = "Select the account to send from."
455 case focusTo:
456 tip = "Enter recipient email addresses."
457 case focusCc:
458 tip = "Carbon copy recipients."
459 case focusBcc:
460 tip = "Blind carbon copy recipients."
461 case focusSubject:
462 tip = "The subject line of your email."
463 case focusBody:
464 tip = "The main content of your email. Markdown and HTML are supported."
465 case focusSignature:
466 tip = "Your email signature. This will be appended to the end of the email."
467 case focusAttachment:
468 tip = "Press Enter to select a file to attach to this email."
469 case focusSend:
470 tip = "Press Enter to send the email."
471 }
472
473 composerViewElements := []string{
474 "Compose New Email",
475 fromField,
476 toFieldView,
477 m.ccInput.View(),
478 m.bccInput.View(),
479 m.subjectInput.View(),
480 m.bodyInput.View(),
481 signatureLabel,
482 m.signatureInput.View(),
483 attachmentStyle.Render(attachmentField),
484 button,
485 "",
486 }
487
488 if !m.hideTips && tip != "" {
489 composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
490 }
491
492 mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
493 helpView := helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit")
494
495 if m.height > 0 {
496 currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
497 gap := m.height - currentHeight
498 if gap >= 0 {
499 mainContent += strings.Repeat("\n", gap+1)
500 } else {
501 mainContent += "\n"
502 }
503 } else {
504 mainContent += "\n\n"
505 }
506
507 composerView.WriteString(mainContent)
508 composerView.WriteString(helpView)
509
510 // Account picker overlay
511 if m.showAccountPicker {
512 var accountList strings.Builder
513 accountList.WriteString("Select Account:\n\n")
514 for i, acc := range m.accounts {
515 display := acc.FetchEmail
516 if acc.Name != "" {
517 display = fmt.Sprintf("%s (%s)", acc.Name, acc.FetchEmail)
518 }
519 if i == m.selectedAccountIdx {
520 accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
521 } else {
522 accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display)))
523 }
524 accountList.WriteString("\n")
525 }
526 accountList.WriteString("\n")
527 accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
528
529 dialog := DialogBoxStyle.Render(accountList.String())
530 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
531 }
532
533 if m.confirmingExit {
534 dialog := DialogBoxStyle.Render(
535 lipgloss.JoinVertical(lipgloss.Center,
536 "Discard draft?",
537 HelpStyle.Render("\n(y/n)"),
538 ),
539 )
540 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
541 }
542
543 return tea.NewView(composerView.String())
544}
545
546// SetAccounts sets the available accounts for sending.
547func (m *Composer) SetAccounts(accounts []config.Account) {
548 m.accounts = accounts
549 if m.selectedAccountIdx >= len(accounts) {
550 m.selectedAccountIdx = 0
551 }
552}
553
554// SetSelectedAccount sets the selected account by ID.
555func (m *Composer) SetSelectedAccount(accountID string) {
556 for i, acc := range m.accounts {
557 if acc.ID == accountID {
558 m.selectedAccountIdx = i
559 return
560 }
561 }
562}
563
564// GetSelectedAccountID returns the ID of the currently selected account.
565func (m *Composer) GetSelectedAccountID() string {
566 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
567 return m.accounts[m.selectedAccountIdx].ID
568 }
569 return ""
570}
571
572// GetDraftID returns the draft ID for this composer.
573func (m *Composer) GetDraftID() string {
574 return m.draftID
575}
576
577// SetDraftID sets the draft ID (for loading existing drafts).
578func (m *Composer) SetDraftID(id string) {
579 m.draftID = id
580}
581
582// GetTo returns the current To field value.
583func (m *Composer) GetTo() string {
584 return m.toInput.Value()
585}
586
587// GetSubject returns the current Subject field value.
588func (m *Composer) GetSubject() string {
589 return m.subjectInput.Value()
590}
591
592// GetBody returns the current Body field value.
593func (m *Composer) GetBody() string {
594 return m.bodyInput.Value()
595}
596
597// GetAttachmentPath returns the current attachment path.
598func (m *Composer) GetAttachmentPath() string {
599 return m.attachmentPath
600}
601
602// GetSignature returns the current signature value.
603func (m *Composer) GetSignature() string {
604 return m.signatureInput.Value()
605}
606
607// SetReplyContext sets the reply context for the draft.
608func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
609 m.inReplyTo = inReplyTo
610 m.references = references
611}
612
613// SetQuotedText sets the hidden quoted text that will be appended when sending.
614func (m *Composer) SetQuotedText(text string) {
615 m.quotedText = text
616}
617
618// GetQuotedText returns the hidden quoted text.
619func (m *Composer) GetQuotedText() string {
620 return m.quotedText
621}
622
623// GetInReplyTo returns the In-Reply-To header value.
624func (m *Composer) GetInReplyTo() string {
625 return m.inReplyTo
626}
627
628// GetReferences returns the References header values.
629func (m *Composer) GetReferences() []string {
630 return m.references
631}
632
633// ToDraft converts the composer state to a Draft for saving.
634func (m *Composer) ToDraft() config.Draft {
635 return config.Draft{
636 ID: m.draftID,
637 To: m.toInput.Value(),
638 Cc: m.ccInput.Value(),
639 Bcc: m.bccInput.Value(),
640 Subject: m.subjectInput.Value(),
641 Body: m.bodyInput.Value(),
642 AttachmentPath: m.attachmentPath,
643 AccountID: m.GetSelectedAccountID(),
644 InReplyTo: m.inReplyTo,
645 References: m.references,
646 QuotedText: m.quotedText,
647 }
648}
649
650// NewComposerFromDraft creates a composer from an existing draft.
651func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
652 m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
653 m.ccInput.SetValue(draft.Cc)
654 m.bccInput.SetValue(draft.Bcc)
655 m.draftID = draft.ID
656 m.attachmentPath = draft.AttachmentPath
657 m.inReplyTo = draft.InReplyTo
658 m.references = draft.References
659 m.quotedText = draft.QuotedText
660 return m
661}