Files
icon-color-tool/src/color_scheme.rs
T

277 lines
8.8 KiB
Rust

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> {
Self::load_with_namespace(path, "")
}
pub fn load_with_namespace(path: impl Into<PathBuf>, namespace: &str) -> Result<Self> {
let path = path.into();
let mut content = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
if !namespace.is_empty() {
content = content.replace(".ColorScheme-", &format!(".{namespace}-ColorScheme-"));
content = content.replace("class=\"ColorScheme-", &format!("class=\"{namespace}-ColorScheme-"));
}
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#"\.([a-zA-Z0-9\-]*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();
}
#[test]
fn load_with_namespace_rewrites_classes() {
let dir = std::env::temp_dir().join(format!(
"icon-color-tool-test-namespace-{}",
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 scheme = ColorScheme::load_with_namespace(&file, "Emblem").unwrap();
assert!(scheme.content.contains(".Emblem-ColorScheme-Text"));
assert!(scheme.content.contains("class=\"Emblem-ColorScheme-Text\""));
assert_eq!(scheme.colors[0].0, "Emblem-ColorScheme-Text");
std::fs::remove_dir_all(&dir).ok();
}
}