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 shift+tab, close suggestions and let it fall through to normal handling
314 if msg.String() == "shift+tab" {
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 switch msg.String() {
370 case "ctrl+c":
371 return m, tea.Quit
372 case "ctrl+e":
373 return m, func() tea.Msg { return OpenEditorMsg{} }
374 case "esc":
375 m.confirmingExit = true
376 return m, nil
377
378 case "tab", "shift+tab":
379 if msg.String() == "shift+tab" {
380 m.focusIndex--
381 } else {
382 m.focusIndex++
383 }
384
385 maxFocus := focusSend
386 minFocus := focusFrom
387 // Skip From field if only one account (nothing to switch)
388 if len(m.accounts) <= 1 {
389 minFocus = focusTo
390 }
391
392 if m.focusIndex > maxFocus {
393 m.focusIndex = minFocus
394 } else if m.focusIndex < minFocus {
395 m.focusIndex = maxFocus
396 }
397
398 m.toInput.Blur()
399 m.ccInput.Blur()
400 m.bccInput.Blur()
401 m.subjectInput.Blur()
402 m.bodyInput.Blur()
403 m.signatureInput.Blur()
404
405 switch m.focusIndex {
406 case focusTo:
407 cmds = append(cmds, m.toInput.Focus())
408 case focusCc:
409 cmds = append(cmds, m.ccInput.Focus())
410 case focusBcc:
411 cmds = append(cmds, m.bccInput.Focus())
412 case focusSubject:
413 cmds = append(cmds, m.subjectInput.Focus())
414 case focusBody:
415 cmds = append(cmds, m.bodyInput.Focus())
416 case focusSignature:
417 cmds = append(cmds, m.signatureInput.Focus())
418 }
419 return m, tea.Batch(cmds...)
420
421 case "backspace", "delete", "d":
422 if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
423 m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
424 return m, nil
425 }
426
427 case "enter", " ":
428 switch m.focusIndex {
429 case focusFrom:
430 if len(m.accounts) > 1 && msg.String() == "enter" {
431 m.showAccountPicker = true
432 }
433 return m, nil
434 case focusAttachment:
435 if msg.String() == "enter" {
436 return m, func() tea.Msg { return GoToFilePickerMsg{} }
437 }
438 case focusEncryptSMIME:
439 if msg.String() == "enter" || msg.String() == " " {
440 m.encryptSMIME = !m.encryptSMIME
441 }
442 return m, nil
443
444 case focusSend:
445 if msg.String() == "enter" {
446 acc := m.getSelectedAccount()
447 accountID := ""
448 if acc != nil {
449 accountID = acc.ID
450 }
451 return m, func() tea.Msg {
452 return SendEmailMsg{
453 To: m.toInput.Value(),
454 Cc: m.ccInput.Value(),
455 Bcc: m.bccInput.Value(),
456 Subject: m.subjectInput.Value(),
457 Body: m.bodyInput.Value(),
458 AttachmentPaths: m.attachmentPaths,
459 AccountID: accountID,
460 QuotedText: m.quotedText,
461 InReplyTo: m.inReplyTo,
462 References: m.references,
463 Signature: m.signatureInput.Value(),
464 SignSMIME: acc != nil && acc.SMIMESignByDefault,
465 EncryptSMIME: m.encryptSMIME,
466 SignPGP: acc != nil && acc.PGPSignByDefault,
467 }
468 }
469 }
470 }
471 }
472 }
473
474 switch m.focusIndex {
475 case focusTo:
476 m.toInput, cmd = m.toInput.Update(msg)
477 cmds = append(cmds, cmd)
478
479 // Check if To field value changed and update suggestions
480 currentValue := m.toInput.Value()
481 if currentValue != m.lastToValue {
482 m.lastToValue = currentValue
483
484 // Extract the last comma-separated part for searching
485 parts := strings.Split(currentValue, ",")
486 lastPart := strings.TrimSpace(parts[len(parts)-1])
487
488 if len(lastPart) >= 2 {
489 m.suggestions = config.SearchContacts(lastPart)
490 m.showSuggestions = len(m.suggestions) > 0
491 m.selectedSuggestion = 0
492 } else {
493 m.showSuggestions = false
494 m.suggestions = nil
495 }
496 }
497 case focusCc:
498 m.ccInput, cmd = m.ccInput.Update(msg)
499 cmds = append(cmds, cmd)
500 case focusBcc:
501 m.bccInput, cmd = m.bccInput.Update(msg)
502 cmds = append(cmds, cmd)
503 case focusSubject:
504 m.subjectInput, cmd = m.subjectInput.Update(msg)
505 cmds = append(cmds, cmd)
506 case focusBody:
507 m.bodyInput, cmd = m.bodyInput.Update(msg)
508 cmds = append(cmds, cmd)
509 case focusSignature:
510 m.signatureInput, cmd = m.signatureInput.Update(msg)
511 cmds = append(cmds, cmd)
512 }
513
514 return m, tea.Batch(cmds...)
515}
516
517func (m *Composer) View() tea.View {
518 var composerView strings.Builder
519 var button string
520
521 if m.focusIndex == focusSend {
522 button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
523 } else {
524 button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]")
525 }
526
527 // From field with account selector
528 fromAddr := m.getFromAddress()
529 var fromField string
530 if len(m.accounts) > 1 {
531 if m.focusIndex == focusFrom {
532 fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
533 } else {
534 fromField = blurredStyle.Render(fmt.Sprintf(" %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
535 }
536 } else if fromAddr != "" {
537 fromField = " " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
538 } else {
539 fromField = blurredStyle.Render(fmt.Sprintf(" %s (%s)", t("composer.from"), t("composer.no_account")))
540 }
541
542 var attachmentField string
543 if len(m.attachmentPaths) == 0 {
544 attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
545 if m.focusIndex == focusAttachment {
546 attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
547 } else {
548 attachmentField = blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.attachments"), attachmentText))
549 }
550 } else {
551 var names []string
552 for _, p := range m.attachmentPaths {
553 names = append(names, filepath.Base(p))
554 }
555 attachmentText := strings.Join(names, ", ")
556 if m.focusIndex == focusAttachment {
557 attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
558 } else {
559 attachmentField = blurredStyle.Render(fmt.Sprintf(" %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
560 }
561 }
562
563 encToggle := "[ ]"
564 if m.encryptSMIME {
565 encToggle = "[x]"
566 }
567 encField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.encrypt_smime"), encToggle))
568 if m.focusIndex == focusEncryptSMIME {
569 encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
570 }
571
572 // Build To field with suggestions
573 toFieldView := m.toInput.View()
574 if m.showSuggestions && len(m.suggestions) > 0 {
575 var suggestionsBuilder strings.Builder
576 for i, s := range m.suggestions {
577 display := s.Email
578 if s.Name != "" && s.Name != s.Email {
579 display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
580 }
581 if i == m.selectedSuggestion {
582 suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
583 } else {
584 suggestionsBuilder.WriteString(suggestionStyle.Render(" "+display) + "\n")
585 }
586 }
587 toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
588 }
589
590 // Signature field label
591 var signatureLabel string
592 if m.focusIndex == focusSignature {
593 signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
594 } else {
595 signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
596 }
597
598 tip := ""
599 switch m.focusIndex {
600 case focusFrom:
601 tip = "Select the account to send from."
602 case focusTo:
603 tip = "Enter recipient email addresses."
604 case focusCc:
605 tip = "Carbon copy recipients."
606 case focusBcc:
607 tip = "Blind carbon copy recipients."
608 case focusSubject:
609 tip = "The subject line of your email."
610 case focusBody:
611 tip = "The main content of your email. Markdown and HTML are supported."
612 case focusSignature:
613 tip = "Your email signature. This will be appended to the end of the email."
614 case focusAttachment:
615 tip = "Enter: add file • backspace/d: remove last attachment"
616 case focusEncryptSMIME:
617 tip = "Press Space or Enter to toggle S/MIME encryption on or off."
618 case focusSend:
619 tip = "Press Enter to send the email."
620 }
621
622 composerViewElements := []string{
623 t("composer.title"),
624 fromField,
625 toFieldView,
626 m.ccInput.View(),
627 m.bccInput.View(),
628 m.subjectInput.View(),
629 m.bodyInput.View(),
630 signatureLabel,
631 m.signatureInput.View(),
632 attachmentStyle.Render(attachmentField),
633 smimeToggleStyle.Render(encField),
634 button,
635 "",
636 }
637
638 if !m.hideTips && tip != "" {
639 composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
640 }
641
642 mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
643 helpText := t("composer.help")
644 for _, pk := range m.pluginKeyBindings {
645 helpText += " • " + pk.Key + ": " + pk.Description
646 }
647 if m.pluginStatus != "" {
648 helpText += " • " + m.pluginStatus
649 }
650 helpView := helpStyle.Render(helpText)
651
652 if m.height > 0 {
653 currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
654 gap := m.height - currentHeight
655 if gap >= 0 {
656 mainContent += strings.Repeat("\n", gap+1)
657 } else {
658 mainContent += "\n"
659 }
660 } else {
661 mainContent += "\n\n"
662 }
663
664 composerView.WriteString(mainContent)
665 composerView.WriteString(helpView)
666
667 // Plugin prompt overlay
668 if m.showPluginPrompt {
669 dialog := DialogBoxStyle.Render(
670 lipgloss.JoinVertical(lipgloss.Left,
671 m.pluginPromptPlaceholder,
672 "",
673 m.pluginPromptInput.View(),
674 "",
675 HelpStyle.Render("enter: submit • esc: cancel"),
676 ),
677 )
678 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
679 }
680
681 // Account picker overlay
682 if m.showAccountPicker {
683 var accountList strings.Builder
684 accountList.WriteString("Select Account:\n\n")
685 for i, acc := range m.accounts {
686 display := acc.GetSendAsEmail()
687 if acc.Name != "" {
688 display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
689 }
690 if i == m.selectedAccountIdx {
691 accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
692 } else {
693 accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display)))
694 }
695 accountList.WriteString("\n")
696 }
697 accountList.WriteString("\n")
698 accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
699
700 dialog := DialogBoxStyle.Render(accountList.String())
701 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
702 }
703
704 if m.confirmingExit {
705 dialog := DialogBoxStyle.Render(
706 lipgloss.JoinVertical(lipgloss.Center,
707 t("composer.exit_confirm"),
708 HelpStyle.Render("\n(y/n)"),
709 ),
710 )
711 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
712 }
713
714 return tea.NewView(composerView.String())
715}
716
717// SetAccounts sets the available accounts for sending.
718func (m *Composer) SetAccounts(accounts []config.Account) {
719 m.accounts = accounts
720 if m.selectedAccountIdx >= len(accounts) {
721 m.selectedAccountIdx = 0
722 }
723 m.updateSignature()
724}
725
726// SetSelectedAccount sets the selected account by ID.
727func (m *Composer) SetSelectedAccount(accountID string) {
728 for i, acc := range m.accounts {
729 if acc.ID == accountID {
730 m.selectedAccountIdx = i
731 m.updateSignature()
732 return
733 }
734 }
735}
736
737// GetSelectedAccountID returns the ID of the currently selected account.
738func (m *Composer) GetSelectedAccountID() string {
739 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
740 return m.accounts[m.selectedAccountIdx].ID
741 }
742 return ""
743}
744
745// GetDraftID returns the draft ID for this composer.
746func (m *Composer) GetDraftID() string {
747 return m.draftID
748}
749
750// SetDraftID sets the draft ID (for loading existing drafts).
751func (m *Composer) SetDraftID(id string) {
752 m.draftID = id
753}
754
755// GetTo returns the current To field value.
756func (m *Composer) GetTo() string {
757 return m.toInput.Value()
758}
759
760// SetTo updates the To field with new content.
761func (m *Composer) SetTo(to string) {
762 m.toInput.SetValue(to)
763}
764
765// GetCc returns the current Cc field value.
766func (m *Composer) GetCc() string {
767 return m.ccInput.Value()
768}
769
770// SetCc updates the Cc field with new content.
771func (m *Composer) SetCc(cc string) {
772 m.ccInput.SetValue(cc)
773}
774
775// GetBcc returns the current Bcc field value.
776func (m *Composer) GetBcc() string {
777 return m.bccInput.Value()
778}
779
780// SetBcc updates the Bcc field with new content.
781func (m *Composer) SetBcc(bcc string) {
782 m.bccInput.SetValue(bcc)
783}
784
785// GetSubject returns the current Subject field value.
786func (m *Composer) GetSubject() string {
787 return m.subjectInput.Value()
788}
789
790// SetSubject updates the Subject field with new content.
791func (m *Composer) SetSubject(subject string) {
792 m.subjectInput.SetValue(subject)
793}
794
795// GetBody returns the current Body field value.
796func (m *Composer) GetBody() string {
797 return m.bodyInput.Value()
798}
799
800// SetBody updates the Body field with new content.
801func (m *Composer) SetBody(body string) {
802 m.bodyInput.SetValue(body)
803}
804
805// GetAttachmentPaths returns the current attachment paths.
806func (m *Composer) GetAttachmentPaths() []string {
807 return m.attachmentPaths
808}
809
810// GetSignature returns the current signature value.
811func (m *Composer) GetSignature() string {
812 return m.signatureInput.Value()
813}
814
815// SetReplyContext sets the reply context for the draft.
816func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
817 m.inReplyTo = inReplyTo
818 m.references = references
819}
820
821// SetQuotedText sets the hidden quoted text that will be appended when sending.
822func (m *Composer) SetQuotedText(text string) {
823 m.quotedText = text
824}
825
826// GetQuotedText returns the hidden quoted text.
827func (m *Composer) GetQuotedText() string {
828 return m.quotedText
829}
830
831// GetInReplyTo returns the In-Reply-To header value.
832func (m *Composer) GetInReplyTo() string {
833 return m.inReplyTo
834}
835
836// GetReferences returns the References header values.
837func (m *Composer) GetReferences() []string {
838 return m.references
839}
840
841// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
842func (m *Composer) SetPluginStatus(status string) {
843 m.pluginStatus = status
844}
845
846// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
847func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
848 m.pluginKeyBindings = bindings
849}
850
851// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
852func (m *Composer) ShowPluginPrompt(placeholder string) {
853 m.pluginPromptPlaceholder = placeholder
854 m.pluginPromptInput = textinput.New()
855 m.pluginPromptInput.Placeholder = placeholder
856 m.pluginPromptInput.Prompt = "> "
857 m.pluginPromptInput.CharLimit = 256
858 m.pluginPromptInput.Focus()
859 m.showPluginPrompt = true
860}
861
862// HidePluginPrompt deactivates the plugin prompt overlay.
863func (m *Composer) HidePluginPrompt() {
864 m.showPluginPrompt = false
865}
866
867// ToDraft converts the composer state to a Draft for saving.
868func (m *Composer) ToDraft() config.Draft {
869 return config.Draft{
870 ID: m.draftID,
871 To: m.toInput.Value(),
872 Cc: m.ccInput.Value(),
873 Bcc: m.bccInput.Value(),
874 Subject: m.subjectInput.Value(),
875 Body: m.bodyInput.Value(),
876 AttachmentPaths: m.attachmentPaths,
877 AccountID: m.GetSelectedAccountID(),
878 InReplyTo: m.inReplyTo,
879 References: m.references,
880 QuotedText: m.quotedText,
881 }
882}
883
884// NewComposerFromDraft creates a composer from an existing draft.
885func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
886 m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
887 m.ccInput.SetValue(draft.Cc)
888 m.bccInput.SetValue(draft.Bcc)
889 m.draftID = draft.ID
890 m.attachmentPaths = draft.AttachmentPaths
891 m.inReplyTo = draft.InReplyTo
892 m.references = draft.References
893 m.quotedText = draft.QuotedText
894 return m
895}