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	categoryCount := int(CategoryEncryption) + 1
241
242	switch msg.String() {
243	case "up", "k":
244		m.menuCursor = (m.menuCursor - 1 + categoryCount) % categoryCount
245	case "down", "j":
246		m.menuCursor = (m.menuCursor + 1) % categoryCount
247	case "right", "l", "enter":
248		m.activeCategory = SettingsCategory(m.menuCursor)
249		m.activePane = PaneContent
250
251		// Reset states
252		m.confirmingDelete = false
253		if m.activeCategory == CategoryTheme {
254			// Find current theme index
255			themes := theme.AllThemes()
256			for i, t := range themes {
257				if t.Name == theme.ActiveTheme.Name {
258					m.themeCursor = i
259					break
260				}
261			}
262		} else if m.activeCategory == CategoryEncryption {
263			m.encError = ""
264			m.encPasswordInput.SetValue("")
265			m.encConfirmInput.SetValue("")
266			m.encFocusIndex = 0
267			m.confirmingDisable = false
268			m.encEnabling = false
269			if !config.IsSecureModeEnabled() {
270				m.encPasswordInput.Focus()
271				m.encConfirmInput.Blur()
272			}
273		}
274
275		return m, textinput.Blink
276	case "esc":
277		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
278	}
279	m.activeCategory = SettingsCategory(m.menuCursor)
280	return m, nil
281}
282
283func (m *Settings) View() tea.View {
284	// Left pane
285	var left strings.Builder
286	left.WriteString(titleStyle.Render(t("settings.title")) + "\n\n")
287
288	categories := []string{
289		t("settings.category_general"),
290		t("settings.category_accounts"),
291		t("settings.category_theme"),
292		t("settings.category_mailing_lists"),
293		t("settings.category_encryption"),
294	}
295	for i, c := range categories {
296		cursor := "  "
297		if m.menuCursor == i {
298			if m.activePane == PaneMenu {
299				cursor = "> "
300			} else {
301				cursor = "• "
302			}
303		}
304
305		style := accountItemStyle
306		if m.menuCursor == i {
307			style = selectedAccountItemStyle
308		}
309
310		left.WriteString(style.Render(cursor+c) + "\n")
311	}
312
313	leftPanel := lipgloss.NewStyle().
314		Width(30).
315		PaddingRight(2).
316		Border(lipgloss.NormalBorder(), false, true, false, false).
317		BorderForeground(theme.ActiveTheme.Secondary).
318		Render(left.String())
319
320	// Right pane
321	var right string
322	switch m.activeCategory {
323	case CategoryGeneral:
324		right = m.viewGeneral()
325	case CategoryAccounts:
326		right = m.viewAccounts()
327	case CategoryTheme:
328		right = m.viewTheme()
329	case CategoryMailingLists:
330		right = m.viewMailingLists()
331	case CategoryEncryption:
332		right = m.viewEncryption()
333	}
334
335	rightPanel := lipgloss.NewStyle().
336		PaddingLeft(2).
337		Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
338		Render(right)
339
340	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
341
342	helpText := t("settings.help_content")
343	if m.activePane == PaneMenu {
344		helpText = t("settings.help_menu")
345	}
346	helpView := helpStyle.Render(helpText)
347
348	if m.height > 0 {
349		currentHeight := lipgloss.Height(content + "\n\n" + helpView)
350		gap := m.height - currentHeight
351		if gap > 0 {
352			content += strings.Repeat("\n", gap)
353		}
354	} else {
355		content += "\n\n"
356	}
357
358	return tea.NewView(docStyle.Render(content + helpView))
359}
360
361func (m *Settings) UpdateConfig(cfg *config.Config) {
362	m.cfg = cfg
363	if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) {
364		m.accountsCursor = len(cfg.Accounts)
365	}
366}