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