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