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