4 Commits
Author SHA1 Message Date
rootiest 4145029ed4 chore(submodules): remove breeze-icons - downloads automatically
Build and Release / build (push) Successful in 16m22s
2026-08-06 01:04:39 -04:00
rootiest 77399f938a Merge pull request 'feat(ui): add custom SVG picker and icon dir support' (#4) from feat-custom-icons into main
Build and Release / build (push) Successful in 2m5s
Reviewed-on: #4
2026-08-06 04:55:58 +00:00
rootiest 2906bdad03 Merge branch 'main' into feat-custom-icons 2026-08-06 04:55:40 +00:00
rootiest a4dfef304a feat(ui): add custom SVG picker and icon dir support 2026-08-06 00:47:29 -04:00
8 changed files with 130 additions and 27 deletions
+24
View File
@@ -1,3 +1,5 @@
#### Submodules
breeze-icons/
# ──────────────── Added by agents-init ────────────────── # ──────────────── Added by agents-init ──────────────────
# agents-init --agents # agents-init --agents
@@ -14,3 +16,25 @@ docs/specs
docs/devlogs docs/devlogs
# ──────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────
/target /target
# id: gi-patterns-b05185fcb02652ad80fb49aa015b2ae8
# Created by https://www.toptal.com/developers/gitignore/api/rust
# Edit at https://www.toptal.com/developers/gitignore?templates=rust
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# End of https://www.toptal.com/developers/gitignore/api/rust
+2
View File
@@ -7,6 +7,8 @@ embedded in [Breeze icon](https://github.com/KDE/breeze-icons) SVGs.
```sh ```sh
icon-color-tool # opens a searchable icon picker icon-color-tool # opens a searchable icon picker
icon-color-tool -h # print colorful help output
icon-color-tool -d path/to/icons # use a custom icon directory
icon-color-tool path/to/icon.svg # opens directly in the color editor icon-color-tool path/to/icon.svg # opens directly in the color editor
``` ```
Submodule breeze-icons deleted from f8d528544f
+3 -3
View File
@@ -63,7 +63,7 @@ impl CompositeIcon {
if let Some(emblem) = &self.emblem { if let Some(emblem) = &self.emblem {
fn parse_dims(svg: &str) -> (f64, f64) { fn parse_dims(svg: &str) -> (f64, f64) {
if let Some(re) = Regex::new(r#"(?i)<svg[^>]*viewBox\s*=\s*"([^"]+)""#).ok() { if let Ok(re) = Regex::new(r#"(?i)<svg[^>]*viewBox\s*=\s*"([^"]+)""#) {
if let Some(caps) = re.captures(svg) { if let Some(caps) = re.captures(svg) {
let parts: Vec<&str> = caps.get(1).unwrap().as_str().split_whitespace().collect(); let parts: Vec<&str> = caps.get(1).unwrap().as_str().split_whitespace().collect();
if parts.len() == 4 { if parts.len() == 4 {
@@ -75,14 +75,14 @@ impl CompositeIcon {
} }
let mut w = 64.0; let mut w = 64.0;
let mut h = 64.0; let mut h = 64.0;
if let Some(re_w) = Regex::new(r#"(?i)<svg[^>]*width\s*=\s*"([^"a-zA-Z]+)[a-zA-Z]*""#).ok() { if let Ok(re_w) = Regex::new(r#"(?i)<svg[^>]*width\s*=\s*"([^"a-zA-Z]+)[a-zA-Z]*""#) {
if let Some(caps) = re_w.captures(svg) { if let Some(caps) = re_w.captures(svg) {
if let Ok(val) = caps.get(1).unwrap().as_str().trim().parse::<f64>() { if let Ok(val) = caps.get(1).unwrap().as_str().trim().parse::<f64>() {
w = val; w = val;
} }
} }
} }
if let Some(re_h) = Regex::new(r#"(?i)<svg[^>]*height\s*=\s*"([^"a-zA-Z]+)[a-zA-Z]*""#).ok() { if let Ok(re_h) = Regex::new(r#"(?i)<svg[^>]*height\s*=\s*"([^"a-zA-Z]+)[a-zA-Z]*""#) {
if let Some(caps) = re_h.captures(svg) { if let Some(caps) = re_h.captures(svg) {
if let Ok(val) = caps.get(1).unwrap().as_str().trim().parse::<f64>() { if let Ok(val) = caps.get(1).unwrap().as_str().trim().parse::<f64>() {
h = val; h = val;
+4 -4
View File
@@ -37,11 +37,11 @@ pub fn list_svg_icons(root: &Path) -> Vec<PathBuf> {
let size_str = if let Some(c) = components.next() { let size_str = if let Some(c) = components.next() {
c.as_os_str().to_string_lossy().into_owned() c.as_os_str().to_string_lossy().into_owned()
} else { continue; }; } else { String::new() };
let category = if let Some(c) = components.next() { let category = if let Some(c) = components.next() {
c.as_os_str().to_string_lossy().into_owned() c.as_os_str().to_string_lossy().into_owned()
} else { continue; }; } else { String::new() };
let score = if size_str == "scalable" { let score = if size_str == "scalable" {
10000 10000
@@ -75,8 +75,8 @@ pub fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
let mut q_char = q_chars.next().unwrap(); let mut q_char = q_chars.next().unwrap();
let mut match_indices = Vec::new(); let mut match_indices = Vec::new();
let mut chars = candidate_lower.char_indices(); let chars = candidate_lower.char_indices();
while let Some((i, c)) = chars.next() { for (i, c) in chars {
if c == q_char { if c == q_char {
match_indices.push(i); match_indices.push(i);
if let Some(next_q) = q_chars.next() { if let Some(next_q) = q_chars.next() {
+46 -11
View File
@@ -31,18 +31,52 @@ use std::rc::Rc;
const APP_ID: &str = "dev.rootiest.IconColorTool"; const APP_ID: &str = "dev.rootiest.IconColorTool";
fn main() -> glib::ExitCode { fn main() -> glib::ExitCode {
let mut args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
println!("\x1b[1;36micon-color-tool\x1b[0m - Edit the ColorScheme-* CSS colors in KDE Breeze icons\n");
println!("\x1b[1;33mUSAGE:\x1b[0m");
println!(" icon-color-tool [OPTIONS] [FILE]\n");
println!("\x1b[1;33mARGS:\x1b[0m");
println!(" \x1b[32m<FILE>\x1b[0m Open a specific SVG file directly in the color editor\n");
println!("\x1b[1;33mOPTIONS:\x1b[0m");
println!(" \x1b[32m-d, --icon-dir <DIR>\x1b[0m Specify a custom directory to source icons from");
println!(" \x1b[32m-h, --help\x1b[0m Print help information");
std::process::exit(0);
}
let mut custom_icon_dir = None;
let mut i = 1;
while i < args.len() {
if (args[i] == "--icon-dir" || args[i] == "-d") && i + 1 < args.len() {
custom_icon_dir = Some(PathBuf::from(&args[i + 1]));
args.remove(i);
args.remove(i);
continue;
}
i += 1;
}
let initial_arg = args.get(1).cloned();
let app = Application::builder().application_id(APP_ID).build(); let app = Application::builder().application_id(APP_ID).build();
app.connect_activate(|app| { app.connect_activate(move |app| {
let breeze_path = match config::load_breeze_path() { let breeze_path = match &custom_icon_dir {
Ok(p) => p, Some(p) => {
Err(e) => { if !p.exists() {
eprintln!("Failed to resolve breeze-icons path: {e:#}"); eprintln!("Error: Icon directory {} does not exist", p.display());
std::process::exit(1); std::process::exit(1);
}
p.clone()
} }
None => 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 let initial_icon = initial_arg
.as_deref() .as_deref()
.map(|a| resolve_icon_arg(a, &breeze_path)); .map(|a| resolve_icon_arg(a, &breeze_path));
@@ -104,7 +138,7 @@ fn main() -> glib::ExitCode {
window.present(); window.present();
}); });
app.run() app.run_with_args(&args)
} }
fn resolve_icon_arg(arg: &str, breeze_path: &Path) -> anyhow::Result<PathBuf> { fn resolve_icon_arg(arg: &str, breeze_path: &Path) -> anyhow::Result<PathBuf> {
@@ -136,8 +170,9 @@ fn build_picker(stack: &Stack, window: &ApplicationWindow, breeze_path: PathBuf,
let breeze_for_editor = breeze_path.clone(); let breeze_for_editor = breeze_path.clone();
let comp_callback = Rc::clone(&current_composite); let comp_callback = Rc::clone(&current_composite);
let widget = ui::picker::build_picker_page(breeze_path, "folder.svg", move |icon_path| { let window_for_closure = window_for_callback.clone();
build_editor(&stack_for_callback, &window_for_callback, icon_path, breeze_for_editor.clone(), Rc::clone(&comp_callback)); let widget = ui::picker::build_picker_page(&window_for_callback, breeze_path, "folder.svg", move |icon_path| {
build_editor(&stack_for_callback, &window_for_closure, icon_path, breeze_for_editor.clone(), Rc::clone(&comp_callback));
}); });
stack.add_named(&widget, Some("picker")); stack.add_named(&widget, Some("picker"));
stack.set_visible_child_name("picker"); stack.set_visible_child_name("picker");
@@ -253,7 +288,7 @@ fn build_editor(stack: &Stack, window: &ApplicationWindow, icon_path: PathBuf, b
let stack_emblem_callback = stack.clone(); let stack_emblem_callback = stack.clone();
let comp_for_picker = Rc::clone(&composite); let comp_for_picker = Rc::clone(&composite);
let emblem_picker = ui::picker::build_picker_page(breeze_path, "symbolic", move |emblem_path| { let emblem_picker = ui::picker::build_picker_page(window, breeze_path, "symbolic", move |emblem_path| {
if let Ok(mut emblem_scheme) = ColorScheme::load_with_namespace(&emblem_path, "Emblem") { if let Ok(mut emblem_scheme) = ColorScheme::load_with_namespace(&emblem_path, "Emblem") {
emblem_scheme.auto_save = false; emblem_scheme.auto_save = false;
let mut c = comp_for_picker.borrow_mut(); let mut c = comp_for_picker.borrow_mut();
+10 -6
View File
@@ -240,9 +240,12 @@ pub fn build_editor_page(
} }
let popover = gtk::Popover::new(); let popover = gtk::Popover::new();
#[allow(deprecated)]
let chooser = gtk::ColorChooserWidget::new(); let chooser = gtk::ColorChooserWidget::new();
#[allow(deprecated)]
chooser.set_use_alpha(false); chooser.set_use_alpha(false);
chooser.set_rgba(&*current_rgba.borrow()); #[allow(deprecated)]
chooser.set_rgba(&current_rgba.borrow());
popover.set_child(Some(&chooser)); popover.set_child(Some(&chooser));
let button = gtk::MenuButton::builder() let button = gtk::MenuButton::builder()
@@ -257,7 +260,9 @@ pub fn build_editor_page(
let current_rgba = Rc::clone(&current_rgba); let current_rgba = Rc::clone(&current_rgba);
let drawing_area = drawing_area.clone(); let drawing_area = drawing_area.clone();
#[allow(deprecated)]
chooser.connect_rgba_notify(move |c| { chooser.connect_rgba_notify(move |c| {
#[allow(deprecated)]
let new_rgba = c.rgba(); let new_rgba = c.rgba();
let new_hex = format!( let new_hex = format!(
"#{:02x}{:02x}{:02x}", "#{:02x}{:02x}{:02x}",
@@ -267,12 +272,11 @@ pub fn build_editor_page(
); );
let changed = { let changed = {
let mut comp = composite.borrow_mut(); let mut comp = composite.borrow_mut();
let res; let res = if is_emblem {
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 { } else {
res = comp.bg.update_color(&tag, &new_hex).unwrap_or(false); comp.bg.update_color(&tag, &new_hex).unwrap_or(false)
} };
if res { if res {
comp.is_modified = true; comp.is_modified = true;
} }
+41 -2
View File
@@ -24,10 +24,13 @@ use std::rc::Rc;
const RESULT_LIMIT: usize = 300; const RESULT_LIMIT: usize = 300;
pub fn build_picker_page( pub fn build_picker_page(
window: &gtk::ApplicationWindow,
breeze_root: PathBuf, breeze_root: PathBuf,
initial_query: &str, initial_query: &str,
on_select: impl Fn(PathBuf) + 'static, on_select: impl Fn(PathBuf) + 'static,
) -> gtk::Widget { ) -> gtk::Widget {
let on_select = Rc::new(on_select) as Rc<dyn Fn(PathBuf)>;
let root = GtkBox::new(Orientation::Vertical, 8); let root = GtkBox::new(Orientation::Vertical, 8);
root.set_margin_top(8); root.set_margin_top(8);
root.set_margin_bottom(8); root.set_margin_bottom(8);
@@ -39,7 +42,42 @@ pub fn build_picker_page(
search.set_text(initial_query); search.set_text(initial_query);
} }
search.set_placeholder_text(Some("Search icons…")); search.set_placeholder_text(Some("Search icons…"));
root.append(&search); search.set_hexpand(true);
let open_button = gtk::Button::with_label("Open SVG…");
{
let window_clone = window.clone();
let on_select_clone = Rc::clone(&on_select);
open_button.connect_clicked(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::<gtk::FileFilter>();
filters.append(&filter);
let dialog = gtk::FileDialog::builder()
.title("Open SVG")
.filters(&filters)
.default_filter(&filter)
.build();
let on_select_clone2 = Rc::clone(&on_select_clone);
dialog.open(Some(&window_clone), gtk::gio::Cancellable::NONE, move |res| {
if let Ok(file) = res {
if let Some(path) = file.path() {
on_select_clone2(path);
}
}
});
});
}
let search_box = GtkBox::new(Orientation::Horizontal, 8);
search_box.append(&search);
search_box.append(&open_button);
root.append(&search_box);
let flow_box = FlowBox::new(); let flow_box = FlowBox::new();
flow_box.set_valign(gtk::Align::Start); flow_box.set_valign(gtk::Align::Start);
@@ -63,13 +101,14 @@ pub fn build_picker_page(
{ {
let current_matches = Rc::clone(&current_matches); let current_matches = Rc::clone(&current_matches);
let on_select_clone = Rc::clone(&on_select);
flow_box.connect_child_activated(move |_, child| { flow_box.connect_child_activated(move |_, child| {
let index = child.index(); let index = child.index();
if index < 0 { if index < 0 {
return; return;
} }
if let Some(path) = current_matches.borrow().get(index as usize) { if let Some(path) = current_matches.borrow().get(index as usize) {
on_select(path.clone()); on_select_clone(path.clone());
} }
}); });
} }