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