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
 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}