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