feat: Icon overlay and editor improvements #1

Merged
rootiest merged 34 commits from feature/icon-overlay into main 2026-08-04 03:57:24 +00:00
22 changed files with 2337 additions and 335 deletions
+39
View File
@@ -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.
+1
View File
@@ -13,3 +13,4 @@ docs/plans
docs/specs
docs/devlogs
# ────────────────────────────────────────────────────────
/target
Generated
+813
View File
@@ -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",
]
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "icon-color-tool"
version = "0.1.0"
edition = "2021"
[dependencies]
gtk = { package = "gtk4", version = "0.11", features = ["v4_12"] }
anyhow = "1"
regex = "1"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
walkdir = "2"
+47
View File
@@ -0,0 +1,47 @@
# 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, 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
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
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
-45
View File
@@ -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
-141
View File
@@ -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()
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
python3 main.py
-89
View File
@@ -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()
-1
View File
@@ -1 +0,0 @@
PyQt6
+303
View File
@@ -0,0 +1,303 @@
use anyhow::{Context, Result};
use regex::Regex;
use std::path::PathBuf;
pub struct ColorScheme {
pub path: PathBuf,
pub content: String,
pub colors: Vec<(String, String)>,
history: Vec<String>,
redo_history: Vec<String>,
pub auto_save: bool,
}
impl ColorScheme {
pub fn load(path: impl Into<PathBuf>) -> Result<Self> {
Self::load_with_namespace(path, "")
}
pub fn load_with_namespace(path: impl Into<PathBuf>, namespace: &str) -> Result<Self> {
let path = path.into();
let mut content = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
if !namespace.is_empty() {
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-"));
}
let colors = parse_colors(&content);
Ok(Self {
path,
content,
colors,
history: Vec::new(),
redo_history: Vec::new(),
auto_save: true,
})
}
pub fn update_color(&mut self, tag: &str, new_hex: &str) -> Result<bool> {
let Some(old_hex) = self
.colors
.iter()
.find(|(t, _)| t == tag)
.map(|(_, h)| h.clone())
else {
return Ok(false);
};
let Some(new_content) = replace_rule_color(&self.content, tag, &old_hex, new_hex) else {
return Ok(false);
};
self.history
.push(std::mem::replace(&mut self.content, new_content));
self.redo_history.clear();
self.colors = parse_colors(&self.content);
if self.auto_save {
self.save()?;
}
Ok(true)
}
pub fn save(&self) -> Result<()> {
std::fs::write(&self.path, &self.content)
.with_context(|| format!("writing {}", self.path.display()))
}
pub fn undo(&mut self) -> Result<bool> {
let Some(prev) = self.history.pop() else {
return Ok(false);
};
self.redo_history
.push(std::mem::replace(&mut self.content, prev));
self.colors = parse_colors(&self.content);
if self.auto_save {
self.save()?;
}
Ok(true)
}
pub fn redo(&mut self) -> Result<bool> {
let Some(next) = self.redo_history.pop() else {
return Ok(false);
};
self.history
.push(std::mem::replace(&mut self.content, next));
self.colors = parse_colors(&self.content);
if self.auto_save {
self.save()?;
}
Ok(true)
}
}
fn style_block(content: &str) -> Option<&str> {
let re = Regex::new(r#"(?s)<style[^>]*?id="current-color-scheme"[^>]*>(.*?)</style>"#)
.expect("static regex is valid");
re.captures(content).map(|c| c.get(1).unwrap().as_str())
}
fn parse_colors(content: &str) -> Vec<(String, String)> {
let Some(style) = style_block(content) else {
return Vec::new();
};
let re = Regex::new(r#"\.([a-zA-Z0-9\-]*ColorScheme-[a-zA-Z0-9]+)\s*\{\s*color:\s*(#[a-fA-F0-9]{3,6})"#)
.expect("static regex is valid");
re.captures_iter(style)
.map(|c| (c[1].to_string(), c[2].to_string()))
.collect()
}
fn replace_rule_color(content: &str, tag: &str, old_hex: &str, new_hex: &str) -> Option<String> {
let pattern = format!(
r#"(\.{}\s*\{{\s*color:\s*){}"#,
regex::escape(tag),
regex::escape(old_hex)
);
let re = Regex::new(&pattern).ok()?;
if !re.is_match(content) {
return None;
}
Some(re.replace(content, format!("${{1}}{new_hex}")).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
const SINGLE_COLOR_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<path style="fill:currentColor"
d="m8 2c-1.662 0-3 1.338-3 3v3h-2v6h10v-6h-2v-3c0-1.662-1.338-3-3-3zm0 1c1.2465 0 2 0.5458 2 2v3h-4v-3c0-1.4542 0.753506-2 2-2z"
class="ColorScheme-Text"
/>
</svg>
"#;
const MULTI_COLOR_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 22 22">
<defs>
<style type="text/css" id="current-color-scheme">
.ColorScheme-Highlight { color: #3daee9; }
.ColorScheme-Text { color: #232629; }
</style>
</defs>
<path d="M5 9h12v1H5z" style="fill:currentColor" class="ColorScheme-Highlight"/>
<path d="M12 7H6v8h6z" style="fill:currentColor" class="ColorScheme-Text"/>
</svg>
"#;
#[test]
fn parse_colors_finds_single_rule() {
let colors = parse_colors(SINGLE_COLOR_SVG);
assert_eq!(
colors,
vec![("ColorScheme-Text".to_string(), "#232629".to_string())]
);
}
#[test]
fn parse_colors_finds_multiple_rules_in_order() {
let colors = parse_colors(MULTI_COLOR_SVG);
assert_eq!(
colors,
vec![
("ColorScheme-Highlight".to_string(), "#3daee9".to_string()),
("ColorScheme-Text".to_string(), "#232629".to_string()),
]
);
}
#[test]
fn parse_colors_empty_when_no_style_block() {
let colors = parse_colors("<svg></svg>");
assert!(colors.is_empty());
}
#[test]
fn replace_rule_color_only_touches_matched_class() {
// Regression test for the bug the Rust rewrite fixes: two classes
// sharing the same hex value must not both change when only one
// is edited.
let svg = r#".ColorScheme-Highlight { color: #3daee9; }
.ColorScheme-Focus { color: #3daee9; }"#;
let updated =
replace_rule_color(svg, "ColorScheme-Highlight", "#3daee9", "#ff0000").unwrap();
assert!(updated.contains(".ColorScheme-Highlight { color: #ff0000; }"));
assert!(updated.contains(".ColorScheme-Focus { color: #3daee9; }"));
}
#[test]
fn replace_rule_color_returns_none_when_rule_not_found() {
let svg = ".ColorScheme-Text { color: #232629; }";
assert!(replace_rule_color(svg, "ColorScheme-Missing", "#232629", "#ffffff").is_none());
}
#[test]
fn update_color_round_trip_with_undo_redo() {
let dir = std::env::temp_dir().join(format!(
"icon-color-tool-test-colorscheme-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("icon.svg");
std::fs::write(&file, MULTI_COLOR_SVG).unwrap();
let mut scheme = ColorScheme::load(&file).unwrap();
assert_eq!(scheme.colors.len(), 2);
let changed = scheme.update_color("ColorScheme-Highlight", "#ff0000").unwrap();
assert!(changed);
assert_eq!(
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1,
"#ff0000"
);
// The other rule must be untouched.
assert_eq!(
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Text").unwrap().1,
"#232629"
);
let on_disk = std::fs::read_to_string(&file).unwrap();
assert!(on_disk.contains("#ff0000"));
let undone = scheme.undo().unwrap();
assert!(undone);
assert_eq!(
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1,
"#3daee9"
);
let redone = scheme.redo().unwrap();
assert!(redone);
assert_eq!(
scheme.colors.iter().find(|(t, _)| t == "ColorScheme-Highlight").unwrap().1,
"#ff0000"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn update_color_unknown_tag_returns_false() {
let dir = std::env::temp_dir().join(format!(
"icon-color-tool-test-colorscheme-unknown-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("icon.svg");
std::fs::write(&file, SINGLE_COLOR_SVG).unwrap();
let mut scheme = ColorScheme::load(&file).unwrap();
let changed = scheme.update_color("ColorScheme-DoesNotExist", "#ffffff").unwrap();
assert!(!changed);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_with_namespace_rewrites_classes() {
let dir = std::env::temp_dir().join(format!(
"icon-color-tool-test-namespace-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("icon.svg");
std::fs::write(&file, SINGLE_COLOR_SVG).unwrap();
let scheme = ColorScheme::load_with_namespace(&file, "Emblem").unwrap();
assert!(scheme.content.contains(".Emblem-ColorScheme-Text"));
assert!(scheme.content.contains("class=\"Emblem-ColorScheme-Text\""));
assert_eq!(scheme.colors[0].0, "Emblem-ColorScheme-Text");
std::fs::remove_dir_all(&dir).ok();
}
#[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();
}
}
+121
View File
@@ -0,0 +1,121 @@
use crate::color_scheme::ColorScheme;
pub struct CompositeIcon {
pub bg: ColorScheme,
pub emblem: Option<ColorScheme>,
pub emblem_x: f64,
pub emblem_y: f64,
pub emblem_size: f64,
pub is_modified: bool,
}
impl CompositeIcon {
pub fn new(bg: ColorScheme) -> Self {
Self {
bg,
emblem: None,
emblem_x: 32.0,
emblem_y: 32.0,
emblem_size: 32.0,
is_modified: false,
}
}
pub fn set_emblem(&mut self, emblem: ColorScheme) {
self.emblem = Some(emblem);
}
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 {
fn parse_dims(svg: &str) -> (f64, f64) {
if let Some(re) = Regex::new(r#"(?i)<svg[^>]*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::<f64>(), parts[3].parse::<f64>()) {
return (w, h);
}
}
}
}
let mut w = 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 Some(caps) = re_w.captures(svg) {
if let Ok(val) = caps.get(1).unwrap().as_str().trim().parse::<f64>() {
w = val;
}
}
}
if let Some(re_h) = Regex::new(r#"(?i)<svg[^>]*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::<f64>() {
h = val;
}
}
}
(w, h)
}
fn extract_inner(svg: &str) -> &str {
let start = if let Some(idx) = svg.find("<svg") {
if let Some(close_idx) = svg[idx..].find('>') {
idx + close_idx + 1
} else { 0 }
} else { 0 };
let end = if let Some(idx) = svg.rfind("</svg>") { 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);
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
let emblem_inner = extract_inner(&emblem.content);
let emblem_group = format!(
r#"<g transform="translate({}, {}) scale({}, {})">
{}
</g>
</svg>"#,
mapped_x, mapped_y, scale_x, scale_y, emblem_inner
);
let base_svg = strip_dimensions(&self.bg.content);
if let Some(idx) = base_svg.rfind("</svg>") {
let mut new_svg = base_svg[..idx].to_string();
new_svg.push_str(&emblem_group);
new_svg
} else {
base_svg
}
} else {
strip_dimensions(&self.bg.content)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct Settings {
breeze_path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ConfigFile {
settings: Option<Settings>,
}
pub fn config_dir() -> PathBuf {
let xdg = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").expect("HOME must be set");
PathBuf::from(home).join(".config")
});
xdg.join("icon-color-tool")
}
pub fn default_breeze_path() -> PathBuf {
let home = std::env::var("HOME").expect("HOME must be set");
PathBuf::from(home).join(".local/share/breeze-icons")
}
fn expand_home(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
let home = std::env::var("HOME").expect("HOME must be set");
PathBuf::from(home).join(rest)
} else {
PathBuf::from(path)
}
}
pub fn generate_default_config(config_file: &Path) -> Result<()> {
let contents = format!(
"[settings]\nbreeze_path = \"{}\"\n",
default_breeze_path().display()
);
if let Some(parent) = config_file.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(config_file, contents)
.with_context(|| format!("writing {}", config_file.display()))
}
pub fn read_breeze_path(config_file: &Path) -> Result<PathBuf> {
if !config_file.exists() {
generate_default_config(config_file)?;
}
let raw = std::fs::read_to_string(config_file)
.with_context(|| format!("reading {}", config_file.display()))?;
let parsed: ConfigFile = toml::from_str(&raw)
.with_context(|| format!("parsing {}", config_file.display()))?;
let path_str = parsed
.settings
.and_then(|s| s.breeze_path)
.unwrap_or_else(|| default_breeze_path().display().to_string());
Ok(expand_home(&path_str))
}
pub fn ensure_breeze_repo(path: &Path) -> Result<()> {
let needs_clone = !path.exists()
|| std::fs::read_dir(path)
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if needs_clone {
println!(
"Breeze icons not found at {}. Cloning repository...",
path.display()
);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let status = std::process::Command::new("git")
.args(["clone", "--depth", "1"])
.arg("https://github.com/KDE/breeze-icons.git")
.arg(path)
.status()
.context("running `git clone` (is git installed and on PATH?)")?;
anyhow::ensure!(status.success(), "git clone exited with {status}");
}
Ok(())
}
pub fn load_breeze_path() -> Result<PathBuf> {
if let Ok(env_path) = std::env::var("BREEZE_ICONS_PATH") {
let path = expand_home(&env_path);
ensure_breeze_repo(&path)?;
return Ok(path);
}
let config_file = config_dir().join("config.toml");
let path = read_breeze_path(&config_file)?;
ensure_breeze_repo(&path)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("icon-color-tool-test-{label}-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn generate_default_config_writes_expected_toml() {
let dir = temp_dir("gen-default");
let config_file = dir.join("config.toml");
generate_default_config(&config_file).unwrap();
let contents = std::fs::read_to_string(&config_file).unwrap();
assert!(contents.contains("[settings]"));
assert!(contents.contains("breeze_path ="));
assert!(contents.contains(".local/share/breeze-icons"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_breeze_path_creates_default_when_missing() {
let dir = temp_dir("read-default");
let config_file = dir.join("config.toml");
let resolved = read_breeze_path(&config_file).unwrap();
assert!(config_file.exists());
assert!(resolved.ends_with(".local/share/breeze-icons"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_breeze_path_honors_custom_value() {
let dir = temp_dir("read-custom");
let config_file = dir.join("config.toml");
std::fs::write(
&config_file,
"[settings]\nbreeze_path = \"/opt/custom-breeze\"\n",
)
.unwrap();
let resolved = read_breeze_path(&config_file).unwrap();
assert_eq!(resolved, PathBuf::from("/opt/custom-breeze"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn expand_home_expands_leading_tilde() {
let home = std::env::var("HOME").unwrap();
let expanded = expand_home("~/somewhere");
assert_eq!(expanded, PathBuf::from(home).join("somewhere"));
}
#[test]
fn expand_home_leaves_absolute_path_untouched() {
let expanded = expand_home("/already/absolute");
assert_eq!(expanded, PathBuf::from("/already/absolute"));
}
}
+124
View File
@@ -0,0 +1,124 @@
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub fn list_svg_icons(root: &Path) -> Vec<PathBuf> {
use std::collections::HashMap;
let mut best_icons: HashMap<String, (u32, PathBuf)> = 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"));
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::<u32>().unwrap_or(0) * 2
} else {
size_str.parse::<u32>().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 {
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"), "<svg></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();
}
}
+260
View File
@@ -0,0 +1,260 @@
mod color_scheme;
mod composite_icon;
mod config;
mod icon_index;
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";
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")
.default_width(900)
.default_height(800)
.build();
let stack = Stack::new();
window.set_child(Some(&stack));
let current_composite: Rc<RefCell<Option<Rc<RefCell<CompositeIcon>>>>> = Rc::new(RefCell::new(None));
{
let current_composite = Rc::clone(&current_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(&current_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(&current_composite));
if let Some(Ok(path)) = initial_icon {
build_editor(&stack, &window, path, breeze_path, Rc::clone(&current_composite));
}
window.present();
});
app.run()
}
fn resolve_icon_arg(arg: &str, breeze_path: &Path) -> anyhow::Result<PathBuf> {
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, window: &ApplicationWindow, breeze_path: PathBuf, current_composite: Rc<RefCell<Option<Rc<RefCell<CompositeIcon>>>>>) {
let stack_for_callback = stack.clone();
let window_for_callback = window.clone();
let breeze_for_editor = breeze_path.clone();
let comp_callback = Rc::clone(&current_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(), 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, current_composite: Rc<RefCell<Option<Rc<RefCell<CompositeIcon>>>>>) {
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)));
*current_composite.borrow_mut() = Some(Rc::clone(&composite));
let editor_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
let rebuild_editor = {
let composite = Rc::clone(&composite);
let stack = stack.clone();
let window = window.clone();
let editor_container = editor_container.clone();
let current_comp = Rc::clone(&current_composite);
Rc::new(move || {
while let Some(child) = editor_container.first_child() {
editor_container.remove(&child);
}
let stack_clone = stack.clone();
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(&current_comp);
let page = ui::editor::build_editor_page(
Rc::clone(&composite),
move || {
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(&current_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");
},
{
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::<gtk::FileFilter>();
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() {
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;
}
}
}
});
}
}
);
editor_container.append(&page);
})
};
rebuild_editor();
if let Some(existing) = stack.child_by_name("editor") {
stack.remove(&existing);
}
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;
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 {
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");
}
+329
View File
@@ -0,0 +1,329 @@
use crate::composite_icon::CompositeIcon;
use gtk::prelude::*;
use gtk::{
Box as GtkBox, Label, Orientation, Picture, ScrolledWindow,
};
use std::cell::RefCell;
use std::rc::Rc;
pub fn build_editor_page(
composite: Rc<RefCell<CompositeIcon>>,
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);
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 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);
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));
side_panel.append(&swatches_scroller);
// 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(composite.borrow().emblem_x);
let scale_y = gtk::Scale::with_range(Orientation::Horizontal, -64.0, 64.0, 1.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(composite.borrow().emblem_size);
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);
if composite.borrow().emblem.is_some() {
side_panel.append(&geometry_box);
}
content.append(&side_panel);
let preview = Picture::new();
preview.set_hexpand(true);
content.append(&preview);
root.append(&content);
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 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.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;
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();
scale_x.connect_value_changed(move |s| {
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();
}
});
}
{
let composite = Rc::clone(&composite);
let refresh_preview = refresh_preview.clone();
scale_y.connect_value_changed(move |s| {
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();
}
});
}
{
let composite = Rc::clone(&composite);
let refresh_preview = refresh_preview.clone();
scale_size.connect_value_changed(move |s| {
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();
}
});
}
let rebuild_swatches: Rc<RefCell<Box<dyn Fn()>>> = Rc::new(RefCell::new(Box::new(|| {})));
{
let composite = Rc::clone(&composite);
let swatches_box = swatches_box.clone();
let refresh_preview = refresh_preview.clone();
let build_fn = move || {
while let Some(child) = swatches_box.first_child() {
swatches_box.remove(&child);
}
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);
label.set_xalign(0.0);
let rgba = gtk::gdk::RGBA::parse(&hex).unwrap_or(gtk::gdk::RGBA::BLACK);
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(&current_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 popover = gtk::Popover::new();
let chooser = gtk::ColorChooserWidget::new();
chooser.set_use_alpha(false);
chooser.set_rgba(&*current_rgba.borrow());
popover.set_child(Some(&chooser));
let button = gtk::MenuButton::builder()
.child(&drawing_area)
.popover(&popover)
.build();
{
let tag = tag.clone();
let composite = Rc::clone(&composite);
let refresh_preview = refresh_preview.clone();
let current_rgba = Rc::clone(&current_rgba);
let drawing_area = drawing_area.clone();
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);
}
if res {
comp.is_modified = true;
}
res
};
if changed {
*current_rgba.borrow_mut() = new_rgba;
drawing_area.queue_draw();
refresh_preview();
}
});
}
row.append(&label);
row.append(&button);
swatches_box.append(&row);
}
};
*rebuild_swatches.borrow_mut() = Box::new(build_fn);
}
(rebuild_swatches.borrow())();
{
let composite = Rc::clone(&composite);
let refresh_preview = refresh_preview.clone();
let rebuild_swatches = Rc::clone(&rebuild_swatches);
undo_button.connect_clicked(move |_| {
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);
}
if changed {
comp.is_modified = true;
}
changed
};
if changed {
refresh_preview();
(rebuild_swatches.borrow())();
}
});
}
{
let composite = Rc::clone(&composite);
let refresh_preview = refresh_preview.clone();
let rebuild_swatches = Rc::clone(&rebuild_swatches);
redo_button.connect_clicked(move |_| {
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);
}
if changed {
comp.is_modified = true;
}
changed
};
if changed {
refresh_preview();
(rebuild_swatches.borrow())();
}
});
}
root.upcast()
}
+2
View File
@@ -0,0 +1,2 @@
pub mod editor;
pub mod picker;
+108
View File
@@ -0,0 +1,108 @@
use crate::icon_index::{filter_icons, list_svg_icons};
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_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);
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<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
{
let current_matches = Rc::clone(&current_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(&current_matches);
move |query: &str| {
while let Some(child) = flow_box.first_child() {
flow_box.remove(&child);
}
let matches: Vec<PathBuf> = 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()
}
-56
View File
@@ -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 <style id="current-color-scheme">...</style> regardless of attribute order
style_match = re.search(r'<style[^>]*?id="current-color-scheme"[^>]*>(.*?)</style>', 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