composer.go

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