commit 69bc67e29a2676fddce7ba208784500e2b4e3709 Author: Breadway Date: Thu Jul 2 20:59:21 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36f7f5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Rust build artifacts +target/ + +# Editor and IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini + +# Environment and secrets +.env +.env.* +*.env +*.pem +*.key +*.p12 +secrets/ + +# Log files +*.log +logs/ + +# Runtime files +*.sock +*.pid diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..72e7c56 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bread-theme" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#5e58558dd36031433d4a8d8e70c71206c3f1f8f4" +dependencies = [ + "dirs", + "gtk4", + "serde", + "serde_json", +] + +[[package]] +name = "breadclip" +version = "0.1.0" +dependencies = [ + "bread-theme", + "breadclip-core", + "gtk4", + "gtk4-layer-shell", + "serde_json", +] + +[[package]] +name = "breadclip-core" +version = "0.1.0" +dependencies = [ + "dirs", + "hex", + "rusqlite", + "sha2", +] + +[[package]] +name = "breadclipd" +version = "0.1.0" +dependencies = [ + "breadclip-core", +] + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.61.2", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "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 = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f20f8da --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["breadclip-core", "breadclipd", "breadclip"] +resolver = "2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6cf483 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# breadclip + +A Wayland clipboard history manager for Hyprland. It consists of two binaries: + +- **`breadclipd`** — a background daemon that watches the clipboard and persists entries to a local SQLite database. +- **`breadclip`** — a GTK4 Layer Shell popup for browsing and recalling clipboard history. + +## Requirements + +- Rust toolchain (edition 2021) +- GTK 4.12+ +- `gtk4-layer-shell` +- `wl-clipboard` (`wl-paste` and `wl-copy` must be on `$PATH`) +- Hyprland (or any Wayland compositor with Layer Shell support, though the panel-positioning logic is Hyprland-specific) + +## Build + +```sh +git clone https://git.breadway.dev/breadway/breadclip +cd breadclip +cargo build --release +``` + +The compiled binaries are at `target/release/breadclip` and `target/release/breadclipd`. + +## Install + +Copy the binaries to somewhere on your `$PATH`, e.g.: + +```sh +cp target/release/breadclip target/release/breadclipd ~/.local/bin/ +``` + +### systemd user service + +A unit file is provided in `contrib/`: + +```sh +cp contrib/breadclipd.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now breadclipd +``` + +### Hyprland keybind + +Add the contents of `contrib/hyprland.conf` to your `hyprland.conf`: + +``` +layerrule = blur, breadclip +layerrule = ignorezero, breadclip + +bind = $mainMod, V, exec, breadclip +``` + +The `layerrule` lines enable the frosted-glass blur effect behind the panel. + +## Usage + +Start the daemon (or let the systemd unit handle it): + +```sh +breadclipd +``` + +Open the clipboard history popup: + +```sh +breadclip +``` + +Running `breadclip` a second time while it is open closes it (toggle behaviour). + +### Keyboard shortcuts + +| Key | Action | +|-----|--------| +| `Up` / `Down` | Move selection | +| `Enter` | Copy selected entry to clipboard and close | +| `Delete` | Remove selected entry from history | +| `Escape` | Close without copying | + +Clicking an entry copies it and closes the popup. Clicking outside the panel closes it. + +### Filtering and search + +The popup has three filter chips — **All**, **Text**, **Images** — and a search box. The search box filters text entries by content; image entries only appear under the **Images** filter. + +## Data storage + +History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/breadclip/`): + +| Path | Contents | +|------|----------| +| `history.db` | SQLite database of all entries | +| `images/` | PNG files for image entries | + +The daemon keeps at most 200 text entries and 50 image entries, trimming oldest entries automatically. + +## Theming + +`breadclip` inherits its colour palette from `bread-theme`. The panel renders with an 80% opaque background so Hyprland's `layerrule = blur` can show a frosted-glass effect behind it. diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..0c65fff --- /dev/null +++ b/bakery.toml @@ -0,0 +1,15 @@ +name = "breadclip" +description = "Wayland clipboard history manager for Hyprland — daemon + GTK4 popup" +binaries = ["breadclip", "breadclipd"] +system_deps = ["gtk4", "gtk4-layer-shell", "wl-clipboard"] +optional_system_deps = ["hyprland"] +bread_deps = [] + +[[service]] +unit = "breadclipd.service" +enable = true + +[install] +post_install = [ + "systemctl --user is-active --quiet breadclipd || systemctl --user start breadclipd", +] diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml new file mode 100644 index 0000000..4ae8786 --- /dev/null +++ b/breadclip-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "breadclip-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +sha2 = "0.10" +hex = "0.4" +dirs = "5" diff --git a/breadclip-core/src/lib.rs b/breadclip-core/src/lib.rs new file mode 100644 index 0000000..c1aab20 --- /dev/null +++ b/breadclip-core/src/lib.rs @@ -0,0 +1,165 @@ +use rusqlite::{params, Connection, Result as SqlResult}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct ClipEntry { + pub id: i64, + pub timestamp: i64, + pub mime_type: String, + pub content: Option, + pub image_path: Option, + pub content_hash: String, +} + +pub struct HistoryDb { + conn: Connection, +} + +impl HistoryDb { + pub fn open() -> SqlResult { + let dir = data_dir(); + std::fs::create_dir_all(&dir).ok(); + let conn = Connection::open(dir.join("history.db"))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + mime_type TEXT NOT NULL, + content TEXT, + image_path TEXT, + content_hash TEXT NOT NULL UNIQUE + ); + CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);", + )?; + Ok(Self { conn }) + } + + pub fn insert_text(&self, text: &str) -> SqlResult<()> { + let hash = sha256_hex(text.as_bytes()); + let ts = unix_now(); + self.conn.execute( + "INSERT INTO history (timestamp, mime_type, content, content_hash) + VALUES (?1, 'text/plain', ?2, ?3) + ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", + params![ts, text, hash], + )?; + self.trim(200, 50) + } + + pub fn insert_image(&self, png_bytes: &[u8]) -> SqlResult<()> { + let hash = sha256_hex(png_bytes); + let images_dir = data_dir().join("images"); + std::fs::create_dir_all(&images_dir).ok(); + // Use first 16 hex chars for the filename (collision-safe for 50 images) + let path = images_dir.join(format!("{}.png", &hash[..16])); + if !path.exists() { + std::fs::write(&path, png_bytes).ok(); + } + let path_str = path.to_string_lossy().to_string(); + let ts = unix_now(); + self.conn.execute( + "INSERT INTO history (timestamp, mime_type, image_path, content_hash) + VALUES (?1, 'image/png', ?2, ?3) + ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", + params![ts, path_str, hash], + )?; + self.trim(200, 50) + } + + pub fn list_entries(&self, limit: usize) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, timestamp, mime_type, content, image_path, content_hash + FROM history ORDER BY timestamp DESC LIMIT ?1", + )?; + let rows = stmt.query_map([limit as i64], |row| { + Ok(ClipEntry { + id: row.get(0)?, + timestamp: row.get(1)?, + mime_type: row.get(2)?, + content: row.get(3)?, + image_path: row.get(4)?, + content_hash: row.get(5)?, + }) + })? + .collect::>>(); + rows + } + + pub fn delete_entry(&self, id: i64) -> SqlResult<()> { + // Clean up image file if present + let image_path: Option = self + .conn + .query_row( + "SELECT image_path FROM history WHERE id = ?1", + [id], + |row| row.get(0), + ) + .ok() + .flatten(); + if let Some(p) = image_path { + let _ = std::fs::remove_file(p); + } + self.conn.execute("DELETE FROM history WHERE id = ?1", [id])?; + Ok(()) + } + + fn trim(&self, max_text: usize, max_images: usize) -> SqlResult<()> { + self.conn.execute( + "DELETE FROM history + WHERE mime_type = 'text/plain' + AND id NOT IN ( + SELECT id FROM history WHERE mime_type = 'text/plain' + ORDER BY timestamp DESC LIMIT ?1 + )", + [max_text as i64], + )?; + // Collect old image paths before deleting rows + let old_paths: Vec = { + let mut stmt = self.conn.prepare( + "SELECT image_path FROM history + WHERE mime_type = 'image/png' + AND image_path IS NOT NULL + AND id NOT IN ( + SELECT id FROM history WHERE mime_type = 'image/png' + ORDER BY timestamp DESC LIMIT ?1 + )", + )?; + let paths = stmt.query_map([max_images as i64], |row| row.get(0))? + .collect::>>()?; + paths + }; + for path in old_paths { + let _ = std::fs::remove_file(path); + } + self.conn.execute( + "DELETE FROM history + WHERE mime_type = 'image/png' + AND id NOT IN ( + SELECT id FROM history WHERE mime_type = 'image/png' + ORDER BY timestamp DESC LIMIT ?1 + )", + [max_images as i64], + )?; + Ok(()) + } +} + +pub fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + hex::encode(hasher.finalize()) +} + +pub fn data_dir() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join("breadclip") +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml new file mode 100644 index 0000000..978d108 --- /dev/null +++ b/breadclip/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "breadclip" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "breadclip" +path = "src/main.rs" + +[dependencies] +breadclip-core = { path = "../breadclip-core" } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } +gtk4 = { version = "0.11", features = ["v4_12"] } +gtk4-layer-shell = "0.8" +serde_json = "1" diff --git a/breadclip/src/css.rs b/breadclip/src/css.rs new file mode 100644 index 0000000..a76cf17 --- /dev/null +++ b/breadclip/src/css.rs @@ -0,0 +1,119 @@ +use bread_theme::{hex_to_rgba, ink_on, tokens::*, Palette}; + +pub fn build_css(p: &Palette) -> String { + let bg_panel = hex_to_rgba(&p.background, 0.80); + let surface = hex_to_rgba(&p.color0, 0.85); + let on_bg = ink_on(&p.background); + let on_surface = ink_on(&p.color0); + let accent = &p.color4; + let on_accent = ink_on(&p.color4); + let overlay = &p.color7; + let on_overlay = ink_on(&p.color7); + + // `window` must be transparent so Hyprland's `layerrule = blur` can render + // the frosted effect behind the panel. The panel itself carries the 80% fill. + // + // Row colours are set on `.clip-row` (not the shared `row` selector) so + // they override the shared `row:selected { background: @accent }` rule and + // use the muted surface tone instead — consistent with breadbox. + format!( + "window {{ background-color: transparent; }}\ + .clip-panel {{\ + background-color: {bg_panel};\ + color: {on_bg};\ + border-radius: {r1}px;\ + box-shadow: 0 8px 32px rgba(0,0,0,0.6);\ + }}\ + .clip-row {{\ + background-color: transparent;\ + border-radius: {r2}px;\ + padding: {sm}px {md}px;\ + color: {on_bg};\ + }}\ + .clip-row:hover {{\ + background-color: {surface};\ + color: {on_surface};\ + }}\ + .clip-row:selected {{\ + background-color: {surface};\ + color: {on_surface};\ + }}\ + .clip-text-preview {{\ + font-size: {base}px;\ + }}\ + .clip-meta {{\ + font-size: {sec}px;\ + opacity: 0.6;\ + }}\ + .clip-thumbnail {{\ + min-width: 48px;\ + min-height: 48px;\ + max-width: 48px;\ + max-height: 48px;\ + }}\ + .chips-row {{\ + padding: {xs}px {sm}px;\ + }}\ + .chip {{\ + background-color: {overlay};\ + color: {on_overlay};\ + border: none;\ + background-image: none;\ + box-shadow: none;\ + outline: none;\ + border-radius: 999px;\ + padding: {xs}px {md}px;\ + font-size: {sec}px;\ + }}\ + .chip:hover {{\ + background-color: {overlay};\ + background-image: none;\ + box-shadow: none;\ + }}\ + .chip.active {{\ + background-color: {accent};\ + color: {on_accent};\ + background-image: none;\ + box-shadow: none;\ + }}\ + .chip.active:hover {{\ + background-color: {accent};\ + background-image: none;\ + box-shadow: none;\ + }}\ + searchentry {{\ + background-color: {surface_raw};\ + color: {on_surface};\ + caret-color: {accent};\ + border: none;\ + outline: none;\ + box-shadow: none;\ + padding: {md}px {lg}px;\ + border-radius: {r1}px {r1}px 0 0;\ + }}\ + listbox {{\ + background-color: transparent;\ + padding: {xs}px;\ + }}\ + row {{ padding: 0; }}\ + row:selected {{ background-color: transparent; }}\ + row:hover {{ background-color: transparent; }}", + bg_panel = bg_panel, + surface = surface, + surface_raw = p.color0, + on_bg = on_bg, + on_surface = on_surface, + accent = accent, + on_accent = on_accent, + overlay = overlay, + on_overlay = on_overlay, + base = FONT_SIZE_BASE, + sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, + sm = SPACE_SM, + md = SPACE_MD, + lg = SPACE_LG, + r1 = RADIUS_PRIMARY, + r2 = RADIUS_SECONDARY, + ) +} diff --git a/breadclip/src/main.rs b/breadclip/src/main.rs new file mode 100644 index 0000000..2df0dd5 --- /dev/null +++ b/breadclip/src/main.rs @@ -0,0 +1,513 @@ +mod css; +mod position; + +use breadclip_core::{ClipEntry, HistoryDb}; +use bread_theme::{load_palette}; +use gtk4::{ + glib, + pango::EllipsizeMode, + prelude::*, + Application, ApplicationWindow, Box as GBox, Button, ContentFit, EventControllerKey, Label, + ListBox, Orientation, Picture, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, +}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use std::{ + cell::Cell, + env, + fs, + io::Write, + path::PathBuf, + process::{Command, Stdio}, + rc::Rc, +}; + +const PANEL_WIDTH: i32 = 520; +const MAX_ENTRIES: usize = 200; +const PANEL_GAP: i32 = 8; // gap between focused window bottom and panel top +// Worst-case panel height (search + chips + full-height list), used to keep +// the panel fully on-screen before its natural size is known. +const PANEL_HEIGHT_ESTIMATE: i32 = 580; + +#[derive(Clone, Copy, PartialEq)] +enum Filter { + All, + Text, + Images, +} + +// ---- Helpers ---------------------------------------------------------------- + +fn format_timestamp(ts: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let diff = now.saturating_sub(ts); + if diff < 60 { + "just now".to_string() + } else if diff < 3600 { + format!("{}m ago", diff / 60) + } else if diff < 86400 { + format!("{}h ago", diff / 3600) + } else { + format!("{}d ago", diff / 86400) + } +} + +fn get_row_entry(row: >k4::ListBoxRow) -> Option { + unsafe { row.data::("entry").map(|p| p.as_ref().clone()) } +} + +fn entry_matches(entry: &ClipEntry, query: &str, filter: Filter) -> bool { + let is_image = entry.mime_type.starts_with("image/"); + let type_ok = match filter { + Filter::All => true, + Filter::Text => !is_image, + Filter::Images => is_image, + }; + if !type_ok { + return false; + } + if query.is_empty() { + return true; + } + if is_image { + // Images are unsearchable by text; only show in Images filter + return filter == Filter::Images; + } + entry + .content + .as_deref() + .map(|t| t.to_lowercase().contains(&query.to_lowercase())) + .unwrap_or(false) +} + +fn refresh_list(list: &ListBox, query: &str, filter: Filter) { + let mut i = 0i32; + while let Some(row) = list.row_at_index(i) { + let vis = get_row_entry(&row) + .map(|e| entry_matches(&e, query, filter)) + .unwrap_or(false); + row.set_visible(vis); + i += 1; + } + let first = (0i32..).find_map(|j| list.row_at_index(j).filter(|r| r.is_visible())); + list.select_row(first.as_ref()); +} + +fn build_row(entry: &ClipEntry) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + row.add_css_class("clip-row"); + + let hbox = GBox::new(Orientation::Horizontal, 8); + hbox.set_valign(gtk4::Align::Center); + + if entry.mime_type.starts_with("image/") { + // Thumbnail + let thumb = if let Some(ref path) = entry.image_path { + let pic = Picture::new(); + pic.set_filename(Some(path.as_str())); + pic.set_can_shrink(true); + pic.set_content_fit(ContentFit::Contain); + pic.add_css_class("clip-thumbnail"); + pic + } else { + let pic = Picture::new(); + pic.add_css_class("clip-thumbnail"); + pic + }; + hbox.append(&thumb); + + let type_lbl = Label::new(Some("image")); + type_lbl.add_css_class("clip-meta"); + type_lbl.set_hexpand(true); + type_lbl.set_xalign(0.0); + hbox.append(&type_lbl); + } else { + // Text preview — first line, truncated + let preview = entry + .content + .as_deref() + .unwrap_or("") + .lines() + .next() + .unwrap_or("") + .chars() + .take(120) + .collect::(); + + let text_lbl = Label::new(Some(&preview)); + text_lbl.add_css_class("clip-text-preview"); + text_lbl.set_hexpand(true); + text_lbl.set_xalign(0.0); + text_lbl.set_ellipsize(EllipsizeMode::End); + text_lbl.set_single_line_mode(true); + hbox.append(&text_lbl); + } + + let ts_lbl = Label::new(Some(&format_timestamp(entry.timestamp))); + ts_lbl.add_css_class("clip-meta"); + ts_lbl.set_xalign(1.0); + hbox.append(&ts_lbl); + + row.set_child(Some(&hbox)); + unsafe { row.set_data("entry", entry.clone()) }; + row +} + +fn do_copy(entry: &ClipEntry) { + match entry.mime_type.as_str() { + "image/png" => { + if let Some(ref path) = entry.image_path { + if let Ok(file) = fs::File::open(path) { + let _ = Command::new("wl-copy") + .args(["--type", "image/png"]) + .stdin(Stdio::from(file)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } + } + } + _ => { + if let Some(ref content) = entry.content { + if let Ok(mut child) = Command::new("wl-copy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(content.as_bytes()); + } + } + } + } + } +} + +// ---- PID file toggle (single-instance, matches breadbox pattern) ------------- + +fn pid_file() -> PathBuf { + env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("breadclip.pid") +} + +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::() { + let alive = fs::read_to_string(format!("/proc/{}/comm", pid)) + .map(|s| s.trim() == "breadclip") + .unwrap_or(false); + if alive { + 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 run_ui(entries: Vec) { + let app = Application::builder() + .application_id("com.breadway.breadclip") + .build(); + + app.connect_activate(move |app| { + bread_theme::gtk::apply_shared(); + bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette())); + + // Full-screen transparent overlay; panel widget is positioned inside it. + let window = ApplicationWindow::builder().application(app).build(); + window.init_layer_shell(); + window.set_namespace(Some("breadclip")); + 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); + + // ---- Position panel relative to the active window ---- + // If a non-fullscreen window is focused, anchor the panel just below it. + // Otherwise, centre the panel on screen. + let active_win = position::get_active_window(); + let monitor = position::get_focused_monitor(); + + let panel = GBox::new(Orientation::Vertical, 0); + panel.add_css_class("clip-panel"); + panel.set_size_request(PANEL_WIDTH, -1); + + if let Some(ref win) = active_win { + let (mon_x, mon_y, mon_w, mon_h) = monitor + .as_ref() + .map(|m| (m.x, m.y, m.width, m.height)) + .unwrap_or((0, 0, 1920, 1080)); + + // Clamp horizontally so the panel never runs off the left/right + // edge of the focused monitor. + let clamped_left = win + .x + .min(mon_x + mon_w - PANEL_WIDTH - PANEL_GAP) + .max(mon_x + PANEL_GAP); + + // Prefer anchoring below the window, but flip above it when there + // isn't enough room underneath (e.g. a maximized/tiled window + // with a text box near the bottom of the screen) — otherwise the + // panel gets pushed off-screen and never becomes visible. + let space_below = (mon_y + mon_h) - (win.y + win.height + PANEL_GAP); + let space_above = win.y - mon_y - PANEL_GAP; + let top = if space_below >= PANEL_HEIGHT_ESTIMATE || space_below >= space_above { + win.y + win.height + PANEL_GAP + } else { + win.y - PANEL_GAP - PANEL_HEIGHT_ESTIMATE + }; + let clamped_top = top + .min(mon_y + mon_h - PANEL_HEIGHT_ESTIMATE - PANEL_GAP) + .max(mon_y + PANEL_GAP); + + panel.set_halign(gtk4::Align::Start); + panel.set_valign(gtk4::Align::Start); + panel.set_margin_top(clamped_top); + panel.set_margin_start(clamped_left); + } else { + panel.set_halign(gtk4::Align::Center); + panel.set_valign(gtk4::Align::Center); + } + + // ---- Search entry ---- + let search = SearchEntry::new(); + search.set_placeholder_text(Some("Search clipboard…")); + panel.append(&search); + + // ---- Filter chips ---- + let chips_box = GBox::new(Orientation::Horizontal, 4); + chips_box.add_css_class("chips-row"); + + let chip_labels = ["All", "Text", "Images"]; + let chip_filters = [Filter::All, Filter::Text, Filter::Images]; + let chip_buttons: Vec