Files
cumulus/src/config/mod.rs
T
skkeyeandClaude Opus 5.5 abe3dc1d84 Read config.toml, keys.toml and themes, and open them with :config (stage 8b)
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]>
2026-10-01 23:55:11 -04:00

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);
}
}