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		// Global shortcut to return to menu from content pane
198		if m.activePane == PaneContent && msg.String() == "esc" {
199			// unless we are in crypto config or encryption editing which have their own esc logic
200			if (m.activeCategory != CategoryAccounts || !m.isCryptoConfig) &&
201				(m.activeCategory != CategoryEncryption || m.encFocusIndex <= -1) &&
202				(m.activeCategory != CategoryPlugins || (!m.pluginEditing && m.pluginSelected == "")) {
203				m.activePane = PaneMenu
204				return m, nil
205			}
206		}
207
208		if m.activePane == PaneMenu {
209			return m.updateMenu(msg)
210		}
211		switch m.activeCategory {
212		case CategoryGeneral:
213			return m.updateGeneral(msg)
214		case CategoryAccounts:
215			return m.updateAccounts(msg)
216		case CategoryTheme:
217			return m.updateTheme(msg)
218		case CategoryMailingLists:
219			return m.updateMailingLists(msg)
220		case CategoryEncryption:
221			return m.updateEncryption(msg)
222		case CategoryPlugins:
223			return m.updatePlugins(msg)
224		}
225
226	case SecureModeEnabledMsg:
227		m.encEnabling = false
228		if msg.Err != nil {
229			m.encError = msg.Err.Error()
230			return m, nil
231		}
232		m.activePane = PaneMenu
233		return m, nil
234
235	case SecureModeDisabledMsg:
236		if msg.Err != nil {
237			m.encError = msg.Err.Error()
238			return m, nil
239		}
240		m.confirmingDisable = false
241		m.activePane = PaneMenu
242		return m, nil
243	}
244
245	// Update text inputs if active
246	if m.activePane == PaneContent {
247		switch {
248		case m.activeCategory == CategoryEncryption:
249			m.encPasswordInput, cmd = m.encPasswordInput.Update(msg)
250			cmds = append(cmds, cmd)
251			m.encConfirmInput, cmd = m.encConfirmInput.Update(msg)
252			cmds = append(cmds, cmd)
253		case m.activeCategory == CategoryAccounts && m.isCryptoConfig:
254			m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
255			cmds = append(cmds, cmd)
256			m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
257			cmds = append(cmds, cmd)
258			m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
259			cmds = append(cmds, cmd)
260			m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
261			cmds = append(cmds, cmd)
262			m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
263			cmds = append(cmds, cmd)
264		case m.activeCategory == CategoryPlugins && m.pluginEditing:
265			m.pluginInput, cmd = m.pluginInput.Update(msg)
266			cmds = append(cmds, cmd)
267		}
268	}
269
270	return m, tea.Batch(cmds...)
271}
272
273func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
274	categoryCount := int(CategoryPlugins) + 1
275
276	switch msg.String() {
277	case "up", "k":
278		m.menuCursor = (m.menuCursor - 1 + categoryCount) % categoryCount
279	case keyDown, "j":
280		m.menuCursor = (m.menuCursor + 1) % categoryCount
281	case keyRight, "l", keyEnter:
282		m.activeCategory = SettingsCategory(m.menuCursor)
283		m.activePane = PaneContent
284
285		// Reset states
286		m.confirmingDelete = false
287		if m.activeCategory == CategoryTheme {
288			// Find current theme index
289			themes := theme.AllThemes()
290			for i, t := range themes {
291				if t.Name == theme.ActiveTheme.Name {
292					m.themeCursor = i
293					break
294				}
295			}
296		} else if m.activeCategory == CategoryEncryption {
297			m.encError = ""
298			m.encPasswordInput.SetValue("")
299			m.encConfirmInput.SetValue("")
300			m.encPasswordStrength = ""
301			m.encFocusIndex = 0
302			m.confirmingDisable = false
303			m.encEnabling = false
304			if !config.IsSecureModeEnabled() {
305				m.encPasswordInput.Focus()
306				m.encConfirmInput.Blur()
307			}
308		}
309
310		return m, textinput.Blink
311	case "esc":
312		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
313	}
314	m.activeCategory = SettingsCategory(m.menuCursor)
315	return m, nil
316}
317
318func (m *Settings) View() tea.View {
319	// Left pane
320	var left strings.Builder
321	left.WriteString(titleStyle.Render(t("settings.title")) + "\n\n")
322
323	categories := []string{
324		t("settings.category_general"),
325		t("settings.category_accounts"),
326		t("settings.category_theme"),
327		t("settings.category_mailing_lists"),
328		t("settings.category_encryption"),
329		t("settings.category_plugins"),
330	}
331	for i, c := range categories {
332		cursor := "  "
333		if m.menuCursor == i {
334			if m.activePane == PaneMenu {
335				cursor = "> "
336			} else {
337				cursor = "• "
338			}
339		}
340
341		style := accountItemStyle
342		if m.menuCursor == i {
343			style = selectedAccountItemStyle
344		}
345
346		left.WriteString(style.Render(cursor+c) + "\n")
347	}
348
349	leftPanel := lipgloss.NewStyle().
350		Width(30).
351		PaddingRight(2).
352		Border(lipgloss.NormalBorder(), false, true, false, false).
353		BorderForeground(theme.ActiveTheme.Secondary).
354		Render(left.String())
355
356	// Right pane
357	var right string
358	switch m.activeCategory {
359	case CategoryGeneral:
360		right = m.viewGeneral()
361	case CategoryAccounts:
362		right = m.viewAccounts()
363	case CategoryTheme:
364		right = m.viewTheme()
365	case CategoryMailingLists:
366		right = m.viewMailingLists()
367	case CategoryEncryption:
368		right = m.viewEncryption()
369	case CategoryPlugins:
370		right = m.viewPlugins()
371	}
372
373	rightPanel := lipgloss.NewStyle().
374		PaddingLeft(2).
375		Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
376		Render(right)
377
378	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
379
380	helpText := t("settings.help_content")
381	if m.activePane == PaneMenu {
382		helpText = t("settings.help_menu")
383	}
384	helpView := helpStyle.Render(helpText)
385
386	if m.height > 0 {
387		currentHeight := lipgloss.Height(content + "\n\n" + helpView)
388		gap := m.height - currentHeight
389		if gap > 0 {
390			content += strings.Repeat("\n", gap)
391		}
392	} else {
393		content += "\n\n"
394	}
395
396	return tea.NewView(docStyle.Render(content + helpView))
397}
398
399func (m *Settings) UpdateConfig(cfg *config.Config) {
400	m.cfg = cfg
401	if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) {
402		m.accountsCursor = len(cfg.Accounts)
403	}
404}