settings.go

  1package tui
  2
  3import (
  4	"strings"
  5
  6	"charm.land/bubbles/v2/textinput"
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9	"github.com/floatpane/matcha/config"
 10	"github.com/floatpane/matcha/internal/passwordstrength"
 11	"github.com/floatpane/matcha/plugin"
 12	"github.com/floatpane/matcha/theme"
 13)
 14
 15var (
 16	accountItemStyle         = lipgloss.NewStyle().PaddingLeft(2)
 17	selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")).Bold(true)
 18	accountEmailStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 19	dangerStyle              = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
 20	successStyle             = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 21
 22	settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 23	settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 24)
 25
 26type SettingsPane int
 27
 28const (
 29	PaneMenu SettingsPane = iota
 30	PaneContent
 31)
 32
 33type SettingsCategory int
 34
 35const (
 36	CategoryGeneral SettingsCategory = iota
 37	CategoryAccounts
 38	CategoryTheme
 39	CategoryMailingLists
 40	CategoryEncryption
 41	CategoryPlugins
 42)
 43
 44type Settings struct {
 45	cfg    *config.Config
 46	width  int
 47	height int
 48
 49	activePane     SettingsPane
 50	activeCategory SettingsCategory
 51
 52	// Menu state
 53	menuCursor int
 54
 55	// Sub-components states
 56	generalCursor    int
 57	accountsCursor   int
 58	themeCursor      int
 59	listsCursor      int
 60	confirmingDelete bool
 61
 62	// S/MIME Config fields
 63	isCryptoConfig     bool
 64	editingAccountIdx  int
 65	cryptoFocusIndex   int
 66	smimeCertInput     textinput.Model
 67	smimeKeyInput      textinput.Model
 68	pgpPublicKeyInput  textinput.Model
 69	pgpPrivateKeyInput textinput.Model
 70	pgpKeySource       string // "file" or "yubikey"
 71	pgpPINInput        textinput.Model
 72
 73	// Encryption fields
 74	encPasswordInput    textinput.Model
 75	encConfirmInput     textinput.Model
 76	encFocusIndex       int
 77	encError            string
 78	encEnabling         bool
 79	confirmingDisable   bool
 80	passwordMeter       passwordstrength.Meter
 81	encPasswordStrength passwordstrength.Strength
 82
 83	// Plugin settings state
 84	plugins             *plugin.Manager
 85	pluginListCursor    int
 86	pluginSelected      string // name of plugin whose settings are open ("" = list view)
 87	pluginSettingCursor int
 88	pluginEditing       bool
 89	pluginEditingKey    string
 90	pluginEditingType   plugin.SettingType
 91	pluginInput         textinput.Model
 92}
 93
 94type SettingsState struct {
 95	ActivePane     SettingsPane
 96	ActiveCategory SettingsCategory
 97	MenuCursor     int
 98	GeneralCursor  int
 99	AccountsCursor int
100	ThemeCursor    int
101	ListsCursor    int
102	PluginCursor   int
103}
104
105func NewSettings(cfg *config.Config) *Settings {
106	if cfg == nil {
107		cfg = &config.Config{}
108	}
109
110	tiStyles := ThemedTextInputStyles()
111
112	newInput := func(placeholder, prompt string, isPassword bool) textinput.Model {
113		t := textinput.New()
114		t.Placeholder = placeholder
115		t.Prompt = prompt
116		t.CharLimit = 256
117		t.SetStyles(tiStyles)
118		if isPassword {
119			t.EchoMode = textinput.EchoPassword
120			t.EchoCharacter = '*'
121		}
122		return t
123	}
124
125	return &Settings{
126		cfg:                cfg,
127		activePane:         PaneMenu,
128		activeCategory:     CategoryGeneral,
129		smimeCertInput:     newInput("/path/to/cert.pem", "> ", false),
130		smimeKeyInput:      newInput("/path/to/private_key.pem", "> ", false),
131		pgpPublicKeyInput:  newInput("/path/to/public_key.asc", "> ", false),
132		pgpPrivateKeyInput: newInput("/path/to/private_key.asc", "> ", false),
133		pgpPINInput:        newInput("YubiKey PIN (6-8 digits)", "> ", true),
134		pgpKeySource:       "file",
135		encPasswordInput:   newInput("Password", "> ", true),
136		encConfirmInput:    newInput("Confirm Password", "> ", true),
137		passwordMeter:      passwordstrength.NewLibMeter(),
138		pluginInput:        newInput("", "> ", false),
139	}
140}
141
142// SetPlugins attaches the plugin manager so the settings view can list and
143// edit plugin-declared settings.
144func (m *Settings) SetPlugins(p *plugin.Manager) {
145	m.plugins = p
146}
147
148func (m *Settings) GetState() SettingsState {
149	return SettingsState{
150		ActivePane:     m.activePane,
151		ActiveCategory: m.activeCategory,
152		MenuCursor:     m.menuCursor,
153		GeneralCursor:  m.generalCursor,
154		AccountsCursor: m.accountsCursor,
155		ThemeCursor:    m.themeCursor,
156		ListsCursor:    m.listsCursor,
157		PluginCursor:   m.pluginListCursor,
158	}
159}
160
161func (m *Settings) RestoreState(state SettingsState) {
162	m.activePane = state.ActivePane
163	m.activeCategory = state.ActiveCategory
164	m.menuCursor = state.MenuCursor
165	m.generalCursor = state.GeneralCursor
166	m.accountsCursor = state.AccountsCursor
167	m.themeCursor = state.ThemeCursor
168	m.listsCursor = state.ListsCursor
169	m.pluginListCursor = state.PluginCursor
170}
171
172func (m *Settings) Init() tea.Cmd {
173	return textinput.Blink
174}
175
176func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
177	var cmds []tea.Cmd
178	var cmd tea.Cmd
179
180	switch msg := msg.(type) {
181	case tea.WindowSizeMsg:
182		m.width = msg.Width
183		m.height = msg.Height
184		inputWidth := (m.width - 30) - 6 // left pane is 30
185		if inputWidth < 20 {
186			inputWidth = 20
187		}
188		m.smimeCertInput.SetWidth(inputWidth)
189		m.smimeKeyInput.SetWidth(inputWidth)
190		m.pgpPublicKeyInput.SetWidth(inputWidth)
191		m.pgpPrivateKeyInput.SetWidth(inputWidth)
192		m.pgpPINInput.SetWidth(inputWidth)
193		m.pluginInput.SetWidth(inputWidth)
194		return m, nil
195
196	case tea.KeyPressMsg:
197		return m.updateKeyPress(msg)
198
199	case SecureModeEnabledMsg:
200		m.encEnabling = false
201		if msg.Err != nil {
202			m.encError = msg.Err.Error()
203			return m, nil
204		}
205		m.activePane = PaneMenu
206		return m, nil
207
208	case SecureModeDisabledMsg:
209		if msg.Err != nil {
210			m.encError = msg.Err.Error()
211			return m, nil
212		}
213		m.confirmingDisable = false
214		m.activePane = PaneMenu
215		return m, nil
216	}
217
218	// Update text inputs if active
219	if m.activePane == PaneContent {
220		switch {
221		case m.activeCategory == CategoryEncryption:
222			m.encPasswordInput, cmd = m.encPasswordInput.Update(msg)
223			cmds = append(cmds, cmd)
224			m.encConfirmInput, cmd = m.encConfirmInput.Update(msg)
225			cmds = append(cmds, cmd)
226		case m.activeCategory == CategoryAccounts && m.isCryptoConfig:
227			m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
228			cmds = append(cmds, cmd)
229			m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
230			cmds = append(cmds, cmd)
231			m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
232			cmds = append(cmds, cmd)
233			m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
234			cmds = append(cmds, cmd)
235			m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
236			cmds = append(cmds, cmd)
237		case m.activeCategory == CategoryPlugins && m.pluginEditing:
238			m.pluginInput, cmd = m.pluginInput.Update(msg)
239			cmds = append(cmds, cmd)
240		}
241	}
242
243	return m, tea.Batch(cmds...)
244}
245
246func (m *Settings) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
247	// Global shortcut to return to menu from content pane
248	if m.activePane == PaneContent && msg.String() == "esc" {
249		// unless we are in crypto config or encryption editing which have their own esc logic
250		if (m.activeCategory != CategoryAccounts || !m.isCryptoConfig) &&
251			(m.activeCategory != CategoryEncryption || m.encFocusIndex <= -1) &&
252			(m.activeCategory != CategoryPlugins || (!m.pluginEditing && m.pluginSelected == "")) {
253			m.activePane = PaneMenu
254			return m, nil
255		}
256	}
257
258	if m.activePane == PaneContent && msg.String() == keyLeft && m.canFocusSettingsMenuWithLeft() {
259		m.activePane = PaneMenu
260		return m, nil
261	}
262
263	if m.activePane == PaneMenu {
264		return m.updateMenu(msg)
265	}
266	switch m.activeCategory {
267	case CategoryGeneral:
268		return m.updateGeneral(msg)
269	case CategoryAccounts:
270		return m.updateAccounts(msg)
271	case CategoryTheme:
272		return m.updateTheme(msg)
273	case CategoryMailingLists:
274		return m.updateMailingLists(msg)
275	case CategoryEncryption:
276		return m.updateEncryption(msg)
277	case CategoryPlugins:
278		return m.updatePlugins(msg)
279	}
280
281	return m, nil
282}
283
284func (m *Settings) canFocusSettingsMenuWithLeft() bool {
285	switch m.activeCategory {
286	case CategoryAccounts:
287		return !m.isCryptoConfig && !m.confirmingDelete
288	case CategoryEncryption:
289		return config.IsSecureModeEnabled() && !m.confirmingDisable
290	case CategoryPlugins:
291		return !m.pluginEditing && m.pluginSelected == ""
292	case CategoryGeneral, CategoryTheme, CategoryMailingLists:
293		return true
294	default:
295		return true
296	}
297}
298
299func (m *Settings) contentItemStyle(selected bool) lipgloss.Style {
300	if selected && m.activePane == PaneContent {
301		return selectedAccountItemStyle
302	}
303	return accountItemStyle
304}
305
306func (m *Settings) contentCursor(selected bool) string {
307	if selected && m.activePane == PaneContent {
308		return "> "
309	}
310	return "  "
311}
312
313func (m *Settings) contentFocusStyle() lipgloss.Style {
314	if m.activePane == PaneContent {
315		return settingsFocusedStyle
316	}
317	return settingsBlurredStyle
318}
319
320func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
321	categoryCount := int(CategoryPlugins) + 1
322
323	switch msg.String() {
324	case "up", "k":
325		m.menuCursor = (m.menuCursor - 1 + categoryCount) % categoryCount
326	case keyDown, "j":
327		m.menuCursor = (m.menuCursor + 1) % categoryCount
328	case keyRight, "l", keyEnter:
329		m.activeCategory = SettingsCategory(m.menuCursor)
330		m.activePane = PaneContent
331
332		// Reset states
333		m.confirmingDelete = false
334		if m.activeCategory == CategoryTheme {
335			// Find current theme index
336			themes := theme.AllThemes()
337			for i, t := range themes {
338				if t.Name == theme.ActiveTheme.Name {
339					m.themeCursor = i
340					break
341				}
342			}
343		} else if m.activeCategory == CategoryEncryption {
344			m.encError = ""
345			m.encPasswordInput.SetValue("")
346			m.encConfirmInput.SetValue("")
347			m.encPasswordStrength = ""
348			m.encFocusIndex = 0
349			m.confirmingDisable = false
350			m.encEnabling = false
351			if !config.IsSecureModeEnabled() {
352				m.encPasswordInput.Focus()
353				m.encConfirmInput.Blur()
354			}
355		}
356
357		return m, textinput.Blink
358	case "esc":
359		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
360	}
361	m.activeCategory = SettingsCategory(m.menuCursor)
362	return m, nil
363}
364
365func (m *Settings) View() tea.View {
366	// Left pane
367	var left strings.Builder
368	left.WriteString(titleStyle.Render(t("settings.title")) + "\n\n")
369
370	categories := []string{
371		t("settings.category_general"),
372		t("settings.category_accounts"),
373		t("settings.category_theme"),
374		t("settings.category_mailing_lists"),
375		t("settings.category_encryption"),
376		t("settings.category_plugins"),
377	}
378	for i, c := range categories {
379		cursor := "  "
380		if m.menuCursor == i {
381			if m.activePane == PaneMenu {
382				cursor = "> "
383			} else {
384				cursor = "• "
385			}
386		}
387
388		style := accountItemStyle
389		if m.menuCursor == i {
390			if m.activePane == PaneMenu {
391				style = selectedAccountItemStyle
392			} else {
393				style = selectedAccountItemStyle.UnsetBold()
394			}
395		}
396
397		left.WriteString(style.Render(cursor+c) + "\n")
398	}
399
400	leftPanel := lipgloss.NewStyle().
401		Width(30).
402		PaddingRight(2).
403		Border(lipgloss.NormalBorder(), false, true, false, false).
404		BorderForeground(theme.ActiveTheme.Secondary).
405		Render(left.String())
406
407	// Right pane
408	var right string
409	switch m.activeCategory {
410	case CategoryGeneral:
411		right = m.viewGeneral()
412	case CategoryAccounts:
413		right = m.viewAccounts()
414	case CategoryTheme:
415		right = m.viewTheme()
416	case CategoryMailingLists:
417		right = m.viewMailingLists()
418	case CategoryEncryption:
419		right = m.viewEncryption()
420	case CategoryPlugins:
421		right = m.viewPlugins()
422	}
423
424	rightPanel := lipgloss.NewStyle().
425		PaddingLeft(2).
426		Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
427		Render(right)
428
429	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
430
431	helpText := t("settings.help_content")
432	if m.activePane == PaneMenu {
433		helpText = t("settings.help_menu")
434	}
435	helpView := helpStyle.Render(helpText)
436
437	if m.height > 0 {
438		currentHeight := lipgloss.Height(content + "\n\n" + helpView)
439		gap := m.height - currentHeight
440		if gap > 0 {
441			content += strings.Repeat("\n", gap)
442		}
443	} else {
444		content += "\n\n"
445	}
446
447	return tea.NewView(docStyle.Render(content + helpView))
448}
449
450func (m *Settings) UpdateConfig(cfg *config.Config) {
451	m.cfg = cfg
452	if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) {
453		m.accountsCursor = len(cfg.Accounts)
454	}
455}