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
 78func NewSettings(cfg *config.Config) *Settings {
 79	if cfg == nil {
 80		cfg = &config.Config{}
 81	}
 82
 83	tiStyles := ThemedTextInputStyles()
 84
 85	newInput := func(placeholder, prompt string, isPassword bool) textinput.Model {
 86		t := textinput.New()
 87		t.Placeholder = placeholder
 88		t.Prompt = prompt
 89		t.CharLimit = 256
 90		t.SetStyles(tiStyles)
 91		if isPassword {
 92			t.EchoMode = textinput.EchoPassword
 93			t.EchoCharacter = '*'
 94		}
 95		return t
 96	}
 97
 98	return &Settings{
 99		cfg:                cfg,
100		activePane:         PaneMenu,
101		activeCategory:     CategoryGeneral,
102		smimeCertInput:     newInput("/path/to/cert.pem", "> ", false),
103		smimeKeyInput:      newInput("/path/to/private_key.pem", "> ", false),
104		pgpPublicKeyInput:  newInput("/path/to/public_key.asc", "> ", false),
105		pgpPrivateKeyInput: newInput("/path/to/private_key.asc", "> ", false),
106		pgpPINInput:        newInput("YubiKey PIN (6-8 digits)", "> ", true),
107		pgpKeySource:       "file",
108		encPasswordInput:   newInput("Password", "> ", true),
109		encConfirmInput:    newInput("Confirm Password", "> ", true),
110	}
111}
112
113func (m *Settings) Init() tea.Cmd {
114	return textinput.Blink
115}
116
117func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118	var cmds []tea.Cmd
119	var cmd tea.Cmd
120
121	switch msg := msg.(type) {
122	case tea.WindowSizeMsg:
123		m.width = msg.Width
124		m.height = msg.Height
125		inputWidth := (m.width - 30) - 6 // left pane is 30
126		if inputWidth < 20 {
127			inputWidth = 20
128		}
129		m.smimeCertInput.SetWidth(inputWidth)
130		m.smimeKeyInput.SetWidth(inputWidth)
131		m.pgpPublicKeyInput.SetWidth(inputWidth)
132		m.pgpPrivateKeyInput.SetWidth(inputWidth)
133		m.pgpPINInput.SetWidth(inputWidth)
134		return m, nil
135
136	case tea.KeyPressMsg:
137		// Global shortcut to return to menu from content pane
138		if m.activePane == PaneContent && msg.String() == "esc" {
139			// unless we are in crypto config or encryption editing which have their own esc logic
140			if !(m.activeCategory == CategoryAccounts && m.isCryptoConfig) &&
141				!(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) {
142				m.activePane = PaneMenu
143				return m, nil
144			}
145		}
146
147		if m.activePane == PaneMenu {
148			return m.updateMenu(msg)
149		} else {
150			switch m.activeCategory {
151			case CategoryGeneral:
152				return m.updateGeneral(msg)
153			case CategoryAccounts:
154				return m.updateAccounts(msg)
155			case CategoryTheme:
156				return m.updateTheme(msg)
157			case CategoryMailingLists:
158				return m.updateMailingLists(msg)
159			case CategoryEncryption:
160				return m.updateEncryption(msg)
161			}
162		}
163
164	case SecureModeEnabledMsg:
165		m.encEnabling = false
166		if msg.Err != nil {
167			m.encError = msg.Err.Error()
168			return m, nil
169		}
170		m.activePane = PaneMenu
171		return m, nil
172
173	case SecureModeDisabledMsg:
174		if msg.Err != nil {
175			m.encError = msg.Err.Error()
176			return m, nil
177		}
178		m.confirmingDisable = false
179		m.activePane = PaneMenu
180		return m, nil
181	}
182
183	// Update text inputs if active
184	if m.activePane == PaneContent {
185		if m.activeCategory == CategoryEncryption {
186			m.encPasswordInput, cmd = m.encPasswordInput.Update(msg)
187			cmds = append(cmds, cmd)
188			m.encConfirmInput, cmd = m.encConfirmInput.Update(msg)
189			cmds = append(cmds, cmd)
190		} else if m.activeCategory == CategoryAccounts && m.isCryptoConfig {
191			m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
192			cmds = append(cmds, cmd)
193			m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
194			cmds = append(cmds, cmd)
195			m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
196			cmds = append(cmds, cmd)
197			m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
198			cmds = append(cmds, cmd)
199			m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
200			cmds = append(cmds, cmd)
201		}
202	}
203
204	return m, tea.Batch(cmds...)
205}
206
207func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
208	switch msg.String() {
209	case "up", "k":
210		if m.menuCursor > 0 {
211			m.menuCursor--
212		}
213	case "down", "j":
214		if m.menuCursor < 4 {
215			m.menuCursor++
216		}
217	case "right", "l", "enter":
218		m.activeCategory = SettingsCategory(m.menuCursor)
219		m.activePane = PaneContent
220
221		// Reset states
222		m.confirmingDelete = false
223		if m.activeCategory == CategoryTheme {
224			// Find current theme index
225			themes := theme.AllThemes()
226			for i, t := range themes {
227				if t.Name == theme.ActiveTheme.Name {
228					m.themeCursor = i
229					break
230				}
231			}
232		} else if m.activeCategory == CategoryEncryption {
233			m.encError = ""
234			m.encPasswordInput.SetValue("")
235			m.encConfirmInput.SetValue("")
236			m.encFocusIndex = 0
237			m.confirmingDisable = false
238			m.encEnabling = false
239			if !config.IsSecureModeEnabled() {
240				m.encPasswordInput.Focus()
241				m.encConfirmInput.Blur()
242			}
243		}
244
245		return m, textinput.Blink
246	case "esc":
247		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
248	}
249	m.activeCategory = SettingsCategory(m.menuCursor)
250	return m, nil
251}
252
253func (m *Settings) View() tea.View {
254	// Left pane
255	var left strings.Builder
256	left.WriteString(titleStyle.Render("Settings") + "\n\n")
257
258	categories := []string{"General", "Accounts", "Theme", "Mailing Lists", "App Encryption"}
259	for i, c := range categories {
260		cursor := "  "
261		if m.menuCursor == i {
262			if m.activePane == PaneMenu {
263				cursor = "> "
264			} else {
265				cursor = "• "
266			}
267		}
268
269		style := accountItemStyle
270		if m.menuCursor == i {
271			style = selectedAccountItemStyle
272		}
273
274		left.WriteString(style.Render(cursor+c) + "\n")
275	}
276
277	leftPanel := lipgloss.NewStyle().
278		Width(30).
279		PaddingRight(2).
280		Border(lipgloss.NormalBorder(), false, true, false, false).
281		BorderForeground(theme.ActiveTheme.Secondary).
282		Render(left.String())
283
284	// Right pane
285	var right string
286	switch m.activeCategory {
287	case CategoryGeneral:
288		right = m.viewGeneral()
289	case CategoryAccounts:
290		right = m.viewAccounts()
291	case CategoryTheme:
292		right = m.viewTheme()
293	case CategoryMailingLists:
294		right = m.viewMailingLists()
295	case CategoryEncryption:
296		right = m.viewEncryption()
297	}
298
299	rightPanel := lipgloss.NewStyle().
300		PaddingLeft(2).
301		Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
302		Render(right)
303
304	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
305
306	helpText := "esc: back to menu"
307	if m.activePane == PaneMenu {
308		helpText = "↑/↓: navigate • right/enter: select • esc: go back"
309	}
310	helpView := helpStyle.Render(helpText)
311
312	if m.height > 0 {
313		currentHeight := lipgloss.Height(content + "\n\n" + helpView)
314		gap := m.height - currentHeight
315		if gap > 0 {
316			content += strings.Repeat("\n", gap)
317		}
318	} else {
319		content += "\n\n"
320	}
321
322	return tea.NewView(docStyle.Render(content + helpView))
323}
324
325func (m *Settings) UpdateConfig(cfg *config.Config) {
326	m.cfg = cfg
327	if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) {
328		m.accountsCursor = len(cfg.Accounts)
329	}
330}