1package tui
2
3import (
4 "fmt"
5 "net/mail"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10 "unicode"
11
12 "charm.land/bubbles/v2/textarea"
13 "charm.land/bubbles/v2/textinput"
14 tea "charm.land/bubbletea/v2"
15 "charm.land/lipgloss/v2"
16 "github.com/floatpane/matcha/config"
17 "github.com/floatpane/matcha/spellcheck"
18 "github.com/google/uuid"
19)
20
21// spellcheckReadyMsg is delivered when the background spellcheck loader
22// finishes (either downloading the default dictionary or loading an
23// already-installed one).
24type spellcheckReadyMsg struct {
25 checker *spellcheck.Checker
26}
27
28var (
29 suggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
30 selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
31 suggestionBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
32)
33
34// Styles for the UI
35var (
36 focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
37 blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
38 helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
39 emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
40 attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
41 smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
42 composerErrorStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("196"))
43)
44
45const (
46 focusFrom = iota
47 focusTo
48 focusCc
49 focusBcc
50 focusSubject
51 focusBody
52 focusSignature
53 focusAttachment
54 focusEncryptSMIME
55 focusSend
56)
57
58type hideComposerNoticeMsg struct{}
59
60// Composer model holds the state of the email composition UI.
61type Composer struct {
62 focusIndex int
63 toInput textinput.Model
64 ccInput textinput.Model
65 bccInput textinput.Model
66 fromError string
67 toError string
68 ccError string
69 bccError string
70 subjectInput textinput.Model
71 bodyInput textarea.Model
72 signatureInput textarea.Model
73 attachmentPaths []string
74 attachmentNames map[string]string
75 attachmentCursor int
76 encryptSMIME bool
77 width int
78 height int
79 confirmingExit bool
80 showNotice bool
81 noticeText string
82 hideTips bool
83
84 // Multi-account support
85 accounts []config.Account
86 selectedAccountIdx int
87 showAccountPicker bool
88 fromInput textinput.Model // editable From when account is catch-all
89
90 // Contact suggestions
91 suggestions []config.Contact
92 selectedSuggestion int
93 showSuggestions bool
94 lastToValue string
95
96 // Draft persistence
97 draftID string
98
99 // Reply context
100 inReplyTo string
101 references []string
102
103 // Hidden quoted text (appended to body when sending, but not shown in editor)
104 quotedText string
105
106 // Plugin status text shown in the help bar
107 pluginStatus string
108 pluginKeyBindings []PluginKeyBinding
109
110 // Plugin prompt overlay
111 showPluginPrompt bool
112 pluginPromptInput textinput.Model
113 pluginPromptPlaceholder string
114
115 // Spellcheck (loaded asynchronously; nil until ready).
116 spellChecker *spellcheck.Checker
117 spellSuggestions []string
118 spellSelected int
119 spellShow bool
120 spellWordOnLine int // index of the logical line containing the word
121 spellWordLineStart int // byte offset of the word within its logical line
122 spellWordLineEnd int // byte offset of the word's end within its logical line
123 spellWord string // the misspelled word (as currently in body)
124 spellLastBody string // last body value we computed suggestions for
125 spellLastCursorRow int
126 spellLastCursorCol int
127 disableSpellcheck bool
128 disableSpellSuggestions bool
129}
130
131// NewComposer initializes a new composer model.
132func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
133 m := &Composer{
134 draftID: uuid.New().String(),
135 hideTips: hideTips,
136 attachmentNames: make(map[string]string),
137 }
138
139 tiStyles := ThemedTextInputStyles()
140 taStyles := ThemedTextAreaStyles()
141
142 m.toInput = textinput.New()
143 m.toInput.Placeholder = t("composer.to_placeholder")
144 m.toInput.SetValue(to)
145 m.toInput.Prompt = "> "
146 m.toInput.CharLimit = 256
147 m.toInput.SetStyles(tiStyles)
148
149 m.ccInput = textinput.New()
150 m.ccInput.Placeholder = t("composer.cc_placeholder")
151 m.ccInput.Prompt = "> "
152 m.ccInput.CharLimit = 256
153 m.ccInput.SetStyles(tiStyles)
154
155 m.bccInput = textinput.New()
156 m.bccInput.Placeholder = t("composer.bcc_placeholder")
157 m.bccInput.Prompt = "> "
158 m.bccInput.CharLimit = 256
159 m.bccInput.SetStyles(tiStyles)
160
161 m.subjectInput = textinput.New()
162 m.subjectInput.Placeholder = t("composer.subject_placeholder")
163 m.subjectInput.SetValue(subject)
164 m.subjectInput.Prompt = "> "
165 m.subjectInput.CharLimit = 256
166 m.subjectInput.SetStyles(tiStyles)
167
168 m.bodyInput = textarea.New()
169 m.bodyInput.Placeholder = t("composer.body_placeholder")
170 m.bodyInput.SetValue(body)
171 m.bodyInput.Prompt = "> "
172 m.bodyInput.SetHeight(10)
173 m.bodyInput.SetStyles(taStyles)
174
175 m.signatureInput = textarea.New()
176 m.signatureInput.Placeholder = t("composer.signature_placeholder")
177 m.signatureInput.Prompt = "> "
178 m.signatureInput.SetHeight(3)
179 m.signatureInput.SetStyles(taStyles)
180 m.updateSignature()
181
182 m.fromInput = textinput.New()
183 m.fromInput.Placeholder = t("composer.from_placeholder")
184 m.fromInput.Prompt = "> "
185 m.fromInput.CharLimit = 256
186 m.fromInput.SetStyles(tiStyles)
187
188 // Start focus on To field (From is selectable but not a text input)
189 m.focusIndex = focusTo
190 m.toInput.Focus()
191
192 return m
193}
194
195func normalizeEmailList(value string) (string, bool) {
196 value = strings.TrimSpace(value)
197 if value == "" {
198 return "", true
199 }
200
201 parts := strings.Split(value, ",")
202 addresses := make([]string, 0, len(parts))
203 for _, part := range parts {
204 part = strings.TrimSpace(part)
205 if part == "" {
206 continue
207 }
208 addr, err := mail.ParseAddress(part)
209 if err != nil || addr.Address == "" {
210 return value, false
211 }
212 addresses = append(addresses, addr.Address)
213 }
214 if len(addresses) == 0 {
215 return "", true
216 }
217 return strings.Join(addresses, ", "), true
218}
219
220func (m *Composer) hasAnyRecipient() bool {
221 return strings.TrimSpace(m.toInput.Value()) != "" ||
222 strings.TrimSpace(m.ccInput.Value()) != "" ||
223 strings.TrimSpace(m.bccInput.Value()) != ""
224}
225
226func (m *Composer) showComposerNotice(message string) tea.Cmd {
227 m.noticeText = message
228 m.showNotice = true
229 return tea.Tick(5*time.Second, func(time.Time) tea.Msg {
230 return hideComposerNoticeMsg{}
231 })
232}
233
234func (m *Composer) hideComposerNotice() {
235 m.showNotice = false
236 m.noticeText = ""
237}
238
239func (m *Composer) validateFromField() bool { //nolint:unparam
240 if !m.isCatchAllAccount() {
241 m.fromError = ""
242 return true
243 }
244 value := strings.TrimSpace(m.fromInput.Value())
245 addr, err := mail.ParseAddress(value)
246 if value == "" || err != nil || addr.Address == "" {
247 m.fromError = t("composer.invalid_email")
248 return false
249 }
250 m.fromError = ""
251 return true
252}
253
254func (m *Composer) validateEmailField(focus int) bool { //nolint:unparam
255 var input *textinput.Model
256 var setError func(string)
257 switch focus {
258 case focusTo:
259 input = &m.toInput
260 setError = func(err string) { m.toError = err }
261 case focusCc:
262 input = &m.ccInput
263 setError = func(err string) { m.ccError = err }
264 case focusBcc:
265 input = &m.bccInput
266 setError = func(err string) { m.bccError = err }
267 default:
268 return true
269 }
270
271 normalized, ok := normalizeEmailList(input.Value())
272 if !ok {
273 setError(t("composer.invalid_email"))
274 return false
275 }
276 input.SetValue(normalized)
277 setError("")
278 return true
279}
280
281func (m *Composer) canSendEmail() bool {
282 m.validateFromField()
283 m.validateEmailField(focusTo)
284 m.validateEmailField(focusCc)
285 m.validateEmailField(focusBcc)
286 return m.fromError == "" && m.toError == "" && m.ccError == "" && m.bccError == ""
287}
288
289// updateSignature updates the signature input based on the current selected account.
290func (m *Composer) updateSignature() {
291 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
292 acc := &m.accounts[m.selectedAccountIdx]
293 if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
294 m.signatureInput.SetValue(sig)
295 } else if sig, err := config.LoadSignature(); err == nil && sig != "" {
296 m.signatureInput.SetValue(sig)
297 } else {
298 m.signatureInput.SetValue("")
299 }
300 // Seed the editable From address for catch-all accounts.
301 m.fromInput.SetValue(acc.FormatFromHeader())
302 return
303 }
304
305 if sig, err := config.LoadSignature(); err == nil && sig != "" {
306 m.signatureInput.SetValue(sig)
307 } else {
308 m.signatureInput.SetValue("")
309 }
310}
311
312// NewComposerWithAccounts initializes a composer with multiple account support.
313func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
314 m := NewComposer("", to, subject, body, hideTips)
315 m.accounts = accounts
316
317 // Find the selected account index
318 for i, acc := range accounts {
319 if acc.ID == selectedAccountID {
320 m.selectedAccountIdx = i
321 break
322 }
323 }
324 m.updateSignature()
325
326 return m
327}
328
329// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
330func (m *Composer) ResetConfirmation() {
331 m.confirmingExit = false
332}
333
334// SetFromOverride pre-fills the editable From field (used for catch-all replies).
335func (m *Composer) SetFromOverride(addr string) {
336 m.fromInput.SetValue(addr)
337}
338
339// SetSpellcheckOptions toggles spellcheck features for this composer. Pass
340// disableCheck=true to skip dictionary download/highlighting entirely;
341// disableSuggestions=true keeps inline underlines but suppresses the popup.
342func (m *Composer) SetSpellcheckOptions(disableCheck, disableSuggestions bool) {
343 m.disableSpellcheck = disableCheck
344 m.disableSpellSuggestions = disableSuggestions
345 if disableCheck {
346 m.spellChecker = nil
347 m.spellShow = false
348 m.spellSuggestions = nil
349 }
350}
351
352func (m *Composer) Init() tea.Cmd {
353 cmds := []tea.Cmd{textinput.Blink}
354 if !m.disableSpellcheck {
355 cmds = append(cmds, loadSpellcheckCmd())
356 }
357 return tea.Batch(cmds...)
358}
359
360// loadSpellcheckCmd ensures the default dictionary is downloaded and
361// loaded into a new Checker. Network errors are swallowed: spellcheck is a
362// non-essential overlay, so the composer continues to work normally.
363func loadSpellcheckCmd() tea.Cmd {
364 return func() tea.Msg {
365 lang, err := spellcheck.EnsureDefault()
366 if err != nil {
367 return spellcheckReadyMsg{checker: nil}
368 }
369 c := spellcheck.NewChecker()
370 if err := c.LoadLang(lang); err != nil {
371 return spellcheckReadyMsg{checker: nil}
372 }
373 return spellcheckReadyMsg{checker: c}
374 }
375}
376
377// updateSpellSuggestions inspects the body cursor position and refreshes
378// the suggestion popup. It only fires when the cursor sits at the end of
379// a misspelled word.
380func (m *Composer) updateSpellSuggestions() {
381 m.spellShow = false
382 m.spellSuggestions = nil
383 m.spellWord = ""
384
385 if m.disableSpellcheck || m.disableSpellSuggestions {
386 return
387 }
388 if m.spellChecker == nil || !m.spellChecker.Loaded() {
389 return
390 }
391 if m.focusIndex != focusBody {
392 return
393 }
394
395 value := m.bodyInput.Value()
396 row := m.bodyInput.Line()
397 col := m.bodyInput.Column()
398 lines := strings.Split(value, "\n")
399 if row < 0 || row >= len(lines) {
400 return
401 }
402 line := lines[row]
403 lineRunes := []rune(line)
404 if col > len(lineRunes) {
405 col = len(lineRunes)
406 }
407
408 // Walk back from cursor while we have letters or internal connectors.
409 end := col
410 start := col
411 for start > 0 {
412 r := lineRunes[start-1]
413 if isWordContinuation(r) {
414 start--
415 continue
416 }
417 break
418 }
419 // Trim leading connectors so the word starts on a letter.
420 for start < end && !isLetter(lineRunes[start]) {
421 start++
422 }
423 // Trim trailing connectors so we don't suggest replacements while the
424 // user is still mid-apostrophe.
425 for end > start && !isLetter(lineRunes[end-1]) {
426 end--
427 }
428 if end-start < 2 {
429 return
430 }
431
432 word := string(lineRunes[start:end])
433 if !spellcheck.IsCheckable(word) {
434 return
435 }
436 if m.spellChecker.Check(word) {
437 return
438 }
439
440 suggestions := m.spellChecker.Suggest(word, 5)
441 if len(suggestions) == 0 {
442 return
443 }
444
445 m.spellSuggestions = suggestions
446 m.spellSelected = 0
447 m.spellShow = true
448 m.spellWord = word
449
450 // Byte offsets within the current line, needed by the accept handler.
451 m.spellWordLineStart = len(string(lineRunes[:start]))
452 m.spellWordLineEnd = len(string(lineRunes[:end]))
453 m.spellWordOnLine = row
454
455 // Cache cursor position so a no-op key (e.g. arrow without movement)
456 // doesn't redundantly recompute suggestions.
457 m.spellLastBody = value
458 m.spellLastCursorRow = row
459 m.spellLastCursorCol = col
460}
461
462// acceptSpellSuggestion replaces the misspelled word currently under the
463// cursor with the selected suggestion. It works by sending backspace key
464// events to the textarea (so the textarea's own bookkeeping stays in
465// sync) and then inserting the replacement text.
466func (m *Composer) acceptSpellSuggestion() {
467 if !m.spellShow || len(m.spellSuggestions) == 0 {
468 return
469 }
470 if m.spellSelected < 0 || m.spellSelected >= len(m.spellSuggestions) {
471 return
472 }
473 suggestion := m.spellSuggestions[m.spellSelected]
474
475 // Only replace when the cursor is still at the end of the word we
476 // recorded — otherwise the user moved and the popup is stale.
477 row := m.bodyInput.Line()
478 col := m.bodyInput.Column()
479 lines := strings.Split(m.bodyInput.Value(), "\n")
480 if row != m.spellWordOnLine || row >= len(lines) {
481 m.spellShow = false
482 m.spellSuggestions = nil
483 return
484 }
485 endRunes := len([]rune(lines[row][:m.spellWordLineEnd]))
486 if col != endRunes {
487 m.spellShow = false
488 m.spellSuggestions = nil
489 return
490 }
491
492 wordRuneLen := len([]rune(m.spellWord))
493 for i := 0; i < wordRuneLen; i++ {
494 m.bodyInput, _ = m.bodyInput.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
495 }
496 m.bodyInput.InsertString(suggestion)
497
498 m.spellShow = false
499 m.spellSuggestions = nil
500 m.spellWord = ""
501}
502
503func isWordContinuation(r rune) bool {
504 return isLetter(r) || r == '\'' || r == '’' || r == '-'
505}
506
507func isLetter(r rune) bool {
508 if r < 0x80 {
509 return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
510 }
511 return unicode.IsLetter(r)
512}
513
514func (m *Composer) getFromAddress() string {
515 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
516 return m.accounts[m.selectedAccountIdx].FormatFromHeader()
517 }
518 return ""
519}
520
521func (m *Composer) isCatchAllAccount() bool {
522 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
523 return m.accounts[m.selectedAccountIdx].CatchAll
524 }
525 return false
526}
527
528func (m *Composer) getSelectedAccount() *config.Account {
529 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
530 return &m.accounts[m.selectedAccountIdx]
531 }
532 return nil
533}
534
535func formatAttachmentName(path string) string {
536 name := filepath.Base(path)
537 info, err := os.Stat(path)
538 if err != nil || info.IsDir() {
539 return name
540 }
541 return fmt.Sprintf("%s (%s)", name, tfs(info.Size()))
542}
543
544func (m *Composer) attachmentDisplayName(path string) string {
545 if name, ok := m.attachmentNames[path]; ok {
546 return name
547 }
548 return filepath.Base(path)
549}
550
551func (m *Composer) clampAttachmentCursor() {
552 if len(m.attachmentPaths) == 0 {
553 m.attachmentCursor = 0
554 return
555 }
556 if m.attachmentCursor < 0 {
557 m.attachmentCursor = 0
558 }
559 if m.attachmentCursor >= len(m.attachmentPaths) {
560 m.attachmentCursor = len(m.attachmentPaths) - 1
561 }
562}
563
564func (m *Composer) removeSelectedAttachment() {
565 if len(m.attachmentPaths) == 0 {
566 return
567 }
568
569 m.clampAttachmentCursor()
570 idx := m.attachmentCursor
571 delete(m.attachmentNames, m.attachmentPaths[idx])
572 m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
573 m.clampAttachmentCursor()
574}
575
576func suggestionDisplay(s config.Contact, suggestionWidth int) string {
577 display := s.Email
578 if len(s.Addresses) > 0 {
579 display = fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Addresses, ", "))
580 return truncateSuggestionDisplay(display, suggestionWidth)
581 } else if s.Name != "" && s.Name != s.Email {
582 display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
583 }
584 return display
585}
586
587func suggestionDisplayWidth(width int) int {
588 if width > 12 {
589 return width - 6
590 }
591 return 40
592}
593
594func truncateSuggestionDisplay(s string, maxLen int) string {
595 runes := []rune(s)
596 if len(runes) <= maxLen {
597 return s
598 }
599 if maxLen <= 0 {
600 return ""
601 }
602 if maxLen <= 3 {
603 return string(runes[:maxLen])
604 }
605 return string(runes[:maxLen-3]) + "..."
606}
607
608func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
609 var cmds []tea.Cmd
610 var cmd tea.Cmd
611
612 switch msg := msg.(type) {
613 case tea.WindowSizeMsg:
614 m.width = msg.Width
615 m.height = msg.Height
616 inputWidth := msg.Width - 6
617 m.toInput.SetWidth(inputWidth)
618 m.ccInput.SetWidth(inputWidth)
619 m.bccInput.SetWidth(inputWidth)
620 m.subjectInput.SetWidth(inputWidth)
621 m.bodyInput.SetWidth(inputWidth)
622 m.signatureInput.SetWidth(inputWidth)
623 if msg.Height > 0 {
624 // Fixed rows: title, from, to, cc, bcc, subject, sig label,
625 // attachment, smime, button, blank, tip, help = 13
626 const fixedRows = 13
627 available := msg.Height - fixedRows
628 if available < 6 {
629 available = 6
630 }
631 bodyHeight := (available * 55) / 100
632 sigHeight := (available * 15) / 100
633 if bodyHeight < 3 {
634 bodyHeight = 3
635 }
636 if sigHeight < 2 {
637 sigHeight = 2
638 }
639 m.bodyInput.SetHeight(bodyHeight)
640 m.signatureInput.SetHeight(sigHeight)
641 }
642
643 case hideComposerNoticeMsg:
644 m.hideComposerNotice()
645 return m, nil
646
647 case spellcheckReadyMsg:
648 if msg.checker != nil {
649 m.spellChecker = msg.checker
650 m.updateSpellSuggestions()
651 }
652 return m, nil
653
654 case FileSelectedMsg:
655 // Avoid duplicates and add all selected paths
656 for _, newPath := range msg.Paths {
657 exists := false
658 for _, p := range m.attachmentPaths {
659 if p == newPath {
660 exists = true
661 break
662 }
663 }
664 if !exists {
665 m.attachmentPaths = append(m.attachmentPaths, newPath)
666 m.attachmentNames[newPath] = formatAttachmentName(newPath)
667 }
668 }
669 m.clampAttachmentCursor()
670 return m, nil
671
672 case tea.KeyPressMsg:
673 // Handle contact suggestions mode
674 if m.showSuggestions && len(m.suggestions) > 0 {
675 switch msg.String() {
676 case "up", "ctrl+p":
677 if m.selectedSuggestion > 0 {
678 m.selectedSuggestion--
679 }
680 return m, nil
681 case keyDown, "ctrl+n":
682 if m.selectedSuggestion < len(m.suggestions)-1 {
683 m.selectedSuggestion++
684 }
685 return m, nil
686 case "tab", keyEnter:
687 // Select the suggestion
688 selected := m.suggestions[m.selectedSuggestion]
689
690 var newEmail string
691 switch {
692 case len(selected.Addresses) > 0:
693 // Mailing list: emit just the addresses to maintain valid email formatting
694 newEmail = strings.Join(selected.Addresses, ", ")
695 case selected.Name != "" && selected.Name != selected.Email:
696 newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
697 default:
698 newEmail = selected.Email
699 }
700
701 parts := strings.Split(m.toInput.Value(), ",")
702 if len(parts) > 0 {
703 if len(parts) == 1 {
704 parts[0] = newEmail
705 } else {
706 parts[len(parts)-1] = " " + newEmail
707 }
708 } else {
709 parts = []string{newEmail}
710 }
711
712 finalValue := strings.Join(parts, ",")
713 if !strings.HasSuffix(finalValue, ", ") {
714 finalValue += ", "
715 }
716
717 m.toInput.SetValue(finalValue)
718 m.toInput.SetCursor(len(finalValue))
719 m.toError = ""
720 m.lastToValue = m.toInput.Value()
721 m.showSuggestions = false
722 m.suggestions = nil
723 return m, nil
724 case "esc":
725 m.showSuggestions = false
726 m.suggestions = nil
727 return m, nil
728 }
729 // For prev-field key, close suggestions and let it fall through to normal handling
730 if msg.String() == config.Keybinds.Composer.PrevField {
731 m.showSuggestions = false
732 m.suggestions = nil
733 }
734 }
735
736 // Handle plugin prompt overlay
737 if m.showPluginPrompt {
738 switch msg.String() {
739 case keyEnter:
740 value := m.pluginPromptInput.Value()
741 m.showPluginPrompt = false
742 return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
743 case "esc":
744 m.showPluginPrompt = false
745 return m, func() tea.Msg { return PluginPromptCancelMsg{} }
746 default:
747 m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
748 return m, cmd
749 }
750 }
751
752 // Handle account picker mode
753 if m.showAccountPicker {
754 switch msg.String() {
755 case "up", "k":
756 if m.selectedAccountIdx > 0 {
757 m.selectedAccountIdx--
758 m.updateSignature()
759 }
760 case keyDown, "j":
761 if m.selectedAccountIdx < len(m.accounts)-1 {
762 m.selectedAccountIdx++
763 m.updateSignature()
764 }
765 case keyEnter:
766 m.showAccountPicker = false
767 case "esc":
768 m.showAccountPicker = false
769 }
770 return m, nil
771 }
772
773 if m.confirmingExit {
774 switch msg.String() {
775 case "y", "Y":
776 return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
777 case "n", "N", "esc":
778 m.confirmingExit = false
779 return m, nil
780 default:
781 return m, nil
782 }
783 }
784
785 if m.showNotice {
786 switch msg.String() {
787 case keyEnter, "esc", " ":
788 m.hideComposerNotice()
789 }
790 return m, nil
791 }
792
793 // Spellcheck suggestion popup (only while body is focused).
794 if m.focusIndex == focusBody && m.spellShow && len(m.spellSuggestions) > 0 {
795 sk := config.Keybinds.Composer
796 switch msg.String() {
797 case sk.SpellPrev:
798 if m.spellSelected > 0 {
799 m.spellSelected--
800 }
801 return m, nil
802 case sk.SpellNext:
803 if m.spellSelected < len(m.spellSuggestions)-1 {
804 m.spellSelected++
805 }
806 return m, nil
807 case sk.SpellAccept:
808 m.acceptSpellSuggestion()
809 return m, nil
810 case sk.SpellDismiss:
811 m.spellShow = false
812 m.spellSuggestions = nil
813 return m, nil
814 }
815 }
816
817 kb := config.Keybinds
818 attachmentPathSize := len(m.attachmentPaths)
819 if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
820 switch msg.String() {
821 case "up", kb.Global.NavUp:
822 m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
823 return m, nil
824 case keyDown, kb.Global.NavDown:
825 m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
826 return m, nil
827 }
828 }
829
830 switch msg.String() {
831 case kb.Global.Quit:
832 return m, tea.Quit
833 case kb.Composer.ExternalEditor:
834 return m, func() tea.Msg { return OpenEditorMsg{} }
835 case kb.Global.Cancel:
836 m.confirmingExit = true
837 return m, nil
838
839 case kb.Composer.NextField, kb.Composer.PrevField:
840 previousFocus := m.focusIndex
841 if msg.String() == kb.Composer.PrevField {
842 m.focusIndex--
843 } else {
844 m.focusIndex++
845 }
846
847 maxFocus := focusSend
848 minFocus := focusFrom
849 // Skip From field if only one non-catch-all account (nothing to switch or edit)
850 if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
851 minFocus = focusTo
852 }
853
854 if m.focusIndex > maxFocus {
855 m.focusIndex = minFocus
856 } else if m.focusIndex < minFocus {
857 m.focusIndex = maxFocus
858 }
859
860 if previousFocus == focusFrom {
861 m.validateFromField()
862 } else if previousFocus != m.focusIndex {
863 m.validateEmailField(previousFocus)
864 }
865
866 m.fromInput.Blur()
867 m.toInput.Blur()
868 m.ccInput.Blur()
869 m.bccInput.Blur()
870 m.subjectInput.Blur()
871 m.bodyInput.Blur()
872 m.signatureInput.Blur()
873 m.spellShow = false
874 m.spellSuggestions = nil
875
876 switch m.focusIndex {
877 case focusFrom:
878 if m.isCatchAllAccount() {
879 cmds = append(cmds, m.fromInput.Focus())
880 }
881 case focusTo:
882 cmds = append(cmds, m.toInput.Focus())
883 case focusCc:
884 cmds = append(cmds, m.ccInput.Focus())
885 case focusBcc:
886 cmds = append(cmds, m.bccInput.Focus())
887 case focusSubject:
888 cmds = append(cmds, m.subjectInput.Focus())
889 case focusBody:
890 cmds = append(cmds, m.bodyInput.Focus())
891 case focusSignature:
892 cmds = append(cmds, m.signatureInput.Focus())
893 }
894 return m, tea.Batch(cmds...)
895
896 case kb.Composer.Delete:
897 if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
898 m.removeSelectedAttachment()
899 return m, nil
900 }
901
902 case keyEnter, " ":
903 switch m.focusIndex {
904 case focusFrom:
905 if msg.String() == keyEnter && len(m.accounts) > 1 {
906 m.showAccountPicker = true
907 return m, nil
908 }
909 if m.isCatchAllAccount() && msg.String() == " " {
910 break
911 }
912 return m, nil
913 case focusAttachment:
914 if msg.String() == keyEnter {
915 return m, func() tea.Msg { return GoToFilePickerMsg{} }
916 }
917 case focusEncryptSMIME:
918 if msg.String() == keyEnter || msg.String() == " " {
919 m.encryptSMIME = !m.encryptSMIME
920 }
921 return m, nil
922
923 case focusSend:
924 if msg.String() == keyEnter {
925 if !m.canSendEmail() {
926 return m, m.showComposerNotice(t("composer.invalid_email_fields"))
927 }
928 if !m.hasAnyRecipient() {
929 return m, m.showComposerNotice(t("composer.recipient_required"))
930 }
931 acc := m.getSelectedAccount()
932 accountID := ""
933 if acc != nil {
934 accountID = acc.ID
935 }
936 fromOverride := ""
937 if m.isCatchAllAccount() {
938 fromOverride = m.fromInput.Value()
939 }
940 return m, func() tea.Msg {
941 return SendEmailMsg{
942 To: m.toInput.Value(),
943 Cc: m.ccInput.Value(),
944 Bcc: m.bccInput.Value(),
945 Subject: m.subjectInput.Value(),
946 Body: m.bodyInput.Value(),
947 AttachmentPaths: m.attachmentPaths,
948 AccountID: accountID,
949 FromOverride: fromOverride,
950 QuotedText: m.quotedText,
951 InReplyTo: m.inReplyTo,
952 References: m.references,
953 Signature: m.signatureInput.Value(),
954 SignSMIME: acc != nil && acc.SMIMESignByDefault,
955 EncryptSMIME: m.encryptSMIME,
956 SignPGP: acc != nil && acc.PGPSignByDefault,
957 }
958 }
959 }
960 }
961 }
962 }
963
964 switch m.focusIndex {
965 case focusFrom:
966 if m.isCatchAllAccount() {
967 previousFromValue := m.fromInput.Value()
968 m.fromInput, cmd = m.fromInput.Update(msg)
969 cmds = append(cmds, cmd)
970 if m.fromInput.Value() != previousFromValue {
971 m.fromError = ""
972 }
973 }
974 case focusTo:
975 previousToValue := m.toInput.Value()
976 m.toInput, cmd = m.toInput.Update(msg)
977 cmds = append(cmds, cmd)
978
979 // Check if To field value changed and update suggestions
980 currentValue := m.toInput.Value()
981 if currentValue != m.lastToValue {
982 if currentValue != previousToValue {
983 m.toError = ""
984 }
985 m.lastToValue = currentValue
986
987 // Extract the last comma-separated part for searching
988 parts := strings.Split(currentValue, ",")
989 lastPart := strings.TrimSpace(parts[len(parts)-1])
990
991 if len(lastPart) >= 2 {
992 m.suggestions = config.SearchContactsForAccount(lastPart, m.GetSelectedAccountID())
993 m.showSuggestions = len(m.suggestions) > 0
994 m.selectedSuggestion = 0
995 } else {
996 m.showSuggestions = false
997 m.suggestions = nil
998 }
999 }
1000 case focusCc:
1001 previousCcValue := m.ccInput.Value()
1002 m.ccInput, cmd = m.ccInput.Update(msg)
1003 cmds = append(cmds, cmd)
1004 if m.ccInput.Value() != previousCcValue {
1005 m.ccError = ""
1006 }
1007 case focusBcc:
1008 previousBccValue := m.bccInput.Value()
1009 m.bccInput, cmd = m.bccInput.Update(msg)
1010 cmds = append(cmds, cmd)
1011 if m.bccInput.Value() != previousBccValue {
1012 m.bccError = ""
1013 }
1014 case focusSubject:
1015 m.subjectInput, cmd = m.subjectInput.Update(msg)
1016 cmds = append(cmds, cmd)
1017 case focusBody:
1018 prevBody := m.bodyInput.Value()
1019 prevRow := m.bodyInput.Line()
1020 prevCol := m.bodyInput.Column()
1021 m.bodyInput, cmd = m.bodyInput.Update(msg)
1022 cmds = append(cmds, cmd)
1023 // Only recompute suggestions when the body state actually changes.
1024 // Cursor-blink ticks otherwise reset spellSelected to 0 every blink.
1025 if m.bodyInput.Value() != prevBody ||
1026 m.bodyInput.Line() != prevRow ||
1027 m.bodyInput.Column() != prevCol {
1028 m.updateSpellSuggestions()
1029 }
1030 case focusSignature:
1031 m.signatureInput, cmd = m.signatureInput.Update(msg)
1032 cmds = append(cmds, cmd)
1033 }
1034
1035 return m, tea.Batch(cmds...)
1036}
1037
1038func (m *Composer) View() tea.View { //nolint:gocyclo
1039 var composerView strings.Builder
1040 var button string
1041 ck := config.Keybinds.Composer
1042
1043 if m.focusIndex == focusSend {
1044 button = focusedStyle.Render("[ " + t("composer.send") + " ]")
1045 } else {
1046 button = blurredStyle.Render("[ " + t("composer.send") + " ]")
1047 }
1048
1049 // From field with account selector
1050 fromAddr := m.getFromAddress()
1051 var fromField string
1052 if m.isCatchAllAccount() { //nolint:gocritic
1053 fromAddrView := m.fromInput.View()
1054 if len(m.accounts) > 1 {
1055 if m.focusIndex == focusFrom {
1056 fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
1057 } else {
1058 fromField = blurredStyle.Render(fmt.Sprintf(" %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
1059 }
1060 } else {
1061 fromField = " " + t("composer.from") + " " + fromAddrView
1062 }
1063 if m.fromError != "" {
1064 fromField += "\n" + composerErrorStyle.Render(m.fromError)
1065 }
1066 } else if len(m.accounts) > 1 {
1067 if m.focusIndex == focusFrom {
1068 fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
1069 } else {
1070 fromField = blurredStyle.Render(fmt.Sprintf(" %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
1071 }
1072 } else if fromAddr != "" {
1073 fromField = " " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
1074 } else {
1075 fromField = blurredStyle.Render(fmt.Sprintf(" %s (%s)", t("composer.from"), t("composer.no_account")))
1076 }
1077
1078 var attachmentField string
1079 if len(m.attachmentPaths) == 0 {
1080 attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
1081 if m.focusIndex == focusAttachment {
1082 attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
1083 } else {
1084 attachmentField = blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.attachments"), attachmentText))
1085 }
1086 } else {
1087 var b strings.Builder
1088 headerPrefix := " "
1089 headerStyle := blurredStyle
1090 if m.focusIndex == focusAttachment {
1091 headerPrefix = "> "
1092 headerStyle = focusedStyle
1093 }
1094 b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
1095 for i, p := range m.attachmentPaths {
1096 cursor := " "
1097 style := blurredStyle
1098 if m.focusIndex == focusAttachment && i == m.attachmentCursor {
1099 cursor = " > "
1100 style = focusedStyle
1101 }
1102 b.WriteString("\n")
1103 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
1104 }
1105 attachmentField = b.String()
1106 }
1107
1108 encToggle := "[ ]"
1109 if m.encryptSMIME {
1110 encToggle = "[x]"
1111 }
1112 encField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.encrypt_smime"), encToggle))
1113 if m.focusIndex == focusEncryptSMIME {
1114 encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
1115 }
1116
1117 // Build To field with suggestions
1118 toFieldView := m.toInput.View()
1119 if m.toError != "" {
1120 toFieldView += "\n" + composerErrorStyle.Render(m.toError)
1121 }
1122 if m.showSuggestions && len(m.suggestions) > 0 {
1123 var suggestionsBuilder strings.Builder
1124 suggestionWidth := suggestionDisplayWidth(m.width)
1125 for i, s := range m.suggestions {
1126 display := suggestionDisplay(s, suggestionWidth)
1127 if i == m.selectedSuggestion {
1128 suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
1129 } else {
1130 suggestionsBuilder.WriteString(suggestionStyle.Render(" "+display) + "\n")
1131 }
1132 }
1133 toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
1134 }
1135
1136 ccFieldView := m.ccInput.View()
1137 if m.ccError != "" {
1138 ccFieldView += "\n" + composerErrorStyle.Render(m.ccError)
1139 }
1140
1141 bccFieldView := m.bccInput.View()
1142 if m.bccError != "" {
1143 bccFieldView += "\n" + composerErrorStyle.Render(m.bccError)
1144 }
1145
1146 // Signature field label
1147 var signatureLabel string
1148 if m.focusIndex == focusSignature {
1149 signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
1150 } else {
1151 signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
1152 }
1153
1154 tip := ""
1155 switch m.focusIndex {
1156 case focusFrom:
1157 tip = "Select the account to send from."
1158 case focusTo:
1159 tip = "Enter recipient email addresses."
1160 case focusCc:
1161 tip = "Carbon copy recipients."
1162 case focusBcc:
1163 tip = "Blind carbon copy recipients."
1164 case focusSubject:
1165 tip = "The subject line of your email."
1166 case focusBody:
1167 if m.spellShow && len(m.spellSuggestions) > 0 {
1168 sk := config.Keybinds.Composer
1169 tip = fmt.Sprintf("Spelling: %s accept • %s/%s navigate • %s dismiss",
1170 sk.SpellAccept, sk.SpellNext, sk.SpellPrev, sk.SpellDismiss)
1171 } else {
1172 tip = "The main content of your email. Markdown and HTML are supported."
1173 }
1174 case focusSignature:
1175 tip = "Your email signature. This will be appended to the end of the email."
1176 case focusAttachment:
1177 tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
1178 case focusEncryptSMIME:
1179 tip = "Press Space or Enter to toggle S/MIME encryption on or off."
1180 case focusSend:
1181 tip = "Press Enter to send the email."
1182 }
1183
1184 bodyView := m.bodyInput.View()
1185 if !m.disableSpellcheck && m.spellChecker != nil && m.spellChecker.Loaded() {
1186 bodyView = spellcheck.Highlight(bodyView, m.spellChecker, -1)
1187 }
1188
1189 composerViewElements := []string{
1190 t("composer.title"),
1191 fromField,
1192 toFieldView,
1193 ccFieldView,
1194 bccFieldView,
1195 m.subjectInput.View(),
1196 bodyView,
1197 signatureLabel,
1198 m.signatureInput.View(),
1199 attachmentStyle.Render(attachmentField),
1200 }
1201 if len(m.attachmentPaths) > 0 {
1202 composerViewElements = append(composerViewElements, "")
1203 }
1204 composerViewElements = append(composerViewElements,
1205 smimeToggleStyle.Render(encField),
1206 button,
1207 "",
1208 )
1209
1210 if !m.hideTips && tip != "" {
1211 composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
1212 }
1213
1214 mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
1215 helpText := t("composer.help")
1216 for _, pk := range m.pluginKeyBindings {
1217 helpText += " • " + pk.Key + ": " + pk.Description
1218 }
1219 if m.pluginStatus != "" {
1220 helpText += " • " + m.pluginStatus
1221 }
1222 helpView := helpStyle.Render(helpText)
1223
1224 if m.height > 0 {
1225 currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
1226 gap := m.height - currentHeight
1227 if gap >= 0 {
1228 mainContent += strings.Repeat("\n", gap+1)
1229 } else {
1230 mainContent += "\n"
1231 }
1232 } else {
1233 mainContent += "\n\n"
1234 }
1235
1236 composerView.WriteString(mainContent)
1237 composerView.WriteString(helpView)
1238
1239 // Plugin prompt overlay
1240 if m.showPluginPrompt {
1241 dialog := DialogBoxStyle.Render(
1242 lipgloss.JoinVertical(lipgloss.Left,
1243 m.pluginPromptPlaceholder,
1244 "",
1245 m.pluginPromptInput.View(),
1246 "",
1247 HelpStyle.Render("enter: submit • esc: cancel"),
1248 ),
1249 )
1250 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1251 }
1252
1253 // Account picker overlay
1254 if m.showAccountPicker {
1255 var accountList strings.Builder
1256 accountList.WriteString("Select Account:\n\n")
1257 for i, acc := range m.accounts {
1258 display := acc.GetSendAsEmail()
1259 if acc.Name != "" {
1260 display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
1261 }
1262 if i == m.selectedAccountIdx {
1263 accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
1264 } else {
1265 accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display)))
1266 }
1267 accountList.WriteString("\n")
1268 }
1269 accountList.WriteString("\n")
1270 accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
1271
1272 dialog := DialogBoxStyle.Render(accountList.String())
1273 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1274 }
1275
1276 if m.confirmingExit {
1277 dialog := DialogBoxStyle.Render(
1278 lipgloss.JoinVertical(lipgloss.Center,
1279 t("composer.exit_confirm"),
1280 HelpStyle.Render("\n(y/n)"),
1281 ),
1282 )
1283 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1284 }
1285
1286 if m.showNotice {
1287 dialog := DialogBoxStyle.Render(
1288 lipgloss.JoinVertical(lipgloss.Center,
1289 dangerStyle.Render(m.noticeText),
1290 HelpStyle.Render("\nenter/esc: close"),
1291 ),
1292 )
1293 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1294 }
1295
1296 out := composerView.String()
1297 if m.spellShow && len(m.spellSuggestions) > 0 && m.focusIndex == focusBody {
1298 out = m.overlaySpellPopup(out, composerViewElements)
1299 }
1300 return tea.NewView(out)
1301}
1302
1303// overlaySpellPopup floats the suggestion box at the body cursor position
1304// in the rendered composer view. It returns the view unchanged when the
1305// cursor can't be located.
1306func (m *Composer) overlaySpellPopup(view string, elementsBeforeBody []string) string {
1307 // Body is the 7th element (index 6) of composerViewElements: title,
1308 // from, to, cc, bcc, subject, body, ...
1309 const bodyIdx = 6
1310 if bodyIdx > len(elementsBeforeBody) {
1311 return view
1312 }
1313 bodyStartRow := 0
1314 for i := 0; i < bodyIdx; i++ {
1315 bodyStartRow += lipgloss.Height(elementsBeforeBody[i])
1316 }
1317
1318 li := m.bodyInput.LineInfo()
1319 const promptWidth = 2 // "> "
1320 cursorRow := bodyStartRow + li.RowOffset
1321 cursorCol := li.CharOffset + promptWidth
1322
1323 popup := m.renderSpellPopupLines()
1324 if len(popup) == 0 {
1325 return view
1326 }
1327
1328 // Anchor below cursor. If popup would clip the bottom, raise it above
1329 // the cursor row instead.
1330 anchorRow := cursorRow + 1
1331 if m.height > 0 && anchorRow+len(popup) > m.height-1 && cursorRow-len(popup) >= 0 {
1332 anchorRow = cursorRow - len(popup)
1333 }
1334 anchorCol := cursorCol
1335 popupWidth := lipgloss.Width(popup[0])
1336 if m.width > 0 && anchorCol+popupWidth > m.width {
1337 anchorCol = max(0, m.width-popupWidth)
1338 }
1339
1340 return overlayBlock(view, popup, anchorRow, anchorCol)
1341}
1342
1343// renderSpellPopupLines builds the styled, bordered suggestion box and
1344// returns its rendered lines. Each row carries an "abc" badge to mirror
1345// the language-server look familiar from VSCode.
1346func (m *Composer) renderSpellPopupLines() []string {
1347 if !m.spellShow || len(m.spellSuggestions) == 0 {
1348 return nil
1349 }
1350 maxWidth := 0
1351 for _, s := range m.spellSuggestions {
1352 if w := len(s); w > maxWidth {
1353 maxWidth = w
1354 }
1355 }
1356 rowWidth := maxWidth + 6 // " abc " badge + word + trailing space
1357
1358 iconStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
1359 rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
1360 selStyle := lipgloss.NewStyle().Background(lipgloss.Color("24")).Foreground(lipgloss.Color("231"))
1361
1362 var rows []string
1363 for i, s := range m.spellSuggestions {
1364 text := " " + iconStyle.Render("abc") + " " + s
1365 pad := rowWidth - lipgloss.Width(text)
1366 if pad < 0 {
1367 pad = 0
1368 }
1369 text += strings.Repeat(" ", pad)
1370 if i == m.spellSelected {
1371 rows = append(rows, selStyle.Render(text))
1372 } else {
1373 rows = append(rows, rowStyle.Render(text))
1374 }
1375 }
1376 box := suggestionBoxStyle.Render(strings.Join(rows, "\n"))
1377 return strings.Split(box, "\n")
1378}
1379
1380// SetAccounts sets the available accounts for sending.
1381func (m *Composer) SetAccounts(accounts []config.Account) {
1382 m.accounts = accounts
1383 if m.selectedAccountIdx >= len(accounts) {
1384 m.selectedAccountIdx = 0
1385 }
1386 m.updateSignature()
1387}
1388
1389// SetSelectedAccount sets the selected account by ID.
1390func (m *Composer) SetSelectedAccount(accountID string) {
1391 for i, acc := range m.accounts {
1392 if acc.ID == accountID {
1393 m.selectedAccountIdx = i
1394 m.updateSignature()
1395 return
1396 }
1397 }
1398}
1399
1400// GetSelectedAccountID returns the ID of the currently selected account.
1401func (m *Composer) GetSelectedAccountID() string {
1402 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
1403 return m.accounts[m.selectedAccountIdx].ID
1404 }
1405 return ""
1406}
1407
1408// GetDraftID returns the draft ID for this composer.
1409func (m *Composer) GetDraftID() string {
1410 return m.draftID
1411}
1412
1413// SetDraftID sets the draft ID (for loading existing drafts).
1414func (m *Composer) SetDraftID(id string) {
1415 m.draftID = id
1416}
1417
1418// GetTo returns the current To field value.
1419func (m *Composer) GetTo() string {
1420 return m.toInput.Value()
1421}
1422
1423// SetTo updates the To field with new content.
1424func (m *Composer) SetTo(to string) {
1425 m.toInput.SetValue(to)
1426}
1427
1428// GetCc returns the current Cc field value.
1429func (m *Composer) GetCc() string {
1430 return m.ccInput.Value()
1431}
1432
1433// SetCc updates the Cc field with new content.
1434func (m *Composer) SetCc(cc string) {
1435 m.ccInput.SetValue(cc)
1436}
1437
1438// GetBcc returns the current Bcc field value.
1439func (m *Composer) GetBcc() string {
1440 return m.bccInput.Value()
1441}
1442
1443// SetBcc updates the Bcc field with new content.
1444func (m *Composer) SetBcc(bcc string) {
1445 m.bccInput.SetValue(bcc)
1446}
1447
1448// GetSubject returns the current Subject field value.
1449func (m *Composer) GetSubject() string {
1450 return m.subjectInput.Value()
1451}
1452
1453// SetSubject updates the Subject field with new content.
1454func (m *Composer) SetSubject(subject string) {
1455 m.subjectInput.SetValue(subject)
1456}
1457
1458// GetBody returns the current Body field value.
1459func (m *Composer) GetBody() string {
1460 return m.bodyInput.Value()
1461}
1462
1463// SetBody updates the Body field with new content.
1464func (m *Composer) SetBody(body string) {
1465 m.bodyInput.SetValue(body)
1466}
1467
1468// GetAttachmentPaths returns the current attachment paths.
1469func (m *Composer) GetAttachmentPaths() []string {
1470 return m.attachmentPaths
1471}
1472
1473// GetSignature returns the current signature value.
1474func (m *Composer) GetSignature() string {
1475 return m.signatureInput.Value()
1476}
1477
1478// SetReplyContext sets the reply context for the draft.
1479func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
1480 m.inReplyTo = inReplyTo
1481 m.references = references
1482}
1483
1484// SetQuotedText sets the hidden quoted text that will be appended when sending.
1485func (m *Composer) SetQuotedText(text string) {
1486 m.quotedText = text
1487}
1488
1489// GetQuotedText returns the hidden quoted text.
1490func (m *Composer) GetQuotedText() string {
1491 return m.quotedText
1492}
1493
1494// GetInReplyTo returns the In-Reply-To header value.
1495func (m *Composer) GetInReplyTo() string {
1496 return m.inReplyTo
1497}
1498
1499// GetReferences returns the References header values.
1500func (m *Composer) GetReferences() []string {
1501 return m.references
1502}
1503
1504// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
1505func (m *Composer) SetPluginStatus(status string) {
1506 m.pluginStatus = status
1507}
1508
1509// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1510func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1511 m.pluginKeyBindings = bindings
1512}
1513
1514// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
1515func (m *Composer) ShowPluginPrompt(placeholder string) {
1516 m.pluginPromptPlaceholder = placeholder
1517 m.pluginPromptInput = textinput.New()
1518 m.pluginPromptInput.Placeholder = placeholder
1519 m.pluginPromptInput.Prompt = "> "
1520 m.pluginPromptInput.CharLimit = 256
1521 m.pluginPromptInput.Focus()
1522 m.showPluginPrompt = true
1523}
1524
1525// HidePluginPrompt deactivates the plugin prompt overlay.
1526func (m *Composer) HidePluginPrompt() {
1527 m.showPluginPrompt = false
1528}
1529
1530// ToDraft converts the composer state to a Draft for saving.
1531func (m *Composer) ToDraft() config.Draft {
1532 return config.Draft{
1533 ID: m.draftID,
1534 To: m.toInput.Value(),
1535 Cc: m.ccInput.Value(),
1536 Bcc: m.bccInput.Value(),
1537 Subject: m.subjectInput.Value(),
1538 Body: m.bodyInput.Value(),
1539 AttachmentPaths: m.attachmentPaths,
1540 AccountID: m.GetSelectedAccountID(),
1541 FromOverride: m.fromInput.Value(),
1542 InReplyTo: m.inReplyTo,
1543 References: m.references,
1544 QuotedText: m.quotedText,
1545 }
1546}
1547
1548// NewComposerFromDraft creates a composer from an existing draft.
1549func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
1550 m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
1551 m.ccInput.SetValue(draft.Cc)
1552 m.bccInput.SetValue(draft.Bcc)
1553 m.draftID = draft.ID
1554 m.attachmentPaths = draft.AttachmentPaths
1555 m.attachmentNames = make(map[string]string, len(m.attachmentPaths))
1556 for _, path := range m.attachmentPaths {
1557 m.attachmentNames[path] = formatAttachmentName(path)
1558 }
1559 m.clampAttachmentCursor()
1560 if m.isCatchAllAccount() && draft.FromOverride != "" {
1561 m.fromInput.SetValue(draft.FromOverride)
1562 }
1563 m.inReplyTo = draft.InReplyTo
1564 m.references = draft.References
1565 m.quotedText = draft.QuotedText
1566 return m
1567}