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