The config folder now sets everything: config.toml the settings (now also `theme` and `editor`), keys.toml the keys, and themes/<name>.toml the theme, laid over the built-in one, so a theme only needs what it changes. Each file is applied again a second after it's saved; a mistake only drops its own line, and the status bar says what's wrong, naming the file. :config lists the three files with their folder; Enter hands the terminal to your editor (the editor setting, $VISUAL, $EDITOR, else notepad or vi), writing the file first if it's missing, with every option at its default behind a #, explained (keys.toml lists every command by name). :theme switches themes until cumulus quits, like :set. The built-in theme gets the named styles the user picked, each also put on by its tag: question (yellow, dashed), important (red, thick) and done (gray, its title faded); and link styles blocks and supports, whose label shows on a link without its own. Themes can also set [modes], [node], [root], [branch] and [link]. Co-Authored-By: Claude Opus 5.5 <[email protected]>
179 lines
6.1 KiB
Rust
179 lines
6.1 KiB
Rust
//! Where cumulus keeps its files ([`paths`]), and the files in its config
|
|
//! folder: config.toml ([`settings`]), keys.toml ([`keys_file`]) and themes
|
|
//! ([`theme_file`]). Each one only states what it changes; a missing file
|
|
//! changes nothing, and a mistake only drops the line it's on, with a
|
|
//! message saying what's wrong. cumulus applies a file again when it's
|
|
//! saved.
|
|
|
|
pub mod keys_file;
|
|
pub mod paths;
|
|
pub mod settings;
|
|
pub mod theme_file;
|
|
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use paths::Paths;
|
|
use settings::Settings;
|
|
|
|
use crate::input::keymap::{DEFAULT_LEADER, Keymap};
|
|
use crate::ui::theme::Theme;
|
|
|
|
/// One of the config folder's files.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ConfigFile {
|
|
/// config.toml: the settings.
|
|
Settings,
|
|
/// keys.toml: your keys.
|
|
Keys,
|
|
/// The theme's file, `themes/<name>.toml`.
|
|
Theme,
|
|
}
|
|
|
|
impl ConfigFile {
|
|
pub const ALL: [ConfigFile; 3] = [ConfigFile::Settings, ConfigFile::Keys, ConfigFile::Theme];
|
|
|
|
/// Where it is, for the theme named `theme`.
|
|
pub fn path(self, paths: &Paths, theme: &str) -> PathBuf {
|
|
match self {
|
|
ConfigFile::Settings => paths.config.join("config.toml"),
|
|
ConfigFile::Keys => paths.config.join("keys.toml"),
|
|
ConfigFile::Theme => theme_file::path(paths, theme),
|
|
}
|
|
}
|
|
|
|
/// What's in it, in a few words.
|
|
pub fn about(self) -> &'static str {
|
|
match self {
|
|
ConfigFile::Settings => "the settings",
|
|
ConfigFile::Keys => "your keys",
|
|
ConfigFile::Theme => "the theme",
|
|
}
|
|
}
|
|
|
|
/// The file as `:config` first writes it: everything it can hold, each
|
|
/// at its default, commented out.
|
|
pub fn template(self) -> String {
|
|
match self {
|
|
ConfigFile::Settings => Settings::template(),
|
|
ConfigFile::Keys => keys_file::template(),
|
|
ConfigFile::Theme => theme_file::template(&Theme::default()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Everything the config folder sets.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Config {
|
|
pub settings: Settings,
|
|
pub keymap: Keymap,
|
|
pub theme: Theme,
|
|
/// What was wrong in the files, in words.
|
|
pub problems: Vec<String>,
|
|
}
|
|
|
|
impl Default for Config {
|
|
/// No files: everything as cumulus has it.
|
|
fn default() -> Config {
|
|
Config {
|
|
settings: Settings::default(),
|
|
keymap: Keymap::new(DEFAULT_LEADER),
|
|
theme: Theme::default(),
|
|
problems: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
/// Reads the config folder: config.toml, keys.toml, and the theme
|
|
/// config.toml names.
|
|
pub fn load(paths: &Paths) -> Config {
|
|
let (settings, mut problems) = read(&ConfigFile::Settings.path(paths, ""))
|
|
.map_or_else(|| (Settings::default(), Vec::new()), |text| Settings::from_toml(&text));
|
|
let (keymap, keys_problems) = keys(paths);
|
|
problems.extend(keys_problems);
|
|
let (theme, theme_problems) = theme_file::load(paths, &settings.theme, settings.glyphs);
|
|
problems.extend(theme_problems);
|
|
Config { settings, keymap, theme, problems }
|
|
}
|
|
}
|
|
|
|
/// The keys: keys.toml on top of the defaults.
|
|
pub fn keys(paths: &Paths) -> (Keymap, Vec<String>) {
|
|
match read(&ConfigFile::Keys.path(paths, "")) {
|
|
Some(text) => {
|
|
let (keymap, problems) = Keymap::from_toml(&text);
|
|
(keymap, problems.into_iter().map(|problem| format!("keys.toml: {problem}")).collect())
|
|
}
|
|
None => (Keymap::new(DEFAULT_LEADER), Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// A config file's text, when there's a file.
|
|
fn read(path: &std::path::Path) -> Option<String> {
|
|
fs::read_to_string(path).ok()
|
|
}
|
|
|
|
/// Text as comment lines for a config file: `# ` before each, and at most
|
|
/// 76 columns.
|
|
pub(crate) fn comment(text: &str) -> String {
|
|
let mut lines: Vec<String> = Vec::new();
|
|
for word in text.split_whitespace() {
|
|
match lines.last_mut() {
|
|
Some(line) if line.len() + 1 + word.len() <= 76 => {
|
|
line.push(' ');
|
|
line.push_str(word);
|
|
}
|
|
_ => lines.push(format!("# {word}")),
|
|
}
|
|
}
|
|
lines.iter().map(|line| format!("{line}\n")).collect()
|
|
}
|
|
|
|
/// Text that doesn't read as TOML, in one line: where it went wrong, and
|
|
/// what's wrong there.
|
|
pub fn toml_problem(text: &str, error: &toml::de::Error) -> String {
|
|
match error.span() {
|
|
Some(span) => {
|
|
let before = text.char_indices().take_while(|&(at, _)| at < span.start);
|
|
let line = before.filter(|&(_, c)| c == '\n').count() + 1;
|
|
format!("line {line}: {}", error.message().trim())
|
|
}
|
|
None => error.message().trim().to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::storage::test_support::TempDir;
|
|
use crate::ui::glyphs::Glyphs;
|
|
|
|
#[test]
|
|
fn the_folder_sets_settings_keys_and_theme() {
|
|
let folder = TempDir::new();
|
|
let paths = Paths::inside(folder.path());
|
|
let config = Config::load(&paths);
|
|
assert!(config.problems.is_empty());
|
|
assert_eq!(config.settings, Settings::default());
|
|
|
|
fs::create_dir_all(paths.config.join("themes")).unwrap();
|
|
fs::write(paths.config.join("config.toml"), "theme = \"night\"\nglyphs = \"ascii\"\n").unwrap();
|
|
fs::write(paths.config.join("keys.toml"), "[normal]\n\"Q\" = \"close\"\n\"x\" = \"frobnicate\"\n")
|
|
.unwrap();
|
|
fs::write(paths.config.join("themes").join("night.toml"), "accent = \"magenta\"\n").unwrap();
|
|
let config = Config::load(&paths);
|
|
assert_eq!(config.theme.accent, ratatui::style::Color::Magenta);
|
|
assert_eq!(config.theme.glyphs, Glyphs::Ascii, "the theme draws with the glyphs setting");
|
|
assert_eq!(config.problems.len(), 1, "{:?}", config.problems);
|
|
assert!(config.problems[0].contains("frobnicate"), "{:?}", config.problems);
|
|
}
|
|
|
|
#[test]
|
|
fn comments_wrap_at_76_columns() {
|
|
let text = comment(&"word ".repeat(30));
|
|
assert!(text.lines().all(|line| line.starts_with("# ") && line.len() <= 76), "{text}");
|
|
assert_eq!(text.split_whitespace().filter(|word| *word == "word").count(), 30);
|
|
}
|
|
}
|