composer.go

  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}