From d6492fe68a303288b2dd7cd5b75c874324c2bcfd Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:09:56 -0400 Subject: [PATCH 01/34] feat: scaffold Rust/GTK4 project --- .gitignore | 1 + Cargo.toml | 12 ++++++++++++ src/main.rs | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index 2345db7..ab17bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ docs/plans docs/specs docs/devlogs # ──────────────────────────────────────────────────────── +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b8e5732 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "icon-color-tool" +version = "0.1.0" +edition = "2021" + +[dependencies] +gtk = { package = "gtk4", version = "0.11" } +anyhow = "1" +regex = "1" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +walkdir = "2" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0dabc14 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,21 @@ +use gtk::prelude::*; +use gtk::{glib, Application, ApplicationWindow}; + +const APP_ID: &str = "dev.rootiest.IconColorTool"; + +fn main() -> glib::ExitCode { + let app = Application::builder().application_id(APP_ID).build(); + + app.connect_activate(|app| { + let window = ApplicationWindow::builder() + .application(app) + .title("Icon Color Tool") + .default_width(900) + .default_height(650) + .build(); + + window.present(); + }); + + app.run() +} From 3296e495fe04b9c6edc9aa6757948b8e87ec4625 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:10:43 -0400 Subject: [PATCH 02/34] 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}; From 06d1204aace0e25efe5ba4ac7e7e778187d67380 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:11:29 -0400 Subject: [PATCH 03/34] feat: add icon_index module for recursive SVG listing and fuzzy search --- src/icon_index.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 90 insertions(+) create mode 100644 src/icon_index.rs diff --git a/src/icon_index.rs b/src/icon_index.rs new file mode 100644 index 0000000..901efab --- /dev/null +++ b/src/icon_index.rs @@ -0,0 +1,89 @@ +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +pub fn list_svg_icons(root: &Path) -> Vec { + WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")) + .map(|e| e.path().to_path_buf()) + .collect() +} + +pub fn fuzzy_match(query: &str, candidate: &str) -> bool { + if query.is_empty() { + return true; + } + let candidate_lower = candidate.to_lowercase(); + let mut chars = candidate_lower.chars(); + for q in query.to_lowercase().chars() { + if !chars.any(|c| c == q) { + return false; + } + } + true +} + +pub fn filter_icons<'a>(icons: &'a [PathBuf], query: &str, limit: usize) -> Vec<&'a PathBuf> { + icons + .iter() + .filter(|p| fuzzy_match(query, &p.to_string_lossy())) + .take(limit) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuzzy_match_empty_query_matches_everything() { + assert!(fuzzy_match("", "anything")); + } + + #[test] + fn fuzzy_match_is_case_insensitive_subsequence() { + assert!(fuzzy_match("cls", "actions/16/edit-clear-symbolic.svg")); + assert!(fuzzy_match("CLS", "actions/16/edit-clear-symbolic.svg")); + } + + #[test] + fn fuzzy_match_rejects_out_of_order_or_missing_chars() { + assert!(!fuzzy_match("zzz", "actions/16/edit-clear-symbolic.svg")); + assert!(!fuzzy_match("scl", "edit-clear-symbolic.svg")); // 's' comes after "cl" here, wrong order + } + + #[test] + fn filter_icons_applies_query_and_limit() { + let icons = vec![ + PathBuf::from("a/edit-clear.svg"), + PathBuf::from("b/edit-copy.svg"), + PathBuf::from("c/folder.svg"), + ]; + + let matches = filter_icons(&icons, "edit", 10); + assert_eq!(matches.len(), 2); + + let limited = filter_icons(&icons, "", 1); + assert_eq!(limited.len(), 1); + } + + #[test] + fn list_svg_icons_finds_only_svg_files_recursively() { + let dir = std::env::temp_dir().join(format!( + "icon-color-tool-test-iconindex-{}", + std::process::id() + )); + let sub = dir.join("actions/16"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("edit-clear.svg"), "").unwrap(); + std::fs::write(sub.join("readme.txt"), "not an icon").unwrap(); + + let found = list_svg_icons(&dir); + assert_eq!(found.len(), 1); + assert!(found[0].ends_with("edit-clear.svg")); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/src/main.rs b/src/main.rs index 87f4dd1..8658643 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod color_scheme; mod config; +mod icon_index; use gtk::prelude::*; use gtk::{glib, Application, ApplicationWindow}; From 8dd51b48ca290988f87deba61e6b1e42110804f4 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:16:02 -0400 Subject: [PATCH 04/34] feat: add picker UI (live-filtered thumbnail grid) --- src/ui/mod.rs | 2 + src/ui/picker.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/ui/mod.rs create mode 100644 src/ui/picker.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..39451c9 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,2 @@ +pub mod editor; +pub mod picker; diff --git a/src/ui/picker.rs b/src/ui/picker.rs new file mode 100644 index 0000000..945360d --- /dev/null +++ b/src/ui/picker.rs @@ -0,0 +1,105 @@ +use crate::icon_index::{filter_icons, list_svg_icons}; +use gtk::glib; +use gtk::prelude::*; +use gtk::{Box as GtkBox, FlowBox, Label, Orientation, Picture, ScrolledWindow, SearchEntry}; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +const RESULT_LIMIT: usize = 300; + +pub fn build_picker_page( + breeze_root: PathBuf, + on_select: impl Fn(PathBuf) + 'static, +) -> gtk::Widget { + let root = GtkBox::new(Orientation::Vertical, 8); + root.set_margin_top(8); + root.set_margin_bottom(8); + root.set_margin_start(8); + root.set_margin_end(8); + + let search = SearchEntry::new(); + search.set_placeholder_text(Some("Search icons…")); + root.append(&search); + + let flow_box = FlowBox::new(); + flow_box.set_valign(gtk::Align::Start); + flow_box.set_max_children_per_line(10); + flow_box.set_selection_mode(gtk::SelectionMode::Single); + + let scroller = ScrolledWindow::new(); + scroller.set_vexpand(true); + scroller.set_child(Some(&flow_box)); + root.append(&scroller); + + let all_icons = Rc::new(list_svg_icons(&breeze_root)); + // Paths for whatever's currently shown in flow_box, in the same order + // as its children — FlowBoxChild::index() looks up into this. Simpler + // and unsafe-free compared to attaching data to each child widget. + let current_matches: Rc>> = Rc::new(RefCell::new(Vec::new())); + + { + let current_matches = Rc::clone(¤t_matches); + flow_box.connect_child_activated(move |_, child| { + let index = child.index(); + if index < 0 { + return; + } + if let Some(path) = current_matches.borrow().get(index as usize) { + on_select(path.clone()); + } + }); + } + + let populate = { + let flow_box = flow_box.clone(); + let all_icons = Rc::clone(&all_icons); + let current_matches = Rc::clone(¤t_matches); + move |query: &str| { + while let Some(child) = flow_box.first_child() { + flow_box.remove(&child); + } + + let matches: Vec = filter_icons(&all_icons, query, RESULT_LIMIT) + .into_iter() + .cloned() + .collect(); + + for path in &matches { + let item_box = GtkBox::new(Orientation::Vertical, 4); + item_box.set_size_request(96, 96); + + let picture = Picture::new(); + if let Ok(texture) = gtk::gdk::Texture::from_filename(path) { + picture.set_paintable(Some(&texture)); + } + picture.set_size_request(64, 64); + item_box.append(&picture); + + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let label = Label::new(Some(&name)); + label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + label.set_max_width_chars(14); + item_box.append(&label); + + flow_box.append(&item_box); + } + + *current_matches.borrow_mut() = matches; + } + }; + + populate(""); + + { + let populate = populate.clone(); + search.connect_search_changed(move |entry| { + populate(&entry.text()); + }); + } + + root.upcast() +} From 36cf8ecd87fa4a4ae3cd0ae8c5550a6fcceaf30c Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:16:44 -0400 Subject: [PATCH 05/34] feat: wire picker and editor views into the application window --- src/main.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 8658643..7ec0602 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ mod color_scheme; mod config; mod icon_index; +mod ui; use gtk::prelude::*; -use gtk::{glib, Application, ApplicationWindow}; +use gtk::{glib, Application, ApplicationWindow, Stack}; +use std::path::{Path, PathBuf}; const APP_ID: &str = "dev.rootiest.IconColorTool"; @@ -11,6 +13,24 @@ fn main() -> glib::ExitCode { let app = Application::builder().application_id(APP_ID).build(); app.connect_activate(|app| { + let breeze_path = match config::load_breeze_path() { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to resolve breeze-icons path: {e:#}"); + std::process::exit(1); + } + }; + + let initial_arg = std::env::args().nth(1); + let initial_icon = initial_arg + .as_deref() + .map(|a| resolve_icon_arg(a, &breeze_path)); + + if let Some(Err(e)) = &initial_icon { + eprintln!("Error: {e}"); + std::process::exit(1); + } + let window = ApplicationWindow::builder() .application(app) .title("Icon Color Tool") @@ -18,8 +38,68 @@ fn main() -> glib::ExitCode { .default_height(650) .build(); + let stack = Stack::new(); + window.set_child(Some(&stack)); + + build_picker(&stack, breeze_path); + + if let Some(Ok(path)) = initial_icon { + build_editor(&stack, path); + } + window.present(); }); app.run() } + +fn resolve_icon_arg(arg: &str, breeze_path: &Path) -> anyhow::Result { + let as_given = PathBuf::from(arg); + if as_given.is_absolute() { + anyhow::ensure!( + as_given.exists(), + "Icon not found at {}", + as_given.display() + ); + return Ok(as_given); + } + if as_given.exists() { + return Ok(as_given.canonicalize()?); + } + let under_breeze = breeze_path.join(arg); + anyhow::ensure!( + under_breeze.exists(), + "Icon not found at {} or {}", + as_given.display(), + under_breeze.display() + ); + Ok(under_breeze.canonicalize()?) +} + +fn build_picker(stack: &Stack, breeze_path: PathBuf) { + let stack_for_callback = stack.clone(); + let widget = ui::picker::build_picker_page(breeze_path, move |icon_path| { + build_editor(&stack_for_callback, icon_path); + stack_for_callback.set_visible_child_name("editor"); + }); + stack.add_named(&widget, Some("picker")); + stack.set_visible_child_name("picker"); +} + +fn build_editor(stack: &Stack, icon_path: PathBuf) { + let stack_for_callback = stack.clone(); + match ui::editor::build_editor_page(&icon_path, move || { + stack_for_callback.set_visible_child_name("picker"); + }) { + Ok(widget) => { + if let Some(existing) = stack.child_by_name("editor") { + stack.remove(&existing); + } + stack.add_named(&widget, Some("editor")); + stack.set_visible_child_name("editor"); + } + Err(e) => { + eprintln!("Failed to open {}: {e:#}", icon_path.display()); + } + } +} From 1531fae972e544f9d78570763df6d0dd10527cb2 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:18:07 -0400 Subject: [PATCH 06/34] chore: remove Python implementation, add README for Rust version --- README.md | 46 ++++++++++++++++ config.py | 45 --------------- gui.py | 141 ----------------------------------------------- icon-color-tool | 3 - main.py | 89 ------------------------------ requirements.txt | 1 - svg_parser.py | 56 ------------------- 7 files changed, 46 insertions(+), 335 deletions(-) create mode 100644 README.md delete mode 100644 config.py delete mode 100644 gui.py delete mode 100755 icon-color-tool delete mode 100644 main.py delete mode 100644 requirements.txt delete mode 100644 svg_parser.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..a558bdf --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# icon-color-tool + +A small GTK4 desktop app for editing the `ColorScheme-*` CSS colors +embedded in [Breeze icon](https://github.com/KDE/breeze-icons) SVGs. + +## Usage + +```sh +icon-color-tool # opens a searchable icon picker +icon-color-tool path/to/icon.svg # opens directly in the color editor +``` + +The picker's search box does a fuzzy (subsequence) match against each +icon's path. Selecting an icon (or passing one directly) opens the color +editor: click a swatch to pick a new color for that `ColorScheme-*` class; +changes save to disk immediately, and Undo/Redo are available from the +toolbar. + +## Configuration + +On first run, a config file is created at +`~/.config/icon-color-tool/config.toml`: + +```toml +[settings] +breeze_path = "~/.local/share/breeze-icons" +``` + +If the configured `breeze_path` doesn't exist (or is empty), the +[breeze-icons](https://github.com/KDE/breeze-icons) repository is cloned +there automatically (shallow clone via `git`, which must be installed and +on `PATH`). + +Set the `BREEZE_ICONS_PATH` environment variable to override the +configured path entirely (also auto-clones if missing). + +## Building + +Requires Rust (stable) and the GTK4 + librsvg development libraries +installed system-wide (e.g. `gtk4-devel`/`libgtk-4-dev` and +`librsvg2-devel`/`librsvg2-dev`, depending on your distro). + +```sh +cargo build --release +./target/release/icon-color-tool +``` diff --git a/config.py b/config.py deleted file mode 100644 index 7234da6..0000000 --- a/config.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import sys -import subprocess -from pathlib import Path -import tomllib - -def get_config_dir(): - xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) - return Path(xdg_config) / 'svg-color-tool' - -def generate_default_config(config_path: Path): - default_path = os.path.expanduser("~/.local/share/breeze-icons") - config_content = f"""[settings] -breeze_path = "{default_path}" -""" - config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, "w") as f: - f.write(config_content) - -def ensure_breeze_repo(breeze_path: str): - path = Path(breeze_path) - if not path.exists() or not any(path.iterdir()): - print(f"Breeze icons not found at {path}. Cloning repository...") - path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "clone", "--depth", "1", "https://github.com/KDE/breeze-icons.git", str(path)], check=True) - -def load_config(): - env_path = os.environ.get('BREEZE_ICONS_PATH') - if env_path: - ensure_breeze_repo(env_path) - return env_path - - config_dir = get_config_dir() - config_file = config_dir / 'config.toml' - - if not config_file.exists(): - generate_default_config(config_file) - - with open(config_file, "rb") as f: - config_data = tomllib.load(f) - - breeze_path = config_data.get('settings', {}).get('breeze_path', os.path.expanduser("~/.local/share/breeze-icons")) - breeze_path = os.path.expanduser(breeze_path) - ensure_breeze_repo(breeze_path) - return breeze_path diff --git a/gui.py b/gui.py deleted file mode 100644 index 2d79818..0000000 --- a/gui.py +++ /dev/null @@ -1,141 +0,0 @@ -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, - QLabel, QColorDialog, QScrollArea, QFrame, QSplitter, QMainWindow, - QMessageBox -) -from PyQt6.QtGui import QColor, QAction, QKeySequence -from PyQt6.QtSvgWidgets import QSvgWidget -from PyQt6.QtCore import Qt - -from svg_parser import SvgColorParser - -class ColorSwatch(QFrame): - def __init__(self, tag, color_hex, on_click): - super().__init__() - self.tag = tag - self.color_hex = color_hex - self.on_click = on_click - - self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) - self.setLineWidth(1) - - layout = QHBoxLayout() - self.setLayout(layout) - - self.lbl_tag = QLabel(tag) - self.lbl_color = QLabel(color_hex) - - self.swatch = QLabel() - self.swatch.setFixedSize(30, 30) - self.swatch.setAutoFillBackground(True) - self.update_swatch_color(color_hex) - - layout.addWidget(self.swatch) - layout.addWidget(self.lbl_tag) - layout.addWidget(self.lbl_color) - layout.addStretch() - - def update_swatch_color(self, hex_color): - self.color_hex = hex_color - self.lbl_color.setText(hex_color) - self.swatch.setStyleSheet(f"background-color: {hex_color}; border: 1px solid black;") - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - self.on_click(self.tag, self.color_hex, self) - -class MainWindow(QMainWindow): - def __init__(self, svg_path): - super().__init__() - self.setWindowTitle(f"SVG Color Editor - {svg_path}") - self.resize(800, 600) - - self.parser = SvgColorParser(svg_path) - self.svg_path = svg_path - - self.setup_ui() - self.setup_menu() - - if not self.parser.colors: - QMessageBox.warning(self, "No Colors Found", "No ColorScheme tags were found in the SVG file.") - - def setup_menu(self): - menubar = self.menuBar() - edit_menu = menubar.addMenu("Edit") - - self.undo_action = QAction("Undo", self) - self.undo_action.setShortcut(QKeySequence.StandardKey.Undo) - self.undo_action.triggered.connect(self.undo) - edit_menu.addAction(self.undo_action) - - self.redo_action = QAction("Redo", self) - self.redo_action.setShortcut(QKeySequence.StandardKey.Redo) - self.redo_action.triggered.connect(self.redo) - edit_menu.addAction(self.redo_action) - - def setup_ui(self): - central_widget = QWidget() - self.setCentralWidget(central_widget) - main_layout = QVBoxLayout(central_widget) - - splitter = QSplitter(Qt.Orientation.Horizontal) - main_layout.addWidget(splitter) - - # Left Panel - Colors - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - - self.colors_widget = QWidget() - self.colors_layout = QVBoxLayout(self.colors_widget) - scroll_area.setWidget(self.colors_widget) - - splitter.addWidget(scroll_area) - - # Right Panel - SVG Preview - preview_container = QWidget() - preview_layout = QVBoxLayout(preview_container) - - self.svg_widget = QSvgWidget(str(self.svg_path)) - self.svg_widget.renderer().setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) - preview_layout.addWidget(self.svg_widget) - - splitter.addWidget(preview_container) - splitter.setSizes([350, 450]) - - self.populate_colors() - - def populate_colors(self): - # Clear layout - while self.colors_layout.count(): - item = self.colors_layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - - for tag, color in self.parser.colors.items(): - swatch = ColorSwatch(tag, color, self.on_swatch_click) - self.colors_layout.addWidget(swatch) - - self.colors_layout.addStretch() - - def on_swatch_click(self, tag, current_color, swatch_widget): - color = QColorDialog.getColor(QColor(current_color), self, f"Select Color for {tag}") - if color.isValid(): - new_hex = color.name() # Returns #RRGGBB - self.parser.update_color(tag, new_hex) - swatch_widget.update_swatch_color(new_hex) - self.refresh_preview() - self.populate_colors() - - def refresh_preview(self): - self.svg_widget.load(self.parser.content.encode('utf-8')) - - def undo(self): - if self.parser.undo(): - self.refresh_preview() - self.populate_colors() - - def redo(self): - if self.parser.redo(): - self.refresh_preview() - self.populate_colors() diff --git a/icon-color-tool b/icon-color-tool deleted file mode 100755 index 603b4df..0000000 --- a/icon-color-tool +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -python3 main.py diff --git a/main.py b/main.py deleted file mode 100644 index f57a70d..0000000 --- a/main.py +++ /dev/null @@ -1,89 +0,0 @@ -import sys -import os -import subprocess -from pathlib import Path -from config import load_config -from gui import MainWindow -from PyQt6.QtWidgets import QApplication - - -def get_icon_with_fzf(breeze_path): - try: - preview_script = r''' - FILE="$1" - DIM="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" - if command -v kitten >/dev/null; then - kitten icat --clear --transfer-mode=memory --stdin=no --scale-up --place="${DIM}@${FZF_PREVIEW_LEFT:-0}x${FZF_PREVIEW_TOP:-0}" "$FILE" > /dev/tty - elif command -v wezterm >/dev/null; then - wezterm imgcat "$FILE" - elif command -v chafa >/dev/null; then - chafa -s "$DIM" "$FILE" - else - cat "$FILE" - fi - ''' - env = os.environ.copy() - env['FZF_PREVIEW_SCRIPT'] = preview_script - - find_proc = subprocess.Popen( - ["find", ".", "-type", "f", "-name", "*.svg"], - cwd=breeze_path, - stdout=subprocess.PIPE, - ) - fzf_proc = subprocess.Popen( - ["fzf", "--preview", "bash -c \"$FZF_PREVIEW_SCRIPT\" _ {}"], - stdin=find_proc.stdout, - stdout=subprocess.PIPE, - text=True, - cwd=breeze_path, - env=env - ) - find_proc.stdout.close() - out, _ = fzf_proc.communicate() - if fzf_proc.returncode == 0 and out.strip(): - return (Path(breeze_path) / out.strip()).resolve() - return None - except Exception as e: - print(f"Error running fzf: {e}") - return None - - -def main(): - breeze_path = load_config() - - icon_path = None - if len(sys.argv) > 1: - arg_path = Path(sys.argv[1]) - if arg_path.is_absolute(): - icon_path = arg_path - else: - # First try relative to current directory - if arg_path.exists(): - icon_path = arg_path.resolve() - else: - # Then try relative to breeze_path - icon_path = (Path(breeze_path) / arg_path).resolve() - - if not icon_path.exists(): - print(f"Error: Icon not found at {icon_path}") - sys.exit(1) - else: - print("Select an icon using fzf...") - icon_path = get_icon_with_fzf(breeze_path) - if not icon_path: - print("No icon selected.") - sys.exit(0) - - app = QApplication(sys.argv) - window = MainWindow(icon_path) - window.show() - exit_code = app.exec() - - if window.parser.history: - print(f"\nIcon colors updated: {icon_path.resolve()}") - - sys.exit(exit_code) - - -if __name__ == "__main__": - main() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ee397a4..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -PyQt6 diff --git a/svg_parser.py b/svg_parser.py deleted file mode 100644 index ca3af6a..0000000 --- a/svg_parser.py +++ /dev/null @@ -1,56 +0,0 @@ -import re -from pathlib import Path - -class SvgColorParser: - def __init__(self, filepath): - self.filepath = Path(filepath) - self.content = self.filepath.read_text(encoding="utf-8") - self.colors = {} # tag -> hex - self.history = [] # list of contents for undo - self.redo_history = [] - self._parse() - - def _parse(self): - # Find the regardless of attribute order - style_match = re.search(r']*?id="current-color-scheme"[^>]*>(.*?)', self.content, re.DOTALL) - if style_match: - style_content = style_match.group(1) - # Find classes e.g. .ColorScheme-Text { color:#232629; } - class_matches = re.finditer(r'\.(ColorScheme-[a-zA-Z0-9]+)\s*\{\s*color:\s*(#[a-fA-F0-9]{3,6})', style_content) - self.colors = {m.group(1): m.group(2) for m in class_matches} - - def update_color(self, tag, new_hex): - if tag not in self.colors: return - old_hex = self.colors[tag] - self.history.append(self.content) - self.redo_history.clear() - - # Globally replace the exact hex string, case insensitive - # We need to compile a regex for the old hex. - hex_pattern = re.compile(re.escape(old_hex), re.IGNORECASE) - self.content = hex_pattern.sub(new_hex, self.content) - - self.colors[tag] = new_hex - self.save() - self._parse() # re-parse to ensure consistency - - def save(self): - self.filepath.write_text(self.content, encoding="utf-8") - - def undo(self): - if self.history: - self.redo_history.append(self.content) - self.content = self.history.pop() - self.save() - self._parse() - return True - return False - - def redo(self): - if self.redo_history: - self.history.append(self.content) - self.content = self.redo_history.pop() - self.save() - self._parse() - return True - return False From 2cc0ae90abe43062efe9a4ea18e976ea732cf0f7 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:30:35 -0400 Subject: [PATCH 07/34] ci: add release workflow for main --- .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..83f0d53 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Build and Release + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-4-dev librsvg2-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build + run: cargo build --release + + - name: Generate short SHA + id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: release-${{ steps.vars.outputs.sha_short }} + name: Build ${{ steps.vars.outputs.sha_short }} + files: target/release/icon-color-tool + body: | + Automated build from commit ${{ github.sha }}. + + **Note**: This is a dynamically linked binary. It requires GTK4 and librsvg to be installed on the host system to run. From d6dae7d0c465edcfbe72d6a2f0bb8ae006aa355a Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 19:33:35 -0400 Subject: [PATCH 08/34] fix: include missed files from rewrite --- Cargo.lock | 813 +++++++++++++++++++++++++ Cargo.toml | 2 +- __pycache__/config.cpython-314.pyc | Bin 3408 -> 0 bytes __pycache__/gui.cpython-314.pyc | Bin 9351 -> 0 bytes __pycache__/svg_parser.cpython-314.pyc | Bin 3796 -> 0 bytes src/ui/editor.rs | 153 +++++ 6 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock delete mode 100644 __pycache__/config.cpython-314.pyc delete mode 100644 __pycache__/gui.cpython-314.pyc delete mode 100644 __pycache__/svg_parser.cpython-314.pyc create mode 100644 src/ui/editor.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a43c1d4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,813 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" +dependencies = [ + "glib", + "graphene-sys", +] + +[[package]] +name = "graphene-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icon-color-tool" +version = "0.1.0" +dependencies = [ + "anyhow", + "gtk4", + "regex", + "serde", + "toml 0.8.23", + "walkdir", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pango" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" +dependencies = [ + "gio", + "glib", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml index b8e5732..99982a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -gtk = { package = "gtk4", version = "0.11" } +gtk = { package = "gtk4", version = "0.11", features = ["v4_12"] } anyhow = "1" regex = "1" serde = { version = "1", features = ["derive"] } diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc deleted file mode 100644 index 0df73e55deda1210b0b0e91faca08bab203f0727..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3408 zcmbtWO>7&-6`uX$F1ZpZQ7&1?l(@2G8;mV7vXsiL=|MWOXn@K<4=zw3*BA&;^hTKsD6AEvKy%4Wt{WK0sc&Yvl&l(U zkvwu|X6NU<*?Hgh-kkMzdl1N#>!lAuE`1)q6S43%|U5P!r7VA0d+z+^wMsshu08oMjy&g zFGO4xs~eW674PJ+UbMKJW?Fm+PQdbL_e$wvwrpq^^I$i^Se|>?T*_8tkj*L9o3TZG zS&Ly^H}jfd#!6WKK+Bj$>{T-{dr`EsVtK(rTSqFJ$Jh&cM0kt|D5_|$w{Nq5qknU7 zW3aA_{^k596Mvp)jm3Ay;-4-x#?(gdyVdJ^vhSg%As?>~Mn9LMbw2t6mZA*VQTxac zIWp?9VQ5rQ61~q}_$@+N=nJ7=GK-AL9Jnz?0Kk4t)>yK=(-b)ZOfz4h?1O%~Kd7mYFQ)tR!j8Wi zq#OX0;9HVR7l8jfS7MVW@&5vhQ#qBNXoD;K3b<6k0o_F=#gy4HmqO$1t(UkYmmr65 zM3SttqcBokkE8@435I zm$t@Q?r_r`u8ruOm+-IKC^5d&^{hu@;uA(#v*)(zJ4TN^U zRs!%R+CYQZ9dY35KrX8g$jx=4Gpr(LI3ms}N4v8+H$zu<;TnME$&J2^pB7Rm72ztX6{f z&x}^iEShG?_(?1V3hu$q=>+ZJu@mT#rx|vz1=aw3oLOr-# zFR!$u%5xD7joWYq+Z`c*qHrbA^Ls{hfVcy`EC2OnJi%5cz8{Y2b0m#XNdl<s*Q!@&yeK5fx5PHf^Tq z9t?8pfHVkVXF{3iZeR59+c0<>9^-FN09Adx>y@?2`h&Fx^}*4G?|gOYOVPI{^>1Bh zO2fOp;HvMd-f*LLsCs?ZAKLoO=l*lm8-JGrEqP!^9%#wor*e2t3fJVObnYvf1IJr# zrRi2`m-hk#n}v@Gwb9?{t1?M~Ly$lF1~+3HvDzEk^9}!K%m3Dn|EG6AyK#9V}wVZFw8ULeug~HQ12J$htH7rpFGRNzL7*m`NmJu>A$>wCP-EN E8|<-#^8f$< diff --git a/__pycache__/gui.cpython-314.pyc b/__pycache__/gui.cpython-314.pyc deleted file mode 100644 index dca7e7f0f88c326ef2cbdcd540b37eca6999bfdd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9351 zcmdT~+ix3JdLP~o#Stm%YKf#o$&w=4x;Tz4C$W=Qw&Tz@g`&K)Q_JGg~2;+e{|LoFQlFZiM-Pp$Ccuc5Ha&2lNnX)fWA z90{l7Ot>Uh!Y#QI9?6sNO5TJ|@+F8w5`M{_XpkCu@O$U!K!TU}M5EN02ueX#=Q^{9K(JVEyvgdS5qE%|`;o7)fF6HguQobv`gD!0iQX8u!Q0u?qH)`8c4STqF z;1Bpwyg~I%T`c711xXD|rH_|yoy*=XFX2nVeDO}5%U%_VDnE6iTrAU*g>11r51&lu zXt`LNph8ylPMxCJMM3pXO;?HqNfIcupU)Ob7Yn6)`6j*yqL`f*VAQw^K4&Ar-YUj$1H20Px<>=swL~^H`9h~GzxghtZ+>$TtkVu+4;*k7sG#)j; zrkTE(m2wL?w`CqI!|9S2PhrBNFv*P3IPFPuKZV;rSgYybE;g0)WpNi{rP{54-0UwI zW2U)ez!<_P%breSuWoY; zz4Rk`#q@8@=MQ>~nDu%|KieKaAy5R@A5bnXOvXD@kGNC;ohF$~p;VAE8Pz3a=T(0W z@gTDx+)_!ol*ttfx$A19C`b$prls3Of%;(x)ib@2tq5v>Rppb}5}=6}>JS>dscbbUn>B1dBZG)Z@ zOH%n%p;$bg&0U|T<)uyTx2PecKLh*Ciee@s;@Sow#g^5Pv4!%YFh=cJ>(}ECk3Kl6gx`|GZ~boW?-&1eQ5ioYkDvLc@R=$(yMv!~%A|9xw@M<< zc17+-SG-VpFSr`~>_(MzKkJCDFineZ*}g4a`0Ju!~UkGV0Duc@U|RL2FI- zgKjn|^uncXlkf}JK9@5v6kbp&82ILDJL=ml+@-QWtUJwK+?xct#svk5=6IK!TI5D}xfe|-D#_05UPn|(9ey>XIatOB79r8aQ8G5{2WeMr}AJ%t>0ADZHuw`fYfG8$wC4-h>)j z#1WDde4G!lQ4L#frLGaLR6GnB+(@A5-qF>gzkL7KsfV)jhGWMx>21ZOVulwdBEbIVU&4|6o3q0c zd49mb$Z7{uAdv!>F8q~Rj&U%*+#MB0mG-x?sbs)H;#XZyan{uR_ zrt>mmD2EAX;1n6-12`J$1@juNmOh%*RfTrLnR-RLjnXA;Tut3~ng?SU;b!oDSxUC+ zCG*(}YMXMU+!iJEfRe&r+0Ty4A?!tX+>p(^Qo8fv-s>FTFSs2BcihI*gy z9SYy`HQ%%D-W)u<`OdX%{yLydQ=eu4tPqbt4tpRkmf<3n5qhny(1=Ml02K!q2;6a9 zN*O^%#W`chYzm?@UBW?@jeanPA8oA=KK}27(s|-W(fQ16ZAHu z-E6ar?z9W7?B_IlSyo*SSIqL`&n8kgG`w0yf7RL8 z?9A+yasg7fRX5^;>i!834XQgO4h?ta!V3Le8d1HTgqo?9@q%_6Rh!r)tjFO7|E-?T)9*NXI^#VAS#9pHTEI^&HAV` zjosW>Q&W9yEmvVJegRgR!Ifzs5Q}Lb@G*92vw(mJD#&Mu-#`Y=LUa2U!=De|A6@b7 zw1&U<`16m~Pi?jCTk$^&_pOg_9NY@;UvXE-Zsvxgne=}(qYS(!54@)g9G3@< zSIG%Wd!J1D6tY()dp8dL_MJ!XRLP+ovP%P)wUbI@SdI)=$;kih1lwffWspHgU?8&> za^P7Eh_L_*X9p~%G3?KHy)W#;fEefzGuN+|l6&-$`P9$bjsddVIfh1cDTy&vUDDSA(W2M( zOl;R>>1BVC#`(awE<3&m-M!HtI)&&z)Fpkd5aBR5yik~hdnx<_WI)_dG@)t};K>OH zOK7&J5gin5I7O+dML-|Z0hFXsg{^isuH zL8w?NnJb2rkEn(!<;qeK=^brZai11F2Xori^%(<;z)NRpizK4KSBs3!&Sa6#-c_N( zje>9!hJtf|;E1Y0y-MpTB8m*gSkh2@5;tje5*^xTRpLkF_>ryXQC4M+kXO3*%ia5{eaRHu@;KHE?*V>uvBSbq`Wwg0|3l9FobQ-(6M46Y_XM z8NVctU#gN%c1U}TENcBt&zC*c2{*%NQg(2DMoGQ3?poUm>=5I!A~K0=lkS)80lI(8 z9{eLaN5BH8s84qGK-UjFFv%xs+(Lb4c()7AW(_xmxw3}sFuUtBRGew2KFd6;HNu?* z7Xs{r5vaY~r-wT`&#>4)c=H=O`(uYc`ut7YDd2f-f>TZpnzrZSK{zX$Kur(2D)767 z?OC>&E74173uc{to8$akH@g3Nv*l^$>gwwRq;i1c=A;qki!WA(D!5qjZy;Odo(7whU`!6il;EHo98`isUk8VFS~`@L z0l8&BX&IJVhBv0RTEW!VIcBQFLZtB}^>VMkR{czud zeM;A`+%>Fp9gw>YYdT?R240o6DMCY}3ObWCKCOvYEcTd17X( z>B@=+Da-d(-@Et0FFx354}Wp(^K18uD<2_Yd3N=z!uQL3|N3;5Kd{4hD*TAdk8I3W zc_cIyJ|^?AD&J?6xB0$r0~{ZI8SM@4AJ@((26)ydJG6&ew#s|0%a0`wOh4T8-fDO5 zS(4^P-bU&ke{xXW#d0>U-6w{H5?qbyh2O5hXL;mcpd*5YaW$g;m)OO619kU}yA;vY zf*?<0XcU5$aP7jw%MUIq(fx9CzY;wxM-Ttrw-r6MO(tGGGADFr2J#ndoL9hdO=aN| zBk3Y;sOg@Xfized<5PpxVd{bcUzhZ;OuC&S(H(bd&^-%fiig1joc8M5V}dq-A}D1opX2rGf;*MX=ZOZ9T);dmP_;{U@TWnr(cIV5cU!66~j zfOk&UFxw9qIKF75=rzok&HO2r&SQqsOFw}u9-_!C&<`*>hS>yWNVrhsBPhDF+C8Co zYAa$F$QF1U>rP;H60;8>`vJQTUKU3&8^acqiyFJndy0^EWt!z^N9 zzdUO(P5@hxFjE8i5FafSC_*%&g*5V~S#N{?XL6B6G@=%`@Os0EGQ=rpMYIS;qlFnq z%sdgc&_~)Gu#i9{N44Kw-ltzeF9-aI#~}No%i(Z*?{qty-*ZORz&XZc?$9?}{2MO( iuiU_Q?q0{cpACM;!Q;DAj#-Ce&u8I(=kUpn<-Y+gN4HY| diff --git a/__pycache__/svg_parser.cpython-314.pyc b/__pycache__/svg_parser.cpython-314.pyc deleted file mode 100644 index 0b8ed1ade6d5eed56c898f996481e68c262fb16c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3796 zcmeHK-BTOa6~8O3q!n6WLW~5KY`zl08YFN`VkgDK6zqn8u|{^4P7U6!R|~8WTKVpk ziE%yQtzyO#$C>G1HxGukGuUrUr!#3D`yZf+R`NETcBXmhTMC_~ee1bbp9se7Lmztw z&YpYExnJjZKCb#2y$uMOuaLU=hJestXs6ux60;WuW))pWQEmbe`n64P@7jp19krlN z6t(+NlpnGcwTV5-btB2~SK6JHIE#-cWI-(oipZdhkOBMn z5#ggYpbok^aYlKduBZd35Oo4|M_r^LhKMIBz;14<@yhM_F(YB%h=NTGtEJPho#;L6 z2nl;~=&r1VX|FPy z^_*o{3`LHV`RaG5#I}Q_yU{cIiX%O__;|9(WmWdXH> zc>%~O`m#0n=zE_wX88NwfCw5rmf64og$ z#=TY=j{g9mAox^-TCuKOuXtL@R&yd2d;3-4L|n{Pj;547W^Y2@<$M(Eb~ zi`S3whstebPrqTCd=()#IsxmYc<{{1?WnKO|1`ukmFY;bubdq=$(@9D?fS^<$#?Yl zxh^%0F?d$V159;6Thh9ic%(Oc@*Qb-fK3=#IX@HbWf6IW>4dH+p_S3l_Xk67&Gtz% z=E<46K$suB4NPx0$md2w7peU2>HatGNz;sntRdT_>%%!h_C!S3HWOBQAaeHYc7rdLf^!!`z=8jE#aqbt&lQWOcY@GV^?TuSo z%bUwjZ~biLw}De%oXZ}Y$c%q9`8PKb{r}n(P~bhzq=5bARq5Nuc(vq(Fn|!f2J`zb zncu(P6k)^r$y*?cgH{myzMFdfelhFCxc+0+Ver8JIv;{&#ha=tls&JV?gurdzC|do zb*-8KXDG0&MHF^8PiN&2?(ftwbCT5!7-}{(i@ycPmv2h@%P? zpm}gQ1q=8O3ahsTdE1NR6g7#Pmb}d0F?C4Gsiaa`GS1=yQyW8Z>g16f4{PviRH!^Is>=jNM* zX5V_>!@fuA@0z-x}Q#*nLqj{k%E41Z>_N>spBXs^oJ% zo1fhLdGl7=PurfJ$#tIoRrK@O&t`MJu?$p=uJsQeez>vxx%iow^Pi(75Nrz2Iw=6r8JXPxSnEyqV>jlxz55AL&)pMJPrgb3-|G^dGkU?R_NF`x)t0E zLQxeuelMIqctd)2_!sv6!R8)*!%CH#XI1WhnG;~w`gT|iD@RFftif4dm_0yFW2CN? zR|5M(Jkj^+GuzdRl8eO?3nAPIQ;HSGo-$IH-kyaB9t5ri4U11FL&7$HVzRUpCN}3xqP`_oqPj z!Nl6c{YlD9u1(&*^!#Q2^CL(94-{7~zO>svI0fhNLe>Tvc4-94lgSo)#{}DvUx3szy zGdF!TRvmC!66sqysVr&mmTr*cB_p0rP+pYfTWKXxk{prcIUSoMp(nMZA#A=aclPE-2Wk};#zmX=I32VM2%j{Vc*HxlwiG(b}Zw+tFAWWkf=zK8) z(UgcOq4~``3FMzY0vqkxoDSb^lgsfQx7+Lm_5|k`<#wGeN6R Result { + let scheme = ColorScheme::load(icon_path)?; + let scheme = Rc::new(RefCell::new(scheme)); + + let root = GtkBox::new(Orientation::Vertical, 8); + root.set_margin_top(8); + root.set_margin_bottom(8); + root.set_margin_start(8); + root.set_margin_end(8); + + let toolbar = GtkBox::new(Orientation::Horizontal, 8); + let back_button = gtk::Button::with_label("← Choose another icon"); + back_button.connect_clicked(move |_| on_back()); + toolbar.append(&back_button); + + let undo_button = gtk::Button::with_label("Undo"); + let redo_button = gtk::Button::with_label("Redo"); + toolbar.append(&undo_button); + toolbar.append(&redo_button); + root.append(&toolbar); + + if scheme.borrow().colors.is_empty() { + let info = InfoBar::new(); + info.set_message_type(MessageType::Warning); + info.add_child(&Label::new(Some( + "No ColorScheme tags were found in this SVG.", + ))); + root.append(&info); + } + + let content = GtkBox::new(Orientation::Horizontal, 8); + content.set_vexpand(true); + + let swatches_scroller = ScrolledWindow::new(); + swatches_scroller.set_min_content_width(300); + let swatches_box = GtkBox::new(Orientation::Vertical, 4); + swatches_scroller.set_child(Some(&swatches_box)); + content.append(&swatches_scroller); + + let preview = Picture::new(); + preview.set_hexpand(true); + content.append(&preview); + + root.append(&content); + + let refresh_preview = { + let scheme = Rc::clone(&scheme); + let preview = preview.clone(); + move || { + let bytes = glib::Bytes::from_owned(scheme.borrow().content.clone().into_bytes()); + match gtk::gdk::Texture::from_bytes(&bytes) { + Ok(texture) => preview.set_paintable(Some(&texture)), + Err(_) => preview.set_paintable(gtk::gdk::Paintable::NONE), + } + } + }; + refresh_preview(); + + let rebuild_swatches: Rc>> = Rc::new(RefCell::new(Box::new(|| {}))); + { + let scheme = Rc::clone(&scheme); + let swatches_box = swatches_box.clone(); + let refresh_preview = refresh_preview.clone(); + let rebuild_swatches_for_closure = Rc::clone(&rebuild_swatches); + + let build_fn = move || { + while let Some(child) = swatches_box.first_child() { + swatches_box.remove(&child); + } + + let tags: Vec<(String, String)> = scheme.borrow().colors.clone(); + for (tag, hex) in tags { + let row = GtkBox::new(Orientation::Horizontal, 8); + let label = Label::new(Some(&tag)); + label.set_hexpand(true); + label.set_xalign(0.0); + + let rgba = gtk::gdk::RGBA::parse(&hex).unwrap_or(gtk::gdk::RGBA::BLACK); + let dialog = ColorDialog::builder().with_alpha(false).build(); + let button = ColorDialogButton::new(Some(dialog)); + button.set_rgba(&rgba); + + { + let scheme = Rc::clone(&scheme); + let tag = tag.clone(); + let refresh_preview = refresh_preview.clone(); + let rebuild_swatches_for_closure = Rc::clone(&rebuild_swatches_for_closure); + button.connect_rgba_notify(move |btn| { + let new_rgba = btn.rgba(); + let new_hex = format!( + "#{:02x}{:02x}{:02x}", + (new_rgba.red() * 255.0).round() as u8, + (new_rgba.green() * 255.0).round() as u8, + (new_rgba.blue() * 255.0).round() as u8, + ); + let changed = scheme + .borrow_mut() + .update_color(&tag, &new_hex) + .unwrap_or(false); + if changed { + refresh_preview(); + (rebuild_swatches_for_closure.borrow())(); + } + }); + } + + row.append(&label); + row.append(&button); + swatches_box.append(&row); + } + }; + + *rebuild_swatches.borrow_mut() = Box::new(build_fn); + } + (rebuild_swatches.borrow())(); + + { + let scheme = Rc::clone(&scheme); + let refresh_preview = refresh_preview.clone(); + let rebuild_swatches = Rc::clone(&rebuild_swatches); + undo_button.connect_clicked(move |_| { + if scheme.borrow_mut().undo().unwrap_or(false) { + refresh_preview(); + (rebuild_swatches.borrow())(); + } + }); + } + { + let scheme = Rc::clone(&scheme); + let refresh_preview = refresh_preview.clone(); + let rebuild_swatches = Rc::clone(&rebuild_swatches); + redo_button.connect_clicked(move |_| { + if scheme.borrow_mut().redo().unwrap_or(false) { + refresh_preview(); + (rebuild_swatches.borrow())(); + } + }); + } + + Ok(root.upcast()) +} From 644eb2c12f111e6d8dd2babc2fbacacc0638ff1d Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 20:30:42 -0400 Subject: [PATCH 09/34] feat: add namespace support to ColorScheme to prevent css collisions --- src/color_scheme.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/color_scheme.rs b/src/color_scheme.rs index be112cb..1de6214 100644 --- a/src/color_scheme.rs +++ b/src/color_scheme.rs @@ -12,9 +12,19 @@ pub struct ColorScheme { impl ColorScheme { pub fn load(path: impl Into) -> Result { + Self::load_with_namespace(path, "") + } + + pub fn load_with_namespace(path: impl Into, namespace: &str) -> Result { let path = path.into(); - let content = std::fs::read_to_string(&path) + 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, @@ -85,7 +95,7 @@ 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})"#) + 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())) @@ -245,4 +255,22 @@ mod tests { 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(); + } } From d6dbfdf7ea7fe17f724da452c55ac9838e6f2bd6 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 20:48:37 -0400 Subject: [PATCH 10/34] feat: add CompositeIcon struct for nesting SVGs --- src/composite_icon.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 44 insertions(+) create mode 100644 src/composite_icon.rs diff --git a/src/composite_icon.rs b/src/composite_icon.rs new file mode 100644 index 0000000..86cce42 --- /dev/null +++ b/src/composite_icon.rs @@ -0,0 +1,43 @@ +use crate::color_scheme::ColorScheme; + +pub struct CompositeIcon { + pub bg: ColorScheme, + pub emblem: Option, + pub emblem_x: f64, + pub emblem_y: f64, + pub emblem_size: f64, +} + +impl CompositeIcon { + pub fn new(bg: ColorScheme) -> Self { + Self { + bg, + emblem: None, + emblem_x: 32.0, + emblem_y: 32.0, + emblem_size: 32.0, + } + } + + pub fn set_emblem(&mut self, emblem: ColorScheme) { + self.emblem = Some(emblem); + } + + pub fn render(&self) -> String { + if let Some(emblem) = &self.emblem { + format!( + r#" + +{} + + +{} + +"#, + self.bg.content, self.emblem_x, self.emblem_y, self.emblem_size, self.emblem_size, emblem.content + ) + } else { + self.bg.content.clone() + } + } +} diff --git a/src/main.rs b/src/main.rs index 7ec0602..bc56a20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod color_scheme; +mod composite_icon; mod config; mod icon_index; mod ui; From d0233b544d0ec9127675d2e0245b48d1f9ea9818 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:03:50 -0400 Subject: [PATCH 11/34] feat: add CompositeIcon struct for nesting SVGs --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + src/color_scheme.rs | 3 ++- src/composite_icon.rs | 13 ++++++------- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a43c1d4..59b8f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "bitflags" version = "2.13.1" @@ -402,6 +408,7 @@ name = "icon-color-tool" version = "0.1.0" dependencies = [ "anyhow", + "base64", "gtk4", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 99982a7..24d2182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ regex = "1" serde = { version = "1", features = ["derive"] } toml = "0.8" walkdir = "2" +base64 = "0.23.0" diff --git a/src/color_scheme.rs b/src/color_scheme.rs index 1de6214..9a18f41 100644 --- a/src/color_scheme.rs +++ b/src/color_scheme.rs @@ -21,8 +21,9 @@ impl ColorScheme { .with_context(|| format!("reading {}", path.display()))?; if !namespace.is_empty() { + let class_re = Regex::new(r#"class=(['"])([^'"]*?)ColorScheme-([^'"]*?)(['"])"#).unwrap(); + content = class_re.replace_all(&content, format!("class=${{1}}${{2}}{namespace}-ColorScheme-${{3}}${{4}}").as_str()).into_owned(); content = content.replace(".ColorScheme-", &format!(".{namespace}-ColorScheme-")); - content = content.replace("class=\"ColorScheme-", &format!("class=\"{namespace}-ColorScheme-")); } let colors = parse_colors(&content); diff --git a/src/composite_icon.rs b/src/composite_icon.rs index 86cce42..00be610 100644 --- a/src/composite_icon.rs +++ b/src/composite_icon.rs @@ -25,16 +25,15 @@ impl CompositeIcon { pub fn render(&self) -> String { if let Some(emblem) = &self.emblem { + use base64::prelude::*; + let bg_b64 = BASE64_STANDARD.encode(&self.bg.content); + let emblem_b64 = BASE64_STANDARD.encode(&emblem.content); format!( r#" - -{} - - -{} - + + "#, - self.bg.content, self.emblem_x, self.emblem_y, self.emblem_size, self.emblem_size, emblem.content + bg_b64, self.emblem_x, self.emblem_y, self.emblem_size, self.emblem_size, emblem_b64 ) } else { self.bg.content.clone() From 6a9995cc49d4bb6ef2628c5c255533c9ccdadb01 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:07:10 -0400 Subject: [PATCH 12/34] feat: update editor UI to use CompositeIcon --- src/ui/editor.rs | 96 +++++++++++++++++++++++++++++++----------------- 1 file changed, 62 insertions(+), 34 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 45f2430..a55f638 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -1,19 +1,18 @@ -use crate::color_scheme::ColorScheme; -use anyhow::Result; +use crate::composite_icon::CompositeIcon; use gtk::glib; use gtk::prelude::*; use gtk::{ - Box as GtkBox, ColorDialog, ColorDialogButton, InfoBar, Label, MessageType, Orientation, - Picture, ScrolledWindow, + Box as GtkBox, ColorDialog, ColorDialogButton, Label, Orientation, Picture, ScrolledWindow, }; use std::cell::RefCell; -use std::path::Path; use std::rc::Rc; -pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Result { - let scheme = ColorScheme::load(icon_path)?; - let scheme = Rc::new(RefCell::new(scheme)); - +pub fn build_editor_page( + composite: Rc>, + on_back: impl Fn() + 'static, + on_add_emblem: impl Fn() + 'static, + on_save_as: impl Fn() + 'static, +) -> gtk::Widget { let root = GtkBox::new(Orientation::Vertical, 8); root.set_margin_top(8); root.set_margin_bottom(8); @@ -25,29 +24,36 @@ pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Resu back_button.connect_clicked(move |_| on_back()); toolbar.append(&back_button); + let add_emblem_button = gtk::Button::with_label("Add Emblem"); + add_emblem_button.connect_clicked(move |_| on_add_emblem()); + toolbar.append(&add_emblem_button); + + let save_as_button = gtk::Button::with_label("Save As..."); + save_as_button.connect_clicked(move |_| on_save_as()); + toolbar.append(&save_as_button); + let undo_button = gtk::Button::with_label("Undo"); let redo_button = gtk::Button::with_label("Redo"); toolbar.append(&undo_button); toolbar.append(&redo_button); root.append(&toolbar); - if scheme.borrow().colors.is_empty() { - let info = InfoBar::new(); - info.set_message_type(MessageType::Warning); - info.add_child(&Label::new(Some( - "No ColorScheme tags were found in this SVG.", - ))); - root.append(&info); - } - let content = GtkBox::new(Orientation::Horizontal, 8); content.set_vexpand(true); + let side_panel = GtkBox::new(Orientation::Vertical, 8); let swatches_scroller = ScrolledWindow::new(); swatches_scroller.set_min_content_width(300); + swatches_scroller.set_vexpand(true); let swatches_box = GtkBox::new(Orientation::Vertical, 4); swatches_scroller.set_child(Some(&swatches_box)); - content.append(&swatches_scroller); + side_panel.append(&swatches_scroller); + + // Geometry sliders will go here in Task 4 + let geometry_box = GtkBox::new(Orientation::Vertical, 4); + side_panel.append(&geometry_box); + + content.append(&side_panel); let preview = Picture::new(); preview.set_hexpand(true); @@ -56,10 +62,10 @@ pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Resu root.append(&content); let refresh_preview = { - let scheme = Rc::clone(&scheme); + let composite = Rc::clone(&composite); let preview = preview.clone(); move || { - let bytes = glib::Bytes::from_owned(scheme.borrow().content.clone().into_bytes()); + let bytes = glib::Bytes::from_owned(composite.borrow().render().into_bytes()); match gtk::gdk::Texture::from_bytes(&bytes) { Ok(texture) => preview.set_paintable(Some(&texture)), Err(_) => preview.set_paintable(gtk::gdk::Paintable::NONE), @@ -70,7 +76,7 @@ pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Resu let rebuild_swatches: Rc>> = Rc::new(RefCell::new(Box::new(|| {}))); { - let scheme = Rc::clone(&scheme); + let composite = Rc::clone(&composite); let swatches_box = swatches_box.clone(); let refresh_preview = refresh_preview.clone(); let rebuild_swatches_for_closure = Rc::clone(&rebuild_swatches); @@ -80,8 +86,18 @@ pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Resu swatches_box.remove(&child); } - let tags: Vec<(String, String)> = scheme.borrow().colors.clone(); - for (tag, hex) in tags { + let comp = composite.borrow(); + let mut all_colors = Vec::new(); + for c in &comp.bg.colors { + all_colors.push((c.0.clone(), c.1.clone(), false)); + } + if let Some(emblem) = &comp.emblem { + for c in &emblem.colors { + all_colors.push((c.0.clone(), c.1.clone(), true)); + } + } + + for (tag, hex, is_emblem) in all_colors { let row = GtkBox::new(Orientation::Horizontal, 8); let label = Label::new(Some(&tag)); label.set_hexpand(true); @@ -93,7 +109,7 @@ pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Resu button.set_rgba(&rgba); { - let scheme = Rc::clone(&scheme); + let composite = Rc::clone(&composite); let tag = tag.clone(); let refresh_preview = refresh_preview.clone(); let rebuild_swatches_for_closure = Rc::clone(&rebuild_swatches_for_closure); @@ -105,10 +121,12 @@ pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Resu (new_rgba.green() * 255.0).round() as u8, (new_rgba.blue() * 255.0).round() as u8, ); - let changed = scheme - .borrow_mut() - .update_color(&tag, &new_hex) - .unwrap_or(false); + let mut comp = composite.borrow_mut(); + let changed = if is_emblem { + comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false) + } else { + comp.bg.update_color(&tag, &new_hex).unwrap_or(false) + }; if changed { refresh_preview(); (rebuild_swatches_for_closure.borrow())(); @@ -127,27 +145,37 @@ pub fn build_editor_page(icon_path: &Path, on_back: impl Fn() + 'static) -> Resu (rebuild_swatches.borrow())(); { - let scheme = Rc::clone(&scheme); + let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); let rebuild_swatches = Rc::clone(&rebuild_swatches); undo_button.connect_clicked(move |_| { - if scheme.borrow_mut().undo().unwrap_or(false) { + let mut comp = composite.borrow_mut(); + let mut changed = comp.bg.undo().unwrap_or(false); + if let Some(emblem) = &mut comp.emblem { + changed |= emblem.undo().unwrap_or(false); + } + if changed { refresh_preview(); (rebuild_swatches.borrow())(); } }); } { - let scheme = Rc::clone(&scheme); + let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); let rebuild_swatches = Rc::clone(&rebuild_swatches); redo_button.connect_clicked(move |_| { - if scheme.borrow_mut().redo().unwrap_or(false) { + let mut comp = composite.borrow_mut(); + let mut changed = comp.bg.redo().unwrap_or(false); + if let Some(emblem) = &mut comp.emblem { + changed |= emblem.redo().unwrap_or(false); + } + if changed { refresh_preview(); (rebuild_swatches.borrow())(); } }); } - Ok(root.upcast()) + root.upcast() } From d5bb10528de3d2e5562f9b1a1ec7854a9d070aed Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:15:28 -0400 Subject: [PATCH 13/34] feat: add emblem geometry sliders --- src/ui/editor.rs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index a55f638..23e8296 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -51,6 +51,20 @@ pub fn build_editor_page( // Geometry sliders will go here in Task 4 let geometry_box = GtkBox::new(Orientation::Vertical, 4); + + let scale_x = gtk::Scale::with_range(Orientation::Horizontal, -64.0, 64.0, 1.0); + scale_x.set_value(32.0); + let scale_y = gtk::Scale::with_range(Orientation::Horizontal, -64.0, 64.0, 1.0); + scale_y.set_value(32.0); + let scale_size = gtk::Scale::with_range(Orientation::Horizontal, 8.0, 128.0, 1.0); + scale_size.set_value(32.0); + + geometry_box.append(&Label::new(Some("Emblem X"))); + geometry_box.append(&scale_x); + geometry_box.append(&Label::new(Some("Emblem Y"))); + geometry_box.append(&scale_y); + geometry_box.append(&Label::new(Some("Emblem Size"))); + geometry_box.append(&scale_size); side_panel.append(&geometry_box); content.append(&side_panel); @@ -74,6 +88,31 @@ pub fn build_editor_page( }; refresh_preview(); + { + let composite = Rc::clone(&composite); + let refresh_preview = refresh_preview.clone(); + scale_x.connect_value_changed(move |s| { + composite.borrow_mut().emblem_x = s.value(); + refresh_preview(); + }); + } + { + let composite = Rc::clone(&composite); + let refresh_preview = refresh_preview.clone(); + scale_y.connect_value_changed(move |s| { + composite.borrow_mut().emblem_y = s.value(); + refresh_preview(); + }); + } + { + let composite = Rc::clone(&composite); + let refresh_preview = refresh_preview.clone(); + scale_size.connect_value_changed(move |s| { + composite.borrow_mut().emblem_size = s.value(); + refresh_preview(); + }); + } + let rebuild_swatches: Rc>> = Rc::new(RefCell::new(Box::new(|| {}))); { let composite = Rc::clone(&composite); From 296be9bd1d3b07619db62374f71226359f44423e Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:19:28 -0400 Subject: [PATCH 14/34] fix: initialize geometry sliders to composite state and conditionally display --- src/ui/editor.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 23e8296..414c003 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -53,11 +53,11 @@ pub fn build_editor_page( let geometry_box = GtkBox::new(Orientation::Vertical, 4); let scale_x = gtk::Scale::with_range(Orientation::Horizontal, -64.0, 64.0, 1.0); - scale_x.set_value(32.0); + scale_x.set_value(composite.borrow().emblem_x); let scale_y = gtk::Scale::with_range(Orientation::Horizontal, -64.0, 64.0, 1.0); - scale_y.set_value(32.0); + scale_y.set_value(composite.borrow().emblem_y); let scale_size = gtk::Scale::with_range(Orientation::Horizontal, 8.0, 128.0, 1.0); - scale_size.set_value(32.0); + scale_size.set_value(composite.borrow().emblem_size); geometry_box.append(&Label::new(Some("Emblem X"))); geometry_box.append(&scale_x); @@ -65,7 +65,9 @@ pub fn build_editor_page( geometry_box.append(&scale_y); geometry_box.append(&Label::new(Some("Emblem Size"))); geometry_box.append(&scale_size); - side_panel.append(&geometry_box); + if composite.borrow().emblem.is_some() { + side_panel.append(&geometry_box); + } content.append(&side_panel); From d063870bcde0eaa5ce7f92422fd1bb52412522f8 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:20:47 -0400 Subject: [PATCH 15/34] feat: add save as dialog and auto_save toggle --- src/color_scheme.rs | 14 +++++++-- src/main.rs | 70 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/color_scheme.rs b/src/color_scheme.rs index 9a18f41..958f9f4 100644 --- a/src/color_scheme.rs +++ b/src/color_scheme.rs @@ -8,6 +8,7 @@ pub struct ColorScheme { pub colors: Vec<(String, String)>, history: Vec, redo_history: Vec, + pub auto_save: bool, } impl ColorScheme { @@ -33,6 +34,7 @@ impl ColorScheme { colors, history: Vec::new(), redo_history: Vec::new(), + auto_save: true, }) } @@ -54,7 +56,9 @@ impl ColorScheme { .push(std::mem::replace(&mut self.content, new_content)); self.redo_history.clear(); self.colors = parse_colors(&self.content); - self.save()?; + if self.auto_save { + self.save()?; + } Ok(true) } @@ -70,7 +74,9 @@ impl ColorScheme { self.redo_history .push(std::mem::replace(&mut self.content, prev)); self.colors = parse_colors(&self.content); - self.save()?; + if self.auto_save { + self.save()?; + } Ok(true) } @@ -81,7 +87,9 @@ impl ColorScheme { self.history .push(std::mem::replace(&mut self.content, next)); self.colors = parse_colors(&self.content); - self.save()?; + if self.auto_save { + self.save()?; + } Ok(true) } } diff --git a/src/main.rs b/src/main.rs index bc56a20..7a6e6fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,10 @@ mod ui; use gtk::prelude::*; use gtk::{glib, Application, ApplicationWindow, Stack}; use std::path::{Path, PathBuf}; +use crate::color_scheme::ColorScheme; +use crate::composite_icon::CompositeIcon; +use std::cell::RefCell; +use std::rc::Rc; const APP_ID: &str = "dev.rootiest.IconColorTool"; @@ -42,10 +46,10 @@ fn main() -> glib::ExitCode { let stack = Stack::new(); window.set_child(Some(&stack)); - build_picker(&stack, breeze_path); + build_picker(&stack, &window, breeze_path); if let Some(Ok(path)) = initial_icon { - build_editor(&stack, path); + build_editor(&stack, &window, path); } window.present(); @@ -77,30 +81,62 @@ fn resolve_icon_arg(arg: &str, breeze_path: &Path) -> anyhow::Result { Ok(under_breeze.canonicalize()?) } -fn build_picker(stack: &Stack, breeze_path: PathBuf) { +fn build_picker(stack: &Stack, window: &ApplicationWindow, breeze_path: PathBuf) { let stack_for_callback = stack.clone(); + let window_for_callback = window.clone(); let widget = ui::picker::build_picker_page(breeze_path, move |icon_path| { - build_editor(&stack_for_callback, icon_path); - stack_for_callback.set_visible_child_name("editor"); + build_editor(&stack_for_callback, &window_for_callback, icon_path); }); stack.add_named(&widget, Some("picker")); stack.set_visible_child_name("picker"); } -fn build_editor(stack: &Stack, icon_path: PathBuf) { - let stack_for_callback = stack.clone(); - match ui::editor::build_editor_page(&icon_path, move || { - stack_for_callback.set_visible_child_name("picker"); - }) { - Ok(widget) => { - if let Some(existing) = stack.child_by_name("editor") { - stack.remove(&existing); - } - stack.add_named(&widget, Some("editor")); - stack.set_visible_child_name("editor"); - } +fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf) { + let scheme = match ColorScheme::load(&icon_path) { + Ok(s) => s, Err(e) => { eprintln!("Failed to open {}: {e:#}", icon_path.display()); + return; } + }; + + let composite = Rc::new(RefCell::new(CompositeIcon::new(scheme))); + + let stack_for_callback = stack.clone(); + + let widget = ui::editor::build_editor_page( + Rc::clone(&composite), + move || { + stack_for_callback.set_visible_child_name("picker"); + }, + { + let stack_clone = stack.clone(); + move || { + stack_clone.set_visible_child_name("emblem_picker"); + } + }, + { + let composite = Rc::clone(&composite); + let window = window.clone(); + move || { + let dialog = gtk::FileDialog::builder() + .title("Save Composite Icon As...") + .build(); + let comp = Rc::clone(&composite); + dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |res| { + if let Ok(file) = res { + if let Some(path) = file.path() { + let _ = std::fs::write(path, comp.borrow().render()); + } + } + }); + } + } + ); + + if let Some(existing) = stack.child_by_name("editor") { + stack.remove(&existing); } + stack.add_named(&widget, Some("editor")); + stack.set_visible_child_name("editor"); } From d88aadd40f81f408cc2701902db82cb093992f53 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:27:36 -0400 Subject: [PATCH 16/34] fix: add error handling to save dialog and auto_save false test --- src/color_scheme.rs | 18 ++++++++++++++++++ src/main.rs | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/color_scheme.rs b/src/color_scheme.rs index 958f9f4..70e8521 100644 --- a/src/color_scheme.rs +++ b/src/color_scheme.rs @@ -282,4 +282,22 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn update_color_auto_save_false_does_not_write() { + let dir = std::env::temp_dir().join(format!("auto_save_false_{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + let file = dir.join("icon.svg"); + std::fs::write(&file, SINGLE_COLOR_SVG).unwrap(); + + let mut scheme = ColorScheme::load(&file).unwrap(); + scheme.auto_save = false; + + assert!(scheme.update_color("ColorScheme-Text", "#000000").unwrap()); + + let file_content = std::fs::read_to_string(&file).unwrap(); + assert_eq!(file_content, SINGLE_COLOR_SVG, "Disk content should not be modified"); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/src/main.rs b/src/main.rs index 7a6e6fc..699b032 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,7 +126,9 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf) { dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |res| { if let Ok(file) = res { if let Some(path) = file.path() { - let _ = std::fs::write(path, comp.borrow().render()); + if let Err(e) = std::fs::write(&path, comp.borrow().render()) { + eprintln!("Failed to save composite to {}: {e}", path.display()); + } } } }); From e1e27bb4ff620050a57afcc36c4010d697960839 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:28:45 -0400 Subject: [PATCH 17/34] feat: implement emblem picker and dynamic editor rebuild --- src/main.rs | 105 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/src/main.rs b/src/main.rs index 699b032..97d1ed5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,10 +46,10 @@ fn main() -> glib::ExitCode { let stack = Stack::new(); window.set_child(Some(&stack)); - build_picker(&stack, &window, breeze_path); + build_picker(&stack, &window, breeze_path.clone()); if let Some(Ok(path)) = initial_icon { - build_editor(&stack, &window, path); + build_editor(&stack, &window, path, breeze_path); } window.present(); @@ -84,14 +84,16 @@ fn resolve_icon_arg(arg: &str, breeze_path: &Path) -> anyhow::Result { fn build_picker(stack: &Stack, window: &ApplicationWindow, breeze_path: PathBuf) { let stack_for_callback = stack.clone(); let window_for_callback = window.clone(); + let breeze_for_editor = breeze_path.clone(); + let widget = ui::picker::build_picker_page(breeze_path, move |icon_path| { - build_editor(&stack_for_callback, &window_for_callback, icon_path); + build_editor(&stack_for_callback, &window_for_callback, icon_path, breeze_for_editor.clone()); }); stack.add_named(&widget, Some("picker")); stack.set_visible_child_name("picker"); } -fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf) { +fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, breeze_path: PathBuf) { let scheme = match ColorScheme::load(&icon_path) { Ok(s) => s, Err(e) => { @@ -102,43 +104,78 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf) { let composite = Rc::new(RefCell::new(CompositeIcon::new(scheme))); - let stack_for_callback = stack.clone(); + let editor_container = gtk::Box::new(gtk::Orientation::Vertical, 0); - let widget = ui::editor::build_editor_page( - Rc::clone(&composite), - move || { - stack_for_callback.set_visible_child_name("picker"); - }, - { - let stack_clone = stack.clone(); - move || { - stack_clone.set_visible_child_name("emblem_picker"); + let rebuild_editor = { + let composite = Rc::clone(&composite); + let stack = stack.clone(); + let window = window.clone(); + let editor_container = editor_container.clone(); + + Rc::new(move || { + while let Some(child) = editor_container.first_child() { + editor_container.remove(&child); } - }, - { - let composite = Rc::clone(&composite); - let window = window.clone(); - move || { - let dialog = gtk::FileDialog::builder() - .title("Save Composite Icon As...") - .build(); - let comp = Rc::clone(&composite); - dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |res| { - if let Ok(file) = res { - if let Some(path) = file.path() { - if let Err(e) = std::fs::write(&path, comp.borrow().render()) { - eprintln!("Failed to save composite to {}: {e}", path.display()); + + let stack_clone = stack.clone(); + let stack_emblem = stack.clone(); + let window_clone = window.clone(); + let comp = Rc::clone(&composite); + + let page = ui::editor::build_editor_page( + Rc::clone(&composite), + move || { + stack_clone.set_visible_child_name("picker"); + }, + move || { + stack_emblem.set_visible_child_name("emblem_picker"); + }, + move || { + let dialog = gtk::FileDialog::builder() + .title("Save Composite Icon As...") + .build(); + let c = Rc::clone(&comp); + dialog.save(Some(&window_clone), gtk::gio::Cancellable::NONE, move |res| { + if let Ok(file) = res { + if let Some(path) = file.path() { + if let Err(e) = std::fs::write(&path, c.borrow().render()) { + eprintln!("Failed to save composite to {}: {e}", path.display()); + } } } - } - }); - } - } - ); + }); + } + ); + editor_container.append(&page); + }) + }; + + rebuild_editor(); if let Some(existing) = stack.child_by_name("editor") { stack.remove(&existing); } - stack.add_named(&widget, Some("editor")); + stack.add_named(&editor_container, Some("editor")); + + let rebuild_editor_clone = Rc::clone(&rebuild_editor); + let stack_emblem_callback = stack.clone(); + let comp_for_picker = Rc::clone(&composite); + + let emblem_picker = ui::picker::build_picker_page(breeze_path, move |emblem_path| { + if let Ok(mut emblem_scheme) = ColorScheme::load_with_namespace(&emblem_path, "Emblem") { + emblem_scheme.auto_save = false; + comp_for_picker.borrow_mut().set_emblem(emblem_scheme); + rebuild_editor_clone(); + stack_emblem_callback.set_visible_child_name("editor"); + } else { + eprintln!("Failed to load emblem: {}", emblem_path.display()); + } + }); + + if let Some(existing) = stack.child_by_name("emblem_picker") { + stack.remove(&existing); + } + stack.add_named(&emblem_picker, Some("emblem_picker")); + stack.set_visible_child_name("editor"); } From 68e9966bb3f4872f5753787ce78ac90ed4948ee8 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:40:56 -0400 Subject: [PATCH 18/34] fix: drop RefMut before calling refresh_preview to avoid panic --- src/ui/editor.rs | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 414c003..58c5353 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -153,7 +153,6 @@ pub fn build_editor_page( let composite = Rc::clone(&composite); let tag = tag.clone(); let refresh_preview = refresh_preview.clone(); - let rebuild_swatches_for_closure = Rc::clone(&rebuild_swatches_for_closure); button.connect_rgba_notify(move |btn| { let new_rgba = btn.rgba(); let new_hex = format!( @@ -162,15 +161,16 @@ pub fn build_editor_page( (new_rgba.green() * 255.0).round() as u8, (new_rgba.blue() * 255.0).round() as u8, ); - let mut comp = composite.borrow_mut(); - let changed = if is_emblem { - comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false) - } else { - comp.bg.update_color(&tag, &new_hex).unwrap_or(false) + let changed = { + let mut comp = composite.borrow_mut(); + if is_emblem { + comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false) + } else { + comp.bg.update_color(&tag, &new_hex).unwrap_or(false) + } }; if changed { refresh_preview(); - (rebuild_swatches_for_closure.borrow())(); } }); } @@ -190,11 +190,14 @@ pub fn build_editor_page( let refresh_preview = refresh_preview.clone(); let rebuild_swatches = Rc::clone(&rebuild_swatches); undo_button.connect_clicked(move |_| { - let mut comp = composite.borrow_mut(); - let mut changed = comp.bg.undo().unwrap_or(false); - if let Some(emblem) = &mut comp.emblem { - changed |= emblem.undo().unwrap_or(false); - } + let changed = { + let mut comp = composite.borrow_mut(); + let mut changed = comp.bg.undo().unwrap_or(false); + if let Some(emblem) = &mut comp.emblem { + changed |= emblem.undo().unwrap_or(false); + } + changed + }; if changed { refresh_preview(); (rebuild_swatches.borrow())(); @@ -206,11 +209,14 @@ pub fn build_editor_page( let refresh_preview = refresh_preview.clone(); let rebuild_swatches = Rc::clone(&rebuild_swatches); redo_button.connect_clicked(move |_| { - let mut comp = composite.borrow_mut(); - let mut changed = comp.bg.redo().unwrap_or(false); - if let Some(emblem) = &mut comp.emblem { - changed |= emblem.redo().unwrap_or(false); - } + let changed = { + let mut comp = composite.borrow_mut(); + let mut changed = comp.bg.redo().unwrap_or(false); + if let Some(emblem) = &mut comp.emblem { + changed |= emblem.redo().unwrap_or(false); + } + changed + }; if changed { refresh_preview(); (rebuild_swatches.borrow())(); From d1719e58363a554bc6c2f8437a2a356ed8775273 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 21:42:55 -0400 Subject: [PATCH 19/34] fix: clean up unused variables and imports --- src/ui/editor.rs | 1 - src/ui/picker.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 58c5353..899620d 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -120,7 +120,6 @@ pub fn build_editor_page( let composite = Rc::clone(&composite); let swatches_box = swatches_box.clone(); let refresh_preview = refresh_preview.clone(); - let rebuild_swatches_for_closure = Rc::clone(&rebuild_swatches); let build_fn = move || { while let Some(child) = swatches_box.first_child() { diff --git a/src/ui/picker.rs b/src/ui/picker.rs index 945360d..f013ad6 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -1,5 +1,4 @@ use crate::icon_index::{filter_icons, list_svg_icons}; -use gtk::glib; use gtk::prelude::*; use gtk::{Box as GtkBox, FlowBox, Label, Orientation, Picture, ScrolledWindow, SearchEntry}; use std::cell::RefCell; From 84e236ea4ce52196f4a1b51f6b119479a7e8e9a6 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 22:02:21 -0400 Subject: [PATCH 20/34] fix: replace base64 images with nested svg to fix rasterization blurriness --- Cargo.lock | 7 ------- Cargo.toml | 1 - src/composite_icon.rs | 24 ++++++++++++++++++------ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59b8f42..a43c1d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,12 +23,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" - [[package]] name = "bitflags" version = "2.13.1" @@ -408,7 +402,6 @@ name = "icon-color-tool" version = "0.1.0" dependencies = [ "anyhow", - "base64", "gtk4", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 24d2182..99982a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,3 @@ regex = "1" serde = { version = "1", features = ["derive"] } toml = "0.8" walkdir = "2" -base64 = "0.23.0" diff --git a/src/composite_icon.rs b/src/composite_icon.rs index 00be610..5731ccd 100644 --- a/src/composite_icon.rs +++ b/src/composite_icon.rs @@ -25,15 +25,27 @@ impl CompositeIcon { pub fn render(&self) -> String { if let Some(emblem) = &self.emblem { - use base64::prelude::*; - let bg_b64 = BASE64_STANDARD.encode(&self.bg.content); - let emblem_b64 = BASE64_STANDARD.encode(&emblem.content); + fn strip_xml(svg: &str) -> &str { + if let Some(idx) = svg.find(" - - + +{} + + +{} + "#, - bg_b64, self.emblem_x, self.emblem_y, self.emblem_size, self.emblem_size, emblem_b64 + bg_svg, self.emblem_x, self.emblem_y, self.emblem_size, self.emblem_size, emblem_svg ) } else { self.bg.content.clone() From 80db9daf0bafdc5408aab09d220efac5df1af7a2 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 22:24:34 -0400 Subject: [PATCH 21/34] fix: deduplicate icons in browser by preferring highest resolution version --- src/composite_icon.rs | 79 ++++++++++++++++++++++++++++++++++--------- src/icon_index.rs | 43 ++++++++++++++++++++--- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/composite_icon.rs b/src/composite_icon.rs index 5731ccd..ea94d0b 100644 --- a/src/composite_icon.rs +++ b/src/composite_icon.rs @@ -25,28 +25,75 @@ impl CompositeIcon { pub fn render(&self) -> String { if let Some(emblem) = &self.emblem { - fn strip_xml(svg: &str) -> &str { - if let Some(idx) = svg.find(" (f64, f64) { + if let Some(re) = Regex::new(r#"(?i)]*viewBox\s*=\s*"([^"]+)""#).ok() { + if let Some(caps) = re.captures(svg) { + let parts: Vec<&str> = caps.get(1).unwrap().as_str().split_whitespace().collect(); + if parts.len() == 4 { + if let (Ok(w), Ok(h)) = (parts[2].parse::(), parts[3].parse::()) { + return (w, h); + } + } + } } + let mut w = 64.0; + let mut h = 64.0; + if let Some(re_w) = Regex::new(r#"(?i)]*width\s*=\s*"([^"a-zA-Z]+)[a-zA-Z]*""#).ok() { + if let Some(caps) = re_w.captures(svg) { + if let Ok(val) = caps.get(1).unwrap().as_str().trim().parse::() { + w = val; + } + } + } + if let Some(re_h) = Regex::new(r#"(?i)]*height\s*=\s*"([^"a-zA-Z]+)[a-zA-Z]*""#).ok() { + if let Some(caps) = re_h.captures(svg) { + if let Ok(val) = caps.get(1).unwrap().as_str().trim().parse::() { + h = val; + } + } + } + (w, h) } - let bg_svg = strip_xml(&self.bg.content); - let emblem_svg = strip_xml(&emblem.content); + fn extract_inner(svg: &str) -> &str { + let start = if let Some(idx) = svg.find("') { + idx + close_idx + 1 + } else { 0 } + } else { 0 }; + let end = if let Some(idx) = svg.rfind("") { idx } else { svg.len() }; + if start < end { &svg[start..end] } else { svg } + } + + let (bg_w, bg_h) = parse_dims(&self.bg.content); + let (em_w, em_h) = parse_dims(&emblem.content); - format!( - r#" - + let mapped_x = self.emblem_x * bg_w / 64.0; + let mapped_y = self.emblem_y * bg_h / 64.0; + let mapped_size = self.emblem_size * bg_w / 64.0; // Assuming uniform coordinate mapping scaling based on width + + let scale_x = mapped_size / em_w; + let scale_y = mapped_size / em_h; // Keep aspect ratio mapping relative to its own viewBox + + let emblem_inner = extract_inner(&emblem.content); + + let emblem_group = format!( + r#" {} - - -{} - + "#, - bg_svg, self.emblem_x, self.emblem_y, self.emblem_size, self.emblem_size, emblem_svg - ) + mapped_x, mapped_y, scale_x, scale_y, emblem_inner + ); + + if let Some(idx) = self.bg.content.rfind("") { + let mut new_svg = self.bg.content[..idx].to_string(); + new_svg.push_str(&emblem_group); + new_svg + } else { + self.bg.content.clone() // fallback + } } else { self.bg.content.clone() } diff --git a/src/icon_index.rs b/src/icon_index.rs index 901efab..a0319c4 100644 --- a/src/icon_index.rs +++ b/src/icon_index.rs @@ -2,13 +2,48 @@ use std::path::{Path, PathBuf}; use walkdir::WalkDir; pub fn list_svg_icons(root: &Path) -> Vec { - WalkDir::new(root) + use std::collections::HashMap; + let mut best_icons: HashMap = HashMap::new(); + + let walker = WalkDir::new(root) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file()) - .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")) - .map(|e| e.path().to_path_buf()) - .collect() + .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg")); + + for entry in walker { + let path = entry.path().to_path_buf(); + let mut components = path.components().rev(); + + let filename = if let Some(c) = components.next() { + c.as_os_str().to_string_lossy().into_owned() + } else { continue; }; + + let size_str = if let Some(c) = components.next() { + c.as_os_str().to_string_lossy().into_owned() + } else { continue; }; + + let category = if let Some(c) = components.next() { + c.as_os_str().to_string_lossy().into_owned() + } else { continue; }; + + let score = if size_str == "scalable" { + 10000 + } else if size_str.ends_with("x2") || size_str.ends_with("@2x") { + size_str.trim_matches(|c: char| !c.is_numeric()).parse::().unwrap_or(0) * 2 + } else { + size_str.parse::().unwrap_or(0) + }; + + let key = format!("{}/{}", category, filename); + + let entry = best_icons.entry(key).or_insert((score, path.clone())); + if score > entry.0 { + *entry = (score, path); + } + } + + best_icons.into_values().map(|(_, p)| p).collect() } pub fn fuzzy_match(query: &str, candidate: &str) -> bool { From d757917ccfd8aa58986c5935c16c8462f81d7a3d Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 22:43:47 -0400 Subject: [PATCH 22/34] feat: add drag-to-move emblem, center emblem origin, and fix editor preview blurriness --- src/composite_icon.rs | 4 ++-- src/ui/editor.rs | 44 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/composite_icon.rs b/src/composite_icon.rs index ea94d0b..82777c4 100644 --- a/src/composite_icon.rs +++ b/src/composite_icon.rs @@ -70,9 +70,9 @@ impl CompositeIcon { let (bg_w, bg_h) = parse_dims(&self.bg.content); let (em_w, em_h) = parse_dims(&emblem.content); - let mapped_x = self.emblem_x * bg_w / 64.0; - let mapped_y = self.emblem_y * bg_h / 64.0; let mapped_size = self.emblem_size * bg_w / 64.0; // Assuming uniform coordinate mapping scaling based on width + let mapped_x = (self.emblem_x * bg_w / 64.0) - (mapped_size / 2.0); + let mapped_y = (self.emblem_y * bg_h / 64.0) - (mapped_size / 2.0); let scale_x = mapped_size / em_w; let scale_y = mapped_size / em_h; // Keep aspect ratio mapping relative to its own viewBox diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 899620d..7a2457a 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -80,16 +80,52 @@ pub fn build_editor_page( let refresh_preview = { let composite = Rc::clone(&composite); let preview = preview.clone(); + let temp_path = std::env::temp_dir().join(format!("icon-color-tool-preview-{}.svg", std::process::id())); move || { - let bytes = glib::Bytes::from_owned(composite.borrow().render().into_bytes()); - match gtk::gdk::Texture::from_bytes(&bytes) { - Ok(texture) => preview.set_paintable(Some(&texture)), - Err(_) => preview.set_paintable(gtk::gdk::Paintable::NONE), + let svg_data = composite.borrow().render(); + if std::fs::write(&temp_path, svg_data).is_ok() { + let file = gtk::gio::File::for_path(&temp_path); + preview.set_file(Some(&file)); } } }; refresh_preview(); + { + let drag = gtk::GestureDrag::new(); + let initial_x = Rc::new(std::cell::Cell::new(0.0)); + let initial_y = Rc::new(std::cell::Cell::new(0.0)); + + drag.connect_drag_begin({ + let composite = Rc::clone(&composite); + let initial_x = initial_x.clone(); + let initial_y = initial_y.clone(); + move |_, _, _| { + initial_x.set(composite.borrow().emblem_x); + initial_y.set(composite.borrow().emblem_y); + } + }); + + drag.connect_drag_update({ + let scale_x = scale_x.clone(); + let scale_y = scale_y.clone(); + let preview = preview.clone(); + move |_, offset_x, offset_y| { + let width = preview.allocated_width() as f64; + let height = preview.allocated_height() as f64; + let size = width.min(height); + if size > 0.0 { + let dx = offset_x * 64.0 / size; + let dy = offset_y * 64.0 / size; + scale_x.set_value(initial_x.get() + dx); + scale_y.set_value(initial_y.get() + dy); + } + } + }); + + preview.add_controller(drag); + } + { let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); From 2db5ca2b2eef23b69ce9b29fc65e5568b4e9a3aa Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 22:56:33 -0400 Subject: [PATCH 23/34] fix: ensure editor scales SVG purely by stripping absolute dimensions, disable auto_save, and fix FlowBox columns --- src/composite_icon.rs | 28 +++++++++++++++++++++++----- src/main.rs | 3 ++- src/ui/editor.rs | 5 ++--- src/ui/picker.rs | 6 +++++- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/composite_icon.rs b/src/composite_icon.rs index 82777c4..7ef8d97 100644 --- a/src/composite_icon.rs +++ b/src/composite_icon.rs @@ -24,8 +24,25 @@ impl CompositeIcon { } pub fn render(&self) -> String { + use regex::Regex; + + let strip_dimensions = |svg: &str| -> String { + if let Some(root_svg_end) = svg.find('>') { + let mut root_tag = svg[..=root_svg_end].to_string(); + let re_w = Regex::new(r#"(?i)\s+width\s*=\s*"[^"]*""#).unwrap(); + root_tag = re_w.replace_all(&root_tag, "").into_owned(); + let re_h = Regex::new(r#"(?i)\s+height\s*=\s*"[^"]*""#).unwrap(); + root_tag = re_h.replace_all(&root_tag, "").into_owned(); + + let mut new_svg = root_tag; + new_svg.push_str(&svg[root_svg_end + 1..]); + new_svg + } else { + svg.to_string() + } + }; + if let Some(emblem) = &self.emblem { - use regex::Regex; fn parse_dims(svg: &str) -> (f64, f64) { if let Some(re) = Regex::new(r#"(?i)]*viewBox\s*=\s*"([^"]+)""#).ok() { @@ -87,15 +104,16 @@ impl CompositeIcon { mapped_x, mapped_y, scale_x, scale_y, emblem_inner ); - if let Some(idx) = self.bg.content.rfind("") { - let mut new_svg = self.bg.content[..idx].to_string(); + let mut base_svg = strip_dimensions(&self.bg.content); + if let Some(idx) = base_svg.rfind("") { + let mut new_svg = base_svg[..idx].to_string(); new_svg.push_str(&emblem_group); new_svg } else { - self.bg.content.clone() // fallback + base_svg } } else { - self.bg.content.clone() + strip_dimensions(&self.bg.content) } } } diff --git a/src/main.rs b/src/main.rs index 97d1ed5..821f545 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,13 +94,14 @@ fn build_picker(stack: &Stack, window: &ApplicationWindow, breeze_path: PathBuf) } fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, breeze_path: PathBuf) { - let scheme = match ColorScheme::load(&icon_path) { + let mut scheme = match ColorScheme::load(&icon_path) { Ok(s) => s, Err(e) => { eprintln!("Failed to open {}: {e:#}", icon_path.display()); return; } }; + scheme.auto_save = false; let composite = Rc::new(RefCell::new(CompositeIcon::new(scheme))); diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 7a2457a..1a31ea2 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -1,5 +1,4 @@ use crate::composite_icon::CompositeIcon; -use gtk::glib; use gtk::prelude::*; use gtk::{ Box as GtkBox, ColorDialog, ColorDialogButton, Label, Orientation, Picture, ScrolledWindow, @@ -111,8 +110,8 @@ pub fn build_editor_page( let scale_y = scale_y.clone(); let preview = preview.clone(); move |_, offset_x, offset_y| { - let width = preview.allocated_width() as f64; - let height = preview.allocated_height() as f64; + let width = preview.width() as f64; + let height = preview.height() as f64; let size = width.min(height); if size > 0.0 { let dx = offset_x * 64.0 / size; diff --git a/src/ui/picker.rs b/src/ui/picker.rs index f013ad6..76e1867 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -23,11 +23,15 @@ pub fn build_picker_page( let flow_box = FlowBox::new(); flow_box.set_valign(gtk::Align::Start); - flow_box.set_max_children_per_line(10); + flow_box.set_halign(gtk::Align::Fill); + flow_box.set_max_children_per_line(20); + flow_box.set_min_children_per_line(4); flow_box.set_selection_mode(gtk::SelectionMode::Single); let scroller = ScrolledWindow::new(); scroller.set_vexpand(true); + scroller.set_hexpand(true); + scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); scroller.set_child(Some(&flow_box)); root.append(&scroller); From e9b80817f9c9b1d83b6f5cf0a916bdc39f0ee833 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:03:24 -0400 Subject: [PATCH 24/34] feat: add unsaved changes confirmation prompts for window close and navigation using gtk::AlertDialog --- src/composite_icon.rs | 4 +- src/main.rs | 105 ++++++++++++++++++++++++++++++++++-------- src/ui/editor.rs | 39 ++++++++++++---- 3 files changed, 120 insertions(+), 28 deletions(-) diff --git a/src/composite_icon.rs b/src/composite_icon.rs index 7ef8d97..05d5a9d 100644 --- a/src/composite_icon.rs +++ b/src/composite_icon.rs @@ -6,6 +6,7 @@ pub struct CompositeIcon { pub emblem_x: f64, pub emblem_y: f64, pub emblem_size: f64, + pub is_modified: bool, } impl CompositeIcon { @@ -16,6 +17,7 @@ impl CompositeIcon { emblem_x: 32.0, emblem_y: 32.0, emblem_size: 32.0, + is_modified: false, } } @@ -104,7 +106,7 @@ impl CompositeIcon { mapped_x, mapped_y, scale_x, scale_y, emblem_inner ); - let mut base_svg = strip_dimensions(&self.bg.content); + let base_svg = strip_dimensions(&self.bg.content); if let Some(idx) = base_svg.rfind("") { let mut new_svg = base_svg[..idx].to_string(); new_svg.push_str(&emblem_group); diff --git a/src/main.rs b/src/main.rs index 821f545..af4badb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,10 +46,43 @@ fn main() -> glib::ExitCode { let stack = Stack::new(); window.set_child(Some(&stack)); - build_picker(&stack, &window, breeze_path.clone()); + let current_composite: Rc>>>> = Rc::new(RefCell::new(None)); + + { + let current_composite = Rc::clone(¤t_composite); + let window_clone = window.clone(); + window.connect_close_request(move |_| { + if let Some(comp) = current_composite.borrow().as_ref() { + if comp.borrow().is_modified { + let dialog = gtk::AlertDialog::builder() + .message("You have unsaved changes. Are you sure you want to close?") + .detail("All unsaved changes will be lost.") + .buttons(["Cancel", "Discard"]) + .cancel_button(0) + .default_button(1) + .build(); + + let window_clone2 = window_clone.clone(); + let comp_ref = Rc::clone(¤t_composite); + dialog.choose(Some(&window_clone), gtk::gio::Cancellable::NONE, move |res| { + if let Ok(1) = res { + if let Some(c) = comp_ref.borrow().as_ref() { + c.borrow_mut().is_modified = false; + } + window_clone2.close(); + } + }); + return glib::Propagation::Stop; + } + } + glib::Propagation::Proceed + }); + } + + build_picker(&stack, &window, breeze_path.clone(), Rc::clone(¤t_composite)); if let Some(Ok(path)) = initial_icon { - build_editor(&stack, &window, path, breeze_path); + build_editor(&stack, &window, path, breeze_path, Rc::clone(¤t_composite)); } window.present(); @@ -81,19 +114,20 @@ fn resolve_icon_arg(arg: &str, breeze_path: &Path) -> anyhow::Result { Ok(under_breeze.canonicalize()?) } -fn build_picker(stack: &Stack, window: &ApplicationWindow, breeze_path: PathBuf) { +fn build_picker(stack: &Stack, window: &ApplicationWindow, breeze_path: PathBuf, current_composite: Rc>>>>) { let stack_for_callback = stack.clone(); let window_for_callback = window.clone(); let breeze_for_editor = breeze_path.clone(); + let comp_callback = Rc::clone(¤t_composite); let widget = ui::picker::build_picker_page(breeze_path, move |icon_path| { - build_editor(&stack_for_callback, &window_for_callback, icon_path, breeze_for_editor.clone()); + build_editor(&stack_for_callback, &window_for_callback, icon_path, breeze_for_editor.clone(), Rc::clone(&comp_callback)); }); stack.add_named(&widget, Some("picker")); stack.set_visible_child_name("picker"); } -fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, breeze_path: PathBuf) { +fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, breeze_path: PathBuf, current_composite: Rc>>>>) { let mut scheme = match ColorScheme::load(&icon_path) { Ok(s) => s, Err(e) => { @@ -104,6 +138,7 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, b scheme.auto_save = false; let composite = Rc::new(RefCell::new(CompositeIcon::new(scheme))); + *current_composite.borrow_mut() = Some(Rc::clone(&composite)); let editor_container = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -112,6 +147,7 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, b let stack = stack.clone(); let window = window.clone(); let editor_container = editor_container.clone(); + let current_comp = Rc::clone(¤t_composite); Rc::new(move || { while let Some(child) = editor_container.first_child() { @@ -122,29 +158,57 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, b let stack_emblem = stack.clone(); let window_clone = window.clone(); let comp = Rc::clone(&composite); + let comp_for_back = Rc::clone(&comp); + let current_comp_for_back = Rc::clone(¤t_comp); let page = ui::editor::build_editor_page( Rc::clone(&composite), move || { - stack_clone.set_visible_child_name("picker"); + if comp_for_back.borrow().is_modified { + let dialog = gtk::AlertDialog::builder() + .message("You have unsaved changes. Are you sure you want to discard them?") + .detail("All unsaved changes will be lost.") + .buttons(["Cancel", "Discard"]) + .cancel_button(0) + .default_button(1) + .build(); + + let stack_clone = stack_clone.clone(); + let comp_ref = Rc::clone(¤t_comp_for_back); + dialog.choose(Some(&window_clone), gtk::gio::Cancellable::NONE, move |res| { + if let Ok(1) = res { + *comp_ref.borrow_mut() = None; + stack_clone.set_visible_child_name("picker"); + } + }); + } else { + *current_comp_for_back.borrow_mut() = None; + stack_clone.set_visible_child_name("picker"); + } }, move || { stack_emblem.set_visible_child_name("emblem_picker"); }, - move || { - let dialog = gtk::FileDialog::builder() - .title("Save Composite Icon As...") - .build(); - let c = Rc::clone(&comp); - dialog.save(Some(&window_clone), gtk::gio::Cancellable::NONE, move |res| { - if let Ok(file) = res { - if let Some(path) = file.path() { - if let Err(e) = std::fs::write(&path, c.borrow().render()) { - eprintln!("Failed to save composite to {}: {e}", path.display()); + { + let window_clone = window.clone(); + let comp = Rc::clone(&comp); + move || { + let dialog = gtk::FileDialog::builder() + .title("Save Composite Icon As...") + .build(); + let c = Rc::clone(&comp); + dialog.save(Some(&window_clone), gtk::gio::Cancellable::NONE, move |res| { + if let Ok(file) = res { + if let Some(path) = file.path() { + if let Err(e) = std::fs::write(&path, c.borrow().render()) { + eprintln!("Failed to save composite to {}: {e}", path.display()); + } else { + c.borrow_mut().is_modified = false; + } } } - } - }); + }); + } } ); editor_container.append(&page); @@ -165,7 +229,10 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, b let emblem_picker = ui::picker::build_picker_page(breeze_path, move |emblem_path| { if let Ok(mut emblem_scheme) = ColorScheme::load_with_namespace(&emblem_path, "Emblem") { emblem_scheme.auto_save = false; - comp_for_picker.borrow_mut().set_emblem(emblem_scheme); + let mut c = comp_for_picker.borrow_mut(); + c.set_emblem(emblem_scheme); + c.is_modified = true; + drop(c); rebuild_editor_clone(); stack_emblem_callback.set_visible_child_name("editor"); } else { diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 1a31ea2..233c2b5 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -129,24 +129,36 @@ pub fn build_editor_page( let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); scale_x.connect_value_changed(move |s| { - composite.borrow_mut().emblem_x = s.value(); - refresh_preview(); + let mut comp = composite.borrow_mut(); + if (comp.emblem_x - s.value()).abs() > f64::EPSILON { + comp.emblem_x = s.value(); + comp.is_modified = true; + refresh_preview(); + } }); } { let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); scale_y.connect_value_changed(move |s| { - composite.borrow_mut().emblem_y = s.value(); - refresh_preview(); + let mut comp = composite.borrow_mut(); + if (comp.emblem_y - s.value()).abs() > f64::EPSILON { + comp.emblem_y = s.value(); + comp.is_modified = true; + refresh_preview(); + } }); } { let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); scale_size.connect_value_changed(move |s| { - composite.borrow_mut().emblem_size = s.value(); - refresh_preview(); + let mut comp = composite.borrow_mut(); + if (comp.emblem_size - s.value()).abs() > f64::EPSILON { + comp.emblem_size = s.value(); + comp.is_modified = true; + refresh_preview(); + } }); } @@ -197,11 +209,16 @@ pub fn build_editor_page( ); let changed = { let mut comp = composite.borrow_mut(); + let res; if is_emblem { - comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false) + res = comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false); } else { - comp.bg.update_color(&tag, &new_hex).unwrap_or(false) + res = comp.bg.update_color(&tag, &new_hex).unwrap_or(false); } + if res { + comp.is_modified = true; + } + res }; if changed { refresh_preview(); @@ -230,6 +247,9 @@ pub fn build_editor_page( if let Some(emblem) = &mut comp.emblem { changed |= emblem.undo().unwrap_or(false); } + if changed { + comp.is_modified = true; + } changed }; if changed { @@ -249,6 +269,9 @@ pub fn build_editor_page( if let Some(emblem) = &mut comp.emblem { changed |= emblem.redo().unwrap_or(false); } + if changed { + comp.is_modified = true; + } changed }; if changed { From 35f0af2188fb13c9bd33e3bcd81e1e7097c44fa8 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:05:17 -0400 Subject: [PATCH 25/34] fix: drop RefCell borrow in slider callbacks before calling refresh_preview --- src/ui/editor.rs | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 233c2b5..a71c3c4 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -129,10 +129,16 @@ pub fn build_editor_page( let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); scale_x.connect_value_changed(move |s| { - let mut comp = composite.borrow_mut(); - if (comp.emblem_x - s.value()).abs() > f64::EPSILON { - comp.emblem_x = s.value(); - comp.is_modified = true; + let mut changed = false; + { + let mut comp = composite.borrow_mut(); + if (comp.emblem_x - s.value()).abs() > f64::EPSILON { + comp.emblem_x = s.value(); + comp.is_modified = true; + changed = true; + } + } + if changed { refresh_preview(); } }); @@ -141,10 +147,16 @@ pub fn build_editor_page( let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); scale_y.connect_value_changed(move |s| { - let mut comp = composite.borrow_mut(); - if (comp.emblem_y - s.value()).abs() > f64::EPSILON { - comp.emblem_y = s.value(); - comp.is_modified = true; + let mut changed = false; + { + let mut comp = composite.borrow_mut(); + if (comp.emblem_y - s.value()).abs() > f64::EPSILON { + comp.emblem_y = s.value(); + comp.is_modified = true; + changed = true; + } + } + if changed { refresh_preview(); } }); @@ -153,10 +165,16 @@ pub fn build_editor_page( let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); scale_size.connect_value_changed(move |s| { - let mut comp = composite.borrow_mut(); - if (comp.emblem_size - s.value()).abs() > f64::EPSILON { - comp.emblem_size = s.value(); - comp.is_modified = true; + let mut changed = false; + { + let mut comp = composite.borrow_mut(); + if (comp.emblem_size - s.value()).abs() > f64::EPSILON { + comp.emblem_size = s.value(); + comp.is_modified = true; + changed = true; + } + } + if changed { refresh_preview(); } }); From f45eb5e70af3ed13196234fd476b876922b1fe10 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:09:54 -0400 Subject: [PATCH 26/34] fix: drop RefCell borrow during SVG saving and configure FileDialog to default to SVG --- src/main.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index af4badb..9de6349 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,14 +193,25 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, b let window_clone = window.clone(); let comp = Rc::clone(&comp); move || { + let filter = gtk::FileFilter::new(); + filter.set_name(Some("SVG Images")); + filter.add_mime_type("image/svg+xml"); + filter.add_pattern("*.svg"); + + let filters = gtk::gio::ListStore::new::(); + filters.append(&filter); + let dialog = gtk::FileDialog::builder() .title("Save Composite Icon As...") + .filters(&filters) + .default_filter(&filter) .build(); let c = Rc::clone(&comp); dialog.save(Some(&window_clone), gtk::gio::Cancellable::NONE, move |res| { if let Ok(file) = res { if let Some(path) = file.path() { - if let Err(e) = std::fs::write(&path, c.borrow().render()) { + let rendered = c.borrow().render(); + if let Err(e) = std::fs::write(&path, rendered) { eprintln!("Failed to save composite to {}: {e}", path.display()); } else { c.borrow_mut().is_modified = false; From 9bd6189e1085097fd51eef055da08797600ae166 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:15:36 -0400 Subject: [PATCH 27/34] fix: increase default window height to prevent color picker popover scrollbars --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 9de6349..bddc2ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ fn main() -> glib::ExitCode { .application(app) .title("Icon Color Tool") .default_width(900) - .default_height(650) + .default_height(800) .build(); let stack = Stack::new(); From 1e4154ab90c52c781ca8999f806f1216cf7bb84d Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:20:13 -0400 Subject: [PATCH 28/34] fix: remove with_alpha(false) from ColorDialog to bypass GTK4 popover sizing bug --- src/ui/editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index a71c3c4..b23173d 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -209,7 +209,7 @@ pub fn build_editor_page( label.set_xalign(0.0); let rgba = gtk::gdk::RGBA::parse(&hex).unwrap_or(gtk::gdk::RGBA::BLACK); - let dialog = ColorDialog::builder().with_alpha(false).build(); + let dialog = ColorDialog::builder().build(); let button = ColorDialogButton::new(Some(dialog)); button.set_rgba(&rgba); From 3a2443c859d056a7e1db24ea95ab96fffe2b69c1 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:26:51 -0400 Subject: [PATCH 29/34] fix: replace GTK4 ColorDialogButton popovers with custom GTK buttons that spawn full native ColorDialog windows to avoid unresizing popovers --- src/ui/editor.rs | 87 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index b23173d..e904cf2 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -1,7 +1,7 @@ use crate::composite_icon::CompositeIcon; use gtk::prelude::*; use gtk::{ - Box as GtkBox, ColorDialog, ColorDialogButton, Label, Orientation, Picture, ScrolledWindow, + Box as GtkBox, ColorDialog, Label, Orientation, Picture, ScrolledWindow, }; use std::cell::RefCell; use std::rc::Rc; @@ -209,38 +209,71 @@ pub fn build_editor_page( label.set_xalign(0.0); let rgba = gtk::gdk::RGBA::parse(&hex).unwrap_or(gtk::gdk::RGBA::BLACK); - let dialog = ColorDialog::builder().build(); - let button = ColorDialogButton::new(Some(dialog)); - button.set_rgba(&rgba); + let current_rgba = Rc::new(RefCell::new(rgba)); + + let drawing_area = gtk::DrawingArea::new(); + drawing_area.set_size_request(40, 24); + { + let current_rgba = Rc::clone(¤t_rgba); + drawing_area.set_draw_func(move |_, cr, width, height| { + let c = current_rgba.borrow(); + cr.set_source_rgba(c.red() as f64, c.green() as f64, c.blue() as f64, 1.0); + cr.rectangle(0.0, 0.0, width as f64, height as f64); + cr.fill().expect("fill"); + }); + } + + let button = gtk::Button::builder() + .child(&drawing_area) + .build(); + + let dialog = ColorDialog::builder().title("Pick a Color").build(); { let composite = Rc::clone(&composite); let tag = tag.clone(); let refresh_preview = refresh_preview.clone(); - button.connect_rgba_notify(move |btn| { - let new_rgba = btn.rgba(); - let new_hex = format!( - "#{:02x}{:02x}{:02x}", - (new_rgba.red() * 255.0).round() as u8, - (new_rgba.green() * 255.0).round() as u8, - (new_rgba.blue() * 255.0).round() as u8, - ); - let changed = { - let mut comp = composite.borrow_mut(); - let res; - if is_emblem { - res = comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false); - } else { - res = comp.bg.update_color(&tag, &new_hex).unwrap_or(false); + let current_rgba = Rc::clone(¤t_rgba); + let drawing_area = drawing_area.clone(); + + button.connect_clicked(move |btn| { + let dialog = dialog.clone(); + let tag = tag.clone(); + let composite = Rc::clone(&composite); + let refresh_preview = refresh_preview.clone(); + let current_rgba = Rc::clone(¤t_rgba); + let drawing_area = drawing_area.clone(); + let parent_window = btn.root().and_downcast::(); + let initial_color = current_rgba.borrow().clone(); + + dialog.choose_rgba(parent_window.as_ref(), Some(&initial_color), gtk::gio::Cancellable::NONE, move |res| { + if let Ok(new_rgba) = res { + let new_hex = format!( + "#{:02x}{:02x}{:02x}", + (new_rgba.red() * 255.0).round() as u8, + (new_rgba.green() * 255.0).round() as u8, + (new_rgba.blue() * 255.0).round() as u8, + ); + let changed = { + let mut comp = composite.borrow_mut(); + let res; + if is_emblem { + res = comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false); + } else { + res = comp.bg.update_color(&tag, &new_hex).unwrap_or(false); + } + if res { + comp.is_modified = true; + } + res + }; + if changed { + *current_rgba.borrow_mut() = new_rgba; + drawing_area.queue_draw(); + refresh_preview(); + } } - if res { - comp.is_modified = true; - } - res - }; - if changed { - refresh_preview(); - } + }); }); } From b9f80fa8fa2d335018d2a2cf13e9bfd81aa7526d Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:31:00 -0400 Subject: [PATCH 30/34] fix: use legacy ColorChooserDialog to sidestep GTK4 ColorDialog popover bug completely --- src/ui/editor.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index e904cf2..132827e 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -1,7 +1,7 @@ use crate::composite_icon::CompositeIcon; use gtk::prelude::*; use gtk::{ - Box as GtkBox, ColorDialog, Label, Orientation, Picture, ScrolledWindow, + Box as GtkBox, Label, Orientation, Picture, ScrolledWindow, }; use std::cell::RefCell; use std::rc::Rc; @@ -227,8 +227,6 @@ pub fn build_editor_page( .child(&drawing_area) .build(); - let dialog = ColorDialog::builder().title("Pick a Color").build(); - { let composite = Rc::clone(&composite); let tag = tag.clone(); @@ -237,7 +235,6 @@ pub fn build_editor_page( let drawing_area = drawing_area.clone(); button.connect_clicked(move |btn| { - let dialog = dialog.clone(); let tag = tag.clone(); let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); @@ -246,8 +243,13 @@ pub fn build_editor_page( let parent_window = btn.root().and_downcast::(); let initial_color = current_rgba.borrow().clone(); - dialog.choose_rgba(parent_window.as_ref(), Some(&initial_color), gtk::gio::Cancellable::NONE, move |res| { - if let Ok(new_rgba) = res { + let dialog = gtk::ColorChooserDialog::new(Some("Pick a Color"), parent_window.as_ref()); + dialog.set_use_alpha(false); + dialog.set_rgba(&initial_color); + + dialog.connect_response(move |d, response| { + if response == gtk::ResponseType::Ok { + let new_rgba = d.rgba(); let new_hex = format!( "#{:02x}{:02x}{:02x}", (new_rgba.red() * 255.0).round() as u8, @@ -273,7 +275,10 @@ pub fn build_editor_page( refresh_preview(); } } + d.destroy(); }); + + dialog.show(); }); } From f0f3cdfaf549ed4647e62a8c8323e90bfe0f637c Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:36:55 -0400 Subject: [PATCH 31/34] fix: explicitly set default_size on ColorChooserDialog to override GTK4's bad height constraints --- src/ui/editor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 132827e..1391ff3 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -246,6 +246,7 @@ pub fn build_editor_page( let dialog = gtk::ColorChooserDialog::new(Some("Pick a Color"), parent_window.as_ref()); dialog.set_use_alpha(false); dialog.set_rgba(&initial_color); + dialog.set_default_size(600, 600); dialog.connect_response(move |d, response| { if response == gtk::ResponseType::Ok { From a12c17d89617110d1e12700177bbda017a27999c Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:40:07 -0400 Subject: [PATCH 32/34] fix: use custom MenuButton and Popover with ColorChooserWidget explicitly sized to bypass Wayland desktop portal popover layout constraints --- src/ui/editor.rs | 82 +++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 1391ff3..f7cd08a 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -223,63 +223,51 @@ pub fn build_editor_page( }); } - let button = gtk::Button::builder() + let popover = gtk::Popover::new(); + let chooser = gtk::ColorChooserWidget::new(); + chooser.set_use_alpha(false); + chooser.set_rgba(&*current_rgba.borrow()); + chooser.set_size_request(350, 450); + popover.set_child(Some(&chooser)); + + let button = gtk::MenuButton::builder() .child(&drawing_area) + .popover(&popover) .build(); { - let composite = Rc::clone(&composite); let tag = tag.clone(); + let composite = Rc::clone(&composite); let refresh_preview = refresh_preview.clone(); let current_rgba = Rc::clone(¤t_rgba); let drawing_area = drawing_area.clone(); - button.connect_clicked(move |btn| { - let tag = tag.clone(); - let composite = Rc::clone(&composite); - let refresh_preview = refresh_preview.clone(); - let current_rgba = Rc::clone(¤t_rgba); - let drawing_area = drawing_area.clone(); - let parent_window = btn.root().and_downcast::(); - let initial_color = current_rgba.borrow().clone(); - - let dialog = gtk::ColorChooserDialog::new(Some("Pick a Color"), parent_window.as_ref()); - dialog.set_use_alpha(false); - dialog.set_rgba(&initial_color); - dialog.set_default_size(600, 600); - - dialog.connect_response(move |d, response| { - if response == gtk::ResponseType::Ok { - let new_rgba = d.rgba(); - let new_hex = format!( - "#{:02x}{:02x}{:02x}", - (new_rgba.red() * 255.0).round() as u8, - (new_rgba.green() * 255.0).round() as u8, - (new_rgba.blue() * 255.0).round() as u8, - ); - let changed = { - let mut comp = composite.borrow_mut(); - let res; - if is_emblem { - res = comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false); - } else { - res = comp.bg.update_color(&tag, &new_hex).unwrap_or(false); - } - if res { - comp.is_modified = true; - } - res - }; - if changed { - *current_rgba.borrow_mut() = new_rgba; - drawing_area.queue_draw(); - refresh_preview(); - } + chooser.connect_rgba_notify(move |c| { + let new_rgba = c.rgba(); + let new_hex = format!( + "#{:02x}{:02x}{:02x}", + (new_rgba.red() * 255.0).round() as u8, + (new_rgba.green() * 255.0).round() as u8, + (new_rgba.blue() * 255.0).round() as u8, + ); + let changed = { + let mut comp = composite.borrow_mut(); + let res; + if is_emblem { + res = comp.emblem.as_mut().unwrap().update_color(&tag, &new_hex).unwrap_or(false); + } else { + res = comp.bg.update_color(&tag, &new_hex).unwrap_or(false); } - d.destroy(); - }); - - dialog.show(); + if res { + comp.is_modified = true; + } + res + }; + if changed { + *current_rgba.borrow_mut() = new_rgba; + drawing_area.queue_draw(); + refresh_preview(); + } }); } From 59238f5a0917131648605d482d8fa6940661bacb Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:42:42 -0400 Subject: [PATCH 33/34] fix: remove explicit size_request from ColorChooserWidget to allow natural popover sizing --- src/ui/editor.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/editor.rs b/src/ui/editor.rs index f7cd08a..eefef42 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -227,7 +227,6 @@ pub fn build_editor_page( let chooser = gtk::ColorChooserWidget::new(); chooser.set_use_alpha(false); chooser.set_rgba(&*current_rgba.borrow()); - chooser.set_size_request(350, 450); popover.set_child(Some(&chooser)); let button = gtk::MenuButton::builder() From 6cc4a019b8aedf761bfb21ce88b68be53e458d31 Mon Sep 17 00:00:00 2001 From: rootiest Date: Mon, 3 Aug 2026 23:53:40 -0400 Subject: [PATCH 34/34] docs: sync README with new emblem and editor features --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a558bdf..c4c43b7 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ icon-color-tool path/to/icon.svg # opens directly in the color editor ``` The picker's search box does a fuzzy (subsequence) match against each -icon's path. Selecting an icon (or passing one directly) opens the color -editor: click a swatch to pick a new color for that `ColorScheme-*` class; -changes save to disk immediately, and Undo/Redo are available from the -toolbar. +icon's path, automatically prioritizing the highest-resolution version of each icon. +Selecting an icon (or passing one directly) opens the color editor: +- **Color Editing**: Click a swatch to pick a new color for that `ColorScheme-*` class. +- **Emblem Support**: Click "Add Emblem" to overlay another SVG on top of your background icon. You can drag to position it visually, or use sliders to adjust its X, Y, and Size. The tool automatically namespaces the emblem's CSS so you can color its layers independently from the background. +- **Saving**: Source files are preserved. Modifications are kept in memory until you use the "Save As..." button to export a new SVG. Unsaved changes are protected by a confirmation prompt if you try to exit. Undo/Redo are available from the toolbar. ## Configuration