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: Save button
 316// 9: Cancel button
 317const cryptoConfigMaxFocus = 9
 318
 319func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
 320	var cmds []tea.Cmd
 321	var cmd tea.Cmd
 322
 323	switch msg.String() {
 324	case "esc":
 325		m.state = SettingsAccounts
 326		return m, nil
 327	case "tab", "shift+tab", "up", "down":
 328		if msg.String() == "shift+tab" || msg.String() == "up" {
 329			m.focusIndex--
 330			if m.focusIndex < 0 {
 331				m.focusIndex = cryptoConfigMaxFocus
 332			}
 333		} else {
 334			m.focusIndex++
 335			if m.focusIndex > cryptoConfigMaxFocus {
 336				m.focusIndex = 0
 337			}
 338		}
 339
 340		m.smimeCertInput.Blur()
 341		m.smimeKeyInput.Blur()
 342		m.pgpPublicKeyInput.Blur()
 343		m.pgpPrivateKeyInput.Blur()
 344		m.pgpPINInput.Blur()
 345
 346		switch m.focusIndex {
 347		case 0:
 348			cmds = append(cmds, m.smimeCertInput.Focus())
 349		case 1:
 350			cmds = append(cmds, m.smimeKeyInput.Focus())
 351		case 3:
 352			cmds = append(cmds, m.pgpPublicKeyInput.Focus())
 353		case 4:
 354			cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
 355		case 6:
 356			cmds = append(cmds, m.pgpPINInput.Focus())
 357		}
 358		return m, tea.Batch(cmds...)
 359	case "enter", " ":
 360		switch m.focusIndex {
 361		case 0: // S/MIME cert - enter advances to next field
 362			if msg.String() == "enter" {
 363				m.focusIndex = 1
 364				m.smimeCertInput.Blur()
 365				cmds = append(cmds, m.smimeKeyInput.Focus())
 366				return m, tea.Batch(cmds...)
 367			}
 368		case 1: // S/MIME key - enter advances
 369			if msg.String() == "enter" {
 370				m.focusIndex = 2
 371				m.smimeKeyInput.Blur()
 372				return m, nil
 373			}
 374		case 2: // S/MIME sign toggle
 375			if msg.String() == "enter" || msg.String() == " " {
 376				m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
 377			}
 378			return m, nil
 379		case 3: // PGP public key - enter advances
 380			if msg.String() == "enter" {
 381				m.focusIndex = 4
 382				m.pgpPublicKeyInput.Blur()
 383				cmds = append(cmds, m.pgpPrivateKeyInput.Focus())
 384				return m, tea.Batch(cmds...)
 385			}
 386		case 4: // PGP private key - enter advances
 387			if msg.String() == "enter" {
 388				m.focusIndex = 5
 389				m.pgpPrivateKeyInput.Blur()
 390				return m, nil
 391			}
 392		case 5: // PGP key source toggle (file/yubikey)
 393			if msg.String() == "enter" || msg.String() == " " {
 394				if m.pgpKeySource == "file" {
 395					m.pgpKeySource = "yubikey"
 396				} else {
 397					m.pgpKeySource = "file"
 398				}
 399			}
 400			return m, nil
 401		case 6: // PGP PIN input - enter advances
 402			if msg.String() == "enter" {
 403				m.focusIndex = 7
 404				m.pgpPINInput.Blur()
 405				return m, nil
 406			}
 407		case 7: // PGP sign toggle
 408			if msg.String() == "enter" || msg.String() == " " {
 409				m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault
 410			}
 411			return m, nil
 412		case 8: // Save
 413			if msg.String() == "enter" {
 414				m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
 415				m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
 416				m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value()
 417				m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value()
 418				m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource
 419				m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value()
 420				_ = config.SaveConfig(m.cfg)
 421				m.state = SettingsAccounts
 422				return m, nil
 423			}
 424		case 9: // Cancel
 425			if msg.String() == "enter" {
 426				m.state = SettingsAccounts
 427				return m, nil
 428			}
 429		}
 430	}
 431
 432	switch m.focusIndex {
 433	case 0:
 434		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
 435		cmds = append(cmds, cmd)
 436	case 1:
 437		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
 438		cmds = append(cmds, cmd)
 439	case 3:
 440		m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
 441		cmds = append(cmds, cmd)
 442	case 4:
 443		m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
 444		cmds = append(cmds, cmd)
 445	case 6:
 446		m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
 447		cmds = append(cmds, cmd)
 448	}
 449
 450	return m, tea.Batch(cmds...)
 451}
 452
 453func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 454	if m.confirmingDelete {
 455		switch msg.String() {
 456		case "y", "Y":
 457			if m.cursor < len(m.cfg.MailingLists) {
 458				m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
 459				_ = config.SaveConfig(m.cfg)
 460				if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
 461					m.cursor--
 462				}
 463				m.confirmingDelete = false
 464			}
 465		case "n", "N", "esc":
 466			m.confirmingDelete = false
 467			return m, nil
 468		}
 469		return m, nil
 470	}
 471
 472	switch msg.String() {
 473	case "up", "k":
 474		if m.cursor > 0 {
 475			m.cursor--
 476		}
 477	case "down", "j":
 478		if m.cursor < len(m.cfg.MailingLists) {
 479			m.cursor++
 480		}
 481	case "d":
 482		if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
 483			m.confirmingDelete = true
 484		}
 485	case "e":
 486		// Edit selected mailing list
 487		if m.cursor < len(m.cfg.MailingLists) {
 488			list := m.cfg.MailingLists[m.cursor]
 489			idx := m.cursor
 490			return m, func() tea.Msg {
 491				return GoToEditMailingListMsg{
 492					Index:     idx,
 493					Name:      list.Name,
 494					Addresses: strings.Join(list.Addresses, ", "),
 495				}
 496			}
 497		}
 498	case "enter":
 499		if m.cursor == len(m.cfg.MailingLists) {
 500			return m, func() tea.Msg { return GoToAddMailingListMsg{} }
 501		}
 502	case "esc":
 503		m.state = SettingsMain
 504		m.cursor = 0
 505		return m, nil
 506	}
 507	return m, nil
 508}
 509
 510// View renders the settings screen.
 511func (m *Settings) View() tea.View {
 512	if m.state == SettingsMain {
 513		return tea.NewView(m.viewMain())
 514	} else if m.state == SettingsTheme {
 515		return tea.NewView(m.viewTheme())
 516	} else if m.state == SettingsMailingLists {
 517		return tea.NewView(m.viewMailingLists())
 518	} else if m.state == SettingsSMIMEConfig {
 519		return tea.NewView(m.viewSMIMEConfig())
 520	}
 521	return tea.NewView(m.viewAccounts())
 522}
 523
 524func (m *Settings) viewMain() string {
 525	var b strings.Builder
 526
 527	b.WriteString(titleStyle.Render("Settings") + "\n\n")
 528
 529	// Option 0: Email Accounts
 530	if m.cursor == 0 {
 531		b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
 532	} else {
 533		b.WriteString(accountItemStyle.Render("  Email Accounts"))
 534	}
 535	b.WriteString("\n")
 536
 537	// Option 1: Theme
 538	themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
 539	if m.cursor == 1 {
 540		b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
 541	} else {
 542		b.WriteString(accountItemStyle.Render("  " + themeText))
 543	}
 544	b.WriteString("\n")
 545
 546	// Option 2: Image Display
 547	status := "ON"
 548	if m.cfg.DisableImages {
 549		status = "OFF"
 550	}
 551	text := fmt.Sprintf("Image Display: %s", status)
 552	if m.cursor == 2 {
 553		b.WriteString(selectedAccountItemStyle.Render("> " + text))
 554	} else {
 555		b.WriteString(accountItemStyle.Render("  " + text))
 556	}
 557	b.WriteString("\n")
 558
 559	// Option 3: Edit Signature
 560	sigText := "Edit Signature"
 561	if config.HasSignature() {
 562		sigText = "Edit Signature (configured)"
 563	}
 564	if m.cursor == 3 {
 565		b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
 566	} else {
 567		b.WriteString(accountItemStyle.Render("  " + sigText))
 568	}
 569	b.WriteString("\n")
 570
 571	// Option 4: Contextual Tips
 572	tipsStatus := "ON"
 573	if m.cfg.HideTips {
 574		tipsStatus = "OFF"
 575	}
 576	tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
 577	if m.cursor == 4 {
 578		b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
 579	} else {
 580		b.WriteString(accountItemStyle.Render("  " + tipsText))
 581	}
 582	b.WriteString("\n")
 583
 584	// Option 5: Desktop Notifications
 585	notifStatus := "ON"
 586	if m.cfg.DisableNotifications {
 587		notifStatus = "OFF"
 588	}
 589	notifText := fmt.Sprintf("Desktop Notifications: %s", notifStatus)
 590	if m.cursor == 5 {
 591		b.WriteString(selectedAccountItemStyle.Render("> " + notifText))
 592	} else {
 593		b.WriteString(accountItemStyle.Render("  " + notifText))
 594	}
 595	b.WriteString("\n")
 596
 597	// Option 6: Mailing Lists
 598	mailingListsText := "Mailing Lists"
 599	if m.cursor == 6 {
 600		b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
 601	} else {
 602		b.WriteString(accountItemStyle.Render("  " + mailingListsText))
 603	}
 604	b.WriteString("\n\n")
 605
 606	if !m.cfg.HideTips {
 607		tip := ""
 608		switch m.cursor {
 609		case 0:
 610			tip = "Manage your connected email accounts."
 611		case 1:
 612			tip = "Choose a color theme for the application."
 613		case 2:
 614			tip = "Toggle displaying images in emails."
 615		case 3:
 616			tip = "Configure the signature appended to your outgoing emails."
 617		case 4:
 618			tip = "Toggle displaying helpful contextual tips like this one."
 619		case 5:
 620			tip = "Toggle desktop notifications when new mail arrives."
 621		case 6:
 622			tip = "Manage groups of email addresses to quickly send to multiple people."
 623		}
 624		if tip != "" {
 625			b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
 626		}
 627	}
 628
 629	mainContent := b.String()
 630	helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
 631
 632	if m.height > 0 {
 633		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
 634		gap := m.height - currentHeight
 635		if gap > 0 {
 636			mainContent += strings.Repeat("\n", gap)
 637		}
 638	} else {
 639		mainContent += "\n\n"
 640	}
 641
 642	return docStyle.Render(mainContent + helpView)
 643}
 644
 645func (m *Settings) viewAccounts() string {
 646	var b strings.Builder
 647
 648	b.WriteString(titleStyle.Render("Account Settings"))
 649	b.WriteString("\n\n")
 650
 651	if len(m.cfg.Accounts) == 0 {
 652		b.WriteString(accountEmailStyle.Render("  No accounts configured.\n"))
 653		b.WriteString("\n")
 654	}
 655
 656	for i, account := range m.cfg.Accounts {
 657		displayName := account.Email
 658		if account.Name != "" {
 659			displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
 660		}
 661
 662		providerInfo := account.ServiceProvider
 663		if account.ServiceProvider == "custom" {
 664			providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
 665		}
 666
 667		if account.SMIMECert != "" && account.SMIMEKey != "" {
 668			providerInfo += " [S/MIME Configured]"
 669		}
 670		if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
 671			providerInfo += " [PGP Configured]"
 672		}
 673
 674		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
 675
 676		if m.cursor == i {
 677			b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
 678		} else {
 679			b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", line)))
 680		}
 681		b.WriteString("\n")
 682	}
 683
 684	// Add Account option
 685	addAccountText := "Add New Account"
 686	if m.cursor == len(m.cfg.Accounts) {
 687		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
 688	} else {
 689		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addAccountText)))
 690	}
 691	b.WriteString("\n")
 692
 693	mainContent := b.String()
 694	helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
 695
 696	if m.height > 0 {
 697		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
 698		gap := m.height - currentHeight
 699		if gap > 0 {
 700			mainContent += strings.Repeat("\n", gap)
 701		}
 702	} else {
 703		mainContent += "\n\n"
 704	}
 705
 706	if m.confirmingDelete {
 707		accountName := m.cfg.Accounts[m.cursor].Email
 708		dialog := DialogBoxStyle.Render(
 709			lipgloss.JoinVertical(lipgloss.Center,
 710				dangerStyle.Render("Delete account?"),
 711				accountEmailStyle.Render(accountName),
 712				HelpStyle.Render("\n(y/n)"),
 713			),
 714		)
 715		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
 716	}
 717
 718	return docStyle.Render(mainContent + helpView)
 719}
 720
 721func (m *Settings) viewSMIMEConfig() string {
 722	var b strings.Builder
 723
 724	account := m.cfg.Accounts[m.editingAccountIdx]
 725	b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail)))
 726	b.WriteString("\n\n")
 727
 728	// --- S/MIME Section ---
 729	b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n")
 730
 731	if m.focusIndex == 0 {
 732		b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
 733	} else {
 734		b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
 735	}
 736	b.WriteString(m.smimeCertInput.View() + "\n\n")
 737
 738	if m.focusIndex == 1 {
 739		b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
 740	} else {
 741		b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
 742	}
 743	b.WriteString(m.smimeKeyInput.View() + "\n\n")
 744
 745	smimeSignStatus := "OFF"
 746	if account.SMIMESignByDefault {
 747		smimeSignStatus = "ON"
 748	}
 749	if m.focusIndex == 2 {
 750		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", smimeSignStatus)))
 751	} else {
 752		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", smimeSignStatus)))
 753	}
 754
 755	// --- PGP Section ---
 756	b.WriteString(settingsFocusedStyle.Render("PGP") + "\n")
 757
 758	if m.focusIndex == 3 {
 759		b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n"))
 760	} else {
 761		b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n"))
 762	}
 763	b.WriteString(m.pgpPublicKeyInput.View() + "\n\n")
 764
 765	if m.focusIndex == 4 {
 766		b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n"))
 767	} else {
 768		b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n"))
 769	}
 770	b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n")
 771
 772	// Key source toggle
 773	keySourceDisplay := "File"
 774	if m.pgpKeySource == "yubikey" {
 775		keySourceDisplay = "YubiKey"
 776	}
 777	if m.focusIndex == 5 {
 778		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Key Source: %s\n\n", keySourceDisplay)))
 779	} else {
 780		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Key Source: %s\n\n", keySourceDisplay)))
 781	}
 782
 783	// PIN input (only shown if YubiKey is selected)
 784	if m.pgpKeySource == "yubikey" {
 785		if m.focusIndex == 6 {
 786			b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n"))
 787		} else {
 788			b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n"))
 789		}
 790		b.WriteString(m.pgpPINInput.View() + "\n\n")
 791	}
 792
 793	pgpSignStatus := "OFF"
 794	if account.PGPSignByDefault {
 795		pgpSignStatus = "ON"
 796	}
 797	if m.focusIndex == 7 {
 798		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", pgpSignStatus)))
 799	} else {
 800		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", pgpSignStatus)))
 801	}
 802
 803	// --- Buttons ---
 804	saveBtn := "[ Save ]"
 805	cancelBtn := "[ Cancel ]"
 806
 807	if m.focusIndex == 8 {
 808		saveBtn = settingsFocusedStyle.Render(saveBtn)
 809	} else {
 810		saveBtn = settingsBlurredStyle.Render(saveBtn)
 811	}
 812
 813	if m.focusIndex == 9 {
 814		cancelBtn = settingsFocusedStyle.Render(cancelBtn)
 815	} else {
 816		cancelBtn = settingsBlurredStyle.Render(cancelBtn)
 817	}
 818
 819	b.WriteString(saveBtn + "  " + cancelBtn + "\n\n")
 820
 821	mainContent := b.String()
 822	helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back")
 823
 824	if m.height > 0 {
 825		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
 826		gap := m.height - currentHeight
 827		if gap > 0 {
 828			mainContent += strings.Repeat("\n", gap)
 829		}
 830	} else {
 831		mainContent += "\n\n"
 832	}
 833
 834	return docStyle.Render(mainContent + helpView)
 835}
 836
 837func (m *Settings) viewMailingLists() string {
 838	var b strings.Builder
 839
 840	b.WriteString(titleStyle.Render("Mailing Lists"))
 841	b.WriteString("\n\n")
 842
 843	if len(m.cfg.MailingLists) == 0 {
 844		b.WriteString(accountEmailStyle.Render("  No mailing lists configured.\n"))
 845		b.WriteString("\n")
 846	}
 847
 848	for i, list := range m.cfg.MailingLists {
 849		displayName := list.Name
 850
 851		addrCount := fmt.Sprintf("%d address", len(list.Addresses))
 852		if len(list.Addresses) != 1 {
 853			addrCount += "es"
 854		}
 855
 856		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
 857
 858		if m.cursor == i {
 859			b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
 860		} else {
 861			b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", line)))
 862		}
 863		b.WriteString("\n")
 864	}
 865
 866	// Add Mailing List option
 867	addListText := "Add New Mailing List"
 868	if m.cursor == len(m.cfg.MailingLists) {
 869		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
 870	} else {
 871		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addListText)))
 872	}
 873	b.WriteString("\n")
 874
 875	helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
 876	mainContent := b.String()
 877
 878	if m.height > 0 {
 879		contentHeight := strings.Count(mainContent, "\n") + 1
 880		gap := m.height - contentHeight - 2 // -2 for margins
 881		if gap > 0 {
 882			mainContent += strings.Repeat("\n", gap)
 883		}
 884	} else {
 885		mainContent += "\n\n"
 886	}
 887
 888	if m.confirmingDelete {
 889		listName := m.cfg.MailingLists[m.cursor].Name
 890		dialog := DialogBoxStyle.Render(
 891			lipgloss.JoinVertical(lipgloss.Center,
 892				dangerStyle.Render("Delete mailing list?"),
 893				accountEmailStyle.Render(listName),
 894				HelpStyle.Render("\n(y/n)"),
 895			),
 896		)
 897		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
 898	}
 899
 900	return docStyle.Render(mainContent + helpView)
 901}
 902
 903func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 904	themes := theme.AllThemes()
 905
 906	switch msg.String() {
 907	case "up", "k":
 908		if m.cursor > 0 {
 909			m.cursor--
 910		}
 911	case "down", "j":
 912		if m.cursor < len(themes)-1 {
 913			m.cursor++
 914		}
 915	case "enter":
 916		if m.cursor < len(themes) {
 917			selected := themes[m.cursor]
 918			theme.SetTheme(selected.Name)
 919			RebuildStyles()
 920			m.cfg.Theme = selected.Name
 921			_ = config.SaveConfig(m.cfg)
 922		}
 923		m.state = SettingsMain
 924		m.cursor = 1 // Return to Theme option
 925		return m, nil
 926	case "esc":
 927		m.state = SettingsMain
 928		m.cursor = 1 // Return to Theme option
 929		return m, nil
 930	}
 931	return m, nil
 932}
 933
 934func (m *Settings) viewTheme() string {
 935	themes := theme.AllThemes()
 936
 937	// Build left panel: theme list
 938	var left strings.Builder
 939	left.WriteString(titleStyle.Render("Theme") + "\n\n")
 940
 941	for i, t := range themes {
 942		isActive := t.Name == theme.ActiveTheme.Name
 943
 944		label := t.Name
 945		if isActive {
 946			label += " (active)"
 947		}
 948
 949		if m.cursor == i {
 950			left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
 951		} else {
 952			left.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", label)))
 953		}
 954		left.WriteString("\n")
 955	}
 956
 957	left.WriteString("\n")
 958	if !m.cfg.HideTips {
 959		left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
 960	}
 961
 962	// Build right panel: theme preview
 963	var previewTheme theme.Theme
 964	if m.cursor < len(themes) {
 965		previewTheme = themes[m.cursor]
 966	} else {
 967		previewTheme = theme.ActiveTheme
 968	}
 969	preview := renderThemePreview(previewTheme, m.width)
 970
 971	// Join panels side by side
 972	leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
 973	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, "  ", preview)
 974
 975	helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
 976
 977	if m.height > 0 {
 978		currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
 979		gap := m.height - currentHeight
 980		if gap > 0 {
 981			content += strings.Repeat("\n", gap)
 982		}
 983	} else {
 984		content += "\n\n"
 985	}
 986
 987	return docStyle.Render(content + "\n" + helpView)
 988}
 989
 990// renderThemePreview renders a small mockup showing how a theme looks.
 991func renderThemePreview(t theme.Theme, maxWidth int) string {
 992	previewWidth := maxWidth - 38 // 30 for left panel + padding/margins
 993	if previewWidth < 30 {
 994		previewWidth = 30
 995	}
 996	if previewWidth > 60 {
 997		previewWidth = 60
 998	}
 999
1000	accent := lipgloss.NewStyle().Foreground(t.Accent)
1001	accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
1002	secondary := lipgloss.NewStyle().Foreground(t.Secondary)
1003	muted := lipgloss.NewStyle().Foreground(t.MutedText)
1004	dim := lipgloss.NewStyle().Foreground(t.DimText)
1005	danger := lipgloss.NewStyle().Foreground(t.Danger)
1006	warn := lipgloss.NewStyle().Foreground(t.Warning)
1007	tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
1008	link := lipgloss.NewStyle().Foreground(t.Link)
1009	title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
1010	activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
1011	activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
1012
1013	var b strings.Builder
1014
1015	b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
1016
1017	// Fake inbox tabs
1018	b.WriteString(activeTab.Render("Inbox") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Drafts") + "\n")
1019	b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
1020
1021	// Fake email list
1022	b.WriteString(accentBold.Render("> ") + dim.Render("Alice  ") + accent.Render("Meeting tomorrow") + "  " + muted.Render("2m ago") + "\n")
1023	b.WriteString("  " + dim.Render("Bob    ") + secondary.Render("Re: Project update") + "  " + muted.Render("1h ago") + "\n")
1024	b.WriteString("  " + dim.Render("Carol  ") + secondary.Render("Quick question") + "    " + muted.Render("3h ago") + "\n\n")
1025
1026	// Folder sidebar sample
1027	b.WriteString(accentBold.Render("Folders") + "\n")
1028	b.WriteString(activeFolder.Render(" INBOX ") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Trash") + "\n\n")
1029
1030	// Status indicators
1031	b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
1032	b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
1033	b.WriteString(warn.Render("Update available: v2.0") + "\n")
1034	b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
1035	b.WriteString(link.Render("https://example.com") + "\n")
1036
1037	box := lipgloss.NewStyle().
1038		Border(lipgloss.RoundedBorder()).
1039		BorderForeground(t.AccentDark).
1040		Padding(1, 2).
1041		Width(previewWidth).
1042		Render(b.String())
1043
1044	return box
1045}
1046
1047// UpdateConfig updates the configuration (used when accounts are deleted).
1048func (m *Settings) UpdateConfig(cfg *config.Config) {
1049	m.cfg = cfg
1050	if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
1051		m.cursor = len(cfg.Accounts)
1052	}
1053}