1mod env;
2mod key_binding;
3mod symbol;
4
5use core::fmt;
6use std::{collections::BTreeMap, fs::read_to_string};
7
8use etcetera::{choose_base_strategy, home_dir, BaseStrategy};
9use key_binding::KeyBinding;
10use serde::Deserialize;
11
12use crate::{app::Message, command::Command};
13
14pub(crate) use key_binding::{Key, Keystroke};
15pub(crate) use symbol::Symbols;
16
17#[derive(Debug, thiserror::Error)]
18pub enum ConfigError {
19 #[error(transparent)]
21 Io(#[from] std::io::Error),
22 #[error(transparent)]
24 HomeDir(#[from] etcetera::HomeDirError),
25 #[error(transparent)]
27 Toml(#[from] toml::de::Error),
28 #[error("Invalid keybinding: {0}")]
29 InvalidKeybinding(String),
30 #[error("Unknown code: {0}")]
31 UnknownKeyCode(String),
32 #[error("Unknown modifiers: {0}")]
33 UnknownKeyModifiers(String),
34 #[error("User config not found: {0}")]
35 UserConfigNotFound(String),
36 #[error("Invalid config: {0}")]
37 InvalidConfig(String),
38}
39
40#[derive(Clone, Debug, PartialEq)]
41pub struct ConfigSection<'a> {
42 pub key_bindings: BTreeMap<String, Message<'a>>,
43}
44
45impl ConfigSection<'_> {
46 pub(crate) fn merge_key_bindings(&mut self, config: Self) {
49 config.key_bindings.into_iter().for_each(|(key, message)| {
50 self.key_bindings.insert(key, message);
51 });
52 }
53
54 pub(crate) fn replace_key_bindings(&mut self, config: Self) {
56 if !config.key_bindings.is_empty() {
57 self.key_bindings = config.key_bindings;
58 }
59 }
60
61 pub fn sequence_to_message(&self, keys: &[Keystroke]) -> Option<Message<'_>> {
62 let s: String = keys.iter().map(|k| k.to_string()).collect();
63 self.key_bindings.get(&s).cloned()
64 }
65
66 pub fn is_sequence_prefix(&self, keys: &[Keystroke]) -> bool {
67 let s: String = keys.iter().map(|k| k.to_string()).collect();
68
69 self.key_bindings
70 .keys()
71 .any(|k| k.starts_with(&s) && k.len() > s.len())
72 }
73}
74
75impl fmt::Display for ConfigSection<'_> {
76 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
77 self.key_bindings
78 .iter()
79 .try_for_each(|(key, message)| -> fmt::Result { writeln!(f, "{key}: {message:?}") })?;
80
81 Ok(())
82 }
83}
84
85#[derive(Clone, Debug, PartialEq)]
86pub struct Config<'a> {
87 pub experimental_editor: bool,
88 pub vim_mode: bool,
89 pub symbols: Symbols,
90 pub global: ConfigSection<'a>,
91 pub splash: ConfigSection<'a>,
92 pub explorer: ConfigSection<'a>,
93 pub outline: ConfigSection<'a>,
94 pub input_modal: ConfigSection<'a>,
95 pub help_modal: ConfigSection<'a>,
96 pub note_editor: ConfigSection<'a>,
97 pub vault_selector_modal: ConfigSection<'a>,
98}
99
100impl Default for Config<'_> {
101 fn default() -> Self {
102 Self::from(TomlConfig::default())
103 }
104}
105
106impl From<TomlConfig> for Config<'_> {
107 fn from(value: TomlConfig) -> Self {
108 Self {
109 symbols: value.symbols.into(),
110 experimental_editor: value.experimental_editor,
111 vim_mode: value.vim_mode,
112 global: value.global.into(),
113 splash: value.splash.into(),
114 explorer: value.explorer.into(),
115 outline: value.outline.into(),
116 input_modal: value.input_modal.into(),
117 help_modal: value.help_modal.into(),
118 note_editor: value.note_editor.into(),
119 vault_selector_modal: value.vault_selector_modal.into(),
120 }
121 }
122}
123
124impl From<TomlConfigSection> for ConfigSection<'_> {
125 fn from(TomlConfigSection { key_bindings }: TomlConfigSection) -> Self {
126 Self {
127 key_bindings: key_bindings
128 .into_iter()
129 .map(|KeyBinding { key, command }| (key.to_string(), command.into()))
130 .collect(),
131 }
132 }
133}
134
135impl Config<'_> {
136 pub(crate) fn merge(&mut self, config: Self) -> Self {
139 self.symbols = config.symbols;
140 self.experimental_editor = config.experimental_editor;
141 self.vim_mode = config.vim_mode;
142 self.global.merge_key_bindings(config.global);
143 self.explorer.merge_key_bindings(config.explorer);
144 self.splash.merge_key_bindings(config.splash);
145 self.outline.merge_key_bindings(config.outline);
146 self.input_modal.merge_key_bindings(config.input_modal);
147 self.note_editor.merge_key_bindings(config.note_editor);
148 self.help_modal.merge_key_bindings(config.help_modal);
149 self.vault_selector_modal
150 .merge_key_bindings(config.vault_selector_modal);
151 self.clone()
152 }
153
154 pub(crate) fn replace(&mut self, config: Self) -> Self {
157 self.global.replace_key_bindings(config.global);
158 self.explorer.replace_key_bindings(config.explorer);
159 self.splash.replace_key_bindings(config.splash);
160 self.outline.replace_key_bindings(config.outline);
161 self.input_modal.replace_key_bindings(config.input_modal);
162 self.note_editor.replace_key_bindings(config.note_editor);
163 self.help_modal.replace_key_bindings(config.help_modal);
164 self.vault_selector_modal
165 .replace_key_bindings(config.vault_selector_modal);
166 self.clone()
167 }
168}
169
170impl fmt::Display for Config<'_> {
171 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
172 writeln!(f, "[global]\n{}", self.global)?;
173 writeln!(f, "[splash]\n{}", self.splash)?;
174 writeln!(f, "[explorer]\n{}", self.explorer)?;
175 writeln!(f, "[note_editor]\n{}", self.note_editor)?;
176 writeln!(f, "[help_modal]\n{}", self.help_modal)?;
177 writeln!(f, "[vault_selector_modal]\n{}", self.vault_selector_modal)?;
178
179 Ok(())
180 }
181}
182
183impl<'a> From<BTreeMap<String, Message<'a>>> for ConfigSection<'a> {
184 fn from(value: BTreeMap<String, Message<'a>>) -> Self {
185 Self {
186 key_bindings: value,
187 }
188 }
189}
190
191impl<'a, const N: usize> From<[(String, Message<'a>); N]> for ConfigSection<'a> {
192 fn from(value: [(String, Message<'a>); N]) -> Self {
193 BTreeMap::from(value).into()
194 }
195}
196
197#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
198struct TomlConfigSection {
199 #[serde(default)]
200 key_bindings: KeyBindings,
201}
202
203#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
204struct KeyBindings(Vec<KeyBinding>);
205
206impl IntoIterator for KeyBindings {
207 type Item = KeyBinding;
208 type IntoIter = std::vec::IntoIter<Self::Item>;
209
210 fn into_iter(self) -> Self::IntoIter {
211 self.0.into_iter()
212 }
213}
214
215impl AsRef<Vec<KeyBinding>> for KeyBindings {
216 fn as_ref(&self) -> &Vec<KeyBinding> {
217 &self.0
218 }
219}
220
221impl<const N: usize> From<[(Key, Command); N]> for KeyBindings {
222 fn from(value: [(Key, Command); N]) -> Self {
223 Self(value.into_iter().map(KeyBinding::from).collect())
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Deserialize, Default)]
228struct TomlConfig {
229 #[serde(default)]
230 symbols: symbol::TomlSymbols,
231 #[serde(default)]
232 experimental_editor: bool,
233 #[serde(default)]
234 vim_mode: bool,
235 #[serde(default)]
236 global: TomlConfigSection,
237 #[serde(default)]
238 splash: TomlConfigSection,
239 #[serde(default)]
240 explorer: TomlConfigSection,
241 #[serde(default)]
242 outline: TomlConfigSection,
243 #[serde(default)]
244 input_modal: TomlConfigSection,
245 #[serde(default)]
246 help_modal: TomlConfigSection,
247 #[serde(default)]
248 note_editor: TomlConfigSection,
249 #[serde(default)]
250 vault_selector_modal: TomlConfigSection,
251}
252
253fn read_user_config<'a>() -> Result<Config<'a>, ConfigError> {
263 let home_dir_path = home_dir().map(|home_dir| home_dir.join(".basalt.toml"));
264 let config_dir_path =
265 choose_base_strategy().map(|strategy| strategy.config_dir().join("basalt/config.toml"));
266
267 let config_path = [home_dir_path, config_dir_path]
268 .into_iter()
269 .flatten()
270 .find(|path| path.exists())
271 .ok_or(ConfigError::UserConfigNotFound(
272 "Could not find user config".to_string(),
273 ))?;
274
275 toml::from_str::<TomlConfig>(&read_to_string(config_path)?)
276 .map(Config::from)
277 .map_err(|err| ConfigError::InvalidConfig(err.message().to_string()))
278}
279
280const BASE_CONFIGURATION_STR: &str =
281 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml"));
282
283const VIM_CONFIGURATION_STR: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vim.toml"));
284
285pub fn load<'a>() -> Result<(Config<'a>, Vec<String>), ConfigError> {
295 let mut config: Config = toml::from_str::<TomlConfig>(BASE_CONFIGURATION_STR)?.into();
298
299 if config.symbols.preset == symbol::Preset::Auto {
300 config.symbols.preset = symbol::detect_preset(env::SystemEnv)
301 }
302
303 let (user_config, warnings) = match read_user_config() {
304 Ok(config) => (Some(config), vec![]),
305 Err(ConfigError::UserConfigNotFound(_)) => (None, vec![]),
306 Err(err) => (None, vec![err.to_string()]),
307 };
308
309 if user_config.as_ref().is_some_and(|c| c.vim_mode) {
310 let vim_config: Config = toml::from_str::<TomlConfig>(VIM_CONFIGURATION_STR)
311 .map_err(ConfigError::from)?
312 .into();
313 config.replace(vim_config);
314 }
315
316 if let Some(user) = user_config {
317 config.merge(user);
318 }
319
320 let system_key_binding_overrides: ConfigSection =
321 [(Key::CTRL_C.to_string(), Message::Quit)].into();
322
323 config
324 .global
325 .merge_key_bindings(system_key_binding_overrides);
326
327 Ok((config, warnings))
328}
329
330#[cfg(test)]
331mod tests {
332 use ratatui::crossterm::event::KeyModifiers;
333 use similar_asserts::assert_eq;
334
335 use super::*;
336 #[test]
339 fn test_base_config_snapshot() {
340 }
349
350 #[test]
351 fn test_config() {
352 use key_binding::Key;
353
354 let dummy_toml = r#"
355 [global]
356 key_bindings = [
357 { key = "q", command = "quit" },
358 { key = "ctrl+g", command = "vault_selector_modal_toggle" },
359 { key = "?", command = "help_modal_toggle" },
360 ]
361 "#;
362 let dummy_toml_config: TomlConfig = toml::from_str::<TomlConfig>(dummy_toml).unwrap();
363
364 let expected_toml_config = TomlConfig {
365 global: TomlConfigSection {
366 key_bindings: [
367 (Key::from('q'), Command::Quit),
368 (
369 Key::from(('g', KeyModifiers::CONTROL)),
370 Command::VaultSelectorModalToggle,
371 ),
372 (Key::from('?'), Command::HelpModalToggle),
373 ]
374 .into(),
375 },
376 ..Default::default()
377 };
378
379 assert_eq!(dummy_toml_config, expected_toml_config);
380
381 let expected_config = Config::default().merge(expected_toml_config.into());
382
383 assert_eq!(
384 Config::default().merge(Config::from(dummy_toml_config)),
385 expected_config
386 );
387 }
388}