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
78func NewSettings(cfg *config.Config) *Settings {
79 if cfg == nil {
80 cfg = &config.Config{}
81 }
82
83 tiStyles := ThemedTextInputStyles()
84
85 newInput := func(placeholder, prompt string, isPassword bool) textinput.Model {
86 t := textinput.New()
87 t.Placeholder = placeholder
88 t.Prompt = prompt
89 t.CharLimit = 256
90 t.SetStyles(tiStyles)
91 if isPassword {
92 t.EchoMode = textinput.EchoPassword
93 t.EchoCharacter = '*'
94 }
95 return t
96 }
97
98 return &Settings{
99 cfg: cfg,
100 activePane: PaneMenu,
101 activeCategory: CategoryGeneral,
102 smimeCertInput: newInput("/path/to/cert.pem", "> ", false),
103 smimeKeyInput: newInput("/path/to/private_key.pem", "> ", false),
104 pgpPublicKeyInput: newInput("/path/to/public_key.asc", "> ", false),
105 pgpPrivateKeyInput: newInput("/path/to/private_key.asc", "> ", false),
106 pgpPINInput: newInput("YubiKey PIN (6-8 digits)", "> ", true),
107 pgpKeySource: "file",
108 encPasswordInput: newInput("Password", "> ", true),
109 encConfirmInput: newInput("Confirm Password", "> ", true),
110 }
111}
112
113func (m *Settings) Init() tea.Cmd {
114 return textinput.Blink
115}
116
117func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118 var cmds []tea.Cmd
119 var cmd tea.Cmd
120
121 switch msg := msg.(type) {
122 case tea.WindowSizeMsg:
123 m.width = msg.Width
124 m.height = msg.Height
125 inputWidth := (m.width - 30) - 6 // left pane is 30
126 if inputWidth < 20 {
127 inputWidth = 20
128 }
129 m.smimeCertInput.SetWidth(inputWidth)
130 m.smimeKeyInput.SetWidth(inputWidth)
131 m.pgpPublicKeyInput.SetWidth(inputWidth)
132 m.pgpPrivateKeyInput.SetWidth(inputWidth)
133 m.pgpPINInput.SetWidth(inputWidth)
134 return m, nil
135
136 case tea.KeyPressMsg:
137 // Global shortcut to return to menu from content pane
138 if m.activePane == PaneContent && msg.String() == "esc" {
139 // unless we are in crypto config or encryption editing which have their own esc logic
140 if !(m.activeCategory == CategoryAccounts && m.isCryptoConfig) &&
141 !(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) {
142 m.activePane = PaneMenu
143 return m, nil
144 }
145 }
146
147 if m.activePane == PaneMenu {
148 return m.updateMenu(msg)
149 } else {
150 switch m.activeCategory {
151 case CategoryGeneral:
152 return m.updateGeneral(msg)
153 case CategoryAccounts:
154 return m.updateAccounts(msg)
155 case CategoryTheme:
156 return m.updateTheme(msg)
157 case CategoryMailingLists:
158 return m.updateMailingLists(msg)
159 case CategoryEncryption:
160 return m.updateEncryption(msg)
161 }
162 }
163
164 case SecureModeEnabledMsg:
165 m.encEnabling = false
166 if msg.Err != nil {
167 m.encError = msg.Err.Error()
168 return m, nil
169 }
170 m.activePane = PaneMenu
171 return m, nil
172
173 case SecureModeDisabledMsg:
174 if msg.Err != nil {
175 m.encError = msg.Err.Error()
176 return m, nil
177 }
178 m.confirmingDisable = false
179 m.activePane = PaneMenu
180 return m, nil
181 }
182
183 // Update text inputs if active
184 if m.activePane == PaneContent {
185 if m.activeCategory == CategoryEncryption {
186 m.encPasswordInput, cmd = m.encPasswordInput.Update(msg)
187 cmds = append(cmds, cmd)
188 m.encConfirmInput, cmd = m.encConfirmInput.Update(msg)
189 cmds = append(cmds, cmd)
190 } else if m.activeCategory == CategoryAccounts && m.isCryptoConfig {
191 m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
192 cmds = append(cmds, cmd)
193 m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
194 cmds = append(cmds, cmd)
195 m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
196 cmds = append(cmds, cmd)
197 m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
198 cmds = append(cmds, cmd)
199 m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
200 cmds = append(cmds, cmd)
201 }
202 }
203
204 return m, tea.Batch(cmds...)
205}
206
207func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
208 switch msg.String() {
209 case "up", "k":
210 if m.menuCursor > 0 {
211 m.menuCursor--
212 }
213 case "down", "j":
214 if m.menuCursor < 4 {
215 m.menuCursor++
216 }
217 case "right", "l", "enter":
218 m.activeCategory = SettingsCategory(m.menuCursor)
219 m.activePane = PaneContent
220
221 // Reset states
222 m.confirmingDelete = false
223 if m.activeCategory == CategoryTheme {
224 // Find current theme index
225 themes := theme.AllThemes()
226 for i, t := range themes {
227 if t.Name == theme.ActiveTheme.Name {
228 m.themeCursor = i
229 break
230 }
231 }
232 } else if m.activeCategory == CategoryEncryption {
233 m.encError = ""
234 m.encPasswordInput.SetValue("")
235 m.encConfirmInput.SetValue("")
236 m.encFocusIndex = 0
237 m.confirmingDisable = false
238 m.encEnabling = false
239 if !config.IsSecureModeEnabled() {
240 m.encPasswordInput.Focus()
241 m.encConfirmInput.Blur()
242 }
243 }
244
245 return m, textinput.Blink
246 case "esc":
247 return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
248 }
249 m.activeCategory = SettingsCategory(m.menuCursor)
250 return m, nil
251}
252
253func (m *Settings) View() tea.View {
254 // Left pane
255 var left strings.Builder
256 left.WriteString(titleStyle.Render(t("settings.title")) + "\n\n")
257
258 categories := []string{
259 t("settings.category_general"),
260 t("settings.category_accounts"),
261 t("settings.category_theme"),
262 t("settings.category_mailing_lists"),
263 t("settings.category_encryption"),
264 }
265 for i, c := range categories {
266 cursor := " "
267 if m.menuCursor == i {
268 if m.activePane == PaneMenu {
269 cursor = "> "
270 } else {
271 cursor = "• "
272 }
273 }
274
275 style := accountItemStyle
276 if m.menuCursor == i {
277 style = selectedAccountItemStyle
278 }
279
280 left.WriteString(style.Render(cursor+c) + "\n")
281 }
282
283 leftPanel := lipgloss.NewStyle().
284 Width(30).
285 PaddingRight(2).
286 Border(lipgloss.NormalBorder(), false, true, false, false).
287 BorderForeground(theme.ActiveTheme.Secondary).
288 Render(left.String())
289
290 // Right pane
291 var right string
292 switch m.activeCategory {
293 case CategoryGeneral:
294 right = m.viewGeneral()
295 case CategoryAccounts:
296 right = m.viewAccounts()
297 case CategoryTheme:
298 right = m.viewTheme()
299 case CategoryMailingLists:
300 right = m.viewMailingLists()
301 case CategoryEncryption:
302 right = m.viewEncryption()
303 }
304
305 rightPanel := lipgloss.NewStyle().
306 PaddingLeft(2).
307 Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
308 Render(right)
309
310 content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
311
312 helpText := t("settings.help_content")
313 if m.activePane == PaneMenu {
314 helpText = t("settings.help_menu")
315 }
316 helpView := helpStyle.Render(helpText)
317
318 if m.height > 0 {
319 currentHeight := lipgloss.Height(content + "\n\n" + helpView)
320 gap := m.height - currentHeight
321 if gap > 0 {
322 content += strings.Repeat("\n", gap)
323 }
324 } else {
325 content += "\n\n"
326 }
327
328 return tea.NewView(docStyle.Render(content + helpView))
329}
330
331func (m *Settings) UpdateConfig(cfg *config.Config) {
332 m.cfg = cfg
333 if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) {
334 m.accountsCursor = len(cfg.Accounts)
335 }
336}