can't be bothered writing a commit message
This commit is contained in:
parent
f4996e495f
commit
d823edc14e
12 changed files with 1971 additions and 573 deletions
675
Cargo.lock
generated
675
Cargo.lock
generated
|
|
@ -2,12 +2,24 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
|
|
@ -18,8 +30,27 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
|||
name = "breadbox"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"breadbox-shared",
|
||||
"gtk4",
|
||||
"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]]
|
||||
|
|
@ -45,6 +76,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.7"
|
||||
|
|
@ -55,6 +96,32 @@ dependencies = [
|
|||
"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]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -71,6 +138,31 @@ dependencies = [
|
|||
"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]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
|
|
@ -191,6 +283,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "gio"
|
||||
version = "0.22.6"
|
||||
|
|
@ -218,7 +321,7 @@ dependencies = [
|
|||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -441,6 +544,109 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
|
|
@ -451,6 +657,12 @@ dependencies = [
|
|||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
|
|
@ -463,6 +675,12 @@ version = "0.2.186"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
|
|
@ -484,6 +702,22 @@ dependencies = [
|
|||
"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]]
|
||||
name = "pango"
|
||||
version = "0.22.6"
|
||||
|
|
@ -508,6 +742,12 @@ dependencies = [
|
|||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -520,13 +760,22 @@ version = "0.3.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -547,6 +796,20 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
|
|
@ -556,12 +819,57 @@ dependencies = [
|
|||
"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]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
|
|
@ -582,6 +890,28 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
|
|
@ -591,6 +921,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -603,6 +945,18 @@ version = "1.15.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
|
@ -614,6 +968,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "system-deps"
|
||||
version = "7.0.8"
|
||||
|
|
@ -623,7 +988,7 @@ dependencies = [
|
|||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
|
|
@ -633,6 +998,28 @@ version = "0.13.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
|
|
@ -641,11 +1028,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
|||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"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]]
|
||||
|
|
@ -657,6 +1053,20 @@ 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.11+spec-1.1.0"
|
||||
|
|
@ -664,9 +1074,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -675,9 +1085,15 @@ version = "1.1.2+spec-1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
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]]
|
||||
name = "toml_writer"
|
||||
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"
|
||||
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]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
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.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
|
@ -711,6 +1200,79 @@ dependencies = [
|
|||
"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]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
|
|
@ -720,8 +1282,103 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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"
|
||||
|
|
|
|||
11
Cargo.toml
11
Cargo.toml
|
|
@ -1,8 +1,3 @@
|
|||
[package]
|
||||
name = "breadbox"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
gtk4 = "0.11"
|
||||
gtk4-layer-shell = "0.8"
|
||||
[workspace]
|
||||
members = ["breadbox-shared", "breadbox-sync", "breadbox"]
|
||||
resolver = "2"
|
||||
|
|
|
|||
124
README.md
Normal file
124
README.md
Normal 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).
|
||||
9
breadbox-shared/Cargo.toml
Normal file
9
breadbox-shared/Cargo.toml
Normal 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
261
breadbox-shared/src/lib.rs
Normal 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
14
breadbox-sync/Cargo.toml
Normal 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
281
breadbox-sync/src/main.rs
Normal 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
15
breadbox/Cargo.toml
Normal 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
564
breadbox/src/main.rs
Normal 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: >k4::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
19
config.example.toml
Normal 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"]
|
||||
15
packaging/breadbox-sync.service
Normal file
15
packaging/breadbox-sync.service
Normal 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
|
||||
556
src/main.rs
556
src/main.rs
|
|
@ -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: >k4::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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue