pub mod schema;
pub mod sections;
#[allow(dead_code)]
mod presets_gen {
include!(concat!(env!("OUT_DIR"), "/presets_gen.rs"));
}
pub use presets_gen::{BASE_SETTINGS_OPTIONS, DEFAULTS_YAML, OVERLAYS};
use std::cell::RefCell;
use std::collections::HashMap;
pub const CONFIG_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq)]
pub enum ConfigValue {
Float(f64),
Int(i64),
Bool(bool),
Str(String),
}
struct Config {
defaults: HashMap<String, ConfigValue>,
overlays: HashMap<String, HashMap<String, ConfigValue>>,
user: HashMap<String, ConfigValue>,
}
thread_local! {
static CONFIG: RefCell<Config> = RefCell::new(Config::new());
}
impl Config {
fn new() -> Self {
let defaults = parse_yaml_preset(presets_gen::DEFAULTS_YAML)
.unwrap_or_else(|e| panic!("failed to parse defaults.yaml: {e}"));
let mut overlays = HashMap::new();
for (name, yaml) in presets_gen::OVERLAYS {
let map = parse_yaml_preset(yaml)
.unwrap_or_else(|e| panic!("failed to parse overlay {name}: {e}"));
overlays.insert((*name).to_string(), map);
}
Config {
defaults,
overlays,
user: HashMap::new(),
}
}
fn get(&self, key: &str) -> Option<&ConfigValue> {
if let Some(v) = self.user.get(key) {
return Some(v);
}
if let Some(ConfigValue::Str(name)) = self.user.get("app.baseSettings") {
if let Some(v) = self.overlays.get(name).and_then(|m| m.get(key)) {
return Some(v);
}
}
self.defaults.get(key)
}
fn base_value(&self, key: &str) -> Option<&ConfigValue> {
if let Some(ConfigValue::Str(name)) = self.user.get("app.baseSettings") {
if let Some(v) = self.overlays.get(name).and_then(|m| m.get(key)) {
return Some(v);
}
}
self.defaults.get(key)
}
}
fn parse_yaml_preset(yaml: &str) -> Result<HashMap<String, ConfigValue>, String> {
let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml).map_err(|e| e.to_string())?;
let map = match value {
serde_yaml_ng::Value::Mapping(m) => m,
serde_yaml_ng::Value::Null => return Ok(HashMap::new()),
other => return Err(format!("expected top-level mapping, got {other:?}")),
};
let mut out: HashMap<String, ConfigValue> = HashMap::new();
for (k, v) in map {
let Some(key) = k.as_str() else { continue };
match key {
"name" | "description" => {
continue;
}
"hotkeys" => collect_string_facet(&v, "hotkeys.", &mut out)?,
"mouse_clicks" => collect_string_facet(&v, "mouseclicks.", &mut out)?,
"settings" => collect_settings_facet(&v, &mut out)?,
_ => {
if let Some(cv) = yaml_to_config_value(&v) {
out.insert(key.to_string(), cv);
}
}
}
}
Ok(out)
}
fn collect_string_facet(
v: &serde_yaml_ng::Value,
prefix: &str,
out: &mut HashMap<String, ConfigValue>,
) -> Result<(), String> {
let m = match v {
serde_yaml_ng::Value::Mapping(m) => m,
serde_yaml_ng::Value::Null => return Ok(()),
other => return Err(format!("{prefix} expected a mapping, got {other:?}")),
};
for (k, v) in m {
let Some(key) = k.as_str() else { continue };
let full_key = format!("{prefix}{key}");
match v {
serde_yaml_ng::Value::String(s) => {
out.insert(full_key, ConfigValue::Str(s.clone()));
}
serde_yaml_ng::Value::Sequence(seq) => {
let mut parts: Vec<String> = Vec::with_capacity(seq.len());
for item in seq {
if let serde_yaml_ng::Value::String(s) = item {
parts.push(s.clone());
} else {
return Err(format!("{full_key}: list item is not a string"));
}
}
out.insert(full_key, ConfigValue::Str(parts.join("|")));
}
serde_yaml_ng::Value::Null => {
out.insert(full_key, ConfigValue::Str(String::new()));
}
other => return Err(format!("{full_key}: unexpected value {other:?}")),
}
}
Ok(())
}
fn collect_settings_facet(
v: &serde_yaml_ng::Value,
out: &mut HashMap<String, ConfigValue>,
) -> Result<(), String> {
let m = match v {
serde_yaml_ng::Value::Mapping(m) => m,
serde_yaml_ng::Value::Null => return Ok(()),
other => return Err(format!("settings expected a mapping, got {other:?}")),
};
for (k, v) in m {
let Some(key) = k.as_str() else { continue };
if let Some(cv) = yaml_to_config_value(v) {
out.insert(key.to_string(), cv);
} else {
return Err(format!("settings.{key}: unsupported value {v:?}"));
}
}
Ok(())
}
fn yaml_to_config_value(v: &serde_yaml_ng::Value) -> Option<ConfigValue> {
match v {
serde_yaml_ng::Value::Bool(b) => Some(ConfigValue::Bool(*b)),
serde_yaml_ng::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Some(ConfigValue::Int(i))
} else {
n.as_f64().map(ConfigValue::Float)
}
}
serde_yaml_ng::Value::String(s) => Some(ConfigValue::Str(s.clone())),
serde_yaml_ng::Value::Null => None,
_ => None,
}
}
pub fn get(key: &str) -> Option<ConfigValue> {
CONFIG.with(|c| c.borrow().get(key).cloned())
}
pub fn get_f64(key: &str) -> f64 {
match get(key) {
Some(ConfigValue::Float(f)) => f,
Some(ConfigValue::Int(i)) => i as f64,
other => panic!("config key {key:?}: expected numeric, got {other:?}"),
}
}
pub fn get_i64(key: &str) -> i64 {
match get(key) {
Some(ConfigValue::Int(i)) => i,
other => panic!("config key {key:?}: expected int, got {other:?}"),
}
}
pub fn get_str(key: &str) -> String {
match get(key) {
Some(ConfigValue::Str(s)) => s,
other => panic!("config key {key:?}: expected string, got {other:?}"),
}
}
pub fn get_bool(key: &str) -> bool {
match get(key) {
Some(ConfigValue::Bool(b)) => b,
other => panic!("config key {key:?}: expected bool, got {other:?}"),
}
}
pub fn base_value(key: &str) -> Option<ConfigValue> {
CONFIG.with(|c| c.borrow().base_value(key).cloned())
}
pub fn set(key: &str, value: ConfigValue) {
CONFIG.with(|c| {
c.borrow_mut().user.insert(key.to_string(), value);
});
}
pub fn reset(key: &str) {
CONFIG.with(|c| {
c.borrow_mut().user.remove(key);
});
}
pub fn reset_all() {
CONFIG.with(|c| {
let mut cfg = c.borrow_mut();
let base = cfg.user.remove("app.baseSettings");
cfg.user.clear();
if let Some(v) = base {
cfg.user.insert("app.baseSettings".to_string(), v);
}
});
}
pub fn base_names() -> Vec<String> {
presets_gen::OVERLAYS
.iter()
.map(|(name, _)| (*name).to_string())
.collect()
}
pub fn kind_is_int(key: &str) -> bool {
for section in sections::registrations() {
for pref in section.prefs {
if pref.key == key {
return matches!(pref.kind, schema::PrefKind::Int { .. });
}
}
}
false
}
pub fn schema_info() -> Vec<schema::SectionInfo> {
let mut out: Vec<_> = sections::registrations()
.iter()
.map(schema::SectionInfo::from_section)
.collect();
out.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.id.cmp(b.id)));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_state() {
CONFIG.with(|c| {
c.borrow_mut().user.clear();
});
}
fn pick(name: &str) {
set("app.baseSettings", ConfigValue::Str(name.to_string()));
}
#[test]
fn defaults_from_yaml() {
reset_state();
assert_eq!(get_i64("animation.veil_divisor"), 2);
assert_eq!(get_i64("canvas.width"), 1920);
assert!(get_bool("autosave.enabled"));
assert_eq!(get_i64("autosave.intervalSeconds"), 120);
assert!(kind_is_int("autosave.intervalSeconds"));
assert_eq!(get_str("hotkeys.nav.trigger"), "Space");
assert!(get_bool("input.fingerPainting"));
assert_eq!(get_str("hotkeys.addBrushNode"), "Shift+KeyA");
}
#[test]
fn overlay_resolves_above_defaults() {
reset_state();
pick("Krita");
assert_eq!(get_str("hotkeys.brushTool"), "KeyB");
assert_eq!(get_str("hotkeys.addBrushNode"), "Shift+KeyA");
pick("Photoshop");
assert_eq!(get_str("hotkeys.rectSelectTool"), "KeyM");
assert_eq!(get_str("hotkeys.addBrushNode"), "Shift+KeyA");
}
#[test]
fn user_wins_over_overlay_and_defaults() {
reset_state();
pick("Krita");
set("hotkeys.brushTool", ConfigValue::Str("KeyZ".into()));
assert_eq!(get_str("hotkeys.brushTool"), "KeyZ");
reset("hotkeys.brushTool");
assert_eq!(get_str("hotkeys.brushTool"), "KeyB");
}
#[test]
fn reset_all_preserves_base_choice() {
reset_state();
pick("Photoshop");
set("hotkeys.brushTool", ConfigValue::Str("KeyZ".into()));
reset_all();
assert_eq!(get_str("hotkeys.brushTool"), "KeyB");
assert_eq!(get_str("app.baseSettings"), "Photoshop");
}
#[test]
fn base_value_skips_user_layer() {
reset_state();
pick("Krita");
set("hotkeys.brushTool", ConfigValue::Str("KeyZ".into()));
match base_value("hotkeys.brushTool") {
Some(ConfigValue::Str(s)) => assert_eq!(s, "KeyB"),
other => panic!("expected overlay value, got {other:?}"),
}
}
#[test]
fn base_names_lists_overlays_alphabetically() {
let names = base_names();
assert!(!names.is_empty(), "expected at least one overlay");
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
}
#[test]
fn kind_is_int_uses_schema() {
assert!(kind_is_int("canvas.width"));
assert!(!kind_is_int("nav.panSensitivity"));
assert!(!kind_is_int("bogus.key"));
}
}