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)
 13
 14// Styles for the UI
 15var (
 16	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 17	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 18	cursorStyle         = focusedStyle.Copy()
 19	noStyle             = lipgloss.NewStyle()
 20	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
 21	focusedButton       = focusedStyle.Copy().Render("[ Send ]")
 22	blurredButton       = blurredStyle.Copy().Render("[ Send ]")
 23	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 24	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240"))
 25	fromSelectorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 26)
 27
 28const (
 29	focusFrom = iota
 30	focusTo
 31	focusSubject
 32	focusBody
 33	focusAttachment
 34	focusSend
 35)
 36
 37// Composer model holds the state of the email composition UI.
 38type Composer struct {
 39	focusIndex     int
 40	toInput        textinput.Model
 41	subjectInput   textinput.Model
 42	bodyInput      textarea.Model
 43	attachmentPath string
 44	width          int
 45	height         int
 46	confirmingExit bool
 47
 48	// Multi-account support
 49	accounts           []config.Account
 50	selectedAccountIdx int
 51	showAccountPicker  bool
 52}
 53
 54// NewComposer initializes a new composer model.
 55func NewComposer(from, to, subject, body string) *Composer {
 56	m := &Composer{}
 57
 58	m.toInput = textinput.New()
 59	m.toInput.Cursor.Style = cursorStyle
 60	m.toInput.Placeholder = "To"
 61	m.toInput.SetValue(to)
 62	m.toInput.Prompt = "> "
 63	m.toInput.CharLimit = 256
 64
 65	m.subjectInput = textinput.New()
 66	m.subjectInput.Cursor.Style = cursorStyle
 67	m.subjectInput.Placeholder = "Subject"
 68	m.subjectInput.SetValue(subject)
 69	m.subjectInput.Prompt = "> "
 70	m.subjectInput.CharLimit = 256
 71
 72	m.bodyInput = textarea.New()
 73	m.bodyInput.Cursor.Style = cursorStyle
 74	m.bodyInput.Placeholder = "Body (Markdown supported)..."
 75	m.bodyInput.SetValue(body)
 76	m.bodyInput.Prompt = "> "
 77	m.bodyInput.SetHeight(10)
 78	m.bodyInput.SetCursor(0)
 79
 80	// Start focus on To field (From is selectable but not a text input)
 81	m.focusIndex = focusTo
 82	m.toInput.Focus()
 83
 84	return m
 85}
 86
 87// NewComposerWithAccounts initializes a composer with multiple account support.
 88func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string) *Composer {
 89	m := NewComposer("", to, subject, body)
 90	m.accounts = accounts
 91
 92	// Find the selected account index
 93	for i, acc := range accounts {
 94		if acc.ID == selectedAccountID {
 95			m.selectedAccountIdx = i
 96			break
 97		}
 98	}
 99
100	return m
101}
102
103// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt.
104func (m *Composer) ResetConfirmation() {
105	m.confirmingExit = false
106}
107
108func (m *Composer) Init() tea.Cmd {
109	return textinput.Blink
110}
111
112func (m *Composer) getFromAddress() string {
113	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
114		acc := m.accounts[m.selectedAccountIdx]
115		if acc.Name != "" {
116			return fmt.Sprintf("%s <%s>", acc.Name, acc.Email)
117		}
118		return acc.Email
119	}
120	return ""
121}
122
123func (m *Composer) getSelectedAccount() *config.Account {
124	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
125		return &m.accounts[m.selectedAccountIdx]
126	}
127	return nil
128}
129
130func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131	var cmds []tea.Cmd
132	var cmd tea.Cmd
133
134	switch msg := msg.(type) {
135	case tea.WindowSizeMsg:
136		m.width = msg.Width
137		m.height = msg.Height
138		inputWidth := msg.Width - 6
139		m.toInput.Width = inputWidth
140		m.subjectInput.Width = inputWidth
141		m.bodyInput.SetWidth(inputWidth)
142
143	case SetComposerCursorToStartMsg:
144		m.bodyInput.SetCursor(0)
145		return m, nil
146
147	case FileSelectedMsg:
148		m.attachmentPath = msg.Path
149		return m, nil
150
151	case tea.KeyMsg:
152		// Handle account picker mode
153		if m.showAccountPicker {
154			switch msg.String() {
155			case "up", "k":
156				if m.selectedAccountIdx > 0 {
157					m.selectedAccountIdx--
158				}
159			case "down", "j":
160				if m.selectedAccountIdx < len(m.accounts)-1 {
161					m.selectedAccountIdx++
162				}
163			case "enter":
164				m.showAccountPicker = false
165			case "esc":
166				m.showAccountPicker = false
167			}
168			return m, nil
169		}
170
171		if m.confirmingExit {
172			switch msg.String() {
173			case "y", "Y":
174				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
175			case "n", "N", "esc":
176				m.confirmingExit = false
177				return m, nil
178			default:
179				return m, nil
180			}
181		}
182
183		switch msg.Type {
184		case tea.KeyCtrlC:
185			return m, tea.Quit
186		case tea.KeyEsc:
187			m.confirmingExit = true
188			return m, nil
189
190		case tea.KeyTab, tea.KeyShiftTab:
191			if msg.Type == tea.KeyShiftTab {
192				m.focusIndex--
193			} else {
194				m.focusIndex++
195			}
196
197			maxFocus := focusSend
198			if m.focusIndex > maxFocus {
199				m.focusIndex = focusFrom
200			} else if m.focusIndex < focusFrom {
201				m.focusIndex = maxFocus
202			}
203
204			m.toInput.Blur()
205			m.subjectInput.Blur()
206			m.bodyInput.Blur()
207
208			switch m.focusIndex {
209			case focusTo:
210				cmds = append(cmds, m.toInput.Focus())
211			case focusSubject:
212				cmds = append(cmds, m.subjectInput.Focus())
213			case focusBody:
214				cmds = append(cmds, m.bodyInput.Focus())
215				cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
216			}
217			return m, tea.Batch(cmds...)
218
219		case tea.KeyEnter:
220			switch m.focusIndex {
221			case focusFrom:
222				if len(m.accounts) > 1 {
223					m.showAccountPicker = true
224				}
225				return m, nil
226			case focusAttachment:
227				return m, func() tea.Msg { return GoToFilePickerMsg{} }
228			case focusSend:
229				acc := m.getSelectedAccount()
230				accountID := ""
231				if acc != nil {
232					accountID = acc.ID
233				}
234				return m, func() tea.Msg {
235					return SendEmailMsg{
236						To:             m.toInput.Value(),
237						Subject:        m.subjectInput.Value(),
238						Body:           m.bodyInput.Value(),
239						AttachmentPath: m.attachmentPath,
240						AccountID:      accountID,
241					}
242				}
243			}
244		}
245	}
246
247	switch m.focusIndex {
248	case focusTo:
249		m.toInput, cmd = m.toInput.Update(msg)
250		cmds = append(cmds, cmd)
251	case focusSubject:
252		m.subjectInput, cmd = m.subjectInput.Update(msg)
253		cmds = append(cmds, cmd)
254	case focusBody:
255		m.bodyInput, cmd = m.bodyInput.Update(msg)
256		cmds = append(cmds, cmd)
257	}
258
259	return m, tea.Batch(cmds...)
260}
261
262func (m *Composer) View() string {
263	var composerView strings.Builder
264	var button string
265
266	if m.focusIndex == focusSend {
267		button = focusedButton
268	} else {
269		button = blurredButton
270	}
271
272	// From field with account selector
273	fromAddr := m.getFromAddress()
274	var fromField string
275	if len(m.accounts) > 1 {
276		if m.focusIndex == focusFrom {
277			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s (press Enter to change)", fromAddr))
278		} else {
279			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s", fromAddr))
280		}
281	} else {
282		fromField = "From: " + emailRecipientStyle.Render(fromAddr)
283	}
284
285	var attachmentField string
286	attachmentText := "None (Press Enter to select)"
287	if m.attachmentPath != "" {
288		attachmentText = m.attachmentPath
289	}
290
291	if m.focusIndex == focusAttachment {
292		attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
293	} else {
294		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
295	}
296
297	composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left,
298		"Compose New Email",
299		fromField,
300		m.toInput.View(),
301		m.subjectInput.View(),
302		m.bodyInput.View(),
303		attachmentStyle.Render(attachmentField),
304		button,
305		helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"),
306	))
307
308	// Account picker overlay
309	if m.showAccountPicker {
310		var accountList strings.Builder
311		accountList.WriteString("Select Account:\n\n")
312		for i, acc := range m.accounts {
313			display := acc.Email
314			if acc.Name != "" {
315				display = fmt.Sprintf("%s (%s)", acc.Name, acc.Email)
316			}
317			if i == m.selectedAccountIdx {
318				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
319			} else {
320				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
321			}
322			accountList.WriteString("\n")
323		}
324		accountList.WriteString("\n")
325		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
326
327		dialog := DialogBoxStyle.Render(accountList.String())
328		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
329	}
330
331	if m.confirmingExit {
332		dialog := DialogBoxStyle.Render(
333			lipgloss.JoinVertical(lipgloss.Center,
334				"Discard draft?",
335				HelpStyle.Render("\n(y/n)"),
336			),
337		)
338		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
339	}
340
341	return composerView.String()
342}
343
344// SetAccounts sets the available accounts for sending.
345func (m *Composer) SetAccounts(accounts []config.Account) {
346	m.accounts = accounts
347	if m.selectedAccountIdx >= len(accounts) {
348		m.selectedAccountIdx = 0
349	}
350}
351
352// SetSelectedAccount sets the selected account by ID.
353func (m *Composer) SetSelectedAccount(accountID string) {
354	for i, acc := range m.accounts {
355		if acc.ID == accountID {
356			m.selectedAccountIdx = i
357			return
358		}
359	}
360}
361
362// GetSelectedAccountID returns the ID of the currently selected account.
363func (m *Composer) GetSelectedAccountID() string {
364	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
365		return m.accounts[m.selectedAccountIdx].ID
366	}
367	return ""
368}