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 "github.com/floatpane/matcha/theme"
12)
13
14var (
15 accountItemStyle = lipgloss.NewStyle().PaddingLeft(2)
16 selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
17 accountEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
18 dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
19
20 settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
21 settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
22)
23
24type SettingsState int
25
26const (
27 SettingsMain SettingsState = iota
28 SettingsAccounts
29 SettingsMailingLists
30 SettingsSMIMEConfig
31 SettingsTheme
32)
33
34// Settings displays the settings screen.
35type Settings struct {
36 cfg *config.Config
37 state SettingsState
38 cursor int
39 confirmingDelete bool
40 width int
41 height int
42
43 // S/MIME Config fields
44 editingAccountIdx int
45 focusIndex int
46 smimeCertInput textinput.Model
47 smimeKeyInput textinput.Model
48
49 // PGP Config fields
50 pgpPublicKeyInput textinput.Model
51 pgpPrivateKeyInput textinput.Model
52 pgpKeySource string // "file" or "yubikey"
53 pgpPINInput textinput.Model
54}
55
56// NewSettings creates a new settings model.
57func NewSettings(cfg *config.Config) *Settings {
58 if cfg == nil {
59 cfg = &config.Config{}
60 }
61
62 tiStyles := ThemedTextInputStyles()
63
64 certInput := textinput.New()
65 certInput.Placeholder = "/path/to/cert.pem"
66 certInput.Prompt = "> "
67 certInput.CharLimit = 256
68 certInput.SetStyles(tiStyles)
69
70 keyInput := textinput.New()
71 keyInput.Placeholder = "/path/to/private_key.pem"
72 keyInput.Prompt = "> "
73 keyInput.CharLimit = 256
74 keyInput.SetStyles(tiStyles)
75
76 pgpPubInput := textinput.New()
77 pgpPubInput.Placeholder = "/path/to/public_key.asc"
78 pgpPubInput.Prompt = "> "
79 pgpPubInput.CharLimit = 256
80 pgpPubInput.SetStyles(tiStyles)
81
82 pgpPrivInput := textinput.New()
83 pgpPrivInput.Placeholder = "/path/to/private_key.asc"
84 pgpPrivInput.Prompt = "> "
85 pgpPrivInput.CharLimit = 256
86 pgpPrivInput.SetStyles(tiStyles)
87
88 pgpPINInput := textinput.New()
89 pgpPINInput.Placeholder = "YubiKey PIN (6-8 digits)"
90 pgpPINInput.Prompt = "> "
91 pgpPINInput.CharLimit = 16
92 pgpPINInput.EchoMode = textinput.EchoPassword
93 pgpPINInput.EchoCharacter = '*'
94 pgpPINInput.SetStyles(tiStyles)
95
96 return &Settings{
97 cfg: cfg,
98 state: SettingsMain,
99 cursor: 0,
100 smimeCertInput: certInput,
101 smimeKeyInput: keyInput,
102 pgpPublicKeyInput: pgpPubInput,
103 pgpPrivateKeyInput: pgpPrivInput,
104 pgpKeySource: "file", // Default to file-based keys
105 pgpPINInput: pgpPINInput,
106 }
107}
108
109// Init initializes the settings model.
110func (m *Settings) Init() tea.Cmd {
111 return textinput.Blink
112}
113
114// Update handles messages for the settings model.
115func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116 var cmds []tea.Cmd
117 var cmd tea.Cmd
118
119 switch msg := msg.(type) {
120 case tea.WindowSizeMsg:
121 m.width = msg.Width
122 m.height = msg.Height
123 m.smimeCertInput.SetWidth(m.width - 6)
124 m.smimeKeyInput.SetWidth(m.width - 6)
125 m.pgpPublicKeyInput.SetWidth(m.width - 6)
126 m.pgpPrivateKeyInput.SetWidth(m.width - 6)
127 m.pgpPINInput.SetWidth(m.width - 6)
128 return m, nil
129
130 case tea.KeyPressMsg:
131 if m.state == SettingsMain {
132 return m.updateMain(msg)
133 } else if m.state == SettingsTheme {
134 return m.updateTheme(msg)
135 } else if m.state == SettingsMailingLists {
136 return m.updateMailingLists(msg)
137 } else if m.state == SettingsSMIMEConfig {
138 var m2 *Settings
139 m2, cmd = m.updateSMIMEConfig(msg)
140 cmds = append(cmds, cmd)
141 return m2, tea.Batch(cmds...)
142 } else {
143 return m.updateAccounts(msg)
144 }
145 }
146
147 if m.state == SettingsSMIMEConfig {
148 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
149 cmds = append(cmds, cmd)
150 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
151 cmds = append(cmds, cmd)
152 m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
153 cmds = append(cmds, cmd)
154 m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
155 cmds = append(cmds, cmd)
156 m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
157 cmds = append(cmds, cmd)
158 }
159
160 return m, tea.Batch(cmds...)
161}
162
163func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
164 switch msg.String() {
165 case "up", "k":
166 if m.cursor > 0 {
167 m.cursor--
168 }
169 case "down", "j":
170 // Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Desktop Notifications, 6: Mailing Lists
171 if m.cursor < 6 {
172 m.cursor++
173 }
174 case "enter":
175 switch m.cursor {
176 case 0: // Email Accounts
177 m.state = SettingsAccounts
178 m.cursor = 0
179 return m, nil
180 case 1: // Theme
181 m.state = SettingsTheme
182 // Position cursor on the currently active theme
183 themes := theme.AllThemes()
184 m.cursor = 0
185 for i, t := range themes {
186 if t.Name == theme.ActiveTheme.Name {
187 m.cursor = i
188 break
189 }
190 }
191 return m, nil
192 case 2: // Image Display
193 m.cfg.DisableImages = !m.cfg.DisableImages
194 _ = config.SaveConfig(m.cfg)
195 return m, nil
196 case 3: // Edit Signature
197 return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
198 case 4: // Contextual Tips
199 m.cfg.HideTips = !m.cfg.HideTips
200 _ = config.SaveConfig(m.cfg)
201 return m, nil
202 case 5: // Desktop Notifications
203 m.cfg.DisableNotifications = !m.cfg.DisableNotifications
204 _ = config.SaveConfig(m.cfg)
205 return m, nil
206 case 6: // Mailing Lists
207 m.state = SettingsMailingLists
208 m.cursor = 0
209 return m, nil
210 }
211 case "esc":
212 return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
213 }
214 return m, nil
215}
216
217func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
218 if m.confirmingDelete {
219 switch msg.String() {
220 case "y", "Y":
221 if m.cursor < len(m.cfg.Accounts) {
222 accountID := m.cfg.Accounts[m.cursor].ID
223 m.confirmingDelete = false
224 return m, func() tea.Msg {
225 return DeleteAccountMsg{AccountID: accountID}
226 }
227 }
228 case "n", "N", "esc":
229 m.confirmingDelete = false
230 return m, nil
231 }
232 return m, nil
233 }
234
235 switch msg.String() {
236 case "up", "k":
237 if m.cursor > 0 {
238 m.cursor--
239 }
240 case "down", "j":
241 // +1 for "Add Account" option
242 if m.cursor < len(m.cfg.Accounts) {
243 m.cursor++
244 }
245 case "d":
246 // Delete selected account (not the "Add Account" option)
247 if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
248 m.confirmingDelete = true
249 }
250 case "e":
251 // Edit selected account
252 if m.cursor < len(m.cfg.Accounts) {
253 acc := m.cfg.Accounts[m.cursor]
254 return m, func() tea.Msg {
255 return GoToEditAccountMsg{
256 AccountID: acc.ID,
257 Provider: acc.ServiceProvider,
258 Name: acc.Name,
259 Email: acc.Email,
260 FetchEmail: acc.FetchEmail,
261 IMAPServer: acc.IMAPServer,
262 IMAPPort: acc.IMAPPort,
263 SMTPServer: acc.SMTPServer,
264 SMTPPort: acc.SMTPPort,
265 Protocol: acc.Protocol,
266 JMAPEndpoint: acc.JMAPEndpoint,
267 POP3Server: acc.POP3Server,
268 POP3Port: acc.POP3Port,
269 }
270 }
271 }
272 case "enter":
273 // If cursor is on "Add Account"
274 if m.cursor == len(m.cfg.Accounts) {
275 return m, func() tea.Msg { return GoToAddAccountMsg{} }
276 } else if m.cursor < len(m.cfg.Accounts) {
277 m.editingAccountIdx = m.cursor
278 m.state = SettingsSMIMEConfig
279 m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert)
280 m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey)
281 m.pgpPublicKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPublicKey)
282 m.pgpPrivateKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPrivateKey)
283 // Initialize PGP key source
284 if m.cfg.Accounts[m.cursor].PGPKeySource == "" {
285 m.pgpKeySource = "file"
286 } else {
287 m.pgpKeySource = m.cfg.Accounts[m.cursor].PGPKeySource
288 }
289 m.pgpPINInput.SetValue(m.cfg.Accounts[m.cursor].PGPPIN)
290 m.focusIndex = 0
291 m.smimeCertInput.Focus()
292 m.smimeKeyInput.Blur()
293 m.pgpPublicKeyInput.Blur()
294 m.pgpPrivateKeyInput.Blur()
295 m.pgpPINInput.Blur()
296 return m, textinput.Blink
297 }
298 case "esc":
299 m.state = SettingsMain
300 m.cursor = 0
301 return m, nil
302 }
303 return m, nil
304}
305
306// Focus indices for the crypto config screen:
307// 0: S/MIME Certificate Path
308// 1: S/MIME Private Key Path
309// 2: S/MIME Sign By Default toggle
310// 3: PGP Public Key Path
311// 4: PGP Private Key Path
312// 5: PGP Key Source toggle (file/yubikey)
313// 6: PGP PIN input (only shown if yubikey)
314// 7: PGP Sign By Default toggle
315// 8: PGP Encrypt By Default toggle
316// 9: Save button
317// 10: Cancel button
318const cryptoConfigMaxFocus = 10
319
320func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
321 var cmds []tea.Cmd
322 var cmd tea.Cmd
323
324 switch msg.String() {
325 case "esc":
326 m.state = SettingsAccounts
327 return m, nil
328 case "tab", "shift+tab", "up", "down":
329 if msg.String() == "shift+tab" || msg.String() == "up" {
330 m.focusIndex--
331 if m.focusIndex < 0 {
332 m.focusIndex = cryptoConfigMaxFocus
333 }
334 } else {
335 m.focusIndex++
336 if m.focusIndex > cryptoConfigMaxFocus {
337 m.focusIndex = 0
338 }
339 }
340
341 m.smimeCertInput.Blur()
342 m.smimeKeyInput.Blur()
343 m.pgpPublicKeyInput.Blur()
344 m.pgpPrivateKeyInput.Blur()
345 m.pgpPINInput.Blur()
346
347 switch m.focusIndex {
348 case 0:
349 cmds = append(cmds, m.smimeCertInput.Focus())
350 case 1:
351 cmds = append(cmds, m.smimeKeyInput.Focus())
352 case 3:
353 cmds = append(cmds, m.pgpPublicKeyInput.Focus())
354 case 4:
355 cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
356 case 6:
357 cmds = append(cmds, m.pgpPINInput.Focus())
358 }
359 return m, tea.Batch(cmds...)
360 case "enter", " ":
361 switch m.focusIndex {
362 case 0: // S/MIME cert - enter advances to next field
363 if msg.String() == "enter" {
364 m.focusIndex = 1
365 m.smimeCertInput.Blur()
366 cmds = append(cmds, m.smimeKeyInput.Focus())
367 return m, tea.Batch(cmds...)
368 }
369 case 1: // S/MIME key - enter advances
370 if msg.String() == "enter" {
371 m.focusIndex = 2
372 m.smimeKeyInput.Blur()
373 return m, nil
374 }
375 case 2: // S/MIME sign toggle
376 if msg.String() == "enter" || msg.String() == " " {
377 m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
378 }
379 return m, nil
380 case 3: // PGP public key - enter advances
381 if msg.String() == "enter" {
382 m.focusIndex = 4
383 m.pgpPublicKeyInput.Blur()
384 cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
385 return m, tea.Batch(cmds...)
386 }
387 case 4: // PGP private key - enter advances
388 if msg.String() == "enter" {
389 m.focusIndex = 5
390 m.pgpPrivateKeyInput.Blur()
391 return m, nil
392 }
393 case 5: // PGP key source toggle (file/yubikey)
394 if msg.String() == "enter" || msg.String() == " " {
395 if m.pgpKeySource == "file" {
396 m.pgpKeySource = "yubikey"
397 } else {
398 m.pgpKeySource = "file"
399 }
400 }
401 return m, nil
402 case 6: // PGP PIN input - enter advances
403 if msg.String() == "enter" {
404 m.focusIndex = 7
405 m.pgpPINInput.Blur()
406 return m, nil
407 }
408 case 7: // PGP sign toggle
409 if msg.String() == "enter" || msg.String() == " " {
410 m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault
411 }
412 return m, nil
413 case 8: // PGP encrypt toggle
414 if msg.String() == "enter" || msg.String() == " " {
415 m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault
416 }
417 return m, nil
418 case 9: // Save
419 if msg.String() == "enter" {
420 m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
421 m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
422 m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value()
423 m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value()
424 m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource
425 m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value()
426 _ = config.SaveConfig(m.cfg)
427 m.state = SettingsAccounts
428 return m, nil
429 }
430 case 10: // Cancel
431 if msg.String() == "enter" {
432 m.state = SettingsAccounts
433 return m, nil
434 }
435 }
436 }
437
438 switch m.focusIndex {
439 case 0:
440 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
441 cmds = append(cmds, cmd)
442 case 1:
443 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
444 cmds = append(cmds, cmd)
445 case 3:
446 m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
447 cmds = append(cmds, cmd)
448 case 4:
449 m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
450 cmds = append(cmds, cmd)
451 case 6:
452 m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
453 cmds = append(cmds, cmd)
454 }
455
456 return m, tea.Batch(cmds...)
457}
458
459func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
460 if m.confirmingDelete {
461 switch msg.String() {
462 case "y", "Y":
463 if m.cursor < len(m.cfg.MailingLists) {
464 m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
465 _ = config.SaveConfig(m.cfg)
466 if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
467 m.cursor--
468 }
469 m.confirmingDelete = false
470 }
471 case "n", "N", "esc":
472 m.confirmingDelete = false
473 return m, nil
474 }
475 return m, nil
476 }
477
478 switch msg.String() {
479 case "up", "k":
480 if m.cursor > 0 {
481 m.cursor--
482 }
483 case "down", "j":
484 if m.cursor < len(m.cfg.MailingLists) {
485 m.cursor++
486 }
487 case "d":
488 if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
489 m.confirmingDelete = true
490 }
491 case "e":
492 // Edit selected mailing list
493 if m.cursor < len(m.cfg.MailingLists) {
494 list := m.cfg.MailingLists[m.cursor]
495 idx := m.cursor
496 return m, func() tea.Msg {
497 return GoToEditMailingListMsg{
498 Index: idx,
499 Name: list.Name,
500 Addresses: strings.Join(list.Addresses, ", "),
501 }
502 }
503 }
504 case "enter":
505 if m.cursor == len(m.cfg.MailingLists) {
506 return m, func() tea.Msg { return GoToAddMailingListMsg{} }
507 }
508 case "esc":
509 m.state = SettingsMain
510 m.cursor = 0
511 return m, nil
512 }
513 return m, nil
514}
515
516// View renders the settings screen.
517func (m *Settings) View() tea.View {
518 if m.state == SettingsMain {
519 return tea.NewView(m.viewMain())
520 } else if m.state == SettingsTheme {
521 return tea.NewView(m.viewTheme())
522 } else if m.state == SettingsMailingLists {
523 return tea.NewView(m.viewMailingLists())
524 } else if m.state == SettingsSMIMEConfig {
525 return tea.NewView(m.viewSMIMEConfig())
526 }
527 return tea.NewView(m.viewAccounts())
528}
529
530func (m *Settings) viewMain() string {
531 var b strings.Builder
532
533 b.WriteString(titleStyle.Render("Settings") + "\n\n")
534
535 // Option 0: Email Accounts
536 if m.cursor == 0 {
537 b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
538 } else {
539 b.WriteString(accountItemStyle.Render(" Email Accounts"))
540 }
541 b.WriteString("\n")
542
543 // Option 1: Theme
544 themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
545 if m.cursor == 1 {
546 b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
547 } else {
548 b.WriteString(accountItemStyle.Render(" " + themeText))
549 }
550 b.WriteString("\n")
551
552 // Option 2: Image Display
553 status := "ON"
554 if m.cfg.DisableImages {
555 status = "OFF"
556 }
557 text := fmt.Sprintf("Image Display: %s", status)
558 if m.cursor == 2 {
559 b.WriteString(selectedAccountItemStyle.Render("> " + text))
560 } else {
561 b.WriteString(accountItemStyle.Render(" " + text))
562 }
563 b.WriteString("\n")
564
565 // Option 3: Edit Signature
566 sigText := "Edit Signature"
567 if config.HasSignature() {
568 sigText = "Edit Signature (configured)"
569 }
570 if m.cursor == 3 {
571 b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
572 } else {
573 b.WriteString(accountItemStyle.Render(" " + sigText))
574 }
575 b.WriteString("\n")
576
577 // Option 4: Contextual Tips
578 tipsStatus := "ON"
579 if m.cfg.HideTips {
580 tipsStatus = "OFF"
581 }
582 tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
583 if m.cursor == 4 {
584 b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
585 } else {
586 b.WriteString(accountItemStyle.Render(" " + tipsText))
587 }
588 b.WriteString("\n")
589
590 // Option 5: Desktop Notifications
591 notifStatus := "ON"
592 if m.cfg.DisableNotifications {
593 notifStatus = "OFF"
594 }
595 notifText := fmt.Sprintf("Desktop Notifications: %s", notifStatus)
596 if m.cursor == 5 {
597 b.WriteString(selectedAccountItemStyle.Render("> " + notifText))
598 } else {
599 b.WriteString(accountItemStyle.Render(" " + notifText))
600 }
601 b.WriteString("\n")
602
603 // Option 6: Mailing Lists
604 mailingListsText := "Mailing Lists"
605 if m.cursor == 6 {
606 b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
607 } else {
608 b.WriteString(accountItemStyle.Render(" " + mailingListsText))
609 }
610 b.WriteString("\n\n")
611
612 if !m.cfg.HideTips {
613 tip := ""
614 switch m.cursor {
615 case 0:
616 tip = "Manage your connected email accounts."
617 case 1:
618 tip = "Choose a color theme for the application."
619 case 2:
620 tip = "Toggle displaying images in emails."
621 case 3:
622 tip = "Configure the signature appended to your outgoing emails."
623 case 4:
624 tip = "Toggle displaying helpful contextual tips like this one."
625 case 5:
626 tip = "Toggle desktop notifications when new mail arrives."
627 case 6:
628 tip = "Manage groups of email addresses to quickly send to multiple people."
629 }
630 if tip != "" {
631 b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
632 }
633 }
634
635 mainContent := b.String()
636 helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
637
638 if m.height > 0 {
639 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
640 gap := m.height - currentHeight
641 if gap > 0 {
642 mainContent += strings.Repeat("\n", gap)
643 }
644 } else {
645 mainContent += "\n\n"
646 }
647
648 return docStyle.Render(mainContent + helpView)
649}
650
651func (m *Settings) viewAccounts() string {
652 var b strings.Builder
653
654 b.WriteString(titleStyle.Render("Account Settings"))
655 b.WriteString("\n\n")
656
657 if len(m.cfg.Accounts) == 0 {
658 b.WriteString(accountEmailStyle.Render(" No accounts configured.\n"))
659 b.WriteString("\n")
660 }
661
662 for i, account := range m.cfg.Accounts {
663 displayName := account.Email
664 if account.Name != "" {
665 displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
666 }
667
668 providerInfo := account.ServiceProvider
669 if account.ServiceProvider == "custom" {
670 providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
671 }
672
673 if account.SMIMECert != "" && account.SMIMEKey != "" {
674 providerInfo += " [S/MIME Configured]"
675 }
676 if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
677 providerInfo += " [PGP Configured]"
678 }
679
680 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
681
682 if m.cursor == i {
683 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
684 } else {
685 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
686 }
687 b.WriteString("\n")
688 }
689
690 // Add Account option
691 addAccountText := "Add New Account"
692 if m.cursor == len(m.cfg.Accounts) {
693 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
694 } else {
695 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addAccountText)))
696 }
697 b.WriteString("\n")
698
699 mainContent := b.String()
700 helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
701
702 if m.height > 0 {
703 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
704 gap := m.height - currentHeight
705 if gap > 0 {
706 mainContent += strings.Repeat("\n", gap)
707 }
708 } else {
709 mainContent += "\n\n"
710 }
711
712 if m.confirmingDelete {
713 accountName := m.cfg.Accounts[m.cursor].Email
714 dialog := DialogBoxStyle.Render(
715 lipgloss.JoinVertical(lipgloss.Center,
716 dangerStyle.Render("Delete account?"),
717 accountEmailStyle.Render(accountName),
718 HelpStyle.Render("\n(y/n)"),
719 ),
720 )
721 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
722 }
723
724 return docStyle.Render(mainContent + helpView)
725}
726
727func (m *Settings) viewSMIMEConfig() string {
728 var b strings.Builder
729
730 account := m.cfg.Accounts[m.editingAccountIdx]
731 b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail)))
732 b.WriteString("\n\n")
733
734 // --- S/MIME Section ---
735 b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n")
736
737 if m.focusIndex == 0 {
738 b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
739 } else {
740 b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
741 }
742 b.WriteString(m.smimeCertInput.View() + "\n\n")
743
744 if m.focusIndex == 1 {
745 b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
746 } else {
747 b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
748 }
749 b.WriteString(m.smimeKeyInput.View() + "\n\n")
750
751 smimeSignStatus := "OFF"
752 if account.SMIMESignByDefault {
753 smimeSignStatus = "ON"
754 }
755 if m.focusIndex == 2 {
756 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", smimeSignStatus)))
757 } else {
758 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", smimeSignStatus)))
759 }
760
761 // --- PGP Section ---
762 b.WriteString(settingsFocusedStyle.Render("PGP") + "\n")
763
764 if m.focusIndex == 3 {
765 b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n"))
766 } else {
767 b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n"))
768 }
769 b.WriteString(m.pgpPublicKeyInput.View() + "\n\n")
770
771 if m.focusIndex == 4 {
772 b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n"))
773 } else {
774 b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n"))
775 }
776 b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n")
777
778 // Key source toggle
779 keySourceDisplay := "File"
780 if m.pgpKeySource == "yubikey" {
781 keySourceDisplay = "YubiKey"
782 }
783 if m.focusIndex == 5 {
784 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Key Source: %s\n\n", keySourceDisplay)))
785 } else {
786 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Key Source: %s\n\n", keySourceDisplay)))
787 }
788
789 // PIN input (only shown if YubiKey is selected)
790 if m.pgpKeySource == "yubikey" {
791 if m.focusIndex == 6 {
792 b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n"))
793 } else {
794 b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n"))
795 }
796 b.WriteString(m.pgpPINInput.View() + "\n\n")
797 }
798
799 pgpSignStatus := "OFF"
800 if account.PGPSignByDefault {
801 pgpSignStatus = "ON"
802 }
803 if m.focusIndex == 7 {
804 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", pgpSignStatus)))
805 } else {
806 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", pgpSignStatus)))
807 }
808
809 pgpEncryptStatus := "OFF"
810 if account.PGPEncryptByDefault {
811 pgpEncryptStatus = "ON"
812 }
813 if m.focusIndex == 8 {
814 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Encrypt By Default: %s\n\n", pgpEncryptStatus)))
815 } else {
816 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Encrypt By Default: %s\n\n", pgpEncryptStatus)))
817 }
818
819 // --- Buttons ---
820 saveBtn := "[ Save ]"
821 cancelBtn := "[ Cancel ]"
822
823 if m.focusIndex == 9 {
824 saveBtn = settingsFocusedStyle.Render(saveBtn)
825 } else {
826 saveBtn = settingsBlurredStyle.Render(saveBtn)
827 }
828
829 if m.focusIndex == 10 {
830 cancelBtn = settingsFocusedStyle.Render(cancelBtn)
831 } else {
832 cancelBtn = settingsBlurredStyle.Render(cancelBtn)
833 }
834
835 b.WriteString(saveBtn + " " + cancelBtn + "\n\n")
836
837 mainContent := b.String()
838 helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back")
839
840 if m.height > 0 {
841 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
842 gap := m.height - currentHeight
843 if gap > 0 {
844 mainContent += strings.Repeat("\n", gap)
845 }
846 } else {
847 mainContent += "\n\n"
848 }
849
850 return docStyle.Render(mainContent + helpView)
851}
852
853func (m *Settings) viewMailingLists() string {
854 var b strings.Builder
855
856 b.WriteString(titleStyle.Render("Mailing Lists"))
857 b.WriteString("\n\n")
858
859 if len(m.cfg.MailingLists) == 0 {
860 b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n"))
861 b.WriteString("\n")
862 }
863
864 for i, list := range m.cfg.MailingLists {
865 displayName := list.Name
866
867 addrCount := fmt.Sprintf("%d address", len(list.Addresses))
868 if len(list.Addresses) != 1 {
869 addrCount += "es"
870 }
871
872 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
873
874 if m.cursor == i {
875 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
876 } else {
877 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
878 }
879 b.WriteString("\n")
880 }
881
882 // Add Mailing List option
883 addListText := "Add New Mailing List"
884 if m.cursor == len(m.cfg.MailingLists) {
885 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
886 } else {
887 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addListText)))
888 }
889 b.WriteString("\n")
890
891 helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
892 mainContent := b.String()
893
894 if m.height > 0 {
895 contentHeight := strings.Count(mainContent, "\n") + 1
896 gap := m.height - contentHeight - 2 // -2 for margins
897 if gap > 0 {
898 mainContent += strings.Repeat("\n", gap)
899 }
900 } else {
901 mainContent += "\n\n"
902 }
903
904 if m.confirmingDelete {
905 listName := m.cfg.MailingLists[m.cursor].Name
906 dialog := DialogBoxStyle.Render(
907 lipgloss.JoinVertical(lipgloss.Center,
908 dangerStyle.Render("Delete mailing list?"),
909 accountEmailStyle.Render(listName),
910 HelpStyle.Render("\n(y/n)"),
911 ),
912 )
913 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
914 }
915
916 return docStyle.Render(mainContent + helpView)
917}
918
919func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
920 themes := theme.AllThemes()
921
922 switch msg.String() {
923 case "up", "k":
924 if m.cursor > 0 {
925 m.cursor--
926 }
927 case "down", "j":
928 if m.cursor < len(themes)-1 {
929 m.cursor++
930 }
931 case "enter":
932 if m.cursor < len(themes) {
933 selected := themes[m.cursor]
934 theme.SetTheme(selected.Name)
935 RebuildStyles()
936 m.cfg.Theme = selected.Name
937 _ = config.SaveConfig(m.cfg)
938 }
939 m.state = SettingsMain
940 m.cursor = 1 // Return to Theme option
941 return m, nil
942 case "esc":
943 m.state = SettingsMain
944 m.cursor = 1 // Return to Theme option
945 return m, nil
946 }
947 return m, nil
948}
949
950func (m *Settings) viewTheme() string {
951 themes := theme.AllThemes()
952
953 // Build left panel: theme list
954 var left strings.Builder
955 left.WriteString(titleStyle.Render("Theme") + "\n\n")
956
957 for i, t := range themes {
958 isActive := t.Name == theme.ActiveTheme.Name
959
960 label := t.Name
961 if isActive {
962 label += " (active)"
963 }
964
965 if m.cursor == i {
966 left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
967 } else {
968 left.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", label)))
969 }
970 left.WriteString("\n")
971 }
972
973 left.WriteString("\n")
974 if !m.cfg.HideTips {
975 left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
976 }
977
978 // Build right panel: theme preview
979 var previewTheme theme.Theme
980 if m.cursor < len(themes) {
981 previewTheme = themes[m.cursor]
982 } else {
983 previewTheme = theme.ActiveTheme
984 }
985 preview := renderThemePreview(previewTheme, m.width)
986
987 // Join panels side by side
988 leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
989 content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", preview)
990
991 helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
992
993 if m.height > 0 {
994 currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
995 gap := m.height - currentHeight
996 if gap > 0 {
997 content += strings.Repeat("\n", gap)
998 }
999 } else {
1000 content += "\n\n"
1001 }
1002
1003 return docStyle.Render(content + "\n" + helpView)
1004}
1005
1006// renderThemePreview renders a small mockup showing how a theme looks.
1007func renderThemePreview(t theme.Theme, maxWidth int) string {
1008 previewWidth := maxWidth - 38 // 30 for left panel + padding/margins
1009 if previewWidth < 30 {
1010 previewWidth = 30
1011 }
1012 if previewWidth > 60 {
1013 previewWidth = 60
1014 }
1015
1016 accent := lipgloss.NewStyle().Foreground(t.Accent)
1017 accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
1018 secondary := lipgloss.NewStyle().Foreground(t.Secondary)
1019 muted := lipgloss.NewStyle().Foreground(t.MutedText)
1020 dim := lipgloss.NewStyle().Foreground(t.DimText)
1021 danger := lipgloss.NewStyle().Foreground(t.Danger)
1022 warn := lipgloss.NewStyle().Foreground(t.Warning)
1023 tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
1024 link := lipgloss.NewStyle().Foreground(t.Link)
1025 title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
1026 activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
1027 activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
1028
1029 var b strings.Builder
1030
1031 b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
1032
1033 // Fake inbox tabs
1034 b.WriteString(activeTab.Render("Inbox") + " " + secondary.Render("Sent") + " " + secondary.Render("Drafts") + "\n")
1035 b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
1036
1037 // Fake email list
1038 b.WriteString(accentBold.Render("> ") + dim.Render("Alice ") + accent.Render("Meeting tomorrow") + " " + muted.Render("2m ago") + "\n")
1039 b.WriteString(" " + dim.Render("Bob ") + secondary.Render("Re: Project update") + " " + muted.Render("1h ago") + "\n")
1040 b.WriteString(" " + dim.Render("Carol ") + secondary.Render("Quick question") + " " + muted.Render("3h ago") + "\n\n")
1041
1042 // Folder sidebar sample
1043 b.WriteString(accentBold.Render("Folders") + "\n")
1044 b.WriteString(activeFolder.Render(" INBOX ") + " " + secondary.Render("Sent") + " " + secondary.Render("Trash") + "\n\n")
1045
1046 // Status indicators
1047 b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
1048 b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
1049 b.WriteString(warn.Render("Update available: v2.0") + "\n")
1050 b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
1051 b.WriteString(link.Render("https://example.com") + "\n")
1052
1053 box := lipgloss.NewStyle().
1054 Border(lipgloss.RoundedBorder()).
1055 BorderForeground(t.AccentDark).
1056 Padding(1, 2).
1057 Width(previewWidth).
1058 Render(b.String())
1059
1060 return box
1061}
1062
1063// UpdateConfig updates the configuration (used when accounts are deleted).
1064func (m *Settings) UpdateConfig(cfg *config.Config) {
1065 m.cfg = cfg
1066 if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
1067 m.cursor = len(cfg.Accounts)
1068 }
1069}