diff --git a/.github/workflows/qml-migration.yml b/.github/workflows/qml-migration.yml new file mode 100644 index 0000000..2a154a3 --- /dev/null +++ b/.github/workflows/qml-migration.yml @@ -0,0 +1,50 @@ +name: QML Migration Check + +on: + pull_request: + push: + branches: + - main + - feat/qml-ui-migration + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install native dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + cmake \ + ninja-build \ + pkg-config \ + libgtk-4-dev \ + libhidapi-dev \ + libudev-dev \ + qt6-base-dev \ + qt6-declarative-dev \ + qt6-declarative-dev-tools \ + qt6-tools-dev \ + qml6-module-qtquick \ + qml6-module-qtquick-controls \ + qml6-module-qtquick-layouts \ + lua5.4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Test + run: cargo test --locked + + - name: Build + run: cargo build --locked diff --git a/Cargo.lock b/Cargo.lock index fac6783..432a8db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "autocfg" version = "1.5.1" @@ -14,12 +35,36 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cairo-rs" version = "0.22.0" @@ -50,6 +95,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -69,6 +116,96 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-format" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696283b40e1a39d208ee614b92e5f6521d16962edeb47c48372585ec92419943" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crc" version = "3.4.0" @@ -84,6 +221,167 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "cxx" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d93a9baeb9798b2e8ff0de24463b9e5cf9a0dd0a88f5ea02afc9ea35e7abae7" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32b61d087102ad38552a458eb61d24ff13cdf29d6e06f1ec85c1e1c066c0457" +dependencies = [ + "cc", + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.5", +] + +[[package]] +name = "cxx-gen" +version = "0.7.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a4480ca3e9a70cfa4d27d3ba4ddecd482880b38add6ebbeed6d62485ce2116" +dependencies = [ + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "cxx-qt" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af6a52cbe33f8a23c6cc7dafb628819b89af6536102c3976f5efc33a038d4a82" +dependencies = [ + "cxx", + "cxx-qt-build", + "cxx-qt-macro", + "qt-build-utils", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "cxx-qt-build" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb2bb9e19b427e79ec5db1d88cde2f44f2d4190e5a0177e2d599ceb9277b670" +dependencies = [ + "cc", + "codespan-reporting 0.11.1", + "cxx-gen", + "cxx-qt-gen", + "proc-macro2", + "qt-build-utils", + "quote", + "serde", + "serde_json", +] + +[[package]] +name = "cxx-qt-gen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6de19cacc58672a9fb70fad66e3d62b6aeb4b2f323b6251aeb99b50c6b6aa0c" +dependencies = [ + "clang-format", + "convert_case", + "cxx-gen", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "cxx-qt-lib" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c9fea8106d4bbe438d15c1242acd38f4869ca4f6d2a0b19e1f0dbbf23a50e" +dependencies = [ + "bytes", + "chrono", + "cxx", + "cxx-qt", + "cxx-qt-build", + "http", + "image 0.24.9", + "image 0.25.10", + "qt-build-utils", + "rgb", + "serde", + "time", + "url", + "uuid", +] + +[[package]] +name = "cxx-qt-macro" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb794f10a4d27bfbd810eb8bf7fd7d9a0b0b5cefcd86a4c7b13ae3f8d4fa0169" +dependencies = [ + "cxx-qt-gen", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25784b9f840a76b9b5151553ad70fc2abb3ec3b21fe379c5c7b029b2e0b91faf" +dependencies = [ + "clap", + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6fafa9a47c12b9a48140893e4850480bd64a091b6f6467b9b6d30df61c3ec5" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83246854d73be26cb7c32e35779698a8828a8f17654bd7fd1ebf4cbca414dfb2" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dirs" version = "6.0.0" @@ -105,6 +403,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -127,6 +436,21 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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" @@ -167,7 +491,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -257,6 +581,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "gio" version = "0.22.8" @@ -317,7 +652,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -423,7 +758,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -470,130 +805,393 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + [[package]] name = "hyprbinds" version = "0.1.0" dependencies = [ + "cxx", + "cxx-qt", + "cxx-qt-build", + "cxx-qt-lib", "dirs", "gtk4", "hidapi", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "toml_edit 0.22.27", "via-protocol", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "equivalent", - "hashbrown", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] [[package]] -name = "libc" -version = "0.2.186" +name = "icu_collections" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "libredox" -version = "0.1.18" +name = "icu_locale_core" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - "libc", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "lzma-rs" -version = "0.3.0" +name = "icu_normalizer" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ - "byteorder", - "crc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "icu_normalizer_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] -name = "memoffset" -version = "0.9.1" +name = "icu_properties" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ - "autocfg", + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[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" +name = "icu_properties_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] -name = "pango" -version = "0.22.8" +name = "icu_provider" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ - "gio", - "glib", - "pango-sys", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "pango-sys" -version = "0.22.0" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "image" +version = "0.24.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", +] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "image" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" +dependencies = [ + "gio", + "glib", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "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" +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 = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -605,6 +1203,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qt-build-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c97911f105c362cbc035f631be7b43e44d9bca3a3aed2434260ba896f23d6b4" +dependencies = [ + "anyhow", + "cc", + "semver", + "serde", + "thiserror 1.0.69", + "which", +] + [[package]] name = "quote" version = "1.0.46" @@ -614,15 +1232,30 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_users" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", ] [[package]] @@ -634,6 +1267,18 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + [[package]] name = "semver" version = "1.0.28" @@ -667,7 +1312,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -710,6 +1355,24 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.118" @@ -721,6 +1384,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "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 2.0.118", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -740,13 +1425,42 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[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 2.0.118", ] [[package]] @@ -757,7 +1471,36 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] @@ -854,7 +1597,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -872,6 +1615,52 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[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 = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -887,7 +1676,7 @@ dependencies = [ "hidapi", "lzma-rs", "serde_json", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -897,12 +1686,128 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -930,6 +1835,89 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +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 2.0.118", + "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 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 637de8a..4abb335 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ keywords = ["hyprland", "wayland", "gtk", "keybinds", "config"] categories = ["gui", "config"] [dependencies] +cxx = "1" +cxx-qt = "0.10.0" +cxx-qt-lib = { version = "0.10.0", features = ["full"] } dirs = "6.0.0" gtk4 = { version = "0.11.4", features = ["v4_12"] } hidapi = "2.6.6" @@ -17,3 +20,6 @@ serde_json = "1.0.150" thiserror = "2.0.18" toml_edit = "0.22" via-protocol = "0.1.0" + +[build-dependencies] +cxx-qt-build = { version = "0.10.0", features = ["link_qt_object_files"] } diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index c40f3b2..0fbded4 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -2,22 +2,28 @@ Runtime and build dependencies for **hyprbinds**. Distro package names below are Arch/CachyOS-oriented; equivalents exist on other distros. -## Build (Rust / GTK) +## Build (Rust / GTK + Qt/QML) | Package | Why | |---|---| | `rust` / `cargo` | Compile the app | -| `gtk4` | UI toolkit | -| `pkgconf` | Find GTK via pkg-config | -| `gcc` / `base-devel` | Link C dependencies | +| `gtk4` | Legacy/developer GTK compatibility UI (`--gtk`) | +| `qt6-base` | Qt runtime/base libraries for the primary QML UI | +| `qt6-declarative` | Qt Quick / QML runtime and development files | +| `qt6-tools` | Qt build tooling used by the QML bridge | +| `cmake` / `ninja` | Native build tooling used by CXX-Qt | +| `pkgconf` | Find GTK/Qt native dependencies | +| `gcc` / `base-devel` | Compile/link native dependencies | -Cargo crates (see `Cargo.toml`): `gtk4`, `serde`, `serde_json`, `dirs`, `thiserror`, `hidapi`, `via-protocol`. +Cargo crates (see `Cargo.toml`): `gtk4`, `cxx`, `cxx-qt`, `cxx-qt-lib`, `serde`, `serde_json`, `dirs`, `thiserror`, `hidapi`, `via-protocol`. ```bash -sudo pacman -S rust gtk4 pkgconf base-devel +sudo pacman -S rust gtk4 qt6-base qt6-declarative qt6-tools cmake ninja pkgconf base-devel cargo build --release ``` +The primary desktop launch path is the QML UI. The GTK UI remains available with `hyprbinds --gtk` as a compatibility path and for the experimental companion studios while those surfaces are migrated. + ## VIA hardware keymap (experimental) Talks to VIA-enabled QMK keyboards over USB HID (usage page `0xFF60`). On Linux you need read/write access to the keyboard's `hidraw` node — either run as a user in the right group or install a udev rule, then re-plug: diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..20230e0 --- /dev/null +++ b/build.rs @@ -0,0 +1,56 @@ +use cxx_qt_build::{CxxQtBuilder, QmlModule}; + +fn main() { + CxxQtBuilder::new_qml_module( + QmlModule::new("dev.hyprbinds.ui") + .qml_files([ + "qml/Main.qml", + "qml/AppTopBar.qml", + "qml/AppSidebar.qml", + "qml/PageHeader.qml", + "qml/StatusBar.qml", + "qml/OverviewPage.qml", + "qml/SummaryCard.qml", + "qml/EnvironmentPage.qml", + "qml/VariablesPage.qml", + "qml/StartupPage.qml", + "qml/SubmapsPage.qml", + "qml/BindsPage.qml", + "qml/RuleCatalogPage.qml", + "qml/SpecCatalogPage.qml", + "qml/ConfigPage.qml", + "qml/HealthPage.qml", + "qml/LogsPage.qml", + "qml/ImportExportPage.qml", + "qml/DeveloperToolsPage.qml", + "qml/GlassPanel.qml", + "qml/GlassField.qml", + "qml/GlassTextArea.qml", + "qml/GlassButton.qml", + "qml/IconButton.qml", + "qml/NavItem.qml", + "qml/UiIcon.qml", + "qml/HyprbindTheme.qml", + "qml/LookFeelPage.qml", + "qml/SettingsCard.qml", + "qml/SettingField.qml", + "qml/SettingToggle.qml", + "qml/SettingSlider.qml", + ]) + .depend("QtQuick") + .depend("QtQuick.Controls") + .depend("QtQuick.Layouts"), + ) + .files([ + "src/qml_bridge.rs", + "src/qml_startup_bridge.rs", + "src/qml_submaps_bridge.rs", + "src/qml_health_bridge.rs", + "src/qml_logs_bridge.rs", + "src/qml_catalog_bridge.rs", + "src/qml_window_bridge.rs", + ]) + .qt_module("Quick") + .qt_module("QuickControls2") + .build(); +} diff --git a/qml/AppSidebar.qml b/qml/AppSidebar.qml new file mode 100644 index 0000000..fff536c --- /dev/null +++ b/qml/AppSidebar.qml @@ -0,0 +1,163 @@ +import QtQuick +import QtQuick.Controls + +Rectangle { + id: root + + property bool darkMode: true + property string currentPage: "Overview" + readonly property bool compact: width < 110 + signal pageSelected(string page) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + color: theme.sidebarFill + + Rectangle { + width: 1 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + color: theme.divider + } + + GlassField { + id: navSearch + visible: !root.compact + anchors.top: parent.top + anchors.topMargin: 14 + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 14 + anchors.rightMargin: 14 + implicitHeight: 39 + placeholderText: "Search settings..." + leftPadding: 38 + darkMode: root.darkMode + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + + Text { + visible: root.width >= 240 + text: "Ctrl K" + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + color: theme.alpha(theme.textSecondary, 0.62) + font.family: "Inter" + font.pixelSize: 10 + } + } + + Flickable { + id: navFlick + anchors.top: root.compact ? parent.top : navSearch.bottom + anchors.topMargin: root.compact ? 9 : 10 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 9 + clip: true + contentHeight: navColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: root.compact ? ScrollBar.AlwaysOff : ScrollBar.AsNeeded } + + Column { + id: navColumn + width: navFlick.width + spacing: root.compact ? 2 : 3 + + Repeater { + model: [ + { title: "START HERE", items: [ + { name: "Overview" } + ]}, + { title: "KEYBOARD & CONFIG", items: [ + { name: "Binds" }, + { name: "Variables" }, + { name: "Environment" }, + { name: "Submaps" }, + { name: "Startup" } + ]}, + { title: "RULES", items: [ + { name: "Window rules" }, + { name: "Workspace rules" }, + { name: "Layer rules" } + ]}, + { title: "APPEARANCE & INPUT", items: [ + { name: "Look & Feel" }, + { name: "Config" }, + { name: "Monitors" }, + { name: "Devices" }, + { name: "Animations" }, + { name: "Curves" }, + { name: "Gestures" } + ]}, + { title: "SYSTEM", items: [ + { name: "Health" }, + { name: "Logs" }, + { name: "Import / Export" }, + { name: "Developer tools" } + ]} + ] + + delegate: Column { + id: groupColumn + width: navColumn.width + property var groupData: modelData + spacing: root.compact ? 2 : 3 + + Item { width: 1; height: index === 0 ? 2 : (root.compact ? 7 : 9) } + + Rectangle { + visible: root.compact && index > 0 + width: 28 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: theme.divider + } + + Text { + visible: !root.compact + text: groupColumn.groupData.title + color: theme.alpha(theme.textSecondary, 0.66) + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 1.15 + leftPadding: 20 + height: visible ? 22 : 0 + verticalAlignment: Text.AlignVCenter + } + + Repeater { + model: groupColumn.groupData.items + delegate: NavItem { + property var itemData: modelData + width: root.compact ? 48 : groupColumn.width - 24 + anchors.horizontalCenter: parent.horizontalCenter + text: itemData.name + iconName: theme.pageIcon(itemData.name) + darkMode: root.darkMode + compact: root.compact + selected: root.currentPage === itemData.name + visible: root.compact || !navSearch.text || itemData.name.toLowerCase().indexOf(navSearch.text.toLowerCase()) >= 0 + height: visible ? implicitHeight : 0 + onClicked: { + if (root.currentPage !== itemData.name) + root.pageSelected(itemData.name) + } + } + } + } + } + } + } +} diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml new file mode 100644 index 0000000..435ce38 --- /dev/null +++ b/qml/AppTopBar.qml @@ -0,0 +1,334 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import dev.hyprbinds.ui + +Rectangle { + id: root + + required property var appWindow + property bool darkMode: true + property bool livePreviewEnabled: false + property bool backupAvailable: false + property string backupAge: "" + property bool compositorMaximized: false + + signal toggleThemeRequested() + signal toggleLivePreviewRequested() + signal restoreRequested() + signal reloadRequested() + + HyprbindTheme { id: theme; darkMode: root.darkMode } + WindowControlBridge { id: windowBridge } + + readonly property bool showTagline: width >= 1180 + readonly property bool showActionLabels: width >= 1010 + readonly property bool compact: width < 900 + + height: 55 + color: theme.toolbarFill + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, message: String(error) } } + } + + function toggleMaximize() { + const result = parse(windowBridge.toggleMaximize()) + if (result.ok) { + compositorMaximized = !compositorMaximized + return + } + + if (appWindow.visibility === Window.Maximized || compositorMaximized) { + appWindow.showNormal() + compositorMaximized = false + } else { + appWindow.showMaximized() + compositorMaximized = true + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: theme.divider + } + + MouseArea { + z: 0 + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onPressed: root.appWindow.startSystemMove() + onDoubleClicked: root.toggleMaximize() + } + + Row { + id: brandRow + z: 2 + anchors.left: parent.left + anchors.leftMargin: root.compact ? 14 : 20 + anchors.right: actionRow.left + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + spacing: 16 + clip: true + + Text { + text: "Hyprbind" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: root.compact ? 17 : 18 + font.weight: Font.DemiBold + } + + Rectangle { + visible: root.showTagline + width: 1 + height: 23 + color: theme.divider + anchors.verticalCenter: parent.verticalCenter + } + + Text { + visible: root.showTagline + text: "Configure. Bind. Customize." + color: theme.alpha(theme.textSecondary, 0.72) + font.family: "Inter" + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + id: actionRow + z: 3 + anchors.right: windowControls.left + anchors.rightMargin: root.compact ? 8 : 14 + anchors.verticalCenter: parent.verticalCenter + spacing: root.compact ? 4 : 6 + + Button { + id: themeButton + implicitWidth: root.showActionLabels ? 122 : 42 + implicitHeight: 38 + padding: 0 + flat: true + hoverEnabled: true + scale: pressed ? 0.975 : 1 + Behavior on scale { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } } + + contentItem: Item { + Row { + anchors.centerIn: parent + spacing: 8 + + UiIcon { + width: 17 + height: 17 + name: root.darkMode ? "moon" : "sun" + iconColor: themeButton.hovered + ? theme.alpha(theme.accent, 0.88) + : theme.alpha(theme.textPrimary, 0.86) + anchors.verticalCenter: parent.verticalCenter + Behavior on iconColor { ColorAnimation { duration: 115 } } + } + + Text { + visible: root.showActionLabels + text: root.darkMode ? "Dark mode" : "Light mode" + color: themeButton.hovered + ? theme.textPrimary + : theme.alpha(theme.textPrimary, 0.86) + font.family: "Inter" + font.pixelSize: 12 + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + Behavior on color { ColorAnimation { duration: 115 } } + } + } + } + + background: Rectangle { + anchors.fill: parent + anchors.margins: 2 + radius: 10 + color: themeButton.pressed + ? theme.alpha(theme.controlPressed, 0.82) + : themeButton.hovered + ? theme.alpha(theme.controlHover, 0.86) + : "transparent" + border.width: 0 + Behavior on color { ColorAnimation { duration: 115; easing.type: Easing.OutCubic } } + } + + onClicked: root.toggleThemeRequested() + ToolTip.visible: hovered && !root.showActionLabels + ToolTip.delay: 450 + ToolTip.text: root.darkMode ? "Switch to light theme" : "Switch to dark theme" + } + + Button { + id: livePreviewButton + implicitWidth: root.showActionLabels ? 136 : 42 + implicitHeight: 38 + padding: 0 + flat: true + hoverEnabled: true + scale: pressed ? 0.975 : 1 + Behavior on scale { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } } + + contentItem: Item { + Row { + anchors.centerIn: parent + spacing: 8 + + UiIcon { + width: 17 + height: 17 + name: "realtime" + iconColor: root.livePreviewEnabled + ? theme.accent + : livePreviewButton.hovered + ? theme.alpha(theme.textPrimary, 0.90) + : theme.alpha(theme.textSecondary, 0.82) + anchors.verticalCenter: parent.verticalCenter + Behavior on iconColor { ColorAnimation { duration: 115 } } + } + + Text { + visible: root.showActionLabels + text: "Live preview" + color: root.livePreviewEnabled || livePreviewButton.hovered + ? theme.textPrimary + : theme.alpha(theme.textSecondary, 0.84) + font.family: "Inter" + font.pixelSize: 12 + font.weight: root.livePreviewEnabled ? Font.Medium : Font.Normal + anchors.verticalCenter: parent.verticalCenter + Behavior on color { ColorAnimation { duration: 115 } } + } + + Rectangle { + visible: root.showActionLabels + width: 5 + height: 5 + radius: 2.5 + color: root.livePreviewEnabled + ? theme.accent + : theme.alpha(theme.textSecondary, livePreviewButton.hovered ? 0.42 : 0.24) + anchors.verticalCenter: parent.verticalCenter + Behavior on color { ColorAnimation { duration: 115 } } + } + } + } + + background: Rectangle { + anchors.fill: parent + anchors.margins: 2 + radius: 10 + color: livePreviewButton.pressed + ? theme.alpha(theme.controlPressed, 0.82) + : livePreviewButton.hovered + ? theme.alpha(theme.controlHover, 0.82) + : "transparent" + border.width: 0 + Behavior on color { ColorAnimation { duration: 115; easing.type: Easing.OutCubic } } + } + + onClicked: root.toggleLivePreviewRequested() + ToolTip.visible: hovered && !root.showActionLabels + ToolTip.delay: 450 + ToolTip.text: root.livePreviewEnabled ? "Disable Look & Feel live preview" : "Enable Look & Feel live preview" + } + + Rectangle { width: 1; height: 23; color: theme.divider; anchors.verticalCenter: parent.verticalCenter } + + IconButton { + iconName: "history" + tooltip: root.backupAvailable ? (root.backupAge.length ? "Restore latest snapshot · " + root.backupAge : "Restore latest snapshot") : "No snapshot available" + darkMode: root.darkMode + implicitWidth: 36 + implicitHeight: 36 + enabled: root.backupAvailable + onClicked: root.restoreRequested() + } + + IconButton { + iconName: "reload" + tooltip: "Reload current page" + darkMode: root.darkMode + implicitWidth: 36 + implicitHeight: 36 + onClicked: root.reloadRequested() + } + } + + Row { + id: windowControls + z: 4 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Repeater { + model: [ + { icon: "minimize", tip: "Minimize" }, + { icon: "maximize", tip: "Maximize" }, + { icon: "close", tip: "Close" } + ] + + delegate: Button { + id: windowButton + required property var modelData + readonly property bool isClose: modelData.icon === "close" + readonly property bool isMaximize: modelData.icon === "maximize" + readonly property bool isRestoring: isMaximize && (root.compositorMaximized || root.appWindow.visibility === Window.Maximized) + readonly property string effectiveIcon: isRestoring ? "restore-window" : modelData.icon + readonly property string effectiveTip: isRestoring ? "Restore" : modelData.tip + + implicitWidth: root.compact ? 36 : 39 + implicitHeight: 36 + padding: 0 + flat: true + hoverEnabled: true + scale: pressed ? 0.94 : 1.0 + Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } + + contentItem: Item { + UiIcon { + anchors.centerIn: parent + width: 15 + height: 15 + name: windowButton.effectiveIcon + strokeWidth: 1.30 + iconColor: windowButton.isClose && windowButton.hovered ? "#ffe4e1" : windowButton.hovered ? theme.alpha(theme.textPrimary, 0.96) : theme.alpha(theme.textSecondary, 0.82) + } + } + + background: Rectangle { + radius: 9 + color: windowButton.isClose && windowButton.hovered + ? Qt.rgba(0.72, 0.18, 0.20, windowButton.pressed ? 0.58 : 0.44) + : windowButton.pressed ? theme.controlPressed : windowButton.hovered ? theme.hoverFill : "transparent" + border.width: windowButton.hovered && !windowButton.isClose ? 1 : 0 + border.color: theme.controlRim + Behavior on color { ColorAnimation { duration: 100 } } + } + + onClicked: { + if (modelData.icon === "minimize") root.appWindow.showMinimized() + else if (modelData.icon === "maximize") root.toggleMaximize() + else root.appWindow.close() + } + + ToolTip.visible: hovered + ToolTip.delay: 450 + ToolTip.text: effectiveTip + } + } + } +} diff --git a/qml/BindsPage.qml b/qml/BindsPage.qml new file mode 100644 index 0000000..481b8da --- /dev/null +++ b/qml/BindsPage.qml @@ -0,0 +1,457 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + property bool darkMode: true + property var allItems: [] + property var submaps: [] + property string submapFilter: "" + property bool conflictsOnly: false + property string categoryFilter: "All" + readonly property var categoryFilters: ["All", "General", "Window", "Workspace", "Group", "Cursor", "Other"] + property int selectedId: -1 + property string configPath: "" + readonly property bool compactToolbar: width < 760 + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + CatalogBridge { id: catalog } + ListModel { id: bindModel } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function load() { + const payload = parse(catalog.snapshot("binds")) + allItems = payload.items || [] + submaps = payload.submaps || [] + configPath = payload.configPath || "" + configResolved(configPath) + healthChanged(payload.ok === true) + if (!payload.ok) + status(payload.error || "Could not load binds.") + else if (payload.warning) + status(payload.warning) + else + status("Binds loaded from config.") + if (selectedId >= 0 && !selectedItem()) selectedId = -1 + rebuildModel() + } + + function rebuildModel() { + const query = searchField.text.trim().toLowerCase() + bindModel.clear() + for (let i = 0; i < allItems.length; ++i) { + const item = allItems[i] + const haystack = (item.displayName + " " + item.keys + " " + item.action + " " + item.flagsLabel + " " + item.submap + " " + item.sourceLabel).toLowerCase() + const searchMatches = !query || haystack.indexOf(query) >= 0 + const submapMatches = root.submapFilter === "" + || (root.submapFilter === "__global__" ? item.submap.length === 0 : item.submap === root.submapFilter) + const conflictMatches = !root.conflictsOnly || item.conflict + const categoryMatches = root.categoryFilter === "All" || item.category === root.categoryFilter + if (searchMatches && submapMatches && conflictMatches && categoryMatches) + bindModel.append(item) + } + } + + function selectedItem() { + for (let i = 0; i < allItems.length; ++i) + if (allItems[i].entryId === selectedId) return allItems[i] + return null + } + + function openAdd() { + editor.editing = false + editor.entryId = -1 + editorName.text = "" + editorKeys.text = "" + editorAction.text = "" + editorSubmap.text = "" + editorMeta.text = "New binds are written to Hyprbind’s managed section." + editorError.text = "" + editor.open() + editorName.forceActiveFocus() + } + + function openEdit() { + const item = selectedItem() + if (!item) return + editor.editing = true + editor.entryId = item.entryId + editorName.text = item.name || item.displayName + editorKeys.text = item.keys || "" + editorAction.text = item.action || "" + editorSubmap.text = item.submap || "" + editorMeta.text = (item.sharedSource ? "Shared source · edits materialize safely. " : "") + (item.sourceLabel || "managed config") + (item.flagsLabel ? " · flags: " + item.flagsLabel : "") + editorError.text = "" + editor.open() + editorName.forceActiveFocus() + } + + function saveEditor() { + const payload = parse(catalog.saveItem("binds", editor.entryId, JSON.stringify({ + name: editorName.text, + keys: editorKeys.text, + action: editorAction.text, + submap: editor.editing ? editor.originalSubmap : editorSubmap.text + }))) + if (!payload.ok) { + editorError.text = payload.error || "Save failed." + status(editorError.text) + return + } + editor.close() + load() + status(payload.message || "Bind saved.") + } + + function deleteSelected() { + if (selectedId < 0) return + const payload = parse(catalog.deleteItem("binds", selectedId)) + if (!payload.ok) { + status(payload.error || "Delete failed.") + return + } + selectedId = -1 + load() + status(payload.message || "Bind deleted.") + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 145 : 101 + + GlassField { + id: searchField + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : actions.left + anchors.rightMargin: root.compactToolbar ? 0 : 12 + anchors.top: parent.top + anchors.topMargin: 4 + implicitHeight: 43 + darkMode: root.darkMode + placeholderText: "Filter binds by key, action, name, submap..." + leftPadding: 42 + onTextChanged: root.rebuildModel() + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + } + + Row { + id: actions + anchors.right: parent.right + anchors.top: root.compactToolbar ? searchField.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 7 : 4 + spacing: 7 + + IconButton { iconName: "plus"; tooltip: "Add bind"; darkMode: root.darkMode; accent: true; onClicked: root.openAdd() } + IconButton { iconName: "edit"; tooltip: "Edit selected bind"; darkMode: root.darkMode; enabled: root.selectedId >= 0; onClicked: root.openEdit() } + IconButton { iconName: "trash"; tooltip: "Delete selected bind"; darkMode: root.darkMode; danger: true; enabled: root.selectedId >= 0; onClicked: root.deleteSelected() } + } + + Item { + id: filterRow + anchors.left: parent.left + anchors.right: parent.right + anchors.top: root.compactToolbar ? actions.bottom : searchField.bottom + anchors.topMargin: 8 + height: 39 + + ComboBox { + id: submapBox + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.min(180, Math.max(132, root.width * 0.22)) + height: 38 + model: ["All submaps", "Global"].concat(root.submaps) + hoverEnabled: true + font.family: "Inter" + font.pixelSize: 11 + + contentItem: Text { + leftPadding: 12 + rightPadding: 28 + text: submapBox.displayText + color: theme.textPrimary + font: submapBox.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + indicator: Text { + anchors.right: parent.right + anchors.rightMargin: 11 + anchors.verticalCenter: parent.verticalCenter + text: "⌄" + color: theme.textSecondary + font.pixelSize: 14 + } + + background: Rectangle { + radius: 11 + color: submapBox.pressed ? theme.controlPressed : submapBox.hovered ? theme.controlHover : theme.controlFill + border.width: 1 + border.color: theme.controlRim + } + + onActivated: index => { + root.submapFilter = index === 0 ? "" : index === 1 ? "__global__" : submapBox.currentText + root.rebuildModel() + } + } + + GlassButton { + id: conflictsButton + anchors.left: submapBox.right + anchors.leftMargin: 7 + anchors.verticalCenter: parent.verticalCenter + text: "Conflicts" + darkMode: root.darkMode + accent: root.conflictsOnly + implicitHeight: 38 + onClicked: { + root.conflictsOnly = !root.conflictsOnly + root.rebuildModel() + } + } + + Flickable { + id: categoryScroll + anchors.left: conflictsButton.right + anchors.leftMargin: 8 + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + clip: true + contentWidth: categoryRow.width + contentHeight: height + interactive: contentWidth > width + boundsBehavior: Flickable.StopAtBounds + + Row { + id: categoryRow + height: parent.height + spacing: 6 + + Repeater { + model: root.categoryFilters + delegate: GlassButton { + required property string modelData + anchors.verticalCenter: parent.verticalCenter + text: modelData + darkMode: root.darkMode + accent: root.categoryFilter === modelData + implicitHeight: 36 + onClicked: { + root.categoryFilter = modelData + root.rebuildModel() + } + } + } + } + } + } + } + + ListView { + id: list + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 5 + model: bindModel + clip: true + spacing: 4 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: row + required property int entryId + required property string displayName + required property string keys + required property string action + required property string category + required property string flagsLabel + required property string submap + required property string sourceLabel + required property bool sharedSource + required property bool conflict + + width: list.width + height: root.width < 640 ? 96 : 78 + radius: 11 + color: root.selectedId === entryId ? theme.selectedFill : hover.hovered ? theme.hoverFill : "transparent" + border.width: root.selectedId === entryId || conflict ? 1 : 0 + border.color: conflict ? theme.alpha(theme.danger, 0.24) : theme.selectedRim + + HoverHandler { id: hover } + + Column { + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.right: badges.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Row { + width: parent.width + spacing: 8 + Text { + width: Math.max(50, parent.width - keysBadge.width - 8) + text: displayName + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + Rectangle { + id: keysBadge + width: Math.min(190, keysText.implicitWidth + 18) + height: 25 + radius: 8 + color: theme.alpha(theme.accent, 0.06) + border.width: 1 + border.color: theme.alpha(theme.accent, 0.13) + Text { + id: keysText + anchors.centerIn: parent + text: keys + color: theme.alpha(theme.textPrimary, 0.90) + font.family: "monospace" + font.pixelSize: 10 + elide: Text.ElideRight + } + } + } + + Text { + width: parent.width + text: action + color: theme.alpha(theme.textSecondary, 0.88) + font.family: "monospace" + font.pixelSize: 10 + elide: Text.ElideRight + } + + Text { + width: parent.width + text: sourceLabel + (sharedSource ? " · shared source" : "") + (conflict ? " · conflict" : "") + color: conflict ? theme.danger : theme.alpha(theme.textSecondary, 0.54) + font.family: "Inter" + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + Row { + id: badges + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + visible: root.width >= 640 + + Rectangle { + visible: submap.length > 0 + width: submapText.implicitWidth + 16 + height: 26 + radius: 8 + color: theme.controlFill + border.width: 1 + border.color: theme.controlRim + Text { id: submapText; anchors.centerIn: parent; text: submap; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 9 } + } + Rectangle { + visible: flagsLabel.length > 0 + width: flagsText.implicitWidth + 16 + height: 26 + radius: 8 + color: theme.controlFill + border.width: 1 + border.color: theme.controlRim + Text { id: flagsText; anchors.centerIn: parent; text: flagsLabel; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 9 } + } + } + + TapHandler { + onTapped: root.selectedId = entryId + onDoubleTapped: { root.selectedId = entryId; root.openEdit() } + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 40, 500) + spacing: 12 + visible: bindModel.count === 0 + UiIcon { width: 30; height: 30; anchors.horizontalCenter: parent.horizontalCenter; name: "keyboard"; iconColor: theme.alpha(theme.textSecondary, 0.65) } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: root.allItems.length ? "No binds match this filter" : "No binds found"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 17; font.weight: Font.Medium } + GlassButton { visible: root.allItems.length === 0; anchors.horizontalCenter: parent.horizontalCenter; text: "Add your first bind"; darkMode: root.darkMode; accent: true; onClicked: root.openAdd() } + } + } + + Popup { + id: editor + property bool editing: false + property int entryId: -1 + property string originalSubmap: editorSubmap.text + + width: Math.min(650, root.width - 28) + height: Math.min(560, root.height - 24) + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + onOpened: originalSubmap = editorSubmap.text + + Overlay.modal: Rectangle { color: theme.alpha(theme.background, root.darkMode ? 0.48 : 0.24) } + background: Rectangle { radius: 18; color: theme.alpha(theme.familyShell, 0.96); border.width: 1; border.color: theme.shellRim } + + Column { + anchors.fill: parent + anchors.margins: root.width < 600 ? 16 : 22 + spacing: 11 + + Text { text: editor.editing ? "Edit bind" : "Add bind"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 20; font.weight: Font.DemiBold } + Text { id: editorMeta; width: parent.width; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 10; wrapMode: Text.WordWrap } + GlassField { id: editorName; width: parent.width; darkMode: root.darkMode; placeholderText: "Name" } + GlassField { id: editorKeys; width: parent.width; darkMode: root.darkMode; placeholderText: "Keys — e.g. SUPER + RETURN" } + GlassField { id: editorSubmap; width: parent.width; darkMode: root.darkMode; enabled: !editor.editing; placeholderText: editor.editing ? "Submap cannot be moved from this editor" : "Submap (optional)" } + GlassTextArea { id: editorAction; width: parent.width; height: Math.max(110, editor.height - 325); darkMode: root.darkMode; placeholderText: "Action — hl.dsp.* call or command" } + Text { id: editorError; width: parent.width; visible: text.length > 0; color: theme.danger; font.family: "Inter"; font.pixelSize: 10; wrapMode: Text.WordWrap } + Row { + anchors.right: parent.right + spacing: 8 + GlassButton { text: "Cancel"; darkMode: root.darkMode; onClicked: editor.close() } + GlassButton { text: editor.editing ? "Save" : "Add"; darkMode: root.darkMode; accent: true; enabled: editorName.text.trim().length > 0 && editorKeys.text.trim().length > 0 && editorAction.text.trim().length > 0; onClicked: root.saveEditor() } + } + } + } +} diff --git a/qml/ConfigPage.qml b/qml/ConfigPage.qml new file mode 100644 index 0000000..21e076a --- /dev/null +++ b/qml/ConfigPage.qml @@ -0,0 +1,121 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + property bool darkMode: true + property string configPath: "" + property string originalText: "{}" + property bool dirty: configEditor.text !== originalText + readonly property bool compactToolbar: width < 620 + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + CatalogBridge { id: catalog } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function load() { + const payload = parse(catalog.configSnapshot()) + configPath = payload.configPath || "" + configResolved(configPath) + healthChanged(payload.ok === true) + if (!payload.ok) { + configEditor.text = "{}" + originalText = configEditor.text + status(payload.error || "Could not load config.") + return + } + configEditor.text = JSON.stringify(payload.config || {}, null, 2) + originalText = configEditor.text + status(payload.warning || "Merged config loaded. Changes are written through the managed override writer.") + } + + function save() { + let parsed + try { + parsed = JSON.parse(configEditor.text) + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") + throw new Error("top level must be an object") + } catch (error) { + status("Config JSON is invalid: " + error) + return + } + const payload = parse(catalog.saveConfig(JSON.stringify({ config: parsed }))) + if (!payload.ok) { + status(payload.error || "Config save failed.") + return + } + originalText = configEditor.text + status(payload.message || "Config saved.") + } + + Component.onCompleted: load() + + Column { + anchors.fill: parent + spacing: 10 + + Item { + id: toolbar + width: parent.width + height: root.compactToolbar ? 82 : 46 + + Row { + id: actions + spacing: 7 + x: Math.max(0, toolbar.width - width) + y: root.compactToolbar + ? Math.max(0, toolbar.height - height) + : Math.round((toolbar.height - height) / 2) + + IconButton { + iconName: "reload" + tooltip: "Discard edits and reload" + darkMode: root.darkMode + onClicked: root.load() + } + IconButton { + iconName: "save" + tooltip: root.dirty ? "Save config override" : "No unsaved changes" + darkMode: root.darkMode + accent: root.dirty + enabled: root.dirty + onClicked: root.save() + } + } + + Text { + id: hint + x: 0 + y: root.compactToolbar + ? 0 + : Math.round((toolbar.height - height) / 2) + width: root.compactToolbar + ? toolbar.width + : Math.max(0, actions.x - 14) + text: "Advanced merged-config editor · typed JSON is preserved by the existing managed writer." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 10 + elide: Text.ElideRight + } + } + + GlassTextArea { + id: configEditor + width: parent.width + height: parent.height - y + darkMode: root.darkMode + placeholderText: "{}" + } + } +} diff --git a/qml/DeveloperToolsPage.qml b/qml/DeveloperToolsPage.qml new file mode 100644 index 0000000..1d46d56 --- /dev/null +++ b/qml/DeveloperToolsPage.qml @@ -0,0 +1,92 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property var bridge + property bool darkMode: true + property bool developerMode: false + + signal status(string message) + signal developerModeCommitted(bool enabled) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid bridge response: " + error } } + } + + function setDeveloperMode(enabled) { + const result = parse(bridge.setDeveloperMode(enabled)) + if (!result.ok) { + status(result.error || "Could not update developer mode.") + return + } + developerModeCommitted(enabled) + status(result.message) + } + + Column { + anchors.fill: parent + spacing: 12 + + GlassPanel { + width: parent.width + height: 118 + darkMode: root.darkMode + elevated: true + + Column { + anchors.fill: parent + anchors.margins: 18 + spacing: 8 + Text { text: "EXPERIMENTAL COMPANION STUDIOS"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.7 } + Text { width: parent.width; text: "Wallpaper, Waybar, Starship, VIA, Screenshare, and Audio are still hosted by the GTK compatibility UI while their QML migration is intentionally deferred."; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 11; wrapMode: Text.Wrap } + } + } + + GlassPanel { + width: parent.width + height: 176 + darkMode: root.darkMode + elevated: false + + Column { + anchors.fill: parent + anchors.margins: 18 + spacing: 8 + + SettingToggle { + width: parent.width + darkMode: root.darkMode + title: "Developer mode" + subtitle: "Unlock the existing experimental companion studios" + checked: root.developerMode + onToggled: checked => root.setDeveloperMode(checked) + } + + Rectangle { width: parent.width; height: 1; color: theme.divider } + + Item { + width: parent.width + height: 48 + Text { anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: root.developerMode ? "Developer tools are unlocked." : "Enable developer mode to open the experimental studios."; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 11 } + GlassButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Open GTK tools" + accent: true + darkMode: root.darkMode + enabled: root.developerMode + onClicked: { + const result = root.parse(root.bridge.launchGtkUi()) + root.status(result.ok ? result.message : (result.error || "Could not launch GTK compatibility UI.")) + } + } + } + } + } + } +} diff --git a/qml/EnvironmentPage.qml b/qml/EnvironmentPage.qml new file mode 100644 index 0000000..f2eb23f --- /dev/null +++ b/qml/EnvironmentPage.qml @@ -0,0 +1,453 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property var bridge + property bool darkMode: true + property var allEnvironment: [] + property string selectedName: "" + property string configPath: "" + property bool healthy: true + readonly property bool compactToolbar: width < 620 + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + ListModel { id: environmentModel } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function load() { + const payload = parse(bridge.environmentSnapshot()) + configPath = payload.configPath || "" + configResolved(configPath) + allEnvironment = payload.env || [] + healthy = payload.ok === true + healthChanged(healthy) + selectedName = "" + if (!payload.ok) + status(payload.error || "Could not load the Hyprland config.") + else if (payload.warning) + status(payload.warning) + else + status("Environment values loaded from config.") + rebuildModel() + } + + function rebuildModel() { + const query = envSearch.text.trim().toLowerCase() + environmentModel.clear() + for (let i = 0; i < allEnvironment.length; ++i) { + const item = allEnvironment[i] + const haystack = (item.name + " " + item.value).toLowerCase() + if (!query || haystack.indexOf(query) >= 0) + environmentModel.append(item) + } + } + + function selectedEnvironment() { + for (let i = 0; i < allEnvironment.length; ++i) { + if (allEnvironment[i].name === selectedName) + return allEnvironment[i] + } + return null + } + + function runAction(raw, closeEditor) { + const payload = parse(raw) + if (!payload.ok) { + editorError.text = payload.error || "The operation failed." + status(editorError.text) + return false + } + if (closeEditor) + envEditor.close() + load() + status(payload.message || "Saved.") + return true + } + + function openAddEditor() { + editorTitle.text = "Add environment variable" + editorName.text = "" + editorValue.text = "" + editorError.text = "" + envEditor.editing = false + envEditor.originalName = "" + envEditor.open() + editorName.forceActiveFocus() + } + + function openEditEditor() { + const item = selectedEnvironment() + if (!item) + return + editorTitle.text = "Edit environment variable" + editorName.text = item.name + editorValue.text = item.value + editorError.text = "" + envEditor.editing = true + envEditor.originalName = item.name + envEditor.open() + editorName.forceActiveFocus() + } + + function deleteSelected() { + if (!selectedName) + return + const deleting = selectedName + const payload = parse(bridge.deleteEnvironment(deleting)) + if (!payload.ok) { + status(payload.error || "Delete failed.") + return + } + load() + status(payload.message || ("Deleted " + deleting + ".")) + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 92 : 52 + + GlassField { + id: envSearch + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : toolbarButtons.left + anchors.rightMargin: root.compactToolbar ? 0 : 12 + anchors.top: parent.top + anchors.topMargin: 4 + implicitHeight: 43 + placeholderText: "Filter environment variables..." + leftPadding: 42 + darkMode: root.darkMode + onTextChanged: root.rebuildModel() + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + } + + Row { + id: toolbarButtons + anchors.right: parent.right + anchors.top: root.compactToolbar ? envSearch.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 6 : 4 + spacing: 7 + + IconButton { + iconName: "plus" + tooltip: "Add environment variable" + darkMode: root.darkMode + accent: true + onClicked: root.openAddEditor() + } + IconButton { + iconName: "edit" + tooltip: "Edit selected variable" + darkMode: root.darkMode + enabled: root.selectedName.length > 0 + onClicked: root.openEditEditor() + } + IconButton { + iconName: "trash" + tooltip: "Delete selected variable" + darkMode: root.darkMode + danger: true + enabled: root.selectedName.length > 0 + onClicked: root.deleteSelected() + } + } + } + + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 6 + + ListView { + id: environmentList + anchors.fill: parent + clip: true + model: environmentModel + spacing: 4 + visible: environmentModel.count > 0 + + delegate: Rectangle { + id: envRow + required property string name + required property string value + required property string sourceFile + required property int sourceLine + + width: environmentList.width + height: 68 + radius: 11 + color: root.selectedName === name + ? theme.selectedFill + : rowMouse.containsMouse ? theme.hoverFill : "transparent" + border.width: root.selectedName === name ? 1 : 0 + border.color: theme.selectedRim + + Rectangle { + visible: root.selectedName === name + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 16 + anchors.rightMargin: 16 + height: 1 + color: theme.selectedSpecular + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 12 + anchors.rightMargin: 12 + height: 1 + color: theme.divider + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 15 + anchors.right: parent.right + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: name + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + width: parent.width + text: value + color: theme.alpha(theme.textSecondary, 0.88) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + Text { + width: parent.width + text: sourceFile ? sourceFile.split("/").pop() + ":" + sourceLine : "managed config" + color: theme.alpha(theme.textSecondary, 0.56) + font.family: "Inter" + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.selectedName = name + onDoubleClicked: { + root.selectedName = name + root.openEditEditor() + } + } + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 40, 500) + spacing: 13 + visible: environmentModel.count === 0 + + GlassPanel { + width: 72 + height: 72 + cornerRadius: 36 + anchors.horizontalCenter: parent.horizontalCenter + darkMode: root.darkMode + elevated: true + border.color: theme.controlRim + + UiIcon { + anchors.centerIn: parent + width: 27 + height: 27 + name: "document" + iconColor: theme.alpha(theme.textPrimary, 0.82) + strokeWidth: 1.55 + } + } + + Text { + text: root.allEnvironment.length === 0 + ? "No environment variables yet" + : "No matching environment variables" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 19 + font.weight: Font.DemiBold + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + width: parent.width + text: root.allEnvironment.length === 0 + ? "Environment variables define key-value pairs available to Hyprland and launched apps." + : "Try a different filter." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 12 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + GlassButton { + visible: root.allEnvironment.length === 0 + text: "Add your first variable" + darkMode: root.darkMode + accent: true + anchors.horizontalCenter: parent.horizontalCenter + onClicked: root.openAddEditor() + } + } + } + + Popup { + id: envEditor + property bool editing: false + property string originalName: "" + width: Math.min(470, root.width - 32) + height: 326 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + + Overlay.modal: Rectangle { color: theme.alpha(theme.background, root.darkMode ? 0.46 : 0.24) } + + background: Rectangle { + radius: 18 + color: theme.alpha(theme.familyShell, root.darkMode ? 0.94 : 0.96) + border.width: 1 + border.color: theme.shellRim + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.top: parent.top + height: 1 + color: theme.shellInnerLine + } + } + + Column { + anchors.fill: parent + anchors.margins: 22 + spacing: 14 + + Text { + id: editorTitle + text: "Add environment variable" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 20 + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: "Changes use Hyprbind’s existing writer and backup path." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + + GlassField { + id: editorName + width: parent.width + darkMode: root.darkMode + placeholderText: "Name — e.g. XCURSOR_SIZE" + } + + GlassField { + id: editorValue + width: parent.width + darkMode: root.darkMode + placeholderText: "Value — e.g. 24" + onAccepted: editorSave.clicked() + } + + Text { + id: editorError + width: parent.width + height: text ? implicitHeight : 0 + visible: text.length > 0 + color: "#e1a19f" + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 2 } + + Row { + anchors.right: parent.right + spacing: 9 + + GlassButton { + text: "Cancel" + darkMode: root.darkMode + onClicked: envEditor.close() + } + + GlassButton { + id: editorSave + text: envEditor.editing ? "Save" : "Add" + darkMode: root.darkMode + accent: true + enabled: editorName.text.trim().length > 0 + onClicked: { + editorError.text = "" + if (envEditor.editing) { + root.runAction( + root.bridge.editEnvironment(envEditor.originalName, editorName.text, editorValue.text), + true + ) + } else { + root.runAction(root.bridge.addEnvironment(editorName.text, editorValue.text), true) + } + } + } + } + } + } +} diff --git a/qml/GlassButton.qml b/qml/GlassButton.qml new file mode 100644 index 0000000..6fe8dcb --- /dev/null +++ b/qml/GlassButton.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: control + property bool accent: false + property bool danger: false + property bool darkMode: true + hoverEnabled: true + HyprbindTheme { id: theme; darkMode: control.darkMode } + + implicitHeight: 38 + leftPadding: 16 + rightPadding: 16 + font.family: "Inter" + font.pixelSize: 12 + font.weight: accent ? Font.DemiBold : Font.Normal + scale: down ? 0.96 : 1 + Behavior on scale { NumberAnimation { duration: 105; easing.type: Easing.OutCubic } } + + contentItem: Text { + text: control.text + font: control.font + color: control.danger ? theme.danger : theme.textPrimary + opacity: control.enabled ? 1 : 0.38 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 12 + antialiasing: true + color: control.danger + ? (control.hovered ? theme.dangerFill : theme.alpha(theme.danger, 0.035)) + : control.accent + ? theme.alpha(theme.accent, control.hovered ? 0.18 : 0.13) + : control.hovered ? theme.controlHover : theme.controlFill + border.width: 1 + border.color: control.danger ? theme.dangerRim + : control.accent ? theme.alpha(theme.accent, 0.24) : theme.controlRim + Behavior on color { ColorAnimation { duration: 145; easing.type: Easing.OutCubic } } + } +} diff --git a/qml/GlassField.qml b/qml/GlassField.qml new file mode 100644 index 0000000..5aae7c4 --- /dev/null +++ b/qml/GlassField.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Controls + +TextField { + id: control + property bool darkMode: true + HyprbindTheme { id: theme; darkMode: control.darkMode } + + implicitHeight: 43 + leftPadding: 14 + rightPadding: 14 + color: theme.textPrimary + placeholderTextColor: theme.alpha(theme.textSecondary, 0.72) + selectionColor: theme.alpha(theme.foreground, control.darkMode ? 0.14 : 0.16) + selectedTextColor: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + selectByMouse: true + + background: Rectangle { + radius: 14 + antialiasing: true + color: theme.searchFill + border.width: 1 + border.color: control.activeFocus ? theme.searchFocusRim : theme.searchRim + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.top: parent.top + height: 1 + radius: 1 + color: theme.searchSpecular + } + + Behavior on border.color { ColorAnimation { duration: 135; easing.type: Easing.OutCubic } } + } +} diff --git a/qml/GlassPanel.qml b/qml/GlassPanel.qml new file mode 100644 index 0000000..7d4a75e --- /dev/null +++ b/qml/GlassPanel.qml @@ -0,0 +1,44 @@ +import QtQuick + +Rectangle { + id: panel + property bool darkMode: true + property bool elevated: false + property color tint: "#37d5e9" + property real cornerRadius: 13 + + HyprbindTheme { id: theme; darkMode: panel.darkMode } + + color: elevated ? theme.raisedFill : theme.contentFill + radius: cornerRadius + border.width: 1 + border.color: elevated ? theme.shellRim : theme.quietRim + antialiasing: true + clip: true + + Rectangle { + anchors.fill: parent + radius: panel.radius + antialiasing: true + color: "transparent" + gradient: Gradient { + GradientStop { position: 0.00; color: theme.shellTopSpecular } + GradientStop { position: 0.22; color: theme.shellAccentWash } + GradientStop { position: 0.74; color: "transparent" } + GradientStop { position: 1.00; color: theme.shellBottomShade } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Math.max(18, panel.cornerRadius * 1.4) + anchors.rightMargin: Math.max(18, panel.cornerRadius * 1.4) + anchors.top: parent.top + anchors.topMargin: 1 + height: 1 + radius: 1 + antialiasing: true + color: theme.shellInnerLine + } +} diff --git a/qml/GlassTextArea.qml b/qml/GlassTextArea.qml new file mode 100644 index 0000000..f39e762 --- /dev/null +++ b/qml/GlassTextArea.qml @@ -0,0 +1,48 @@ +import QtQuick +import QtQuick.Controls + +ScrollView { + id: control + + property bool darkMode: true + property alias text: editor.text + property alias placeholderText: editor.placeholderText + property alias readOnly: editor.readOnly + + clip: true + padding: 1 + + HyprbindTheme { id: theme; darkMode: control.darkMode } + + background: Rectangle { + radius: 11 + color: theme.searchFill + border.width: 1 + border.color: editor.activeFocus ? theme.searchFocusRim : theme.searchRim + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.top: parent.top + height: 1 + color: theme.searchSpecular + } + + Behavior on border.color { ColorAnimation { duration: 135; easing.type: Easing.OutCubic } } + } + + TextArea { + id: editor + color: theme.textPrimary + placeholderTextColor: theme.alpha(theme.textSecondary, 0.58) + selectionColor: theme.alpha(theme.foreground, control.darkMode ? 0.14 : 0.16) + selectedTextColor: theme.textPrimary + font.family: "monospace" + font.pixelSize: 11 + wrapMode: TextEdit.NoWrap + padding: 12 + background: null + } +} diff --git a/qml/HealthPage.qml b/qml/HealthPage.qml new file mode 100644 index 0000000..4cb0a76 --- /dev/null +++ b/qml/HealthPage.qml @@ -0,0 +1,450 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + property bool darkMode: true + property var allChecks: [] + property string severityFilter: "ALL" + property int total: 0 + property int okCount: 0 + property int warnings: 0 + property int failures: 0 + + readonly property bool compactToolbar: width < 760 + readonly property int summaryColumns: width >= 980 ? 3 : width >= 620 ? 2 : 1 + readonly property real summaryGap: 10 + readonly property real summaryCardHeight: 126 + readonly property real summaryCardWidth: summaryColumns === 3 + ? (width - summaryGap * 2) / 3 + : summaryColumns === 2 + ? (width - summaryGap) / 2 + : width + + signal status(string message) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + HealthBridge { id: healthBridge } + ListModel { id: checkModel } + + TextInput { + id: clipboardBuffer + width: 1 + height: 1 + opacity: 0 + readOnly: true + } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid diagnostics response: " + error } } + } + + function load() { + const payload = parse(healthBridge.snapshot()) + if (!payload.ok) { + allChecks = [] + total = 0 + okCount = 0 + warnings = 0 + failures = 1 + healthChanged(false) + status(payload.error || "Could not run health diagnostics.") + rebuildModel() + return + } + + allChecks = payload.checks || [] + total = payload.total || allChecks.length + okCount = payload.okCount || 0 + warnings = payload.warnings || 0 + failures = payload.failures || 0 + healthChanged(failures === 0) + status(failures > 0 + ? failures + " health check" + (failures === 1 ? " is" : "s are") + " failing." + : warnings > 0 + ? warnings + " diagnostic warning" + (warnings === 1 ? " remains." : "s remain.") + : "All health checks passed.") + rebuildModel() + } + + function severityMatches(severity) { + if (severityFilter === "ALL") return true + if (severityFilter === "ISSUES") return severity === "FAIL" || severity === "WARN" + if (severityFilter === "WARN") return severity === "WARN" + if (severityFilter === "OK") return severity === "OK" + return true + } + + function rebuildModel() { + const query = searchField.text.trim().toLowerCase() + checkModel.clear() + for (let i = 0; i < allChecks.length; ++i) { + const item = allChecks[i] + const haystack = (item.category + " " + item.title + " " + item.detail + " " + item.fixHint + " " + item.fixCommand).toLowerCase() + if (severityMatches(item.severity) && (!query || haystack.indexOf(query) >= 0)) + checkModel.append(item) + } + } + + function severityColor(severity) { + if (severity === "FAIL") return root.darkMode ? "#e39b96" : "#a84843" + if (severity === "WARN") return root.darkMode ? "#e6bd79" : "#98651d" + if (severity === "OK") return root.darkMode ? "#9bd2ad" : "#31744a" + return theme.textSecondary + } + + function formatReport() { + let out = "Hyprbinds health report\n" + okCount + " ok · " + warnings + " warnings · " + failures + " failures\n\n" + let lastCategory = "" + for (let i = 0; i < allChecks.length; ++i) { + const item = allChecks[i] + if (item.category !== lastCategory) { + lastCategory = item.category + out += "## " + item.category + "\n" + } + out += "[" + item.severity + "] " + item.title + "\n " + item.detail + "\n" + if (item.fixHint) out += " hint: " + item.fixHint + "\n" + if (item.fixCommand) out += " fix: " + item.fixCommand + "\n" + out += "\n" + } + return out + } + + function copyText(text, message) { + clipboardBuffer.text = text + clipboardBuffer.forceActiveFocus() + clipboardBuffer.selectAll() + clipboardBuffer.copy() + clipboardBuffer.deselect() + status(message) + } + + Component.onCompleted: load() + + Column { + id: topArea + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 12 + + Grid { + id: summaryGrid + width: parent.width + columns: root.summaryColumns + spacing: root.summaryGap + height: Math.ceil(3 / root.summaryColumns) * root.summaryCardHeight + + (Math.ceil(3 / root.summaryColumns) - 1) * root.summaryGap + + SummaryCard { + width: root.summaryCardWidth + height: root.summaryCardHeight + darkMode: root.darkMode + iconName: "heart" + title: "CHECKS" + value: root.total.toString() + subtitle: "Desktop and session diagnostics" + accent: root.failures === 0 + } + + SummaryCard { + width: root.summaryCardWidth + height: root.summaryCardHeight + darkMode: root.darkMode + iconName: "info" + title: "WARNINGS" + value: root.warnings.toString() + subtitle: root.warnings === 1 ? "One item needs attention" : "Items worth reviewing" + } + + SummaryCard { + width: root.summaryCardWidth + height: root.summaryCardHeight + darkMode: root.darkMode + iconName: "heart" + title: "FAILURES" + value: root.failures.toString() + subtitle: root.failures === 0 ? "No blocking checks" : "Checks that are currently failing" + accent: root.failures === 0 + } + } + + Item { + id: reportActions + width: parent.width + height: 38 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + GlassButton { + text: "Copy report" + darkMode: root.darkMode + enabled: root.allChecks.length > 0 + onClicked: root.copyText(root.formatReport(), "Copied full health report to clipboard.") + } + + GlassButton { + text: "Copy system info" + darkMode: root.darkMode + onClicked: { + const report = String(healthBridge.systemInfo()) + root.copyText(report, "Copied system info to clipboard.") + } + } + } + } + + Item { + id: toolbar + width: parent.width + height: root.compactToolbar ? 90 : 44 + + GlassField { + id: searchField + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : filterRow.left + anchors.rightMargin: root.compactToolbar ? 0 : 10 + anchors.top: parent.top + implicitHeight: 42 + darkMode: root.darkMode + placeholderText: "Filter diagnostics..." + leftPadding: 40 + onTextChanged: root.rebuildModel() + + UiIcon { + width: 16 + height: 16 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + } + + Row { + id: filterRow + anchors.right: parent.right + anchors.top: root.compactToolbar ? searchField.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 6 : 0 + spacing: 6 + + Repeater { + model: [ + { label: "All", value: "ALL" }, + { label: "Issues", value: "ISSUES" }, + { label: "Warnings", value: "WARN" }, + { label: "OK", value: "OK" } + ] + + delegate: GlassButton { + required property var modelData + text: modelData.label + darkMode: root.darkMode + accent: root.severityFilter === modelData.value + onClicked: { + root.severityFilter = modelData.value + root.rebuildModel() + } + } + } + } + } + } + + ListView { + id: healthList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: topArea.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 8 + clip: true + model: checkModel + spacing: 6 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: row + + required property string category + required property string title + required property string detail + required property string severity + required property string fixHint + required property string fixCommand + + width: ListView.view.width + height: rowContent.implicitHeight + 24 + radius: 12 + color: hoverHandler.hovered ? theme.hoverFill : "transparent" + border.width: 1 + border.color: theme.divider + + HoverHandler { id: hoverHandler } + + Column { + id: rowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 14 + anchors.rightMargin: 14 + anchors.topMargin: 12 + spacing: 7 + + Item { + width: parent.width + height: 40 + + Column { + anchors.left: parent.left + anchors.right: severityBadge.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: category.toUpperCase() + color: theme.alpha(theme.textSecondary, 0.64) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 0.7 + elide: Text.ElideRight + } + + Text { + width: parent.width + text: title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + } + + Rectangle { + id: severityBadge + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: severityText.implicitWidth + 18 + height: 25 + radius: 8 + color: theme.alpha(root.severityColor(severity), 0.08) + border.width: 1 + border.color: theme.alpha(root.severityColor(severity), 0.20) + + Text { + id: severityText + anchors.centerIn: parent + text: severity + color: root.severityColor(severity) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 0.5 + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: theme.divider + opacity: 0.72 + } + + Text { + width: parent.width + text: detail + color: theme.alpha(theme.textSecondary, 0.88) + font.family: "Inter" + font.pixelSize: 11 + lineHeight: 1.2 + wrapMode: Text.WordWrap + } + + Text { + visible: fixHint.length > 0 + width: parent.width + text: fixHint + color: theme.alpha(theme.textSecondary, 0.68) + font.family: "Inter" + font.pixelSize: 10 + lineHeight: 1.2 + wrapMode: Text.WordWrap + } + + GlassPanel { + visible: fixCommand.length > 0 + width: parent.width + height: visible ? 42 : 0 + cornerRadius: 9 + darkMode: root.darkMode + elevated: false + + GlassButton { + id: copyFixButton + anchors.right: parent.right + anchors.rightMargin: 5 + anchors.verticalCenter: parent.verticalCenter + text: "Copy fix" + darkMode: root.darkMode + implicitHeight: 32 + onClicked: root.copyText(fixCommand, "Copied fix command to clipboard.") + } + + Text { + anchors.left: parent.left + anchors.right: copyFixButton.left + anchors.leftMargin: 12 + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: fixCommand + color: theme.alpha(theme.textPrimary, 0.76) + font.family: "monospace" + font.pixelSize: 10 + elide: Text.ElideRight + } + } + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 36, 420) + visible: checkModel.count === 0 + spacing: 10 + + UiIcon { + width: 28 + height: 28 + anchors.horizontalCenter: parent.horizontalCenter + name: "heart" + iconColor: theme.alpha(theme.textSecondary, 0.66) + } + + Text { + width: parent.width + text: "No diagnostics match this filter" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 16 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + } +} diff --git a/qml/HyprbindTheme.qml b/qml/HyprbindTheme.qml new file mode 100644 index 0000000..c109ce2 --- /dev/null +++ b/qml/HyprbindTheme.qml @@ -0,0 +1,119 @@ +import QtQuick + +QtObject { + id: theme + property bool darkMode: true + + function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } + function mix(a, b, amount) { + const t = Math.max(0, Math.min(1, amount)) + return Qt.rgba( + a.r * (1 - t) + b.r * t, + a.g * (1 - t) + b.g * t, + a.b * (1 - t) + b.b * t, + a.a * (1 - t) + b.a * t + ) + } + + function pageIcon(page) { + if (page === "Overview") return "home" + if (page === "Binds") return "keyboard" + if (page === "Variables") return "braces" + if (page === "Environment") return "document" + if (page === "Submaps") return "grid" + if (page === "Startup") return "power" + if (page === "Window rules") return "window" + if (page === "Workspace rules") return "grid" + if (page === "Layer rules") return "layers" + if (page === "Look & Feel") return "diamond" + if (page === "Config") return "settings" + if (page === "Monitors") return "monitor" + if (page === "Devices") return "device" + if (page === "Animations") return "sparkle" + if (page === "Curves") return "curve" + if (page === "Gestures") return "gesture" + if (page === "Health") return "heart" + if (page === "Logs") return "logs" + if (page === "Import / Export") return "transfer" + if (page === "Developer tools") return "terminal" + return "document" + } + + readonly property color background: darkMode ? "#101416" : "#eef3f5" + readonly property color surface: darkMode ? "#1a2023" : "#f8fafb" + readonly property color surfaceHigh: darkMode ? "#252d31" : "#ffffff" + readonly property color foreground: darkMode ? "#eef2f3" : "#182328" + readonly property color muted: darkMode ? "#bec6ca" : "#5b6a72" + readonly property color accent: darkMode ? "#37d5e9" : "#168fa3" + + readonly property color textPrimary: foreground + readonly property color textSecondary: alpha(muted, darkMode ? 0.78 : 0.90) + + readonly property color familyShell: darkMode + ? mix(surfaceHigh, background, 0.54) + : mix(surfaceHigh, background, 0.12) + readonly property color shellFill: alpha(familyShell, darkMode ? 0.62 : 0.985) + readonly property color shellRim: alpha(foreground, darkMode ? 0.095 : 0.12) + readonly property color shellTopSpecular: darkMode + ? alpha(foreground, 0.034) + : alpha(surfaceHigh, 0.70) + readonly property color shellAccentWash: darkMode ? alpha(accent, 0.018) : "transparent" + readonly property color shellBottomShade: darkMode ? alpha(accent, 0.028) : "transparent" + readonly property color shellInnerLine: darkMode + ? alpha(foreground, 0.052) + : alpha(surfaceHigh, 0.76) + + readonly property color toolbarFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.44), 0.30) + : alpha(mix(surfaceHigh, background, 0.08), 0.96) + readonly property color sidebarFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.55), 0.42) + : alpha(mix(surfaceHigh, background, 0.14), 0.97) + readonly property color contentFill: darkMode + ? alpha(mix(surface, background, 0.62), 0.18) + : alpha(mix(surface, background, 0.10), 0.95) + readonly property color raisedFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.58), 0.40) + : alpha(mix(surfaceHigh, background, 0.06), 0.985) + readonly property color quietRim: alpha(foreground, darkMode ? 0.065 : 0.095) + readonly property color divider: alpha(foreground, darkMode ? 0.036 : 0.065) + + readonly property color controlFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.60), 0.42) + : alpha(mix(surfaceHigh, background, 0.06), 0.94) + readonly property color controlHover: darkMode + ? alpha(mix(surfaceHigh, accent, 0.08), 0.50) + : alpha(mix(surfaceHigh, accent, 0.045), 0.98) + readonly property color controlPressed: darkMode + ? alpha(mix(surfaceHigh, accent, 0.12), 0.56) + : alpha(mix(surfaceHigh, accent, 0.065), 1.0) + readonly property color controlRim: alpha(foreground, darkMode ? 0.055 : 0.085) + readonly property color controlRimActive: alpha(accent, darkMode ? 0.14 : 0.24) + readonly property color controlInnerRim: alpha(foreground, darkMode ? 0.025 : 0.038) + + readonly property color searchFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.62), 0.42) + : alpha(mix(surfaceHigh, background, 0.04), 0.96) + readonly property color searchFocusedFill: searchFill + readonly property color searchRim: alpha(foreground, darkMode ? 0.055 : 0.09) + readonly property color searchFocusRim: alpha(foreground, darkMode ? 0.12 : 0.15) + readonly property color searchSpecular: darkMode + ? alpha(foreground, 0.026) + : alpha(surfaceHigh, 0.66) + readonly property color searchSpecularFocus: searchSpecular + + readonly property color hoverFill: darkMode + ? alpha(foreground, 0.028) + : alpha(foreground, 0.048) + readonly property color selectedFill: darkMode + ? alpha(mix(surfaceHigh, accent, 0.10), 0.54) + : alpha(mix(surfaceHigh, accent, 0.060), 0.96) + readonly property color selectedRim: alpha(accent, darkMode ? 0.10 : 0.16) + readonly property color selectedSpecular: darkMode + ? alpha(foreground, 0.028) + : alpha(surfaceHigh, 0.56) + + readonly property color danger: darkMode ? "#dca3a0" : "#9c3f3b" + readonly property color dangerFill: alpha(danger, darkMode ? 0.055 : 0.065) + readonly property color dangerRim: alpha(danger, darkMode ? 0.14 : 0.18) +} diff --git a/qml/IconButton.qml b/qml/IconButton.qml new file mode 100644 index 0000000..ca64794 --- /dev/null +++ b/qml/IconButton.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: control + property string glyph: "" + property string iconName: "" + property string tooltip: "" + property bool accent: false + property bool danger: false + property bool darkMode: true + hoverEnabled: true + HyprbindTheme { id: theme; darkMode: control.darkMode } + + implicitWidth: 40 + implicitHeight: 40 + padding: 0 + scale: down ? 0.94 : 1 + opacity: enabled ? 1 : 0.30 + + Behavior on scale { NumberAnimation { duration: 105; easing.type: Easing.OutCubic } } + + contentItem: Item { + UiIcon { + visible: control.iconName.length > 0 + anchors.centerIn: parent + width: 19 + height: 19 + name: control.iconName + iconColor: control.danger ? theme.danger + : control.accent ? theme.textPrimary : theme.alpha(theme.textPrimary, 0.90) + } + Text { + visible: control.iconName.length === 0 + anchors.fill: parent + text: control.glyph + font.family: "Inter" + font.pixelSize: 18 + color: control.danger ? theme.danger : theme.textPrimary + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + background: Rectangle { + radius: 13 + antialiasing: true + color: control.danger + ? (control.hovered ? theme.dangerFill : "transparent") + : control.accent + ? theme.alpha(theme.accent, control.hovered ? 0.15 : 0.10) + : control.hovered ? theme.hoverFill : "transparent" + border.width: control.accent || (control.danger && control.hovered) ? 1 : 0 + border.color: control.danger ? theme.dangerRim : theme.alpha(theme.accent, 0.20) + Behavior on color { ColorAnimation { duration: 145; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 145 } } + } + + ToolTip.visible: control.hovered && control.tooltip.length > 0 + ToolTip.text: control.tooltip + ToolTip.delay: 450 +} diff --git a/qml/ImportExportPage.qml b/qml/ImportExportPage.qml new file mode 100644 index 0000000..81ed205 --- /dev/null +++ b/qml/ImportExportPage.qml @@ -0,0 +1,109 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property var bridge + property bool darkMode: true + property bool hyprlandSelected: true + property bool waybarSelected: true + property bool appSelected: true + property bool viaSelected: true + property bool systemSelected: true + + signal status(string message) + signal imported() + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid bridge response: " + error } } + } + + function doExport() { + const result = parse(bridge.exportBundle(exportPath.text)) + status(result.ok ? result.message : (result.error || "Export failed.")) + } + + function doImport() { + const result = parse(bridge.importBundle( + importPath.text, + hyprlandSelected, + waybarSelected, + appSelected, + viaSelected, + systemSelected + )) + status(result.ok ? result.message : (result.error || "Import failed.")) + if (result.ok) imported() + } + + Flickable { + anchors.fill: parent + contentWidth: width + contentHeight: content.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: content + width: parent.width + spacing: 12 + + GlassPanel { + width: parent.width + height: 166 + darkMode: root.darkMode + elevated: true + + Column { + anchors.fill: parent + anchors.margins: 18 + spacing: 10 + + Text { text: "EXPORT BUNDLE"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.7 } + Text { width: parent.width; text: "Write Hyprland settings, Waybar, app preferences, VIA assets, and supported system config to one JSON bundle."; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 11; wrapMode: Text.Wrap } + Row { + width: parent.width + spacing: 8 + GlassField { id: exportPath; width: Math.max(160, parent.width - exportButton.width - 8); text: "~/hyprbinds-export.json"; darkMode: root.darkMode; placeholderText: "~/hyprbinds-export.json" } + GlassButton { id: exportButton; text: "Export"; accent: true; darkMode: root.darkMode; onClicked: root.doExport() } + } + } + } + + GlassPanel { + width: parent.width + height: 410 + darkMode: root.darkMode + elevated: false + + Column { + anchors.fill: parent + anchors.margins: 18 + spacing: 8 + + Text { text: "IMPORT BUNDLE"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.7 } + Text { width: parent.width; text: "Restore selected sections through the existing Rust import path. Managed Hyprland sections are backed up before replacement; unmanaged Lua remains outside Hyprbind markers."; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 11; wrapMode: Text.Wrap } + + GlassField { id: importPath; width: parent.width; text: "~/hyprbinds-export.json"; darkMode: root.darkMode; placeholderText: "Path to hyprbinds export JSON" } + + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "Hyprland"; subtitle: "Binds, rules, monitors, env, startup, and managed settings"; checked: root.hyprlandSelected; onToggled: checked => root.hyprlandSelected = checked } + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "Waybar"; subtitle: "Restore exported Waybar configuration"; checked: root.waybarSelected; onToggled: checked => root.waybarSelected = checked } + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "App preferences"; subtitle: "Theme, developer mode, and Hyprbind preferences"; checked: root.appSelected; onToggled: checked => root.appSelected = checked } + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "VIA assets"; subtitle: "VIA definitions and RGB presets"; checked: root.viaSelected; onToggled: checked => root.viaSelected = checked } + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "Portal config"; subtitle: "Supported desktop portal configuration"; checked: root.systemSelected; onToggled: checked => root.systemSelected = checked } + + Item { + width: parent.width + height: 42 + GlassButton { anchors.right: parent.right; text: "Import selected"; accent: true; darkMode: root.darkMode; enabled: root.hyprlandSelected || root.waybarSelected || root.appSelected || root.viaSelected || root.systemSelected; onClicked: root.doImport() } + } + } + } + } + } +} diff --git a/qml/LogsPage.qml b/qml/LogsPage.qml new file mode 100644 index 0000000..ce0b146 --- /dev/null +++ b/qml/LogsPage.qml @@ -0,0 +1,277 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + property bool darkMode: true + property var allEntries: [] + property string severityFilter: "ALL" + property string loadError: "" + readonly property bool compactToolbar: width < 900 + + signal status(string message) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + LogsBridge { id: logsBridge } + ListModel { id: logModel } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid logs response: " + error } } + } + + function load() { + const payload = parse(logsBridge.snapshot()) + loadError = payload.ok ? "" : (payload.error || "Could not read the user journal.") + allEntries = payload.entries || [] + healthChanged(payload.ok === true) + status(payload.ok + ? "Loaded " + allEntries.length + " recent journal entries." + : loadError) + rebuildModel() + } + + function severityMatches(severity) { + if (severityFilter === "ALL") return true + if (severityFilter === "ERRORS") + return severity === "EMERG" || severity === "ALERT" || severity === "CRIT" || severity === "ERROR" + if (severityFilter === "WARN") return severity === "WARN" + if (severityFilter === "INFO") return severity === "NOTE" || severity === "INFO" + if (severityFilter === "DEBUG") return severity === "DEBUG" + return true + } + + function rebuildModel() { + const query = searchField.text.trim().toLowerCase() + logModel.clear() + for (let i = 0; i < allEntries.length; ++i) { + const item = allEntries[i] + const haystack = (item.message + " " + item.identifier + " " + item.unit + " " + item.severity).toLowerCase() + if (severityMatches(item.severity) && (!query || haystack.indexOf(query) >= 0)) { + logModel.append({ + entryId: item.id, + timestamp: item.timestamp, + severity: item.severity, + message: item.message, + identifier: item.identifier, + unit: item.unit + }) + } + } + } + + function severityColor(severity) { + if (severity === "EMERG" || severity === "ALERT" || severity === "CRIT" || severity === "ERROR") + return root.darkMode ? "#e39b96" : "#a84843" + if (severity === "WARN") + return root.darkMode ? "#e6bd79" : "#98651d" + if (severity === "DEBUG") + return theme.alpha(theme.textSecondary, 0.62) + return theme.accent + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 92 : 44 + + Row { + id: filterRow + spacing: 6 + y: root.compactToolbar ? 49 : 0 + x: root.compactToolbar ? 0 : Math.max(0, toolbar.width - width) + + Repeater { + model: [ + { label: "All", value: "ALL" }, + { label: "Errors", value: "ERRORS" }, + { label: "Warnings", value: "WARN" }, + { label: "Info", value: "INFO" }, + { label: "Debug", value: "DEBUG" } + ] + delegate: GlassButton { + required property var modelData + text: modelData.label + darkMode: root.darkMode + accent: root.severityFilter === modelData.value + onClicked: { + root.severityFilter = modelData.value + root.rebuildModel() + } + } + } + } + + GlassField { + id: searchField + x: 0 + y: 0 + width: root.compactToolbar + ? toolbar.width + : Math.max(220, filterRow.x - 10) + implicitHeight: 42 + darkMode: root.darkMode + placeholderText: "Search recent logs..." + leftPadding: 40 + onTextChanged: root.rebuildModel() + + UiIcon { + width: 16 + height: 16 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + } + } + + ListView { + id: logList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 7 + clip: true + model: logModel + spacing: 3 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: row + required property int entryId + required property string timestamp + required property string severity + required property string message + required property string identifier + required property string unit + + width: logList.width + height: root.width < 620 ? 86 : 72 + radius: 10 + color: hoverHandler.hovered ? theme.hoverFill : "transparent" + + HoverHandler { id: hoverHandler } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 10 + anchors.rightMargin: 10 + height: 1 + color: theme.divider + } + + Rectangle { + id: severityBadge + width: severityText.implicitWidth + 16 + height: 23 + radius: 8 + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.top: parent.top + anchors.topMargin: 11 + color: theme.alpha(root.severityColor(severity), 0.08) + border.width: 1 + border.color: theme.alpha(root.severityColor(severity), 0.18) + + Text { + id: severityText + anchors.centerIn: parent + text: severity + color: root.severityColor(severity) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + + Text { + id: timestampText + anchors.right: parent.right + anchors.rightMargin: 13 + anchors.top: parent.top + anchors.topMargin: 13 + text: timestamp + color: theme.alpha(theme.textSecondary, 0.52) + font.family: "monospace" + font.pixelSize: 9 + } + + Text { + id: sourceText + anchors.left: severityBadge.right + anchors.leftMargin: 9 + anchors.right: timestampText.left + anchors.rightMargin: 10 + anchors.verticalCenter: severityBadge.verticalCenter + text: identifier.length ? identifier : (unit.length ? unit : "journal") + color: theme.alpha(theme.textPrimary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.right: parent.right + anchors.rightMargin: 13 + anchors.top: severityBadge.bottom + anchors.topMargin: 7 + text: message + color: theme.alpha(theme.textSecondary, 0.88) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + maximumLineCount: root.width < 620 ? 2 : 1 + wrapMode: root.width < 620 ? Text.WordWrap : Text.NoWrap + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 36, 460) + spacing: 10 + visible: logModel.count === 0 + + UiIcon { + width: 28 + height: 28 + anchors.horizontalCenter: parent.horizontalCenter + name: "logs" + iconColor: theme.alpha(theme.textSecondary, 0.66) + } + Text { + text: root.loadError.length ? "Logs are unavailable" : "No logs match this filter" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 16 + font.weight: Font.Medium + anchors.horizontalCenter: parent.horizontalCenter + } + Text { + visible: root.loadError.length > 0 + width: parent.width + text: root.loadError + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + } +} diff --git a/qml/LookFeelPage.qml b/qml/LookFeelPage.qml new file mode 100644 index 0000000..0393ada --- /dev/null +++ b/qml/LookFeelPage.qml @@ -0,0 +1,349 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + + required property var bridge + property bool darkMode: true + property bool livePreviewEnabled: false + readonly property bool compactToolbar: width < 760 + readonly property bool singleColumnCards: width < 860 + + signal status(string message) + signal saved() + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + property real gapsIn: 5 + property real gapsOut: 20 + property real borderSize: 1 + property real rounding: 10 + property real activeOpacity: 1 + property real inactiveOpacity: 1 + property real blurSize: 3 + property bool blurEnabled: true + property bool shadowEnabled: true + property bool animationsEnabled: true + property string activeBorder: "" + property string inactiveBorder: "" + property bool loaded: false + + function parse(raw) { + try { return JSON.parse(raw) } + catch (e) { return { ok: false, error: "Invalid Rust bridge response: " + e } } + } + + function payload() { + return JSON.stringify({ + gapsIn: Math.round(gapsIn), + gapsOut: Math.round(gapsOut), + borderSize: Math.round(borderSize), + rounding: Math.round(rounding), + activeOpacity: Math.round(activeOpacity * 100) / 100, + inactiveOpacity: Math.round(inactiveOpacity * 100) / 100, + blurSize: Math.round(blurSize), + blurEnabled: blurEnabled, + shadowEnabled: shadowEnabled, + animationsEnabled: animationsEnabled, + activeBorder: activeBorder, + inactiveBorder: inactiveBorder + }) + } + + function load() { + const data = parse(bridge.lookFeelSnapshot()) + if (!data.ok) { + status(data.error || "Could not load Look & Feel settings.") + return + } + gapsIn = data.gapsIn + gapsOut = data.gapsOut + borderSize = data.borderSize + rounding = data.rounding + activeOpacity = data.activeOpacity + inactiveOpacity = data.inactiveOpacity + blurSize = data.blurSize + blurEnabled = data.blurEnabled + shadowEnabled = data.shadowEnabled + animationsEnabled = data.animationsEnabled + activeBorder = data.activeBorder || "" + inactiveBorder = data.inactiveBorder || "" + loaded = true + status("Look & Feel values loaded from config.") + } + + function preview() { + if (!loaded || !livePreviewEnabled) + return + previewTimer.restart() + } + + function save() { + const data = parse(bridge.saveLookFeel(payload())) + if (!data.ok) { + status(data.error || "Could not save Look & Feel settings.") + return + } + status(data.message || "Look & Feel saved.") + saved() + } + + function applyPreset(name) { + if (name === "compact") { + gapsIn = 2; gapsOut = 4; borderSize = 1; rounding = 4 + activeOpacity = 1; inactiveOpacity = 1; blurSize = 2 + blurEnabled = false; shadowEnabled = false; animationsEnabled = true + } else if (name === "comfortable") { + gapsIn = 5; gapsOut = 12; borderSize = 2; rounding = 10 + activeOpacity = 1; inactiveOpacity = 0.95; blurSize = 5 + blurEnabled = true; shadowEnabled = true; animationsEnabled = true + } else if (name === "spacious") { + gapsIn = 12; gapsOut = 24; borderSize = 2; rounding = 16 + activeOpacity = 0.98; inactiveOpacity = 0.88; blurSize = 8 + blurEnabled = true; shadowEnabled = true; animationsEnabled = true + } + preview() + } + + Component.onCompleted: load() + + Timer { + id: previewTimer + interval: 180 + repeat: false + onTriggered: { + const data = root.parse(root.bridge.previewLookFeel(root.payload())) + if (!data.ok) + root.status(data.error || "Live preview unavailable.") + } + } + + Component { + id: spacingCardComponent + SettingsCard { + title: "SPACING & WINDOWS" + darkMode: root.darkMode + SettingSlider { width: parent.width; darkMode: root.darkMode; title: "Inner gaps"; subtitle: "Space between tiled windows"; from: 0; to: 40; value: root.gapsIn; onEdited: value => { root.gapsIn = value; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingSlider { width: parent.width; darkMode: root.darkMode; title: "Outer gaps"; subtitle: "Space between windows and screen edges"; from: 0; to: 60; value: root.gapsOut; onEdited: value => { root.gapsOut = value; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingSlider { width: parent.width; darkMode: root.darkMode; title: "Border size"; subtitle: "Thickness of window borders"; from: 0; to: 10; value: root.borderSize; onEdited: value => { root.borderSize = value; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingSlider { width: parent.width; darkMode: root.darkMode; title: "Corner rounding"; subtitle: "How round window corners appear"; from: 0; to: 40; value: root.rounding; onEdited: value => { root.rounding = value; root.preview() } } + } + } + + Component { + id: transparencyCardComponent + SettingsCard { + title: "TRANSPARENCY" + darkMode: root.darkMode + SettingSlider { width: parent.width; darkMode: root.darkMode; title: "Active opacity"; subtitle: "Opacity of the focused window"; from: 0.3; to: 1; stepSize: 0.05; decimals: 2; value: root.activeOpacity; onEdited: value => { root.activeOpacity = value; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingSlider { width: parent.width; darkMode: root.darkMode; title: "Inactive opacity"; subtitle: "Opacity of unfocused windows"; from: 0.3; to: 1; stepSize: 0.05; decimals: 2; value: root.inactiveOpacity; onEdited: value => { root.inactiveOpacity = value; root.preview() } } + } + } + + Component { + id: effectsCardComponent + SettingsCard { + title: "EFFECTS" + darkMode: root.darkMode + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "Blur"; subtitle: "Blur behind translucent windows"; checked: root.blurEnabled; onToggled: checked => { root.blurEnabled = checked; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingSlider { width: parent.width; darkMode: root.darkMode; title: "Blur strength"; subtitle: "Blur kernel size"; from: 0; to: 20; value: root.blurSize; onEdited: value => { root.blurSize = value; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "Shadows"; subtitle: "Drop shadows under windows"; checked: root.shadowEnabled; onToggled: checked => { root.shadowEnabled = checked; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingToggle { width: parent.width; darkMode: root.darkMode; title: "Animations"; subtitle: "Window and workspace motion"; checked: root.animationsEnabled; onToggled: checked => { root.animationsEnabled = checked; root.preview() } } + } + } + + Component { + id: borderCardComponent + SettingsCard { + title: "BORDER COLORS" + darkMode: root.darkMode + SettingField { width: parent.width; darkMode: root.darkMode; title: "Active border"; subtitle: "Focused window border color"; text: root.activeBorder; placeholderText: "rgba(37d5e9ee)"; onCommitted: text => { root.activeBorder = text; root.preview() } } + Rectangle { width: parent.width; height: 1; color: theme.divider } + SettingField { width: parent.width; darkMode: root.darkMode; title: "Inactive border"; subtitle: "Unfocused window border color"; text: root.inactiveBorder; placeholderText: "rgba(6b747baa)"; onCommitted: text => { root.inactiveBorder = text; root.preview() } } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 14 + + Item { + id: toolbar + Layout.fillWidth: true + Layout.preferredHeight: root.compactToolbar ? 78 : 40 + + Row { + id: presetsRow + x: 0 + y: root.compactToolbar ? 0 : Math.round((toolbar.height - height) / 2) + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "PRESETS" + color: theme.alpha(theme.textSecondary, 0.68) + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 0.9 + } + GlassButton { text: "Compact"; darkMode: root.darkMode; onClicked: root.applyPreset("compact") } + GlassButton { text: "Comfortable"; darkMode: root.darkMode; onClicked: root.applyPreset("comfortable") } + GlassButton { text: "Spacious"; darkMode: root.darkMode; onClicked: root.applyPreset("spacious") } + } + + Row { + id: actionRow + x: Math.max(0, toolbar.width - width) + y: root.compactToolbar + ? Math.max(0, toolbar.height - height) + : Math.round((toolbar.height - height) / 2) + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.livePreviewEnabled ? "Live preview on" : "Live preview off" + color: root.livePreviewEnabled ? theme.alpha(theme.accent, 0.90) : theme.alpha(theme.textSecondary, 0.60) + font.family: "Inter" + font.pixelSize: 11 + } + IconButton { + iconName: "reload" + tooltip: "Reload values" + darkMode: root.darkMode + onClicked: root.load() + } + IconButton { + iconName: "save" + tooltip: "Save changes" + accent: true + darkMode: root.darkMode + onClicked: root.save() + } + } + } + + Flickable { + id: scroller + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: width + contentHeight: cardStage.height + 4 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Item { + id: cardStage + width: scroller.width + readonly property bool twoColumns: !root.singleColumnCards + height: twoColumns ? wideColumns.implicitHeight : narrowColumn.implicitHeight + + RowLayout { + id: wideColumns + width: parent.width + visible: cardStage.twoColumns + spacing: 14 + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: cardStage.width / 2 + Layout.alignment: Qt.AlignTop + spacing: 14 + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: cardStage.twoColumns + sourceComponent: spacingCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: cardStage.twoColumns + sourceComponent: effectsCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: cardStage.width / 2 + Layout.alignment: Qt.AlignTop + spacing: 14 + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: cardStage.twoColumns + sourceComponent: transparencyCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: cardStage.twoColumns + sourceComponent: borderCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + } + } + + ColumnLayout { + id: narrowColumn + width: parent.width + visible: !cardStage.twoColumns + spacing: 14 + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: !cardStage.twoColumns + sourceComponent: spacingCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: !cardStage.twoColumns + sourceComponent: transparencyCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: !cardStage.twoColumns + sourceComponent: effectsCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: !cardStage.twoColumns + sourceComponent: borderCardComponent + onLoaded: item.width = width + onWidthChanged: if (item) item.width = width + } + } + } + } + } +} diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..53875e0 --- /dev/null +++ b/qml/Main.qml @@ -0,0 +1,276 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import dev.hyprbinds.ui + +ApplicationWindow { + id: appWindow + + width: 1440 + height: 900 + minimumWidth: 620 + minimumHeight: 500 + visible: true + title: "Hyprbind" + color: "transparent" + flags: Qt.Window | Qt.FramelessWindowHint + + property string currentPage: "Overview" + property string configPath: "" + property string statusMessage: "Ready." + property bool backendHealthy: true + property bool darkMode: true + property bool livePreviewEnabled: false + property bool developerMode: false + property bool backupAvailable: false + property string backupAge: "" + + readonly property int sidebarWidth: width < 900 ? 72 : width < 1180 ? 240 : 286 + readonly property int contentMargin: width < 900 ? 10 : width < 1180 ? 16 : 24 + + HyprbindBridge { id: backend } + HyprbindTheme { id: theme; darkMode: appWindow.darkMode } + + function parsePayload(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function loadUiState() { + const payload = parsePayload(backend.uiSnapshot()) + if (!payload.ok) return + darkMode = payload.darkMode !== false + developerMode = payload.developerMode === true + backupAvailable = payload.hasBackup === true + backupAge = payload.backupAge || "" + } + + function toggleDarkMode() { + const next = !darkMode + const payload = parsePayload(backend.setDarkMode(next)) + if (!payload.ok) { + statusMessage = payload.error || "Could not save the theme preference." + return + } + darkMode = next + statusMessage = payload.message || (next ? "Dark theme enabled." : "Light theme enabled.") + } + + function toggleLivePreview() { + livePreviewEnabled = !livePreviewEnabled + statusMessage = livePreviewEnabled ? "Look & Feel live preview enabled." : "Look & Feel live preview disabled." + } + + function reloadCurrentPage() { + loadUiState() + if (pageLoader.item && typeof pageLoader.item.load === "function") { + pageLoader.item.load() + return + } + statusMessage = "This page does not expose reload." + } + + function restoreLastBackup() { + const payload = parsePayload(backend.restoreConfig()) + if (!payload.ok) { + statusMessage = payload.error || "Restore failed." + return + } + loadUiState() + if (pageLoader.item && typeof pageLoader.item.load === "function") pageLoader.item.load() + statusMessage = payload.message || "Restored the latest config snapshot." + } + + onCurrentPageChanged: { + backendHealthy = true + statusMessage = "Ready." + } + + Component.onCompleted: loadUiState() + + Rectangle { + id: shell + anchors.fill: parent + anchors.margins: 1 + radius: 18 + clip: true + color: theme.shellFill + border.width: 1 + border.color: theme.shellRim + antialiasing: true + + Rectangle { + anchors.fill: parent + radius: shell.radius + antialiasing: true + color: "transparent" + gradient: Gradient { + GradientStop { position: 0.00; color: theme.shellTopSpecular } + GradientStop { position: 0.20; color: theme.shellAccentWash } + GradientStop { position: 0.74; color: "transparent" } + GradientStop { position: 1.00; color: theme.shellBottomShade } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 28 + anchors.rightMargin: 28 + anchors.top: parent.top + anchors.topMargin: 1 + height: 1 + color: theme.shellInnerLine + } + + AppTopBar { + id: topBar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + appWindow: appWindow + darkMode: appWindow.darkMode + livePreviewEnabled: appWindow.livePreviewEnabled + backupAvailable: appWindow.backupAvailable + backupAge: appWindow.backupAge + onToggleThemeRequested: appWindow.toggleDarkMode() + onToggleLivePreviewRequested: appWindow.toggleLivePreview() + onRestoreRequested: appWindow.restoreLastBackup() + onReloadRequested: appWindow.reloadCurrentPage() + } + + AppSidebar { + id: sidebar + width: appWindow.sidebarWidth + anchors.left: parent.left + anchors.top: topBar.bottom + anchors.bottom: parent.bottom + darkMode: appWindow.darkMode + currentPage: appWindow.currentPage + onPageSelected: page => appWindow.currentPage = page + } + + Item { + id: mainArea + anchors.left: sidebar.right + anchors.right: parent.right + anchors.top: topBar.bottom + anchors.bottom: parent.bottom + + PageHeader { + id: pageHeader + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + currentPage: appWindow.currentPage + configPath: appWindow.configPath + darkMode: appWindow.darkMode + onStatus: message => appWindow.statusMessage = message + } + + StatusBar { + id: statusBar + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: appWindow.contentMargin + anchors.rightMargin: appWindow.contentMargin + anchors.bottomMargin: appWindow.contentMargin + darkMode: appWindow.darkMode + healthy: appWindow.backendHealthy + message: appWindow.statusMessage + } + + GlassPanel { + id: contentPanel + anchors.left: parent.left + anchors.right: parent.right + anchors.top: pageHeader.bottom + anchors.bottom: statusBar.top + anchors.leftMargin: appWindow.contentMargin + anchors.rightMargin: appWindow.contentMargin + anchors.bottomMargin: 10 + cornerRadius: 14 + darkMode: appWindow.darkMode + elevated: false + + Loader { + id: pageLoader + anchors.fill: parent + anchors.margins: appWindow.currentPage === "Look & Feel" ? 0 : (appWindow.width < 900 ? 10 : 14) + sourceComponent: appWindow.currentPage === "Overview" ? overviewComponent + : appWindow.currentPage === "Binds" ? bindsComponent + : appWindow.currentPage === "Variables" ? variablesComponent + : appWindow.currentPage === "Environment" ? environmentComponent + : appWindow.currentPage === "Submaps" ? submapsComponent + : appWindow.currentPage === "Startup" ? startupComponent + : appWindow.currentPage === "Window rules" ? windowRulesComponent + : appWindow.currentPage === "Workspace rules" ? workspaceRulesComponent + : appWindow.currentPage === "Layer rules" ? layerRulesComponent + : appWindow.currentPage === "Look & Feel" ? lookFeelComponent + : appWindow.currentPage === "Config" ? configComponent + : appWindow.currentPage === "Monitors" ? monitorsComponent + : appWindow.currentPage === "Devices" ? devicesComponent + : appWindow.currentPage === "Animations" ? animationsComponent + : appWindow.currentPage === "Curves" ? curvesComponent + : appWindow.currentPage === "Gestures" ? gesturesComponent + : appWindow.currentPage === "Health" ? healthComponent + : appWindow.currentPage === "Logs" ? logsComponent + : appWindow.currentPage === "Import / Export" ? importExportComponent + : developerToolsComponent + } + } + } + } + + Component { id: overviewComponent; OverviewPage { bridge: backend; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: bindsComponent; BindsPage { darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: variablesComponent; VariablesPage { bridge: backend; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: environmentComponent; EnvironmentPage { bridge: backend; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: submapsComponent; SubmapsPage { darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: startupComponent; StartupPage { darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + + Component { id: windowRulesComponent; RuleCatalogPage { kind: "window_rules"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: workspaceRulesComponent; SpecCatalogPage { kind: "workspace_rules"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: layerRulesComponent; RuleCatalogPage { kind: "layer_rules"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + + Component { + id: lookFeelComponent + LookFeelPage { + bridge: backend + darkMode: appWindow.darkMode + livePreviewEnabled: appWindow.livePreviewEnabled + onStatus: message => appWindow.statusMessage = message + onSaved: { appWindow.loadUiState(); appWindow.statusMessage = "Look & Feel saved." } + } + } + + Component { id: configComponent; ConfigPage { darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: monitorsComponent; SpecCatalogPage { kind: "monitors"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: devicesComponent; SpecCatalogPage { kind: "devices"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: animationsComponent; SpecCatalogPage { kind: "animations"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: curvesComponent; SpecCatalogPage { kind: "curves"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: gesturesComponent; SpecCatalogPage { kind: "gestures"; darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onConfigResolved: path => appWindow.configPath = path; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + + Component { id: healthComponent; HealthPage { darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { id: logsComponent; LogsPage { darkMode: appWindow.darkMode; onStatus: message => appWindow.statusMessage = message; onHealthChanged: healthy => appWindow.backendHealthy = healthy } } + Component { + id: importExportComponent + ImportExportPage { + bridge: backend + darkMode: appWindow.darkMode + onStatus: message => appWindow.statusMessage = message + onImported: appWindow.loadUiState() + } + } + Component { + id: developerToolsComponent + DeveloperToolsPage { + bridge: backend + darkMode: appWindow.darkMode + developerMode: appWindow.developerMode + onStatus: message => appWindow.statusMessage = message + onDeveloperModeCommitted: enabled => appWindow.developerMode = enabled + } + } +} diff --git a/qml/NavItem.qml b/qml/NavItem.qml new file mode 100644 index 0000000..f5fe873 --- /dev/null +++ b/qml/NavItem.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: control + + property string text: "" + property string iconName: "document" + property bool selected: false + property bool darkMode: true + property bool compact: false + + signal clicked() + + implicitHeight: 38 + + HoverHandler { id: hover } + TapHandler { + enabled: !control.selected + acceptedButtons: Qt.LeftButton + onTapped: control.clicked() + } + + HyprbindTheme { id: theme; darkMode: control.darkMode } + + // FIXME(pre-merge): temporary review sentinel - remove before merging. + // No runtime effect. Null, this is your chance to prove you actually opened the diff. + + Rectangle { + anchors.fill: parent + radius: 12 + antialiasing: true + visible: control.selected + color: theme.selectedFill + } + + Rectangle { + anchors.fill: parent + radius: 12 + antialiasing: true + visible: !control.selected && hover.hovered + color: theme.hoverFill + } + + Item { + id: content + anchors.fill: parent + anchors.leftMargin: control.compact ? 0 : 11 + anchors.rightMargin: control.compact ? 0 : 11 + clip: true + + Item { + id: iconSlot + width: 20 + height: 20 + x: control.compact ? Math.round((content.width - width) / 2) : 0 + y: Math.round((content.height - height) / 2) + + UiIcon { + anchors.centerIn: parent + width: 18 + height: 18 + name: control.iconName + iconColor: control.selected + ? theme.alpha(theme.accent, 0.92) + : hover.hovered + ? theme.alpha(theme.textPrimary, 0.92) + : theme.alpha(theme.textSecondary, 0.86) + } + } + + Text { + visible: !control.compact + anchors.left: iconSlot.right + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: control.text + color: control.selected + ? theme.textPrimary + : hover.hovered + ? theme.alpha(theme.textPrimary, 0.94) + : theme.alpha(theme.textPrimary, 0.84) + font.family: "Inter" + font.pixelSize: 13 + font.weight: control.selected ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + } + + ToolTip.visible: control.compact && hover.hovered + ToolTip.delay: 450 + ToolTip.text: control.text +} diff --git a/qml/OverviewPage.qml b/qml/OverviewPage.qml new file mode 100644 index 0000000..b6f5eb3 --- /dev/null +++ b/qml/OverviewPage.qml @@ -0,0 +1,341 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + + required property var bridge + property bool darkMode: true + property string configPath: "" + property bool healthy: true + property int environmentCount: 0 + property int variableCount: 0 + property bool backupAvailable: false + property string backupAge: "" + property bool blurEnabled: false + property int rounding: 0 + property real inactiveOpacity: 1.0 + + readonly property bool compactHero: width < 720 + readonly property int metricColumns: width >= 1080 ? 3 : width >= 640 ? 2 : 1 + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function load() { + const env = parse(bridge.environmentSnapshot()) + const vars = parse(bridge.variablesSnapshot()) + const look = parse(bridge.lookFeelSnapshot()) + const ui = parse(bridge.uiSnapshot()) + + configPath = env.configPath || vars.configPath || "" + configResolved(configPath) + environmentCount = env.ok ? (env.env || []).length : 0 + variableCount = vars.ok ? (vars.variables || []).length : 0 + backupAvailable = ui.hasBackup === true + backupAge = ui.backupAge || "" + blurEnabled = look.blurEnabled === true + rounding = look.rounding || 0 + inactiveOpacity = look.inactiveOpacity === undefined ? 1.0 : look.inactiveOpacity + healthy = env.ok === true && vars.ok === true && look.ok === true && ui.ok === true + healthChanged(healthy) + + if (!healthy) { + status(env.error || vars.error || look.error || ui.error || "Overview loaded with errors.") + return + } + status("Overview refreshed.") + } + + Component.onCompleted: load() + + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: contentColumn.implicitHeight + 4 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + ColumnLayout { + id: contentColumn + width: parent.width + spacing: 14 + + GlassPanel { + id: hero + Layout.fillWidth: true + Layout.preferredHeight: root.compactHero ? 238 : 156 + darkMode: root.darkMode + cornerRadius: 17 + elevated: false + tint: root.healthy ? theme.accent : theme.danger + + Item { + id: statusBlock + x: 18 + y: 18 + width: root.compactHero + ? hero.width - 36 + : Math.floor((hero.width - 58) * 0.54) + height: root.compactHero ? 82 : hero.height - 36 + + Rectangle { + id: statusIcon + width: 48 + height: 48 + radius: 14 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: root.healthy ? theme.alpha(theme.accent, 0.075) : theme.dangerFill + border.width: 1 + border.color: root.healthy ? theme.alpha(theme.accent, 0.16) : theme.dangerRim + + UiIcon { + anchors.centerIn: parent + width: 22 + height: 22 + name: root.healthy ? "check" : "info" + iconColor: root.healthy ? theme.accent : theme.danger + strokeWidth: 1.75 + } + } + + Column { + anchors.left: statusIcon.right + anchors.leftMargin: 15 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + + Text { + width: parent.width + text: root.healthy ? "Configuration ready" : "Configuration needs attention" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: root.compactHero ? 19 : 21 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.healthy + ? "Hyprbind is reading the managed configuration cleanly." + : "One or more configuration sources could not be read." + color: theme.alpha(theme.textSecondary, 0.82) + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + } + } + + Rectangle { + id: heroDivider + x: root.compactHero ? 18 : statusBlock.x + statusBlock.width + 20 + y: root.compactHero ? 108 : 22 + width: root.compactHero ? hero.width - 36 : 1 + height: root.compactHero ? 1 : hero.height - 44 + color: theme.divider + } + + Column { + id: metaColumn + x: root.compactHero ? 18 : heroDivider.x + 19 + y: root.compactHero ? 121 : 24 + width: root.compactHero + ? hero.width - 36 + : hero.width - x - 18 + spacing: 11 + + Item { + width: parent.width + height: 43 + + Text { + anchors.left: parent.left + anchors.top: parent.top + text: "CONFIG SOURCE" + color: theme.alpha(theme.textSecondary, 0.66) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + text: root.configPath.length ? root.configPath : "Config path unresolved" + color: theme.alpha(theme.textPrimary, 0.90) + font.family: "monospace" + font.pixelSize: 10 + elide: Text.ElideMiddle + } + } + + Rectangle { + width: parent.width + height: 1 + color: theme.divider + } + + Item { + width: parent.width + height: 43 + + Text { + anchors.left: parent.left + anchors.top: parent.top + text: "RECOVERY" + color: theme.alpha(theme.textSecondary, 0.66) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + text: root.backupAvailable + ? (root.backupAge.length ? root.backupAge : "Snapshot available") + : "No snapshot yet" + color: root.backupAvailable ? theme.accent : theme.textSecondary + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + Text { + text: "AT A GLANCE" + color: theme.alpha(theme.textSecondary, 0.66) + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: theme.divider + } + } + + GridLayout { + Layout.fillWidth: true + columns: root.metricColumns + columnSpacing: 14 + rowSpacing: 14 + + SummaryCard { + Layout.fillWidth: true + darkMode: root.darkMode + iconName: "document" + title: "ENVIRONMENT" + value: root.environmentCount.toString() + subtitle: root.environmentCount === 1 ? "1 environment variable" : root.environmentCount + " environment variables" + accent: true + } + + SummaryCard { + Layout.fillWidth: true + darkMode: root.darkMode + iconName: "braces" + title: "VARIABLES" + value: root.variableCount.toString() + subtitle: root.variableCount === 1 ? "1 reusable config variable" : root.variableCount + " reusable config variables" + } + + SummaryCard { + Layout.fillWidth: true + darkMode: root.darkMode + iconName: "history" + title: "RESTORE POINT" + value: root.backupAvailable ? "Ready" : "None" + subtitle: root.backupAvailable + ? (root.backupAge.length ? root.backupAge : "A managed snapshot is available") + : "Created after Hyprbind writes config" + accent: root.backupAvailable + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + Text { + text: "WINDOW FEEL" + color: theme.alpha(theme.textSecondary, 0.66) + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: theme.divider + } + } + + GlassPanel { + Layout.fillWidth: true + Layout.preferredHeight: root.width < 640 ? 246 : 112 + darkMode: root.darkMode + cornerRadius: 15 + elevated: false + + GridLayout { + anchors.fill: parent + anchors.margins: 16 + columns: root.width < 640 ? 1 : 3 + columnSpacing: 0 + rowSpacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + Text { text: "BLUR"; color: theme.alpha(theme.textSecondary, 0.64); font.family: "Inter"; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Text { text: root.blurEnabled ? "Enabled" : "Disabled"; color: root.blurEnabled ? theme.accent : theme.textPrimary; font.family: "Inter"; font.pixelSize: 18; font.weight: Font.DemiBold } + Text { text: "Backdrop blur state"; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 10 } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + Text { text: "ROUNDING"; color: theme.alpha(theme.textSecondary, 0.64); font.family: "Inter"; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Text { text: root.rounding + " px"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 18; font.weight: Font.DemiBold } + Text { text: "Window corner radius"; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 10 } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + Text { text: "INACTIVE OPACITY"; color: theme.alpha(theme.textSecondary, 0.64); font.family: "Inter"; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Text { text: Number(root.inactiveOpacity).toFixed(2); color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 18; font.weight: Font.DemiBold } + Text { text: "Unfocused window opacity"; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 10 } + } + } + } + } + } +} diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml new file mode 100644 index 0000000..a221a2f --- /dev/null +++ b/qml/PageHeader.qml @@ -0,0 +1,241 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + property bool darkMode: true + property string currentPage: "Environment" + property string configPath: "" + signal status(string message) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + readonly property bool fullPath: width >= 920 + readonly property bool showSubtitle: width >= 560 + readonly property bool configContext: currentPage !== "Overview" && currentPage !== "Health" && currentPage !== "Logs" && currentPage !== "Import / Export" && currentPage !== "Developer tools" + readonly property int edgeMargin: width < 700 ? 16 : 24 + property bool copied: false + + height: width < 700 ? 82 : 94 + + function pageSubtitle() { + if (currentPage === "Environment") return "Manage environment variables for Hyprland and apps launched in your session." + if (currentPage === "Look & Feel") return "Shape Hyprland’s spacing, transparency, effects, and window borders." + if (currentPage === "Overview") return "A quick view of your configuration and Hyprbind state." + if (currentPage === "Binds") return "Inspect, search, add, and safely edit keybinds across global and submap contexts." + if (currentPage === "Variables") return "Reusable values referenced across binds, rules, and commands." + if (currentPage === "Submaps") return "Organize keybinds into named modes and see which binds each mode owns." + if (currentPage === "Startup") return "Choose which commands run when Hyprland starts, reloads, or shuts down." + if (currentPage === "Window rules") return "Match windows and apply behavior without leaving the managed configuration workflow." + if (currentPage === "Workspace rules") return "Control per-workspace selectors, gaps, layouts, monitors, and related properties." + if (currentPage === "Layer rules") return "Match layer-shell namespaces such as bars and launchers and apply layer effects." + if (currentPage === "Config") return "Inspect and edit the merged configuration through Hyprbind’s managed override writer." + if (currentPage === "Monitors") return "Manage collected monitor calls and their typed output configuration fields." + if (currentPage === "Devices") return "Configure device-specific input behavior using the existing typed call writer." + if (currentPage === "Animations") return "Edit animation leaves, speed, curves, and related animation fields." + if (currentPage === "Curves") return "Create and edit named animation curves while preserving typed curve data." + if (currentPage === "Gestures") return "Manage gesture calls, actions, fingers, direction, and related fields." + if (currentPage === "Health") return "Inspect session, Hyprland, portals, audio, and desktop integration." + if (currentPage === "Logs") return "Search the recent user-session journal without leaving Hyprbind." + if (currentPage === "Import / Export") return "Back up and restore Hyprbind settings through the existing Rust bundle pipeline." + if (currentPage === "Developer tools") return "Access the existing developer-only companion studios during the QML migration." + return "Hyprland configuration." + } + + GlassPanel { + id: pageIcon + width: root.width < 700 ? 44 : 52 + height: width + anchors.left: parent.left + anchors.leftMargin: root.edgeMargin + anchors.verticalCenter: parent.verticalCenter + cornerRadius: root.width < 700 ? 11 : 12 + darkMode: root.darkMode + elevated: false + tint: theme.accent + border.color: theme.alpha(theme.accent, 0.14) + + Rectangle { + anchors.fill: parent + anchors.margins: 8 + radius: 9 + color: theme.alpha(theme.mix(theme.surfaceHigh, theme.accent, 0.08), root.darkMode ? 0.12 : 0.18) + } + + UiIcon { + anchors.centerIn: parent + width: root.width < 700 ? 21 : 24 + height: width + name: theme.pageIcon(root.currentPage) + iconColor: theme.accent + strokeWidth: 1.75 + } + } + + GlassPanel { + id: pathPanel + width: root.fullPath ? 320 : 44 + height: 43 + anchors.right: parent.right + anchors.rightMargin: root.edgeMargin + anchors.verticalCenter: parent.verticalCenter + darkMode: root.darkMode + elevated: false + visible: root.configContext && root.configPath.length > 0 + + TextInput { + id: pathText + x: root.fullPath ? 14 : 1 + width: root.fullPath ? Math.max(1, copyPath.x - 22) : 1 + anchors.verticalCenter: parent.verticalCenter + text: root.configPath + readOnly: true + selectByMouse: root.fullPath + opacity: root.fullPath ? 1 : 0 + color: theme.alpha(theme.textPrimary, 0.80) + font.family: "Inter" + font.pixelSize: 11 + clip: true + } + + Button { + id: copyPath + width: root.fullPath ? 42 : parent.width + height: parent.height + anchors.right: parent.right + padding: 0 + flat: true + hoverEnabled: true + scale: 1 + + contentItem: Item { + Item { + width: 20 + height: 20 + anchors.centerIn: parent + opacity: root.copied ? 0 : 1 + scale: root.copied ? 0.78 : 1 + + Rectangle { + x: 3 + y: 4 + width: 9 + height: 1 + radius: 0.5 + color: copyPath.hovered ? theme.alpha(theme.textPrimary, 0.94) : theme.alpha(theme.textSecondary, 0.88) + } + Rectangle { + x: 3 + y: 4 + width: 1 + height: 9 + radius: 0.5 + color: copyPath.hovered ? theme.alpha(theme.textPrimary, 0.94) : theme.alpha(theme.textSecondary, 0.88) + } + Rectangle { + x: 3 + y: 12 + width: 4 + height: 1 + radius: 0.5 + color: copyPath.hovered ? theme.alpha(theme.textPrimary, 0.94) : theme.alpha(theme.textSecondary, 0.88) + } + Rectangle { + x: 7 + y: 7 + width: 10 + height: 10 + radius: 2 + color: "transparent" + border.width: 1 + border.color: copyPath.hovered ? theme.alpha(theme.textPrimary, 0.94) : theme.alpha(theme.textSecondary, 0.88) + antialiasing: true + } + + Behavior on opacity { NumberAnimation { duration: 100 } } + Behavior on scale { NumberAnimation { duration: 110; easing.type: Easing.OutCubic } } + } + + UiIcon { + anchors.centerIn: parent + width: 18 + height: 18 + name: "check" + iconColor: theme.accent + opacity: root.copied ? 1 : 0 + scale: root.copied ? 1 : 0.72 + Behavior on opacity { NumberAnimation { duration: 120 } } + Behavior on scale { NumberAnimation { duration: 140; easing.type: Easing.OutBack } } + } + } + + background: Rectangle { + radius: 10 + color: root.copied + ? theme.alpha(theme.accent, 0.10) + : copyPath.pressed + ? theme.controlPressed + : copyPath.hovered ? theme.controlHover : "transparent" + border.width: copyPath.hovered || root.copied ? 1 : 0 + border.color: root.copied ? theme.alpha(theme.accent, 0.22) : theme.controlRim + Behavior on color { ColorAnimation { duration: 120 } } + Behavior on border.color { ColorAnimation { duration: 120 } } + } + + onClicked: { + pathText.forceActiveFocus() + pathText.selectAll() + pathText.copy() + pathText.deselect() + root.copied = true + copyPulse.restart() + copiedReset.restart() + root.status("Copied config path.") + } + + ToolTip.visible: hovered + ToolTip.delay: 420 + ToolTip.text: root.copied ? "Copied" : "Copy config path" + } + } + + SequentialAnimation { + id: copyPulse + PropertyAnimation { target: copyPath; property: "scale"; to: 0.94; duration: 65; easing.type: Easing.OutCubic } + PropertyAnimation { target: copyPath; property: "scale"; to: 1.035; duration: 95; easing.type: Easing.OutBack } + PropertyAnimation { target: copyPath; property: "scale"; to: 1.0; duration: 90; easing.type: Easing.OutCubic } + } + + Timer { id: copiedReset; interval: 1050; repeat: false; onTriggered: root.copied = false } + + Column { + id: titleColumn + anchors.left: pageIcon.right + anchors.leftMargin: root.width < 700 ? 13 : 18 + anchors.right: pathPanel.visible ? pathPanel.left : parent.right + anchors.rightMargin: pathPanel.visible ? 16 : root.edgeMargin + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + width: parent.width + text: root.currentPage + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: root.width < 700 ? 22 : 25 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + visible: root.showSubtitle && parent.width > 220 + width: parent.width + text: root.pageSubtitle() + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 12 + elide: Text.ElideRight + } + } +} diff --git a/qml/RuleCatalogPage.qml b/qml/RuleCatalogPage.qml new file mode 100644 index 0000000..686d33f --- /dev/null +++ b/qml/RuleCatalogPage.qml @@ -0,0 +1,232 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + required property string kind + property bool darkMode: true + property var allItems: [] + property int selectedId: -1 + property string configPath: "" + readonly property bool compactToolbar: width < 700 + readonly property bool layerMode: kind === "layer_rules" + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + CatalogBridge { id: catalog } + ListModel { id: model } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function selectedItem() { + for (let i = 0; i < allItems.length; ++i) + if (allItems[i].entryId === selectedId) return allItems[i] + return null + } + + function load() { + const payload = parse(catalog.snapshot(kind)) + allItems = payload.items || [] + configPath = payload.configPath || "" + configResolved(configPath) + healthChanged(payload.ok === true) + if (!payload.ok) status(payload.error || "Could not load rules.") + else if (payload.warning) status(payload.warning) + else status((layerMode ? "Layer" : "Window") + " rules loaded from config.") + if (selectedId >= 0 && !selectedItem()) selectedId = -1 + rebuildModel() + } + + function rebuildModel() { + const q = searchField.text.trim().toLowerCase() + model.clear() + for (let i = 0; i < allItems.length; ++i) { + const item = allItems[i] + const haystack = (item.displayName + " " + item.matchLabel + " " + item.effectsLabel + " " + item.sourceLabel).toLowerCase() + if (!q || haystack.indexOf(q) >= 0) model.append(item) + } + } + + function openEditor(item) { + editor.entryId = item ? item.entryId : -1 + editorName.text = item ? (item.name || item.displayName) : "" + matchText.text = JSON.stringify(item ? item.match : {}, null, 2) + effectsText.text = JSON.stringify(item ? item.effects : {}, null, 2) + editorError.text = "" + editor.open() + editorName.forceActiveFocus() + } + + function parseObject(text, label) { + try { + const value = JSON.parse(text) + if (!value || Array.isArray(value) || typeof value !== "object") + throw new Error(label + " must be a JSON object") + return value + } catch (error) { + editorError.text = label + ": " + error + return null + } + } + + function saveEditor() { + const matchObject = parseObject(matchText.text, "Match") + if (matchObject === null) return + const effectsObject = parseObject(effectsText.text, "Effects") + if (effectsObject === null) return + const payload = parse(catalog.saveItem(kind, editor.entryId, JSON.stringify({ + name: editorName.text, + match: matchObject, + effects: effectsObject + }))) + if (!payload.ok) { + editorError.text = payload.error || "Save failed." + status(editorError.text) + return + } + editor.close() + load() + status(payload.message || "Rule saved.") + } + + function deleteSelected() { + if (selectedId < 0) return + const payload = parse(catalog.deleteItem(kind, selectedId)) + if (!payload.ok) { status(payload.error || "Delete failed."); return } + selectedId = -1 + load() + status(payload.message || "Rule deleted.") + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 96 : 52 + + GlassField { + id: searchField + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : actions.left + anchors.rightMargin: root.compactToolbar ? 0 : 12 + anchors.top: parent.top + anchors.topMargin: 4 + implicitHeight: 43 + darkMode: root.darkMode + placeholderText: root.layerMode ? "Filter layer rules..." : "Filter window rules..." + leftPadding: 42 + onTextChanged: root.rebuildModel() + UiIcon { width: 17; height: 17; name: "search"; anchors.left: parent.left; anchors.leftMargin: 14; anchors.verticalCenter: parent.verticalCenter; iconColor: theme.textSecondary } + } + + Row { + id: actions + anchors.right: parent.right + anchors.top: root.compactToolbar ? searchField.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 7 : 4 + spacing: 7 + IconButton { iconName: "plus"; tooltip: "Add rule"; darkMode: root.darkMode; accent: true; onClicked: root.openEditor(null) } + IconButton { iconName: "edit"; tooltip: "Edit selected rule"; darkMode: root.darkMode; enabled: root.selectedId >= 0; onClicked: root.openEditor(root.selectedItem()) } + IconButton { iconName: "trash"; tooltip: "Delete selected rule"; darkMode: root.darkMode; danger: true; enabled: root.selectedId >= 0; onClicked: root.deleteSelected() } + } + } + + ListView { + id: list + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 5 + model: model + clip: true + spacing: 4 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: row + required property int entryId + required property string displayName + required property string matchLabel + required property string effectsLabel + required property string sourceLabel + width: list.width + height: root.width < 620 ? 102 : 84 + radius: 11 + color: root.selectedId === entryId ? theme.selectedFill : hover.hovered ? theme.hoverFill : "transparent" + border.width: root.selectedId === entryId ? 1 : 0 + border.color: theme.selectedRim + HoverHandler { id: hover } + + Column { + anchors.left: parent.left + anchors.leftMargin: 15 + anchors.right: parent.right + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { width: parent.width; text: displayName; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 13; font.weight: Font.Medium; elide: Text.ElideRight } + Text { width: parent.width; text: "match " + matchLabel; color: theme.alpha(theme.textSecondary, 0.90); font.family: "monospace"; font.pixelSize: 10; elide: Text.ElideRight } + Text { width: parent.width; text: "effects " + effectsLabel; color: theme.alpha(theme.textSecondary, 0.76); font.family: "monospace"; font.pixelSize: 10; elide: Text.ElideRight } + Text { width: parent.width; visible: sourceLabel.length > 0; text: sourceLabel; color: theme.alpha(theme.textSecondary, 0.50); font.family: "Inter"; font.pixelSize: 9; elide: Text.ElideRight } + } + TapHandler { onTapped: root.selectedId = entryId; onDoubleTapped: { root.selectedId = entryId; root.openEditor(root.selectedItem()) } } + } + + Column { + anchors.centerIn: parent + spacing: 12 + visible: model.count === 0 + UiIcon { width: 30; height: 30; anchors.horizontalCenter: parent.horizontalCenter; name: root.layerMode ? "layers" : "window"; iconColor: theme.alpha(theme.textSecondary, 0.65) } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: root.allItems.length ? "No rules match this filter" : "No rules yet"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 17; font.weight: Font.Medium } + GlassButton { visible: root.allItems.length === 0; anchors.horizontalCenter: parent.horizontalCenter; text: "Add rule"; darkMode: root.darkMode; accent: true; onClicked: root.openEditor(null) } + } + } + + Popup { + id: editor + property int entryId: -1 + width: Math.min(720, root.width - 28) + height: Math.min(650, root.height - 24) + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + Overlay.modal: Rectangle { color: theme.alpha(theme.background, root.darkMode ? 0.48 : 0.24) } + background: Rectangle { radius: 18; color: theme.alpha(theme.familyShell, 0.96); border.width: 1; border.color: theme.shellRim } + + Column { + anchors.fill: parent + anchors.margins: root.width < 620 ? 16 : 22 + spacing: 10 + Text { text: editor.entryId >= 0 ? "Edit rule" : "Add rule"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 20; font.weight: Font.DemiBold } + Text { width: parent.width; text: root.layerMode ? "Define namespace matching and layer effects. Values remain typed JSON so booleans and numbers stay intact." : "Define window matching and effects. Values remain typed JSON so booleans and numbers stay intact."; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 10; wrapMode: Text.WordWrap } + GlassField { id: editorName; width: parent.width; darkMode: root.darkMode; placeholderText: "Rule name (optional)" } + Text { text: "MATCH"; color: theme.alpha(theme.textSecondary, 0.70); font.family: "Inter"; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 1 } + GlassTextArea { id: matchText; width: parent.width; height: Math.max(100, (editor.height - 260) / 2); darkMode: root.darkMode; placeholderText: root.layerMode ? "{\n \"namespace\": \"waybar\"\n}" : "{\n \"class\": \"^kitty$\"\n}" } + Text { text: "EFFECTS"; color: theme.alpha(theme.textSecondary, 0.70); font.family: "Inter"; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 1 } + GlassTextArea { id: effectsText; width: parent.width; height: Math.max(100, (editor.height - 260) / 2); darkMode: root.darkMode; placeholderText: "{\n \"float\": true\n}" } + Text { id: editorError; width: parent.width; visible: text.length > 0; color: theme.danger; font.family: "Inter"; font.pixelSize: 10; wrapMode: Text.WordWrap } + Row { + anchors.right: parent.right + spacing: 8 + GlassButton { text: "Cancel"; darkMode: root.darkMode; onClicked: editor.close() } + GlassButton { text: "Save"; darkMode: root.darkMode; accent: true; onClicked: root.saveEditor() } + } + } + } +} diff --git a/qml/SettingField.qml b/qml/SettingField.qml new file mode 100644 index 0000000..f5b4612 --- /dev/null +++ b/qml/SettingField.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + property bool darkMode: true + property string title: "Setting" + property string subtitle: "" + property alias text: field.text + property string placeholderText: "" + readonly property bool compactLayout: width < 500 + + signal committed(string text) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + implicitHeight: compactLayout ? 104 : 68 + + GridLayout { + anchors.fill: parent + columns: root.compactLayout ? 1 : 2 + columnSpacing: 24 + rowSpacing: 8 + + Column { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 3 + + Text { + width: parent.width + text: root.title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + GlassField { + id: field + Layout.fillWidth: root.compactLayout + Layout.preferredWidth: root.compactLayout ? root.width : 250 + Layout.minimumWidth: root.compactLayout ? 0 : 180 + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + darkMode: root.darkMode + placeholderText: root.placeholderText + onEditingFinished: root.committed(text) + } + } +} diff --git a/qml/SettingSlider.qml b/qml/SettingSlider.qml new file mode 100644 index 0000000..5a11f9d --- /dev/null +++ b/qml/SettingSlider.qml @@ -0,0 +1,225 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + + property bool darkMode: true + property string title: "Setting" + property string subtitle: "" + property real from: 0 + property real to: 100 + property real stepSize: 1 + property real value: 0 + property int decimals: 0 + property string suffix: "" + readonly property bool compactLayout: width < 500 + + signal edited(real value) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + implicitHeight: compactLayout ? 96 : 70 + + GridLayout { + anchors.fill: parent + columns: root.compactLayout ? 1 : 2 + columnSpacing: 24 + rowSpacing: 8 + + Column { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 3 + + Text { + width: parent.width + text: root.title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + RowLayout { + Layout.fillWidth: root.compactLayout + Layout.preferredWidth: root.compactLayout ? -1 : 286 + Layout.minimumWidth: root.compactLayout ? 0 : 220 + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + spacing: 12 + + Slider { + id: slider + + readonly property real handleBoxWidth: 28 + readonly property real trackInset: handleBoxWidth / 2 + + Layout.fillWidth: true + Layout.minimumWidth: root.compactLayout ? 130 : 154 + Layout.preferredWidth: 220 + Layout.preferredHeight: 34 + + from: root.from + to: root.to + stepSize: root.stepSize + value: root.value + hoverEnabled: true + onMoved: root.edited(value) + + background: Item { + x: slider.leftPadding + slider.trackInset + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: Math.max(0, slider.availableWidth - slider.handleBoxWidth) + height: 10 + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 3 + radius: 1.5 + color: theme.alpha(theme.textSecondary, slider.hovered ? 0.14 : 0.09) + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 1 + anchors.rightMargin: 1 + height: 1 + radius: 1 + color: theme.alpha(theme.foreground, slider.hovered ? 0.036 : 0.022) + } + } + + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: slider.visualPosition * parent.width + height: 3 + radius: 1.5 + color: theme.alpha(theme.accent, slider.pressed ? 0.72 : slider.hovered ? 0.62 : 0.54) + + Rectangle { + visible: parent.width > 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 1 + anchors.rightMargin: 1 + height: 1 + radius: 1 + color: theme.alpha(theme.foreground, slider.pressed ? 0.08 : 0.045) + } + + Behavior on color { ColorAnimation { duration: 120 } } + } + } + + handle: Item { + id: handleBox + x: Math.round(slider.leftPadding + slider.visualPosition * (slider.availableWidth - width)) + y: Math.round(slider.topPadding + slider.availableHeight / 2 - height / 2) + width: slider.handleBoxWidth + height: 34 + + Rectangle { + anchors.centerIn: parent + width: 20 + height: 28 + radius: 10 + color: theme.alpha(theme.accent, slider.pressed ? 0.055 : slider.hovered ? 0.028 : 0) + opacity: slider.hovered || slider.pressed ? 1 : 0 + scale: slider.pressed ? 1.04 : 1 + + Behavior on opacity { NumberAnimation { duration: 110 } } + Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } + } + + Rectangle { + id: visibleHandle + anchors.centerIn: parent + width: 10 + height: 22 + radius: 5 + antialiasing: true + scale: slider.pressed ? 1.045 : slider.hovered ? 1.02 : 1 + color: slider.pressed + ? theme.mix(theme.surfaceHigh, theme.foreground, 0.14) + : theme.mix(theme.surfaceHigh, theme.foreground, 0.08) + border.width: 1 + border.color: theme.alpha( + slider.hovered || slider.pressed ? theme.accent : theme.foreground, + slider.pressed ? 0.30 : slider.hovered ? 0.18 : 0.085 + ) + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 2 + anchors.rightMargin: 2 + anchors.topMargin: 2 + height: 1 + radius: 1 + color: theme.alpha(theme.foreground, slider.pressed ? 0.19 : 0.11) + } + + Rectangle { + anchors.centerIn: parent + width: 2 + height: 8 + radius: 1 + color: theme.alpha(theme.accent, slider.pressed ? 0.76 : slider.hovered ? 0.60 : 0.44) + } + + Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 110 } } + Behavior on border.color { ColorAnimation { duration: 110 } } + } + } + } + + Rectangle { + Layout.preferredWidth: root.decimals > 0 ? 58 : 54 + Layout.preferredHeight: 31 + radius: 10 + color: theme.controlFill + border.width: 1 + border.color: theme.controlRim + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 8 + anchors.rightMargin: 8 + height: 1 + radius: 1 + color: theme.controlInnerRim + } + + Text { + anchors.centerIn: parent + text: Number(root.value).toFixed(root.decimals) + root.suffix + color: theme.alpha(theme.textPrimary, 0.90) + font.family: "Inter" + font.pixelSize: 11 + font.weight: Font.Medium + } + } + } + } +} diff --git a/qml/SettingToggle.qml b/qml/SettingToggle.qml new file mode 100644 index 0000000..d03014f --- /dev/null +++ b/qml/SettingToggle.qml @@ -0,0 +1,92 @@ +import QtQuick + +Item { + id: root + + property bool darkMode: true + property string title: "Setting" + property string subtitle: "" + property bool checked: false + + signal toggled(bool checked) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + implicitHeight: 64 + + Column { + anchors.left: parent.left + anchors.right: switchTrack.left + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + Rectangle { + id: switchTrack + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 44 + height: 25 + radius: 13 + color: root.checked ? theme.alpha(theme.accent, 0.22) : theme.controlFill + border.width: 1 + border.color: root.checked ? theme.alpha(theme.accent, 0.30) : theme.controlRim + scale: tap.pressed ? 0.97 : 1 + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 7 + anchors.rightMargin: 7 + height: 1 + radius: 1 + color: root.checked + ? theme.alpha(theme.foreground, 0.08) + : theme.controlInnerRim + } + + Rectangle { + width: 19 + height: 19 + radius: 10 + y: 3 + x: root.checked ? parent.width - width - 3 : 3 + color: root.checked + ? theme.mix(theme.foreground, theme.accent, 0.07) + : theme.alpha(theme.foreground, 0.74) + border.width: 1 + border.color: theme.alpha(theme.foreground, root.checked ? 0.10 : 0.055) + + Behavior on x { NumberAnimation { duration: 165; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 150 } } + } + + HoverHandler { id: hover } + TapHandler { id: tap; onTapped: root.toggled(!root.checked) } + + Behavior on color { ColorAnimation { duration: 160; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 150 } } + Behavior on scale { NumberAnimation { duration: 105; easing.type: Easing.OutCubic } } + } +} diff --git a/qml/SettingsCard.qml b/qml/SettingsCard.qml new file mode 100644 index 0000000..49afa30 --- /dev/null +++ b/qml/SettingsCard.qml @@ -0,0 +1,36 @@ +import QtQuick + +GlassPanel { + id: root + property string title: "Section" + default property alias rows: rowColumn.data + + HyprbindTheme { id: theme; darkMode: root.darkMode } + elevated: false + cornerRadius: 16 + implicitHeight: contentColumn.implicitHeight + 30 + + Column { + id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 15 + spacing: 4 + + Text { + text: root.title + color: theme.alpha(theme.textSecondary, 0.72) + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + } + + Column { + id: rowColumn + width: parent.width + spacing: 0 + } + } +} diff --git a/qml/SpecCatalogPage.qml b/qml/SpecCatalogPage.qml new file mode 100644 index 0000000..01f6116 --- /dev/null +++ b/qml/SpecCatalogPage.qml @@ -0,0 +1,227 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + required property string kind + property bool darkMode: true + property var allItems: [] + property int selectedId: -1 + property string configPath: "" + readonly property bool compactToolbar: width < 700 + readonly property bool curveMode: kind === "curves" + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + CatalogBridge { id: catalog } + ListModel { id: itemModel } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function labelForKind() { + if (kind === "workspace_rules") return "workspace rule" + if (kind === "monitors") return "monitor" + if (kind === "devices") return "device" + if (kind === "animations") return "animation" + if (kind === "curves") return "curve" + if (kind === "gestures") return "gesture" + return "item" + } + + function selectedItem() { + for (let i = 0; i < allItems.length; ++i) + if (allItems[i].entryId === selectedId) return allItems[i] + return null + } + + function load() { + const payload = parse(catalog.snapshot(kind)) + allItems = payload.items || [] + configPath = payload.configPath || "" + configResolved(configPath) + healthChanged(payload.ok === true) + if (!payload.ok) status(payload.error || "Could not load " + labelForKind() + "s.") + else if (payload.warning) status(payload.warning) + else status("Loaded " + allItems.length + " " + labelForKind() + (allItems.length === 1 ? "." : "s.")) + if (selectedId >= 0 && !selectedItem()) selectedId = -1 + rebuildModel() + } + + function rebuildModel() { + const q = searchField.text.trim().toLowerCase() + itemModel.clear() + for (let i = 0; i < allItems.length; ++i) { + const item = allItems[i] + const haystack = (item.displayName + " " + item.fieldsLabel + " " + item.sourceLabel).toLowerCase() + if (!q || haystack.indexOf(q) >= 0) itemModel.append(item) + } + } + + function openEditor(item) { + editor.entryId = item ? item.entryId : -1 + editorName.text = item ? (item.name || item.displayName) : "" + fieldsText.text = JSON.stringify(item ? item.fields : {}, null, 2) + editorError.text = "" + editor.open() + if (curveMode) editorName.forceActiveFocus() + else fieldsText.forceActiveFocus() + } + + function saveEditor() { + let fields + try { + fields = JSON.parse(fieldsText.text) + if (!fields || Array.isArray(fields) || typeof fields !== "object") + throw new Error("Fields must be a JSON object") + } catch (error) { + editorError.text = "Fields: " + error + return + } + const payload = parse(catalog.saveItem(kind, editor.entryId, JSON.stringify({ name: editorName.text, fields: fields }))) + if (!payload.ok) { + editorError.text = payload.error || "Save failed." + status(editorError.text) + return + } + editor.close() + load() + status(payload.message || "Saved.") + } + + function deleteSelected() { + if (selectedId < 0) return + const payload = parse(catalog.deleteItem(kind, selectedId)) + if (!payload.ok) { status(payload.error || "Delete failed."); return } + selectedId = -1 + load() + status(payload.message || "Deleted.") + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 96 : 52 + + GlassField { + id: searchField + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : actions.left + anchors.rightMargin: root.compactToolbar ? 0 : 12 + anchors.top: parent.top + anchors.topMargin: 4 + implicitHeight: 43 + darkMode: root.darkMode + placeholderText: "Filter " + root.labelForKind() + "s..." + leftPadding: 42 + onTextChanged: root.rebuildModel() + UiIcon { width: 17; height: 17; name: "search"; anchors.left: parent.left; anchors.leftMargin: 14; anchors.verticalCenter: parent.verticalCenter; iconColor: theme.textSecondary } + } + + Row { + id: actions + anchors.right: parent.right + anchors.top: root.compactToolbar ? searchField.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 7 : 4 + spacing: 7 + IconButton { iconName: "plus"; tooltip: "Add " + root.labelForKind(); darkMode: root.darkMode; accent: true; onClicked: root.openEditor(null) } + IconButton { iconName: "edit"; tooltip: "Edit selected " + root.labelForKind(); darkMode: root.darkMode; enabled: root.selectedId >= 0; onClicked: root.openEditor(root.selectedItem()) } + IconButton { iconName: "trash"; tooltip: "Delete selected " + root.labelForKind(); darkMode: root.darkMode; danger: true; enabled: root.selectedId >= 0; onClicked: root.deleteSelected() } + } + } + + ListView { + id: list + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 5 + model: itemModel + clip: true + spacing: 4 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: row + required property int entryId + required property string displayName + required property string fieldsLabel + required property string sourceLabel + width: list.width + height: root.width < 620 ? 88 : 76 + radius: 11 + color: root.selectedId === entryId ? theme.selectedFill : hover.hovered ? theme.hoverFill : "transparent" + border.width: root.selectedId === entryId ? 1 : 0 + border.color: theme.selectedRim + HoverHandler { id: hover } + + Column { + anchors.left: parent.left + anchors.leftMargin: 15 + anchors.right: parent.right + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + Text { width: parent.width; text: displayName; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 13; font.weight: Font.Medium; elide: Text.ElideRight } + Text { width: parent.width; text: fieldsLabel; color: theme.alpha(theme.textSecondary, 0.84); font.family: "monospace"; font.pixelSize: 10; elide: Text.ElideRight } + Text { width: parent.width; visible: sourceLabel.length > 0; text: sourceLabel; color: theme.alpha(theme.textSecondary, 0.50); font.family: "Inter"; font.pixelSize: 9; elide: Text.ElideRight } + } + TapHandler { onTapped: root.selectedId = entryId; onDoubleTapped: { root.selectedId = entryId; root.openEditor(root.selectedItem()) } } + } + + Column { + anchors.centerIn: parent + spacing: 12 + visible: itemModel.count === 0 + UiIcon { width: 30; height: 30; anchors.horizontalCenter: parent.horizontalCenter; name: kind === "monitors" ? "monitor" : kind === "devices" ? "device" : kind === "animations" ? "sparkle" : kind === "curves" ? "curve" : kind === "gestures" ? "gesture" : "grid"; iconColor: theme.alpha(theme.textSecondary, 0.65) } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: root.allItems.length ? "Nothing matches this filter" : "No " + root.labelForKind() + "s yet"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 17; font.weight: Font.Medium } + GlassButton { visible: root.allItems.length === 0; anchors.horizontalCenter: parent.horizontalCenter; text: "Add " + root.labelForKind(); darkMode: root.darkMode; accent: true; onClicked: root.openEditor(null) } + } + } + + Popup { + id: editor + property int entryId: -1 + width: Math.min(700, root.width - 28) + height: Math.min(610, root.height - 24) + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + Overlay.modal: Rectangle { color: theme.alpha(theme.background, root.darkMode ? 0.48 : 0.24) } + background: Rectangle { radius: 18; color: theme.alpha(theme.familyShell, 0.96); border.width: 1; border.color: theme.shellRim } + + Column { + anchors.fill: parent + anchors.margins: root.width < 620 ? 16 : 22 + spacing: 10 + Text { text: (editor.entryId >= 0 ? "Edit " : "Add ") + root.labelForKind(); color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 20; font.weight: Font.DemiBold } + Text { width: parent.width; text: "Edit the collected call fields as typed JSON. Strings, numbers, booleans, arrays, and nested objects are preserved by the existing writer."; color: theme.textSecondary; font.family: "Inter"; font.pixelSize: 10; wrapMode: Text.WordWrap } + GlassField { id: editorName; visible: root.curveMode; width: parent.width; darkMode: root.darkMode; placeholderText: "Curve name" } + Text { text: "FIELDS"; color: theme.alpha(theme.textSecondary, 0.70); font.family: "Inter"; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 1 } + GlassTextArea { id: fieldsText; width: parent.width; height: Math.max(180, editor.height - (root.curveMode ? 220 : 175)); darkMode: root.darkMode; placeholderText: "{\n \"name\": \"value\"\n}" } + Text { id: editorError; width: parent.width; visible: text.length > 0; color: theme.danger; font.family: "Inter"; font.pixelSize: 10; wrapMode: Text.WordWrap } + Row { + anchors.right: parent.right + spacing: 8 + GlassButton { text: "Cancel"; darkMode: root.darkMode; onClicked: editor.close() } + GlassButton { text: "Save"; darkMode: root.darkMode; accent: true; enabled: !root.curveMode || editorName.text.trim().length > 0; onClicked: root.saveEditor() } + } + } + } +} diff --git a/qml/StartupPage.qml b/qml/StartupPage.qml new file mode 100644 index 0000000..592036b --- /dev/null +++ b/qml/StartupPage.qml @@ -0,0 +1,526 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + property bool darkMode: true + property var allEntries: [] + property int selectedId: -1 + property string configPath: "" + property bool healthy: true + readonly property bool compactToolbar: width < 620 + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + StartupBridge { id: startupBackend } + ListModel { id: startupModel } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function load() { + const payload = parse(startupBackend.snapshot()) + configPath = payload.configPath || "" + configResolved(configPath) + allEntries = payload.entries || [] + healthy = payload.ok === true + healthChanged(healthy) + selectedId = -1 + if (!payload.ok) + status(payload.error || "Could not load startup entries.") + else if (payload.warning) + status(payload.warning) + else + status("Startup entries loaded from config.") + rebuildModel() + } + + function rebuildModel() { + const query = startupSearch.text.trim().toLowerCase() + startupModel.clear() + for (let i = 0; i < allEntries.length; ++i) { + const item = allEntries[i] + const haystack = (item.command + " " + item.when + " " + item.workspace).toLowerCase() + if (!query || haystack.indexOf(query) >= 0) + startupModel.append(item) + } + } + + function selectedEntry() { + for (let i = 0; i < allEntries.length; ++i) { + if (allEntries[i].entryId === selectedId) + return allEntries[i] + } + return null + } + + function runAction(raw, closeEditor) { + const payload = parse(raw) + if (!payload.ok) { + editorError.text = payload.error || "The operation failed." + status(editorError.text) + return false + } + if (closeEditor) + startupEditor.close() + load() + status(payload.message || "Saved.") + return true + } + + function openAddEditor() { + editorTitle.text = "Add startup entry" + editorCommand.text = "" + editorWorkspace.text = "" + editorError.text = "" + startupEditor.editing = false + startupEditor.entryId = -1 + startupEditor.selectedWhen = "start" + startupEditor.open() + editorCommand.forceActiveFocus() + } + + function openEditEditor() { + const item = selectedEntry() + if (!item) + return + editorTitle.text = "Edit startup entry" + editorCommand.text = item.command + editorWorkspace.text = item.workspace || "" + editorError.text = "" + startupEditor.editing = true + startupEditor.entryId = item.entryId + startupEditor.selectedWhen = item.when || "start" + startupEditor.open() + editorCommand.forceActiveFocus() + } + + function deleteSelected() { + if (selectedId < 0) + return + const payload = parse(startupBackend.deleteEntry(selectedId)) + if (!payload.ok) { + status(payload.error || "Delete failed.") + return + } + load() + status(payload.message || "Startup entry deleted.") + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 92 : 52 + + GlassField { + id: startupSearch + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : toolbarButtons.left + anchors.rightMargin: root.compactToolbar ? 0 : 12 + anchors.top: parent.top + anchors.topMargin: 4 + implicitHeight: 43 + placeholderText: "Filter startup entries..." + leftPadding: 42 + darkMode: root.darkMode + onTextChanged: root.rebuildModel() + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + } + + Row { + id: toolbarButtons + anchors.right: parent.right + anchors.top: root.compactToolbar ? startupSearch.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 6 : 4 + spacing: 7 + + IconButton { + iconName: "plus" + tooltip: "Add startup entry" + darkMode: root.darkMode + accent: true + onClicked: root.openAddEditor() + } + IconButton { + iconName: "edit" + tooltip: "Edit selected entry" + darkMode: root.darkMode + enabled: root.selectedId >= 0 + onClicked: root.openEditEditor() + } + IconButton { + iconName: "trash" + tooltip: "Delete selected entry" + darkMode: root.darkMode + danger: true + enabled: root.selectedId >= 0 + onClicked: root.deleteSelected() + } + } + } + + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 6 + + ListView { + id: startupList + anchors.fill: parent + clip: true + model: startupModel + spacing: 4 + visible: startupModel.count > 0 + + delegate: Rectangle { + id: startupRow + required property int entryId + required property string command + required property string when + required property string workspace + required property string sourceFile + required property int sourceLine + + width: startupList.width + height: 74 + radius: 11 + color: root.selectedId === entryId + ? theme.selectedFill + : rowMouse.containsMouse ? theme.hoverFill : "transparent" + border.width: root.selectedId === entryId ? 1 : 0 + border.color: theme.selectedRim + + Rectangle { + visible: root.selectedId === entryId + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 16 + anchors.rightMargin: 16 + height: 1 + color: theme.selectedSpecular + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 12 + anchors.rightMargin: 12 + height: 1 + color: theme.divider + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 15 + anchors.right: metaRow.left + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + width: parent.width + text: command + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: sourceFile ? sourceFile.split("/").pop() + ":" + sourceLine : "managed config" + color: theme.alpha(theme.textSecondary, 0.56) + font.family: "Inter" + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + Row { + id: metaRow + anchors.right: parent.right + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Rectangle { + width: whenText.implicitWidth + 16 + height: 28 + radius: 9 + color: theme.controlFill + border.width: 1 + border.color: theme.controlRim + Text { + id: whenText + anchors.centerIn: parent + text: when === "reload" ? "on reload" : when === "shutdown" ? "on shutdown" : "on start" + color: theme.alpha(theme.textPrimary, 0.86) + font.family: "Inter" + font.pixelSize: 10 + } + } + + Rectangle { + visible: workspace.length > 0 + width: workspaceText.implicitWidth + 16 + height: 28 + radius: 9 + color: theme.alpha(theme.accent, 0.06) + border.width: 1 + border.color: theme.alpha(theme.accent, 0.12) + Text { + id: workspaceText + anchors.centerIn: parent + text: workspace + color: theme.alpha(theme.textPrimary, 0.84) + font.family: "Inter" + font.pixelSize: 10 + } + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.selectedId = entryId + onDoubleClicked: { + root.selectedId = entryId + root.openEditEditor() + } + } + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 40, 500) + spacing: 13 + visible: startupModel.count === 0 + + GlassPanel { + width: 72 + height: 72 + cornerRadius: 36 + anchors.horizontalCenter: parent.horizontalCenter + darkMode: root.darkMode + elevated: true + border.color: theme.controlRim + + UiIcon { + anchors.centerIn: parent + width: 27 + height: 27 + name: "power" + iconColor: theme.alpha(theme.textPrimary, 0.82) + strokeWidth: 1.55 + } + } + + Text { + text: root.allEntries.length === 0 ? "No startup entries yet" : "No matching startup entries" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 19 + font.weight: Font.DemiBold + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + width: parent.width + text: root.allEntries.length === 0 + ? "Launch apps or commands when Hyprland starts, reloads, or shuts down." + : "Try a different filter." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 12 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + GlassButton { + visible: root.allEntries.length === 0 + text: "Add your first entry" + darkMode: root.darkMode + accent: true + anchors.horizontalCenter: parent.horizontalCenter + onClicked: root.openAddEditor() + } + } + } + + Popup { + id: startupEditor + property bool editing: false + property int entryId: -1 + property string selectedWhen: "start" + + width: Math.min(520, root.width - 32) + height: 410 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + + Overlay.modal: Rectangle { color: theme.alpha(theme.background, root.darkMode ? 0.46 : 0.24) } + + background: Rectangle { + radius: 18 + color: theme.alpha(theme.familyShell, root.darkMode ? 0.94 : 0.96) + border.width: 1 + border.color: theme.shellRim + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.top: parent.top + height: 1 + color: theme.shellInnerLine + } + } + + Column { + anchors.fill: parent + anchors.margins: 22 + spacing: 14 + + Text { + id: editorTitle + text: "Add startup entry" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 20 + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: "Choose when the command runs. Workspace is optional." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + GlassField { + id: editorCommand + width: parent.width + darkMode: root.darkMode + placeholderText: "Command — e.g. waybar" + } + + Row { + spacing: 8 + GlassButton { + text: "Start" + darkMode: root.darkMode + accent: startupEditor.selectedWhen === "start" + onClicked: startupEditor.selectedWhen = "start" + } + GlassButton { + text: "Reload" + darkMode: root.darkMode + accent: startupEditor.selectedWhen === "reload" + onClicked: startupEditor.selectedWhen = "reload" + } + GlassButton { + text: "Shutdown" + darkMode: root.darkMode + accent: startupEditor.selectedWhen === "shutdown" + onClicked: startupEditor.selectedWhen = "shutdown" + } + } + + GlassField { + id: editorWorkspace + width: parent.width + darkMode: root.darkMode + placeholderText: "Workspace (optional)" + onAccepted: editorSave.clicked() + } + + Text { + id: editorError + width: parent.width + height: text ? implicitHeight : 0 + visible: text.length > 0 + color: "#e1a19f" + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 2 } + + Row { + anchors.right: parent.right + spacing: 9 + + GlassButton { + text: "Cancel" + darkMode: root.darkMode + onClicked: startupEditor.close() + } + + GlassButton { + id: editorSave + text: startupEditor.editing ? "Save" : "Add" + darkMode: root.darkMode + accent: true + enabled: editorCommand.text.trim().length > 0 + onClicked: { + editorError.text = "" + if (startupEditor.editing) { + root.runAction( + startupBackend.editEntry( + startupEditor.entryId, + editorCommand.text, + startupEditor.selectedWhen, + editorWorkspace.text + ), + true + ) + } else { + root.runAction( + startupBackend.addEntry( + editorCommand.text, + startupEditor.selectedWhen, + editorWorkspace.text + ), + true + ) + } + } + } + } + } + } +} diff --git a/qml/StatusBar.qml b/qml/StatusBar.qml new file mode 100644 index 0000000..82a8b34 --- /dev/null +++ b/qml/StatusBar.qml @@ -0,0 +1,39 @@ +import QtQuick + +GlassPanel { + id: root + + property bool healthy: true + property string message: "Ready." + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + height: 50 + cornerRadius: 11 + elevated: false + + UiIcon { + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + width: 18 + height: 18 + name: "info" + iconColor: root.healthy + ? theme.alpha(theme.textPrimary, 0.78) + : (root.darkMode ? "#e3aaa7" : "#ad4c49") + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 45 + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: root.message + color: theme.alpha(theme.textSecondary, 0.90) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } +} diff --git a/qml/SubmapsPage.qml b/qml/SubmapsPage.qml new file mode 100644 index 0000000..cf8c90c --- /dev/null +++ b/qml/SubmapsPage.qml @@ -0,0 +1,447 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + property bool darkMode: true + property var allSubmaps: [] + property int selectedId: -1 + property string configPath: "" + property bool healthy: true + readonly property bool compactToolbar: width < 620 + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + SubmapsBridge { id: submapsBackend } + ListModel { id: submapsModel } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function load() { + const payload = parse(submapsBackend.snapshot()) + configPath = payload.configPath || "" + configResolved(configPath) + allSubmaps = payload.submaps || [] + healthy = payload.ok === true + healthChanged(healthy) + selectedId = -1 + if (!payload.ok) + status(payload.error || "Could not load submaps.") + else if (payload.warning) + status(payload.warning) + else + status("Submaps loaded from config.") + rebuildModel() + } + + function rebuildModel() { + const query = submapSearch.text.trim().toLowerCase() + submapsModel.clear() + for (let i = 0; i < allSubmaps.length; ++i) { + const item = allSubmaps[i] + const haystack = (item.name + " " + (item.reset || "")).toLowerCase() + if (!query || haystack.indexOf(query) >= 0) + submapsModel.append(item) + } + } + + function selectedSubmap() { + for (let i = 0; i < allSubmaps.length; ++i) { + if (allSubmaps[i].entryId === selectedId) + return allSubmaps[i] + } + return null + } + + function openAddEditor() { + editorName.text = "" + editorError.text = "" + addEditor.open() + editorName.forceActiveFocus() + } + + function addSubmap() { + const payload = parse(submapsBackend.addSubmap(editorName.text)) + if (!payload.ok) { + editorError.text = payload.error || "Could not add submap." + status(editorError.text) + return + } + addEditor.close() + load() + status(payload.message || "Submap added.") + } + + function deleteSelected() { + const selected = selectedSubmap() + if (!selected) + return + if (selected.bindCount > 0) { + status("Move or delete this submap’s binds before deleting the submap.") + return + } + const payload = parse(submapsBackend.deleteSubmap(selectedId)) + if (!payload.ok) { + status(payload.error || "Delete failed.") + return + } + load() + status(payload.message || "Submap deleted.") + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 92 : 52 + + GlassField { + id: submapSearch + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : toolbarButtons.left + anchors.rightMargin: root.compactToolbar ? 0 : 12 + anchors.top: parent.top + anchors.topMargin: 4 + implicitHeight: 43 + placeholderText: "Filter submaps..." + leftPadding: 42 + darkMode: root.darkMode + onTextChanged: root.rebuildModel() + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + } + + Row { + id: toolbarButtons + anchors.right: parent.right + anchors.top: root.compactToolbar ? submapSearch.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 6 : 4 + spacing: 7 + + IconButton { + iconName: "plus" + tooltip: "Add submap" + darkMode: root.darkMode + accent: true + onClicked: root.openAddEditor() + } + + IconButton { + iconName: "trash" + tooltip: { + const selected = root.selectedSubmap() + if (!selected) return "Select a submap to delete" + if (selected.bindCount > 0) return "Submap still owns binds" + return "Delete selected submap" + } + darkMode: root.darkMode + danger: true + enabled: { + const selected = root.selectedSubmap() + return selected !== null && selected.bindCount === 0 + } + onClicked: root.deleteSelected() + } + } + } + + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 6 + + ListView { + id: submapsList + anchors.fill: parent + clip: true + model: submapsModel + spacing: 4 + visible: submapsModel.count > 0 + + delegate: Rectangle { + id: submapRow + required property int entryId + required property string name + required property string reset + required property int bindCount + required property string sourceFile + required property int sourceLine + + width: submapsList.width + height: 72 + radius: 11 + color: root.selectedId === entryId + ? theme.selectedFill + : rowMouse.containsMouse ? theme.hoverFill : "transparent" + border.width: root.selectedId === entryId ? 1 : 0 + border.color: theme.selectedRim + + Rectangle { + visible: root.selectedId === entryId + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 16 + anchors.rightMargin: 16 + height: 1 + color: theme.selectedSpecular + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 12 + anchors.rightMargin: 12 + height: 1 + color: theme.divider + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 15 + anchors.right: badges.left + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + width: parent.width + text: name + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: sourceFile ? sourceFile.split("/").pop() + ":" + sourceLine : "managed submap" + color: theme.alpha(theme.textSecondary, 0.56) + font.family: "Inter" + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + Row { + id: badges + anchors.right: parent.right + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Rectangle { + width: bindText.implicitWidth + 16 + height: 28 + radius: 9 + color: bindCount > 0 ? theme.alpha(theme.accent, 0.06) : theme.controlFill + border.width: 1 + border.color: bindCount > 0 ? theme.alpha(theme.accent, 0.12) : theme.controlRim + Text { + id: bindText + anchors.centerIn: parent + text: bindCount === 1 ? "1 bind" : bindCount + " binds" + color: theme.alpha(theme.textPrimary, 0.86) + font.family: "Inter" + font.pixelSize: 10 + } + } + + Rectangle { + visible: reset.length > 0 + width: resetText.implicitWidth + 16 + height: 28 + radius: 9 + color: theme.controlFill + border.width: 1 + border.color: theme.controlRim + Text { + id: resetText + anchors.centerIn: parent + text: "reset " + reset + color: theme.alpha(theme.textSecondary, 0.82) + font.family: "Inter" + font.pixelSize: 10 + } + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.selectedId = entryId + } + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 40, 500) + spacing: 13 + visible: submapsModel.count === 0 + + GlassPanel { + width: 72 + height: 72 + cornerRadius: 36 + anchors.horizontalCenter: parent.horizontalCenter + darkMode: root.darkMode + elevated: true + border.color: theme.controlRim + + UiIcon { + anchors.centerIn: parent + width: 27 + height: 27 + name: "grid" + iconColor: theme.alpha(theme.textPrimary, 0.82) + strokeWidth: 1.55 + } + } + + Text { + text: root.allSubmaps.length === 0 ? "No submaps yet" : "No matching submaps" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 19 + font.weight: Font.DemiBold + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + width: parent.width + text: root.allSubmaps.length === 0 + ? "Submaps group keybinds into named modes that can be entered and reset independently." + : "Try a different filter." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 12 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + GlassButton { + visible: root.allSubmaps.length === 0 + text: "Add your first submap" + darkMode: root.darkMode + accent: true + anchors.horizontalCenter: parent.horizontalCenter + onClicked: root.openAddEditor() + } + } + } + + Popup { + id: addEditor + width: Math.min(440, root.width - 32) + height: 250 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + + Overlay.modal: Rectangle { color: theme.alpha(theme.background, root.darkMode ? 0.46 : 0.24) } + + background: Rectangle { + radius: 18 + color: theme.alpha(theme.familyShell, root.darkMode ? 0.94 : 0.96) + border.width: 1 + border.color: theme.shellRim + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.top: parent.top + height: 1 + color: theme.shellInnerLine + } + } + + Column { + anchors.fill: parent + anchors.margins: 22 + spacing: 14 + + Text { + text: "Add submap" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 20 + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: "The new submap starts empty. Binds can be added to it from the Binds page later." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + GlassField { + id: editorName + width: parent.width + darkMode: root.darkMode + placeholderText: "Name — e.g. resize" + onAccepted: addButton.clicked() + } + + Text { + id: editorError + width: parent.width + height: text ? implicitHeight : 0 + visible: text.length > 0 + color: "#e1a19f" + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + Row { + anchors.right: parent.right + spacing: 9 + GlassButton { + text: "Cancel" + darkMode: root.darkMode + onClicked: addEditor.close() + } + GlassButton { + id: addButton + text: "Add" + darkMode: root.darkMode + accent: true + enabled: editorName.text.trim().length > 0 + onClicked: root.addSubmap() + } + } + } + } +} diff --git a/qml/SummaryCard.qml b/qml/SummaryCard.qml new file mode 100644 index 0000000..b3b13d6 --- /dev/null +++ b/qml/SummaryCard.qml @@ -0,0 +1,83 @@ +import QtQuick + +GlassPanel { + id: root + + property string iconName: "info" + property string title: "Summary" + property string value: "—" + property string subtitle: "" + property bool accent: false + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + implicitHeight: 126 + cornerRadius: 15 + elevated: false + + Rectangle { + width: 40 + height: 40 + radius: 12 + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.top: parent.top + anchors.topMargin: 16 + color: root.accent ? theme.alpha(theme.accent, 0.08) : theme.controlFill + border.width: 1 + border.color: root.accent ? theme.alpha(theme.accent, 0.16) : theme.controlRim + + UiIcon { + anchors.centerIn: parent + width: 19 + height: 19 + name: root.iconName + iconColor: root.accent ? theme.accent : theme.alpha(theme.textPrimary, 0.78) + } + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 68 + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.top: parent.top + anchors.topMargin: 15 + spacing: 2 + + Text { + width: parent.width + text: root.title + color: theme.alpha(theme.textSecondary, 0.76) + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 0.7 + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.value + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 24 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.bottom: parent.bottom + anchors.bottomMargin: 15 + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } +} diff --git a/qml/UiIcon.qml b/qml/UiIcon.qml new file mode 100644 index 0000000..b3ef4fb --- /dev/null +++ b/qml/UiIcon.qml @@ -0,0 +1,112 @@ +import QtQuick + +Canvas { + id: icon + property string name: "plus" + property color iconColor: "#d7e0e6" + property real strokeWidth: 1.65 + + implicitWidth: 20 + implicitHeight: 20 + antialiasing: true + + onNameChanged: requestPaint() + onIconColorChanged: requestPaint() + onStrokeWidthChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + const c = getContext("2d") + c.reset() + c.scale(width / 20, height / 20) + c.strokeStyle = iconColor + c.fillStyle = iconColor + c.lineWidth = strokeWidth + c.lineCap = "round" + c.lineJoin = "round" + c.beginPath() + + if (name === "plus") { + c.moveTo(10, 4); c.lineTo(10, 16); c.moveTo(4, 10); c.lineTo(16, 10) + } else if (name === "edit") { + c.moveTo(5, 15); c.lineTo(6.3, 11.4); c.lineTo(13.4, 4.3); c.lineTo(15.8, 6.7); c.lineTo(8.7, 13.9); c.closePath(); + c.moveTo(12.2, 5.5); c.lineTo(14.7, 8) + } else if (name === "trash") { + c.moveTo(5, 6.2); c.lineTo(15, 6.2); c.moveTo(7, 6.2); c.lineTo(7.7, 16); c.lineTo(12.3, 16); c.lineTo(13, 6.2); + c.moveTo(7.7, 4.2); c.lineTo(12.3, 4.2); c.moveTo(9, 4.2); c.lineTo(9.3, 3); c.lineTo(10.7, 3); c.lineTo(11, 4.2) + } else if (name === "copy") { + c.rect(6.5, 6.5, 9, 9); c.moveTo(5, 13); c.lineTo(4, 13); c.lineTo(4, 4); c.lineTo(13, 4); c.lineTo(13, 5) + } else if (name === "check") { + c.moveTo(4.2, 10.4); c.lineTo(8.3, 14.1); c.lineTo(15.9, 5.9) + } else if (name === "search") { + c.arc(8.6, 8.6, 4.7, 0, Math.PI * 2); c.moveTo(12.1, 12.1); c.lineTo(16.3, 16.3) + } else if (name === "reload") { + c.arc(10, 10, 6, -0.55, Math.PI * 1.42); c.moveTo(14.6, 3.8); c.lineTo(15.6, 7.1); c.lineTo(12.3, 6.4) + } else if (name === "save") { + c.moveTo(4, 3.5); c.lineTo(13.2, 3.5); c.lineTo(16, 6.3); c.lineTo(16, 16.5); c.lineTo(4, 16.5); c.closePath(); + c.rect(6.2, 4.8, 5.8, 4.1); c.rect(6.5, 11.2, 7, 4.2) + } else if (name === "history") { + c.arc(10.2, 10, 6, -2.45, Math.PI * 0.9); c.moveTo(5.6, 4.8); c.lineTo(4.2, 8); c.lineTo(7.6, 7.3); c.moveTo(10.2, 6.5); c.lineTo(10.2, 10.2); c.lineTo(12.8, 11.8) + } else if (name === "transfer") { + c.moveTo(3.5, 7); c.lineTo(15.5, 7); c.moveTo(12.5, 4); c.lineTo(15.5, 7); c.lineTo(12.5, 10); + c.moveTo(16.5, 13); c.lineTo(4.5, 13); c.moveTo(7.5, 10); c.lineTo(4.5, 13); c.lineTo(7.5, 16) + } else if (name === "terminal") { + c.moveTo(4, 5); c.lineTo(8, 9); c.lineTo(4, 13); c.moveTo(10, 14); c.lineTo(16, 14) + } else if (name === "monitor") { + c.rect(3.2, 4.2, 13.6, 9.2); c.moveTo(8, 16); c.lineTo(12, 16); c.moveTo(10, 13.5); c.lineTo(10, 16) + } else if (name === "info") { + c.arc(10, 10, 7, 0, Math.PI * 2); c.moveTo(10, 9); c.lineTo(10, 13.5); c.stroke(); c.beginPath(); c.arc(10, 6.4, 0.7, 0, Math.PI * 2); c.fill(); return + } else if (name === "close") { + c.moveTo(6.2, 6.2); c.lineTo(13.8, 13.8); c.moveTo(13.8, 6.2); c.lineTo(6.2, 13.8) + } else if (name === "minimize") { + c.moveTo(5.6, 12.5); c.lineTo(14.4, 12.5) + } else if (name === "maximize") { + c.rect(5.5, 5.5, 9, 9) + } else if (name === "restore-window") { + c.moveTo(7.3, 5.3); c.lineTo(14.7, 5.3); c.lineTo(14.7, 12.7) + c.rect(5.3, 7.3, 7.4, 7.4) + } else if (name === "moon") { + c.arc(10.5, 9.5, 6, 0.35, Math.PI * 1.78); c.quadraticCurveTo(8.5, 14.4, 7.2, 11.4); c.quadraticCurveTo(5.8, 7.8, 9.5, 3.8) + } else if (name === "sun") { + c.arc(10, 10, 3.5, 0, Math.PI * 2); for (let i = 0; i < 8; ++i) { const a = i * Math.PI / 4; c.moveTo(10 + Math.cos(a) * 5.5, 10 + Math.sin(a) * 5.5); c.lineTo(10 + Math.cos(a) * 7.4, 10 + Math.sin(a) * 7.4) } + } else if (name === "realtime") { + c.rect(4, 4.5, 12, 9.5); c.moveTo(8, 16); c.lineTo(12, 16); c.moveTo(10, 14); c.lineTo(10, 16) + } else if (name === "home") { + c.moveTo(3.5, 9); c.lineTo(10, 3.8); c.lineTo(16.5, 9); c.moveTo(5.3, 8); c.lineTo(5.3, 16); c.lineTo(14.7, 16); c.lineTo(14.7, 8) + } else if (name === "keyboard") { + c.rect(3, 5, 14, 10); c.moveTo(5, 8); c.lineTo(5.1, 8); c.moveTo(8, 8); c.lineTo(8.1, 8); c.moveTo(11, 8); c.lineTo(11.1, 8); c.moveTo(14, 8); c.lineTo(14.1, 8); c.moveTo(6, 11); c.lineTo(14, 11) + } else if (name === "braces") { + c.moveTo(7.5, 3.5); c.quadraticCurveTo(5.5, 3.5, 5.5, 6); c.lineTo(5.5, 8); c.quadraticCurveTo(5.5, 10, 3.8, 10); c.quadraticCurveTo(5.5, 10, 5.5, 12); c.lineTo(5.5, 14); c.quadraticCurveTo(5.5, 16.5, 7.5, 16.5); c.moveTo(12.5, 3.5); c.quadraticCurveTo(14.5, 3.5, 14.5, 6); c.lineTo(14.5, 8); c.quadraticCurveTo(14.5, 10, 16.2, 10); c.quadraticCurveTo(14.5, 10, 14.5, 12); c.lineTo(14.5, 14); c.quadraticCurveTo(14.5, 16.5, 12.5, 16.5) + } else if (name === "document") { + c.moveTo(5, 3.5); c.lineTo(12, 3.5); c.lineTo(15, 6.5); c.lineTo(15, 16.5); c.lineTo(5, 16.5); c.closePath(); c.moveTo(12, 3.5); c.lineTo(12, 6.5); c.lineTo(15, 6.5); c.moveTo(7.5, 10); c.lineTo(12.5, 10); c.moveTo(7.5, 13); c.lineTo(11.5, 13) + } else if (name === "grid") { + c.rect(3.5, 3.5, 5, 5); c.rect(11.5, 3.5, 5, 5); c.rect(3.5, 11.5, 5, 5); c.rect(11.5, 11.5, 5, 5) + } else if (name === "power") { + c.moveTo(10, 2.8); c.lineTo(10, 9); c.moveTo(6.3, 5); c.arc(10, 10, 6, -2.25, 0.9) + } else if (name === "window") { + c.rect(3.5, 4, 13, 12); c.moveTo(3.5, 7); c.lineTo(16.5, 7) + } else if (name === "layers") { + c.moveTo(10, 3.5); c.lineTo(17, 7); c.lineTo(10, 10.5); c.lineTo(3, 7); c.closePath(); c.moveTo(4.2, 10); c.lineTo(10, 13); c.lineTo(15.8, 10); c.moveTo(4.2, 13); c.lineTo(10, 16); c.lineTo(15.8, 13) + } else if (name === "diamond") { + c.moveTo(10, 3); c.lineTo(17, 10); c.lineTo(10, 17); c.lineTo(3, 10); c.closePath(); c.moveTo(7.5, 10); c.lineTo(10, 7.5); c.lineTo(12.5, 10); c.lineTo(10, 12.5); c.closePath() + } else if (name === "settings") { + c.arc(10, 10, 3, 0, Math.PI * 2); for (let i = 0; i < 8; ++i) { const a = i * Math.PI / 4; c.moveTo(10 + Math.cos(a) * 5, 10 + Math.sin(a) * 5); c.lineTo(10 + Math.cos(a) * 7, 10 + Math.sin(a) * 7) } + } else if (name === "device") { + c.arc(10, 10, 6.5, 0, Math.PI * 2); c.arc(10, 10, 2.2, 0, Math.PI * 2) + } else if (name === "sparkle") { + c.moveTo(10, 2.8); c.quadraticCurveTo(11.2, 8.8, 17.2, 10); c.quadraticCurveTo(11.2, 11.2, 10, 17.2); c.quadraticCurveTo(8.8, 11.2, 2.8, 10); c.quadraticCurveTo(8.8, 8.8, 10, 2.8) + } else if (name === "curve") { + c.moveTo(3, 13); c.bezierCurveTo(6, 4, 9, 16, 17, 6) + } else if (name === "gesture") { + c.moveTo(4, 13); c.bezierCurveTo(6, 10, 8, 10, 10, 13); c.bezierCurveTo(12, 16, 14, 16, 16, 12); c.moveTo(5, 7); c.lineTo(8, 4); c.moveTo(5, 7); c.lineTo(8, 8) + } else if (name === "heart") { + c.moveTo(10, 16); c.lineTo(4.4, 10.5); c.bezierCurveTo(0.8, 6.7, 5.5, 2.3, 10, 6); c.bezierCurveTo(14.5, 2.3, 19.2, 6.7, 15.6, 10.5); c.closePath() + } else if (name === "logs") { + c.moveTo(4, 5); c.lineTo(16, 5); c.moveTo(4, 10); c.lineTo(16, 10); c.moveTo(4, 15); c.lineTo(13, 15) + } else { + c.arc(10, 10, 2, 0, Math.PI * 2) + } + c.stroke() + } +} diff --git a/qml/VariablesPage.qml b/qml/VariablesPage.qml new file mode 100644 index 0000000..97cd94d --- /dev/null +++ b/qml/VariablesPage.qml @@ -0,0 +1,458 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property var bridge + property bool darkMode: true + property var allVariables: [] + property string selectedName: "" + property string configPath: "" + property bool healthy: true + readonly property bool compactToolbar: width < 620 + + signal status(string message) + signal configResolved(string path) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + ListModel { id: variablesModel } + + function parse(raw) { + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } + } + + function load() { + const payload = parse(bridge.variablesSnapshot()) + configPath = payload.configPath || "" + configResolved(configPath) + allVariables = payload.variables || [] + healthy = payload.ok === true + healthChanged(healthy) + selectedName = "" + if (!payload.ok) + status(payload.error || "Could not load variables.") + else if (payload.warning) + status(payload.warning) + else + status("Variables loaded from config.") + rebuildModel() + } + + function rebuildModel() { + const query = variableSearch.text.trim().toLowerCase() + variablesModel.clear() + for (let i = 0; i < allVariables.length; ++i) { + const item = allVariables[i] + const haystack = (item.name + " " + item.value).toLowerCase() + if (!query || haystack.indexOf(query) >= 0) + variablesModel.append(item) + } + } + + function selectedVariable() { + for (let i = 0; i < allVariables.length; ++i) { + if (allVariables[i].name === selectedName) + return allVariables[i] + } + return null + } + + function runAction(raw, closeEditor) { + const payload = parse(raw) + if (!payload.ok) { + editorError.text = payload.error || "The operation failed." + status(editorError.text) + return false + } + if (closeEditor) + variableEditor.close() + load() + status(payload.message || "Saved.") + return true + } + + function openAddEditor() { + editorTitle.text = "Add variable" + editorName.text = "" + editorValue.text = "" + editorError.text = "" + variableEditor.editing = false + variableEditor.originalName = "" + variableEditor.open() + editorName.forceActiveFocus() + } + + function openEditEditor() { + const item = selectedVariable() + if (!item) + return + editorTitle.text = "Edit variable" + editorName.text = item.name + editorValue.text = item.value + editorError.text = "" + variableEditor.editing = true + variableEditor.originalName = item.name + variableEditor.open() + editorName.forceActiveFocus() + } + + function deleteSelected() { + if (!selectedName) + return + const deleting = selectedName + const payload = parse(bridge.deleteVariable(deleting)) + if (!payload.ok) { + status(payload.error || "Delete failed.") + return + } + load() + status(payload.message || ("Deleted " + deleting + ".")) + } + + Component.onCompleted: load() + + Item { + id: toolbar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.compactToolbar ? 92 : 52 + + GlassField { + id: variableSearch + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : toolbarButtons.left + anchors.rightMargin: root.compactToolbar ? 0 : 12 + anchors.top: parent.top + anchors.topMargin: 4 + implicitHeight: 43 + placeholderText: "Filter variables..." + leftPadding: 42 + darkMode: root.darkMode + onTextChanged: root.rebuildModel() + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + iconColor: theme.textSecondary + } + } + + Row { + id: toolbarButtons + anchors.right: parent.right + anchors.top: root.compactToolbar ? variableSearch.bottom : parent.top + anchors.topMargin: root.compactToolbar ? 6 : 4 + spacing: 7 + + IconButton { + iconName: "plus" + tooltip: "Add variable" + darkMode: root.darkMode + accent: true + onClicked: root.openAddEditor() + } + IconButton { + iconName: "edit" + tooltip: "Edit selected variable" + darkMode: root.darkMode + enabled: root.selectedName.length > 0 + onClicked: root.openEditEditor() + } + IconButton { + iconName: "trash" + tooltip: "Delete selected variable" + darkMode: root.darkMode + danger: true + enabled: root.selectedName.length > 0 + onClicked: root.deleteSelected() + } + } + } + + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: toolbar.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 6 + + ListView { + id: variablesList + anchors.fill: parent + clip: true + model: variablesModel + spacing: 4 + visible: variablesModel.count > 0 + + delegate: Rectangle { + id: variableRow + required property string name + required property string value + required property string sourceFile + required property int sourceLine + + width: variablesList.width + height: 68 + radius: 11 + color: root.selectedName === name + ? theme.selectedFill + : rowMouse.containsMouse ? theme.hoverFill : "transparent" + border.width: root.selectedName === name ? 1 : 0 + border.color: theme.selectedRim + + Rectangle { + visible: root.selectedName === name + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 16 + anchors.rightMargin: 16 + height: 1 + color: theme.selectedSpecular + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 12 + anchors.rightMargin: 12 + height: 1 + color: theme.divider + } + + Row { + anchors.left: parent.left + anchors.leftMargin: 15 + anchors.right: parent.right + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + spacing: 14 + + Column { + width: Math.max(120, parent.width * 0.38) + spacing: 3 + Text { + width: parent.width + text: name + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + width: parent.width + text: sourceFile ? sourceFile.split("/").pop() + ":" + sourceLine : "managed config" + color: theme.alpha(theme.textSecondary, 0.56) + font.family: "Inter" + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + Text { + width: Math.max(80, parent.width - x) + anchors.verticalCenter: parent.verticalCenter + text: value + color: theme.alpha(theme.textSecondary, 0.88) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.selectedName = name + onDoubleClicked: { + root.selectedName = name + root.openEditEditor() + } + } + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 40, 500) + spacing: 13 + visible: variablesModel.count === 0 + + GlassPanel { + width: 72 + height: 72 + cornerRadius: 36 + anchors.horizontalCenter: parent.horizontalCenter + darkMode: root.darkMode + elevated: true + border.color: theme.controlRim + + UiIcon { + anchors.centerIn: parent + width: 27 + height: 27 + name: "braces" + iconColor: theme.alpha(theme.textPrimary, 0.82) + strokeWidth: 1.55 + } + } + + Text { + text: root.allVariables.length === 0 ? "No variables yet" : "No matching variables" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 19 + font.weight: Font.DemiBold + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + width: parent.width + text: root.allVariables.length === 0 + ? "Variables let you reuse values across binds, commands, and rules without duplicating them." + : "Try a different filter." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 12 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + GlassButton { + visible: root.allVariables.length === 0 + text: "Add your first variable" + darkMode: root.darkMode + accent: true + anchors.horizontalCenter: parent.horizontalCenter + onClicked: root.openAddEditor() + } + } + } + + Popup { + id: variableEditor + property bool editing: false + property string originalName: "" + width: Math.min(500, root.width - 32) + height: 344 + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + + Overlay.modal: Rectangle { color: theme.alpha(theme.background, root.darkMode ? 0.46 : 0.24) } + + background: Rectangle { + radius: 18 + color: theme.alpha(theme.familyShell, root.darkMode ? 0.94 : 0.96) + border.width: 1 + border.color: theme.shellRim + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.top: parent.top + height: 1 + color: theme.shellInnerLine + } + } + + Column { + anchors.fill: parent + anchors.margins: 22 + spacing: 14 + + Text { + id: editorTitle + text: "Add variable" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 20 + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: variableEditor.editing + ? "Renaming a variable updates its identifier references across related config files." + : "Variables are written as local Lua declarations through Hyprbind’s existing writer." + color: theme.textSecondary + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + GlassField { + id: editorName + width: parent.width + darkMode: root.darkMode + placeholderText: "Name — e.g. mainMod" + } + + GlassField { + id: editorValue + width: parent.width + darkMode: root.darkMode + placeholderText: "Value — e.g. SUPER" + onAccepted: editorSave.clicked() + } + + Text { + id: editorError + width: parent.width + height: text ? implicitHeight : 0 + visible: text.length > 0 + color: "#e1a19f" + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 2 } + + Row { + anchors.right: parent.right + spacing: 9 + + GlassButton { + text: "Cancel" + darkMode: root.darkMode + onClicked: variableEditor.close() + } + + GlassButton { + id: editorSave + text: variableEditor.editing ? "Save" : "Add" + darkMode: root.darkMode + accent: true + enabled: editorName.text.trim().length > 0 + onClicked: { + editorError.text = "" + if (variableEditor.editing) { + root.runAction( + root.bridge.editVariable(variableEditor.originalName, editorName.text, editorValue.text), + true + ) + } else { + root.runAction(root.bridge.addVariable(editorName.text, editorValue.text), true) + } + } + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index f7ff277..fc902af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,13 @@ mod lookfeel_ui; mod nav; mod overview_ui; mod palette; +mod qml_bridge; +mod qml_catalog_bridge; +mod qml_health_bridge; +mod qml_logs_bridge; +mod qml_startup_bridge; +mod qml_submaps_bridge; +mod qml_window_bridge; mod settings_config; mod spec; mod startup; @@ -190,7 +197,14 @@ fn main() { return; } - let app = Application::builder().application_id(APP_ID).build(); - app.connect_activate(ui::build_ui); - app.run(); + if args.iter().any(|a| a == "--gtk") { + let app = Application::builder().application_id(APP_ID).build(); + app.connect_activate(ui::build_ui); + app.run_with_args(&["hyprbinds"]); + return; + } + + // QML is the primary UI. `--qml` remains accepted for compatibility, + // while `--gtk` provides the explicit legacy/developer fallback. + qml_bridge::run(); } diff --git a/src/qml_bridge.rs b/src/qml_bridge.rs new file mode 100644 index 0000000..c532c85 --- /dev/null +++ b/src/qml_bridge.rs @@ -0,0 +1,721 @@ +use cxx_qt_lib::QString; +use serde_json::{Value, json}; + +#[cxx_qt::bridge] +mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + type HyprbindBridge = super::HyprbindBridgeRust; + + #[qinvokable] + #[rust_name = "ui_snapshot"] + fn uiSnapshot(self: &HyprbindBridge) -> QString; + + #[qinvokable] + #[rust_name = "set_dark_mode"] + fn setDarkMode(self: &HyprbindBridge, enabled: bool) -> QString; + + #[qinvokable] + #[rust_name = "restore_config"] + fn restoreConfig(self: &HyprbindBridge) -> QString; + + #[qinvokable] + #[rust_name = "lookfeel_snapshot"] + fn lookFeelSnapshot(self: &HyprbindBridge) -> QString; + + #[qinvokable] + #[rust_name = "save_lookfeel"] + fn saveLookFeel(self: &HyprbindBridge, payload: &QString) -> QString; + + #[qinvokable] + #[rust_name = "preview_lookfeel"] + fn previewLookFeel(self: &HyprbindBridge, payload: &QString) -> QString; + + #[qinvokable] + #[rust_name = "environment_snapshot"] + fn environmentSnapshot(self: &HyprbindBridge) -> QString; + + #[qinvokable] + #[rust_name = "add_environment"] + fn addEnvironment(self: &HyprbindBridge, name: &QString, value: &QString) -> QString; + + #[qinvokable] + #[rust_name = "edit_environment"] + fn editEnvironment( + self: &HyprbindBridge, + current_name: &QString, + name: &QString, + value: &QString, + ) -> QString; + + #[qinvokable] + #[rust_name = "delete_environment"] + fn deleteEnvironment(self: &HyprbindBridge, name: &QString) -> QString; + + #[qinvokable] + #[rust_name = "variables_snapshot"] + fn variablesSnapshot(self: &HyprbindBridge) -> QString; + + #[qinvokable] + #[rust_name = "add_variable"] + fn addVariable(self: &HyprbindBridge, name: &QString, value: &QString) -> QString; + + #[qinvokable] + #[rust_name = "edit_variable"] + fn editVariable( + self: &HyprbindBridge, + current_name: &QString, + name: &QString, + value: &QString, + ) -> QString; + + #[qinvokable] + #[rust_name = "delete_variable"] + fn deleteVariable(self: &HyprbindBridge, name: &QString) -> QString; + + #[qinvokable] + #[rust_name = "set_developer_mode"] + fn setDeveloperMode(self: &HyprbindBridge, enabled: bool) -> QString; + + #[qinvokable] + #[rust_name = "export_bundle"] + fn exportBundle(self: &HyprbindBridge, path: &QString) -> QString; + + #[qinvokable] + #[rust_name = "import_bundle"] + fn importBundle( + self: &HyprbindBridge, + path: &QString, + hyprland: bool, + waybar: bool, + app: bool, + via: bool, + system: bool, + ) -> QString; + + #[qinvokable] + #[rust_name = "launch_gtk_ui"] + fn launchGtkUi(self: &HyprbindBridge) -> QString; + } +} + +#[derive(Default)] +pub struct HyprbindBridgeRust; + +impl qobject::HyprbindBridge { + fn ui_snapshot(&self) -> QString { + let prefs = crate::ui_prefs::load(); + let config_path = crate::config::default_config_path(); + let has_backup = config_path + .as_ref() + .is_some_and(|path| crate::backup::has_backup(path)); + let backup_age = config_path + .as_ref() + .and_then(|path| crate::backup::backup_age_label(path)) + .unwrap_or_default(); + to_qstring(json!({ + "ok": true, + "darkMode": prefs.dark_mode, + "developerMode": prefs.developer_mode, + "hasBackup": has_backup, + "backupAge": backup_age, + })) + } + + fn set_dark_mode(&self, enabled: bool) -> QString { + crate::ui_prefs::update(|prefs| prefs.dark_mode = enabled); + action_ok(if enabled { + "Dark glass theme enabled.".into() + } else { + "Light glass theme enabled.".into() + }) + } + + fn set_developer_mode(&self, enabled: bool) -> QString { + crate::ui_prefs::update(|prefs| prefs.developer_mode = enabled); + action_ok(if enabled { + "Developer mode enabled.".into() + } else { + "Developer mode disabled.".into() + }) + } + + fn export_bundle(&self, path: &QString) -> QString { + let path = match resolve_user_path(&String::from(path)) { + Ok(path) => path, + Err(error) => return action_error(error), + }; + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + match crate::bundle::export_to_path(&collection, &path) { + Ok(()) => action_ok(format!("Exported settings to {}", path.display())), + Err(error) => action_error(error.to_string()), + } + } + + fn import_bundle( + &self, + path: &QString, + hyprland: bool, + waybar: bool, + app: bool, + via: bool, + system: bool, + ) -> QString { + let path = match resolve_user_path(&String::from(path)) { + Ok(path) => path, + Err(error) => return action_error(error), + }; + let options = crate::bundle::ImportOptions { + hyprland, + waybar, + app, + via, + system, + }; + match crate::bundle::import_from_path(&path, options) { + Ok(report) if report.ok() => action_ok(format!("Import: {}", report.summary())), + Ok(report) if !report.messages.is_empty() => to_qstring(json!({ + "ok": true, + "warning": true, + "message": format!("Import: {}", report.summary()) + })), + Ok(report) => action_error(format!("Import: {}", report.summary())), + Err(error) => action_error(error.to_string()), + } + } + + fn launch_gtk_ui(&self) -> QString { + let executable = match std::env::current_exe() { + Ok(path) => path, + Err(error) => return action_error(error.to_string()), + }; + match std::process::Command::new(executable).arg("--gtk").spawn() { + Ok(_) => action_ok("Opened the GTK compatibility UI.".into()), + Err(error) => action_error(error.to_string()), + } + } + + fn restore_config(&self) -> QString { + let Some(path) = crate::config::default_config_path() else { + return action_error("Could not determine the Hyprland config path."); + }; + match crate::backup::restore_last_good(&path) { + Ok(source) => action_ok(format!( + "Restored {} from {}", + path.display(), + source.display() + )), + Err(error) => action_error(error.to_string()), + } + } + + fn lookfeel_snapshot(&self) -> QString { + match crate::config::load_binds(None) { + Ok(mut collection) => { + collection.finalize(); + let merged = &collection.config_merged; + to_qstring(json!({ + "ok": true, + "gapsIn": crate::settings_config::get_i64(merged, &["general", "gaps_in"], 5), + "gapsOut": crate::settings_config::get_i64(merged, &["general", "gaps_out"], 20), + "borderSize": crate::settings_config::get_i64(merged, &["general", "border_size"], 1), + "rounding": crate::settings_config::get_i64(merged, &["decoration", "rounding"], 10), + "activeOpacity": crate::settings_config::get_f64(merged, &["decoration", "active_opacity"], 1.0), + "inactiveOpacity": crate::settings_config::get_f64(merged, &["decoration", "inactive_opacity"], 1.0), + "blurSize": crate::settings_config::get_i64(merged, &["decoration", "blur", "size"], 3), + "blurEnabled": crate::settings_config::get_bool(merged, &["decoration", "blur", "enabled"], true), + "shadowEnabled": crate::settings_config::get_bool(merged, &["decoration", "shadow", "enabled"], true), + "animationsEnabled": crate::settings_config::get_bool(merged, &["animations", "enabled"], true), + "activeBorder": crate::settings_config::get_path(merged, &["general", "col", "active_border"]) + .map(crate::settings_config::color_to_display) + .unwrap_or_default(), + "inactiveBorder": crate::settings_config::get_path(merged, &["general", "col", "inactive_border"]) + .map(crate::settings_config::color_to_display) + .unwrap_or_default(), + })) + } + Err(error) => action_error(error.to_string()), + } + } + + fn save_lookfeel(&self, payload: &QString) -> QString { + let input = match parse_json_payload(payload) { + Ok(value) => value, + Err(error) => return action_error(error), + }; + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let mut merged = collection.config_merged.clone(); + if !merged.is_object() { + merged = json!({}); + } + apply_lookfeel_payload(&mut merged, &input); + match crate::writer::save_config_override(&collection.config_path, &merged) { + Ok(result) => action_ok(format!("Saved Look & Feel to {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn preview_lookfeel(&self, payload: &QString) -> QString { + let input = match parse_json_payload(payload) { + Ok(value) => value, + Err(error) => return action_error(error), + }; + let expr = build_lookfeel_eval(&input); + let output = match std::process::Command::new("hyprctl") + .args(["eval", &expr]) + .output() + { + Ok(output) => output, + Err(error) => return action_error(error.to_string()), + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}").to_ascii_lowercase(); + if !output.status.success() || combined.contains("error") || combined.contains("can't work") + { + let message = if !stderr.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + return action_error(if message.is_empty() { + "Live preview failed" + } else { + message + }); + } + action_ok("Live preview applied.".into()) + } + + fn environment_snapshot(&self) -> QString { + match crate::config::load_binds(None) { + Ok(mut collection) => { + collection.finalize(); + let env = collection + .env + .iter() + .map(|item| { + json!({ + "name": item.name, + "value": item.value, + "sourceFile": item.source_file, + "sourceLine": item.source_line, + }) + }) + .collect::>(); + to_qstring(json!({ + "ok": true, + "configPath": collection.config_path.display().to_string(), + "env": env, + "warning": collection.error.clone().unwrap_or_default(), + })) + } + Err(error) => to_qstring(json!({ + "ok": false, + "configPath": crate::config::default_config_path() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + "env": [], + "error": error.to_string(), + })), + } + } + + fn add_environment(&self, name: &QString, value: &QString) -> QString { + let name = String::from(name); + let value = String::from(value); + let Some(path) = crate::config::default_config_path() else { + return action_error("Could not determine the Hyprland config path."); + }; + match crate::writer::add_env(&path, &name, &value) { + Ok(result) => action_ok(format!("Added {name} in {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn edit_environment(&self, current_name: &QString, name: &QString, value: &QString) -> QString { + let current_name = String::from(current_name); + let name = String::from(name); + let value = String::from(value); + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let Some(var) = collection.env.iter().find(|item| item.name == current_name) else { + return action_error(format!( + "Environment variable `{current_name}` is no longer present." + )); + }; + match crate::writer::save_env(var, &name, &value) { + Ok(result) => action_ok(format!("Saved {name} in {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn delete_environment(&self, name: &QString) -> QString { + let name = String::from(name); + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let Some(var) = collection.env.iter().find(|item| item.name == name) else { + return action_error(format!( + "Environment variable `{name}` is no longer present." + )); + }; + match crate::writer::delete_env(var) { + Ok(result) => action_ok(format!("Deleted {name} from {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn variables_snapshot(&self) -> QString { + match crate::config::load_binds(None) { + Ok(mut collection) => { + collection.finalize(); + let variables = collection + .variables + .iter() + .map(|item| { + json!({ + "name": item.name, + "value": item.value, + "sourceFile": item.source_file, + "sourceLine": item.source_line, + }) + }) + .collect::>(); + to_qstring(json!({ + "ok": true, + "configPath": collection.config_path.display().to_string(), + "variables": variables, + "warning": collection.error.clone().unwrap_or_default(), + })) + } + Err(error) => to_qstring(json!({ + "ok": false, + "configPath": crate::config::default_config_path() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + "variables": [], + "error": error.to_string(), + })), + } + } + + fn add_variable(&self, name: &QString, value: &QString) -> QString { + let name = String::from(name); + let value = String::from(value); + let Some(path) = crate::config::default_config_path() else { + return action_error("Could not determine the Hyprland config path."); + }; + match crate::writer::add_variable(&path, &name, &value) { + Ok(result) => action_ok(format!("Added {name} in {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn edit_variable(&self, current_name: &QString, name: &QString, value: &QString) -> QString { + let current_name = String::from(current_name); + let name = String::from(name); + let value = String::from(value); + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let related_files = collection.related_files(); + let existing_names = collection + .variables + .iter() + .map(|item| item.name.clone()) + .collect::>(); + let Some(var) = collection + .variables + .iter() + .find(|item| item.name == current_name) + else { + return action_error(format!("Variable `{current_name}` is no longer present.")); + }; + match crate::writer::save_variable(var, &name, &value, &related_files, &existing_names) { + Ok(result) => action_ok(format!("Saved {name} in {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn delete_variable(&self, name: &QString) -> QString { + let name = String::from(name); + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let Some(var) = collection.variables.iter().find(|item| item.name == name) else { + return action_error(format!("Variable `{name}` is no longer present.")); + }; + match crate::writer::delete_variable(var) { + Ok(result) => action_ok(format!("Deleted {name} from {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } +} + +fn parse_json_payload(payload: &QString) -> Result { + serde_json::from_str::(&String::from(payload)).map_err(|error| error.to_string()) +} + +fn payload_i64(input: &Value, key: &str, fallback: i64) -> i64 { + input.get(key).and_then(Value::as_i64).unwrap_or(fallback) +} + +fn payload_f64(input: &Value, key: &str, fallback: f64) -> f64 { + input.get(key).and_then(Value::as_f64).unwrap_or(fallback) +} + +fn payload_bool(input: &Value, key: &str, fallback: bool) -> bool { + input.get(key).and_then(Value::as_bool).unwrap_or(fallback) +} + +fn payload_string(input: &Value, key: &str) -> String { + input + .get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string() +} + +fn apply_lookfeel_payload(merged: &mut Value, input: &Value) { + crate::settings_config::set_path( + merged, + &["general", "gaps_in"], + json!(payload_i64(input, "gapsIn", 5)), + ); + crate::settings_config::set_path( + merged, + &["general", "gaps_out"], + json!(payload_i64(input, "gapsOut", 20)), + ); + crate::settings_config::set_path( + merged, + &["general", "border_size"], + json!(payload_i64(input, "borderSize", 1)), + ); + crate::settings_config::set_path( + merged, + &["decoration", "rounding"], + json!(payload_i64(input, "rounding", 10)), + ); + crate::settings_config::set_path( + merged, + &["decoration", "active_opacity"], + json!(payload_f64(input, "activeOpacity", 1.0)), + ); + crate::settings_config::set_path( + merged, + &["decoration", "inactive_opacity"], + json!(payload_f64(input, "inactiveOpacity", 1.0)), + ); + crate::settings_config::set_path( + merged, + &["decoration", "blur", "enabled"], + json!(payload_bool(input, "blurEnabled", true)), + ); + crate::settings_config::set_path( + merged, + &["decoration", "blur", "size"], + json!(payload_i64(input, "blurSize", 3)), + ); + crate::settings_config::set_path( + merged, + &["decoration", "shadow", "enabled"], + json!(payload_bool(input, "shadowEnabled", true)), + ); + crate::settings_config::set_path( + merged, + &["animations", "enabled"], + json!(payload_bool(input, "animationsEnabled", true)), + ); + let active = payload_string(input, "activeBorder"); + if !active.is_empty() { + crate::settings_config::set_path( + merged, + &["general", "col", "active_border"], + crate::settings_config::parse_color_input(&active), + ); + } + let inactive = payload_string(input, "inactiveBorder"); + if !inactive.is_empty() { + crate::settings_config::set_path( + merged, + &["general", "col", "inactive_border"], + crate::settings_config::parse_color_input(&inactive), + ); + } +} + +fn lua_escape(input: &str) -> String { + input.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn build_lookfeel_eval(input: &Value) -> String { + let gaps_in = payload_i64(input, "gapsIn", 5); + let gaps_out = payload_i64(input, "gapsOut", 20); + let border = payload_i64(input, "borderSize", 1); + let rounding = payload_i64(input, "rounding", 10); + let active_op = payload_f64(input, "activeOpacity", 1.0); + let inactive_op = payload_f64(input, "inactiveOpacity", 1.0); + let blur_size = payload_i64(input, "blurSize", 3); + let blur = payload_bool(input, "blurEnabled", true); + let shadow = payload_bool(input, "shadowEnabled", true); + let anims = payload_bool(input, "animationsEnabled", true); + let mut colors = Vec::new(); + let active = payload_string(input, "activeBorder"); + let inactive = payload_string(input, "inactiveBorder"); + if !active.is_empty() { + colors.push(format!("active_border = \"{}\"", lua_escape(&active))); + } + if !inactive.is_empty() { + colors.push(format!("inactive_border = \"{}\"", lua_escape(&inactive))); + } + let general = if colors.is_empty() { + format!( + "general = {{ gaps_in = {gaps_in}, gaps_out = {gaps_out}, border_size = {border} }}" + ) + } else { + format!( + "general = {{ gaps_in = {gaps_in}, gaps_out = {gaps_out}, border_size = {border}, col = {{ {} }} }}", + colors.join(", ") + ) + }; + format!( + "hl.config({{ {general}, decoration = {{ rounding = {rounding}, active_opacity = {active_op:.2}, inactive_opacity = {inactive_op:.2}, blur = {{ enabled = {blur}, size = {blur_size} }}, shadow = {{ enabled = {shadow} }} }}, animations = {{ enabled = {anims} }} }})" + ) +} + +fn to_qstring(value: serde_json::Value) -> QString { + QString::from(value.to_string()) +} + +fn action_ok(message: String) -> QString { + to_qstring(json!({ "ok": true, "message": message })) +} + +fn action_error(message: impl Into) -> QString { + to_qstring(json!({ "ok": false, "error": message.into() })) +} + +fn resolve_user_path(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err("Choose a JSON file path first.".into()); + } + if value == "~" { + return dirs::home_dir().ok_or_else(|| "Could not determine the home directory.".into()); + } + if let Some(rest) = value.strip_prefix("~/") { + let home = dirs::home_dir().ok_or_else(|| "Could not determine the home directory.".to_string())?; + return Ok(home.join(rest)); + } + Ok(std::path::PathBuf::from(value)) +} + +pub fn run() { + use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QUrl}; + + let mut app = QGuiApplication::new(); + let mut engine = QQmlApplicationEngine::new(); + if let Some(engine) = engine.as_mut() { + engine.load(&QUrl::from("qrc:/qt/qml/dev/hyprbinds/ui/qml/Main.qml")); + } + if let Some(app) = app.as_mut() { + app.exec(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookfeel_payload_maps_to_config_and_live_eval() { + let input = json!({ + "gapsIn": 7, + "gapsOut": 19, + "borderSize": 2, + "rounding": 14, + "activeOpacity": 0.97, + "inactiveOpacity": 0.84, + "blurSize": 6, + "blurEnabled": true, + "shadowEnabled": false, + "animationsEnabled": true, + "activeBorder": "rgba(37d5e9ee)", + "inactiveBorder": "rgba(6b747baa)" + }); + let mut merged = json!({}); + apply_lookfeel_payload(&mut merged, &input); + assert_eq!( + crate::settings_config::get_i64(&merged, &["general", "gaps_in"], -1), + 7 + ); + assert_eq!( + crate::settings_config::get_i64(&merged, &["decoration", "rounding"], -1), + 14 + ); + assert_eq!( + crate::settings_config::get_bool(&merged, &["decoration", "blur", "enabled"], false), + true + ); + assert_eq!( + crate::settings_config::get_bool(&merged, &["decoration", "shadow", "enabled"], true), + false + ); + let eval = build_lookfeel_eval(&input); + assert!(eval.contains("gaps_in = 7")); + assert!(eval.contains("rounding = 14")); + assert!(eval.contains("size = 6")); + assert!(eval.contains("active_border = \"rgba(37d5e9ee)\"")); + } + + #[test] + fn lookfeel_config_write_uses_existing_managed_writer() { + let unique = format!( + "hyprbind-lookfeel-{}-{}.lua", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let path = std::env::temp_dir().join(unique); + std::fs::write(&path, "-- untouched user config\n").unwrap(); + let input = json!({ "gapsIn": 9, "gapsOut": 21, "borderSize": 2, "rounding": 12 }); + let mut merged = json!({}); + apply_lookfeel_payload(&mut merged, &input); + crate::writer::save_config_override(&path, &merged).unwrap(); + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("-- untouched user config")); + assert!(written.contains("hyprbinds")); + assert!(written.contains("gaps_in")); + assert!(crate::backup::sidecar_backup(&path).is_file()); + let _ = std::fs::remove_file(crate::backup::sidecar_backup(&path)); + let _ = std::fs::remove_file(path); + } +} diff --git a/src/qml_catalog_bridge.rs b/src/qml_catalog_bridge.rs new file mode 100644 index 0000000..0d33b36 --- /dev/null +++ b/src/qml_catalog_bridge.rs @@ -0,0 +1,354 @@ +use cxx_qt_lib::QString; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +#[cxx_qt::bridge] +mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + type CatalogBridge = super::CatalogBridgeRust; + + #[qinvokable] + #[rust_name = "snapshot"] + fn snapshot(self: &CatalogBridge, kind: &QString) -> QString; + + #[qinvokable] + #[rust_name = "save_item"] + fn saveItem(self: &CatalogBridge, kind: &QString, id: i32, payload: &QString) -> QString; + + #[qinvokable] + #[rust_name = "delete_item"] + fn deleteItem(self: &CatalogBridge, kind: &QString, id: i32) -> QString; + + #[qinvokable] + #[rust_name = "config_snapshot"] + fn configSnapshot(self: &CatalogBridge) -> QString; + + #[qinvokable] + #[rust_name = "save_config"] + fn saveConfig(self: &CatalogBridge, payload: &QString) -> QString; + } +} + +#[derive(Default)] +pub struct CatalogBridgeRust; + +impl qobject::CatalogBridge { + fn snapshot(&self, kind: &QString) -> QString { + let kind = String::from(kind); + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + + let items = match kind.as_str() { + "binds" => { + let conflicts = crate::conflicts::conflicting_ids(&collection.binds); + collection.binds.iter().map(|item| json!({ + "entryId": item.id, + "displayName": item.name, + "name": item.name, + "keys": item.keys, + "action": item.action, + "category": crate::dispatchers::action_category(&item.action) + .map(|category| category.label()) + .unwrap_or("Other"), + "flags": item.flags, + "flagsLabel": item.flags_label(), + "submap": item.submap, + "sourceFile": item.source_file, + "sourceLine": item.source_line, + "sourceLabel": item.source_label(), + "sharedSource": collection.source_share_count(item) > 1, + "conflict": conflicts.contains(&item.id), + })).collect::>() + } + "window_rules" => rule_rows(&collection.window_rules), + "layer_rules" => rule_rows(&collection.layer_rules), + "workspace_rules" => spec_rows(&collection.workspace_rules), + "monitors" => spec_rows(&collection.monitors), + "devices" => spec_rows(&collection.devices), + "animations" => spec_rows(&collection.animations), + "curves" => spec_rows(&collection.curves), + "gestures" => spec_rows(&collection.gestures), + _ => return action_error(format!("Unknown catalog kind `{kind}`")), + }; + + to_qstring(json!({ + "ok": true, + "configPath": collection.config_path.display().to_string(), + "items": items, + "submaps": collection.submap_names(), + "warning": collection.error.unwrap_or_default(), + })) + } + + fn save_item(&self, kind: &QString, id: i32, payload: &QString) -> QString { + let kind = String::from(kind); + let input = match parse_payload(payload) { + Ok(value) => value, + Err(error) => return action_error(error), + }; + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let adding = id < 0; + + let result = match kind.as_str() { + "binds" => { + let name = text(&input, "name"); + let keys = text(&input, "keys"); + let action = text(&input, "action"); + let submap = text(&input, "submap"); + if adding { + crate::writer::add_bind(&collection.config_path, &name, &keys, &action, &submap) + } else { + let Some(item) = collection.bind_by_id(id as usize) else { + return action_error("The selected bind is no longer present."); + }; + let shared = collection.source_share_count(item) > 1; + crate::writer::save_bind(item, &name, &keys, &action, shared) + } + } + "window_rules" | "layer_rules" => { + let name = text(&input, "name"); + let match_props = match object_map(&input, "match") { + Ok(map) => map, + Err(error) => return action_error(error), + }; + let effects = match object_map(&input, "effects") { + Ok(map) => map, + Err(error) => return action_error(error), + }; + if kind == "window_rules" { + if adding { + crate::writer::add_window_rule(&collection.config_path, &name, &match_props, &effects) + } else { + let Some(item) = collection.rule_by_id(id as usize) else { + return action_error("The selected window rule is no longer present."); + }; + crate::writer::save_window_rule(item, &name, &match_props, &effects) + } + } else if adding { + crate::writer::add_layer_rule(&collection.config_path, &name, &match_props, &effects) + } else { + let Some(item) = collection.layer_rule_by_id(id as usize) else { + return action_error("The selected layer rule is no longer present."); + }; + crate::writer::save_layer_rule(item, &name, &match_props, &effects) + } + } + "workspace_rules" | "monitors" | "devices" | "animations" | "curves" | "gestures" => { + let fields = match object_map(&input, "fields") { + Ok(map) => map, + Err(error) => return action_error(error), + }; + save_spec_kind(&collection, &kind, id, adding, &input, &fields) + } + _ => return action_error(format!("Unknown catalog kind `{kind}`")), + }; + + match result { + Ok(result) => action_ok(format!("Saved to {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn delete_item(&self, kind: &QString, id: i32) -> QString { + if id < 0 { + return action_error("Select an item first."); + } + let kind = String::from(kind); + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let id = id as usize; + + let result = match kind.as_str() { + "binds" => { + let Some(item) = collection.bind_by_id(id) else { return action_error("Bind not found."); }; + crate::writer::delete_bind(item, collection.source_share_count(item) > 1) + } + "window_rules" => { + let Some(item) = collection.rule_by_id(id) else { return action_error("Window rule not found."); }; + crate::writer::delete_window_rule(item) + } + "layer_rules" => { + let Some(item) = collection.layer_rule_by_id(id) else { return action_error("Layer rule not found."); }; + crate::writer::delete_layer_rule(item) + } + "workspace_rules" => delete_spec(collection.workspace_rule_by_id(id), crate::writer::delete_workspace_rule), + "monitors" => delete_spec(collection.monitor_by_id(id), crate::writer::delete_monitor), + "devices" => delete_spec(collection.device_by_id(id), crate::writer::delete_device), + "animations" => delete_spec(collection.animation_by_id(id), crate::writer::delete_animation), + "curves" => delete_spec(collection.curve_by_id(id), crate::writer::delete_curve), + "gestures" => delete_spec(collection.gesture_by_id(id), crate::writer::delete_gesture), + _ => return action_error(format!("Unknown catalog kind `{kind}`")), + }; + + match result { + Ok(result) => action_ok(format!("Deleted from {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn config_snapshot(&self) -> QString { + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + to_qstring(json!({ + "ok": true, + "configPath": collection.config_path.display().to_string(), + "config": collection.config_merged, + "warning": collection.error.unwrap_or_default(), + })) + } + + fn save_config(&self, payload: &QString) -> QString { + let input = match parse_payload(payload) { + Ok(value) => value, + Err(error) => return action_error(error), + }; + let Some(config) = input.get("config") else { + return action_error("Missing config object."); + }; + if !config.is_object() { + return action_error("Config must be a JSON object."); + } + let Some(path) = crate::config::default_config_path() else { + return action_error("Could not determine the Hyprland config path."); + }; + match crate::writer::save_config_override(&path, config) { + Ok(result) => action_ok(format!("Saved config override to {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } +} + +fn rule_rows(items: &[crate::window_rules::WindowRule]) -> Vec { + items.iter().map(|item| json!({ + "entryId": item.id, + "displayName": item.display_name, + "name": item.name, + "match": item.match_props, + "effects": item.effects, + "matchLabel": item.match_label(), + "effectsLabel": item.effects_label(), + "sourceFile": item.source_file, + "sourceLine": item.source_line, + "sourceLabel": item.source_label(), + })).collect() +} + +fn spec_rows(items: &[crate::spec::SpecItem]) -> Vec { + items.iter().map(|item| json!({ + "entryId": item.id, + "displayName": item.display_name, + "name": item.name, + "fields": item.fields, + "fieldsLabel": item.fields_label(), + "sourceFile": item.source_file, + "sourceLine": item.source_line, + "sourceLabel": item.source_label(), + })).collect() +} + +fn save_spec_kind( + collection: &crate::bind::BindCollection, + kind: &str, + id: i32, + adding: bool, + input: &Value, + fields: &BTreeMap, +) -> Result { + match kind { + "workspace_rules" => if adding { + crate::writer::add_workspace_rule(&collection.config_path, fields) + } else { + let item = collection.workspace_rule_by_id(id as usize).ok_or_else(|| crate::writer::WriteError::Invalid("workspace rule not found".into()))?; + crate::writer::save_workspace_rule(item, fields) + }, + "monitors" => if adding { + crate::writer::add_monitor(&collection.config_path, fields) + } else { + let item = collection.monitor_by_id(id as usize).ok_or_else(|| crate::writer::WriteError::Invalid("monitor not found".into()))?; + crate::writer::save_monitor(item, fields) + }, + "devices" => if adding { + crate::writer::add_device(&collection.config_path, fields) + } else { + let item = collection.device_by_id(id as usize).ok_or_else(|| crate::writer::WriteError::Invalid("device not found".into()))?; + crate::writer::save_device(item, fields) + }, + "animations" => if adding { + crate::writer::add_animation(&collection.config_path, fields) + } else { + let item = collection.animation_by_id(id as usize).ok_or_else(|| crate::writer::WriteError::Invalid("animation not found".into()))?; + crate::writer::save_animation(item, fields) + }, + "curves" => { + let name = text(input, "name"); + if adding { + crate::writer::add_curve(&collection.config_path, &name, fields) + } else { + let item = collection.curve_by_id(id as usize).ok_or_else(|| crate::writer::WriteError::Invalid("curve not found".into()))?; + crate::writer::save_curve(item, &name, fields) + } + } + "gestures" => if adding { + crate::writer::add_gesture(&collection.config_path, fields) + } else { + let item = collection.gesture_by_id(id as usize).ok_or_else(|| crate::writer::WriteError::Invalid("gesture not found".into()))?; + crate::writer::save_gesture(item, fields) + }, + _ => Err(crate::writer::WriteError::Invalid(format!("unsupported catalog kind `{kind}`"))), + } +} + +fn delete_spec( + item: Option<&crate::spec::SpecItem>, + delete: fn(&crate::spec::SpecItem) -> Result, +) -> Result { + let item = item.ok_or_else(|| crate::writer::WriteError::Invalid("item not found".into()))?; + delete(item) +} + +fn object_map(input: &Value, key: &str) -> Result, String> { + let Some(object) = input.get(key).and_then(Value::as_object) else { + return Err(format!("`{key}` must be a JSON object.")); + }; + Ok(object.iter().map(|(key, value)| (key.clone(), value.clone())).collect()) +} + +fn text(input: &Value, key: &str) -> String { + input.get(key).and_then(Value::as_str).unwrap_or_default().trim().to_string() +} + +fn parse_payload(payload: &QString) -> Result { + serde_json::from_str::(&String::from(payload)).map_err(|error| error.to_string()) +} + +fn to_qstring(value: Value) -> QString { + QString::from(value.to_string()) +} + +fn action_ok(message: String) -> QString { + to_qstring(json!({ "ok": true, "message": message })) +} + +fn action_error(message: impl Into) -> QString { + to_qstring(json!({ "ok": false, "error": message.into() })) +} diff --git a/src/qml_health_bridge.rs b/src/qml_health_bridge.rs new file mode 100644 index 0000000..947d5dd --- /dev/null +++ b/src/qml_health_bridge.rs @@ -0,0 +1,63 @@ +use cxx_qt_lib::QString; +use serde_json::json; + +#[cxx_qt::bridge] +mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + type HealthBridge = super::HealthBridgeRust; + + #[qinvokable] + #[rust_name = "snapshot"] + fn snapshot(self: &HealthBridge) -> QString; + + #[qinvokable] + #[rust_name = "system_info"] + fn systemInfo(self: &HealthBridge) -> QString; + } +} + +#[derive(Default)] +pub struct HealthBridgeRust; + +impl qobject::HealthBridge { + fn system_info(&self) -> QString { + QString::from(crate::sysinfo::collect_report()) + } + + fn snapshot(&self) -> QString { + let checks = crate::health::run_all(); + let rows = checks + .iter() + .map(|item| { + json!({ + "category": item.category, + "title": item.title, + "detail": item.detail, + "severity": item.severity.label(), + "fixHint": item.fix_hint.clone().unwrap_or_default(), + "fixCommand": item.fix_command.clone().unwrap_or_default(), + }) + }) + .collect::>(); + let (ok_count, warnings, failures) = crate::health::summarize(&checks); + to_qstring(json!({ + "ok": true, + "checks": rows, + "okCount": ok_count, + "failures": failures, + "warnings": warnings, + "total": checks.len(), + })) + } +} + +fn to_qstring(value: serde_json::Value) -> QString { + QString::from(value.to_string()) +} diff --git a/src/qml_logs_bridge.rs b/src/qml_logs_bridge.rs new file mode 100644 index 0000000..96ccea8 --- /dev/null +++ b/src/qml_logs_bridge.rs @@ -0,0 +1,64 @@ +use cxx_qt_lib::QString; +use serde_json::json; + +#[cxx_qt::bridge] +mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + type LogsBridge = super::LogsBridgeRust; + + #[qinvokable] + #[rust_name = "snapshot"] + fn snapshot(self: &LogsBridge) -> QString; + } +} + +#[derive(Default)] +pub struct LogsBridgeRust; + +impl qobject::LogsBridge { + fn snapshot(&self) -> QString { + match crate::logs::fetch(300) { + Ok(entries) => { + let rows = entries + .iter() + .map(|item| { + json!({ + "id": item.id, + "timestamp": item.timestamp_display, + "severity": crate::logs::severity_label(item.severity), + "message": item.message, + "identifier": item.identifier, + "unit": item.unit, + "tags": item.tags, + }) + }) + .collect::>(); + let tags = crate::logs::known_tags(&entries); + to_qstring(json!({ + "ok": true, + "entries": rows, + "tags": tags, + "count": entries.len(), + })) + } + Err(error) => to_qstring(json!({ + "ok": false, + "entries": [], + "tags": [], + "count": 0, + "error": error, + })), + } + } +} + +fn to_qstring(value: serde_json::Value) -> QString { + QString::from(value.to_string()) +} diff --git a/src/qml_startup_bridge.rs b/src/qml_startup_bridge.rs new file mode 100644 index 0000000..190e1e5 --- /dev/null +++ b/src/qml_startup_bridge.rs @@ -0,0 +1,154 @@ +use cxx_qt_lib::QString; +use serde_json::json; + +#[cxx_qt::bridge] +mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + type StartupBridge = super::StartupBridgeRust; + + #[qinvokable] + #[rust_name = "snapshot"] + fn snapshot(self: &StartupBridge) -> QString; + + #[qinvokable] + #[rust_name = "add_entry"] + fn addEntry( + self: &StartupBridge, + command: &QString, + when: &QString, + workspace: &QString, + ) -> QString; + + #[qinvokable] + #[rust_name = "edit_entry"] + fn editEntry( + self: &StartupBridge, + id: i32, + command: &QString, + when: &QString, + workspace: &QString, + ) -> QString; + + #[qinvokable] + #[rust_name = "delete_entry"] + fn deleteEntry(self: &StartupBridge, id: i32) -> QString; + } +} + +#[derive(Default)] +pub struct StartupBridgeRust; + +impl qobject::StartupBridge { + fn snapshot(&self) -> QString { + match crate::config::load_binds(None) { + Ok(mut collection) => { + collection.finalize(); + let entries = collection + .startup + .iter() + .map(|item| { + json!({ + "entryId": item.id, + "command": item.command, + "when": item.when, + "workspace": item.workspace, + "sourceFile": item.source_file, + "sourceLine": item.source_line, + }) + }) + .collect::>(); + to_qstring(json!({ + "ok": true, + "configPath": collection.config_path.display().to_string(), + "entries": entries, + "warning": collection.error.clone().unwrap_or_default(), + })) + } + Err(error) => to_qstring(json!({ + "ok": false, + "configPath": crate::config::default_config_path() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + "entries": [], + "error": error.to_string(), + })), + } + } + + fn add_entry(&self, command: &QString, when: &QString, workspace: &QString) -> QString { + let command = String::from(command); + let when = String::from(when); + let workspace = String::from(workspace); + let Some(path) = crate::config::default_config_path() else { + return action_error("Could not determine the Hyprland config path."); + }; + match crate::writer::add_startup(&path, &command, &when, &workspace) { + Ok(result) => action_ok(format!("Added startup entry in {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn edit_entry( + &self, + id: i32, + command: &QString, + when: &QString, + workspace: &QString, + ) -> QString { + if id < 0 { + return action_error("Invalid startup entry id."); + } + let command = String::from(command); + let when = String::from(when); + let workspace = String::from(workspace); + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let Some(entry) = collection.startup_by_id(id as usize) else { + return action_error(format!("Startup entry `{id}` is no longer present.")); + }; + match crate::writer::save_startup(entry, &command, &when, &workspace) { + Ok(result) => action_ok(format!("Saved startup entry in {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn delete_entry(&self, id: i32) -> QString { + if id < 0 { + return action_error("Invalid startup entry id."); + } + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let Some(entry) = collection.startup_by_id(id as usize) else { + return action_error(format!("Startup entry `{id}` is no longer present.")); + }; + match crate::writer::delete_startup(entry) { + Ok(result) => action_ok(format!("Deleted startup entry from {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } +} + +fn to_qstring(value: serde_json::Value) -> QString { + QString::from(value.to_string()) +} + +fn action_ok(message: String) -> QString { + to_qstring(json!({ "ok": true, "message": message })) +} + +fn action_error(message: impl Into) -> QString { + to_qstring(json!({ "ok": false, "error": message.into() })) +} diff --git a/src/qml_submaps_bridge.rs b/src/qml_submaps_bridge.rs new file mode 100644 index 0000000..5e798d4 --- /dev/null +++ b/src/qml_submaps_bridge.rs @@ -0,0 +1,110 @@ +use cxx_qt_lib::QString; +use serde_json::json; + +#[cxx_qt::bridge] +mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + type SubmapsBridge = super::SubmapsBridgeRust; + + #[qinvokable] + #[rust_name = "snapshot"] + fn snapshot(self: &SubmapsBridge) -> QString; + + #[qinvokable] + #[rust_name = "add_submap"] + fn addSubmap(self: &SubmapsBridge, name: &QString) -> QString; + + #[qinvokable] + #[rust_name = "delete_submap"] + fn deleteSubmap(self: &SubmapsBridge, id: i32) -> QString; + } +} + +#[derive(Default)] +pub struct SubmapsBridgeRust; + +impl qobject::SubmapsBridge { + fn snapshot(&self) -> QString { + match crate::config::load_binds(None) { + Ok(mut collection) => { + collection.finalize(); + let submaps = collection + .submaps + .iter() + .map(|item| { + json!({ + "entryId": item.id, + "name": item.name, + "reset": item.reset, + "bindCount": item.bind_count, + "sourceFile": item.source_file, + "sourceLine": item.source_line, + }) + }) + .collect::>(); + to_qstring(json!({ + "ok": true, + "configPath": collection.config_path.display().to_string(), + "submaps": submaps, + "warning": collection.error.clone().unwrap_or_default(), + })) + } + Err(error) => to_qstring(json!({ + "ok": false, + "configPath": crate::config::default_config_path() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + "submaps": [], + "error": error.to_string(), + })), + } + } + + fn add_submap(&self, name: &QString) -> QString { + let name = String::from(name); + let Some(path) = crate::config::default_config_path() else { + return action_error("Could not determine the Hyprland config path."); + }; + match crate::writer::add_submap(&path, &name) { + Ok(result) => action_ok(format!("Added submap {name} in {}", result.path)), + Err(error) => action_error(error.to_string()), + } + } + + fn delete_submap(&self, id: i32) -> QString { + if id < 0 { + return action_error("Invalid submap id."); + } + let mut collection = match crate::config::load_binds(None) { + Ok(collection) => collection, + Err(error) => return action_error(error.to_string()), + }; + collection.finalize(); + let Some(submap) = collection.submap_by_id(id as usize) else { + return action_error(format!("Submap `{id}` is no longer present.")); + }; + match crate::writer::delete_submap(submap) { + Ok(result) => action_ok(format!("Deleted submap {} from {}", submap.name, result.path)), + Err(error) => action_error(error.to_string()), + } + } +} + +fn to_qstring(value: serde_json::Value) -> QString { + QString::from(value.to_string()) +} + +fn action_ok(message: String) -> QString { + to_qstring(json!({ "ok": true, "message": message })) +} + +fn action_error(message: impl Into) -> QString { + to_qstring(json!({ "ok": false, "error": message.into() })) +} diff --git a/src/qml_window_bridge.rs b/src/qml_window_bridge.rs new file mode 100644 index 0000000..93dc08a --- /dev/null +++ b/src/qml_window_bridge.rs @@ -0,0 +1,63 @@ +use cxx_qt_lib::QString; +use serde_json::json; + +#[cxx_qt::bridge] +mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + type WindowControlBridge = super::WindowControlBridgeRust; + + #[qinvokable] + #[rust_name = "toggle_maximize"] + fn toggleMaximize(self: &WindowControlBridge) -> QString; + } +} + +#[derive(Default)] +pub struct WindowControlBridgeRust; + +impl qobject::WindowControlBridge { + fn toggle_maximize(&self) -> QString { + const EXPR: &str = + "hl.dispatch(hl.dsp.window.fullscreen({ mode = \"maximized\", action = \"toggle\" }))"; + + let output = match std::process::Command::new("hyprctl") + .args(["eval", EXPR]) + .output() + { + Ok(output) => output, + Err(error) => return response(false, error.to_string()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let combined = format!("{stdout}\n{stderr}").to_ascii_lowercase(); + if output.status.success() + && !combined.contains("error") + && !combined.contains("failed") + { + response(true, if stdout.is_empty() { "ok".into() } else { stdout }) + } else { + response( + false, + if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "Hyprland maximize request failed".into() + }, + ) + } + } +} + +fn response(ok: bool, message: String) -> QString { + QString::from(json!({ "ok": ok, "message": message }).to_string()) +} diff --git a/src/writer/env.rs b/src/writer/env.rs index a0515bc..2bbab6c 100644 --- a/src/writer/env.rs +++ b/src/writer/env.rs @@ -88,3 +88,58 @@ pub fn delete_env(var: &EnvVar) -> Result { write_config_atomic(path, &updated)?; Ok(ok_result(path.display().to_string(), WriteMode::InPlace)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_config() -> std::path::PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "hyprbinds-env-{}-{stamp}.lua", + std::process::id() + )); + std::fs::write( + &path, + "-- >>> hyprbinds:managed-env\n-- <<< hyprbinds:managed-env\n", + ) + .expect("write fixture"); + path + } + + #[test] + fn add_save_delete_round_trip() { + let path = test_config(); + add_env(&path, "TEST_VAR", "one").expect("add env"); + let added = std::fs::read_to_string(&path).expect("read added"); + assert!(added.contains("hl.env(\"TEST_VAR\", \"one\")")); + + let var = EnvVar { + name: "TEST_VAR".into(), + value: "one".into(), + source_file: path.display().to_string(), + source_line: 2, + }; + save_env(&var, "RENAMED_VAR", "two").expect("save env"); + let saved = std::fs::read_to_string(&path).expect("read saved"); + assert!(!saved.contains("TEST_VAR")); + assert!(saved.contains("hl.env(\"RENAMED_VAR\", \"two\")")); + + let renamed = EnvVar { + name: "RENAMED_VAR".into(), + value: "two".into(), + source_file: path.display().to_string(), + source_line: 2, + }; + delete_env(&renamed).expect("delete env"); + let deleted = std::fs::read_to_string(&path).expect("read deleted"); + assert!(!deleted.contains("RENAMED_VAR")); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(crate::backup::sidecar_backup(&path)); + } +}