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