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 switch msg.String() {
241 case "up", "k":
242 if m.menuCursor > 0 {
243 m.menuCursor--
244 }
245 case "down", "j":
246 if m.menuCursor < 4 {
247 m.menuCursor++
248 }
249 case "right", "l", "enter":
250 m.activeCategory = SettingsCategory(m.menuCursor)
251 m.activePane = PaneContent
252
253 // Reset states
254 m.confirmingDelete = false
255 if m.activeCategory == CategoryTheme {
256 // Find current theme index
257 themes := theme.AllThemes()
258 for i, t := range themes {
259 if t.Name == theme.ActiveTheme.Name {
260 m.themeCursor = i
261 break
262 }
263 }
264 } else if m.activeCategory == CategoryEncryption {
265 m.encError = ""
266 m.encPasswordInput.SetValue("")
267 m.encConfirmInput.SetValue("")
268 m.encFocusIndex = 0
269 m.confirmingDisable = false
270 m.encEnabling = false
271 if !config.IsSecureModeEnabled() {
272 m.encPasswordInput.Focus()
273 m.encConfirmInput.Blur()
274 }
275 }
276
277 return m, textinput.Blink
278 case "esc":
279 return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
280 }
281 m.activeCategory = SettingsCategory(m.menuCursor)
282 return m, nil
283}
284
285func (m *Settings) View() tea.View {
286 // Left pane
287 var left strings.Builder
288 left.WriteString(titleStyle.Render(t("settings.title")) + "\n\n")
289
290 categories := []string{
291 t("settings.category_general"),
292 t("settings.category_accounts"),
293 t("settings.category_theme"),
294 t("settings.category_mailing_lists"),
295 t("settings.category_encryption"),
296 }
297 for i, c := range categories {
298 cursor := " "
299 if m.menuCursor == i {
300 if m.activePane == PaneMenu {
301 cursor = "> "
302 } else {
303 cursor = "• "
304 }
305 }
306
307 style := accountItemStyle
308 if m.menuCursor == i {
309 style = selectedAccountItemStyle
310 }
311
312 left.WriteString(style.Render(cursor+c) + "\n")
313 }
314
315 leftPanel := lipgloss.NewStyle().
316 Width(30).
317 PaddingRight(2).
318 Border(lipgloss.NormalBorder(), false, true, false, false).
319 BorderForeground(theme.ActiveTheme.Secondary).
320 Render(left.String())
321
322 // Right pane
323 var right string
324 switch m.activeCategory {
325 case CategoryGeneral:
326 right = m.viewGeneral()
327 case CategoryAccounts:
328 right = m.viewAccounts()
329 case CategoryTheme:
330 right = m.viewTheme()
331 case CategoryMailingLists:
332 right = m.viewMailingLists()
333 case CategoryEncryption:
334 right = m.viewEncryption()
335 }
336
337 rightPanel := lipgloss.NewStyle().
338 PaddingLeft(2).
339 Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
340 Render(right)
341
342 content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
343
344 helpText := t("settings.help_content")
345 if m.activePane == PaneMenu {
346 helpText = t("settings.help_menu")
347 }
348 helpView := helpStyle.Render(helpText)
349
350 if m.height > 0 {
351 currentHeight := lipgloss.Height(content + "\n\n" + helpView)
352 gap := m.height - currentHeight
353 if gap > 0 {
354 content += strings.Repeat("\n", gap)
355 }
356 } else {
357 content += "\n\n"
358 }
359
360 return tea.NewView(docStyle.Render(content + helpView))
361}
362
363func (m *Settings) UpdateConfig(cfg *config.Config) {
364 m.cfg = cfg
365 if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) {
366 m.accountsCursor = len(cfg.Accounts)
367 }
368}