settings.go

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