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	// PGP Config fields
  50	pgpPublicKeyInput  textinput.Model
  51	pgpPrivateKeyInput textinput.Model
  52	pgpKeySource       string // "file" or "yubikey"
  53	pgpPINInput        textinput.Model
  54}
  55
  56// NewSettings creates a new settings model.
  57func NewSettings(cfg *config.Config) *Settings {
  58	if cfg == nil {
  59		cfg = &config.Config{}
  60	}
  61
  62	tiStyles := ThemedTextInputStyles()
  63
  64	certInput := textinput.New()
  65	certInput.Placeholder = "/path/to/cert.pem"
  66	certInput.Prompt = "> "
  67	certInput.CharLimit = 256
  68	certInput.SetStyles(tiStyles)
  69
  70	keyInput := textinput.New()
  71	keyInput.Placeholder = "/path/to/private_key.pem"
  72	keyInput.Prompt = "> "
  73	keyInput.CharLimit = 256
  74	keyInput.SetStyles(tiStyles)
  75
  76	pgpPubInput := textinput.New()
  77	pgpPubInput.Placeholder = "/path/to/public_key.asc"
  78	pgpPubInput.Prompt = "> "
  79	pgpPubInput.CharLimit = 256
  80	pgpPubInput.SetStyles(tiStyles)
  81
  82	pgpPrivInput := textinput.New()
  83	pgpPrivInput.Placeholder = "/path/to/private_key.asc"
  84	pgpPrivInput.Prompt = "> "
  85	pgpPrivInput.CharLimit = 256
  86	pgpPrivInput.SetStyles(tiStyles)
  87
  88	pgpPINInput := textinput.New()
  89	pgpPINInput.Placeholder = "YubiKey PIN (6-8 digits)"
  90	pgpPINInput.Prompt = "> "
  91	pgpPINInput.CharLimit = 16
  92	pgpPINInput.EchoMode = textinput.EchoPassword
  93	pgpPINInput.EchoCharacter = '*'
  94	pgpPINInput.SetStyles(tiStyles)
  95
  96	return &Settings{
  97		cfg:                cfg,
  98		state:              SettingsMain,
  99		cursor:             0,
 100		smimeCertInput:     certInput,
 101		smimeKeyInput:      keyInput,
 102		pgpPublicKeyInput:  pgpPubInput,
 103		pgpPrivateKeyInput: pgpPrivInput,
 104		pgpKeySource:       "file", // Default to file-based keys
 105		pgpPINInput:        pgpPINInput,
 106	}
 107}
 108
 109// Init initializes the settings model.
 110func (m *Settings) Init() tea.Cmd {
 111	return textinput.Blink
 112}
 113
 114// Update handles messages for the settings model.
 115func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 116	var cmds []tea.Cmd
 117	var cmd tea.Cmd
 118
 119	switch msg := msg.(type) {
 120	case tea.WindowSizeMsg:
 121		m.width = msg.Width
 122		m.height = msg.Height
 123		m.smimeCertInput.SetWidth(m.width - 6)
 124		m.smimeKeyInput.SetWidth(m.width - 6)
 125		m.pgpPublicKeyInput.SetWidth(m.width - 6)
 126		m.pgpPrivateKeyInput.SetWidth(m.width - 6)
 127		m.pgpPINInput.SetWidth(m.width - 6)
 128		return m, nil
 129
 130	case tea.KeyPressMsg:
 131		if m.state == SettingsMain {
 132			return m.updateMain(msg)
 133		} else if m.state == SettingsTheme {
 134			return m.updateTheme(msg)
 135		} else if m.state == SettingsMailingLists {
 136			return m.updateMailingLists(msg)
 137		} else if m.state == SettingsSMIMEConfig {
 138			var m2 *Settings
 139			m2, cmd = m.updateSMIMEConfig(msg)
 140			cmds = append(cmds, cmd)
 141			return m2, tea.Batch(cmds...)
 142		} else {
 143			return m.updateAccounts(msg)
 144		}
 145	}
 146
 147	if m.state == SettingsSMIMEConfig {
 148		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
 149		cmds = append(cmds, cmd)
 150		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
 151		cmds = append(cmds, cmd)
 152		m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
 153		cmds = append(cmds, cmd)
 154		m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
 155		cmds = append(cmds, cmd)
 156		m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
 157		cmds = append(cmds, cmd)
 158	}
 159
 160	return m, tea.Batch(cmds...)
 161}
 162
 163func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 164	switch msg.String() {
 165	case "up", "k":
 166		if m.cursor > 0 {
 167			m.cursor--
 168		}
 169	case "down", "j":
 170		// Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Desktop Notifications, 6: Mailing Lists
 171		if m.cursor < 6 {
 172			m.cursor++
 173		}
 174	case "enter":
 175		switch m.cursor {
 176		case 0: // Email Accounts
 177			m.state = SettingsAccounts
 178			m.cursor = 0
 179			return m, nil
 180		case 1: // Theme
 181			m.state = SettingsTheme
 182			// Position cursor on the currently active theme
 183			themes := theme.AllThemes()
 184			m.cursor = 0
 185			for i, t := range themes {
 186				if t.Name == theme.ActiveTheme.Name {
 187					m.cursor = i
 188					break
 189				}
 190			}
 191			return m, nil
 192		case 2: // Image Display
 193			m.cfg.DisableImages = !m.cfg.DisableImages
 194			_ = config.SaveConfig(m.cfg)
 195			return m, nil
 196		case 3: // Edit Signature
 197			return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
 198		case 4: // Contextual Tips
 199			m.cfg.HideTips = !m.cfg.HideTips
 200			_ = config.SaveConfig(m.cfg)
 201			return m, nil
 202		case 5: // Desktop Notifications
 203			m.cfg.DisableNotifications = !m.cfg.DisableNotifications
 204			_ = config.SaveConfig(m.cfg)
 205			return m, nil
 206		case 6: // Mailing Lists
 207			m.state = SettingsMailingLists
 208			m.cursor = 0
 209			return m, nil
 210		}
 211	case "esc":
 212		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
 213	}
 214	return m, nil
 215}
 216
 217func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 218	if m.confirmingDelete {
 219		switch msg.String() {
 220		case "y", "Y":
 221			if m.cursor < len(m.cfg.Accounts) {
 222				accountID := m.cfg.Accounts[m.cursor].ID
 223				m.confirmingDelete = false
 224				return m, func() tea.Msg {
 225					return DeleteAccountMsg{AccountID: accountID}
 226				}
 227			}
 228		case "n", "N", "esc":
 229			m.confirmingDelete = false
 230			return m, nil
 231		}
 232		return m, nil
 233	}
 234
 235	switch msg.String() {
 236	case "up", "k":
 237		if m.cursor > 0 {
 238			m.cursor--
 239		}
 240	case "down", "j":
 241		// +1 for "Add Account" option
 242		if m.cursor < len(m.cfg.Accounts) {
 243			m.cursor++
 244		}
 245	case "d":
 246		// Delete selected account (not the "Add Account" option)
 247		if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
 248			m.confirmingDelete = true
 249		}
 250	case "e":
 251		// Edit selected account
 252		if m.cursor < len(m.cfg.Accounts) {
 253			acc := m.cfg.Accounts[m.cursor]
 254			return m, func() tea.Msg {
 255				return GoToEditAccountMsg{
 256					AccountID:    acc.ID,
 257					Provider:     acc.ServiceProvider,
 258					Name:         acc.Name,
 259					Email:        acc.Email,
 260					FetchEmail:   acc.FetchEmail,
 261					IMAPServer:   acc.IMAPServer,
 262					IMAPPort:     acc.IMAPPort,
 263					SMTPServer:   acc.SMTPServer,
 264					SMTPPort:     acc.SMTPPort,
 265					Protocol:     acc.Protocol,
 266					JMAPEndpoint: acc.JMAPEndpoint,
 267					POP3Server:   acc.POP3Server,
 268					POP3Port:     acc.POP3Port,
 269				}
 270			}
 271		}
 272	case "enter":
 273		// If cursor is on "Add Account"
 274		if m.cursor == len(m.cfg.Accounts) {
 275			return m, func() tea.Msg { return GoToAddAccountMsg{} }
 276		} else if m.cursor < len(m.cfg.Accounts) {
 277			m.editingAccountIdx = m.cursor
 278			m.state = SettingsSMIMEConfig
 279			m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert)
 280			m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey)
 281			m.pgpPublicKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPublicKey)
 282			m.pgpPrivateKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPrivateKey)
 283			// Initialize PGP key source
 284			if m.cfg.Accounts[m.cursor].PGPKeySource == "" {
 285				m.pgpKeySource = "file"
 286			} else {
 287				m.pgpKeySource = m.cfg.Accounts[m.cursor].PGPKeySource
 288			}
 289			m.pgpPINInput.SetValue(m.cfg.Accounts[m.cursor].PGPPIN)
 290			m.focusIndex = 0
 291			m.smimeCertInput.Focus()
 292			m.smimeKeyInput.Blur()
 293			m.pgpPublicKeyInput.Blur()
 294			m.pgpPrivateKeyInput.Blur()
 295			m.pgpPINInput.Blur()
 296			return m, textinput.Blink
 297		}
 298	case "esc":
 299		m.state = SettingsMain
 300		m.cursor = 0
 301		return m, nil
 302	}
 303	return m, nil
 304}
 305
 306// Focus indices for the crypto config screen:
 307// 0: S/MIME Certificate Path
 308// 1: S/MIME Private Key Path
 309// 2: S/MIME Sign By Default toggle
 310// 3: PGP Public Key Path
 311// 4: PGP Private Key Path
 312// 5: PGP Key Source toggle (file/yubikey)
 313// 6: PGP PIN input (only shown if yubikey)
 314// 7: PGP Sign By Default toggle
 315// 8: PGP Encrypt By Default toggle
 316// 9: Save button
 317// 10: Cancel button
 318const cryptoConfigMaxFocus = 10
 319
 320func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
 321	var cmds []tea.Cmd
 322	var cmd tea.Cmd
 323
 324	switch msg.String() {
 325	case "esc":
 326		m.state = SettingsAccounts
 327		return m, nil
 328	case "tab", "shift+tab", "up", "down":
 329		if msg.String() == "shift+tab" || msg.String() == "up" {
 330			m.focusIndex--
 331			if m.focusIndex < 0 {
 332				m.focusIndex = cryptoConfigMaxFocus
 333			}
 334		} else {
 335			m.focusIndex++
 336			if m.focusIndex > cryptoConfigMaxFocus {
 337				m.focusIndex = 0
 338			}
 339		}
 340
 341		m.smimeCertInput.Blur()
 342		m.smimeKeyInput.Blur()
 343		m.pgpPublicKeyInput.Blur()
 344		m.pgpPrivateKeyInput.Blur()
 345		m.pgpPINInput.Blur()
 346
 347		switch m.focusIndex {
 348		case 0:
 349			cmds = append(cmds, m.smimeCertInput.Focus())
 350		case 1:
 351			cmds = append(cmds, m.smimeKeyInput.Focus())
 352		case 3:
 353			cmds = append(cmds, m.pgpPublicKeyInput.Focus())
 354		case 4:
 355			cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
 356		case 6:
 357			cmds = append(cmds, m.pgpPINInput.Focus())
 358		}
 359		return m, tea.Batch(cmds...)
 360	case "enter", " ":
 361		switch m.focusIndex {
 362		case 0: // S/MIME cert - enter advances to next field
 363			if msg.String() == "enter" {
 364				m.focusIndex = 1
 365				m.smimeCertInput.Blur()
 366				cmds = append(cmds, m.smimeKeyInput.Focus())
 367				return m, tea.Batch(cmds...)
 368			}
 369		case 1: // S/MIME key - enter advances
 370			if msg.String() == "enter" {
 371				m.focusIndex = 2
 372				m.smimeKeyInput.Blur()
 373				return m, nil
 374			}
 375		case 2: // S/MIME sign toggle
 376			if msg.String() == "enter" || msg.String() == " " {
 377				m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
 378			}
 379			return m, nil
 380		case 3: // PGP public key - enter advances
 381			if msg.String() == "enter" {
 382				m.focusIndex = 4
 383				m.pgpPublicKeyInput.Blur()
 384				cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
 385				return m, tea.Batch(cmds...)
 386			}
 387		case 4: // PGP private key - enter advances
 388			if msg.String() == "enter" {
 389				m.focusIndex = 5
 390				m.pgpPrivateKeyInput.Blur()
 391				return m, nil
 392			}
 393		case 5: // PGP key source toggle (file/yubikey)
 394			if msg.String() == "enter" || msg.String() == " " {
 395				if m.pgpKeySource == "file" {
 396					m.pgpKeySource = "yubikey"
 397				} else {
 398					m.pgpKeySource = "file"
 399				}
 400			}
 401			return m, nil
 402		case 6: // PGP PIN input - enter advances
 403			if msg.String() == "enter" {
 404				m.focusIndex = 7
 405				m.pgpPINInput.Blur()
 406				return m, nil
 407			}
 408		case 7: // PGP sign toggle
 409			if msg.String() == "enter" || msg.String() == " " {
 410				m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault
 411			}
 412			return m, nil
 413		case 8: // PGP encrypt toggle
 414			if msg.String() == "enter" || msg.String() == " " {
 415				m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault
 416			}
 417			return m, nil
 418		case 9: // Save
 419			if msg.String() == "enter" {
 420				m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
 421				m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
 422				m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value()
 423				m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value()
 424				m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource
 425				m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value()
 426				_ = config.SaveConfig(m.cfg)
 427				m.state = SettingsAccounts
 428				return m, nil
 429			}
 430		case 10: // Cancel
 431			if msg.String() == "enter" {
 432				m.state = SettingsAccounts
 433				return m, nil
 434			}
 435		}
 436	}
 437
 438	switch m.focusIndex {
 439	case 0:
 440		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
 441		cmds = append(cmds, cmd)
 442	case 1:
 443		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
 444		cmds = append(cmds, cmd)
 445	case 3:
 446		m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
 447		cmds = append(cmds, cmd)
 448	case 4:
 449		m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
 450		cmds = append(cmds, cmd)
 451	case 6:
 452		m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
 453		cmds = append(cmds, cmd)
 454	}
 455
 456	return m, tea.Batch(cmds...)
 457}
 458
 459func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 460	if m.confirmingDelete {
 461		switch msg.String() {
 462		case "y", "Y":
 463			if m.cursor < len(m.cfg.MailingLists) {
 464				m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
 465				_ = config.SaveConfig(m.cfg)
 466				if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
 467					m.cursor--
 468				}
 469				m.confirmingDelete = false
 470			}
 471		case "n", "N", "esc":
 472			m.confirmingDelete = false
 473			return m, nil
 474		}
 475		return m, nil
 476	}
 477
 478	switch msg.String() {
 479	case "up", "k":
 480		if m.cursor > 0 {
 481			m.cursor--
 482		}
 483	case "down", "j":
 484		if m.cursor < len(m.cfg.MailingLists) {
 485			m.cursor++
 486		}
 487	case "d":
 488		if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
 489			m.confirmingDelete = true
 490		}
 491	case "e":
 492		// Edit selected mailing list
 493		if m.cursor < len(m.cfg.MailingLists) {
 494			list := m.cfg.MailingLists[m.cursor]
 495			idx := m.cursor
 496			return m, func() tea.Msg {
 497				return GoToEditMailingListMsg{
 498					Index:     idx,
 499					Name:      list.Name,
 500					Addresses: strings.Join(list.Addresses, ", "),
 501				}
 502			}
 503		}
 504	case "enter":
 505		if m.cursor == len(m.cfg.MailingLists) {
 506			return m, func() tea.Msg { return GoToAddMailingListMsg{} }
 507		}
 508	case "esc":
 509		m.state = SettingsMain
 510		m.cursor = 0
 511		return m, nil
 512	}
 513	return m, nil
 514}
 515
 516// View renders the settings screen.
 517func (m *Settings) View() tea.View {
 518	if m.state == SettingsMain {
 519		return tea.NewView(m.viewMain())
 520	} else if m.state == SettingsTheme {
 521		return tea.NewView(m.viewTheme())
 522	} else if m.state == SettingsMailingLists {
 523		return tea.NewView(m.viewMailingLists())
 524	} else if m.state == SettingsSMIMEConfig {
 525		return tea.NewView(m.viewSMIMEConfig())
 526	}
 527	return tea.NewView(m.viewAccounts())
 528}
 529
 530func (m *Settings) viewMain() string {
 531	var b strings.Builder
 532
 533	b.WriteString(titleStyle.Render("Settings") + "\n\n")
 534
 535	// Option 0: Email Accounts
 536	if m.cursor == 0 {
 537		b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
 538	} else {
 539		b.WriteString(accountItemStyle.Render("  Email Accounts"))
 540	}
 541	b.WriteString("\n")
 542
 543	// Option 1: Theme
 544	themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
 545	if m.cursor == 1 {
 546		b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
 547	} else {
 548		b.WriteString(accountItemStyle.Render("  " + themeText))
 549	}
 550	b.WriteString("\n")
 551
 552	// Option 2: Image Display
 553	status := "ON"
 554	if m.cfg.DisableImages {
 555		status = "OFF"
 556	}
 557	text := fmt.Sprintf("Image Display: %s", status)
 558	if m.cursor == 2 {
 559		b.WriteString(selectedAccountItemStyle.Render("> " + text))
 560	} else {
 561		b.WriteString(accountItemStyle.Render("  " + text))
 562	}
 563	b.WriteString("\n")
 564
 565	// Option 3: Edit Signature
 566	sigText := "Edit Signature"
 567	if config.HasSignature() {
 568		sigText = "Edit Signature (configured)"
 569	}
 570	if m.cursor == 3 {
 571		b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
 572	} else {
 573		b.WriteString(accountItemStyle.Render("  " + sigText))
 574	}
 575	b.WriteString("\n")
 576
 577	// Option 4: Contextual Tips
 578	tipsStatus := "ON"
 579	if m.cfg.HideTips {
 580		tipsStatus = "OFF"
 581	}
 582	tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
 583	if m.cursor == 4 {
 584		b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
 585	} else {
 586		b.WriteString(accountItemStyle.Render("  " + tipsText))
 587	}
 588	b.WriteString("\n")
 589
 590	// Option 5: Desktop Notifications
 591	notifStatus := "ON"
 592	if m.cfg.DisableNotifications {
 593		notifStatus = "OFF"
 594	}
 595	notifText := fmt.Sprintf("Desktop Notifications: %s", notifStatus)
 596	if m.cursor == 5 {
 597		b.WriteString(selectedAccountItemStyle.Render("> " + notifText))
 598	} else {
 599		b.WriteString(accountItemStyle.Render("  " + notifText))
 600	}
 601	b.WriteString("\n")
 602
 603	// Option 6: Mailing Lists
 604	mailingListsText := "Mailing Lists"
 605	if m.cursor == 6 {
 606		b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
 607	} else {
 608		b.WriteString(accountItemStyle.Render("  " + mailingListsText))
 609	}
 610	b.WriteString("\n\n")
 611
 612	if !m.cfg.HideTips {
 613		tip := ""
 614		switch m.cursor {
 615		case 0:
 616			tip = "Manage your connected email accounts."
 617		case 1:
 618			tip = "Choose a color theme for the application."
 619		case 2:
 620			tip = "Toggle displaying images in emails."
 621		case 3:
 622			tip = "Configure the signature appended to your outgoing emails."
 623		case 4:
 624			tip = "Toggle displaying helpful contextual tips like this one."
 625		case 5:
 626			tip = "Toggle desktop notifications when new mail arrives."
 627		case 6:
 628			tip = "Manage groups of email addresses to quickly send to multiple people."
 629		}
 630		if tip != "" {
 631			b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
 632		}
 633	}
 634
 635	mainContent := b.String()
 636	helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
 637
 638	if m.height > 0 {
 639		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
 640		gap := m.height - currentHeight
 641		if gap > 0 {
 642			mainContent += strings.Repeat("\n", gap)
 643		}
 644	} else {
 645		mainContent += "\n\n"
 646	}
 647
 648	return docStyle.Render(mainContent + helpView)
 649}
 650
 651func (m *Settings) viewAccounts() string {
 652	var b strings.Builder
 653
 654	b.WriteString(titleStyle.Render("Account Settings"))
 655	b.WriteString("\n\n")
 656
 657	if len(m.cfg.Accounts) == 0 {
 658		b.WriteString(accountEmailStyle.Render("  No accounts configured.\n"))
 659		b.WriteString("\n")
 660	}
 661
 662	for i, account := range m.cfg.Accounts {
 663		displayName := account.Email
 664		if account.Name != "" {
 665			displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
 666		}
 667
 668		providerInfo := account.ServiceProvider
 669		if account.ServiceProvider == "custom" {
 670			providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
 671		}
 672
 673		if account.SMIMECert != "" && account.SMIMEKey != "" {
 674			providerInfo += " [S/MIME Configured]"
 675		}
 676		if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
 677			providerInfo += " [PGP Configured]"
 678		}
 679
 680		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
 681
 682		if m.cursor == i {
 683			b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
 684		} else {
 685			b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", line)))
 686		}
 687		b.WriteString("\n")
 688	}
 689
 690	// Add Account option
 691	addAccountText := "Add New Account"
 692	if m.cursor == len(m.cfg.Accounts) {
 693		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
 694	} else {
 695		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addAccountText)))
 696	}
 697	b.WriteString("\n")
 698
 699	mainContent := b.String()
 700	helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
 701
 702	if m.height > 0 {
 703		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
 704		gap := m.height - currentHeight
 705		if gap > 0 {
 706			mainContent += strings.Repeat("\n", gap)
 707		}
 708	} else {
 709		mainContent += "\n\n"
 710	}
 711
 712	if m.confirmingDelete {
 713		accountName := m.cfg.Accounts[m.cursor].Email
 714		dialog := DialogBoxStyle.Render(
 715			lipgloss.JoinVertical(lipgloss.Center,
 716				dangerStyle.Render("Delete account?"),
 717				accountEmailStyle.Render(accountName),
 718				HelpStyle.Render("\n(y/n)"),
 719			),
 720		)
 721		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
 722	}
 723
 724	return docStyle.Render(mainContent + helpView)
 725}
 726
 727func (m *Settings) viewSMIMEConfig() string {
 728	var b strings.Builder
 729
 730	account := m.cfg.Accounts[m.editingAccountIdx]
 731	b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail)))
 732	b.WriteString("\n\n")
 733
 734	// --- S/MIME Section ---
 735	b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n")
 736
 737	if m.focusIndex == 0 {
 738		b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
 739	} else {
 740		b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
 741	}
 742	b.WriteString(m.smimeCertInput.View() + "\n\n")
 743
 744	if m.focusIndex == 1 {
 745		b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
 746	} else {
 747		b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
 748	}
 749	b.WriteString(m.smimeKeyInput.View() + "\n\n")
 750
 751	smimeSignStatus := "OFF"
 752	if account.SMIMESignByDefault {
 753		smimeSignStatus = "ON"
 754	}
 755	if m.focusIndex == 2 {
 756		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", smimeSignStatus)))
 757	} else {
 758		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", smimeSignStatus)))
 759	}
 760
 761	// --- PGP Section ---
 762	b.WriteString(settingsFocusedStyle.Render("PGP") + "\n")
 763
 764	if m.focusIndex == 3 {
 765		b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n"))
 766	} else {
 767		b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n"))
 768	}
 769	b.WriteString(m.pgpPublicKeyInput.View() + "\n\n")
 770
 771	if m.focusIndex == 4 {
 772		b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n"))
 773	} else {
 774		b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n"))
 775	}
 776	b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n")
 777
 778	// Key source toggle
 779	keySourceDisplay := "File"
 780	if m.pgpKeySource == "yubikey" {
 781		keySourceDisplay = "YubiKey"
 782	}
 783	if m.focusIndex == 5 {
 784		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Key Source: %s\n\n", keySourceDisplay)))
 785	} else {
 786		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Key Source: %s\n\n", keySourceDisplay)))
 787	}
 788
 789	// PIN input (only shown if YubiKey is selected)
 790	if m.pgpKeySource == "yubikey" {
 791		if m.focusIndex == 6 {
 792			b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n"))
 793		} else {
 794			b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n"))
 795		}
 796		b.WriteString(m.pgpPINInput.View() + "\n\n")
 797	}
 798
 799	pgpSignStatus := "OFF"
 800	if account.PGPSignByDefault {
 801		pgpSignStatus = "ON"
 802	}
 803	if m.focusIndex == 7 {
 804		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", pgpSignStatus)))
 805	} else {
 806		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", pgpSignStatus)))
 807	}
 808
 809	pgpEncryptStatus := "OFF"
 810	if account.PGPEncryptByDefault {
 811		pgpEncryptStatus = "ON"
 812	}
 813	if m.focusIndex == 8 {
 814		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Encrypt By Default: %s\n\n", pgpEncryptStatus)))
 815	} else {
 816		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Encrypt By Default: %s\n\n", pgpEncryptStatus)))
 817	}
 818
 819	// --- Buttons ---
 820	saveBtn := "[ Save ]"
 821	cancelBtn := "[ Cancel ]"
 822
 823	if m.focusIndex == 9 {
 824		saveBtn = settingsFocusedStyle.Render(saveBtn)
 825	} else {
 826		saveBtn = settingsBlurredStyle.Render(saveBtn)
 827	}
 828
 829	if m.focusIndex == 10 {
 830		cancelBtn = settingsFocusedStyle.Render(cancelBtn)
 831	} else {
 832		cancelBtn = settingsBlurredStyle.Render(cancelBtn)
 833	}
 834
 835	b.WriteString(saveBtn + "  " + cancelBtn + "\n\n")
 836
 837	mainContent := b.String()
 838	helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back")
 839
 840	if m.height > 0 {
 841		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
 842		gap := m.height - currentHeight
 843		if gap > 0 {
 844			mainContent += strings.Repeat("\n", gap)
 845		}
 846	} else {
 847		mainContent += "\n\n"
 848	}
 849
 850	return docStyle.Render(mainContent + helpView)
 851}
 852
 853func (m *Settings) viewMailingLists() string {
 854	var b strings.Builder
 855
 856	b.WriteString(titleStyle.Render("Mailing Lists"))
 857	b.WriteString("\n\n")
 858
 859	if len(m.cfg.MailingLists) == 0 {
 860		b.WriteString(accountEmailStyle.Render("  No mailing lists configured.\n"))
 861		b.WriteString("\n")
 862	}
 863
 864	for i, list := range m.cfg.MailingLists {
 865		displayName := list.Name
 866
 867		addrCount := fmt.Sprintf("%d address", len(list.Addresses))
 868		if len(list.Addresses) != 1 {
 869			addrCount += "es"
 870		}
 871
 872		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
 873
 874		if m.cursor == i {
 875			b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
 876		} else {
 877			b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", line)))
 878		}
 879		b.WriteString("\n")
 880	}
 881
 882	// Add Mailing List option
 883	addListText := "Add New Mailing List"
 884	if m.cursor == len(m.cfg.MailingLists) {
 885		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
 886	} else {
 887		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addListText)))
 888	}
 889	b.WriteString("\n")
 890
 891	helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
 892	mainContent := b.String()
 893
 894	if m.height > 0 {
 895		contentHeight := strings.Count(mainContent, "\n") + 1
 896		gap := m.height - contentHeight - 2 // -2 for margins
 897		if gap > 0 {
 898			mainContent += strings.Repeat("\n", gap)
 899		}
 900	} else {
 901		mainContent += "\n\n"
 902	}
 903
 904	if m.confirmingDelete {
 905		listName := m.cfg.MailingLists[m.cursor].Name
 906		dialog := DialogBoxStyle.Render(
 907			lipgloss.JoinVertical(lipgloss.Center,
 908				dangerStyle.Render("Delete mailing list?"),
 909				accountEmailStyle.Render(listName),
 910				HelpStyle.Render("\n(y/n)"),
 911			),
 912		)
 913		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
 914	}
 915
 916	return docStyle.Render(mainContent + helpView)
 917}
 918
 919func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 920	themes := theme.AllThemes()
 921
 922	switch msg.String() {
 923	case "up", "k":
 924		if m.cursor > 0 {
 925			m.cursor--
 926		}
 927	case "down", "j":
 928		if m.cursor < len(themes)-1 {
 929			m.cursor++
 930		}
 931	case "enter":
 932		if m.cursor < len(themes) {
 933			selected := themes[m.cursor]
 934			theme.SetTheme(selected.Name)
 935			RebuildStyles()
 936			m.cfg.Theme = selected.Name
 937			_ = config.SaveConfig(m.cfg)
 938		}
 939		m.state = SettingsMain
 940		m.cursor = 1 // Return to Theme option
 941		return m, nil
 942	case "esc":
 943		m.state = SettingsMain
 944		m.cursor = 1 // Return to Theme option
 945		return m, nil
 946	}
 947	return m, nil
 948}
 949
 950func (m *Settings) viewTheme() string {
 951	themes := theme.AllThemes()
 952
 953	// Build left panel: theme list
 954	var left strings.Builder
 955	left.WriteString(titleStyle.Render("Theme") + "\n\n")
 956
 957	for i, t := range themes {
 958		isActive := t.Name == theme.ActiveTheme.Name
 959
 960		label := t.Name
 961		if isActive {
 962			label += " (active)"
 963		}
 964
 965		if m.cursor == i {
 966			left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
 967		} else {
 968			left.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", label)))
 969		}
 970		left.WriteString("\n")
 971	}
 972
 973	left.WriteString("\n")
 974	if !m.cfg.HideTips {
 975		left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
 976	}
 977
 978	// Build right panel: theme preview
 979	var previewTheme theme.Theme
 980	if m.cursor < len(themes) {
 981		previewTheme = themes[m.cursor]
 982	} else {
 983		previewTheme = theme.ActiveTheme
 984	}
 985	preview := renderThemePreview(previewTheme, m.width)
 986
 987	// Join panels side by side
 988	leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
 989	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, "  ", preview)
 990
 991	helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
 992
 993	if m.height > 0 {
 994		currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
 995		gap := m.height - currentHeight
 996		if gap > 0 {
 997			content += strings.Repeat("\n", gap)
 998		}
 999	} else {
1000		content += "\n\n"
1001	}
1002
1003	return docStyle.Render(content + "\n" + helpView)
1004}
1005
1006// renderThemePreview renders a small mockup showing how a theme looks.
1007func renderThemePreview(t theme.Theme, maxWidth int) string {
1008	previewWidth := maxWidth - 38 // 30 for left panel + padding/margins
1009	if previewWidth < 30 {
1010		previewWidth = 30
1011	}
1012	if previewWidth > 60 {
1013		previewWidth = 60
1014	}
1015
1016	accent := lipgloss.NewStyle().Foreground(t.Accent)
1017	accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
1018	secondary := lipgloss.NewStyle().Foreground(t.Secondary)
1019	muted := lipgloss.NewStyle().Foreground(t.MutedText)
1020	dim := lipgloss.NewStyle().Foreground(t.DimText)
1021	danger := lipgloss.NewStyle().Foreground(t.Danger)
1022	warn := lipgloss.NewStyle().Foreground(t.Warning)
1023	tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
1024	link := lipgloss.NewStyle().Foreground(t.Link)
1025	title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
1026	activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
1027	activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
1028
1029	var b strings.Builder
1030
1031	b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
1032
1033	// Fake inbox tabs
1034	b.WriteString(activeTab.Render("Inbox") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Drafts") + "\n")
1035	b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
1036
1037	// Fake email list
1038	b.WriteString(accentBold.Render("> ") + dim.Render("Alice  ") + accent.Render("Meeting tomorrow") + "  " + muted.Render("2m ago") + "\n")
1039	b.WriteString("  " + dim.Render("Bob    ") + secondary.Render("Re: Project update") + "  " + muted.Render("1h ago") + "\n")
1040	b.WriteString("  " + dim.Render("Carol  ") + secondary.Render("Quick question") + "    " + muted.Render("3h ago") + "\n\n")
1041
1042	// Folder sidebar sample
1043	b.WriteString(accentBold.Render("Folders") + "\n")
1044	b.WriteString(activeFolder.Render(" INBOX ") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Trash") + "\n\n")
1045
1046	// Status indicators
1047	b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
1048	b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
1049	b.WriteString(warn.Render("Update available: v2.0") + "\n")
1050	b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
1051	b.WriteString(link.Render("https://example.com") + "\n")
1052
1053	box := lipgloss.NewStyle().
1054		Border(lipgloss.RoundedBorder()).
1055		BorderForeground(t.AccentDark).
1056		Padding(1, 2).
1057		Width(previewWidth).
1058		Render(b.String())
1059
1060	return box
1061}
1062
1063// UpdateConfig updates the configuration (used when accounts are deleted).
1064func (m *Settings) UpdateConfig(cfg *config.Config) {
1065	m.cfg = cfg
1066	if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
1067		m.cursor = len(cfg.Accounts)
1068	}
1069}