1package tui
2
3import (
4 "fmt"
5 "strings"
6
7 "charm.land/bubbles/v2/textinput"
8 tea "charm.land/bubbletea/v2"
9 "charm.land/lipgloss/v2"
10 "github.com/floatpane/matcha/config"
11)
12
13var (
14 accountItemStyle = lipgloss.NewStyle().PaddingLeft(2)
15 selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
16 accountEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
17 dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
18
19 settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
20 settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
21)
22
23type SettingsState int
24
25const (
26 SettingsMain SettingsState = iota
27 SettingsAccounts
28 SettingsMailingLists
29 SettingsSMIMEConfig
30)
31
32// Settings displays the settings screen.
33type Settings struct {
34 cfg *config.Config
35 state SettingsState
36 cursor int
37 confirmingDelete bool
38 width int
39 height int
40
41 // S/MIME Config fields
42 editingAccountIdx int
43 focusIndex int
44 smimeCertInput textinput.Model
45 smimeKeyInput textinput.Model
46}
47
48// NewSettings creates a new settings model.
49func NewSettings(cfg *config.Config) *Settings {
50 if cfg == nil {
51 cfg = &config.Config{}
52 }
53
54 certInput := textinput.New()
55 certInput.Placeholder = "/path/to/cert.pem"
56 certInput.Prompt = "> "
57 certInput.CharLimit = 256
58
59 keyInput := textinput.New()
60 keyInput.Placeholder = "/path/to/private_key.pem"
61 keyInput.Prompt = "> "
62 keyInput.CharLimit = 256
63
64 return &Settings{
65 cfg: cfg,
66 state: SettingsMain,
67 cursor: 0,
68 smimeCertInput: certInput,
69 smimeKeyInput: keyInput,
70 }
71}
72
73// Init initializes the settings model.
74func (m *Settings) Init() tea.Cmd {
75 return textinput.Blink
76}
77
78// Update handles messages for the settings model.
79func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80 var cmds []tea.Cmd
81 var cmd tea.Cmd
82
83 switch msg := msg.(type) {
84 case tea.WindowSizeMsg:
85 m.width = msg.Width
86 m.height = msg.Height
87 m.smimeCertInput.SetWidth(m.width - 6)
88 m.smimeKeyInput.SetWidth(m.width - 6)
89 return m, nil
90
91 case tea.KeyPressMsg:
92 if m.state == SettingsMain {
93 return m.updateMain(msg)
94 } else if m.state == SettingsMailingLists {
95 return m.updateMailingLists(msg)
96 } else if m.state == SettingsSMIMEConfig {
97 var m2 *Settings
98 m2, cmd = m.updateSMIMEConfig(msg)
99 cmds = append(cmds, cmd)
100 return m2, tea.Batch(cmds...)
101 } else {
102 return m.updateAccounts(msg)
103 }
104 }
105
106 if m.state == SettingsSMIMEConfig {
107 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
108 cmds = append(cmds, cmd)
109 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
110 cmds = append(cmds, cmd)
111 }
112
113 return m, tea.Batch(cmds...)
114}
115
116func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
117 switch msg.String() {
118 case "up", "k":
119 if m.cursor > 0 {
120 m.cursor--
121 }
122 case "down", "j":
123 // Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature, 3: Contextual Tips, 4: Mailing Lists
124 if m.cursor < 4 {
125 m.cursor++
126 }
127 case "enter":
128 switch m.cursor {
129 case 0: // Email Accounts
130 m.state = SettingsAccounts
131 m.cursor = 0
132 return m, nil
133 case 1: // Image Display
134 m.cfg.DisableImages = !m.cfg.DisableImages
135 // Save config immediately
136 _ = config.SaveConfig(m.cfg)
137 return m, nil
138 case 2: // Edit Signature
139 return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
140 case 3: // Contextual Tips
141 m.cfg.HideTips = !m.cfg.HideTips
142 // Save config immediately
143 _ = config.SaveConfig(m.cfg)
144 return m, nil
145 case 4: // Mailing Lists
146 m.state = SettingsMailingLists
147 m.cursor = 0
148 return m, nil
149 }
150 case "esc":
151 return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
152 }
153 return m, nil
154}
155
156func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
157 if m.confirmingDelete {
158 switch msg.String() {
159 case "y", "Y":
160 if m.cursor < len(m.cfg.Accounts) {
161 accountID := m.cfg.Accounts[m.cursor].ID
162 m.confirmingDelete = false
163 return m, func() tea.Msg {
164 return DeleteAccountMsg{AccountID: accountID}
165 }
166 }
167 case "n", "N", "esc":
168 m.confirmingDelete = false
169 return m, nil
170 }
171 return m, nil
172 }
173
174 switch msg.String() {
175 case "up", "k":
176 if m.cursor > 0 {
177 m.cursor--
178 }
179 case "down", "j":
180 // +1 for "Add Account" option
181 if m.cursor < len(m.cfg.Accounts) {
182 m.cursor++
183 }
184 case "d":
185 // Delete selected account (not the "Add Account" option)
186 if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
187 m.confirmingDelete = true
188 }
189 case "enter":
190 // If cursor is on "Add Account"
191 if m.cursor == len(m.cfg.Accounts) {
192 return m, func() tea.Msg { return GoToAddAccountMsg{} }
193 } else if m.cursor < len(m.cfg.Accounts) {
194 m.editingAccountIdx = m.cursor
195 m.state = SettingsSMIMEConfig
196 m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert)
197 m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey)
198 m.focusIndex = 0
199 m.smimeCertInput.Focus()
200 m.smimeKeyInput.Blur()
201 return m, textinput.Blink
202 }
203 case "esc":
204 m.state = SettingsMain
205 m.cursor = 0
206 return m, nil
207 }
208 return m, nil
209}
210
211func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
212 var cmds []tea.Cmd
213 var cmd tea.Cmd
214
215 switch msg.String() {
216 case "esc":
217 m.state = SettingsAccounts
218 return m, nil
219 case "tab", "shift+tab", "up", "down":
220 if msg.String() == "shift+tab" || msg.String() == "up" {
221 m.focusIndex--
222 if m.focusIndex < 0 {
223 m.focusIndex = 4
224 }
225 } else {
226 m.focusIndex++
227 if m.focusIndex > 4 {
228 m.focusIndex = 0
229 }
230 }
231
232 m.smimeCertInput.Blur()
233 m.smimeKeyInput.Blur()
234
235 if m.focusIndex == 0 {
236 cmds = append(cmds, m.smimeCertInput.Focus())
237 } else if m.focusIndex == 1 {
238 cmds = append(cmds, m.smimeKeyInput.Focus())
239 }
240 return m, tea.Batch(cmds...)
241 case "enter", " ":
242 if m.focusIndex == 0 && msg.String() == "enter" {
243 m.focusIndex = 1
244 m.smimeCertInput.Blur()
245 cmds = append(cmds, m.smimeKeyInput.Focus())
246 return m, tea.Batch(cmds...)
247 } else if m.focusIndex == 1 && msg.String() == "enter" {
248 m.focusIndex = 2
249 m.smimeKeyInput.Blur()
250 return m, nil
251 } else if m.focusIndex == 2 {
252 if msg.String() == "enter" || msg.String() == " " {
253 m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
254 }
255 return m, nil
256 } else if m.focusIndex == 3 && msg.String() == "enter" {
257 m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
258 m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
259 _ = config.SaveConfig(m.cfg)
260 m.state = SettingsAccounts
261 return m, nil
262 } else if m.focusIndex == 4 && msg.String() == "enter" {
263 m.state = SettingsAccounts
264 return m, nil
265 }
266 }
267
268 if m.focusIndex == 0 {
269 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
270 cmds = append(cmds, cmd)
271 } else if m.focusIndex == 1 {
272 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
273 cmds = append(cmds, cmd)
274 }
275
276 return m, tea.Batch(cmds...)
277}
278
279func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
280 if m.confirmingDelete {
281 switch msg.String() {
282 case "y", "Y":
283 if m.cursor < len(m.cfg.MailingLists) {
284 m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
285 _ = config.SaveConfig(m.cfg)
286 if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
287 m.cursor--
288 }
289 m.confirmingDelete = false
290 }
291 case "n", "N", "esc":
292 m.confirmingDelete = false
293 return m, nil
294 }
295 return m, nil
296 }
297
298 switch msg.String() {
299 case "up", "k":
300 if m.cursor > 0 {
301 m.cursor--
302 }
303 case "down", "j":
304 if m.cursor < len(m.cfg.MailingLists) {
305 m.cursor++
306 }
307 case "d":
308 if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
309 m.confirmingDelete = true
310 }
311 case "enter":
312 if m.cursor == len(m.cfg.MailingLists) {
313 return m, func() tea.Msg { return GoToAddMailingListMsg{} }
314 }
315 case "esc":
316 m.state = SettingsMain
317 m.cursor = 0
318 return m, nil
319 }
320 return m, nil
321}
322
323// View renders the settings screen.
324func (m *Settings) View() tea.View {
325 if m.state == SettingsMain {
326 return tea.NewView(m.viewMain())
327 } else if m.state == SettingsMailingLists {
328 return tea.NewView(m.viewMailingLists())
329 } else if m.state == SettingsSMIMEConfig {
330 return tea.NewView(m.viewSMIMEConfig())
331 }
332 return tea.NewView(m.viewAccounts())
333}
334
335func (m *Settings) viewMain() string {
336 var b strings.Builder
337
338 b.WriteString(titleStyle.Render("Settings") + "\n\n")
339
340 // Option 0: Email Accounts
341 if m.cursor == 0 {
342 b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
343 } else {
344 b.WriteString(accountItemStyle.Render(" Email Accounts"))
345 }
346 b.WriteString("\n")
347
348 // Option 1: Image Display
349 status := "ON"
350 if m.cfg.DisableImages {
351 status = "OFF"
352 }
353 text := fmt.Sprintf("Image Display: %s", status)
354 if m.cursor == 1 {
355 b.WriteString(selectedAccountItemStyle.Render("> " + text))
356 } else {
357 b.WriteString(accountItemStyle.Render(" " + text))
358 }
359 b.WriteString("\n")
360
361 // Option 2: Edit Signature
362 sigText := "Edit Signature"
363 if config.HasSignature() {
364 sigText = "Edit Signature (configured)"
365 }
366 if m.cursor == 2 {
367 b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
368 } else {
369 b.WriteString(accountItemStyle.Render(" " + sigText))
370 }
371 b.WriteString("\n")
372
373 // Option 3: Contextual Tips
374 tipsStatus := "ON"
375 if m.cfg.HideTips {
376 tipsStatus = "OFF"
377 }
378 tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
379 if m.cursor == 3 {
380 b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
381 } else {
382 b.WriteString(accountItemStyle.Render(" " + tipsText))
383 }
384 b.WriteString("\n")
385
386 // Option 4: Mailing Lists
387 mailingListsText := "Mailing Lists"
388 if m.cursor == 4 {
389 b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
390 } else {
391 b.WriteString(accountItemStyle.Render(" " + mailingListsText))
392 }
393 b.WriteString("\n\n")
394
395 if !m.cfg.HideTips {
396 tip := ""
397 switch m.cursor {
398 case 0:
399 tip = "Manage your connected email accounts."
400 case 1:
401 tip = "Toggle displaying images in emails."
402 case 2:
403 tip = "Configure the signature appended to your outgoing emails."
404 case 3:
405 tip = "Toggle displaying helpful contextual tips like this one."
406 case 4:
407 tip = "Manage groups of email addresses to quickly send to multiple people."
408 }
409 if tip != "" {
410 b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
411 }
412 }
413
414 mainContent := b.String()
415 helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
416
417 if m.height > 0 {
418 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
419 gap := m.height - currentHeight
420 if gap > 0 {
421 mainContent += strings.Repeat("\n", gap)
422 }
423 } else {
424 mainContent += "\n\n"
425 }
426
427 return docStyle.Render(mainContent + helpView)
428}
429
430func (m *Settings) viewAccounts() string {
431 var b strings.Builder
432
433 b.WriteString(titleStyle.Render("Account Settings"))
434 b.WriteString("\n\n")
435
436 if len(m.cfg.Accounts) == 0 {
437 b.WriteString(accountEmailStyle.Render(" No accounts configured.\n"))
438 b.WriteString("\n")
439 }
440
441 for i, account := range m.cfg.Accounts {
442 displayName := account.Email
443 if account.Name != "" {
444 displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
445 }
446
447 providerInfo := account.ServiceProvider
448 if account.ServiceProvider == "custom" {
449 providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
450 }
451
452 if account.SMIMECert != "" && account.SMIMEKey != "" {
453 providerInfo += " [S/MIME Configured]"
454 }
455
456 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
457
458 if m.cursor == i {
459 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
460 } else {
461 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
462 }
463 b.WriteString("\n")
464 }
465
466 // Add Account option
467 addAccountText := "Add New Account"
468 if m.cursor == len(m.cfg.Accounts) {
469 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
470 } else {
471 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addAccountText)))
472 }
473 b.WriteString("\n")
474
475 mainContent := b.String()
476 helpView := helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back")
477
478 if m.height > 0 {
479 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
480 gap := m.height - currentHeight
481 if gap > 0 {
482 mainContent += strings.Repeat("\n", gap)
483 }
484 } else {
485 mainContent += "\n\n"
486 }
487
488 if m.confirmingDelete {
489 accountName := m.cfg.Accounts[m.cursor].Email
490 dialog := DialogBoxStyle.Render(
491 lipgloss.JoinVertical(lipgloss.Center,
492 dangerStyle.Render("Delete account?"),
493 accountEmailStyle.Render(accountName),
494 HelpStyle.Render("\n(y/n)"),
495 ),
496 )
497 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
498 }
499
500 return docStyle.Render(mainContent + helpView)
501}
502
503func (m *Settings) viewSMIMEConfig() string {
504 var b strings.Builder
505
506 account := m.cfg.Accounts[m.editingAccountIdx]
507 b.WriteString(titleStyle.Render(fmt.Sprintf("S/MIME Configuration for %s", account.Email)))
508 b.WriteString("\n\n")
509
510 if m.focusIndex == 0 {
511 b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
512 } else {
513 b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
514 }
515 b.WriteString(m.smimeCertInput.View() + "\n\n")
516
517 if m.focusIndex == 1 {
518 b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
519 } else {
520 b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
521 }
522 b.WriteString(m.smimeKeyInput.View() + "\n\n")
523
524 signStatus := "OFF"
525 if account.SMIMESignByDefault {
526 signStatus = "ON"
527 }
528 if m.focusIndex == 2 {
529 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", signStatus)))
530 } else {
531 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", signStatus)))
532 }
533
534 saveBtn := "[ Save ]"
535 cancelBtn := "[ Cancel ]"
536
537 if m.focusIndex == 3 {
538 saveBtn = settingsFocusedStyle.Render(saveBtn)
539 } else {
540 saveBtn = settingsBlurredStyle.Render(saveBtn)
541 }
542
543 if m.focusIndex == 4 {
544 cancelBtn = settingsFocusedStyle.Render(cancelBtn)
545 } else {
546 cancelBtn = settingsBlurredStyle.Render(cancelBtn)
547 }
548
549 b.WriteString(saveBtn + " " + cancelBtn + "\n\n")
550
551 mainContent := b.String()
552 helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • esc: back")
553
554 if m.height > 0 {
555 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
556 gap := m.height - currentHeight
557 if gap > 0 {
558 mainContent += strings.Repeat("\n", gap)
559 }
560 } else {
561 mainContent += "\n\n"
562 }
563
564 return docStyle.Render(mainContent + helpView)
565}
566
567func (m *Settings) viewMailingLists() string {
568 var b strings.Builder
569
570 b.WriteString(titleStyle.Render("Mailing Lists"))
571 b.WriteString("\n\n")
572
573 if len(m.cfg.MailingLists) == 0 {
574 b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n"))
575 b.WriteString("\n")
576 }
577
578 for i, list := range m.cfg.MailingLists {
579 displayName := list.Name
580
581 addrCount := fmt.Sprintf("%d address", len(list.Addresses))
582 if len(list.Addresses) != 1 {
583 addrCount += "es"
584 }
585
586 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
587
588 if m.cursor == i {
589 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
590 } else {
591 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
592 }
593 b.WriteString("\n")
594 }
595
596 // Add Mailing List option
597 addListText := "Add New Mailing List"
598 if m.cursor == len(m.cfg.MailingLists) {
599 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
600 } else {
601 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addListText)))
602 }
603 b.WriteString("\n")
604
605 helpView := helpStyle.Render("↑/↓: navigate • enter: select • d: delete • esc: back")
606 mainContent := b.String()
607
608 if m.height > 0 {
609 contentHeight := strings.Count(mainContent, "\n") + 1
610 gap := m.height - contentHeight - 2 // -2 for margins
611 if gap > 0 {
612 mainContent += strings.Repeat("\n", gap)
613 }
614 } else {
615 mainContent += "\n\n"
616 }
617
618 if m.confirmingDelete {
619 listName := m.cfg.MailingLists[m.cursor].Name
620 dialog := DialogBoxStyle.Render(
621 lipgloss.JoinVertical(lipgloss.Center,
622 dangerStyle.Render("Delete mailing list?"),
623 accountEmailStyle.Render(listName),
624 HelpStyle.Render("\n(y/n)"),
625 ),
626 )
627 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
628 }
629
630 return docStyle.Render(mainContent + helpView)
631}
632
633// UpdateConfig updates the configuration (used when accounts are deleted).
634func (m *Settings) UpdateConfig(cfg *config.Config) {
635 m.cfg = cfg
636 if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
637 m.cursor = len(cfg.Accounts)
638 }
639}