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}