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