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("Settings") + "\n\n")
257
258 categories := []string{"General", "Accounts", "Theme", "Mailing Lists", "App Encryption"}
259 for i, c := range categories {
260 cursor := " "
261 if m.menuCursor == i {
262 if m.activePane == PaneMenu {
263 cursor = "> "
264 } else {
265 cursor = "• "
266 }
267 }
268
269 style := accountItemStyle
270 if m.menuCursor == i {
271 style = selectedAccountItemStyle
272 }
273
274 left.WriteString(style.Render(cursor+c) + "\n")
275 }
276
277 leftPanel := lipgloss.NewStyle().
278 Width(30).
279 PaddingRight(2).
280 Border(lipgloss.NormalBorder(), false, true, false, false).
281 BorderForeground(theme.ActiveTheme.Secondary).
282 Render(left.String())
283
284 // Right pane
285 var right string
286 switch m.activeCategory {
287 case CategoryGeneral:
288 right = m.viewGeneral()
289 case CategoryAccounts:
290 right = m.viewAccounts()
291 case CategoryTheme:
292 right = m.viewTheme()
293 case CategoryMailingLists:
294 right = m.viewMailingLists()
295 case CategoryEncryption:
296 right = m.viewEncryption()
297 }
298
299 rightPanel := lipgloss.NewStyle().
300 PaddingLeft(2).
301 Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
302 Render(right)
303
304 content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
305
306 helpText := "esc: back to menu"
307 if m.activePane == PaneMenu {
308 helpText = "↑/↓: navigate • right/enter: select • esc: go back"
309 }
310 helpView := helpStyle.Render(helpText)
311
312 if m.height > 0 {
313 currentHeight := lipgloss.Height(content + "\n\n" + helpView)
314 gap := m.height - currentHeight
315 if gap > 0 {
316 content += strings.Repeat("\n", gap)
317 }
318 } else {
319 content += "\n\n"
320 }
321
322 return tea.NewView(docStyle.Render(content + helpView))
323}
324
325func (m *Settings) UpdateConfig(cfg *config.Config) {
326 m.cfg = cfg
327 if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) {
328 m.accountsCursor = len(cfg.Accounts)
329 }
330}