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
50// NewSettings creates a new settings model.
51func NewSettings(cfg *config.Config) *Settings {
52 if cfg == nil {
53 cfg = &config.Config{}
54 }
55
56 tiStyles := ThemedTextInputStyles()
57
58 certInput := textinput.New()
59 certInput.Placeholder = "/path/to/cert.pem"
60 certInput.Prompt = "> "
61 certInput.CharLimit = 256
62 certInput.SetStyles(tiStyles)
63
64 keyInput := textinput.New()
65 keyInput.Placeholder = "/path/to/private_key.pem"
66 keyInput.Prompt = "> "
67 keyInput.CharLimit = 256
68 keyInput.SetStyles(tiStyles)
69
70 return &Settings{
71 cfg: cfg,
72 state: SettingsMain,
73 cursor: 0,
74 smimeCertInput: certInput,
75 smimeKeyInput: keyInput,
76 }
77}
78
79// Init initializes the settings model.
80func (m *Settings) Init() tea.Cmd {
81 return textinput.Blink
82}
83
84// Update handles messages for the settings model.
85func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86 var cmds []tea.Cmd
87 var cmd tea.Cmd
88
89 switch msg := msg.(type) {
90 case tea.WindowSizeMsg:
91 m.width = msg.Width
92 m.height = msg.Height
93 m.smimeCertInput.SetWidth(m.width - 6)
94 m.smimeKeyInput.SetWidth(m.width - 6)
95 return m, nil
96
97 case tea.KeyPressMsg:
98 if m.state == SettingsMain {
99 return m.updateMain(msg)
100 } else if m.state == SettingsTheme {
101 return m.updateTheme(msg)
102 } else if m.state == SettingsMailingLists {
103 return m.updateMailingLists(msg)
104 } else if m.state == SettingsSMIMEConfig {
105 var m2 *Settings
106 m2, cmd = m.updateSMIMEConfig(msg)
107 cmds = append(cmds, cmd)
108 return m2, tea.Batch(cmds...)
109 } else {
110 return m.updateAccounts(msg)
111 }
112 }
113
114 if m.state == SettingsSMIMEConfig {
115 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
116 cmds = append(cmds, cmd)
117 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
118 cmds = append(cmds, cmd)
119 }
120
121 return m, tea.Batch(cmds...)
122}
123
124func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
125 switch msg.String() {
126 case "up", "k":
127 if m.cursor > 0 {
128 m.cursor--
129 }
130 case "down", "j":
131 // Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Mailing Lists
132 if m.cursor < 5 {
133 m.cursor++
134 }
135 case "enter":
136 switch m.cursor {
137 case 0: // Email Accounts
138 m.state = SettingsAccounts
139 m.cursor = 0
140 return m, nil
141 case 1: // Theme
142 m.state = SettingsTheme
143 // Position cursor on the currently active theme
144 themes := theme.AllThemes()
145 m.cursor = 0
146 for i, t := range themes {
147 if t.Name == theme.ActiveTheme.Name {
148 m.cursor = i
149 break
150 }
151 }
152 return m, nil
153 case 2: // Image Display
154 m.cfg.DisableImages = !m.cfg.DisableImages
155 _ = config.SaveConfig(m.cfg)
156 return m, nil
157 case 3: // Edit Signature
158 return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
159 case 4: // Contextual Tips
160 m.cfg.HideTips = !m.cfg.HideTips
161 _ = config.SaveConfig(m.cfg)
162 return m, nil
163 case 5: // Mailing Lists
164 m.state = SettingsMailingLists
165 m.cursor = 0
166 return m, nil
167 }
168 case "esc":
169 return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
170 }
171 return m, nil
172}
173
174func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
175 if m.confirmingDelete {
176 switch msg.String() {
177 case "y", "Y":
178 if m.cursor < len(m.cfg.Accounts) {
179 accountID := m.cfg.Accounts[m.cursor].ID
180 m.confirmingDelete = false
181 return m, func() tea.Msg {
182 return DeleteAccountMsg{AccountID: accountID}
183 }
184 }
185 case "n", "N", "esc":
186 m.confirmingDelete = false
187 return m, nil
188 }
189 return m, nil
190 }
191
192 switch msg.String() {
193 case "up", "k":
194 if m.cursor > 0 {
195 m.cursor--
196 }
197 case "down", "j":
198 // +1 for "Add Account" option
199 if m.cursor < len(m.cfg.Accounts) {
200 m.cursor++
201 }
202 case "d":
203 // Delete selected account (not the "Add Account" option)
204 if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
205 m.confirmingDelete = true
206 }
207 case "e":
208 // Edit selected account
209 if m.cursor < len(m.cfg.Accounts) {
210 acc := m.cfg.Accounts[m.cursor]
211 return m, func() tea.Msg {
212 return GoToEditAccountMsg{
213 AccountID: acc.ID,
214 Provider: acc.ServiceProvider,
215 Name: acc.Name,
216 Email: acc.Email,
217 FetchEmail: acc.FetchEmail,
218 IMAPServer: acc.IMAPServer,
219 IMAPPort: acc.IMAPPort,
220 SMTPServer: acc.SMTPServer,
221 SMTPPort: acc.SMTPPort,
222 }
223 }
224 }
225 case "enter":
226 // If cursor is on "Add Account"
227 if m.cursor == len(m.cfg.Accounts) {
228 return m, func() tea.Msg { return GoToAddAccountMsg{} }
229 } else if m.cursor < len(m.cfg.Accounts) {
230 m.editingAccountIdx = m.cursor
231 m.state = SettingsSMIMEConfig
232 m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert)
233 m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey)
234 m.focusIndex = 0
235 m.smimeCertInput.Focus()
236 m.smimeKeyInput.Blur()
237 return m, textinput.Blink
238 }
239 case "esc":
240 m.state = SettingsMain
241 m.cursor = 0
242 return m, nil
243 }
244 return m, nil
245}
246
247func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
248 var cmds []tea.Cmd
249 var cmd tea.Cmd
250
251 switch msg.String() {
252 case "esc":
253 m.state = SettingsAccounts
254 return m, nil
255 case "tab", "shift+tab", "up", "down":
256 if msg.String() == "shift+tab" || msg.String() == "up" {
257 m.focusIndex--
258 if m.focusIndex < 0 {
259 m.focusIndex = 4
260 }
261 } else {
262 m.focusIndex++
263 if m.focusIndex > 4 {
264 m.focusIndex = 0
265 }
266 }
267
268 m.smimeCertInput.Blur()
269 m.smimeKeyInput.Blur()
270
271 if m.focusIndex == 0 {
272 cmds = append(cmds, m.smimeCertInput.Focus())
273 } else if m.focusIndex == 1 {
274 cmds = append(cmds, m.smimeKeyInput.Focus())
275 }
276 return m, tea.Batch(cmds...)
277 case "enter", " ":
278 if m.focusIndex == 0 && msg.String() == "enter" {
279 m.focusIndex = 1
280 m.smimeCertInput.Blur()
281 cmds = append(cmds, m.smimeKeyInput.Focus())
282 return m, tea.Batch(cmds...)
283 } else if m.focusIndex == 1 && msg.String() == "enter" {
284 m.focusIndex = 2
285 m.smimeKeyInput.Blur()
286 return m, nil
287 } else if m.focusIndex == 2 {
288 if msg.String() == "enter" || msg.String() == " " {
289 m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
290 }
291 return m, nil
292 } else if m.focusIndex == 3 && msg.String() == "enter" {
293 m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
294 m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
295 _ = config.SaveConfig(m.cfg)
296 m.state = SettingsAccounts
297 return m, nil
298 } else if m.focusIndex == 4 && msg.String() == "enter" {
299 m.state = SettingsAccounts
300 return m, nil
301 }
302 }
303
304 if m.focusIndex == 0 {
305 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
306 cmds = append(cmds, cmd)
307 } else if m.focusIndex == 1 {
308 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
309 cmds = append(cmds, cmd)
310 }
311
312 return m, tea.Batch(cmds...)
313}
314
315func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
316 if m.confirmingDelete {
317 switch msg.String() {
318 case "y", "Y":
319 if m.cursor < len(m.cfg.MailingLists) {
320 m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
321 _ = config.SaveConfig(m.cfg)
322 if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
323 m.cursor--
324 }
325 m.confirmingDelete = false
326 }
327 case "n", "N", "esc":
328 m.confirmingDelete = false
329 return m, nil
330 }
331 return m, nil
332 }
333
334 switch msg.String() {
335 case "up", "k":
336 if m.cursor > 0 {
337 m.cursor--
338 }
339 case "down", "j":
340 if m.cursor < len(m.cfg.MailingLists) {
341 m.cursor++
342 }
343 case "d":
344 if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
345 m.confirmingDelete = true
346 }
347 case "e":
348 // Edit selected mailing list
349 if m.cursor < len(m.cfg.MailingLists) {
350 list := m.cfg.MailingLists[m.cursor]
351 idx := m.cursor
352 return m, func() tea.Msg {
353 return GoToEditMailingListMsg{
354 Index: idx,
355 Name: list.Name,
356 Addresses: strings.Join(list.Addresses, ", "),
357 }
358 }
359 }
360 case "enter":
361 if m.cursor == len(m.cfg.MailingLists) {
362 return m, func() tea.Msg { return GoToAddMailingListMsg{} }
363 }
364 case "esc":
365 m.state = SettingsMain
366 m.cursor = 0
367 return m, nil
368 }
369 return m, nil
370}
371
372// View renders the settings screen.
373func (m *Settings) View() tea.View {
374 if m.state == SettingsMain {
375 return tea.NewView(m.viewMain())
376 } else if m.state == SettingsTheme {
377 return tea.NewView(m.viewTheme())
378 } else if m.state == SettingsMailingLists {
379 return tea.NewView(m.viewMailingLists())
380 } else if m.state == SettingsSMIMEConfig {
381 return tea.NewView(m.viewSMIMEConfig())
382 }
383 return tea.NewView(m.viewAccounts())
384}
385
386func (m *Settings) viewMain() string {
387 var b strings.Builder
388
389 b.WriteString(titleStyle.Render("Settings") + "\n\n")
390
391 // Option 0: Email Accounts
392 if m.cursor == 0 {
393 b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
394 } else {
395 b.WriteString(accountItemStyle.Render(" Email Accounts"))
396 }
397 b.WriteString("\n")
398
399 // Option 1: Theme
400 themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
401 if m.cursor == 1 {
402 b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
403 } else {
404 b.WriteString(accountItemStyle.Render(" " + themeText))
405 }
406 b.WriteString("\n")
407
408 // Option 2: Image Display
409 status := "ON"
410 if m.cfg.DisableImages {
411 status = "OFF"
412 }
413 text := fmt.Sprintf("Image Display: %s", status)
414 if m.cursor == 2 {
415 b.WriteString(selectedAccountItemStyle.Render("> " + text))
416 } else {
417 b.WriteString(accountItemStyle.Render(" " + text))
418 }
419 b.WriteString("\n")
420
421 // Option 3: Edit Signature
422 sigText := "Edit Signature"
423 if config.HasSignature() {
424 sigText = "Edit Signature (configured)"
425 }
426 if m.cursor == 3 {
427 b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
428 } else {
429 b.WriteString(accountItemStyle.Render(" " + sigText))
430 }
431 b.WriteString("\n")
432
433 // Option 4: Contextual Tips
434 tipsStatus := "ON"
435 if m.cfg.HideTips {
436 tipsStatus = "OFF"
437 }
438 tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
439 if m.cursor == 4 {
440 b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
441 } else {
442 b.WriteString(accountItemStyle.Render(" " + tipsText))
443 }
444 b.WriteString("\n")
445
446 // Option 5: Mailing Lists
447 mailingListsText := "Mailing Lists"
448 if m.cursor == 5 {
449 b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
450 } else {
451 b.WriteString(accountItemStyle.Render(" " + mailingListsText))
452 }
453 b.WriteString("\n\n")
454
455 if !m.cfg.HideTips {
456 tip := ""
457 switch m.cursor {
458 case 0:
459 tip = "Manage your connected email accounts."
460 case 1:
461 tip = "Choose a color theme for the application."
462 case 2:
463 tip = "Toggle displaying images in emails."
464 case 3:
465 tip = "Configure the signature appended to your outgoing emails."
466 case 4:
467 tip = "Toggle displaying helpful contextual tips like this one."
468 case 5:
469 tip = "Manage groups of email addresses to quickly send to multiple people."
470 }
471 if tip != "" {
472 b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
473 }
474 }
475
476 mainContent := b.String()
477 helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
478
479 if m.height > 0 {
480 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
481 gap := m.height - currentHeight
482 if gap > 0 {
483 mainContent += strings.Repeat("\n", gap)
484 }
485 } else {
486 mainContent += "\n\n"
487 }
488
489 return docStyle.Render(mainContent + helpView)
490}
491
492func (m *Settings) viewAccounts() string {
493 var b strings.Builder
494
495 b.WriteString(titleStyle.Render("Account Settings"))
496 b.WriteString("\n\n")
497
498 if len(m.cfg.Accounts) == 0 {
499 b.WriteString(accountEmailStyle.Render(" No accounts configured.\n"))
500 b.WriteString("\n")
501 }
502
503 for i, account := range m.cfg.Accounts {
504 displayName := account.Email
505 if account.Name != "" {
506 displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
507 }
508
509 providerInfo := account.ServiceProvider
510 if account.ServiceProvider == "custom" {
511 providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
512 }
513
514 if account.SMIMECert != "" && account.SMIMEKey != "" {
515 providerInfo += " [S/MIME Configured]"
516 }
517
518 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
519
520 if m.cursor == i {
521 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
522 } else {
523 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
524 }
525 b.WriteString("\n")
526 }
527
528 // Add Account option
529 addAccountText := "Add New Account"
530 if m.cursor == len(m.cfg.Accounts) {
531 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
532 } else {
533 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addAccountText)))
534 }
535 b.WriteString("\n")
536
537 mainContent := b.String()
538 helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
539
540 if m.height > 0 {
541 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
542 gap := m.height - currentHeight
543 if gap > 0 {
544 mainContent += strings.Repeat("\n", gap)
545 }
546 } else {
547 mainContent += "\n\n"
548 }
549
550 if m.confirmingDelete {
551 accountName := m.cfg.Accounts[m.cursor].Email
552 dialog := DialogBoxStyle.Render(
553 lipgloss.JoinVertical(lipgloss.Center,
554 dangerStyle.Render("Delete account?"),
555 accountEmailStyle.Render(accountName),
556 HelpStyle.Render("\n(y/n)"),
557 ),
558 )
559 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
560 }
561
562 return docStyle.Render(mainContent + helpView)
563}
564
565func (m *Settings) viewSMIMEConfig() string {
566 var b strings.Builder
567
568 account := m.cfg.Accounts[m.editingAccountIdx]
569 b.WriteString(titleStyle.Render(fmt.Sprintf("S/MIME Configuration for %s", account.FetchEmail)))
570 b.WriteString("\n\n")
571
572 if m.focusIndex == 0 {
573 b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
574 } else {
575 b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
576 }
577 b.WriteString(m.smimeCertInput.View() + "\n\n")
578
579 if m.focusIndex == 1 {
580 b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
581 } else {
582 b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
583 }
584 b.WriteString(m.smimeKeyInput.View() + "\n\n")
585
586 signStatus := "OFF"
587 if account.SMIMESignByDefault {
588 signStatus = "ON"
589 }
590 if m.focusIndex == 2 {
591 b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", signStatus)))
592 } else {
593 b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", signStatus)))
594 }
595
596 saveBtn := "[ Save ]"
597 cancelBtn := "[ Cancel ]"
598
599 if m.focusIndex == 3 {
600 saveBtn = settingsFocusedStyle.Render(saveBtn)
601 } else {
602 saveBtn = settingsBlurredStyle.Render(saveBtn)
603 }
604
605 if m.focusIndex == 4 {
606 cancelBtn = settingsFocusedStyle.Render(cancelBtn)
607 } else {
608 cancelBtn = settingsBlurredStyle.Render(cancelBtn)
609 }
610
611 b.WriteString(saveBtn + " " + cancelBtn + "\n\n")
612
613 mainContent := b.String()
614 helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • esc: back")
615
616 if m.height > 0 {
617 currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
618 gap := m.height - currentHeight
619 if gap > 0 {
620 mainContent += strings.Repeat("\n", gap)
621 }
622 } else {
623 mainContent += "\n\n"
624 }
625
626 return docStyle.Render(mainContent + helpView)
627}
628
629func (m *Settings) viewMailingLists() string {
630 var b strings.Builder
631
632 b.WriteString(titleStyle.Render("Mailing Lists"))
633 b.WriteString("\n\n")
634
635 if len(m.cfg.MailingLists) == 0 {
636 b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n"))
637 b.WriteString("\n")
638 }
639
640 for i, list := range m.cfg.MailingLists {
641 displayName := list.Name
642
643 addrCount := fmt.Sprintf("%d address", len(list.Addresses))
644 if len(list.Addresses) != 1 {
645 addrCount += "es"
646 }
647
648 line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
649
650 if m.cursor == i {
651 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
652 } else {
653 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
654 }
655 b.WriteString("\n")
656 }
657
658 // Add Mailing List option
659 addListText := "Add New Mailing List"
660 if m.cursor == len(m.cfg.MailingLists) {
661 b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
662 } else {
663 b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addListText)))
664 }
665 b.WriteString("\n")
666
667 helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
668 mainContent := b.String()
669
670 if m.height > 0 {
671 contentHeight := strings.Count(mainContent, "\n") + 1
672 gap := m.height - contentHeight - 2 // -2 for margins
673 if gap > 0 {
674 mainContent += strings.Repeat("\n", gap)
675 }
676 } else {
677 mainContent += "\n\n"
678 }
679
680 if m.confirmingDelete {
681 listName := m.cfg.MailingLists[m.cursor].Name
682 dialog := DialogBoxStyle.Render(
683 lipgloss.JoinVertical(lipgloss.Center,
684 dangerStyle.Render("Delete mailing list?"),
685 accountEmailStyle.Render(listName),
686 HelpStyle.Render("\n(y/n)"),
687 ),
688 )
689 return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
690 }
691
692 return docStyle.Render(mainContent + helpView)
693}
694
695func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
696 themes := theme.AllThemes()
697
698 switch msg.String() {
699 case "up", "k":
700 if m.cursor > 0 {
701 m.cursor--
702 }
703 case "down", "j":
704 if m.cursor < len(themes)-1 {
705 m.cursor++
706 }
707 case "enter":
708 if m.cursor < len(themes) {
709 selected := themes[m.cursor]
710 theme.SetTheme(selected.Name)
711 RebuildStyles()
712 m.cfg.Theme = selected.Name
713 _ = config.SaveConfig(m.cfg)
714 }
715 m.state = SettingsMain
716 m.cursor = 1 // Return to Theme option
717 return m, nil
718 case "esc":
719 m.state = SettingsMain
720 m.cursor = 1 // Return to Theme option
721 return m, nil
722 }
723 return m, nil
724}
725
726func (m *Settings) viewTheme() string {
727 themes := theme.AllThemes()
728
729 // Build left panel: theme list
730 var left strings.Builder
731 left.WriteString(titleStyle.Render("Theme") + "\n\n")
732
733 for i, t := range themes {
734 isActive := t.Name == theme.ActiveTheme.Name
735
736 label := t.Name
737 if isActive {
738 label += " (active)"
739 }
740
741 if m.cursor == i {
742 left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
743 } else {
744 left.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", label)))
745 }
746 left.WriteString("\n")
747 }
748
749 left.WriteString("\n")
750 if !m.cfg.HideTips {
751 left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
752 }
753
754 // Build right panel: theme preview
755 var previewTheme theme.Theme
756 if m.cursor < len(themes) {
757 previewTheme = themes[m.cursor]
758 } else {
759 previewTheme = theme.ActiveTheme
760 }
761 preview := renderThemePreview(previewTheme, m.width)
762
763 // Join panels side by side
764 leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
765 content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", preview)
766
767 helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
768
769 if m.height > 0 {
770 currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
771 gap := m.height - currentHeight
772 if gap > 0 {
773 content += strings.Repeat("\n", gap)
774 }
775 } else {
776 content += "\n\n"
777 }
778
779 return docStyle.Render(content + "\n" + helpView)
780}
781
782// renderThemePreview renders a small mockup showing how a theme looks.
783func renderThemePreview(t theme.Theme, maxWidth int) string {
784 previewWidth := maxWidth - 38 // 30 for left panel + padding/margins
785 if previewWidth < 30 {
786 previewWidth = 30
787 }
788 if previewWidth > 60 {
789 previewWidth = 60
790 }
791
792 accent := lipgloss.NewStyle().Foreground(t.Accent)
793 accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
794 secondary := lipgloss.NewStyle().Foreground(t.Secondary)
795 muted := lipgloss.NewStyle().Foreground(t.MutedText)
796 dim := lipgloss.NewStyle().Foreground(t.DimText)
797 danger := lipgloss.NewStyle().Foreground(t.Danger)
798 warn := lipgloss.NewStyle().Foreground(t.Warning)
799 tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
800 link := lipgloss.NewStyle().Foreground(t.Link)
801 title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
802 activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
803 activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
804
805 var b strings.Builder
806
807 b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
808
809 // Fake inbox tabs
810 b.WriteString(activeTab.Render("Inbox") + " " + secondary.Render("Sent") + " " + secondary.Render("Drafts") + "\n")
811 b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
812
813 // Fake email list
814 b.WriteString(accentBold.Render("> ") + dim.Render("Alice ") + accent.Render("Meeting tomorrow") + " " + muted.Render("2m ago") + "\n")
815 b.WriteString(" " + dim.Render("Bob ") + secondary.Render("Re: Project update") + " " + muted.Render("1h ago") + "\n")
816 b.WriteString(" " + dim.Render("Carol ") + secondary.Render("Quick question") + " " + muted.Render("3h ago") + "\n\n")
817
818 // Folder sidebar sample
819 b.WriteString(accentBold.Render("Folders") + "\n")
820 b.WriteString(activeFolder.Render(" INBOX ") + " " + secondary.Render("Sent") + " " + secondary.Render("Trash") + "\n\n")
821
822 // Status indicators
823 b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
824 b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
825 b.WriteString(warn.Render("Update available: v2.0") + "\n")
826 b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
827 b.WriteString(link.Render("https://example.com") + "\n")
828
829 box := lipgloss.NewStyle().
830 Border(lipgloss.RoundedBorder()).
831 BorderForeground(t.AccentDark).
832 Padding(1, 2).
833 Width(previewWidth).
834 Render(b.String())
835
836 return box
837}
838
839// UpdateConfig updates the configuration (used when accounts are deleted).
840func (m *Settings) UpdateConfig(cfg *config.Config) {
841 m.cfg = cfg
842 if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
843 m.cursor = len(cfg.Accounts)
844 }
845}