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