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