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