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