From 3296e495fe04b9c6edc9aa6757948b8e87ec4625 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:10:43 -0400 Subject: [PATCH] feat: add config and color_scheme modules --- src/color_scheme.rs | 248 ++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 178 +++++++++++++++++++++++++++++++ src/main.rs | 3 + 3 files changed, 429 insertions(+) create mode 100644 src/color_scheme.rs create mode 100644 src/config.rs diff --git a/src/color_scheme.rs b/src/color_scheme.rs new file mode 100644 index 0000000..be112cb --- /dev/null +++ b/src/color_scheme.rs @@ -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, + redo_history: Vec, +} + +impl ColorScheme { + pub fn load(path: impl Into) -> Result { + 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 { + 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 { + 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 { + 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)]*?id="current-color-scheme"[^>]*>(.*?)"#) + .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 { + 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#" + + + + + +"#; + + const MULTI_COLOR_SVG: &str = r#" + + + + + + +"#; + + #[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(""); + 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(); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c0b891c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,178 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Deserialize)] +struct Settings { + breeze_path: Option, +} + +#[derive(Debug, Deserialize)] +struct ConfigFile { + settings: Option, +} + +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 { + 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 { + 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")); + } +} diff --git a/src/main.rs b/src/main.rs index 0dabc14..87f4dd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,6 @@ +mod color_scheme; +mod config; + use gtk::prelude::*; use gtk::{glib, Application, ApplicationWindow};