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