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