composer.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"charm.land/bubbles/v2/textarea"
  8	"charm.land/bubbles/v2/textinput"
  9	tea "charm.land/bubbletea/v2"
 10	"charm.land/lipgloss/v2"
 11	"github.com/floatpane/matcha/config"
 12	"github.com/google/uuid"
 13)
 14
 15var (
 16	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 17	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 18	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(0, 1)
 19)
 20
 21// Styles for the UI
 22var (
 23	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 24	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 25	noStyle             = lipgloss.NewStyle()
 26	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
 27	focusedButton       = focusedStyle.Copy().Render("[ Send ]")
 28	blurredButton       = blurredStyle.Copy().Render("[ Send ]")
 29	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 30	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240"))
 31	fromSelectorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 32)
 33
 34const (
 35	focusFrom = iota
 36	focusTo
 37	focusCc
 38	focusBcc
 39	focusSubject
 40	focusBody
 41	focusSignature
 42	focusAttachment
 43	focusSend
 44)
 45
 46// Composer model holds the state of the email composition UI.
 47type Composer struct {
 48	focusIndex     int
 49	toInput        textinput.Model
 50	ccInput        textinput.Model
 51	bccInput       textinput.Model
 52	subjectInput   textinput.Model
 53	bodyInput      textarea.Model
 54	signatureInput textarea.Model
 55	attachmentPath string
 56	width          int
 57	height         int
 58	confirmingExit bool
 59	hideTips       bool
 60
 61	// Multi-account support
 62	accounts           []config.Account
 63	selectedAccountIdx int
 64	showAccountPicker  bool
 65
 66	// Contact suggestions
 67	suggestions        []config.Contact
 68	selectedSuggestion int
 69	showSuggestions    bool
 70	lastToValue        string
 71
 72	// Draft persistence
 73	draftID string
 74
 75	// Reply context
 76	inReplyTo  string
 77	references []string
 78
 79	// Hidden quoted text (appended to body when sending, but not shown in editor)
 80	quotedText string
 81}
 82
 83// NewComposer initializes a new composer model.
 84func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 85	m := &Composer{
 86		draftID:  uuid.New().String(),
 87		hideTips: hideTips,
 88	}
 89
 90	m.toInput = textinput.New()
 91	m.toInput.Placeholder = "To"
 92	m.toInput.SetValue(to)
 93	m.toInput.Prompt = "> "
 94	m.toInput.CharLimit = 256
 95
 96	m.ccInput = textinput.New()
 97	m.ccInput.Placeholder = "Cc"
 98	m.ccInput.Prompt = "> "
 99	m.ccInput.CharLimit = 256
100
101	m.bccInput = textinput.New()
102	m.bccInput.Placeholder = "Bcc"
103	m.bccInput.Prompt = "> "
104	m.bccInput.CharLimit = 256
105
106	m.subjectInput = textinput.New()
107	m.subjectInput.Placeholder = "Subject"
108	m.subjectInput.SetValue(subject)
109	m.subjectInput.Prompt = "> "
110	m.subjectInput.CharLimit = 256
111
112	m.bodyInput = textarea.New()
113	m.bodyInput.Placeholder = "Body (Markdown supported)..."
114	m.bodyInput.SetValue(body)
115	m.bodyInput.Prompt = "> "
116	m.bodyInput.SetHeight(10)
117
118	m.signatureInput = textarea.New()
119	m.signatureInput.Placeholder = "Signature (optional)..."
120	m.signatureInput.Prompt = "> "
121	m.signatureInput.SetHeight(3)
122	// Load default signature
123	if sig, err := config.LoadSignature(); err == nil && sig != "" {
124		m.signatureInput.SetValue(sig)
125	}
126
127	// Start focus on To field (From is selectable but not a text input)
128	m.focusIndex = focusTo
129	m.toInput.Focus()
130
131	return m
132}
133
134// NewComposerWithAccounts initializes a composer with multiple account support.
135func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
136	m := NewComposer("", to, subject, body, hideTips)
137	m.accounts = accounts
138
139	// Find the selected account index
140	for i, acc := range accounts {
141		if acc.ID == selectedAccountID {
142			m.selectedAccountIdx = i
143			break
144		}
145	}
146
147	return m
148}
149
150// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
151func (m *Composer) ResetConfirmation() {
152	m.confirmingExit = false
153}
154
155func (m *Composer) Init() tea.Cmd {
156	return textinput.Blink
157}
158
159func (m *Composer) getFromAddress() string {
160	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
161		acc := m.accounts[m.selectedAccountIdx]
162		if acc.Name != "" {
163			return fmt.Sprintf("%s <%s>", acc.Name, acc.FetchEmail)
164		}
165		return acc.FetchEmail
166	}
167	return ""
168}
169
170func (m *Composer) getSelectedAccount() *config.Account {
171	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
172		return &m.accounts[m.selectedAccountIdx]
173	}
174	return nil
175}
176
177func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178	var cmds []tea.Cmd
179	var cmd tea.Cmd
180
181	switch msg := msg.(type) {
182	case tea.WindowSizeMsg:
183		m.width = msg.Width
184		m.height = msg.Height
185		inputWidth := msg.Width - 6
186		m.toInput.SetWidth(inputWidth)
187		m.ccInput.SetWidth(inputWidth)
188		m.bccInput.SetWidth(inputWidth)
189		m.subjectInput.SetWidth(inputWidth)
190		m.bodyInput.SetWidth(inputWidth)
191		m.signatureInput.SetWidth(inputWidth)
192
193	case FileSelectedMsg:
194		m.attachmentPath = msg.Path
195		return m, nil
196
197	case tea.KeyPressMsg:
198		// Handle contact suggestions mode
199		if m.showSuggestions && len(m.suggestions) > 0 {
200			switch msg.String() {
201			case "up", "ctrl+p":
202				if m.selectedSuggestion > 0 {
203					m.selectedSuggestion--
204				}
205				return m, nil
206			case "down", "ctrl+n":
207				if m.selectedSuggestion < len(m.suggestions)-1 {
208					m.selectedSuggestion++
209				}
210				return m, nil
211			case "tab", "enter":
212				// Select the suggestion
213				selected := m.suggestions[m.selectedSuggestion]
214
215				var newEmail string
216				if strings.Contains(selected.Email, ",") {
217					// It's a mailing list: insert just the addresses to maintain valid email formatting
218					newEmail = selected.Email
219				} else if selected.Name != "" && selected.Name != selected.Email {
220					newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
221				} else {
222					newEmail = selected.Email
223				}
224
225				parts := strings.Split(m.toInput.Value(), ",")
226				if len(parts) > 0 {
227					if len(parts) == 1 {
228						parts[0] = newEmail
229					} else {
230						parts[len(parts)-1] = " " + newEmail
231					}
232				} else {
233					parts = []string{newEmail}
234				}
235
236				finalValue := strings.Join(parts, ",")
237				if !strings.HasSuffix(finalValue, ", ") {
238					finalValue += ", "
239				}
240
241				m.toInput.SetValue(finalValue)
242				m.toInput.SetCursor(len(finalValue))
243				m.lastToValue = m.toInput.Value()
244				m.showSuggestions = false
245				m.suggestions = nil
246				return m, nil
247			case "esc":
248				m.showSuggestions = false
249				m.suggestions = nil
250				return m, nil
251			}
252			// For shift+tab, close suggestions and let it fall through to normal handling
253			if msg.String() == "shift+tab" {
254				m.showSuggestions = false
255				m.suggestions = nil
256			}
257		}
258
259		// Handle account picker mode
260		if m.showAccountPicker {
261			switch msg.String() {
262			case "up", "k":
263				if m.selectedAccountIdx > 0 {
264					m.selectedAccountIdx--
265				}
266			case "down", "j":
267				if m.selectedAccountIdx < len(m.accounts)-1 {
268					m.selectedAccountIdx++
269				}
270			case "enter":
271				m.showAccountPicker = false
272			case "esc":
273				m.showAccountPicker = false
274			}
275			return m, nil
276		}
277
278		if m.confirmingExit {
279			switch msg.String() {
280			case "y", "Y":
281				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
282			case "n", "N", "esc":
283				m.confirmingExit = false
284				return m, nil
285			default:
286				return m, nil
287			}
288		}
289
290		switch msg.String() {
291		case "ctrl+c":
292			return m, tea.Quit
293		case "esc":
294			m.confirmingExit = true
295			return m, nil
296
297		case "tab", "shift+tab":
298			if msg.String() == "shift+tab" {
299				m.focusIndex--
300			} else {
301				m.focusIndex++
302			}
303
304			maxFocus := focusSend
305			minFocus := focusFrom
306			// Skip From field if only one account (nothing to switch)
307			if len(m.accounts) <= 1 {
308				minFocus = focusTo
309			}
310
311			if m.focusIndex > maxFocus {
312				m.focusIndex = minFocus
313			} else if m.focusIndex < minFocus {
314				m.focusIndex = maxFocus
315			}
316
317			m.toInput.Blur()
318			m.ccInput.Blur()
319			m.bccInput.Blur()
320			m.subjectInput.Blur()
321			m.bodyInput.Blur()
322			m.signatureInput.Blur()
323
324			switch m.focusIndex {
325			case focusTo:
326				cmds = append(cmds, m.toInput.Focus())
327			case focusCc:
328				cmds = append(cmds, m.ccInput.Focus())
329			case focusBcc:
330				cmds = append(cmds, m.bccInput.Focus())
331			case focusSubject:
332				cmds = append(cmds, m.subjectInput.Focus())
333			case focusBody:
334				cmds = append(cmds, m.bodyInput.Focus())
335			case focusSignature:
336				cmds = append(cmds, m.signatureInput.Focus())
337			}
338			return m, tea.Batch(cmds...)
339
340		case "enter":
341			switch m.focusIndex {
342			case focusFrom:
343				if len(m.accounts) > 1 {
344					m.showAccountPicker = true
345				}
346				return m, nil
347			case focusAttachment:
348				return m, func() tea.Msg { return GoToFilePickerMsg{} }
349			case focusSend:
350				acc := m.getSelectedAccount()
351				accountID := ""
352				if acc != nil {
353					accountID = acc.ID
354				}
355				return m, func() tea.Msg {
356					return SendEmailMsg{
357						To:             m.toInput.Value(),
358						Cc:             m.ccInput.Value(),
359						Bcc:            m.bccInput.Value(),
360						Subject:        m.subjectInput.Value(),
361						Body:           m.bodyInput.Value(),
362						AttachmentPath: m.attachmentPath,
363						AccountID:      accountID,
364						QuotedText:     m.quotedText,
365						InReplyTo:      m.inReplyTo,
366						References:     m.references,
367						Signature:      m.signatureInput.Value(),
368					}
369				}
370			}
371		}
372	}
373
374	switch m.focusIndex {
375	case focusTo:
376		m.toInput, cmd = m.toInput.Update(msg)
377		cmds = append(cmds, cmd)
378
379		// Check if To field value changed and update suggestions
380		currentValue := m.toInput.Value()
381		if currentValue != m.lastToValue {
382			m.lastToValue = currentValue
383
384			// Extract the last comma-separated part for searching
385			parts := strings.Split(currentValue, ",")
386			lastPart := strings.TrimSpace(parts[len(parts)-1])
387
388			if len(lastPart) >= 2 {
389				m.suggestions = config.SearchContacts(lastPart)
390				m.showSuggestions = len(m.suggestions) > 0
391				m.selectedSuggestion = 0
392			} else {
393				m.showSuggestions = false
394				m.suggestions = nil
395			}
396		}
397	case focusCc:
398		m.ccInput, cmd = m.ccInput.Update(msg)
399		cmds = append(cmds, cmd)
400	case focusBcc:
401		m.bccInput, cmd = m.bccInput.Update(msg)
402		cmds = append(cmds, cmd)
403	case focusSubject:
404		m.subjectInput, cmd = m.subjectInput.Update(msg)
405		cmds = append(cmds, cmd)
406	case focusBody:
407		m.bodyInput, cmd = m.bodyInput.Update(msg)
408		cmds = append(cmds, cmd)
409	case focusSignature:
410		m.signatureInput, cmd = m.signatureInput.Update(msg)
411		cmds = append(cmds, cmd)
412	}
413
414	return m, tea.Batch(cmds...)
415}
416
417func (m *Composer) View() tea.View {
418	var composerView strings.Builder
419	var button string
420
421	if m.focusIndex == focusSend {
422		button = focusedButton
423	} else {
424		button = blurredButton
425	}
426
427	// From field with account selector
428	fromAddr := m.getFromAddress()
429	var fromField string
430	if len(m.accounts) > 1 {
431		if m.focusIndex == focusFrom {
432			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
433		} else {
434			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s [switchable]", fromAddr))
435		}
436	} else if fromAddr != "" {
437		fromField = "  From: " + emailRecipientStyle.Render(fromAddr)
438	} else {
439		fromField = blurredStyle.Render("  From: (no account configured)")
440	}
441
442	var attachmentField string
443	attachmentText := "None (Press Enter to select)"
444	if m.attachmentPath != "" {
445		attachmentText = m.attachmentPath
446	}
447
448	if m.focusIndex == focusAttachment {
449		attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
450	} else {
451		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
452	}
453
454	// Build To field with suggestions
455	toFieldView := m.toInput.View()
456	if m.showSuggestions && len(m.suggestions) > 0 {
457		var suggestionsBuilder strings.Builder
458		for i, s := range m.suggestions {
459			display := s.Email
460			if s.Name != "" && s.Name != s.Email {
461				display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
462			}
463			if i == m.selectedSuggestion {
464				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
465			} else {
466				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
467			}
468		}
469		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
470	}
471
472	// Signature field label
473	var signatureLabel string
474	if m.focusIndex == focusSignature {
475		signatureLabel = focusedStyle.Render("Signature:")
476	} else {
477		signatureLabel = blurredStyle.Render("Signature:")
478	}
479
480	tip := ""
481	switch m.focusIndex {
482	case focusFrom:
483		tip = "Select the account to send from."
484	case focusTo:
485		tip = "Enter recipient email addresses."
486	case focusCc:
487		tip = "Carbon copy recipients."
488	case focusBcc:
489		tip = "Blind carbon copy recipients."
490	case focusSubject:
491		tip = "The subject line of your email."
492	case focusBody:
493		tip = "The main content of your email. Markdown and HTML are supported."
494	case focusSignature:
495		tip = "Your email signature. This will be appended to the end of the email."
496	case focusAttachment:
497		tip = "Press Enter to select a file to attach to this email."
498	case focusSend:
499		tip = "Press Enter to send the email."
500	}
501
502	composerViewElements := []string{
503		"Compose New Email",
504		fromField,
505		toFieldView,
506		m.ccInput.View(),
507		m.bccInput.View(),
508		m.subjectInput.View(),
509		m.bodyInput.View(),
510		signatureLabel,
511		m.signatureInput.View(),
512		attachmentStyle.Render(attachmentField),
513		button,
514		"",
515	}
516
517	if !m.hideTips && tip != "" {
518		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
519	}
520
521	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
522	helpView := helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit")
523
524	if m.height > 0 {
525		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
526		gap := m.height - currentHeight
527		if gap >= 0 {
528			mainContent += strings.Repeat("\n", gap+1)
529		} else {
530			mainContent += "\n"
531		}
532	} else {
533		mainContent += "\n\n"
534	}
535
536	composerView.WriteString(mainContent)
537	composerView.WriteString(helpView)
538
539	// Account picker overlay
540	if m.showAccountPicker {
541		var accountList strings.Builder
542		accountList.WriteString("Select Account:\n\n")
543		for i, acc := range m.accounts {
544			display := acc.FetchEmail
545			if acc.Name != "" {
546				display = fmt.Sprintf("%s (%s)", acc.Name, acc.FetchEmail)
547			}
548			if i == m.selectedAccountIdx {
549				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
550			} else {
551				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
552			}
553			accountList.WriteString("\n")
554		}
555		accountList.WriteString("\n")
556		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
557
558		dialog := DialogBoxStyle.Render(accountList.String())
559		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
560	}
561
562	if m.confirmingExit {
563		dialog := DialogBoxStyle.Render(
564			lipgloss.JoinVertical(lipgloss.Center,
565				"Discard draft?",
566				HelpStyle.Render("\n(y/n)"),
567			),
568		)
569		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
570	}
571
572	return tea.NewView(composerView.String())
573}
574
575// SetAccounts sets the available accounts for sending.
576func (m *Composer) SetAccounts(accounts []config.Account) {
577	m.accounts = accounts
578	if m.selectedAccountIdx >= len(accounts) {
579		m.selectedAccountIdx = 0
580	}
581}
582
583// SetSelectedAccount sets the selected account by ID.
584func (m *Composer) SetSelectedAccount(accountID string) {
585	for i, acc := range m.accounts {
586		if acc.ID == accountID {
587			m.selectedAccountIdx = i
588			return
589		}
590	}
591}
592
593// GetSelectedAccountID returns the ID of the currently selected account.
594func (m *Composer) GetSelectedAccountID() string {
595	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
596		return m.accounts[m.selectedAccountIdx].ID
597	}
598	return ""
599}
600
601// GetDraftID returns the draft ID for this composer.
602func (m *Composer) GetDraftID() string {
603	return m.draftID
604}
605
606// SetDraftID sets the draft ID (for loading existing drafts).
607func (m *Composer) SetDraftID(id string) {
608	m.draftID = id
609}
610
611// GetTo returns the current To field value.
612func (m *Composer) GetTo() string {
613	return m.toInput.Value()
614}
615
616// GetSubject returns the current Subject field value.
617func (m *Composer) GetSubject() string {
618	return m.subjectInput.Value()
619}
620
621// GetBody returns the current Body field value.
622func (m *Composer) GetBody() string {
623	return m.bodyInput.Value()
624}
625
626// GetAttachmentPath returns the current attachment path.
627func (m *Composer) GetAttachmentPath() string {
628	return m.attachmentPath
629}
630
631// GetSignature returns the current signature value.
632func (m *Composer) GetSignature() string {
633	return m.signatureInput.Value()
634}
635
636// SetReplyContext sets the reply context for the draft.
637func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
638	m.inReplyTo = inReplyTo
639	m.references = references
640}
641
642// SetQuotedText sets the hidden quoted text that will be appended when sending.
643func (m *Composer) SetQuotedText(text string) {
644	m.quotedText = text
645}
646
647// GetQuotedText returns the hidden quoted text.
648func (m *Composer) GetQuotedText() string {
649	return m.quotedText
650}
651
652// GetInReplyTo returns the In-Reply-To header value.
653func (m *Composer) GetInReplyTo() string {
654	return m.inReplyTo
655}
656
657// GetReferences returns the References header values.
658func (m *Composer) GetReferences() []string {
659	return m.references
660}
661
662// ToDraft converts the composer state to a Draft for saving.
663func (m *Composer) ToDraft() config.Draft {
664	return config.Draft{
665		ID:             m.draftID,
666		To:             m.toInput.Value(),
667		Cc:             m.ccInput.Value(),
668		Bcc:            m.bccInput.Value(),
669		Subject:        m.subjectInput.Value(),
670		Body:           m.bodyInput.Value(),
671		AttachmentPath: m.attachmentPath,
672		AccountID:      m.GetSelectedAccountID(),
673		InReplyTo:      m.inReplyTo,
674		References:     m.references,
675		QuotedText:     m.quotedText,
676	}
677}
678
679// NewComposerFromDraft creates a composer from an existing draft.
680func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
681	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
682	m.ccInput.SetValue(draft.Cc)
683	m.bccInput.SetValue(draft.Bcc)
684	m.draftID = draft.ID
685	m.attachmentPath = draft.AttachmentPath
686	m.inReplyTo = draft.InReplyTo
687	m.references = draft.References
688	m.quotedText = draft.QuotedText
689	return m
690}