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