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	composerViewElements = append(composerViewElements, helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"))
492
493	composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left, composerViewElements...))
494
495	// Account picker overlay
496	if m.showAccountPicker {
497		var accountList strings.Builder
498		accountList.WriteString("Select Account:\n\n")
499		for i, acc := range m.accounts {
500			display := acc.FetchEmail
501			if acc.Name != "" {
502				display = fmt.Sprintf("%s (%s)", acc.Name, acc.FetchEmail)
503			}
504			if i == m.selectedAccountIdx {
505				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
506			} else {
507				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
508			}
509			accountList.WriteString("\n")
510		}
511		accountList.WriteString("\n")
512		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
513
514		dialog := DialogBoxStyle.Render(accountList.String())
515		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
516	}
517
518	if m.confirmingExit {
519		dialog := DialogBoxStyle.Render(
520			lipgloss.JoinVertical(lipgloss.Center,
521				"Discard draft?",
522				HelpStyle.Render("\n(y/n)"),
523			),
524		)
525		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
526	}
527
528	return tea.NewView(composerView.String())
529}
530
531// SetAccounts sets the available accounts for sending.
532func (m *Composer) SetAccounts(accounts []config.Account) {
533	m.accounts = accounts
534	if m.selectedAccountIdx >= len(accounts) {
535		m.selectedAccountIdx = 0
536	}
537}
538
539// SetSelectedAccount sets the selected account by ID.
540func (m *Composer) SetSelectedAccount(accountID string) {
541	for i, acc := range m.accounts {
542		if acc.ID == accountID {
543			m.selectedAccountIdx = i
544			return
545		}
546	}
547}
548
549// GetSelectedAccountID returns the ID of the currently selected account.
550func (m *Composer) GetSelectedAccountID() string {
551	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
552		return m.accounts[m.selectedAccountIdx].ID
553	}
554	return ""
555}
556
557// GetDraftID returns the draft ID for this composer.
558func (m *Composer) GetDraftID() string {
559	return m.draftID
560}
561
562// SetDraftID sets the draft ID (for loading existing drafts).
563func (m *Composer) SetDraftID(id string) {
564	m.draftID = id
565}
566
567// GetTo returns the current To field value.
568func (m *Composer) GetTo() string {
569	return m.toInput.Value()
570}
571
572// GetSubject returns the current Subject field value.
573func (m *Composer) GetSubject() string {
574	return m.subjectInput.Value()
575}
576
577// GetBody returns the current Body field value.
578func (m *Composer) GetBody() string {
579	return m.bodyInput.Value()
580}
581
582// GetAttachmentPath returns the current attachment path.
583func (m *Composer) GetAttachmentPath() string {
584	return m.attachmentPath
585}
586
587// GetSignature returns the current signature value.
588func (m *Composer) GetSignature() string {
589	return m.signatureInput.Value()
590}
591
592// SetReplyContext sets the reply context for the draft.
593func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
594	m.inReplyTo = inReplyTo
595	m.references = references
596}
597
598// SetQuotedText sets the hidden quoted text that will be appended when sending.
599func (m *Composer) SetQuotedText(text string) {
600	m.quotedText = text
601}
602
603// GetQuotedText returns the hidden quoted text.
604func (m *Composer) GetQuotedText() string {
605	return m.quotedText
606}
607
608// GetInReplyTo returns the In-Reply-To header value.
609func (m *Composer) GetInReplyTo() string {
610	return m.inReplyTo
611}
612
613// GetReferences returns the References header values.
614func (m *Composer) GetReferences() []string {
615	return m.references
616}
617
618// ToDraft converts the composer state to a Draft for saving.
619func (m *Composer) ToDraft() config.Draft {
620	return config.Draft{
621		ID:             m.draftID,
622		To:             m.toInput.Value(),
623		Cc:             m.ccInput.Value(),
624		Bcc:            m.bccInput.Value(),
625		Subject:        m.subjectInput.Value(),
626		Body:           m.bodyInput.Value(),
627		AttachmentPath: m.attachmentPath,
628		AccountID:      m.GetSelectedAccountID(),
629		InReplyTo:      m.inReplyTo,
630		References:     m.references,
631		QuotedText:     m.quotedText,
632	}
633}
634
635// NewComposerFromDraft creates a composer from an existing draft.
636func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
637	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
638	m.ccInput.SetValue(draft.Cc)
639	m.bccInput.SetValue(draft.Bcc)
640	m.draftID = draft.ID
641	m.attachmentPath = draft.AttachmentPath
642	m.inReplyTo = draft.InReplyTo
643	m.references = draft.References
644	m.quotedText = draft.QuotedText
645	return m
646}