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