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