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}