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