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: 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}