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: Save button
316// 9: Cancel button
317const cryptoConfigMaxFocus = 9
318
319func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
320 var cmds []tea.Cmd
321 var cmd tea.Cmd
322
323 switch msg.String() {
324 case "esc":
325 m.state = SettingsAccounts
326 return m, nil
327 case "tab", "shift+tab", "up", "down":
328 if msg.String() == "shift+tab" || msg.String() == "up" {
329 m.focusIndex--
330 if m.focusIndex < 0 {
331 m.focusIndex = cryptoConfigMaxFocus
332 }
333 } else {
334 m.focusIndex++
335 if m.focusIndex > cryptoConfigMaxFocus {
336 m.focusIndex = 0
337 }
338 }
339
340 m.smimeCertInput.Blur()
341 m.smimeKeyInput.Blur()
342 m.pgpPublicKeyInput.Blur()
343 m.pgpPrivateKeyInput.Blur()
344 m.pgpPINInput.Blur()
345
346 switch m.focusIndex {
347 case 0:
348 cmds = append(cmds, m.smimeCertInput.Focus())
349 case 1:
350 cmds = append(cmds, m.smimeKeyInput.Focus())
351 case 3:
352 cmds = append(cmds, m.pgpPublicKeyInput.Focus())
353 case 4:
354 cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
355 case 6:
356 cmds = append(cmds, m.pgpPINInput.Focus())
357 }
358 return m, tea.Batch(cmds...)
359 case "enter", " ":
360 switch m.focusIndex {
361 case 0: // S/MIME cert - enter advances to next field
362 if msg.String() == "enter" {
363 m.focusIndex = 1
364 m.smimeCertInput.Blur()
365 cmds = append(cmds, m.smimeKeyInput.Focus())
366 return m, tea.Batch(cmds...)
367 }
368 case 1: // S/MIME key - enter advances
369 if msg.String() == "enter" {
370 m.focusIndex = 2
371 m.smimeKeyInput.Blur()
372 return m, nil
373 }
374 case 2: // S/MIME sign toggle
375 if msg.String() == "enter" || msg.String() == " " {
376 m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
377 }
378 return m, nil
379 case 3: // PGP public key - enter advances
380 if msg.String() == "enter" {
381 m.focusIndex = 4
382 m.pgpPublicKeyInput.Blur()
383 cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
384 return m, tea.Batch(cmds...)
385 }
386 case 4: // PGP private key - enter advances
387 if msg.String() == "enter" {
388 m.focusIndex = 5
389 m.pgpPrivateKeyInput.Blur()
390 return m, nil
391 }
392 case 5: // PGP key source toggle (file/yubikey)
393 if msg.String() == "enter" || msg.String() == " " {
394 if m.pgpKeySource == "file" {
395 m.pgpKeySource = "yubikey"
396 } else {
397 m.pgpKeySource = "file"
398 }
399 }
400 return m, nil
401 case 6: // PGP PIN input - enter advances
402 if msg.String() == "enter" {
403 m.focusIndex = 7
404 m.pgpPINInput.Blur()
405 return m, nil
406 }
407 case 7: // PGP sign toggle
408 if msg.String() == "enter" || msg.String() == " " {
409 m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault
410 }
411 return m, nil
412 case 8: // Save
413 if msg.String() == "enter" {
414 m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
415 m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
416 m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value()
417 m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value()
418 m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource
419 m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value()
420 _ = config.SaveConfig(m.cfg)
421 m.state = SettingsAccounts
422 return m, nil
423 }
424 case 9: // Cancel
425 if msg.String() == "enter" {
426 m.state = SettingsAccounts
427 return m, nil
428 }
429 }
430 }
431
432 switch m.focusIndex {
433 case 0:
434 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
435 cmds = append(cmds, cmd)
436 case 1:
437 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
438 cmds = append(cmds, cmd)
439 case 3:
440 m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
441 cmds = append(cmds, cmd)
442 case 4:
443 m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
444 cmds = append(cmds, cmd)
445 case 6:
446 m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
447 cmds = append(cmds, cmd)
448 }
449
450 return m, tea.Batch(cmds...)
451}
452
453func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
454 if m.confirmingDelete {
455 switch msg.String() {
456 case "y", "Y":
457 if m.cursor < len(m.cfg.MailingLists) {
458 m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
459 _ = config.SaveConfig(m.cfg)
460 if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
461 m.cursor--
462 }
463 m.confirmingDelete = false
464 }
465 case "n", "N", "esc":
466 m.confirmingDelete = false
467 return m, nil
468 }
469 return m, nil
470 }
471
472 switch msg.String() {
473 case "up", "k":
474 if m.cursor > 0 {
475 m.cursor--
476 }
477 case "down", "j":
478 if m.cursor < len(m.cfg.MailingLists) {
479 m.cursor++
480 }
481 case "d":
482 if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
483 m.confirmingDelete = true
484 }
485 case "e":
486 // Edit selected mailing list
487 if m.cursor < len(m.cfg.MailingLists) {
488 list := m.cfg.MailingLists[m.cursor]
489 idx := m.cursor
490 return m, func() tea.Msg {
491 return GoToEditMailingListMsg{
492 Index: idx,
493 Name: list.Name,
494 Addresses: strings.Join(list.Addresses, ", "),
495 }
496 }
497 }
498 case "enter":
499 if m.cursor == len(m.cfg.MailingLists) {
500 return m, func() tea.Msg { return GoToAddMailingListMsg{} }
501 }
502 case "esc":
503 m.state = SettingsMain
504 m.cursor = 0
505 return m, nil
506 }
507 return m, nil
508}
509
510// View renders the settings screen.
511func (m *Settings) View() tea.View {
512 if m.state == SettingsMain {
513 return tea.NewView(m.viewMain())
514 } else if m.state == SettingsTheme {
515 return tea.NewView(m.viewTheme())
516 } else if m.state == SettingsMailingLists {
517 return tea.NewView(m.viewMailingLists())
518 } else if m.state == SettingsSMIMEConfig {
519 return tea.NewView(m.viewSMIMEConfig())
520 }
521 return tea.NewView(m.viewAccounts())
522}
523
524func (m *Settings) viewMain() string {
525 var b strings.Builder
526
527 b.WriteString(titleStyle.Render("Settings") + "\n\n")
528
529 // Option 0: Email Accounts
530 if m.cursor == 0 {
531 b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
532 } else {
533 b.WriteString(accountItemStyle.Render(" Email Accounts"))
534 }
535 b.WriteString("\n")
536
537 // Option 1: Theme
538 themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
539 if m.cursor == 1 {
540 b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
541 } else {
542 b.WriteString(accountItemStyle.Render(" " + themeText))
543 }
544 b.WriteString("\n")
545
546 // Option 2: Image Display
547 status := "ON"
548 if m.cfg.DisableImages {
549 status = "OFF"
550 }
551 text := fmt.Sprintf("Image Display: %s", status)
552 if m.cursor == 2 {
553 b.WriteString(selectedAccountItemStyle.Render("> " + text))
554 } else {
555 b.WriteString(accountItemStyle.Render(" " + text))
556 }
557 b.WriteString("\n")
558
559 // Option 3: Edit Signature
560 sigText := "Edit Signature"
561 if config.HasSignature() {
562 sigText = "Edit Signature (configured)"
563 }
564 if m.cursor == 3 {
565 b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
566 } else {
567 b.WriteString(accountItemStyle.Render(" " + sigText))
568 }
569 b.WriteString("\n")
570
571 // Option 4: Contextual Tips
572 tipsStatus := "ON"
573 if m.cfg.HideTips {
574 tipsStatus = "OFF"
575 }
576 tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
577 if m.cursor == 4 {
578 b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
579 } else {
580 b.WriteString(accountItemStyle.Render(" " + tipsText))
581 }
582 b.WriteString("\n")
583
584 // Option 5: Desktop Notifications
585 notifStatus := "ON"
586 if m.cfg.DisableNotifications {
587 notifStatus = "OFF"
588 }
589 notifText := fmt.Sprintf("Desktop Notifications: %s", notifStatus)
590 if m.cursor == 5 {
591 b.WriteString(selectedAccountItemStyle.Render("> " + notifText))
592 } else {
593 b.WriteString(accountItemStyle.Render(" " + notifText))
594 }
595 b.WriteString("\n")
596
597 // Option 6: Mailing Lists
598 mailingListsText := "Mailing Lists"
599 if m.cursor == 6 {
600 b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
601 } else {
602 b.WriteString(accountItemStyle.Render(" " + mailingListsText))
603 }
604 b.WriteString("\n\n")
605
606 if !m.cfg.HideTips {
607 tip := ""
608 switch m.cursor {
609 case 0:
610 tip = "Manage your connected email accounts."
611 case 1:
612 tip = "Choose a color theme for the application."
613 case 2:
614 tip = "Toggle displaying images in emails."
615 case 3:
616 tip = "Configure the signature appended to your outgoing emails."
617 case 4:
618 tip = "Toggle displaying helpful contextual tips like this one."
619 case 5:
620 tip = "Toggle desktop notifications when new mail arrives."
621 case 6:
622 tip = "Manage groups of email addresses to quickly send to multiple people."
623 }
624 if tip != "" {
625 b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
626 }
627 }
628
629 mainContent := b.String()
630 helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
631
632 if m.height > 0 {
633 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
634 gap := m.height - currentHeight
635 if gap > 0 {
636 mainContent += strings.Repeat("\n", gap)
637 }
638 } else {
639 mainContent += "\n\n"
640 }
641
642 return docStyle.Render(mainContent + helpView)
643}
644
645func (m *Settings) viewAccounts() string {
646 var b strings.Builder
647
648 b.WriteString(titleStyle.Render("Account Settings"))
649 b.WriteString("\n\n")
650
651 if len(m.cfg.Accounts) == 0 {
652 b.WriteString(accountEmailStyle.Render(" No accounts configured.\n"))
653 b.WriteString("\n")
654 }
655
656 for i, account := range m.cfg.Accounts {
657 displayName := account.Email
658 if account.Name != "" {
659 displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
660 }
661
662 providerInfo := account.ServiceProvider
663 if account.ServiceProvider == "custom" {
664 providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
665 }
666
667 if account.SMIMECert != "" && account.SMIMEKey != "" {
668 providerInfo += " [S/MIME Configured]"
669 }
670 if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
671 providerInfo += " [PGP Configured]"
672 }
673
674 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
675
676 if m.cursor == i {
677 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
678 } else {
679 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
680 }
681 b.WriteString("\n")
682 }
683
684 // Add Account option
685 addAccountText := "Add New Account"
686 if m.cursor == len(m.cfg.Accounts) {
687 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
688 } else {
689 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addAccountText)))
690 }
691 b.WriteString("\n")
692
693 mainContent := b.String()
694 helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
695
696 if m.height > 0 {
697 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
698 gap := m.height - currentHeight
699 if gap > 0 {
700 mainContent += strings.Repeat("\n", gap)
701 }
702 } else {
703 mainContent += "\n\n"
704 }
705
706 if m.confirmingDelete {
707 accountName := m.cfg.Accounts[m.cursor].Email
708 dialog := DialogBoxStyle.Render(
709 lipgloss.JoinVertical(lipgloss.Center,
710 dangerStyle.Render("Delete account?"),
711 accountEmailStyle.Render(accountName),
712 HelpStyle.Render("\n(y/n)"),
713 ),
714 )
715 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
716 }
717
718 return docStyle.Render(mainContent + helpView)
719}
720
721func (m *Settings) viewSMIMEConfig() string {
722 var b strings.Builder
723
724 account := m.cfg.Accounts[m.editingAccountIdx]
725 b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail)))
726 b.WriteString("\n\n")
727
728 // --- S/MIME Section ---
729 b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n")
730
731 if m.focusIndex == 0 {
732 b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
733 } else {
734 b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
735 }
736 b.WriteString(m.smimeCertInput.View() + "\n\n")
737
738 if m.focusIndex == 1 {
739 b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
740 } else {
741 b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
742 }
743 b.WriteString(m.smimeKeyInput.View() + "\n\n")
744
745 smimeSignStatus := "OFF"
746 if account.SMIMESignByDefault {
747 smimeSignStatus = "ON"
748 }
749 if m.focusIndex == 2 {
750 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", smimeSignStatus)))
751 } else {
752 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", smimeSignStatus)))
753 }
754
755 // --- PGP Section ---
756 b.WriteString(settingsFocusedStyle.Render("PGP") + "\n")
757
758 if m.focusIndex == 3 {
759 b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n"))
760 } else {
761 b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n"))
762 }
763 b.WriteString(m.pgpPublicKeyInput.View() + "\n\n")
764
765 if m.focusIndex == 4 {
766 b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n"))
767 } else {
768 b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n"))
769 }
770 b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n")
771
772 // Key source toggle
773 keySourceDisplay := "File"
774 if m.pgpKeySource == "yubikey" {
775 keySourceDisplay = "YubiKey"
776 }
777 if m.focusIndex == 5 {
778 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Key Source: %s\n\n", keySourceDisplay)))
779 } else {
780 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Key Source: %s\n\n", keySourceDisplay)))
781 }
782
783 // PIN input (only shown if YubiKey is selected)
784 if m.pgpKeySource == "yubikey" {
785 if m.focusIndex == 6 {
786 b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n"))
787 } else {
788 b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n"))
789 }
790 b.WriteString(m.pgpPINInput.View() + "\n\n")
791 }
792
793 pgpSignStatus := "OFF"
794 if account.PGPSignByDefault {
795 pgpSignStatus = "ON"
796 }
797 if m.focusIndex == 7 {
798 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", pgpSignStatus)))
799 } else {
800 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", pgpSignStatus)))
801 }
802
803 // --- Buttons ---
804 saveBtn := "[ Save ]"
805 cancelBtn := "[ Cancel ]"
806
807 if m.focusIndex == 8 {
808 saveBtn = settingsFocusedStyle.Render(saveBtn)
809 } else {
810 saveBtn = settingsBlurredStyle.Render(saveBtn)
811 }
812
813 if m.focusIndex == 9 {
814 cancelBtn = settingsFocusedStyle.Render(cancelBtn)
815 } else {
816 cancelBtn = settingsBlurredStyle.Render(cancelBtn)
817 }
818
819 b.WriteString(saveBtn + " " + cancelBtn + "\n\n")
820
821 mainContent := b.String()
822 helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back")
823
824 if m.height > 0 {
825 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
826 gap := m.height - currentHeight
827 if gap > 0 {
828 mainContent += strings.Repeat("\n", gap)
829 }
830 } else {
831 mainContent += "\n\n"
832 }
833
834 return docStyle.Render(mainContent + helpView)
835}
836
837func (m *Settings) viewMailingLists() string {
838 var b strings.Builder
839
840 b.WriteString(titleStyle.Render("Mailing Lists"))
841 b.WriteString("\n\n")
842
843 if len(m.cfg.MailingLists) == 0 {
844 b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n"))
845 b.WriteString("\n")
846 }
847
848 for i, list := range m.cfg.MailingLists {
849 displayName := list.Name
850
851 addrCount := fmt.Sprintf("%d address", len(list.Addresses))
852 if len(list.Addresses) != 1 {
853 addrCount += "es"
854 }
855
856 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
857
858 if m.cursor == i {
859 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
860 } else {
861 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
862 }
863 b.WriteString("\n")
864 }
865
866 // Add Mailing List option
867 addListText := "Add New Mailing List"
868 if m.cursor == len(m.cfg.MailingLists) {
869 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
870 } else {
871 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addListText)))
872 }
873 b.WriteString("\n")
874
875 helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
876 mainContent := b.String()
877
878 if m.height > 0 {
879 contentHeight := strings.Count(mainContent, "\n") + 1
880 gap := m.height - contentHeight - 2 // -2 for margins
881 if gap > 0 {
882 mainContent += strings.Repeat("\n", gap)
883 }
884 } else {
885 mainContent += "\n\n"
886 }
887
888 if m.confirmingDelete {
889 listName := m.cfg.MailingLists[m.cursor].Name
890 dialog := DialogBoxStyle.Render(
891 lipgloss.JoinVertical(lipgloss.Center,
892 dangerStyle.Render("Delete mailing list?"),
893 accountEmailStyle.Render(listName),
894 HelpStyle.Render("\n(y/n)"),
895 ),
896 )
897 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
898 }
899
900 return docStyle.Render(mainContent + helpView)
901}
902
903func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
904 themes := theme.AllThemes()
905
906 switch msg.String() {
907 case "up", "k":
908 if m.cursor > 0 {
909 m.cursor--
910 }
911 case "down", "j":
912 if m.cursor < len(themes)-1 {
913 m.cursor++
914 }
915 case "enter":
916 if m.cursor < len(themes) {
917 selected := themes[m.cursor]
918 theme.SetTheme(selected.Name)
919 RebuildStyles()
920 m.cfg.Theme = selected.Name
921 _ = config.SaveConfig(m.cfg)
922 }
923 m.state = SettingsMain
924 m.cursor = 1 // Return to Theme option
925 return m, nil
926 case "esc":
927 m.state = SettingsMain
928 m.cursor = 1 // Return to Theme option
929 return m, nil
930 }
931 return m, nil
932}
933
934func (m *Settings) viewTheme() string {
935 themes := theme.AllThemes()
936
937 // Build left panel: theme list
938 var left strings.Builder
939 left.WriteString(titleStyle.Render("Theme") + "\n\n")
940
941 for i, t := range themes {
942 isActive := t.Name == theme.ActiveTheme.Name
943
944 label := t.Name
945 if isActive {
946 label += " (active)"
947 }
948
949 if m.cursor == i {
950 left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
951 } else {
952 left.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", label)))
953 }
954 left.WriteString("\n")
955 }
956
957 left.WriteString("\n")
958 if !m.cfg.HideTips {
959 left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
960 }
961
962 // Build right panel: theme preview
963 var previewTheme theme.Theme
964 if m.cursor < len(themes) {
965 previewTheme = themes[m.cursor]
966 } else {
967 previewTheme = theme.ActiveTheme
968 }
969 preview := renderThemePreview(previewTheme, m.width)
970
971 // Join panels side by side
972 leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
973 content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", preview)
974
975 helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
976
977 if m.height > 0 {
978 currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
979 gap := m.height - currentHeight
980 if gap > 0 {
981 content += strings.Repeat("\n", gap)
982 }
983 } else {
984 content += "\n\n"
985 }
986
987 return docStyle.Render(content + "\n" + helpView)
988}
989
990// renderThemePreview renders a small mockup showing how a theme looks.
991func renderThemePreview(t theme.Theme, maxWidth int) string {
992 previewWidth := maxWidth - 38 // 30 for left panel + padding/margins
993 if previewWidth < 30 {
994 previewWidth = 30
995 }
996 if previewWidth > 60 {
997 previewWidth = 60
998 }
999
1000 accent := lipgloss.NewStyle().Foreground(t.Accent)
1001 accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
1002 secondary := lipgloss.NewStyle().Foreground(t.Secondary)
1003 muted := lipgloss.NewStyle().Foreground(t.MutedText)
1004 dim := lipgloss.NewStyle().Foreground(t.DimText)
1005 danger := lipgloss.NewStyle().Foreground(t.Danger)
1006 warn := lipgloss.NewStyle().Foreground(t.Warning)
1007 tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
1008 link := lipgloss.NewStyle().Foreground(t.Link)
1009 title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
1010 activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
1011 activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
1012
1013 var b strings.Builder
1014
1015 b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
1016
1017 // Fake inbox tabs
1018 b.WriteString(activeTab.Render("Inbox") + " " + secondary.Render("Sent") + " " + secondary.Render("Drafts") + "\n")
1019 b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
1020
1021 // Fake email list
1022 b.WriteString(accentBold.Render("> ") + dim.Render("Alice ") + accent.Render("Meeting tomorrow") + " " + muted.Render("2m ago") + "\n")
1023 b.WriteString(" " + dim.Render("Bob ") + secondary.Render("Re: Project update") + " " + muted.Render("1h ago") + "\n")
1024 b.WriteString(" " + dim.Render("Carol ") + secondary.Render("Quick question") + " " + muted.Render("3h ago") + "\n\n")
1025
1026 // Folder sidebar sample
1027 b.WriteString(accentBold.Render("Folders") + "\n")
1028 b.WriteString(activeFolder.Render(" INBOX ") + " " + secondary.Render("Sent") + " " + secondary.Render("Trash") + "\n\n")
1029
1030 // Status indicators
1031 b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
1032 b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
1033 b.WriteString(warn.Render("Update available: v2.0") + "\n")
1034 b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
1035 b.WriteString(link.Render("https://example.com") + "\n")
1036
1037 box := lipgloss.NewStyle().
1038 Border(lipgloss.RoundedBorder()).
1039 BorderForeground(t.AccentDark).
1040 Padding(1, 2).
1041 Width(previewWidth).
1042 Render(b.String())
1043
1044 return box
1045}
1046
1047// UpdateConfig updates the configuration (used when accounts are deleted).
1048func (m *Settings) UpdateConfig(cfg *config.Config) {
1049 m.cfg = cfg
1050 if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
1051 m.cursor = len(cfg.Accounts)
1052 }
1053}