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