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				if selected.Name != "" && selected.Name != selected.Email {
215					m.toInput.SetValue(fmt.Sprintf("%s <%s>", selected.Name, selected.Email))
216				} else {
217					m.toInput.SetValue(selected.Email)
218				}
219				m.lastToValue = m.toInput.Value()
220				m.showSuggestions = false
221				m.suggestions = nil
222				return m, nil
223			case "esc":
224				m.showSuggestions = false
225				m.suggestions = nil
226				return m, nil
227			}
228			// For shift+tab, close suggestions and let it fall through to normal handling
229			if msg.String() == "shift+tab" {
230				m.showSuggestions = false
231				m.suggestions = nil
232			}
233		}
234
235		// Handle account picker mode
236		if m.showAccountPicker {
237			switch msg.String() {
238			case "up", "k":
239				if m.selectedAccountIdx > 0 {
240					m.selectedAccountIdx--
241				}
242			case "down", "j":
243				if m.selectedAccountIdx < len(m.accounts)-1 {
244					m.selectedAccountIdx++
245				}
246			case "enter":
247				m.showAccountPicker = false
248			case "esc":
249				m.showAccountPicker = false
250			}
251			return m, nil
252		}
253
254		if m.confirmingExit {
255			switch msg.String() {
256			case "y", "Y":
257				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
258			case "n", "N", "esc":
259				m.confirmingExit = false
260				return m, nil
261			default:
262				return m, nil
263			}
264		}
265
266		switch msg.String() {
267		case "ctrl+c":
268			return m, tea.Quit
269		case "esc":
270			m.confirmingExit = true
271			return m, nil
272
273		case "tab", "shift+tab":
274			if msg.String() == "shift+tab" {
275				m.focusIndex--
276			} else {
277				m.focusIndex++
278			}
279
280			maxFocus := focusSend
281			minFocus := focusFrom
282			// Skip From field if only one account (nothing to switch)
283			if len(m.accounts) <= 1 {
284				minFocus = focusTo
285			}
286
287			if m.focusIndex > maxFocus {
288				m.focusIndex = minFocus
289			} else if m.focusIndex < minFocus {
290				m.focusIndex = maxFocus
291			}
292
293			m.toInput.Blur()
294			m.ccInput.Blur()
295			m.bccInput.Blur()
296			m.subjectInput.Blur()
297			m.bodyInput.Blur()
298			m.signatureInput.Blur()
299
300			switch m.focusIndex {
301			case focusTo:
302				cmds = append(cmds, m.toInput.Focus())
303			case focusCc:
304				cmds = append(cmds, m.ccInput.Focus())
305			case focusBcc:
306				cmds = append(cmds, m.bccInput.Focus())
307			case focusSubject:
308				cmds = append(cmds, m.subjectInput.Focus())
309			case focusBody:
310				cmds = append(cmds, m.bodyInput.Focus())
311			case focusSignature:
312				cmds = append(cmds, m.signatureInput.Focus())
313			}
314			return m, tea.Batch(cmds...)
315
316		case "enter":
317			switch m.focusIndex {
318			case focusFrom:
319				if len(m.accounts) > 1 {
320					m.showAccountPicker = true
321				}
322				return m, nil
323			case focusAttachment:
324				return m, func() tea.Msg { return GoToFilePickerMsg{} }
325			case focusSend:
326				acc := m.getSelectedAccount()
327				accountID := ""
328				if acc != nil {
329					accountID = acc.ID
330				}
331				return m, func() tea.Msg {
332					return SendEmailMsg{
333						To:             m.toInput.Value(),
334						Cc:             m.ccInput.Value(),
335						Bcc:            m.bccInput.Value(),
336						Subject:        m.subjectInput.Value(),
337						Body:           m.bodyInput.Value(),
338						AttachmentPath: m.attachmentPath,
339						AccountID:      accountID,
340						QuotedText:     m.quotedText,
341						InReplyTo:      m.inReplyTo,
342						References:     m.references,
343						Signature:      m.signatureInput.Value(),
344					}
345				}
346			}
347		}
348	}
349
350	switch m.focusIndex {
351	case focusTo:
352		m.toInput, cmd = m.toInput.Update(msg)
353		cmds = append(cmds, cmd)
354
355		// Check if To field value changed and update suggestions
356		currentValue := m.toInput.Value()
357		if currentValue != m.lastToValue {
358			m.lastToValue = currentValue
359			if len(currentValue) >= 2 {
360				m.suggestions = config.SearchContacts(currentValue)
361				m.showSuggestions = len(m.suggestions) > 0
362				m.selectedSuggestion = 0
363			} else {
364				m.showSuggestions = false
365				m.suggestions = nil
366			}
367		}
368	case focusCc:
369		m.ccInput, cmd = m.ccInput.Update(msg)
370		cmds = append(cmds, cmd)
371	case focusBcc:
372		m.bccInput, cmd = m.bccInput.Update(msg)
373		cmds = append(cmds, cmd)
374	case focusSubject:
375		m.subjectInput, cmd = m.subjectInput.Update(msg)
376		cmds = append(cmds, cmd)
377	case focusBody:
378		m.bodyInput, cmd = m.bodyInput.Update(msg)
379		cmds = append(cmds, cmd)
380	case focusSignature:
381		m.signatureInput, cmd = m.signatureInput.Update(msg)
382		cmds = append(cmds, cmd)
383	}
384
385	return m, tea.Batch(cmds...)
386}
387
388func (m *Composer) View() tea.View {
389	var composerView strings.Builder
390	var button string
391
392	if m.focusIndex == focusSend {
393		button = focusedButton
394	} else {
395		button = blurredButton
396	}
397
398	// From field with account selector
399	fromAddr := m.getFromAddress()
400	var fromField string
401	if len(m.accounts) > 1 {
402		if m.focusIndex == focusFrom {
403			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
404		} else {
405			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s [switchable]", fromAddr))
406		}
407	} else if fromAddr != "" {
408		fromField = "  From: " + emailRecipientStyle.Render(fromAddr)
409	} else {
410		fromField = blurredStyle.Render("  From: (no account configured)")
411	}
412
413	var attachmentField string
414	attachmentText := "None (Press Enter to select)"
415	if m.attachmentPath != "" {
416		attachmentText = m.attachmentPath
417	}
418
419	if m.focusIndex == focusAttachment {
420		attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
421	} else {
422		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
423	}
424
425	// Build To field with suggestions
426	toFieldView := m.toInput.View()
427	if m.showSuggestions && len(m.suggestions) > 0 {
428		var suggestionsBuilder strings.Builder
429		for i, s := range m.suggestions {
430			display := s.Email
431			if s.Name != "" && s.Name != s.Email {
432				display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
433			}
434			if i == m.selectedSuggestion {
435				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
436			} else {
437				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
438			}
439		}
440		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
441	}
442
443	// Signature field label
444	var signatureLabel string
445	if m.focusIndex == focusSignature {
446		signatureLabel = focusedStyle.Render("Signature:")
447	} else {
448		signatureLabel = blurredStyle.Render("Signature:")
449	}
450
451	tip := ""
452	switch m.focusIndex {
453	case focusFrom:
454		tip = "Select the account to send from."
455	case focusTo:
456		tip = "Enter recipient email addresses."
457	case focusCc:
458		tip = "Carbon copy recipients."
459	case focusBcc:
460		tip = "Blind carbon copy recipients."
461	case focusSubject:
462		tip = "The subject line of your email."
463	case focusBody:
464		tip = "The main content of your email. Markdown and HTML are supported."
465	case focusSignature:
466		tip = "Your email signature. This will be appended to the end of the email."
467	case focusAttachment:
468		tip = "Press Enter to select a file to attach to this email."
469	case focusSend:
470		tip = "Press Enter to send the email."
471	}
472
473	composerViewElements := []string{
474		"Compose New Email",
475		fromField,
476		toFieldView,
477		m.ccInput.View(),
478		m.bccInput.View(),
479		m.subjectInput.View(),
480		m.bodyInput.View(),
481		signatureLabel,
482		m.signatureInput.View(),
483		attachmentStyle.Render(attachmentField),
484		button,
485		"",
486	}
487
488	if !m.hideTips && tip != "" {
489		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
490	}
491
492	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
493	helpView := helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit")
494
495	if m.height > 0 {
496		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
497		gap := m.height - currentHeight
498		if gap >= 0 {
499			mainContent += strings.Repeat("\n", gap+1)
500		} else {
501			mainContent += "\n"
502		}
503	} else {
504		mainContent += "\n\n"
505	}
506
507	composerView.WriteString(mainContent)
508	composerView.WriteString(helpView)
509
510	// Account picker overlay
511	if m.showAccountPicker {
512		var accountList strings.Builder
513		accountList.WriteString("Select Account:\n\n")
514		for i, acc := range m.accounts {
515			display := acc.FetchEmail
516			if acc.Name != "" {
517				display = fmt.Sprintf("%s (%s)", acc.Name, acc.FetchEmail)
518			}
519			if i == m.selectedAccountIdx {
520				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
521			} else {
522				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
523			}
524			accountList.WriteString("\n")
525		}
526		accountList.WriteString("\n")
527		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
528
529		dialog := DialogBoxStyle.Render(accountList.String())
530		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
531	}
532
533	if m.confirmingExit {
534		dialog := DialogBoxStyle.Render(
535			lipgloss.JoinVertical(lipgloss.Center,
536				"Discard draft?",
537				HelpStyle.Render("\n(y/n)"),
538			),
539		)
540		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
541	}
542
543	return tea.NewView(composerView.String())
544}
545
546// SetAccounts sets the available accounts for sending.
547func (m *Composer) SetAccounts(accounts []config.Account) {
548	m.accounts = accounts
549	if m.selectedAccountIdx >= len(accounts) {
550		m.selectedAccountIdx = 0
551	}
552}
553
554// SetSelectedAccount sets the selected account by ID.
555func (m *Composer) SetSelectedAccount(accountID string) {
556	for i, acc := range m.accounts {
557		if acc.ID == accountID {
558			m.selectedAccountIdx = i
559			return
560		}
561	}
562}
563
564// GetSelectedAccountID returns the ID of the currently selected account.
565func (m *Composer) GetSelectedAccountID() string {
566	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
567		return m.accounts[m.selectedAccountIdx].ID
568	}
569	return ""
570}
571
572// GetDraftID returns the draft ID for this composer.
573func (m *Composer) GetDraftID() string {
574	return m.draftID
575}
576
577// SetDraftID sets the draft ID (for loading existing drafts).
578func (m *Composer) SetDraftID(id string) {
579	m.draftID = id
580}
581
582// GetTo returns the current To field value.
583func (m *Composer) GetTo() string {
584	return m.toInput.Value()
585}
586
587// GetSubject returns the current Subject field value.
588func (m *Composer) GetSubject() string {
589	return m.subjectInput.Value()
590}
591
592// GetBody returns the current Body field value.
593func (m *Composer) GetBody() string {
594	return m.bodyInput.Value()
595}
596
597// GetAttachmentPath returns the current attachment path.
598func (m *Composer) GetAttachmentPath() string {
599	return m.attachmentPath
600}
601
602// GetSignature returns the current signature value.
603func (m *Composer) GetSignature() string {
604	return m.signatureInput.Value()
605}
606
607// SetReplyContext sets the reply context for the draft.
608func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
609	m.inReplyTo = inReplyTo
610	m.references = references
611}
612
613// SetQuotedText sets the hidden quoted text that will be appended when sending.
614func (m *Composer) SetQuotedText(text string) {
615	m.quotedText = text
616}
617
618// GetQuotedText returns the hidden quoted text.
619func (m *Composer) GetQuotedText() string {
620	return m.quotedText
621}
622
623// GetInReplyTo returns the In-Reply-To header value.
624func (m *Composer) GetInReplyTo() string {
625	return m.inReplyTo
626}
627
628// GetReferences returns the References header values.
629func (m *Composer) GetReferences() []string {
630	return m.references
631}
632
633// ToDraft converts the composer state to a Draft for saving.
634func (m *Composer) ToDraft() config.Draft {
635	return config.Draft{
636		ID:             m.draftID,
637		To:             m.toInput.Value(),
638		Cc:             m.ccInput.Value(),
639		Bcc:            m.bccInput.Value(),
640		Subject:        m.subjectInput.Value(),
641		Body:           m.bodyInput.Value(),
642		AttachmentPath: m.attachmentPath,
643		AccountID:      m.GetSelectedAccountID(),
644		InReplyTo:      m.inReplyTo,
645		References:     m.references,
646		QuotedText:     m.quotedText,
647	}
648}
649
650// NewComposerFromDraft creates a composer from an existing draft.
651func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
652	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
653	m.ccInput.SetValue(draft.Cc)
654	m.bccInput.SetValue(draft.Bcc)
655	m.draftID = draft.ID
656	m.attachmentPath = draft.AttachmentPath
657	m.inReplyTo = draft.InReplyTo
658	m.references = draft.References
659	m.quotedText = draft.QuotedText
660	return m
661}