1package tui
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7
8 "charm.land/bubbles/v2/textarea"
9 "charm.land/bubbles/v2/textinput"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/floatpane/matcha/config"
13 "github.com/google/uuid"
14)
15
16var (
17 suggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
18 selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
19 suggestionBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
20)
21
22// Styles for the UI
23var (
24 focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
25 blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
26 noStyle = lipgloss.NewStyle()
27 helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
28 emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
29 attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
30 fromSelectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
31 smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
32)
33
34const (
35 focusFrom = iota
36 focusTo
37 focusCc
38 focusBcc
39 focusSubject
40 focusBody
41 focusSignature
42 focusAttachment
43 focusEncryptSMIME
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 attachmentPaths []string
57 encryptSMIME bool
58 width int
59 height int
60 confirmingExit bool
61 hideTips bool
62
63 // Multi-account support
64 accounts []config.Account
65 selectedAccountIdx int
66 showAccountPicker bool
67
68 // Contact suggestions
69 suggestions []config.Contact
70 selectedSuggestion int
71 showSuggestions bool
72 lastToValue string
73
74 // Draft persistence
75 draftID string
76
77 // Reply context
78 inReplyTo string
79 references []string
80
81 // Hidden quoted text (appended to body when sending, but not shown in editor)
82 quotedText string
83
84 // Plugin status text shown in the help bar
85 pluginStatus string
86 pluginKeyBindings []PluginKeyBinding
87
88 // Plugin prompt overlay
89 showPluginPrompt bool
90 pluginPromptInput textinput.Model
91 pluginPromptPlaceholder string
92}
93
94// NewComposer initializes a new composer model.
95func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
96 m := &Composer{
97 draftID: uuid.New().String(),
98 hideTips: hideTips,
99 }
100
101 tiStyles := ThemedTextInputStyles()
102 taStyles := ThemedTextAreaStyles()
103
104 m.toInput = textinput.New()
105 m.toInput.Placeholder = t("composer.to_placeholder")
106 m.toInput.SetValue(to)
107 m.toInput.Prompt = "> "
108 m.toInput.CharLimit = 256
109 m.toInput.SetStyles(tiStyles)
110
111 m.ccInput = textinput.New()
112 m.ccInput.Placeholder = t("composer.cc_placeholder")
113 m.ccInput.Prompt = "> "
114 m.ccInput.CharLimit = 256
115 m.ccInput.SetStyles(tiStyles)
116
117 m.bccInput = textinput.New()
118 m.bccInput.Placeholder = t("composer.bcc_placeholder")
119 m.bccInput.Prompt = "> "
120 m.bccInput.CharLimit = 256
121 m.bccInput.SetStyles(tiStyles)
122
123 m.subjectInput = textinput.New()
124 m.subjectInput.Placeholder = t("composer.subject_placeholder")
125 m.subjectInput.SetValue(subject)
126 m.subjectInput.Prompt = "> "
127 m.subjectInput.CharLimit = 256
128 m.subjectInput.SetStyles(tiStyles)
129
130 m.bodyInput = textarea.New()
131 m.bodyInput.Placeholder = t("composer.body_placeholder")
132 m.bodyInput.SetValue(body)
133 m.bodyInput.Prompt = "> "
134 m.bodyInput.SetHeight(10)
135 m.bodyInput.SetStyles(taStyles)
136
137 m.signatureInput = textarea.New()
138 m.signatureInput.Placeholder = t("composer.signature_placeholder")
139 m.signatureInput.Prompt = "> "
140 m.signatureInput.SetHeight(3)
141 m.signatureInput.SetStyles(taStyles)
142 m.updateSignature()
143
144 // Start focus on To field (From is selectable but not a text input)
145 m.focusIndex = focusTo
146 m.toInput.Focus()
147
148 return m
149}
150
151// updateSignature updates the signature input based on the current selected account.
152func (m *Composer) updateSignature() {
153 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
154 if sig, err := config.LoadSignatureForAccount(&m.accounts[m.selectedAccountIdx]); err == nil && sig != "" {
155 m.signatureInput.SetValue(sig)
156 return
157 }
158 }
159
160 if sig, err := config.LoadSignature(); err == nil && sig != "" {
161 m.signatureInput.SetValue(sig)
162 } else {
163 m.signatureInput.SetValue("")
164 }
165}
166
167// NewComposerWithAccounts initializes a composer with multiple account support.
168func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
169 m := NewComposer("", to, subject, body, hideTips)
170 m.accounts = accounts
171
172 // Find the selected account index
173 for i, acc := range accounts {
174 if acc.ID == selectedAccountID {
175 m.selectedAccountIdx = i
176 break
177 }
178 }
179 m.updateSignature()
180
181 return m
182}
183
184// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
185func (m *Composer) ResetConfirmation() {
186 m.confirmingExit = false
187}
188
189func (m *Composer) Init() tea.Cmd {
190 return textinput.Blink
191}
192
193func (m *Composer) getFromAddress() string {
194 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
195 return m.accounts[m.selectedAccountIdx].FormatFromHeader()
196 }
197 return ""
198}
199
200func (m *Composer) getSelectedAccount() *config.Account {
201 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
202 return &m.accounts[m.selectedAccountIdx]
203 }
204 return nil
205}
206
207func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208 var cmds []tea.Cmd
209 var cmd tea.Cmd
210
211 switch msg := msg.(type) {
212 case tea.WindowSizeMsg:
213 m.width = msg.Width
214 m.height = msg.Height
215 inputWidth := msg.Width - 6
216 m.toInput.SetWidth(inputWidth)
217 m.ccInput.SetWidth(inputWidth)
218 m.bccInput.SetWidth(inputWidth)
219 m.subjectInput.SetWidth(inputWidth)
220 m.bodyInput.SetWidth(inputWidth)
221 m.signatureInput.SetWidth(inputWidth)
222 if msg.Height > 0 {
223 // Fixed rows: title, from, to, cc, bcc, subject, sig label,
224 // attachment, smime, button, blank, tip, help = 13
225 const fixedRows = 13
226 available := msg.Height - fixedRows
227 if available < 6 {
228 available = 6
229 }
230 bodyHeight := (available * 55) / 100
231 sigHeight := (available * 15) / 100
232 if bodyHeight < 3 {
233 bodyHeight = 3
234 }
235 if sigHeight < 2 {
236 sigHeight = 2
237 }
238 m.bodyInput.SetHeight(bodyHeight)
239 m.signatureInput.SetHeight(sigHeight)
240 }
241
242 case FileSelectedMsg:
243 // Avoid duplicates
244 for _, p := range m.attachmentPaths {
245 if p == msg.Path {
246 return m, nil
247 }
248 }
249 m.attachmentPaths = append(m.attachmentPaths, msg.Path)
250 return m, nil
251
252 case tea.KeyPressMsg:
253 // Handle contact suggestions mode
254 if m.showSuggestions && len(m.suggestions) > 0 {
255 switch msg.String() {
256 case "up", "ctrl+p":
257 if m.selectedSuggestion > 0 {
258 m.selectedSuggestion--
259 }
260 return m, nil
261 case "down", "ctrl+n":
262 if m.selectedSuggestion < len(m.suggestions)-1 {
263 m.selectedSuggestion++
264 }
265 return m, nil
266 case "tab", "enter":
267 // Select the suggestion
268 selected := m.suggestions[m.selectedSuggestion]
269
270 var newEmail string
271 if strings.Contains(selected.Email, ",") {
272 // It's a mailing list: insert just the addresses to maintain valid email formatting
273 newEmail = selected.Email
274 } else if selected.Name != "" && selected.Name != selected.Email {
275 newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
276 } else {
277 newEmail = selected.Email
278 }
279
280 parts := strings.Split(m.toInput.Value(), ",")
281 if len(parts) > 0 {
282 if len(parts) == 1 {
283 parts[0] = newEmail
284 } else {
285 parts[len(parts)-1] = " " + newEmail
286 }
287 } else {
288 parts = []string{newEmail}
289 }
290
291 finalValue := strings.Join(parts, ",")
292 if !strings.HasSuffix(finalValue, ", ") {
293 finalValue += ", "
294 }
295
296 m.toInput.SetValue(finalValue)
297 m.toInput.SetCursor(len(finalValue))
298 m.lastToValue = m.toInput.Value()
299 m.showSuggestions = false
300 m.suggestions = nil
301 return m, nil
302 case "esc":
303 m.showSuggestions = false
304 m.suggestions = nil
305 return m, nil
306 }
307 // For shift+tab, close suggestions and let it fall through to normal handling
308 if msg.String() == "shift+tab" {
309 m.showSuggestions = false
310 m.suggestions = nil
311 }
312 }
313
314 // Handle plugin prompt overlay
315 if m.showPluginPrompt {
316 switch msg.String() {
317 case "enter":
318 value := m.pluginPromptInput.Value()
319 m.showPluginPrompt = false
320 return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
321 case "esc":
322 m.showPluginPrompt = false
323 return m, func() tea.Msg { return PluginPromptCancelMsg{} }
324 default:
325 m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
326 return m, cmd
327 }
328 }
329
330 // Handle account picker mode
331 if m.showAccountPicker {
332 switch msg.String() {
333 case "up", "k":
334 if m.selectedAccountIdx > 0 {
335 m.selectedAccountIdx--
336 m.updateSignature()
337 }
338 case "down", "j":
339 if m.selectedAccountIdx < len(m.accounts)-1 {
340 m.selectedAccountIdx++
341 m.updateSignature()
342 }
343 case "enter":
344 m.showAccountPicker = false
345 case "esc":
346 m.showAccountPicker = false
347 }
348 return m, nil
349 }
350
351 if m.confirmingExit {
352 switch msg.String() {
353 case "y", "Y":
354 return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
355 case "n", "N", "esc":
356 m.confirmingExit = false
357 return m, nil
358 default:
359 return m, nil
360 }
361 }
362
363 switch msg.String() {
364 case "ctrl+c":
365 return m, tea.Quit
366 case "ctrl+e":
367 return m, func() tea.Msg { return OpenEditorMsg{} }
368 case "esc":
369 m.confirmingExit = true
370 return m, nil
371
372 case "tab", "shift+tab":
373 if msg.String() == "shift+tab" {
374 m.focusIndex--
375 } else {
376 m.focusIndex++
377 }
378
379 maxFocus := focusSend
380 minFocus := focusFrom
381 // Skip From field if only one account (nothing to switch)
382 if len(m.accounts) <= 1 {
383 minFocus = focusTo
384 }
385
386 if m.focusIndex > maxFocus {
387 m.focusIndex = minFocus
388 } else if m.focusIndex < minFocus {
389 m.focusIndex = maxFocus
390 }
391
392 m.toInput.Blur()
393 m.ccInput.Blur()
394 m.bccInput.Blur()
395 m.subjectInput.Blur()
396 m.bodyInput.Blur()
397 m.signatureInput.Blur()
398
399 switch m.focusIndex {
400 case focusTo:
401 cmds = append(cmds, m.toInput.Focus())
402 case focusCc:
403 cmds = append(cmds, m.ccInput.Focus())
404 case focusBcc:
405 cmds = append(cmds, m.bccInput.Focus())
406 case focusSubject:
407 cmds = append(cmds, m.subjectInput.Focus())
408 case focusBody:
409 cmds = append(cmds, m.bodyInput.Focus())
410 case focusSignature:
411 cmds = append(cmds, m.signatureInput.Focus())
412 }
413 return m, tea.Batch(cmds...)
414
415 case "backspace", "delete", "d":
416 if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
417 m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
418 return m, nil
419 }
420
421 case "enter", " ":
422 switch m.focusIndex {
423 case focusFrom:
424 if len(m.accounts) > 1 && msg.String() == "enter" {
425 m.showAccountPicker = true
426 }
427 return m, nil
428 case focusAttachment:
429 if msg.String() == "enter" {
430 return m, func() tea.Msg { return GoToFilePickerMsg{} }
431 }
432 case focusEncryptSMIME:
433 if msg.String() == "enter" || msg.String() == " " {
434 m.encryptSMIME = !m.encryptSMIME
435 }
436 return m, nil
437
438 case focusSend:
439 if msg.String() == "enter" {
440 acc := m.getSelectedAccount()
441 accountID := ""
442 if acc != nil {
443 accountID = acc.ID
444 }
445 return m, func() tea.Msg {
446 return SendEmailMsg{
447 To: m.toInput.Value(),
448 Cc: m.ccInput.Value(),
449 Bcc: m.bccInput.Value(),
450 Subject: m.subjectInput.Value(),
451 Body: m.bodyInput.Value(),
452 AttachmentPaths: m.attachmentPaths,
453 AccountID: accountID,
454 QuotedText: m.quotedText,
455 InReplyTo: m.inReplyTo,
456 References: m.references,
457 Signature: m.signatureInput.Value(),
458 SignSMIME: acc != nil && acc.SMIMESignByDefault,
459 EncryptSMIME: m.encryptSMIME,
460 SignPGP: acc != nil && acc.PGPSignByDefault,
461 }
462 }
463 }
464 }
465 }
466 }
467
468 switch m.focusIndex {
469 case focusTo:
470 m.toInput, cmd = m.toInput.Update(msg)
471 cmds = append(cmds, cmd)
472
473 // Check if To field value changed and update suggestions
474 currentValue := m.toInput.Value()
475 if currentValue != m.lastToValue {
476 m.lastToValue = currentValue
477
478 // Extract the last comma-separated part for searching
479 parts := strings.Split(currentValue, ",")
480 lastPart := strings.TrimSpace(parts[len(parts)-1])
481
482 if len(lastPart) >= 2 {
483 m.suggestions = config.SearchContacts(lastPart)
484 m.showSuggestions = len(m.suggestions) > 0
485 m.selectedSuggestion = 0
486 } else {
487 m.showSuggestions = false
488 m.suggestions = nil
489 }
490 }
491 case focusCc:
492 m.ccInput, cmd = m.ccInput.Update(msg)
493 cmds = append(cmds, cmd)
494 case focusBcc:
495 m.bccInput, cmd = m.bccInput.Update(msg)
496 cmds = append(cmds, cmd)
497 case focusSubject:
498 m.subjectInput, cmd = m.subjectInput.Update(msg)
499 cmds = append(cmds, cmd)
500 case focusBody:
501 m.bodyInput, cmd = m.bodyInput.Update(msg)
502 cmds = append(cmds, cmd)
503 case focusSignature:
504 m.signatureInput, cmd = m.signatureInput.Update(msg)
505 cmds = append(cmds, cmd)
506 }
507
508 return m, tea.Batch(cmds...)
509}
510
511func (m *Composer) View() tea.View {
512 var composerView strings.Builder
513 var button string
514
515 if m.focusIndex == focusSend {
516 button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
517 } else {
518 button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]")
519 }
520
521 // From field with account selector
522 fromAddr := m.getFromAddress()
523 var fromField string
524 if len(m.accounts) > 1 {
525 if m.focusIndex == focusFrom {
526 fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
527 } else {
528 fromField = blurredStyle.Render(fmt.Sprintf(" %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
529 }
530 } else if fromAddr != "" {
531 fromField = " " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
532 } else {
533 fromField = blurredStyle.Render(fmt.Sprintf(" %s (%s)", t("composer.from"), t("composer.no_account")))
534 }
535
536 var attachmentField string
537 if len(m.attachmentPaths) == 0 {
538 attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
539 if m.focusIndex == focusAttachment {
540 attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
541 } else {
542 attachmentField = blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.attachments"), attachmentText))
543 }
544 } else {
545 var names []string
546 for _, p := range m.attachmentPaths {
547 names = append(names, filepath.Base(p))
548 }
549 attachmentText := strings.Join(names, ", ")
550 if m.focusIndex == focusAttachment {
551 attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
552 } else {
553 attachmentField = blurredStyle.Render(fmt.Sprintf(" %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
554 }
555 }
556
557 encToggle := "[ ]"
558 if m.encryptSMIME {
559 encToggle = "[x]"
560 }
561 encField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.encrypt_smime"), encToggle))
562 if m.focusIndex == focusEncryptSMIME {
563 encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
564 }
565
566 // Build To field with suggestions
567 toFieldView := m.toInput.View()
568 if m.showSuggestions && len(m.suggestions) > 0 {
569 var suggestionsBuilder strings.Builder
570 for i, s := range m.suggestions {
571 display := s.Email
572 if s.Name != "" && s.Name != s.Email {
573 display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
574 }
575 if i == m.selectedSuggestion {
576 suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
577 } else {
578 suggestionsBuilder.WriteString(suggestionStyle.Render(" "+display) + "\n")
579 }
580 }
581 toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
582 }
583
584 // Signature field label
585 var signatureLabel string
586 if m.focusIndex == focusSignature {
587 signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
588 } else {
589 signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
590 }
591
592 tip := ""
593 switch m.focusIndex {
594 case focusFrom:
595 tip = "Select the account to send from."
596 case focusTo:
597 tip = "Enter recipient email addresses."
598 case focusCc:
599 tip = "Carbon copy recipients."
600 case focusBcc:
601 tip = "Blind carbon copy recipients."
602 case focusSubject:
603 tip = "The subject line of your email."
604 case focusBody:
605 tip = "The main content of your email. Markdown and HTML are supported."
606 case focusSignature:
607 tip = "Your email signature. This will be appended to the end of the email."
608 case focusAttachment:
609 tip = "Enter: add file • backspace/d: remove last attachment"
610 case focusEncryptSMIME:
611 tip = "Press Space or Enter to toggle S/MIME encryption on or off."
612 case focusSend:
613 tip = "Press Enter to send the email."
614 }
615
616 composerViewElements := []string{
617 t("composer.title"),
618 fromField,
619 toFieldView,
620 m.ccInput.View(),
621 m.bccInput.View(),
622 m.subjectInput.View(),
623 m.bodyInput.View(),
624 signatureLabel,
625 m.signatureInput.View(),
626 attachmentStyle.Render(attachmentField),
627 smimeToggleStyle.Render(encField),
628 button,
629 "",
630 }
631
632 if !m.hideTips && tip != "" {
633 composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
634 }
635
636 mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
637 helpText := t("composer.help")
638 for _, pk := range m.pluginKeyBindings {
639 helpText += " • " + pk.Key + ": " + pk.Description
640 }
641 if m.pluginStatus != "" {
642 helpText += " • " + m.pluginStatus
643 }
644 helpView := helpStyle.Render(helpText)
645
646 if m.height > 0 {
647 currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
648 gap := m.height - currentHeight
649 if gap >= 0 {
650 mainContent += strings.Repeat("\n", gap+1)
651 } else {
652 mainContent += "\n"
653 }
654 } else {
655 mainContent += "\n\n"
656 }
657
658 composerView.WriteString(mainContent)
659 composerView.WriteString(helpView)
660
661 // Plugin prompt overlay
662 if m.showPluginPrompt {
663 dialog := DialogBoxStyle.Render(
664 lipgloss.JoinVertical(lipgloss.Left,
665 m.pluginPromptPlaceholder,
666 "",
667 m.pluginPromptInput.View(),
668 "",
669 HelpStyle.Render("enter: submit • esc: cancel"),
670 ),
671 )
672 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
673 }
674
675 // Account picker overlay
676 if m.showAccountPicker {
677 var accountList strings.Builder
678 accountList.WriteString("Select Account:\n\n")
679 for i, acc := range m.accounts {
680 display := acc.GetSendAsEmail()
681 if acc.Name != "" {
682 display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
683 }
684 if i == m.selectedAccountIdx {
685 accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
686 } else {
687 accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display)))
688 }
689 accountList.WriteString("\n")
690 }
691 accountList.WriteString("\n")
692 accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
693
694 dialog := DialogBoxStyle.Render(accountList.String())
695 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
696 }
697
698 if m.confirmingExit {
699 dialog := DialogBoxStyle.Render(
700 lipgloss.JoinVertical(lipgloss.Center,
701 t("composer.exit_confirm"),
702 HelpStyle.Render("\n(y/n)"),
703 ),
704 )
705 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
706 }
707
708 return tea.NewView(composerView.String())
709}
710
711// SetAccounts sets the available accounts for sending.
712func (m *Composer) SetAccounts(accounts []config.Account) {
713 m.accounts = accounts
714 if m.selectedAccountIdx >= len(accounts) {
715 m.selectedAccountIdx = 0
716 }
717 m.updateSignature()
718}
719
720// SetSelectedAccount sets the selected account by ID.
721func (m *Composer) SetSelectedAccount(accountID string) {
722 for i, acc := range m.accounts {
723 if acc.ID == accountID {
724 m.selectedAccountIdx = i
725 m.updateSignature()
726 return
727 }
728 }
729}
730
731// GetSelectedAccountID returns the ID of the currently selected account.
732func (m *Composer) GetSelectedAccountID() string {
733 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
734 return m.accounts[m.selectedAccountIdx].ID
735 }
736 return ""
737}
738
739// GetDraftID returns the draft ID for this composer.
740func (m *Composer) GetDraftID() string {
741 return m.draftID
742}
743
744// SetDraftID sets the draft ID (for loading existing drafts).
745func (m *Composer) SetDraftID(id string) {
746 m.draftID = id
747}
748
749// GetTo returns the current To field value.
750func (m *Composer) GetTo() string {
751 return m.toInput.Value()
752}
753
754// SetTo updates the To field with new content.
755func (m *Composer) SetTo(to string) {
756 m.toInput.SetValue(to)
757}
758
759// GetCc returns the current Cc field value.
760func (m *Composer) GetCc() string {
761 return m.ccInput.Value()
762}
763
764// SetCc updates the Cc field with new content.
765func (m *Composer) SetCc(cc string) {
766 m.ccInput.SetValue(cc)
767}
768
769// GetBcc returns the current Bcc field value.
770func (m *Composer) GetBcc() string {
771 return m.bccInput.Value()
772}
773
774// SetBcc updates the Bcc field with new content.
775func (m *Composer) SetBcc(bcc string) {
776 m.bccInput.SetValue(bcc)
777}
778
779// GetSubject returns the current Subject field value.
780func (m *Composer) GetSubject() string {
781 return m.subjectInput.Value()
782}
783
784// SetSubject updates the Subject field with new content.
785func (m *Composer) SetSubject(subject string) {
786 m.subjectInput.SetValue(subject)
787}
788
789// GetBody returns the current Body field value.
790func (m *Composer) GetBody() string {
791 return m.bodyInput.Value()
792}
793
794// SetBody updates the Body field with new content.
795func (m *Composer) SetBody(body string) {
796 m.bodyInput.SetValue(body)
797}
798
799// GetAttachmentPaths returns the current attachment paths.
800func (m *Composer) GetAttachmentPaths() []string {
801 return m.attachmentPaths
802}
803
804// GetSignature returns the current signature value.
805func (m *Composer) GetSignature() string {
806 return m.signatureInput.Value()
807}
808
809// SetReplyContext sets the reply context for the draft.
810func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
811 m.inReplyTo = inReplyTo
812 m.references = references
813}
814
815// SetQuotedText sets the hidden quoted text that will be appended when sending.
816func (m *Composer) SetQuotedText(text string) {
817 m.quotedText = text
818}
819
820// GetQuotedText returns the hidden quoted text.
821func (m *Composer) GetQuotedText() string {
822 return m.quotedText
823}
824
825// GetInReplyTo returns the In-Reply-To header value.
826func (m *Composer) GetInReplyTo() string {
827 return m.inReplyTo
828}
829
830// GetReferences returns the References header values.
831func (m *Composer) GetReferences() []string {
832 return m.references
833}
834
835// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
836func (m *Composer) SetPluginStatus(status string) {
837 m.pluginStatus = status
838}
839
840// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
841func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
842 m.pluginKeyBindings = bindings
843}
844
845// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
846func (m *Composer) ShowPluginPrompt(placeholder string) {
847 m.pluginPromptPlaceholder = placeholder
848 m.pluginPromptInput = textinput.New()
849 m.pluginPromptInput.Placeholder = placeholder
850 m.pluginPromptInput.Prompt = "> "
851 m.pluginPromptInput.CharLimit = 256
852 m.pluginPromptInput.Focus()
853 m.showPluginPrompt = true
854}
855
856// HidePluginPrompt deactivates the plugin prompt overlay.
857func (m *Composer) HidePluginPrompt() {
858 m.showPluginPrompt = false
859}
860
861// ToDraft converts the composer state to a Draft for saving.
862func (m *Composer) ToDraft() config.Draft {
863 return config.Draft{
864 ID: m.draftID,
865 To: m.toInput.Value(),
866 Cc: m.ccInput.Value(),
867 Bcc: m.bccInput.Value(),
868 Subject: m.subjectInput.Value(),
869 Body: m.bodyInput.Value(),
870 AttachmentPaths: m.attachmentPaths,
871 AccountID: m.GetSelectedAccountID(),
872 InReplyTo: m.inReplyTo,
873 References: m.references,
874 QuotedText: m.quotedText,
875 }
876}
877
878// NewComposerFromDraft creates a composer from an existing draft.
879func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
880 m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
881 m.ccInput.SetValue(draft.Cc)
882 m.bccInput.SetValue(draft.Bcc)
883 m.draftID = draft.ID
884 m.attachmentPaths = draft.AttachmentPaths
885 m.inReplyTo = draft.InReplyTo
886 m.references = draft.References
887 m.quotedText = draft.QuotedText
888 return m
889}