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