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