feat: add config and color_scheme modules
This commit is contained in:
@@ -0,0 +1,248 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub struct ColorScheme {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub content: String,
|
||||||
|
pub colors: Vec<(String, String)>,
|
||||||
|
history: Vec<String>,
|
||||||
|
redo_history: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorScheme {
|
||||||
|
pub fn load(path: impl Into<PathBuf>) -> Result<Self> {
|
||||||
|
let path = path.into();
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("reading {}", path.display()))?;
|
||||||
|
let colors = parse_colors(&content);
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
colors,
|
||||||
|
history: Vec::new(),
|
||||||
|
redo_history: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_color(&mut self, tag: &str, new_hex: &str) -> Result<bool> {
|
||||||
|
let Some(old_hex) = self
|
||||||
|
.colors
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| t == tag)
|
||||||
|
.map(|(_, h)| h.clone())
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(new_content) = replace_rule_color(&self.content, tag, &old_hex, new_hex) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.history
|
||||||
|
.push(std::mem::replace(&mut self.content, new_content));
|
||||||
|
self.redo_history.clear();
|
||||||
|
self.colors = parse_colors(&self.content);
|
||||||
|
self.save()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
std::fs::write(&self.path, &self.content)
|
||||||
|
.with_context(|| format!("writing {}", self.path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo(&mut self) -> Result<bool> {
|
||||||
|
let Some(prev) = self.history.pop() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
self.redo_history
|
||||||
|
.push(std::mem::replace(&mut self.content, prev));
|
||||||
|
self.colors = parse_colors(&self.content);
|
||||||
|
self.save()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redo(&mut self) -> Result<bool> {
|
||||||
|
let Some(next) = self.redo_history.pop() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
self.history
|
||||||
|
.push(std::mem::replace(&mut self.content, next));
|
||||||
|
self.colors = parse_colors(&self.content);
|
||||||
|
self.save()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style_block(content: &str) -> Option<&str> {
|
||||||
|
let re = Regex::new(r#"(?s)<style[^>]*?id="current-color-scheme"[^>]*>(.*?)</style>"#)
|
||||||
|
.expect("static regex is valid");
|
||||||
|
re.captures(content).map(|c| c.get(1).unwrap().as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_colors(content: &str) -> Vec<(String, String)> {
|
||||||
|
let Some(style) = style_block(content) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let re = Regex::new(r#"\.(ColorScheme-[a-zA-Z0-9]+)\s*\{\s*color:\s*(#[a-fA-F0-9]{3,6})"#)
|
||||||
|
.expect("static regex is valid");
|
||||||
|
re.captures_iter(style)
|
||||||
|
.map(|c| (c[1].to_string(), c[2].to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_rule_color(content: &str, tag: &str, old_hex: &str, new_hex: &str) -> Option<String> {
|
||||||
|
let pattern = format!(
|
||||||
|
r#"(\.{}\s*\{{\s*color:\s*){}"#,
|
||||||
|
regex::escape(tag),
|
||||||
|
regex::escape(old_hex)
|
||||||
|
);
|
||||||
|
let re = Regex::new(&pattern).ok()?;
|
||||||
|
if !re.is_match(content) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(re.replace(content, format!("${{1}}{new_hex}")).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SINGLE_COLOR_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<defs id="defs3051">
|
||||||
|
<style type="text/css" id="current-color-scheme">
|
||||||
|
.ColorScheme-Text {
|
||||||
|
color:#232629;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path style="fill:currentColor"
|
||||||
|
d="m8 2c-1.662 0-3 1.338-3 3v3h-2v6h10v-6h-2v-3c0-1.662-1.338-3-3-3zm0 1c1.2465 0 2 0.5458 2 2v3h-4v-3c0-1.4542 0.753506-2 2-2z"
|
||||||
|
class="ColorScheme-Text"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const MULTI_COLOR_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 22 22">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css" id="current-color-scheme">
|
||||||
|
.ColorScheme-Highlight { color: #3daee9; }
|
||||||
|
.ColorScheme-Text { color: #232629; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path d="M5 9h12v1H5z" style="fill:currentColor" class="ColorScheme-Highlight"/>
|
||||||
|
<path d="M12 7H6v8h6z" style="fill:currentColor" class="ColorScheme-Text"/>
|
||||||
|
</svg>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_colors_finds_single_rule() {
|
||||||
|
let colors = parse_colors(SINGLE_COLOR_SVG);
|
||||||
|
assert_eq!(
|
||||||
|
colors,
|
||||||
|
vec![("ColorScheme-Text".to_string(), "#232629".to_string())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_colors_finds_multiple_rules_in_order() {
|
||||||
|
let colors = parse_colors(MULTI_COLOR_SVG);
|
||||||
|
assert_eq!(
|
||||||
|
colors,
|
||||||
|
vec![
|
||||||
|
("ColorScheme-Highlight".to_string(), "#3daee9".to_string()),
|
||||||
|
("ColorScheme-Text".to_string(), "#232629".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_colors_empty_when_no_style_block() {
|
||||||
|
let colors = parse_colors("<svg></svg>");
|
||||||
|
assert!(colors.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_rule_color_only_touches_matched_class() {
|
||||||
|
// Regression test for the bug the Rust rewrite fixes: two classes
|
||||||
|
// sharing the same hex value must not both change when only one
|
||||||
|
// is edited.
|
||||||
|
let svg = r#".ColorScheme-Highlight { color: #3daee9; }
|
||||||
|
.ColorScheme-Focus { color: #3daee9; }"#;
|
||||||
|
|
||||||
|
let updated =
|
||||||
|
replace_rule_color(svg, "ColorScheme-Highlight", "#3daee9", "#ff0000").unwrap();
|
||||||
|
|
||||||
|
assert!(updated.contains(".ColorScheme-Highlight { color: #ff0000; }"));
|
||||||
|
assert!(updated.contains(".ColorScheme-Focus { color: #3daee9; }"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_rule_color_returns_none_when_rule_not_found() {
|
||||||
|
let svg = ".ColorScheme-Text { color: #232629; }";
|
||||||
|
assert!(replace_rule_color(svg, "ColorScheme-Missing", "#232629", "#ffffff").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_color_round_trip_with_undo_redo() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"icon-color-tool-test-colorscheme-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let file = dir.join("icon.svg");
|
||||||
|
std::fs::write(&file, MULTI_COLOR_SVG).unwrap();
|
||||||
|
|
||||||
|
let mut scheme = ColorScheme::load(&file).unwrap();
|
||||||
|
assert_eq!(scheme.colors.len(), 2);
|
||||||
|
|
||||||
|
let changed = scheme.update_color("ColorScheme-Highlight", "#ff0000").unwrap();
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(
|
||||||
|
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1,
|
||||||
|
"#ff0000"
|
||||||
|
);
|
||||||
|
// The other rule must be untouched.
|
||||||
|
assert_eq!(
|
||||||
|
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Text").unwrap().1,
|
||||||
|
"#232629"
|
||||||
|
);
|
||||||
|
|
||||||
|
let on_disk = std::fs::read_to_string(&file).unwrap();
|
||||||
|
assert!(on_disk.contains("#ff0000"));
|
||||||
|
|
||||||
|
let undone = scheme.undo().unwrap();
|
||||||
|
assert!(undone);
|
||||||
|
assert_eq!(
|
||||||
|
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1,
|
||||||
|
"#3daee9"
|
||||||
|
);
|
||||||
|
|
||||||
|
let redone = scheme.redo().unwrap();
|
||||||
|
assert!(redone);
|
||||||
|
assert_eq!(
|
||||||
|
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1,
|
||||||
|
"#ff0000"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_color_unknown_tag_returns_false() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"icon-color-tool-test-colorscheme-unknown-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let file = dir.join("icon.svg");
|
||||||
|
std::fs::write(&file, SINGLE_COLOR_SVG).unwrap();
|
||||||
|
|
||||||
|
let mut scheme = ColorScheme::load(&file).unwrap();
|
||||||
|
let changed = scheme.update_color("ColorScheme-DoesNotExist", "#ffffff").unwrap();
|
||||||
|
assert!(!changed);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Settings {
|
||||||
|
breeze_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ConfigFile {
|
||||||
|
settings: Option<Settings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
let xdg = std::env::var("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let home = std::env::var("HOME").expect("HOME must be set");
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
});
|
||||||
|
xdg.join("icon-color-tool")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_breeze_path() -> PathBuf {
|
||||||
|
let home = std::env::var("HOME").expect("HOME must be set");
|
||||||
|
PathBuf::from(home).join(".local/share/breeze-icons")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_home(path: &str) -> PathBuf {
|
||||||
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
|
let home = std::env::var("HOME").expect("HOME must be set");
|
||||||
|
PathBuf::from(home).join(rest)
|
||||||
|
} else {
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_default_config(config_file: &Path) -> Result<()> {
|
||||||
|
let contents = format!(
|
||||||
|
"[settings]\nbreeze_path = \"{}\"\n",
|
||||||
|
default_breeze_path().display()
|
||||||
|
);
|
||||||
|
if let Some(parent) = config_file.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
std::fs::write(config_file, contents)
|
||||||
|
.with_context(|| format!("writing {}", config_file.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_breeze_path(config_file: &Path) -> Result<PathBuf> {
|
||||||
|
if !config_file.exists() {
|
||||||
|
generate_default_config(config_file)?;
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(config_file)
|
||||||
|
.with_context(|| format!("reading {}", config_file.display()))?;
|
||||||
|
let parsed: ConfigFile = toml::from_str(&raw)
|
||||||
|
.with_context(|| format!("parsing {}", config_file.display()))?;
|
||||||
|
let path_str = parsed
|
||||||
|
.settings
|
||||||
|
.and_then(|s| s.breeze_path)
|
||||||
|
.unwrap_or_else(|| default_breeze_path().display().to_string());
|
||||||
|
Ok(expand_home(&path_str))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_breeze_repo(path: &Path) -> Result<()> {
|
||||||
|
let needs_clone = !path.exists()
|
||||||
|
|| std::fs::read_dir(path)
|
||||||
|
.map(|mut d| d.next().is_none())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if needs_clone {
|
||||||
|
println!(
|
||||||
|
"Breeze icons not found at {}. Cloning repository...",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let status = std::process::Command::new("git")
|
||||||
|
.args(["clone", "--depth", "1"])
|
||||||
|
.arg("https://github.com/KDE/breeze-icons.git")
|
||||||
|
.arg(path)
|
||||||
|
.status()
|
||||||
|
.context("running `git clone` (is git installed and on PATH?)")?;
|
||||||
|
anyhow::ensure!(status.success(), "git clone exited with {status}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_breeze_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(env_path) = std::env::var("BREEZE_ICONS_PATH") {
|
||||||
|
let path = expand_home(&env_path);
|
||||||
|
ensure_breeze_repo(&path)?;
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_file = config_dir().join("config.toml");
|
||||||
|
let path = read_breeze_path(&config_file)?;
|
||||||
|
ensure_breeze_repo(&path)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("icon-color-tool-test-{label}-{nanos}"));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_default_config_writes_expected_toml() {
|
||||||
|
let dir = temp_dir("gen-default");
|
||||||
|
let config_file = dir.join("config.toml");
|
||||||
|
|
||||||
|
generate_default_config(&config_file).unwrap();
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(&config_file).unwrap();
|
||||||
|
assert!(contents.contains("[settings]"));
|
||||||
|
assert!(contents.contains("breeze_path ="));
|
||||||
|
assert!(contents.contains(".local/share/breeze-icons"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_breeze_path_creates_default_when_missing() {
|
||||||
|
let dir = temp_dir("read-default");
|
||||||
|
let config_file = dir.join("config.toml");
|
||||||
|
|
||||||
|
let resolved = read_breeze_path(&config_file).unwrap();
|
||||||
|
|
||||||
|
assert!(config_file.exists());
|
||||||
|
assert!(resolved.ends_with(".local/share/breeze-icons"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_breeze_path_honors_custom_value() {
|
||||||
|
let dir = temp_dir("read-custom");
|
||||||
|
let config_file = dir.join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&config_file,
|
||||||
|
"[settings]\nbreeze_path = \"/opt/custom-breeze\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resolved = read_breeze_path(&config_file).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resolved, PathBuf::from("/opt/custom-breeze"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_home_expands_leading_tilde() {
|
||||||
|
let home = std::env::var("HOME").unwrap();
|
||||||
|
let expanded = expand_home("~/somewhere");
|
||||||
|
assert_eq!(expanded, PathBuf::from(home).join("somewhere"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_home_leaves_absolute_path_untouched() {
|
||||||
|
let expanded = expand_home("/already/absolute");
|
||||||
|
assert_eq!(expanded, PathBuf::from("/already/absolute"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
mod color_scheme;
|
||||||
|
mod config;
|
||||||
|
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
use gtk::{glib, Application, ApplicationWindow};
|
use gtk::{glib, Application, ApplicationWindow};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user