can't be bothered writing a commit message

This commit is contained in:
Breadway 2026-05-24 18:57:01 +08:00
parent 7df0003c2c
commit 81319dd584
12 changed files with 1971 additions and 573 deletions

675
Cargo.lock generated
View file

@ -2,12 +2,24 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@ -18,8 +30,27 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
name = "breadbox" name = "breadbox"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"breadbox-shared",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"serde_json",
]
[[package]]
name = "breadbox-shared"
version = "0.1.0"
dependencies = [
"serde",
"toml 0.8.23",
]
[[package]]
name = "breadbox-sync"
version = "0.1.0"
dependencies = [
"breadbox-shared",
"serde_json",
"ureq",
] ]
[[package]] [[package]]
@ -45,6 +76,16 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "cc"
version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]] [[package]]
name = "cfg-expr" name = "cfg-expr"
version = "0.20.7" version = "0.20.7"
@ -55,6 +96,32 @@ dependencies = [
"target-lexicon", "target-lexicon",
] ]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -71,6 +138,31 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@ -191,6 +283,17 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.22.6" version = "0.22.6"
@ -218,7 +321,7 @@ dependencies = [
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -441,6 +544,109 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@ -451,6 +657,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "khronos_api" name = "khronos_api"
version = "3.1.0" version = "3.1.0"
@ -463,6 +675,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.29"
@ -484,6 +702,22 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.22.6" version = "0.22.6"
@ -508,6 +742,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -520,13 +760,22 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit", "toml_edit 0.25.11+spec-1.1.0",
] ]
[[package]] [[package]]
@ -547,6 +796,20 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -556,12 +819,57 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@ -582,6 +890,28 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@ -591,6 +921,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -603,6 +945,18 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@ -614,6 +968,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "7.0.8" version = "7.0.8"
@ -623,7 +988,7 @@ dependencies = [
"cfg-expr", "cfg-expr",
"heck", "heck",
"pkg-config", "pkg-config",
"toml", "toml 1.1.2+spec-1.1.0",
"version-compare", "version-compare",
] ]
@ -633,6 +998,28 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[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]] [[package]]
name = "toml" name = "toml"
version = "1.1.2+spec-1.1.0" version = "1.1.2+spec-1.1.0"
@ -641,11 +1028,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
"serde_spanned", "serde_spanned 1.1.1",
"toml_datetime", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"toml_writer", "toml_writer",
"winnow", "winnow 1.0.3",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
] ]
[[package]] [[package]]
@ -657,6 +1053,20 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.11+spec-1.1.0" version = "0.25.11+spec-1.1.0"
@ -664,9 +1074,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow 1.0.3",
] ]
[[package]] [[package]]
@ -675,9 +1085,15 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow", "winnow 1.0.3",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "toml_writer" name = "toml_writer"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@ -690,18 +1106,91 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@ -711,6 +1200,79 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.3" version = "1.0.3"
@ -720,8 +1282,103 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "xml-rs" name = "xml-rs"
version = "0.8.28" version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "yoke"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -1,8 +1,3 @@
[package] [workspace]
name = "breadbox" members = ["breadbox-shared", "breadbox-sync", "breadbox"]
version = "0.1.0" resolver = "2"
edition = "2021"
[dependencies]
gtk4 = "0.11"
gtk4-layer-shell = "0.8"

124
README.md Normal file
View file

@ -0,0 +1,124 @@
# breadbox
A GTK4 app launcher for Hyprland / Wayland on Arch Linux.
```
breadbox-shared shared types (DesktopEntry, IconCache, Config)
breadbox-sync standalone icon resolution + caching binary
breadbox GTK4 layer-shell launcher
```
## Features
- Layer-shell window, centered 600 px wide, keyboard-exclusive
- Reads the active Hyprland workspace and sorts apps by context priority
- Fuzzy filtering as you type; Enter launches, Escape closes
- App icons loaded from the resolved icon cache (see `breadbox-sync`)
- pywal palette auto-detected from `~/.cache/wal/colors.json`, falls back to Catppuccin Mocha
- User CSS override at `~/.config/breadbox/style.css`
- Toggle/dismiss: running a second instance kills the first
## Build dependencies
```
gtk4 (pacman -S gtk4)
gtk4-layer-shell (pacman -S gtk4-layer-shell)
librsvg (pacman -S librsvg) # for SVG icon support
rust (stable) (rustup toolchain install stable)
```
## Build
```bash
# debug
cargo build
# release (recommended — put both binaries on $PATH)
cargo build --release
# binaries are at target/release/breadbox and target/release/breadbox-sync
```
Install to `~/.cargo/bin` (or anywhere on your PATH):
```bash
cargo install --path breadbox
cargo install --path breadbox-sync
```
## Configuration
Copy and edit the example config:
```bash
mkdir -p ~/.config/breadbox
cp config.example.toml ~/.config/breadbox/config.toml
```
The `[[context]]` blocks map Hyprland workspace names to app priority lists.
Workspace name `"default"` is the catch-all fallback.
```toml
[[context]]
name = "default"
priority = ["firefox", "code", "obsidian", "kitty"]
[[context]]
name = "2"
priority = ["slack", "discord"]
```
### CSS theming
breadbox applies pywal colors automatically when `~/.cache/wal/colors.json` is
present. To override or extend the theme:
```bash
~/.config/breadbox/style.css
```
This file is loaded at the highest CSS priority level, so any rule here wins.
## Icon sync
`breadbox-sync` resolves icons for all installed apps and writes them to
`~/.cache/breadbox/`. Run it once before first launch:
```bash
breadbox-sync
```
Icon resolution order:
1. System icon theme (`~/.local/share/icons`, `/usr/share/icons`, `/usr/share/pixmaps`) — 64 px > 48 px PNG, then SVG
2. Flathub media server — for reverse-DNS app IDs (e.g. `org.gnome.Gedit`)
3. icon.horse — downloaded and cached
4. `application-x-executable` fallback from system theme
### Systemd service (run on login)
```bash
cp packaging/breadbox-sync.service ~/.config/systemd/user/
systemctl --user enable --now breadbox-sync.service
```
The service runs `breadbox-sync` once at login (after network is up) and logs
to journald. Re-run manually after installing new apps:
```bash
systemctl --user start breadbox-sync.service
# or just:
breadbox-sync
```
## Hyprland keybind
Add to `~/.config/hypr/hyprland.conf`:
```
bind = $mainMod, SPACE, exec, breadbox
```
Pressing the keybind again while the launcher is open dismisses it.
## Licence
MIT — see [LICENSE](LICENSE).

View file

@ -0,0 +1,9 @@
[package]
name = "breadbox-shared"
version = "0.1.0"
edition = "2021"
license = "MIT"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"

261
breadbox-shared/src/lib.rs Normal file
View file

@ -0,0 +1,261 @@
use std::{
env,
fs::{self, File},
io::{BufRead, BufReader},
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
// ---- XDG path helpers -------------------------------------------------------
pub fn home_dir() -> PathBuf {
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
}
pub fn cache_dir() -> PathBuf {
env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".cache"))
.join("breadbox")
}
pub fn config_dir() -> PathBuf {
env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".config"))
.join("breadbox")
}
pub fn app_dirs() -> Vec<PathBuf> {
let home = home_dir();
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
let xdg_data_dirs = env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
for d in xdg_data_dirs.split(':') {
let p = PathBuf::from(d).join("applications");
if p != dirs[0] {
dirs.push(p);
}
}
dirs.push(
env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".local/share"))
.join("applications"),
);
dirs
}
// ---- Desktop entry ----------------------------------------------------------
#[derive(Debug, Clone)]
pub struct DesktopEntry {
pub name: String,
pub exec: String,
pub icon_name: String,
pub icon_path: Option<PathBuf>, // resolved by caller from manifest
pub categories: Vec<String>,
pub wm_class: Option<String>,
pub terminal: bool,
}
pub fn strip_exec_codes(exec: &str) -> String {
let mut out = String::with_capacity(exec.len());
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
match chars.peek().copied() {
Some('%') => {
chars.next();
out.push('%');
}
Some(n) if n.is_ascii_alphabetic() => {
chars.next();
}
_ => out.push(c),
}
} else {
out.push(c);
}
}
out
}
/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type).
pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
let file = File::open(path).ok()?;
let mut in_entry = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut categories: Option<String> = None;
let mut wm_class: Option<String> = None;
let mut app_type: Option<String> = None;
let mut no_display = false;
let mut hidden = false;
let mut terminal = false;
for line in BufReader::new(file).lines() {
let Ok(raw) = line else { continue };
let s = raw.trim();
if s.starts_with('#') || s.is_empty() {
continue;
}
if s.starts_with('[') {
in_entry = s == "[Desktop Entry]";
continue;
}
if !in_entry {
continue;
}
if let Some(v) = s.strip_prefix("Name=") {
name.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Exec=") {
exec.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Icon=") {
icon.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Categories=") {
categories.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("StartupWMClass=") {
wm_class.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Type=") {
app_type.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
no_display = v == "true";
} else if let Some(v) = s.strip_prefix("Hidden=") {
hidden = v == "true";
} else if let Some(v) = s.strip_prefix("Terminal=") {
terminal = v == "true" || v == "1";
}
}
if no_display || hidden {
return None;
}
if app_type.as_deref() != Some("Application") {
return None;
}
let name = name?.trim().to_string();
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
if name.is_empty() || exec.is_empty() {
return None;
}
let icon_name = icon.unwrap_or_default().trim().to_string();
let cats = categories
.unwrap_or_default()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
Some(DesktopEntry {
name,
exec,
icon_name,
icon_path: None,
categories: cats,
wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
terminal,
})
}
/// Walk all configured application directories and return deduplicated entries.
/// Entries from later directories (user-local) override those from earlier ones.
pub fn load_all_desktop_entries() -> Vec<DesktopEntry> {
let mut seen: std::collections::HashMap<String, DesktopEntry> = std::collections::HashMap::new();
for dir in app_dirs() {
let Ok(entries) = fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
let key = entry.file_name().to_string_lossy().into_owned();
if let Some(app) = parse_desktop(&path) {
seen.insert(key, app);
}
}
}
seen.into_values().collect()
}
// ---- Icon cache -------------------------------------------------------------
pub struct IconCache {
pub dir: PathBuf,
}
impl IconCache {
pub fn new() -> Self {
IconCache { dir: cache_dir().join("icons") }
}
pub fn path_for(&self, icon_name: &str) -> PathBuf {
self.dir.join(format!("{}.png", icon_name))
}
pub fn manifest_path() -> PathBuf {
cache_dir().join("manifest.json")
}
pub fn ensure_dir(&self) -> std::io::Result<()> {
fs::create_dir_all(&self.dir)
}
}
impl Default for IconCache {
fn default() -> Self {
Self::new()
}
}
// ---- Config -----------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default, rename = "context")]
pub contexts: Vec<Context>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Context {
pub name: String,
#[serde(default)]
pub priority: Vec<String>,
}
impl Config {
pub fn load() -> Self {
let path = config_dir().join("config.toml");
let content = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
eprintln!("breadbox: could not read {}: {}", path.display(), e);
return Self::default();
}
};
match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
eprintln!("breadbox: parse error in {}: {}", path.display(), e);
Self::default()
}
}
}
/// Find the context matching `workspace`, falling back to "default", then
/// returning None if neither exists.
pub fn context_for(&self, workspace: &str) -> Option<&Context> {
self.contexts
.iter()
.find(|c| c.name == workspace)
.or_else(|| self.contexts.iter().find(|c| c.name == "default"))
}
}

14
breadbox-sync/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "breadbox-sync"
version = "0.1.0"
edition = "2021"
license = "MIT"
[[bin]]
name = "breadbox-sync"
path = "src/main.rs"
[dependencies]
breadbox-shared = { path = "../breadbox-shared" }
serde_json = "1"
ureq = "2"

281
breadbox-sync/src/main.rs Normal file
View file

@ -0,0 +1,281 @@
use std::{
collections::HashMap,
env,
fs,
io::Read,
path::{Path, PathBuf},
};
use breadbox_shared::{home_dir, IconCache};
// ---- Icon theme lookup ------------------------------------------------------
fn current_icon_theme() -> String {
let home = home_dir();
for cfg in [
home.join(".config/gtk-4.0/settings.ini"),
home.join(".config/gtk-3.0/settings.ini"),
] {
if let Ok(content) = fs::read_to_string(&cfg) {
for line in content.lines() {
if let Some(v) = line.strip_prefix("gtk-icon-theme-name=") {
let t = v.trim().trim_matches('"');
if !t.is_empty() {
return t.to_string();
}
}
}
}
}
"hicolor".to_string()
}
fn icon_search_dirs() -> Vec<PathBuf> {
let home = home_dir();
let xdg_data_home = env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".local/share"));
let mut dirs = Vec::new();
let mut seen = std::collections::HashSet::new();
for d in [
xdg_data_home.join("icons"),
home.join(".local/share/icons"),
PathBuf::from("/usr/share/icons"),
] {
if seen.insert(d.clone()) {
dirs.push(d);
}
}
dirs
}
/// Search for `name` in system icon theme directories.
/// Prefers 64px > 48px > 128px > 32px > 256px PNG, then scalable SVG.
fn find_system_icon(name: &str, theme: &str) -> Option<PathBuf> {
let sizes = ["64x64", "48x48", "128x128", "32x32", "256x256"];
let dirs = icon_search_dirs();
let themes: Vec<&str> = if theme != "hicolor" {
vec![theme, "hicolor"]
} else {
vec!["hicolor"]
};
for dir in &dirs {
for t in &themes {
for size in &sizes {
let p = dir.join(t).join(size).join("apps").join(format!("{}.png", name));
if p.exists() {
return Some(p);
}
// Alternative path layout: <theme>/apps/<size>/
let p2 = dir.join(t).join("apps").join(size).join(format!("{}.png", name));
if p2.exists() {
return Some(p2);
}
}
// SVG (scalable)
for subdir in ["scalable/apps", "apps/scalable"] {
let p = dir.join(t).join(subdir).join(format!("{}.svg", name));
if p.exists() {
return Some(p);
}
}
}
}
// /usr/share/pixmaps
for ext in ["png", "svg", "xpm"] {
let p = PathBuf::from("/usr/share/pixmaps").join(format!("{}.{}", name, ext));
if p.exists() {
return Some(p);
}
}
None
}
// ---- Helpers ----------------------------------------------------------------
/// Strip file extension from an icon field value, returning the canonical name.
fn canonical_icon_name(icon: &str) -> String {
if icon.starts_with('/') {
return Path::new(icon)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(icon)
.to_string();
}
icon.strip_suffix(".png")
.or_else(|| icon.strip_suffix(".svg"))
.or_else(|| icon.strip_suffix(".xpm"))
.unwrap_or(icon)
.to_string()
}
/// A stem like `org.gnome.Gedit` or `com.github.App` — at least three segments,
/// all alphanumeric/hyphen/underscore.
fn looks_like_reverse_dns(stem: &str) -> bool {
let parts: Vec<&str> = stem.split('.').collect();
parts.len() >= 3
&& parts[0].len() >= 2
&& parts.iter().all(|p| {
!p.is_empty()
&& p.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
})
}
/// Try to GET `url` and write the body to `dest`. Returns true on success.
fn try_download(agent: &ureq::Agent, url: &str, dest: &Path) -> bool {
let resp = match agent.get(url).call() {
Ok(r) if r.status() == 200 => r,
_ => return false,
};
let mut bytes = Vec::new();
if resp.into_reader().take(2_097_152).read_to_end(&mut bytes).is_err() || bytes.is_empty() {
return false;
}
// Validate the PNG signature so a 200 error page is never cached as an icon.
const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
if !bytes.starts_with(&PNG_MAGIC) {
return false;
}
fs::write(dest, &bytes).is_ok()
}
/// Resolve an icon to a local path, downloading if necessary.
/// Returns None only if all strategies fail and no generic fallback is found.
fn resolve_icon(
icon_field: &str,
desktop_stem: &str,
theme: &str,
icon_cache: &IconCache,
agent: &ureq::Agent,
) -> Option<PathBuf> {
// Absolute path in Icon= field
if icon_field.starts_with('/') {
let p = PathBuf::from(icon_field);
if p.exists() {
return Some(p);
}
}
let name = canonical_icon_name(icon_field);
if name.is_empty() {
return find_system_icon("application-x-executable", theme);
}
// 1. System icon theme
if let Some(p) = find_system_icon(&name, theme) {
return Some(p);
}
// Already cached from a previous run?
let cached = icon_cache.path_for(&name);
if cached.exists() {
return Some(cached);
}
// 2. Flathub (appstream icon path, not the media CDN)
if looks_like_reverse_dns(desktop_stem) {
let url = format!(
"https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/{}.png",
desktop_stem
);
let dest = icon_cache.path_for(desktop_stem);
if try_download(agent, &url, &dest) {
eprintln!(" [flathub] {}", desktop_stem);
return Some(dest);
}
}
// 3. Generic fallback
find_system_icon("application-x-executable", theme)
}
// ---- Main -------------------------------------------------------------------
fn main() {
if let Err(e) = run() {
eprintln!("breadbox-sync: {}", e);
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let icon_cache = IconCache::new();
icon_cache.ensure_dir()?;
let theme = current_icon_theme();
eprintln!("breadbox-sync: icon theme = {}", theme);
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
let mut manifest: HashMap<String, String> = HashMap::new();
// Walk directories directly to get both the entry and its filename stem
// (needed for Flathub reverse-DNS resolution).
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for dir in breadbox_shared::app_dirs() {
let Ok(read_dir) = fs::read_dir(&dir) else { continue };
for file_entry in read_dir.flatten() {
let path = file_entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
// User-local overrides system; process in dir order (system first, local last).
// Later entries for the same stem will overwrite earlier ones in the manifest.
let app = match breadbox_shared::parse_desktop(&path) {
Some(a) => a,
None => continue,
};
if app.icon_name.is_empty() {
continue;
}
// Deduplicate by the raw Icon= value, which is also the manifest key,
// so every distinct icon_name gets its own entry.
if !seen.insert(app.icon_name.clone()) {
continue;
}
eprint!("resolving icon for {} ({}) ... ", app.name, app.icon_name);
match resolve_icon(&app.icon_name, &stem, &theme, &icon_cache, &agent) {
Some(p) => {
eprintln!("{}", p.display());
manifest.insert(app.icon_name.clone(), p.to_string_lossy().into_owned());
}
None => {
eprintln!("not found");
}
}
}
}
let manifest_path = IconCache::manifest_path();
let json = serde_json::to_string_pretty(&manifest)?;
let tmp = manifest_path.with_extension("tmp");
fs::write(&tmp, &json)?;
fs::rename(&tmp, &manifest_path)?;
eprintln!(
"breadbox-sync: wrote manifest ({} entries) to {}",
manifest.len(),
manifest_path.display()
);
Ok(())
}

15
breadbox/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "breadbox"
version = "0.1.0"
edition = "2021"
license = "MIT"
[[bin]]
name = "breadbox"
path = "src/main.rs"
[dependencies]
breadbox-shared = { path = "../breadbox-shared" }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"
serde_json = "1"

564
breadbox/src/main.rs Normal file
View file

@ -0,0 +1,564 @@
use std::{
collections::HashMap,
env,
fs,
io::{Read, Write},
os::unix::net::UnixStream,
path::{Path, PathBuf},
process::{Command, Stdio},
rc::Rc,
};
use breadbox_shared::{
config_dir, home_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache,
};
use gtk4::{
gdk::Display,
glib,
pango::EllipsizeMode,
prelude::*,
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label,
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
// ---- Hyprland IPC -----------------------------------------------------------
fn get_active_workspace() -> Option<String> {
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
let rt = env::var("XDG_RUNTIME_DIR").ok()?;
let socket_path = format!("{}/hypr/{}/.socket.sock", rt, sig);
let mut stream = UnixStream::connect(&socket_path).ok()?;
stream.write_all(b"j/activeworkspace").ok()?;
stream.shutdown(std::net::Shutdown::Write).ok()?;
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
let v: serde_json::Value = serde_json::from_str(&response).ok()?;
v["name"].as_str().map(|s| s.to_string())
}
// ---- Manifest ---------------------------------------------------------------
fn load_manifest() -> HashMap<String, PathBuf> {
let path = IconCache::manifest_path();
let content = fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str::<HashMap<String, String>>(&content)
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, PathBuf::from(v)))
.collect()
}
// ---- Entry loading and sorting ----------------------------------------------
fn load_sorted_entries(
manifest: &HashMap<String, PathBuf>,
priority: &[String],
) -> Vec<DesktopEntry> {
let mut entries = load_all_desktop_entries();
// Populate icon_path from manifest
for entry in &mut entries {
if let Some(path) = manifest.get(&entry.icon_name) {
if path.exists() {
entry.icon_path = Some(path.clone());
}
}
}
let priority_lower: Vec<String> = priority.iter().map(|s| s.to_lowercase()).collect();
entries.sort_by(|a, b| {
let ai = priority_rank(a, &priority_lower);
let bi = priority_rank(b, &priority_lower);
match (ai, bi) {
(Some(i), Some(j)) => i.cmp(&j),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
});
entries
}
fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option<usize> {
let name_l = entry.name.to_lowercase();
let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
priority_lower
.iter()
.position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p))
}
/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids
/// "code" matching "vscodium" while still matching "Code", "code-oss", and
/// "Visual Studio Code".
fn matches_term(field: &str, term: &str) -> bool {
if term.is_empty() || field.is_empty() {
return false;
}
if field == term {
return true;
}
let bytes = field.as_bytes();
let tlen = term.len();
let mut start = 0;
while let Some(pos) = field[start..].find(term) {
let i = start + pos;
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
let after = i + tlen;
let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
if before_ok && after_ok {
return true;
}
start = i + 1;
if start >= field.len() {
break;
}
}
false
}
// ---- Theming ----------------------------------------------------------------
#[derive(Debug)]
struct Palette {
bg: String,
surface: String,
fg: String,
accent: String,
}
impl Palette {
fn catppuccin_mocha() -> Self {
Palette {
bg: "#1e1e2e".into(),
surface: "#181825".into(),
fg: "#cdd6f4".into(),
accent: "#89b4fa".into(),
}
}
fn from_wal() -> Option<Self> {
let path = env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".cache"))
.join("wal/colors.json");
let content = fs::read_to_string(&path).ok()?;
let v: serde_json::Value = serde_json::from_str(&content).ok()?;
let spec = &v["special"];
let cols = &v["colors"];
let bg = spec["background"].as_str()?.to_string();
let surface = cols["color0"].as_str().unwrap_or(&bg).to_string();
let fg = cols["color15"].as_str().unwrap_or("#cdd6f4").to_string();
let accent = cols["color1"].as_str().unwrap_or("#89b4fa").to_string();
Some(Palette { bg, surface, fg, accent })
}
}
fn hex_to_rgba(hex: &str, alpha: f32) -> String {
let h = hex.trim_start_matches('#');
let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
format!("rgba({r}, {g}, {b}, {alpha})")
}
fn build_css(p: &Palette) -> String {
let bg_panel = hex_to_rgba(&p.bg, 0.60);
format!(
"* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\
window {{ background-color: transparent; }}\
.launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\
box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\
searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\
border: none; outline: none; box-shadow: none;\
padding: 12px 16px; border-radius: 4px 4px 0 0; }}\
listbox {{ background-color: transparent; padding: 4px; }}\
row {{ padding: 5px 10px; color: {fg}; background-color: transparent;\
border-radius: 4px; }}\
row:hover {{ background-color: {surface}; }}\
row:selected {{ background-color: {surface}; }}\
.app-name {{ font-size: 14px; }}\
.app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\
image {{ margin-right: 8px; }}",
bg_panel = bg_panel,
surface = p.surface,
fg = p.fg,
accent = p.accent,
)
}
// ---- Icon loading -----------------------------------------------------------
fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image {
// Try loading from resolved cached path via gio::File
if let Some(path) = icon_path {
let gio_file = gtk4::gio::File::for_path(path);
if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) {
let img = gtk4::Image::new();
img.set_paintable(Some(&texture));
img.set_pixel_size(32);
return img;
}
}
// Fall back to GTK icon theme lookup by name
let name = if icon_name.is_empty() {
"application-x-executable"
} else {
icon_name
};
let img = gtk4::Image::from_icon_name(name);
img.set_pixel_size(32);
img
}
// ---- Launch -----------------------------------------------------------------
fn pick_terminal() -> String {
if let Ok(t) = env::var("TERMINAL") {
if !t.is_empty() {
return t;
}
}
let path_var = env::var("PATH").unwrap_or_default();
for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] {
if path_var.split(':').any(|d| Path::new(d).join(t).exists()) {
return t.to_string();
}
}
"xterm".to_string()
}
fn do_launch(entry: &DesktopEntry) {
let cmd = entry.exec.trim();
if entry.terminal {
let term = pick_terminal();
let _ = Command::new(&term)
.args(["-e", "bash", "-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
} else {
let _ = Command::new("bash")
.args(["-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
// ---- Fuzzy matching ---------------------------------------------------------
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
if pattern.is_empty() {
return true;
}
let mut chars = text.chars();
for pc in pattern.chars() {
let pl = pc.to_lowercase().next().unwrap_or(pc);
if !chars
.by_ref()
.any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl)
{
return false;
}
}
true
}
// ---- PID file toggle --------------------------------------------------------
fn pid_file() -> PathBuf {
env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("breadbox.pid")
}
fn is_breadbox_pid(pid: u32) -> bool {
fs::read_to_string(format!("/proc/{}/comm", pid))
.map(|s| s.trim() == "breadbox")
.unwrap_or(false)
}
// Returns false if an existing instance was killed (caller should exit).
fn toggle_or_continue() -> bool {
let pf = pid_file();
if let Ok(content) = fs::read_to_string(&pf) {
if let Ok(pid) = content.trim().parse::<u32>() {
if is_breadbox_pid(pid) {
let _ = Command::new("kill").arg(pid.to_string()).status();
return false;
}
}
}
let _ = fs::write(&pf, std::process::id().to_string());
true
}
fn cleanup_pid() {
let _ = fs::remove_file(pid_file());
}
// ---- UI ---------------------------------------------------------------------
fn get_row_entry(row: &gtk4::ListBoxRow) -> Option<DesktopEntry> {
unsafe {
row.data::<DesktopEntry>("entry")
.map(|p| p.as_ref().clone())
}
}
fn run_ui(entries: Vec<DesktopEntry>, css: String) {
let app = Application::builder()
.application_id("com.breadway.breadbox")
.build();
app.connect_activate(move |app| {
// Base CSS
let provider = CssProvider::new();
provider.load_from_string(&css);
gtk4::style_context_add_provider_for_display(
&Display::default().expect("no display"),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
// User CSS override
let user_css_path = config_dir().join("style.css");
if user_css_path.exists() {
let user_provider = CssProvider::new();
user_provider.load_from_path(&user_css_path);
gtk4::style_context_add_provider_for_display(
&Display::default().expect("no display"),
&user_provider,
gtk4::STYLE_PROVIDER_PRIORITY_USER,
);
}
// Full-screen transparent window; clicks outside the launcher panel close it.
let window = ApplicationWindow::builder().application(app).build();
window.init_layer_shell();
window.set_namespace(Some("breadbox"));
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
window.set_exclusive_zone(0);
let close_all: Rc<dyn Fn()> = Rc::new({
let w = window.clone();
move || {
cleanup_pid();
w.close();
}
});
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("launcher-bg");
vbox.set_halign(gtk4::Align::Center);
vbox.set_valign(gtk4::Align::Start);
vbox.set_margin_top(120);
vbox.set_size_request(600, -1);
let search = SearchEntry::new();
search.set_placeholder_text(Some("breadbox"));
vbox.append(&search);
let scroll = ScrolledWindow::new();
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
scroll.set_max_content_height(480);
scroll.set_propagate_natural_height(true);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::Browse);
for entry in &entries {
let row = gtk4::ListBoxRow::new();
let hbox = GBox::new(Orientation::Horizontal, 0);
hbox.set_margin_start(6);
hbox.set_margin_end(6);
hbox.set_valign(gtk4::Align::Center);
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref());
hbox.append(&icon);
let name_lbl = Label::new(Some(&entry.name));
name_lbl.add_css_class("app-name");
name_lbl.set_xalign(0.0);
name_lbl.set_hexpand(true);
name_lbl.set_ellipsize(EllipsizeMode::End);
hbox.append(&name_lbl);
if let Some(ref wm) = entry.wm_class {
let wm_lbl = Label::new(Some(wm));
wm_lbl.add_css_class("app-muted");
wm_lbl.set_xalign(1.0);
hbox.append(&wm_lbl);
}
row.set_child(Some(&hbox));
unsafe { row.set_data("entry", entry.clone()) };
list.append(&row);
}
if let Some(first) = list.row_at_index(0) {
list.select_row(Some(&first));
}
scroll.set_child(Some(&list));
vbox.append(&scroll);
window.set_child(Some(&vbox));
// Filter on keystroke
let list_f = list.clone();
search.connect_changed(move |entry| {
let text = entry.text();
let query = text.as_str();
let mut first_vis: Option<gtk4::ListBoxRow> = None;
let mut i = 0i32;
while let Some(row) = list_f.row_at_index(i) {
let vis = get_row_entry(&row)
.map(|e| {
fuzzy_matches(query, &e.name)
|| e.wm_class
.as_deref()
.is_some_and(|w| fuzzy_matches(query, w))
|| fuzzy_matches(query, &e.exec)
})
.unwrap_or(false);
row.set_visible(vis);
if vis && first_vis.is_none() {
first_vis = Some(row);
}
i += 1;
}
list_f.select_row(first_vis.as_ref());
});
// Keyboard handling — capture phase on window
let key_ctrl = EventControllerKey::new();
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
let close_k = Rc::clone(&close_all);
let list_k = list.clone();
key_ctrl.connect_key_pressed(move |_, key, _, _| {
use gtk4::gdk::Key;
match key {
Key::Escape => {
close_k();
glib::Propagation::Stop
}
Key::Return | Key::KP_Enter => {
if let Some(row) = list_k.selected_row() {
if let Some(entry) = get_row_entry(&row) {
do_launch(&entry);
close_k();
}
}
glib::Propagation::Stop
}
Key::Down => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
let mut i = cur + 1;
loop {
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i += 1,
None => break,
}
}
glib::Propagation::Stop
}
Key::Up => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
let mut i = cur - 1;
loop {
if i < 0 {
break;
}
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i -= 1,
None => break,
}
}
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
window.add_controller(key_ctrl);
// Row click launches
let close_a = Rc::clone(&close_all);
list.connect_row_activated(move |_, row| {
if let Some(entry) = get_row_entry(row) {
do_launch(&entry);
close_a();
}
});
// Click outside launcher panel → close
let close_outside = Rc::clone(&close_all);
let vbox_ref = vbox.clone();
let win_ref = window.clone();
let outside_click = gtk4::GestureClick::new();
outside_click.connect_pressed(move |_, _, x, y| {
if let Some(b) = vbox_ref.compute_bounds(&win_ref) {
if x < b.x() as f64
|| x > (b.x() + b.width()) as f64
|| y < b.y() as f64
|| y > (b.y() + b.height()) as f64
{
close_outside();
}
}
});
window.add_controller(outside_click);
window.connect_destroy(|_| cleanup_pid());
window.present();
search.grab_focus();
});
app.run();
}
// ---- Main -------------------------------------------------------------------
fn main() {
if !toggle_or_continue() {
return;
}
let config = Config::load();
let workspace = get_active_workspace().unwrap_or_default();
let priority = config
.context_for(&workspace)
.map(|c| c.priority.clone())
.unwrap_or_default();
let manifest = load_manifest();
let entries = load_sorted_entries(&manifest, &priority);
let palette = Palette::from_wal().unwrap_or_else(Palette::catppuccin_mocha);
let css = build_css(&palette);
run_ui(entries, css);
}

19
config.example.toml Normal file
View file

@ -0,0 +1,19 @@
# breadbox configuration
# Copy to ~/.config/breadbox/config.toml
# Each [[context]] block matches a Hyprland workspace by name.
# The "default" context is used when no named context matches.
# priority lists app names (case-insensitive substring match against Name= and StartupWMClass=).
# Matched apps are sorted first, in priority order; everything else is alphabetical.
[[context]]
name = "default"
priority = ["firefox", "code", "obsidian", "kitty"]
# [[context]]
# name = "2"
# priority = ["slack", "discord", "telegram"]
# [[context]]
# name = "work"
# priority = ["code", "alacritty", "postman"]

View file

@ -0,0 +1,15 @@
[Unit]
Description=Breadbox icon sync
Documentation=https://github.com/breadway/breadbox
After=network.target
[Service]
Type=oneshot
ExecStart=%h/.cargo/bin/breadbox-sync
StandardOutput=journal
StandardError=journal
# Allow up to 2 minutes for slow icon downloads
TimeoutStartSec=120
[Install]
WantedBy=default.target

View file

@ -1,556 +0,0 @@
use std::{
collections::HashMap,
env,
fs::{self, File},
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
rc::Rc,
time::{SystemTime, UNIX_EPOCH},
};
use gtk4::{
gdk::Display,
glib,
pango::EllipsizeMode,
prelude::*,
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label,
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
const CACHE_TIMEOUT_SECS: u64 = 86400;
const CSS: &str = "
window {
background-color: transparent;
}
.launcher-bg {
background-color: #1e1e2e;
}
searchentry {
background-color: #313244;
color: #cdd6f4;
caret-color: #cba6f7;
border: none;
outline: none;
box-shadow: none;
padding: 12px 16px;
font-size: 15px;
}
listbox {
background-color: transparent;
padding: 4px;
}
row {
padding: 6px 12px;
color: #cdd6f4;
background-color: transparent;
border-radius: 4px;
}
row:selected {
background-color: #45475a;
}
.action {
color: #6c7086;
font-size: 12px;
}
";
// ---- cache helpers --------------------------------------------------------
fn home_dir() -> PathBuf {
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
}
fn cache_path() -> PathBuf {
env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".cache"))
.join("breadbox.cache")
}
fn app_dirs() -> [PathBuf; 2] {
[
PathBuf::from("/usr/share/applications"),
home_dir().join(".local/share/applications"),
]
}
fn mtime(path: &Path) -> u64 {
fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
.unwrap_or(0)
}
fn cache_valid(cache: &Path) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cm = mtime(cache);
now.saturating_sub(cm) < CACHE_TIMEOUT_SECS
&& app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm)
}
fn strip_exec_codes(exec: &str) -> String {
let mut out = String::with_capacity(exec.len());
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
match chars.peek().copied() {
Some('%') => {
chars.next();
out.push('%');
}
Some(n) if n.is_ascii_alphabetic() => {
chars.next();
}
_ => out.push(c),
}
} else {
out.push(c);
}
}
out
}
struct DesktopApp {
name: String,
exec: String,
terminal: bool,
}
fn parse_desktop(path: &Path) -> Option<DesktopApp> {
let file = File::open(path).ok()?;
let mut in_entry = false;
let (mut name, mut exec, mut app_type) = (None::<String>, None::<String>, None::<String>);
let (mut no_display, mut hidden, mut terminal) = (false, false, false);
for line in BufReader::new(file).lines() {
let Ok(raw) = line else { continue };
let s = raw.trim();
if s.starts_with('#') || s.is_empty() {
continue;
}
if s.starts_with('[') {
in_entry = s == "[Desktop Entry]";
continue;
}
if !in_entry {
continue;
}
if let Some(v) = s.strip_prefix("Name=") {
name.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Exec=") {
exec.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Type=") {
app_type.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
no_display = v == "true";
} else if let Some(v) = s.strip_prefix("Hidden=") {
hidden = v == "true";
} else if let Some(v) = s.strip_prefix("Terminal=") {
terminal = v == "true" || v == "1";
}
}
if no_display || hidden {
return None;
}
if app_type.as_deref().is_some_and(|t| t != "Application") {
return None;
}
let name = name?.trim().to_string();
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
if name.is_empty() || exec.is_empty() {
return None;
}
Some(DesktopApp { name, exec, terminal })
}
fn build_cache(cache: &Path) {
let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp")));
let mut apps: HashMap<String, DesktopApp> = HashMap::new();
for dir in &app_dirs() {
let Ok(entries) = fs::read_dir(dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Some(app) = parse_desktop(&path) {
apps.insert(entry.file_name().to_string_lossy().into_owned(), app);
}
}
}
let mut lines: Vec<String> = apps
.into_values()
.map(|a| {
let prefix = if a.terminal { "term" } else { "app" };
format!("{}\t{}::{}", a.name, prefix, a.exec)
})
.collect();
lines.sort_unstable();
let tmp = cache.with_extension("tmp");
if let Ok(mut f) = File::create(&tmp) {
for line in &lines {
let _ = writeln!(f, "{}", line);
}
let _ = fs::rename(&tmp, cache);
}
}
fn load_entries(cache: &Path) -> Vec<(String, String)> {
fs::read_to_string(cache)
.unwrap_or_default()
.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, '\t');
let name = parts.next()?.to_string();
let action = parts.next()?.to_string();
(!name.is_empty() && !action.is_empty()).then_some((name, action))
})
.collect()
}
// ---- launch ---------------------------------------------------------------
fn pick_terminal() -> String {
if let Ok(t) = env::var("TERMINAL") {
if !t.is_empty() {
return t;
}
}
let path_var = env::var("PATH").unwrap_or_default();
for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] {
if path_var.split(':').any(|d| Path::new(d).join(t).exists()) {
return t.to_string();
}
}
"xterm".to_string()
}
fn do_launch(action: &str) {
if let Some(cmd) = action.strip_prefix("app::") {
let _ = Command::new("bash")
.args(["-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
} else if let Some(cmd) = action.strip_prefix("term::") {
let term = pick_terminal();
let _ = Command::new(&term)
.args(["-e", "bash", "-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
// ---- fuzzy matching -------------------------------------------------------
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
if pattern.is_empty() {
return true;
}
let mut chars = text.chars();
for pc in pattern.chars() {
let pl = pc.to_lowercase().next().unwrap_or(pc);
if !chars
.by_ref()
.any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl)
{
return false;
}
}
true
}
// ---- toggle via pid file --------------------------------------------------
fn pid_file() -> PathBuf {
env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("breadbox.pid")
}
// Returns false if an existing instance was killed (caller should exit).
fn toggle_or_continue() -> bool {
let pf = pid_file();
if let Ok(content) = fs::read_to_string(&pf) {
if let Ok(pid) = content.trim().parse::<u32>() {
if Path::new(&format!("/proc/{}", pid)).exists() {
let _ = Command::new("kill").arg(pid.to_string()).status();
return false;
}
}
}
let _ = fs::write(&pf, std::process::id().to_string());
true
}
fn cleanup_pid() {
let _ = fs::remove_file(pid_file());
}
// ---- UI -------------------------------------------------------------------
fn get_row_data(row: &gtk4::ListBoxRow, key: &str) -> String {
unsafe {
row.data::<String>(key)
.map(|p| p.as_ref().clone())
.unwrap_or_default()
}
}
fn run_ui(entries: Vec<(String, String)>) {
let app = Application::builder()
.application_id("com.breadway.breadbox")
.build();
app.connect_activate(move |app| {
let provider = CssProvider::new();
provider.load_from_data(CSS);
gtk4::style_context_add_provider_for_display(
&Display::default().expect("no display"),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
// Single full-screen window covers the entire monitor. The window
// background is transparent; only the launcher vbox is visible.
// Clicks outside the vbox are detected via coordinate check and
// close the window. KeyboardMode::Exclusive keeps focus stable so
// pointer-leave events never steal it away.
let window = ApplicationWindow::builder()
.application(app)
.build();
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
window.set_exclusive_zone(0);
let close_all: Rc<dyn Fn()> = Rc::new({
let w = window.clone();
move || {
cleanup_pid();
w.close();
}
});
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("launcher-bg");
vbox.set_halign(gtk4::Align::Center);
vbox.set_valign(gtk4::Align::Start);
vbox.set_size_request(700, -1);
let search = SearchEntry::new();
search.set_placeholder_text(Some("breadbox"));
vbox.append(&search);
let scroll = ScrolledWindow::new();
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
scroll.set_max_content_height(400);
scroll.set_propagate_natural_height(true);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::Browse);
for (name, action) in &entries {
let row = gtk4::ListBoxRow::new();
let hbox = GBox::new(Orientation::Horizontal, 8);
hbox.set_margin_start(4);
hbox.set_margin_end(4);
let name_lbl = Label::new(Some(name));
name_lbl.set_xalign(0.0);
name_lbl.set_hexpand(true);
hbox.append(&name_lbl);
let action_lbl = Label::new(Some(action));
action_lbl.add_css_class("action");
action_lbl.set_xalign(1.0);
action_lbl.set_ellipsize(EllipsizeMode::End);
action_lbl.set_max_width_chars(50);
hbox.append(&action_lbl);
row.set_child(Some(&hbox));
unsafe {
row.set_data("name", name.clone());
row.set_data("action", action.clone());
}
list.append(&row);
}
if let Some(first) = list.row_at_index(0) {
list.select_row(Some(&first));
}
scroll.set_child(Some(&list));
vbox.append(&scroll);
window.set_child(Some(&vbox));
// Filter rows on every keystroke
let list_f = list.clone();
search.connect_changed(move |entry| {
let text = entry.text();
let query = text.as_str();
let mut first_vis: Option<gtk4::ListBoxRow> = None;
let mut i = 0i32;
while let Some(row) = list_f.row_at_index(i) {
let name = get_row_data(&row, "name");
let vis = fuzzy_matches(query, &name);
row.set_visible(vis);
if vis && first_vis.is_none() {
first_vis = Some(row);
}
i += 1;
}
list_f.select_row(first_vis.as_ref());
});
// Keyboard: Esc, Enter, arrows — capture phase on window so we
// intercept before SearchEntry's own handlers consume them
let key_ctrl = EventControllerKey::new();
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
let close_k = Rc::clone(&close_all);
let list_k = list.clone();
key_ctrl.connect_key_pressed(move |_, key, _, _| {
use gtk4::gdk::Key;
match key {
Key::Escape => {
close_k();
glib::Propagation::Stop
}
Key::Return | Key::KP_Enter => {
if let Some(row) = list_k.selected_row() {
let action = get_row_data(&row, "action");
if !action.is_empty() {
do_launch(&action);
close_k();
}
}
glib::Propagation::Stop
}
Key::Down => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
let mut i = cur + 1;
loop {
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i += 1,
None => break,
}
}
glib::Propagation::Stop
}
Key::Up => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
let mut i = cur - 1;
loop {
if i < 0 {
break;
}
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i -= 1,
None => break,
}
}
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
window.add_controller(key_ctrl);
// Row click / Enter activates launch
let close_a = Rc::clone(&close_all);
list.connect_row_activated(move |_, row| {
let action = get_row_data(row, "action");
if !action.is_empty() {
do_launch(&action);
close_a();
}
});
// Close when clicking outside the launcher box.
// connect_pressed fires before child widgets handle the click, so
// (x, y) are window-relative and always available. Clicks inside the
// vbox are within its allocation and are ignored; everything outside
// (the transparent full-screen area) dismisses the launcher.
let close_outside = Rc::clone(&close_all);
let vbox_ref = vbox.clone();
let outside_click = gtk4::GestureClick::new();
outside_click.connect_pressed(move |_, _, x, y| {
let a = vbox_ref.allocation();
if x < a.x() as f64
|| x > (a.x() + a.width()) as f64
|| y < a.y() as f64
|| y > (a.y() + a.height()) as f64
{
close_outside();
}
});
window.add_controller(outside_click);
// Safety net: clean up PID if the window is destroyed by the compositor
window.connect_destroy(|_| cleanup_pid());
window.present();
search.grab_focus();
});
app.run();
}
// ---- main -----------------------------------------------------------------
fn main() {
let cache = cache_path();
if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") {
build_cache(&cache);
return;
}
if !toggle_or_continue() {
return;
}
if !cache.exists() {
build_cache(&cache);
} else if !cache_valid(&cache) {
if let Ok(exe) = env::current_exe() {
let _ = Command::new(exe)
.env("BREADBOX_REBUILD_ONLY", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
let entries = load_entries(&cache);
run_ui(entries);
}