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}