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)
 12
 13var (
 14	accountItemStyle         = lipgloss.NewStyle().PaddingLeft(2)
 15	selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
 16	accountEmailStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 17	dangerStyle              = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
 18
 19	settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 20	settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 21)
 22
 23type SettingsState int
 24
 25const (
 26	SettingsMain SettingsState = iota
 27	SettingsAccounts
 28	SettingsMailingLists
 29	SettingsSMIMEConfig
 30)
 31
 32// Settings displays the settings screen.
 33type Settings struct {
 34	cfg              *config.Config
 35	state            SettingsState
 36	cursor           int
 37	confirmingDelete bool
 38	width            int
 39	height           int
 40
 41	// S/MIME Config fields
 42	editingAccountIdx int
 43	focusIndex        int
 44	smimeCertInput    textinput.Model
 45	smimeKeyInput     textinput.Model
 46}
 47
 48// NewSettings creates a new settings model.
 49func NewSettings(cfg *config.Config) *Settings {
 50	if cfg == nil {
 51		cfg = &config.Config{}
 52	}
 53
 54	certInput := textinput.New()
 55	certInput.Placeholder = "/path/to/cert.pem"
 56	certInput.Prompt = "> "
 57	certInput.CharLimit = 256
 58
 59	keyInput := textinput.New()
 60	keyInput.Placeholder = "/path/to/private_key.pem"
 61	keyInput.Prompt = "> "
 62	keyInput.CharLimit = 256
 63
 64	return &Settings{
 65		cfg:            cfg,
 66		state:          SettingsMain,
 67		cursor:         0,
 68		smimeCertInput: certInput,
 69		smimeKeyInput:  keyInput,
 70	}
 71}
 72
 73// Init initializes the settings model.
 74func (m *Settings) Init() tea.Cmd {
 75	return textinput.Blink
 76}
 77
 78// Update handles messages for the settings model.
 79func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 80	var cmds []tea.Cmd
 81	var cmd tea.Cmd
 82
 83	switch msg := msg.(type) {
 84	case tea.WindowSizeMsg:
 85		m.width = msg.Width
 86		m.height = msg.Height
 87		m.smimeCertInput.SetWidth(m.width - 6)
 88		m.smimeKeyInput.SetWidth(m.width - 6)
 89		return m, nil
 90
 91	case tea.KeyPressMsg:
 92		if m.state == SettingsMain {
 93			return m.updateMain(msg)
 94		} else if m.state == SettingsMailingLists {
 95			return m.updateMailingLists(msg)
 96		} else if m.state == SettingsSMIMEConfig {
 97			var m2 *Settings
 98			m2, cmd = m.updateSMIMEConfig(msg)
 99			cmds = append(cmds, cmd)
100			return m2, tea.Batch(cmds...)
101		} else {
102			return m.updateAccounts(msg)
103		}
104	}
105
106	if m.state == SettingsSMIMEConfig {
107		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
108		cmds = append(cmds, cmd)
109		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
110		cmds = append(cmds, cmd)
111	}
112
113	return m, tea.Batch(cmds...)
114}
115
116func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
117	switch msg.String() {
118	case "up", "k":
119		if m.cursor > 0 {
120			m.cursor--
121		}
122	case "down", "j":
123		// Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature, 3: Contextual Tips, 4: Mailing Lists
124		if m.cursor < 4 {
125			m.cursor++
126		}
127	case "enter":
128		switch m.cursor {
129		case 0: // Email Accounts
130			m.state = SettingsAccounts
131			m.cursor = 0
132			return m, nil
133		case 1: // Image Display
134			m.cfg.DisableImages = !m.cfg.DisableImages
135			// Save config immediately
136			_ = config.SaveConfig(m.cfg)
137			return m, nil
138		case 2: // Edit Signature
139			return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
140		case 3: // Contextual Tips
141			m.cfg.HideTips = !m.cfg.HideTips
142			// Save config immediately
143			_ = config.SaveConfig(m.cfg)
144			return m, nil
145		case 4: // Mailing Lists
146			m.state = SettingsMailingLists
147			m.cursor = 0
148			return m, nil
149		}
150	case "esc":
151		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
152	}
153	return m, nil
154}
155
156func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
157	if m.confirmingDelete {
158		switch msg.String() {
159		case "y", "Y":
160			if m.cursor < len(m.cfg.Accounts) {
161				accountID := m.cfg.Accounts[m.cursor].ID
162				m.confirmingDelete = false
163				return m, func() tea.Msg {
164					return DeleteAccountMsg{AccountID: accountID}
165				}
166			}
167		case "n", "N", "esc":
168			m.confirmingDelete = false
169			return m, nil
170		}
171		return m, nil
172	}
173
174	switch msg.String() {
175	case "up", "k":
176		if m.cursor > 0 {
177			m.cursor--
178		}
179	case "down", "j":
180		// +1 for "Add Account" option
181		if m.cursor < len(m.cfg.Accounts) {
182			m.cursor++
183		}
184	case "d":
185		// Delete selected account (not the "Add Account" option)
186		if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
187			m.confirmingDelete = true
188		}
189	case "enter":
190		// If cursor is on "Add Account"
191		if m.cursor == len(m.cfg.Accounts) {
192			return m, func() tea.Msg { return GoToAddAccountMsg{} }
193		} else if m.cursor < len(m.cfg.Accounts) {
194			m.editingAccountIdx = m.cursor
195			m.state = SettingsSMIMEConfig
196			m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert)
197			m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey)
198			m.focusIndex = 0
199			m.smimeCertInput.Focus()
200			m.smimeKeyInput.Blur()
201			return m, textinput.Blink
202		}
203	case "esc":
204		m.state = SettingsMain
205		m.cursor = 0
206		return m, nil
207	}
208	return m, nil
209}
210
211func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
212	var cmds []tea.Cmd
213	var cmd tea.Cmd
214
215	switch msg.String() {
216	case "esc":
217		m.state = SettingsAccounts
218		return m, nil
219	case "tab", "shift+tab", "up", "down":
220		if msg.String() == "shift+tab" || msg.String() == "up" {
221			m.focusIndex--
222			if m.focusIndex < 0 {
223				m.focusIndex = 4
224			}
225		} else {
226			m.focusIndex++
227			if m.focusIndex > 4 {
228				m.focusIndex = 0
229			}
230		}
231
232		m.smimeCertInput.Blur()
233		m.smimeKeyInput.Blur()
234
235		if m.focusIndex == 0 {
236			cmds = append(cmds, m.smimeCertInput.Focus())
237		} else if m.focusIndex == 1 {
238			cmds = append(cmds, m.smimeKeyInput.Focus())
239		}
240		return m, tea.Batch(cmds...)
241	case "enter", " ":
242		if m.focusIndex == 0 && msg.String() == "enter" {
243			m.focusIndex = 1
244			m.smimeCertInput.Blur()
245			cmds = append(cmds, m.smimeKeyInput.Focus())
246			return m, tea.Batch(cmds...)
247		} else if m.focusIndex == 1 && msg.String() == "enter" {
248			m.focusIndex = 2
249			m.smimeKeyInput.Blur()
250			return m, nil
251		} else if m.focusIndex == 2 {
252			if msg.String() == "enter" || msg.String() == " " {
253				m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
254			}
255			return m, nil
256		} else if m.focusIndex == 3 && msg.String() == "enter" {
257			m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
258			m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
259			_ = config.SaveConfig(m.cfg)
260			m.state = SettingsAccounts
261			return m, nil
262		} else if m.focusIndex == 4 && msg.String() == "enter" {
263			m.state = SettingsAccounts
264			return m, nil
265		}
266	}
267
268	if m.focusIndex == 0 {
269		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
270		cmds = append(cmds, cmd)
271	} else if m.focusIndex == 1 {
272		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
273		cmds = append(cmds, cmd)
274	}
275
276	return m, tea.Batch(cmds...)
277}
278
279func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
280	if m.confirmingDelete {
281		switch msg.String() {
282		case "y", "Y":
283			if m.cursor < len(m.cfg.MailingLists) {
284				m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
285				_ = config.SaveConfig(m.cfg)
286				if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
287					m.cursor--
288				}
289				m.confirmingDelete = false
290			}
291		case "n", "N", "esc":
292			m.confirmingDelete = false
293			return m, nil
294		}
295		return m, nil
296	}
297
298	switch msg.String() {
299	case "up", "k":
300		if m.cursor > 0 {
301			m.cursor--
302		}
303	case "down", "j":
304		if m.cursor < len(m.cfg.MailingLists) {
305			m.cursor++
306		}
307	case "d":
308		if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
309			m.confirmingDelete = true
310		}
311	case "enter":
312		if m.cursor == len(m.cfg.MailingLists) {
313			return m, func() tea.Msg { return GoToAddMailingListMsg{} }
314		}
315	case "esc":
316		m.state = SettingsMain
317		m.cursor = 0
318		return m, nil
319	}
320	return m, nil
321}
322
323// View renders the settings screen.
324func (m *Settings) View() tea.View {
325	if m.state == SettingsMain {
326		return tea.NewView(m.viewMain())
327	} else if m.state == SettingsMailingLists {
328		return tea.NewView(m.viewMailingLists())
329	} else if m.state == SettingsSMIMEConfig {
330		return tea.NewView(m.viewSMIMEConfig())
331	}
332	return tea.NewView(m.viewAccounts())
333}
334
335func (m *Settings) viewMain() string {
336	var b strings.Builder
337
338	b.WriteString(titleStyle.Render("Settings") + "\n\n")
339
340	// Option 0: Email Accounts
341	if m.cursor == 0 {
342		b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
343	} else {
344		b.WriteString(accountItemStyle.Render("  Email Accounts"))
345	}
346	b.WriteString("\n")
347
348	// Option 1: Image Display
349	status := "ON"
350	if m.cfg.DisableImages {
351		status = "OFF"
352	}
353	text := fmt.Sprintf("Image Display: %s", status)
354	if m.cursor == 1 {
355		b.WriteString(selectedAccountItemStyle.Render("> " + text))
356	} else {
357		b.WriteString(accountItemStyle.Render("  " + text))
358	}
359	b.WriteString("\n")
360
361	// Option 2: Edit Signature
362	sigText := "Edit Signature"
363	if config.HasSignature() {
364		sigText = "Edit Signature (configured)"
365	}
366	if m.cursor == 2 {
367		b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
368	} else {
369		b.WriteString(accountItemStyle.Render("  " + sigText))
370	}
371	b.WriteString("\n")
372
373	// Option 3: Contextual Tips
374	tipsStatus := "ON"
375	if m.cfg.HideTips {
376		tipsStatus = "OFF"
377	}
378	tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
379	if m.cursor == 3 {
380		b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
381	} else {
382		b.WriteString(accountItemStyle.Render("  " + tipsText))
383	}
384	b.WriteString("\n")
385
386	// Option 4: Mailing Lists
387	mailingListsText := "Mailing Lists"
388	if m.cursor == 4 {
389		b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
390	} else {
391		b.WriteString(accountItemStyle.Render("  " + mailingListsText))
392	}
393	b.WriteString("\n\n")
394
395	if !m.cfg.HideTips {
396		tip := ""
397		switch m.cursor {
398		case 0:
399			tip = "Manage your connected email accounts."
400		case 1:
401			tip = "Toggle displaying images in emails."
402		case 2:
403			tip = "Configure the signature appended to your outgoing emails."
404		case 3:
405			tip = "Toggle displaying helpful contextual tips like this one."
406		case 4:
407			tip = "Manage groups of email addresses to quickly send to multiple people."
408		}
409		if tip != "" {
410			b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
411		}
412	}
413
414	mainContent := b.String()
415	helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
416
417	if m.height > 0 {
418		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
419		gap := m.height - currentHeight
420		if gap > 0 {
421			mainContent += strings.Repeat("\n", gap)
422		}
423	} else {
424		mainContent += "\n\n"
425	}
426
427	return docStyle.Render(mainContent + helpView)
428}
429
430func (m *Settings) viewAccounts() string {
431	var b strings.Builder
432
433	b.WriteString(titleStyle.Render("Account Settings"))
434	b.WriteString("\n\n")
435
436	if len(m.cfg.Accounts) == 0 {
437		b.WriteString(accountEmailStyle.Render("  No accounts configured.\n"))
438		b.WriteString("\n")
439	}
440
441	for i, account := range m.cfg.Accounts {
442		displayName := account.Email
443		if account.Name != "" {
444			displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
445		}
446
447		providerInfo := account.ServiceProvider
448		if account.ServiceProvider == "custom" {
449			providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
450		}
451
452		if account.SMIMECert != "" && account.SMIMEKey != "" {
453			providerInfo += " [S/MIME Configured]"
454		}
455
456		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
457
458		if m.cursor == i {
459			b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
460		} else {
461			b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", line)))
462		}
463		b.WriteString("\n")
464	}
465
466	// Add Account option
467	addAccountText := "Add New Account"
468	if m.cursor == len(m.cfg.Accounts) {
469		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
470	} else {
471		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addAccountText)))
472	}
473	b.WriteString("\n")
474
475	mainContent := b.String()
476	helpView := helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back")
477
478	if m.height > 0 {
479		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
480		gap := m.height - currentHeight
481		if gap > 0 {
482			mainContent += strings.Repeat("\n", gap)
483		}
484	} else {
485		mainContent += "\n\n"
486	}
487
488	if m.confirmingDelete {
489		accountName := m.cfg.Accounts[m.cursor].Email
490		dialog := DialogBoxStyle.Render(
491			lipgloss.JoinVertical(lipgloss.Center,
492				dangerStyle.Render("Delete account?"),
493				accountEmailStyle.Render(accountName),
494				HelpStyle.Render("\n(y/n)"),
495			),
496		)
497		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
498	}
499
500	return docStyle.Render(mainContent + helpView)
501}
502
503func (m *Settings) viewSMIMEConfig() string {
504	var b strings.Builder
505
506	account := m.cfg.Accounts[m.editingAccountIdx]
507	b.WriteString(titleStyle.Render(fmt.Sprintf("S/MIME Configuration for %s", account.Email)))
508	b.WriteString("\n\n")
509
510	if m.focusIndex == 0 {
511		b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
512	} else {
513		b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
514	}
515	b.WriteString(m.smimeCertInput.View() + "\n\n")
516
517	if m.focusIndex == 1 {
518		b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
519	} else {
520		b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
521	}
522	b.WriteString(m.smimeKeyInput.View() + "\n\n")
523
524	signStatus := "OFF"
525	if account.SMIMESignByDefault {
526		signStatus = "ON"
527	}
528	if m.focusIndex == 2 {
529		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", signStatus)))
530	} else {
531		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("  Sign By Default: %s\n\n", signStatus)))
532	}
533
534	saveBtn := "[ Save ]"
535	cancelBtn := "[ Cancel ]"
536
537	if m.focusIndex == 3 {
538		saveBtn = settingsFocusedStyle.Render(saveBtn)
539	} else {
540		saveBtn = settingsBlurredStyle.Render(saveBtn)
541	}
542
543	if m.focusIndex == 4 {
544		cancelBtn = settingsFocusedStyle.Render(cancelBtn)
545	} else {
546		cancelBtn = settingsBlurredStyle.Render(cancelBtn)
547	}
548
549	b.WriteString(saveBtn + "  " + cancelBtn + "\n\n")
550
551	mainContent := b.String()
552	helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • esc: back")
553
554	if m.height > 0 {
555		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
556		gap := m.height - currentHeight
557		if gap > 0 {
558			mainContent += strings.Repeat("\n", gap)
559		}
560	} else {
561		mainContent += "\n\n"
562	}
563
564	return docStyle.Render(mainContent + helpView)
565}
566
567func (m *Settings) viewMailingLists() string {
568	var b strings.Builder
569
570	b.WriteString(titleStyle.Render("Mailing Lists"))
571	b.WriteString("\n\n")
572
573	if len(m.cfg.MailingLists) == 0 {
574		b.WriteString(accountEmailStyle.Render("  No mailing lists configured.\n"))
575		b.WriteString("\n")
576	}
577
578	for i, list := range m.cfg.MailingLists {
579		displayName := list.Name
580
581		addrCount := fmt.Sprintf("%d address", len(list.Addresses))
582		if len(list.Addresses) != 1 {
583			addrCount += "es"
584		}
585
586		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
587
588		if m.cursor == i {
589			b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
590		} else {
591			b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", line)))
592		}
593		b.WriteString("\n")
594	}
595
596	// Add Mailing List option
597	addListText := "Add New Mailing List"
598	if m.cursor == len(m.cfg.MailingLists) {
599		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
600	} else {
601		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addListText)))
602	}
603	b.WriteString("\n")
604
605	helpView := helpStyle.Render("↑/↓: navigate • enter: select • d: delete • esc: back")
606	mainContent := b.String()
607
608	if m.height > 0 {
609		contentHeight := strings.Count(mainContent, "\n") + 1
610		gap := m.height - contentHeight - 2 // -2 for margins
611		if gap > 0 {
612			mainContent += strings.Repeat("\n", gap)
613		}
614	} else {
615		mainContent += "\n\n"
616	}
617
618	if m.confirmingDelete {
619		listName := m.cfg.MailingLists[m.cursor].Name
620		dialog := DialogBoxStyle.Render(
621			lipgloss.JoinVertical(lipgloss.Center,
622				dangerStyle.Render("Delete mailing list?"),
623				accountEmailStyle.Render(listName),
624				HelpStyle.Render("\n(y/n)"),
625			),
626		)
627		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
628	}
629
630	return docStyle.Render(mainContent + helpView)
631}
632
633// UpdateConfig updates the configuration (used when accounts are deleted).
634func (m *Settings) UpdateConfig(cfg *config.Config) {
635	m.cfg = cfg
636	if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) {
637		m.cursor = len(cfg.Accounts)
638	}
639}