feat: Icon overlay and editor improvements #1
@@ -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.
|
||||
@@ -13,3 +13,4 @@ docs/plans
|
||||
docs/specs
|
||||
docs/devlogs
|
||||
# ────────────────────────────────────────────────────────
|
||||
/target
|
||||
|
||||
Generated
+813
@@ -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
@@ -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"
|
||||
@@ -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.
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
python3 main.py
|
||||
@@ -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 +0,0 @@
|
||||
PyQt6
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤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, Rc::clone(¤t_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(¤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(), 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(¤t_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(¤t_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(¤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");
|
||||
},
|
||||
{
|
||||
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");
|
||||
}
|
||||
@@ -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(¤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 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(¤t_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()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod editor;
|
||||
pub mod picker;
|
||||
@@ -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(¤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<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()
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user