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