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