From 106538497c887338b32576d5097fe8e45bdd55b3 Mon Sep 17 00:00:00 2001 From: magetsu002 <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:18:38 +0400 Subject: [PATCH 001/109] feat: add QML UI migration and glass design system --- Cargo.lock | 1128 ++++++++++++++++++++++++++++++++++++--- Cargo.toml | 6 + build.rs | 29 + qml/GlassButton.qml | 43 ++ qml/GlassField.qml | 41 ++ qml/GlassPanel.qml | 44 ++ qml/HyprbindTheme.qml | 62 +++ qml/IconButton.qml | 57 ++ qml/LookFeelPage.qml | 223 ++++++++ qml/Main.qml | 1165 +++++++++++++++++++++++++++++++++++++++++ qml/NavItem.qml | 58 ++ qml/SettingField.qml | 45 ++ qml/SettingSlider.qml | 103 ++++ qml/SettingToggle.qml | 61 +++ qml/SettingsCard.qml | 37 ++ qml/UiIcon.qml | 100 ++++ src/main.rs | 6 + src/qml_bridge.rs | 502 ++++++++++++++++++ src/writer/env.rs | 55 ++ 19 files changed, 3695 insertions(+), 70 deletions(-) create mode 100644 build.rs create mode 100644 qml/GlassButton.qml create mode 100644 qml/GlassField.qml create mode 100644 qml/GlassPanel.qml create mode 100644 qml/HyprbindTheme.qml create mode 100644 qml/IconButton.qml create mode 100644 qml/LookFeelPage.qml create mode 100644 qml/Main.qml create mode 100644 qml/NavItem.qml create mode 100644 qml/SettingField.qml create mode 100644 qml/SettingSlider.qml create mode 100644 qml/SettingToggle.qml create mode 100644 qml/SettingsCard.qml create mode 100644 qml/UiIcon.qml create mode 100644 src/qml_bridge.rs 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/build.rs b/build.rs new file mode 100644 index 0000000..0e8b7d0 --- /dev/null +++ b/build.rs @@ -0,0 +1,29 @@ +use cxx_qt_build::{CxxQtBuilder, QmlModule}; + +fn main() { + CxxQtBuilder::new_qml_module( + QmlModule::new("dev.hyprbinds.ui") + .qml_files([ + "qml/Main.qml", + "qml/GlassPanel.qml", + "qml/GlassField.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"]) + .qt_module("Quick") + .qt_module("QuickControls2") + .build(); +} 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..7cf2761 --- /dev/null +++ b/qml/GlassField.qml @@ -0,0 +1,41 @@ +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.accent, 0.26) + selectedTextColor: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + selectByMouse: true + + background: Rectangle { + radius: 14 + antialiasing: true + color: control.activeFocus ? theme.searchFocusedFill : 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: control.activeFocus ? theme.searchSpecularFocus : theme.searchSpecular + } + + Behavior on color { ColorAnimation { duration: 185; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 185; 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/HyprbindTheme.qml b/qml/HyprbindTheme.qml new file mode 100644 index 0000000..fdcbefb --- /dev/null +++ b/qml/HyprbindTheme.qml @@ -0,0 +1,62 @@ +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 + ) + } + + readonly property color background: darkMode ? "#101416" : "#e9eef0" + readonly property color surface: darkMode ? "#1a2023" : "#f4f7f8" + readonly property color surfaceHigh: darkMode ? "#252d31" : "#ffffff" + readonly property color foreground: darkMode ? "#eef2f3" : "#172126" + readonly property color muted: darkMode ? "#bec6ca" : "#526068" + readonly property color accent: "#37d5e9" + + readonly property color textPrimary: foreground + readonly property color textSecondary: alpha(muted, 0.78) + readonly property color familyShell: mix(surfaceHigh, background, darkMode ? 0.54 : 0.30) + readonly property color shellFill: alpha(familyShell, darkMode ? 0.62 : 0.74) + readonly property color shellRim: alpha(foreground, darkMode ? 0.095 : 0.13) + readonly property color shellTopSpecular: alpha(foreground, darkMode ? 0.034 : 0.11) + readonly property color shellAccentWash: alpha(accent, 0.018) + readonly property color shellBottomShade: alpha(accent, darkMode ? 0.028 : 0.018) + readonly property color shellInnerLine: alpha(foreground, darkMode ? 0.052 : 0.09) + readonly property color toolbarFill: alpha(mix(surfaceHigh, background, 0.44), darkMode ? 0.30 : 0.40) + readonly property color sidebarFill: alpha(mix(surfaceHigh, background, 0.55), darkMode ? 0.42 : 0.50) + readonly property color contentFill: alpha(mix(surface, background, 0.62), darkMode ? 0.18 : 0.28) + readonly property color raisedFill: alpha(mix(surfaceHigh, background, 0.58), darkMode ? 0.40 : 0.52) + readonly property color quietRim: alpha(foreground, darkMode ? 0.065 : 0.11) + readonly property color divider: alpha(foreground, darkMode ? 0.036 : 0.075) + + readonly property color controlFill: alpha(mix(surfaceHigh, background, 0.60), darkMode ? 0.42 : 0.50) + readonly property color controlHover: alpha(mix(surfaceHigh, accent, 0.08), darkMode ? 0.50 : 0.56) + readonly property color controlPressed: alpha(mix(surfaceHigh, accent, 0.12), darkMode ? 0.56 : 0.62) + readonly property color controlRim: alpha(foreground, darkMode ? 0.055 : 0.10) + readonly property color controlRimActive: alpha(accent, darkMode ? 0.14 : 0.22) + readonly property color controlInnerRim: alpha(foreground, darkMode ? 0.025 : 0.055) + + readonly property color searchFill: alpha(mix(surfaceHigh, background, 0.62), darkMode ? 0.42 : 0.52) + readonly property color searchFocusedFill: alpha(mix(surfaceHigh, accent, 0.055), darkMode ? 0.50 : 0.58) + readonly property color searchRim: alpha(foreground, darkMode ? 0.055 : 0.10) + readonly property color searchFocusRim: alpha(accent, darkMode ? 0.14 : 0.24) + readonly property color searchSpecular: alpha(foreground, darkMode ? 0.026 : 0.075) + readonly property color searchSpecularFocus: alpha(mix(foreground, accent, 0.10), darkMode ? 0.040 : 0.10) + + readonly property color hoverFill: alpha(foreground, darkMode ? 0.028 : 0.060) + readonly property color selectedFill: alpha(mix(surfaceHigh, accent, 0.10), darkMode ? 0.54 : 0.60) + readonly property color selectedRim: alpha(accent, darkMode ? 0.10 : 0.18) + readonly property color selectedSpecular: alpha(foreground, darkMode ? 0.028 : 0.070) + readonly property color danger: darkMode ? "#dca3a0" : "#9c3f3b" + readonly property color dangerFill: alpha(danger, darkMode ? 0.055 : 0.075) + readonly property color dangerRim: alpha(danger, darkMode ? 0.14 : 0.20) +} diff --git a/qml/IconButton.qml b/qml/IconButton.qml new file mode 100644 index 0000000..09b325c --- /dev/null +++ b/qml/IconButton.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: control + property string glyph: "" + property string iconName: "" + 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 } } + } +} diff --git a/qml/LookFeelPage.qml b/qml/LookFeelPage.qml new file mode 100644 index 0000000..8a9e61c --- /dev/null +++ b/qml/LookFeelPage.qml @@ -0,0 +1,223 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + required property var bridge + property bool darkMode: true + property bool realtimeEnabled: false + 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 || !realtimeEnabled) + 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.") + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 14 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + spacing: 8 + + Text { + 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") } + Item { Layout.fillWidth: true } + Text { + text: root.realtimeEnabled ? "Live preview on" : "Live preview off" + color: root.realtimeEnabled ? theme.alpha(theme.accent, 0.90) : theme.alpha(theme.textSecondary, 0.60) + font.family: "Inter" + font.pixelSize: 11 + } + GlassButton { + text: "Reload" + darkMode: root.darkMode + onClicked: root.load() + } + GlassButton { + text: "Save" + accent: true + darkMode: root.darkMode + onClicked: root.save() + } + } + + Flickable { + id: scroller + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: width + contentHeight: cardGrid.implicitHeight + 4 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + GridLayout { + id: cardGrid + width: scroller.width + columns: width >= 850 ? 2 : 1 + columnSpacing: 14 + rowSpacing: 14 + + SettingsCard { + title: "SPACING & WINDOWS" + darkMode: root.darkMode + Layout.fillWidth: true + 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() } } + } + + SettingsCard { + title: "TRANSPARENCY" + darkMode: root.darkMode + Layout.fillWidth: true + 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() } } + } + + SettingsCard { + title: "EFFECTS" + darkMode: root.darkMode + Layout.fillWidth: true + 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() } } + } + + SettingsCard { + title: "BORDER COLORS" + darkMode: root.darkMode + Layout.fillWidth: true + 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() } } + } + } + } + } +} diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..02f4177 --- /dev/null +++ b/qml/Main.qml @@ -0,0 +1,1165 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import dev.hyprbinds.ui + +ApplicationWindow { + id: appWindow + width: 1440 + height: 900 + minimumWidth: 1060 + minimumHeight: 700 + visible: true + title: "Hyprbind" + color: "transparent" + flags: Qt.Window | Qt.FramelessWindowHint + + readonly property color accent: theme.accent + readonly property color textPrimary: theme.textPrimary + readonly property color textMuted: theme.textSecondary + readonly property color hairline: theme.divider + + property string currentPage: "Environment" + property string configPath: "" + property var allEnvironment: [] + property string selectedName: "" + property string statusMessage: "Environment variables are loaded from your config and applied when Hyprland starts." + property bool backendHealthy: true + property bool darkMode: true + property bool realtimeEnabled: false + property bool backupAvailable: false + property string backupAge: "" + + HyprbindBridge { id: backend } + HyprbindTheme { id: theme; darkMode: appWindow.darkMode } + ListModel { id: environmentModel } + + function parsePayload(raw) { + try { + return JSON.parse(raw) + } catch (error) { + return { ok: false, error: "Invalid response from Rust bridge: " + error } + } + } + + function loadUiState() { + const payload = parsePayload(backend.uiSnapshot()) + if (!payload.ok) + return + darkMode = payload.darkMode !== false + 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 glass theme enabled." : "Light glass theme enabled.") + } + + function restoreLastBackup() { + const payload = parsePayload(backend.restoreConfig()) + if (!payload.ok) { + statusMessage = payload.error || "Restore failed." + return + } + loadEnvironment() + loadUiState() + statusMessage = payload.message || "Restored the latest config snapshot." + } + + function loadEnvironment() { + const payload = parsePayload(backend.environmentSnapshot()) + configPath = payload.configPath || "" + allEnvironment = payload.env || [] + backendHealthy = payload.ok === true + selectedName = "" + if (!payload.ok) + statusMessage = payload.error || "Could not load the Hyprland config." + else if (payload.warning) + statusMessage = payload.warning + else + statusMessage = "Environment variables are loaded from your config and applied when Hyprland starts." + rebuildEnvironmentModel() + } + + function rebuildEnvironmentModel() { + 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 = parsePayload(raw) + if (!payload.ok) { + editorError.text = payload.error || "The operation failed." + statusMessage = editorError.text + return false + } + statusMessage = payload.message || "Saved." + if (closeEditor) + envEditor.close() + loadEnvironment() + statusMessage = 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 = parsePayload(backend.deleteEnvironment(deleting)) + if (!payload.ok) { + statusMessage = payload.error || "Delete failed." + return + } + loadEnvironment() + statusMessage = payload.message || ("Deleted " + deleting + ".") + } + + Component.onCompleted: { + loadUiState() + loadEnvironment() + } + + 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: 30 + anchors.rightMargin: 30 + anchors.top: parent.top + anchors.topMargin: 1 + height: 1 + radius: 1 + antialiasing: true + color: theme.shellInnerLine + } + + Rectangle { + id: topBar + height: 55 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + color: theme.toolbarFill + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: hairline + } + + MouseArea { + anchors.fill: parent + anchors.rightMargin: 360 + onPressed: appWindow.startSystemMove() + onDoubleClicked: { + if (appWindow.visibility === Window.Maximized) + appWindow.showNormal() + else + appWindow.showMaximized() + } + } + + Row { + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.verticalCenter: parent.verticalCenter + spacing: 17 + + Text { + text: "Hyprbind" + color: textPrimary + font.family: "Inter" + font.pixelSize: 18 + font.weight: Font.DemiBold + } + Rectangle { + width: 1 + height: 24 + color: hairline + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "Configure. Bind. Customize." + color: darkMode ? Qt.rgba(0.76, 0.82, 0.86, 0.76) : Qt.rgba(0.20, 0.32, 0.38, 0.68) + font.family: "Inter" + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + anchors.right: windowControls.left + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + spacing: 13 + + Button { + id: themeButton + implicitHeight: 36 + leftPadding: 10 + rightPadding: 11 + hoverEnabled: true + flat: true + contentItem: Row { + spacing: 8 + UiIcon { + width: 18; height: 18 + name: darkMode ? "moon" : "sun" + iconColor: textPrimary + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: darkMode ? "Dark mode" : "Light mode" + color: textPrimary + font.family: "Inter" + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + } + background: Rectangle { + radius: 9 + color: themeButton.hovered + ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.40)) + : "transparent" + border.width: themeButton.hovered ? 1 : 0 + border.color: hairline + } + onClicked: toggleDarkMode() + ToolTip.visible: hovered + ToolTip.text: "Switch Hyprbind's persisted glass theme" + } + + Rectangle { width: 1; height: 25; color: hairline; anchors.verticalCenter: parent.verticalCenter } + + Button { + id: realtimeButton + implicitHeight: 36 + leftPadding: 10 + rightPadding: 11 + hoverEnabled: true + flat: true + contentItem: Row { + spacing: 8 + UiIcon { + width: 17; height: 17 + name: "realtime" + iconColor: realtimeEnabled ? accent : textMuted + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "Realtime" + color: realtimeEnabled ? textPrimary : textMuted + font.family: "Inter" + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + Rectangle { + width: 6; height: 6; radius: 3 + color: realtimeEnabled ? accent : Qt.rgba(0.55,0.62,0.66,0.34) + anchors.verticalCenter: parent.verticalCenter + } + } + background: Rectangle { + radius: 9 + color: realtimeButton.hovered + ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.40)) + : "transparent" + border.width: realtimeEnabled ? 1 : 0 + border.color: Qt.rgba(0.25,0.84,0.94,0.22) + } + onClicked: { + realtimeEnabled = !realtimeEnabled + statusMessage = realtimeEnabled + ? "Realtime preview is armed for migrated live-edit pages. Environment still writes on Save." + : "Realtime preview disabled." + } + ToolTip.visible: hovered + ToolTip.text: "Autosave/live preview is used only where a migrated page supports it" + } + + Rectangle { width: 1; height: 25; color: hairline; anchors.verticalCenter: parent.verticalCenter } + + IconButton { + iconName: "history" + darkMode: appWindow.darkMode + implicitWidth: 36 + implicitHeight: 36 + enabled: backupAvailable + onClicked: restoreLastBackup() + ToolTip.visible: hovered + ToolTip.text: backupAvailable + ? (backupAge.length ? "Restore latest snapshot · " + backupAge : "Restore latest snapshot") + : "No Hyprbind snapshot is available yet" + } + IconButton { + iconName: "reload" + darkMode: appWindow.darkMode + implicitWidth: 36 + implicitHeight: 36 + onClicked: { + loadEnvironment() + loadUiState() + statusMessage = "Reloaded config from disk." + } + ToolTip.visible: hovered + ToolTip.text: "Reload config" + } + } + + Row { + id: windowControls + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Button { + id: minimizeButton + implicitWidth: 40 + implicitHeight: 36 + flat: true + hoverEnabled: true + contentItem: UiIcon { + width: 16; height: 16 + anchors.centerIn: parent + name: "minimize" + iconColor: textMuted + } + background: Rectangle { + radius: 8 + color: minimizeButton.hovered + ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.38)) + : "transparent" + } + onClicked: appWindow.showMinimized() + } + Button { + id: maximizeButton + implicitWidth: 40 + implicitHeight: 36 + flat: true + hoverEnabled: true + contentItem: UiIcon { + width: 15; height: 15 + anchors.centerIn: parent + name: "maximize" + iconColor: textMuted + } + background: Rectangle { + radius: 8 + color: maximizeButton.hovered + ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.38)) + : "transparent" + } + onClicked: appWindow.visibility === Window.Maximized ? appWindow.showNormal() : appWindow.showMaximized() + } + Button { + id: closeButton + implicitWidth: 40 + implicitHeight: 36 + flat: true + hoverEnabled: true + contentItem: UiIcon { + width: 16; height: 16 + anchors.centerIn: parent + name: "close" + iconColor: closeButton.hovered ? "#ffd9d6" : textMuted + } + background: Rectangle { + radius: 8 + color: closeButton.hovered ? Qt.rgba(0.73,0.20,0.22,0.44) : "transparent" + } + onClicked: appWindow.close() + } + } + } + + Rectangle { + id: sidebar + width: 286 + anchors.left: parent.left + anchors.top: topBar.bottom + anchors.bottom: parent.bottom + color: theme.sidebarFill + + Rectangle { + width: 1 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + color: hairline + } + + GlassField { + id: navSearch + anchors.top: parent.top + anchors.topMargin: 15 + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + implicitHeight: 39 + placeholderText: "Search settings..." + leftPadding: 38 + darkMode: appWindow.darkMode + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.verticalCenter: parent.verticalCenter + iconColor: textMuted + } + Text { + text: "Ctrl K" + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + color: darkMode ? Qt.rgba(0.79,0.84,0.87,0.66) : Qt.rgba(0.18,0.31,0.37,0.56) + font.family: "Inter" + font.pixelSize: 10 + } + } + + Flickable { + id: navFlick + anchors.top: navSearch.bottom + anchors.topMargin: 10 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 10 + clip: true + contentHeight: navColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: navColumn + width: navFlick.width + spacing: 3 + + Repeater { + model: [ + { title: "START HERE", items: [ + { name: "Overview", iconName: "home" } + ]}, + { title: "KEYBOARD & CONFIG", items: [ + { name: "Binds", iconName: "keyboard" }, + { name: "Variables", iconName: "braces" }, + { name: "Environment", iconName: "document" }, + { name: "Submaps", iconName: "grid" }, + { name: "Startup", iconName: "power" } + ]}, + { title: "RULES", items: [ + { name: "Window rules", iconName: "window" }, + { name: "Workspace rules", iconName: "grid" }, + { name: "Layer rules", iconName: "layers" } + ]}, + { title: "APPEARANCE & INPUT", items: [ + { name: "Look & Feel", iconName: "diamond" }, + { name: "Config", iconName: "settings" }, + { name: "Monitors", iconName: "monitor" }, + { name: "Devices", iconName: "device" }, + { name: "Animations", iconName: "sparkle" }, + { name: "Curves", iconName: "curve" }, + { name: "Gestures", iconName: "gesture" } + ]}, + { title: "SYSTEM", items: [ + { name: "Health", iconName: "heart" }, + { name: "Logs", iconName: "logs" } + ]} + ] + + delegate: Column { + id: groupColumn + width: navColumn.width + property var groupData: modelData + spacing: 3 + + Item { width: 1; height: index === 0 ? 3 : 9 } + Text { + text: groupColumn.groupData.title + color: darkMode ? Qt.rgba(0.73, 0.79, 0.82, 0.70) : Qt.rgba(0.20, 0.33, 0.39, 0.62) + font.family: "Inter" + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 1.25 + leftPadding: 22 + height: 22 + verticalAlignment: Text.AlignVCenter + } + Repeater { + model: groupColumn.groupData.items + delegate: NavItem { + property var itemData: modelData + width: groupColumn.width - 28 + anchors.horizontalCenter: parent.horizontalCenter + text: itemData.name + iconName: itemData.iconName + darkMode: appWindow.darkMode + selected: appWindow.currentPage === itemData.name + visible: !navSearch.text || itemData.name.toLowerCase().indexOf(navSearch.text.toLowerCase()) >= 0 + height: visible ? implicitHeight : 0 + onClicked: appWindow.currentPage = itemData.name + } + } + } + } + } + } + } + + Item { + id: mainArea + anchors.left: sidebar.right + anchors.right: parent.right + anchors.top: topBar.bottom + anchors.bottom: parent.bottom + + Item { + id: pageHeader + height: 94 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 25 + anchors.rightMargin: 25 + + GlassPanel { + id: pageIcon + width: 52 + height: 52 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + cornerRadius: 12 + darkMode: appWindow.darkMode + elevated: false + tint: accent + border.color: theme.alpha(accent, 0.14) + + Rectangle { + anchors.fill: parent + anchors.margins: 8 + radius: 10 + color: theme.alpha(theme.mix(theme.surfaceHigh, accent, 0.08), darkMode ? 0.12 : 0.18) + } + UiIcon { + anchors.centerIn: parent + width: 24 + height: 24 + name: appWindow.currentPage === "Environment" ? "terminal" + : appWindow.currentPage === "Look & Feel" ? "diamond" : "document" + iconColor: (appWindow.currentPage === "Environment" || appWindow.currentPage === "Look & Feel") ? accent : textMuted + strokeWidth: 1.8 + } + } + + Column { + anchors.left: pageIcon.right + anchors.leftMargin: 18 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + text: appWindow.currentPage + color: textPrimary + font.family: "Inter" + font.pixelSize: 25 + font.weight: Font.DemiBold + } + Text { + text: appWindow.currentPage === "Environment" + ? "Manage environment variables for Hyprland and apps launched in your session." + : appWindow.currentPage === "Look & Feel" + ? "Shape Hyprland’s spacing, transparency, effects, and window borders." + : "This page remains available in the GTK frontend while the QML migration is in progress." + color: textMuted + font.family: "Inter" + font.pixelSize: 12 + } + } + + GlassPanel { + id: pathPanel + width: 320 + height: 43 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + darkMode: appWindow.darkMode + elevated: false + + TextInput { + id: pathText + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.right: copyPath.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: configPath || "No config loaded" + readOnly: true + selectByMouse: true + color: configPath + ? (darkMode ? Qt.rgba(0.82, 0.88, 0.91, 0.84) : Qt.rgba(0.16, 0.29, 0.36, 0.82)) + : textMuted + font.family: "Inter" + font.pixelSize: 11 + clip: true + } + Button { + id: copyPath + width: 40 + height: parent.height + anchors.right: parent.right + flat: true + enabled: configPath.length > 0 + contentItem: UiIcon { + width: 17 + height: 17 + anchors.centerIn: parent + name: "copy" + iconColor: copyPath.enabled ? textMuted : Qt.rgba(0.55,0.62,0.66,0.28) + } + background: Rectangle { + color: copyPath.hovered + ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.34)) + : "transparent" + radius: 9 + } + onClicked: { + pathText.selectAll() + pathText.copy() + pathText.deselect() + statusMessage = "Copied config path." + } + ToolTip.visible: hovered + ToolTip.text: "Copy config path" + } + } + } + + Item { + id: environmentToolbar + height: appWindow.currentPage === "Environment" ? 47 : 0 + visible: appWindow.currentPage === "Environment" + anchors.left: parent.left + anchors.right: parent.right + anchors.top: pageHeader.bottom + anchors.leftMargin: 25 + anchors.rightMargin: 25 + + GlassField { + id: envSearch + anchors.left: parent.left + anchors.right: toolbarButtons.left + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + implicitHeight: 43 + placeholderText: "Filter environment variables..." + leftPadding: 42 + darkMode: appWindow.darkMode + onTextChanged: rebuildEnvironmentModel() + + UiIcon { + width: 17 + height: 17 + name: "search" + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + iconColor: textMuted + } + } + + Row { + id: toolbarButtons + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 9 + + IconButton { + iconName: "plus" + darkMode: appWindow.darkMode + accent: true + onClicked: openAddEditor() + ToolTip.visible: hovered + ToolTip.text: "Add environment variable" + } + IconButton { + iconName: "edit" + darkMode: appWindow.darkMode + enabled: selectedName.length > 0 + onClicked: openEditEditor() + ToolTip.visible: hovered + ToolTip.text: "Edit selected variable" + } + IconButton { + iconName: "trash" + darkMode: appWindow.darkMode + danger: true + enabled: selectedName.length > 0 + onClicked: deleteSelected() + ToolTip.visible: hovered + ToolTip.text: "Delete selected variable" + } + } + } + + Rectangle { + id: contentHalo + anchors.left: parent.left + anchors.right: parent.right + anchors.top: appWindow.currentPage === "Environment" ? environmentToolbar.bottom : pageHeader.bottom + anchors.bottom: infoPanel.top + anchors.leftMargin: 22 + anchors.rightMargin: 22 + anchors.topMargin: appWindow.currentPage === "Environment" ? 9 : -1 + anchors.bottomMargin: 11 + radius: 17 + color: "transparent" + border.width: 1 + border.color: Qt.rgba(0.25, 0.84, 0.94, darkMode ? 0.055 : 0.075) + } + + GlassPanel { + id: contentPanel + anchors.left: parent.left + anchors.right: parent.right + anchors.top: appWindow.currentPage === "Environment" ? environmentToolbar.bottom : pageHeader.bottom + anchors.bottom: infoPanel.top + anchors.leftMargin: 25 + anchors.rightMargin: 25 + anchors.topMargin: appWindow.currentPage === "Environment" ? 12 : 2 + anchors.bottomMargin: 14 + cornerRadius: 14 + darkMode: appWindow.darkMode + elevated: false + + Item { + anchors.fill: parent + visible: appWindow.currentPage === "Environment" + + ListView { + id: environmentList + anchors.fill: parent + anchors.margins: 12 + clip: true + model: environmentModel + spacing: 7 + 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: 69 + radius: 10 + color: selectedName === name + ? theme.selectedFill + : rowMouse.containsMouse ? theme.hoverFill : "transparent" + border.width: selectedName === name ? 1 : 0 + border.color: theme.selectedRim + + Rectangle { + visible: 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: 16 + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Text { + text: name + color: textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + } + Text { + text: value + color: theme.alpha(theme.textSecondary, 0.88) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width + } + Text { + text: sourceFile ? sourceFile.split("/").pop() + ":" + sourceLine : "managed config" + color: theme.alpha(theme.textSecondary, 0.58) + font.family: "Inter" + font.pixelSize: 9 + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + onClicked: selectedName = name + onDoubleClicked: { + selectedName = name + openEditEditor() + } + } + } + } + + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 80, 520) + spacing: 14 + visible: environmentModel.count === 0 + + GlassPanel { + width: 76 + height: 76 + cornerRadius: 38 + anchors.horizontalCenter: parent.horizontalCenter + darkMode: appWindow.darkMode + elevated: true + border.color: theme.controlRim + + Rectangle { + anchors.centerIn: parent + width: 48 + height: 48 + radius: 24 + color: theme.alpha(theme.accent, darkMode ? 0.035 : 0.065) + } + UiIcon { + anchors.centerIn: parent + width: 28 + height: 28 + name: "document" + iconColor: darkMode ? "#dbe8ee" : "#34505c" + strokeWidth: 1.55 + } + } + Item { width: 1; height: 2 } + Text { + text: allEnvironment.length === 0 + ? "No environment variables yet" + : "No matching environment variables" + color: textPrimary + font.family: "Inter" + font.pixelSize: 19 + font.weight: Font.DemiBold + anchors.horizontalCenter: parent.horizontalCenter + } + Text { + width: parent.width + text: allEnvironment.length === 0 + ? "Environment variables let you define key-value pairs for Hyprland and apps launched in your session." + : "Try a different filter." + color: textMuted + font.family: "Inter" + font.pixelSize: 12 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + Item { width: 1; height: 2 } + GlassButton { + visible: allEnvironment.length === 0 + text: "Add your first variable" + darkMode: appWindow.darkMode + accent: true + anchors.horizontalCenter: parent.horizontalCenter + onClicked: openAddEditor() + } + } + } + + Loader { + id: lookFeelLoader + anchors.fill: parent + active: appWindow.currentPage === "Look & Feel" + sourceComponent: Component { + LookFeelPage { + bridge: backend + darkMode: appWindow.darkMode + realtimeEnabled: appWindow.realtimeEnabled + onStatus: message => appWindow.statusMessage = message + onSaved: { + appWindow.loadUiState() + appWindow.loadEnvironment() + } + } + } + } + + Column { + anchors.centerIn: parent + spacing: 12 + visible: appWindow.currentPage !== "Environment" && appWindow.currentPage !== "Look & Feel" + Text { + text: "QML migration in progress" + color: textPrimary + font.family: "Inter" + font.pixelSize: 19 + font.weight: Font.DemiBold + anchors.horizontalCenter: parent.horizontalCenter + } + Text { + width: 430 + text: "Use the existing GTK frontend for " + appWindow.currentPage + " while this page is ported." + color: textMuted + font.family: "Inter" + font.pixelSize: 12 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + } + + GlassPanel { + id: infoPanel + height: 58 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 25 + anchors.rightMargin: 25 + anchors.bottomMargin: 18 + cornerRadius: 11 + darkMode: appWindow.darkMode + elevated: false + + UiIcon { + anchors.left: parent.left + anchors.leftMargin: 17 + anchors.verticalCenter: parent.verticalCenter + width: 18 + height: 18 + name: "info" + iconColor: backendHealthy + ? (darkMode ? "#dce6eb" : "#38535f") + : (darkMode ? "#e3aaa7" : "#ad4c49") + } + Text { + anchors.left: parent.left + anchors.leftMargin: 46 + anchors.right: learnMore.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: statusMessage + color: theme.alpha(theme.textSecondary, 0.90) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + Text { + id: learnMore + anchors.right: parent.right + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + text: "Learn more ↗" + color: accent + opacity: 0.82 + font.family: "Inter" + font.pixelSize: 11 + } + } + } + } + + Popup { + id: envEditor + property bool editing: false + property string originalName: "" + width: 470 + height: 326 + x: Math.round((appWindow.width - width) / 2) + y: Math.round((appWindow.height - height) / 2) + modal: true + focus: true + closePolicy: Popup.CloseOnEscape + padding: 0 + + Overlay.modal: Rectangle { color: theme.alpha(theme.background, darkMode ? 0.46 : 0.24) } + + background: Rectangle { + radius: 18 + color: theme.alpha(theme.familyShell, darkMode ? 0.92 : 0.95) + 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: textPrimary + font.family: "Inter" + font.pixelSize: 20 + font.weight: Font.DemiBold + } + Text { + text: "Changes are written through Hyprbind’s existing Rust writer and backup path." + color: textMuted + font.family: "Inter" + font.pixelSize: 11 + } + GlassField { + id: editorName + width: parent.width + darkMode: appWindow.darkMode + placeholderText: "Name — e.g. XCURSOR_SIZE" + } + GlassField { + id: editorValue + width: parent.width + darkMode: appWindow.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: appWindow.darkMode + onClicked: envEditor.close() + } + GlassButton { + id: editorSave + text: envEditor.editing ? "Save" : "Add" + darkMode: appWindow.darkMode + accent: true + enabled: editorName.text.trim().length > 0 + onClicked: { + editorError.text = "" + if (envEditor.editing) { + runAction( + backend.editEnvironment(envEditor.originalName, editorName.text, editorValue.text), + true + ) + } else { + runAction(backend.addEnvironment(editorName.text, editorValue.text), true) + } + } + } + } + } + } +} diff --git a/qml/NavItem.qml b/qml/NavItem.qml new file mode 100644 index 0000000..577686e --- /dev/null +++ b/qml/NavItem.qml @@ -0,0 +1,58 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: control + property string iconName: "document" + property bool selected: false + property bool darkMode: true + hoverEnabled: true + HyprbindTheme { id: theme; darkMode: control.darkMode } + + implicitHeight: 38 + leftPadding: 11 + rightPadding: 11 + scale: down ? 0.985 : 1 + Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } + + contentItem: Row { + spacing: 11 + anchors.verticalCenter: parent.verticalCenter + UiIcon { + width: 18 + height: 18 + name: control.iconName + iconColor: control.selected ? theme.textPrimary : theme.alpha(theme.textSecondary, 0.88) + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: control.text + color: control.selected ? theme.textPrimary : theme.alpha(theme.textPrimary, 0.86) + font.family: "Inter" + font.pixelSize: 13 + font.weight: control.selected ? Font.Medium : Font.Normal + anchors.verticalCenter: parent.verticalCenter + } + } + + background: Rectangle { + radius: 12 + antialiasing: true + color: control.selected ? theme.selectedFill + : control.hovered ? theme.hoverFill : "transparent" + border.width: control.selected ? 1 : 0 + border.color: theme.selectedRim + Behavior on color { ColorAnimation { duration: 145; easing.type: Easing.OutCubic } } + + Rectangle { + visible: control.selected + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 15 + anchors.rightMargin: 15 + anchors.top: parent.top + height: 1 + color: theme.selectedSpecular + } + } +} diff --git a/qml/SettingField.qml b/qml/SettingField.qml new file mode 100644 index 0000000..3cde825 --- /dev/null +++ b/qml/SettingField.qml @@ -0,0 +1,45 @@ +import QtQuick + +Item { + id: root + property bool darkMode: true + property string title: "Setting" + property string subtitle: "" + property alias text: field.text + property string placeholderText: "" + signal committed(string text) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + implicitHeight: 68 + + Column { + anchors.left: parent.left + anchors.right: field.left + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Text { + text: root.title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + } + Text { + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + } + } + + GlassField { + id: field + width: 250 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + 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..67383d8 --- /dev/null +++ b/qml/SettingSlider.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Controls + +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: "" + signal edited(real value) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + implicitHeight: 70 + + Column { + anchors.left: parent.left + anchors.right: sliderWrap.left + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Text { + text: root.title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + } + Text { + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + } + } + + Row { + id: sliderWrap + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + Slider { + id: slider + width: 220 + height: 32 + from: root.from + to: root.to + stepSize: root.stepSize + value: root.value + onMoved: root.edited(value) + + background: Rectangle { + x: slider.leftPadding + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: slider.availableWidth + height: 4 + radius: 2 + color: theme.alpha(theme.textSecondary, 0.12) + Rectangle { + width: slider.visualPosition * parent.width + height: parent.height + radius: 2 + color: theme.alpha(theme.accent, 0.54) + } + } + + handle: Rectangle { + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: 16 + height: 16 + radius: 8 + color: theme.mix(theme.surfaceHigh, theme.foreground, 0.08) + border.width: 1 + border.color: theme.alpha(theme.accent, slider.pressed ? 0.48 : 0.28) + scale: slider.pressed ? 1.10 : 1 + Behavior on scale { NumberAnimation { duration: 105; easing.type: Easing.OutCubic } } + } + } + + Rectangle { + width: 54 + height: 31 + radius: 10 + color: theme.controlFill + border.width: 1 + border.color: theme.controlRim + 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..affbfb6 --- /dev/null +++ b/qml/SettingToggle.qml @@ -0,0 +1,61 @@ +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: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Text { + text: root.title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + } + Text { + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + } + } + + 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 + Behavior on color { ColorAnimation { duration: 160; easing.type: Easing.OutCubic } } + + 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) + Behavior on x { NumberAnimation { duration: 165; easing.type: Easing.OutCubic } } + } + HoverHandler { id: hover } + scale: tap.pressed ? 0.97 : 1 + Behavior on scale { NumberAnimation { duration: 105; easing.type: Easing.OutCubic } } + TapHandler { id: tap; onTapped: root.toggled(!root.checked) } + } +} diff --git a/qml/SettingsCard.qml b/qml/SettingsCard.qml new file mode 100644 index 0000000..9726f61 --- /dev/null +++ b/qml/SettingsCard.qml @@ -0,0 +1,37 @@ +import QtQuick + +GlassPanel { + id: root + property string title: "Section" + property bool darkMode: true + 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/UiIcon.qml b/qml/UiIcon.qml new file mode 100644 index 0000000..83acc48 --- /dev/null +++ b/qml/UiIcon.qml @@ -0,0 +1,100 @@ +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() + 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 === "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 === "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 === "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(5, 5); c.lineTo(15, 15); c.moveTo(15, 5); c.lineTo(5, 15) + } else if (name === "minimize") { + c.moveTo(5, 11); c.lineTo(15, 11) + } else if (name === "maximize") { + c.rect(5, 5, 10, 10) + } 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/src/main.rs b/src/main.rs index f7ff277..e4cf7c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod lookfeel_ui; mod nav; mod overview_ui; mod palette; +mod qml_bridge; mod settings_config; mod spec; mod startup; @@ -190,6 +191,11 @@ fn main() { return; } + if args.iter().any(|a| a == "--qml") { + qml_bridge::run(); + return; + } + let app = Application::builder().application_id(APP_ID).build(); app.connect_activate(ui::build_ui); app.run(); diff --git a/src/qml_bridge.rs b/src/qml_bridge.rs new file mode 100644 index 0000000..23f170d --- /dev/null +++ b/src/qml_bridge.rs @@ -0,0 +1,502 @@ +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; + } +} + +#[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, + "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 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 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() })) +} + +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/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)); + } +} From 7dded56ddd5d9348e8ab8022212fc83c5ffc8d06 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:21:05 +0400 Subject: [PATCH 002/109] refactor(qml): add shared icon button tooltips --- qml/IconButton.qml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qml/IconButton.qml b/qml/IconButton.qml index 09b325c..ca64794 100644 --- a/qml/IconButton.qml +++ b/qml/IconButton.qml @@ -5,6 +5,7 @@ Button { id: control property string glyph: "" property string iconName: "" + property string tooltip: "" property bool accent: false property bool danger: false property bool darkMode: true @@ -54,4 +55,8 @@ Button { 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 } From b7bd595c08d31bea59ad49cf5f81defc3c391502 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:21:41 +0400 Subject: [PATCH 003/109] feat(qml): add vector save icon --- qml/UiIcon.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qml/UiIcon.qml b/qml/UiIcon.qml index 83acc48..a377cf5 100644 --- a/qml/UiIcon.qml +++ b/qml/UiIcon.qml @@ -40,6 +40,9 @@ Canvas { 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.roundedRect(6.5, 11.2, 7, 4.2, 1) } 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 === "terminal") { From c224fd896d24f3673a1e7101d4f953ae38466403 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:22:00 +0400 Subject: [PATCH 004/109] refine(qml): replace slider knob with embedded capsule handle --- qml/SettingSlider.qml | 69 +++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/qml/SettingSlider.qml b/qml/SettingSlider.qml index 67383d8..d6cd7dd 100644 --- a/qml/SettingSlider.qml +++ b/qml/SettingSlider.qml @@ -23,6 +23,7 @@ Item { anchors.rightMargin: 24 anchors.verticalCenter: parent.verticalCenter spacing: 3 + Text { text: root.title color: theme.textPrimary @@ -54,32 +55,68 @@ Item { value: root.value onMoved: root.edited(value) - background: Rectangle { + background: Item { x: slider.leftPadding y: slider.topPadding + slider.availableHeight / 2 - height / 2 width: slider.availableWidth - height: 4 - radius: 2 - color: theme.alpha(theme.textSecondary, 0.12) + 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.15 : 0.10) + Behavior on color { ColorAnimation { duration: 130 } } + } + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter width: slider.visualPosition * parent.width - height: parent.height - radius: 2 - color: theme.alpha(theme.accent, 0.54) + height: 3 + radius: 1.5 + color: theme.alpha(theme.accent, slider.pressed ? 0.72 : 0.56) + Behavior on color { ColorAnimation { duration: 120 } } } } - handle: Rectangle { + handle: Item { x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) y: slider.topPadding + slider.availableHeight / 2 - height / 2 - width: 16 - height: 16 - radius: 8 - color: theme.mix(theme.surfaceHigh, theme.foreground, 0.08) - border.width: 1 - border.color: theme.alpha(theme.accent, slider.pressed ? 0.48 : 0.28) - scale: slider.pressed ? 1.10 : 1 - Behavior on scale { NumberAnimation { duration: 105; easing.type: Easing.OutCubic } } + width: 10 + height: 22 + + Rectangle { + anchors.centerIn: parent + width: slider.pressed ? 8 : 7 + height: slider.pressed ? 20 : 18 + radius: width / 2 + color: slider.pressed + ? theme.mix(theme.surfaceHigh, theme.foreground, 0.16) + : theme.mix(theme.surfaceHigh, theme.foreground, 0.08) + border.width: 1 + border.color: theme.alpha(theme.accent, slider.hovered || slider.pressed ? 0.32 : 0.16) + antialiasing: true + + 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.15 : 0.08) + } + + Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } + Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 120 } } + Behavior on border.color { ColorAnimation { duration: 120 } } + } } } From 315b913402f621d13e9ffa6424a0baba6e1b8430 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:22:36 +0400 Subject: [PATCH 005/109] refine(qml): use icon actions in Look & Feel --- qml/LookFeelPage.qml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/qml/LookFeelPage.qml b/qml/LookFeelPage.qml index 8a9e61c..c240319 100644 --- a/qml/LookFeelPage.qml +++ b/qml/LookFeelPage.qml @@ -144,13 +144,15 @@ Item { font.family: "Inter" font.pixelSize: 11 } - GlassButton { - text: "Reload" + IconButton { + iconName: "reload" + tooltip: "Reload values" darkMode: root.darkMode onClicked: root.load() } - GlassButton { - text: "Save" + IconButton { + iconName: "save" + tooltip: "Save changes" accent: true darkMode: root.darkMode onClicked: root.save() From 5e9f3246a3e2bb3d520dba98b0ff7bf71f498148 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:46:54 +0400 Subject: [PATCH 006/109] refine(qml): upgrade responsive slider control --- qml/SettingSlider.qml | 286 +++++++++++++++++++++++++++--------------- 1 file changed, 182 insertions(+), 104 deletions(-) diff --git a/qml/SettingSlider.qml b/qml/SettingSlider.qml index d6cd7dd..04e5f47 100644 --- a/qml/SettingSlider.qml +++ b/qml/SettingSlider.qml @@ -1,8 +1,10 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts Item { id: root + property bool darkMode: true property string title: "Setting" property string subtitle: "" @@ -12,128 +14,204 @@ Item { 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: 70 - - Column { - anchors.left: parent.left - anchors.right: sliderWrap.left - anchors.rightMargin: 24 - anchors.verticalCenter: parent.verticalCenter - spacing: 3 - - Text { - text: root.title - color: theme.textPrimary - font.family: "Inter" - font.pixelSize: 13 - font.weight: Font.Medium - } - Text { - text: root.subtitle - color: theme.alpha(theme.textSecondary, 0.78) - font.family: "Inter" - font.pixelSize: 11 - } - } - Row { - id: sliderWrap - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 12 - - Slider { - id: slider - width: 220 - height: 32 - from: root.from - to: root.to - stepSize: root.stepSize - value: root.value - onMoved: root.edited(value) - - background: Item { - x: slider.leftPadding - y: slider.topPadding + slider.availableHeight / 2 - height / 2 - width: slider.availableWidth - height: 10 + implicitHeight: compactLayout ? 96 : 70 - 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.15 : 0.10) - Behavior on color { ColorAnimation { duration: 130 } } - } + GridLayout { + anchors.fill: parent + columns: root.compactLayout ? 1 : 2 + columnSpacing: 24 + rowSpacing: 8 - 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 : 0.56) - Behavior on color { ColorAnimation { duration: 120 } } - } + 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 } - handle: Item { - x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) - y: slider.topPadding + slider.availableHeight / 2 - height / 2 - width: 10 - height: 22 + Text { + width: parent.width + text: root.subtitle + color: theme.alpha(theme.textSecondary, 0.78) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideRight + } + } - Rectangle { - anchors.centerIn: parent - width: slider.pressed ? 8 : 7 - height: slider.pressed ? 20 : 18 - radius: width / 2 - color: slider.pressed - ? theme.mix(theme.surfaceHigh, theme.foreground, 0.16) - : theme.mix(theme.surfaceHigh, theme.foreground, 0.08) - border.width: 1 - border.color: theme.alpha(theme.accent, slider.hovered || slider.pressed ? 0.32 : 0.16) - antialiasing: true + 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 + + 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 + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: slider.availableWidth + height: 12 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.15 : 0.08) + anchors.verticalCenter: parent.verticalCenter + height: 4 + radius: 2 + color: theme.alpha(theme.textSecondary, slider.hovered ? 0.14 : 0.095) + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 2 + anchors.rightMargin: 2 + height: 1 + radius: 1 + color: theme.alpha(theme.foreground, slider.hovered ? 0.035 : 0.022) + } + + Behavior on color { ColorAnimation { duration: 140 } } } - Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } - Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } - Behavior on color { ColorAnimation { duration: 120 } } - Behavior on border.color { ColorAnimation { duration: 120 } } + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Math.max(3, slider.visualPosition * parent.width) + height: 4 + radius: 2 + color: theme.alpha(theme.accent, slider.pressed ? 0.72 : slider.hovered ? 0.62 : 0.54) + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 2 + anchors.rightMargin: 2 + height: 1 + radius: 1 + color: theme.alpha(theme.foreground, slider.pressed ? 0.09 : 0.055) + } + + Behavior on color { ColorAnimation { duration: 130 } } + } + } + + handle: Item { + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: 28 + height: 34 + + Rectangle { + anchors.centerIn: parent + width: slider.hovered || slider.pressed ? 22 : 18 + height: slider.hovered || slider.pressed ? 30 : 26 + radius: 11 + color: theme.alpha(theme.accent, slider.pressed ? 0.055 : slider.hovered ? 0.032 : 0) + + Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on height { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 120 } } + } + + Rectangle { + anchors.centerIn: parent + width: slider.pressed ? 12 : 11 + height: slider.pressed ? 23 : 21 + radius: width / 2 + antialiasing: true + color: slider.pressed + ? theme.mix(theme.surfaceHigh, theme.foreground, 0.13) + : theme.mix(theme.surfaceHigh, theme.foreground, 0.075) + 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.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 2 + width: Math.max(3, parent.width - 5) + height: 1 + radius: 1 + color: theme.alpha(theme.foreground, slider.pressed ? 0.18 : 0.11) + } + + Rectangle { + anchors.centerIn: parent + width: 2 + height: 9 + radius: 1 + color: theme.alpha(theme.accent, slider.pressed ? 0.74 : slider.hovered ? 0.58 : 0.42) + } + + Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } + Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 120 } } + Behavior on border.color { ColorAnimation { duration: 120 } } + } } } - } - Rectangle { - width: 54 - height: 31 - radius: 10 - color: theme.controlFill - border.width: 1 - border.color: theme.controlRim - 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 + 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 + } } } } From e961cf77fbdc7b24613c63a0b279329e80a47065 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:47:41 +0400 Subject: [PATCH 007/109] refine(qml): balance Look & Feel responsive layout --- qml/LookFeelPage.qml | 276 +++++++++++++++++++++++++++++++------------ 1 file changed, 199 insertions(+), 77 deletions(-) diff --git a/qml/LookFeelPage.qml b/qml/LookFeelPage.qml index c240319..8141d1f 100644 --- a/qml/LookFeelPage.qml +++ b/qml/LookFeelPage.qml @@ -4,9 +4,13 @@ import QtQuick.Layouts Item { id: root + required property var bridge property bool darkMode: true property bool realtimeEnabled: false + readonly property bool compactToolbar: width < 760 + readonly property bool singleColumnCards: width < 860 + signal status(string message) signal saved() @@ -116,46 +120,114 @@ Item { } } + 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 - RowLayout { + Item { Layout.fillWidth: true - Layout.preferredHeight: 40 - spacing: 8 - - Text { - 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") } - Item { Layout.fillWidth: true } - Text { - text: root.realtimeEnabled ? "Live preview on" : "Live preview off" - color: root.realtimeEnabled ? 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() + Layout.preferredHeight: root.compactToolbar ? 78 : 40 + + Row { + id: presetsRow + anchors.left: parent.left + anchors.top: parent.top + anchors.verticalCenter: root.compactToolbar ? undefined : parent.verticalCenter + 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") } } - IconButton { - iconName: "save" - tooltip: "Save changes" - accent: true - darkMode: root.darkMode - onClicked: root.save() + + Row { + anchors.right: parent.right + anchors.bottom: root.compactToolbar ? parent.bottom : undefined + anchors.verticalCenter: root.compactToolbar ? undefined : parent.verticalCenter + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.realtimeEnabled ? "Live preview on" : "Live preview off" + color: root.realtimeEnabled ? 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() + } } } @@ -165,59 +237,109 @@ Item { Layout.fillHeight: true clip: true contentWidth: width - contentHeight: cardGrid.implicitHeight + 4 + contentHeight: cardStage.height + 4 boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - GridLayout { - id: cardGrid + Item { + id: cardStage width: scroller.width - columns: width >= 850 ? 2 : 1 - columnSpacing: 14 - rowSpacing: 14 + readonly property bool twoColumns: !root.singleColumnCards + height: twoColumns ? wideColumns.implicitHeight : narrowColumn.implicitHeight - SettingsCard { - title: "SPACING & WINDOWS" - darkMode: root.darkMode - Layout.fillWidth: true - 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() } } - } + RowLayout { + id: wideColumns + width: parent.width + visible: cardStage.twoColumns + spacing: 14 - SettingsCard { - title: "TRANSPARENCY" - darkMode: root.darkMode - Layout.fillWidth: true - 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() } } - } + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: cardStage.width / 2 + Layout.alignment: Qt.AlignTop + spacing: 14 - SettingsCard { - title: "EFFECTS" - darkMode: root.darkMode - Layout.fillWidth: true - 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() } } + 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 + } + } } - SettingsCard { - title: "BORDER COLORS" - darkMode: root.darkMode - Layout.fillWidth: true - 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 { + 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 + } } } } From a3f5b002cf5be1b98bb07ae7c004b2a93e931db9 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:48:44 +0400 Subject: [PATCH 008/109] refine(qml): make setting fields responsive --- qml/SettingField.qml | 75 +++++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/qml/SettingField.qml b/qml/SettingField.qml index 3cde825..f5b4612 100644 --- a/qml/SettingField.qml +++ b/qml/SettingField.qml @@ -1,45 +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: 68 - - Column { - anchors.left: parent.left - anchors.right: field.left - anchors.rightMargin: 24 - anchors.verticalCenter: parent.verticalCenter - spacing: 3 - Text { - text: root.title - color: theme.textPrimary - font.family: "Inter" - font.pixelSize: 13 - font.weight: Font.Medium - } - Text { - text: root.subtitle - color: theme.alpha(theme.textSecondary, 0.78) - font.family: "Inter" - font.pixelSize: 11 + + 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 - width: 250 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - darkMode: root.darkMode - placeholderText: root.placeholderText - onEditingFinished: root.committed(text) + 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) + } } } From 410c92f4277cabb17c384995a5a706120bcff319 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:48:58 +0400 Subject: [PATCH 009/109] refine(qml): harden toggle layout at narrow widths --- qml/SettingToggle.qml | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/qml/SettingToggle.qml b/qml/SettingToggle.qml index affbfb6..d03014f 100644 --- a/qml/SettingToggle.qml +++ b/qml/SettingToggle.qml @@ -2,33 +2,42 @@ 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: 24 + 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 } } @@ -42,7 +51,20 @@ Item { 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 - Behavior on color { ColorAnimation { duration: 160; easing.type: Easing.OutCubic } } + 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 @@ -50,12 +72,21 @@ Item { 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) + 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 } - scale: tap.pressed ? 0.97 : 1 - Behavior on scale { NumberAnimation { duration: 105; easing.type: Easing.OutCubic } } 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 } } } } From 9bf999a581b7c9e1782a3a52e43dc8cf4c521b1e Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:29:12 +0400 Subject: [PATCH 010/109] refactor(qml): add responsive application top bar --- qml/AppTopBar.qml | 259 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 qml/AppTopBar.qml diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml new file mode 100644 index 0000000..8601b56 --- /dev/null +++ b/qml/AppTopBar.qml @@ -0,0 +1,259 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Window + +Rectangle { + id: root + + required property var appWindow + property bool darkMode: true + property bool realtimeEnabled: false + property bool backupAvailable: false + property string backupAge: "" + + signal toggleThemeRequested() + signal toggleRealtimeRequested() + signal restoreRequested() + signal reloadRequested() + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + readonly property bool showTagline: width >= 1180 + readonly property bool showActionLabels: width >= 1010 + readonly property bool compact: width < 900 + + height: 55 + color: theme.toolbarFill + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: theme.divider + } + + MouseArea { + anchors.fill: parent + onPressed: root.appWindow.startSystemMove() + onDoubleClicked: { + if (root.appWindow.visibility === Window.Maximized) + root.appWindow.showNormal() + else + root.appWindow.showMaximized() + } + } + + Row { + id: brandRow + 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 + anchors.right: windowControls.left + anchors.rightMargin: root.compact ? 8 : 14 + anchors.verticalCenter: parent.verticalCenter + spacing: root.compact ? 5 : 9 + + Button { + id: themeButton + implicitWidth: root.showActionLabels ? 105 : 36 + implicitHeight: 36 + padding: 0 + flat: true + hoverEnabled: true + + contentItem: Row { + anchors.centerIn: parent + spacing: 7 + UiIcon { + width: 18 + height: 18 + name: root.darkMode ? "moon" : "sun" + iconColor: theme.textPrimary + anchors.verticalCenter: parent.verticalCenter + } + Text { + visible: root.showActionLabels + text: root.darkMode ? "Dark mode" : "Light mode" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + } + + background: Rectangle { + radius: 10 + color: themeButton.hovered ? theme.hoverFill : "transparent" + border.width: themeButton.hovered ? 1 : 0 + border.color: theme.controlRim + } + onClicked: root.toggleThemeRequested() + ToolTip.visible: hovered + ToolTip.text: root.darkMode ? "Switch to light theme" : "Switch to dark theme" + } + + Button { + id: realtimeButton + implicitWidth: root.showActionLabels ? 105 : 36 + implicitHeight: 36 + padding: 0 + flat: true + hoverEnabled: true + + contentItem: Row { + anchors.centerIn: parent + spacing: 7 + UiIcon { + width: 17 + height: 17 + name: "realtime" + iconColor: root.realtimeEnabled ? theme.accent : theme.textSecondary + anchors.verticalCenter: parent.verticalCenter + } + Text { + visible: root.showActionLabels + text: "Realtime" + color: root.realtimeEnabled ? theme.textPrimary : theme.textSecondary + font.family: "Inter" + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + Rectangle { + visible: root.showActionLabels + width: 5 + height: 5 + radius: 2.5 + color: root.realtimeEnabled ? theme.accent : theme.alpha(theme.textSecondary, 0.28) + anchors.verticalCenter: parent.verticalCenter + } + } + + background: Rectangle { + radius: 10 + color: realtimeButton.hovered ? theme.hoverFill : "transparent" + border.width: root.realtimeEnabled ? 1 : 0 + border.color: theme.alpha(theme.accent, 0.20) + } + onClicked: root.toggleRealtimeRequested() + ToolTip.visible: hovered + ToolTip.text: root.realtimeEnabled ? "Disable live preview" : "Enable live preview on supported pages" + } + + 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 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Repeater { + model: [ + { icon: "minimize", tip: "Minimize" }, + { icon: "maximize", tip: "Maximize" }, + { icon: "close", tip: "Close" } + ] + delegate: Button { + required property var modelData + implicitWidth: root.compact ? 36 : 40 + implicitHeight: 36 + padding: 0 + flat: true + hoverEnabled: true + + contentItem: UiIcon { + width: 15 + height: 15 + anchors.centerIn: parent + name: modelData.icon + iconColor: modelData.icon === "close" && parent.hovered + ? "#ffd9d6" : theme.textSecondary + } + + background: Rectangle { + radius: 8 + color: modelData.icon === "close" && parent.hovered + ? Qt.rgba(0.73, 0.20, 0.22, 0.44) + : parent.hovered ? theme.hoverFill : "transparent" + } + + onClicked: { + if (modelData.icon === "minimize") + root.appWindow.showMinimized() + else if (modelData.icon === "maximize") { + if (root.appWindow.visibility === Window.Maximized) + root.appWindow.showNormal() + else + root.appWindow.showMaximized() + } else { + root.appWindow.close() + } + } + ToolTip.visible: hovered + ToolTip.text: modelData.tip + } + } + } +} From dd5ea2d687b3bb0bf5af9cfb95ee147b24380f7f Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:29:30 +0400 Subject: [PATCH 011/109] refactor(qml): add responsive sidebar component --- qml/AppSidebar.qml | 147 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 qml/AppSidebar.qml diff --git a/qml/AppSidebar.qml b/qml/AppSidebar.qml new file mode 100644 index 0000000..ca7caad --- /dev/null +++ b/qml/AppSidebar.qml @@ -0,0 +1,147 @@ +import QtQuick +import QtQuick.Controls + +Rectangle { + id: root + + property bool darkMode: true + property string currentPage: "Environment" + 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 + 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: navSearch.bottom + anchors.topMargin: 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: ScrollBar.AsNeeded } + + Column { + id: navColumn + width: navFlick.width + spacing: 3 + + Repeater { + model: [ + { title: "START HERE", items: [ + { name: "Overview", iconName: "home" } + ]}, + { title: "KEYBOARD & CONFIG", items: [ + { name: "Binds", iconName: "keyboard" }, + { name: "Variables", iconName: "braces" }, + { name: "Environment", iconName: "document" }, + { name: "Submaps", iconName: "grid" }, + { name: "Startup", iconName: "power" } + ]}, + { title: "RULES", items: [ + { name: "Window rules", iconName: "window" }, + { name: "Workspace rules", iconName: "grid" }, + { name: "Layer rules", iconName: "layers" } + ]}, + { title: "APPEARANCE & INPUT", items: [ + { name: "Look & Feel", iconName: "diamond" }, + { name: "Config", iconName: "settings" }, + { name: "Monitors", iconName: "monitor" }, + { name: "Devices", iconName: "device" }, + { name: "Animations", iconName: "sparkle" }, + { name: "Curves", iconName: "curve" }, + { name: "Gestures", iconName: "gesture" } + ]}, + { title: "SYSTEM", items: [ + { name: "Health", iconName: "heart" }, + { name: "Logs", iconName: "logs" } + ]} + ] + + delegate: Column { + id: groupColumn + width: navColumn.width + property var groupData: modelData + spacing: 3 + + Item { width: 1; height: index === 0 ? 3 : 9 } + + Text { + 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: 22 + verticalAlignment: Text.AlignVCenter + } + + Repeater { + model: groupColumn.groupData.items + delegate: NavItem { + property var itemData: modelData + width: groupColumn.width - 24 + anchors.horizontalCenter: parent.horizontalCenter + text: itemData.name + iconName: itemData.iconName + darkMode: root.darkMode + selected: root.currentPage === itemData.name + visible: !navSearch.text || itemData.name.toLowerCase().indexOf(navSearch.text.toLowerCase()) >= 0 + height: visible ? implicitHeight : 0 + onClicked: root.pageSelected(itemData.name) + } + } + } + } + } + } +} From 47d11408a03a59264dc1fbbe15375d1973b5ce82 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:29:56 +0400 Subject: [PATCH 012/109] refactor(qml): add responsive page header and copy feedback --- qml/PageHeader.qml | 198 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 qml/PageHeader.qml diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml new file mode 100644 index 0000000..73f6843 --- /dev/null +++ b/qml/PageHeader.qml @@ -0,0 +1,198 @@ +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 >= 880 + readonly property bool showSubtitle: width >= 560 + readonly property int edgeMargin: width < 700 ? 16 : 24 + property bool copied: false + + height: width < 700 ? 82 : 94 + + function pageIconName() { + if (currentPage === "Environment") return "terminal" + if (currentPage === "Look & Feel") return "diamond" + if (currentPage === "Overview") return "home" + if (currentPage === "Variables") return "braces" + if (currentPage === "Binds") return "keyboard" + if (currentPage === "Submaps") return "grid" + if (currentPage === "Startup") return "power" + if (currentPage === "Config") return "settings" + if (currentPage === "Monitors") return "monitor" + if (currentPage === "Devices") return "device" + if (currentPage === "Animations") return "sparkle" + if (currentPage === "Curves") return "curve" + if (currentPage === "Gestures") return "gesture" + if (currentPage === "Health") return "heart" + if (currentPage === "Logs") return "logs" + if (currentPage.indexOf("rule") >= 0) return "window" + return "document" + } + + 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 === "Variables") + return "Reusable values referenced across binds, rules, and commands." + return "This page is being migrated to the new interface." + } + + 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: root.pageIconName() + 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.configPath.length > 0 + + TextInput { + id: pathText + visible: root.fullPath + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.right: copyPath.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: root.configPath + readOnly: true + selectByMouse: true + color: theme.alpha(theme.textPrimary, 0.80) + font.family: "Inter" + font.pixelSize: 11 + clip: true + } + + Button { + id: copyPath + width: root.fullPath ? 40 : parent.width + height: parent.height + anchors.right: parent.right + padding: 0 + flat: true + hoverEnabled: true + scale: 1 + + contentItem: UiIcon { + width: 18 + height: 18 + anchors.centerIn: parent + name: root.copied ? "check" : "copy" + iconColor: root.copied ? theme.accent : theme.textSecondary + } + + background: Rectangle { + radius: 10 + color: root.copied + ? theme.alpha(theme.accent, 0.12) + : copyPath.hovered ? theme.hoverFill : "transparent" + border.width: root.copied ? 1 : 0 + border.color: theme.alpha(theme.accent, 0.24) + Behavior on color { ColorAnimation { duration: 140 } } + } + + 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.text: root.copied ? "Copied" : "Copy config path" + } + } + + SequentialAnimation { + id: copyPulse + PropertyAnimation { target: copyPath; property: "scale"; to: 0.88; duration: 70; easing.type: Easing.OutCubic } + PropertyAnimation { target: copyPath; property: "scale"; to: 1.08; duration: 110; easing.type: Easing.OutBack } + PropertyAnimation { target: copyPath; property: "scale"; to: 1.0; duration: 110; easing.type: Easing.OutCubic } + } + + Timer { + id: copiedReset + interval: 1100 + 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 + } + } +} From 52b75fac35b1d31059712d72d29246cd581d1d8f Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:30:08 +0400 Subject: [PATCH 013/109] refactor(qml): replace dead learn-more footer with status bar --- qml/StatusBar.qml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 qml/StatusBar.qml diff --git a/qml/StatusBar.qml b/qml/StatusBar.qml new file mode 100644 index 0000000..a2688d2 --- /dev/null +++ b/qml/StatusBar.qml @@ -0,0 +1,41 @@ +import QtQuick + +GlassPanel { + id: root + + property bool darkMode: true + property bool healthy: true + property string message: "Ready." + + HyprbindTheme { id: theme; darkMode: root.darkMode } + + height: 50 + cornerRadius: 11 + darkMode: root.darkMode + 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 + } +} From 104dcd11aa3e786a9c84fd405d61dac4459abd82 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:30:58 +0400 Subject: [PATCH 014/109] refactor(qml): extract responsive Environment page --- qml/EnvironmentPage.qml | 453 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 qml/EnvironmentPage.qml 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) + } + } + } + } + } + } +} From 76952635604906b3d4b6c12ddd1facb7a3ff06a5 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:31:28 +0400 Subject: [PATCH 015/109] refactor(qml): rebuild shell around responsive components --- qml/Main.qml | 1142 ++++++-------------------------------------------- 1 file changed, 124 insertions(+), 1018 deletions(-) diff --git a/qml/Main.qml b/qml/Main.qml index 02f4177..256544a 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -1,46 +1,38 @@ import QtQuick import QtQuick.Controls -import QtQuick.Layouts import QtQuick.Window import dev.hyprbinds.ui ApplicationWindow { id: appWindow + width: 1440 height: 900 - minimumWidth: 1060 - minimumHeight: 700 + minimumWidth: 760 + minimumHeight: 560 visible: true title: "Hyprbind" color: "transparent" flags: Qt.Window | Qt.FramelessWindowHint - readonly property color accent: theme.accent - readonly property color textPrimary: theme.textPrimary - readonly property color textMuted: theme.textSecondary - readonly property color hairline: theme.divider - property string currentPage: "Environment" property string configPath: "" - property var allEnvironment: [] - property string selectedName: "" - property string statusMessage: "Environment variables are loaded from your config and applied when Hyprland starts." + property string statusMessage: "Ready." property bool backendHealthy: true property bool darkMode: true property bool realtimeEnabled: false property bool backupAvailable: false property string backupAge: "" + readonly property int sidebarWidth: width < 900 ? 210 : width < 1180 ? 240 : 286 + readonly property int contentMargin: width < 900 ? 12 : width < 1180 ? 18 : 24 + HyprbindBridge { id: backend } HyprbindTheme { id: theme; darkMode: appWindow.darkMode } - ListModel { id: environmentModel } function parsePayload(raw) { - try { - return JSON.parse(raw) - } catch (error) { - return { ok: false, error: "Invalid response from Rust bridge: " + error } - } + try { return JSON.parse(raw) } + catch (error) { return { ok: false, error: "Invalid Rust bridge response: " + error } } } function loadUiState() { @@ -60,112 +52,39 @@ ApplicationWindow { return } darkMode = next - statusMessage = payload.message || (next ? "Dark glass theme enabled." : "Light glass theme enabled.") - } - - function restoreLastBackup() { - const payload = parsePayload(backend.restoreConfig()) - if (!payload.ok) { - statusMessage = payload.error || "Restore failed." - return - } - loadEnvironment() - loadUiState() - statusMessage = payload.message || "Restored the latest config snapshot." - } - - function loadEnvironment() { - const payload = parsePayload(backend.environmentSnapshot()) - configPath = payload.configPath || "" - allEnvironment = payload.env || [] - backendHealthy = payload.ok === true - selectedName = "" - if (!payload.ok) - statusMessage = payload.error || "Could not load the Hyprland config." - else if (payload.warning) - statusMessage = payload.warning - else - statusMessage = "Environment variables are loaded from your config and applied when Hyprland starts." - rebuildEnvironmentModel() - } - - function rebuildEnvironmentModel() { - 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) - } + statusMessage = payload.message || (next ? "Dark theme enabled." : "Light theme enabled.") } - function selectedEnvironment() { - for (let i = 0; i < allEnvironment.length; ++i) { - if (allEnvironment[i].name === selectedName) - return allEnvironment[i] - } - return null + function toggleRealtime() { + realtimeEnabled = !realtimeEnabled + statusMessage = realtimeEnabled + ? "Live preview enabled on supported pages." + : "Live preview disabled." } - function runAction(raw, closeEditor) { - const payload = parsePayload(raw) - if (!payload.ok) { - editorError.text = payload.error || "The operation failed." - statusMessage = editorError.text - return false - } - statusMessage = payload.message || "Saved." - if (closeEditor) - envEditor.close() - loadEnvironment() - statusMessage = 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) + function reloadCurrentPage() { + loadUiState() + if (pageLoader.item && typeof pageLoader.item.load === "function") { + pageLoader.item.load() 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() + } + statusMessage = "This page does not expose reload yet." } - function deleteSelected() { - if (!selectedName) - return - const deleting = selectedName - const payload = parsePayload(backend.deleteEnvironment(deleting)) + function restoreLastBackup() { + const payload = parsePayload(backend.restoreConfig()) if (!payload.ok) { - statusMessage = payload.error || "Delete failed." + statusMessage = payload.error || "Restore failed." return } - loadEnvironment() - statusMessage = payload.message || ("Deleted " + deleting + ".") - } - - Component.onCompleted: { loadUiState() - loadEnvironment() + if (pageLoader.item && typeof pageLoader.item.load === "function") + pageLoader.item.load() + statusMessage = payload.message || "Restored the latest config snapshot." } + Component.onCompleted: loadUiState() + Rectangle { id: shell anchors.fill: parent @@ -193,396 +112,39 @@ ApplicationWindow { Rectangle { anchors.left: parent.left anchors.right: parent.right - anchors.leftMargin: 30 - anchors.rightMargin: 30 + anchors.leftMargin: 28 + anchors.rightMargin: 28 anchors.top: parent.top anchors.topMargin: 1 height: 1 - radius: 1 - antialiasing: true color: theme.shellInnerLine } - Rectangle { + AppTopBar { id: topBar - height: 55 anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - color: theme.toolbarFill - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 1 - color: hairline - } - - MouseArea { - anchors.fill: parent - anchors.rightMargin: 360 - onPressed: appWindow.startSystemMove() - onDoubleClicked: { - if (appWindow.visibility === Window.Maximized) - appWindow.showNormal() - else - appWindow.showMaximized() - } - } - - Row { - anchors.left: parent.left - anchors.leftMargin: 20 - anchors.verticalCenter: parent.verticalCenter - spacing: 17 - - Text { - text: "Hyprbind" - color: textPrimary - font.family: "Inter" - font.pixelSize: 18 - font.weight: Font.DemiBold - } - Rectangle { - width: 1 - height: 24 - color: hairline - anchors.verticalCenter: parent.verticalCenter - } - Text { - text: "Configure. Bind. Customize." - color: darkMode ? Qt.rgba(0.76, 0.82, 0.86, 0.76) : Qt.rgba(0.20, 0.32, 0.38, 0.68) - font.family: "Inter" - font.pixelSize: 12 - anchors.verticalCenter: parent.verticalCenter - } - } - - Row { - anchors.right: windowControls.left - anchors.rightMargin: 18 - anchors.verticalCenter: parent.verticalCenter - spacing: 13 - - Button { - id: themeButton - implicitHeight: 36 - leftPadding: 10 - rightPadding: 11 - hoverEnabled: true - flat: true - contentItem: Row { - spacing: 8 - UiIcon { - width: 18; height: 18 - name: darkMode ? "moon" : "sun" - iconColor: textPrimary - anchors.verticalCenter: parent.verticalCenter - } - Text { - text: darkMode ? "Dark mode" : "Light mode" - color: textPrimary - font.family: "Inter" - font.pixelSize: 12 - anchors.verticalCenter: parent.verticalCenter - } - } - background: Rectangle { - radius: 9 - color: themeButton.hovered - ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.40)) - : "transparent" - border.width: themeButton.hovered ? 1 : 0 - border.color: hairline - } - onClicked: toggleDarkMode() - ToolTip.visible: hovered - ToolTip.text: "Switch Hyprbind's persisted glass theme" - } - - Rectangle { width: 1; height: 25; color: hairline; anchors.verticalCenter: parent.verticalCenter } - - Button { - id: realtimeButton - implicitHeight: 36 - leftPadding: 10 - rightPadding: 11 - hoverEnabled: true - flat: true - contentItem: Row { - spacing: 8 - UiIcon { - width: 17; height: 17 - name: "realtime" - iconColor: realtimeEnabled ? accent : textMuted - anchors.verticalCenter: parent.verticalCenter - } - Text { - text: "Realtime" - color: realtimeEnabled ? textPrimary : textMuted - font.family: "Inter" - font.pixelSize: 12 - anchors.verticalCenter: parent.verticalCenter - } - Rectangle { - width: 6; height: 6; radius: 3 - color: realtimeEnabled ? accent : Qt.rgba(0.55,0.62,0.66,0.34) - anchors.verticalCenter: parent.verticalCenter - } - } - background: Rectangle { - radius: 9 - color: realtimeButton.hovered - ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.40)) - : "transparent" - border.width: realtimeEnabled ? 1 : 0 - border.color: Qt.rgba(0.25,0.84,0.94,0.22) - } - onClicked: { - realtimeEnabled = !realtimeEnabled - statusMessage = realtimeEnabled - ? "Realtime preview is armed for migrated live-edit pages. Environment still writes on Save." - : "Realtime preview disabled." - } - ToolTip.visible: hovered - ToolTip.text: "Autosave/live preview is used only where a migrated page supports it" - } - - Rectangle { width: 1; height: 25; color: hairline; anchors.verticalCenter: parent.verticalCenter } - - IconButton { - iconName: "history" - darkMode: appWindow.darkMode - implicitWidth: 36 - implicitHeight: 36 - enabled: backupAvailable - onClicked: restoreLastBackup() - ToolTip.visible: hovered - ToolTip.text: backupAvailable - ? (backupAge.length ? "Restore latest snapshot · " + backupAge : "Restore latest snapshot") - : "No Hyprbind snapshot is available yet" - } - IconButton { - iconName: "reload" - darkMode: appWindow.darkMode - implicitWidth: 36 - implicitHeight: 36 - onClicked: { - loadEnvironment() - loadUiState() - statusMessage = "Reloaded config from disk." - } - ToolTip.visible: hovered - ToolTip.text: "Reload config" - } - } - - Row { - id: windowControls - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - - Button { - id: minimizeButton - implicitWidth: 40 - implicitHeight: 36 - flat: true - hoverEnabled: true - contentItem: UiIcon { - width: 16; height: 16 - anchors.centerIn: parent - name: "minimize" - iconColor: textMuted - } - background: Rectangle { - radius: 8 - color: minimizeButton.hovered - ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.38)) - : "transparent" - } - onClicked: appWindow.showMinimized() - } - Button { - id: maximizeButton - implicitWidth: 40 - implicitHeight: 36 - flat: true - hoverEnabled: true - contentItem: UiIcon { - width: 15; height: 15 - anchors.centerIn: parent - name: "maximize" - iconColor: textMuted - } - background: Rectangle { - radius: 8 - color: maximizeButton.hovered - ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.38)) - : "transparent" - } - onClicked: appWindow.visibility === Window.Maximized ? appWindow.showNormal() : appWindow.showMaximized() - } - Button { - id: closeButton - implicitWidth: 40 - implicitHeight: 36 - flat: true - hoverEnabled: true - contentItem: UiIcon { - width: 16; height: 16 - anchors.centerIn: parent - name: "close" - iconColor: closeButton.hovered ? "#ffd9d6" : textMuted - } - background: Rectangle { - radius: 8 - color: closeButton.hovered ? Qt.rgba(0.73,0.20,0.22,0.44) : "transparent" - } - onClicked: appWindow.close() - } - } + appWindow: appWindow + darkMode: appWindow.darkMode + realtimeEnabled: appWindow.realtimeEnabled + backupAvailable: appWindow.backupAvailable + backupAge: appWindow.backupAge + onToggleThemeRequested: appWindow.toggleDarkMode() + onToggleRealtimeRequested: appWindow.toggleRealtime() + onRestoreRequested: appWindow.restoreLastBackup() + onReloadRequested: appWindow.reloadCurrentPage() } - Rectangle { + AppSidebar { id: sidebar - width: 286 + width: appWindow.sidebarWidth anchors.left: parent.left anchors.top: topBar.bottom anchors.bottom: parent.bottom - color: theme.sidebarFill - - Rectangle { - width: 1 - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.right: parent.right - color: hairline - } - - GlassField { - id: navSearch - anchors.top: parent.top - anchors.topMargin: 15 - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 16 - anchors.rightMargin: 16 - implicitHeight: 39 - placeholderText: "Search settings..." - leftPadding: 38 - darkMode: appWindow.darkMode - - UiIcon { - width: 17 - height: 17 - name: "search" - anchors.left: parent.left - anchors.leftMargin: 13 - anchors.verticalCenter: parent.verticalCenter - iconColor: textMuted - } - Text { - text: "Ctrl K" - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - color: darkMode ? Qt.rgba(0.79,0.84,0.87,0.66) : Qt.rgba(0.18,0.31,0.37,0.56) - font.family: "Inter" - font.pixelSize: 10 - } - } - - Flickable { - id: navFlick - anchors.top: navSearch.bottom - anchors.topMargin: 10 - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.bottomMargin: 10 - clip: true - contentHeight: navColumn.implicitHeight - boundsBehavior: Flickable.StopAtBounds - - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - - Column { - id: navColumn - width: navFlick.width - spacing: 3 - - Repeater { - model: [ - { title: "START HERE", items: [ - { name: "Overview", iconName: "home" } - ]}, - { title: "KEYBOARD & CONFIG", items: [ - { name: "Binds", iconName: "keyboard" }, - { name: "Variables", iconName: "braces" }, - { name: "Environment", iconName: "document" }, - { name: "Submaps", iconName: "grid" }, - { name: "Startup", iconName: "power" } - ]}, - { title: "RULES", items: [ - { name: "Window rules", iconName: "window" }, - { name: "Workspace rules", iconName: "grid" }, - { name: "Layer rules", iconName: "layers" } - ]}, - { title: "APPEARANCE & INPUT", items: [ - { name: "Look & Feel", iconName: "diamond" }, - { name: "Config", iconName: "settings" }, - { name: "Monitors", iconName: "monitor" }, - { name: "Devices", iconName: "device" }, - { name: "Animations", iconName: "sparkle" }, - { name: "Curves", iconName: "curve" }, - { name: "Gestures", iconName: "gesture" } - ]}, - { title: "SYSTEM", items: [ - { name: "Health", iconName: "heart" }, - { name: "Logs", iconName: "logs" } - ]} - ] - - delegate: Column { - id: groupColumn - width: navColumn.width - property var groupData: modelData - spacing: 3 - - Item { width: 1; height: index === 0 ? 3 : 9 } - Text { - text: groupColumn.groupData.title - color: darkMode ? Qt.rgba(0.73, 0.79, 0.82, 0.70) : Qt.rgba(0.20, 0.33, 0.39, 0.62) - font.family: "Inter" - font.pixelSize: 10 - font.weight: Font.DemiBold - font.letterSpacing: 1.25 - leftPadding: 22 - height: 22 - verticalAlignment: Text.AlignVCenter - } - Repeater { - model: groupColumn.groupData.items - delegate: NavItem { - property var itemData: modelData - width: groupColumn.width - 28 - anchors.horizontalCenter: parent.horizontalCenter - text: itemData.name - iconName: itemData.iconName - darkMode: appWindow.darkMode - selected: appWindow.currentPage === itemData.name - visible: !navSearch.text || itemData.name.toLowerCase().indexOf(navSearch.text.toLowerCase()) >= 0 - height: visible ? implicitHeight : 0 - onClicked: appWindow.currentPage = itemData.name - } - } - } - } - } - } + darkMode: appWindow.darkMode + currentPage: appWindow.currentPage + onPageSelected: page => appWindow.currentPage = page } Item { @@ -592,572 +154,116 @@ ApplicationWindow { anchors.top: topBar.bottom anchors.bottom: parent.bottom - Item { + PageHeader { id: pageHeader - height: 94 anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.leftMargin: 25 - anchors.rightMargin: 25 - - GlassPanel { - id: pageIcon - width: 52 - height: 52 - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - cornerRadius: 12 - darkMode: appWindow.darkMode - elevated: false - tint: accent - border.color: theme.alpha(accent, 0.14) - - Rectangle { - anchors.fill: parent - anchors.margins: 8 - radius: 10 - color: theme.alpha(theme.mix(theme.surfaceHigh, accent, 0.08), darkMode ? 0.12 : 0.18) - } - UiIcon { - anchors.centerIn: parent - width: 24 - height: 24 - name: appWindow.currentPage === "Environment" ? "terminal" - : appWindow.currentPage === "Look & Feel" ? "diamond" : "document" - iconColor: (appWindow.currentPage === "Environment" || appWindow.currentPage === "Look & Feel") ? accent : textMuted - strokeWidth: 1.8 - } - } - - Column { - anchors.left: pageIcon.right - anchors.leftMargin: 18 - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - - Text { - text: appWindow.currentPage - color: textPrimary - font.family: "Inter" - font.pixelSize: 25 - font.weight: Font.DemiBold - } - Text { - text: appWindow.currentPage === "Environment" - ? "Manage environment variables for Hyprland and apps launched in your session." - : appWindow.currentPage === "Look & Feel" - ? "Shape Hyprland’s spacing, transparency, effects, and window borders." - : "This page remains available in the GTK frontend while the QML migration is in progress." - color: textMuted - font.family: "Inter" - font.pixelSize: 12 - } - } - - GlassPanel { - id: pathPanel - width: 320 - height: 43 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - darkMode: appWindow.darkMode - elevated: false - - TextInput { - id: pathText - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.right: copyPath.left - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: configPath || "No config loaded" - readOnly: true - selectByMouse: true - color: configPath - ? (darkMode ? Qt.rgba(0.82, 0.88, 0.91, 0.84) : Qt.rgba(0.16, 0.29, 0.36, 0.82)) - : textMuted - font.family: "Inter" - font.pixelSize: 11 - clip: true - } - Button { - id: copyPath - width: 40 - height: parent.height - anchors.right: parent.right - flat: true - enabled: configPath.length > 0 - contentItem: UiIcon { - width: 17 - height: 17 - anchors.centerIn: parent - name: "copy" - iconColor: copyPath.enabled ? textMuted : Qt.rgba(0.55,0.62,0.66,0.28) - } - background: Rectangle { - color: copyPath.hovered - ? (darkMode ? Qt.rgba(1,1,1,0.055) : Qt.rgba(1,1,1,0.34)) - : "transparent" - radius: 9 - } - onClicked: { - pathText.selectAll() - pathText.copy() - pathText.deselect() - statusMessage = "Copied config path." - } - ToolTip.visible: hovered - ToolTip.text: "Copy config path" - } - } - } - - Item { - id: environmentToolbar - height: appWindow.currentPage === "Environment" ? 47 : 0 - visible: appWindow.currentPage === "Environment" - anchors.left: parent.left - anchors.right: parent.right - anchors.top: pageHeader.bottom - anchors.leftMargin: 25 - anchors.rightMargin: 25 - - GlassField { - id: envSearch - anchors.left: parent.left - anchors.right: toolbarButtons.left - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - implicitHeight: 43 - placeholderText: "Filter environment variables..." - leftPadding: 42 - darkMode: appWindow.darkMode - onTextChanged: rebuildEnvironmentModel() - - UiIcon { - width: 17 - height: 17 - name: "search" - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - iconColor: textMuted - } - } - - Row { - id: toolbarButtons - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 9 - - IconButton { - iconName: "plus" - darkMode: appWindow.darkMode - accent: true - onClicked: openAddEditor() - ToolTip.visible: hovered - ToolTip.text: "Add environment variable" - } - IconButton { - iconName: "edit" - darkMode: appWindow.darkMode - enabled: selectedName.length > 0 - onClicked: openEditEditor() - ToolTip.visible: hovered - ToolTip.text: "Edit selected variable" - } - IconButton { - iconName: "trash" - darkMode: appWindow.darkMode - danger: true - enabled: selectedName.length > 0 - onClicked: deleteSelected() - ToolTip.visible: hovered - ToolTip.text: "Delete selected variable" - } - } + currentPage: appWindow.currentPage + configPath: appWindow.configPath + darkMode: appWindow.darkMode + onStatus: message => appWindow.statusMessage = message } - Rectangle { - id: contentHalo + StatusBar { + id: statusBar anchors.left: parent.left anchors.right: parent.right - anchors.top: appWindow.currentPage === "Environment" ? environmentToolbar.bottom : pageHeader.bottom - anchors.bottom: infoPanel.top - anchors.leftMargin: 22 - anchors.rightMargin: 22 - anchors.topMargin: appWindow.currentPage === "Environment" ? 9 : -1 - anchors.bottomMargin: 11 - radius: 17 - color: "transparent" - border.width: 1 - border.color: Qt.rgba(0.25, 0.84, 0.94, darkMode ? 0.055 : 0.075) + 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: appWindow.currentPage === "Environment" ? environmentToolbar.bottom : pageHeader.bottom - anchors.bottom: infoPanel.top - anchors.leftMargin: 25 - anchors.rightMargin: 25 - anchors.topMargin: appWindow.currentPage === "Environment" ? 12 : 2 - anchors.bottomMargin: 14 + anchors.top: pageHeader.bottom + anchors.bottom: statusBar.top + anchors.leftMargin: appWindow.contentMargin + anchors.rightMargin: appWindow.contentMargin + anchors.topMargin: 0 + anchors.bottomMargin: 10 cornerRadius: 14 darkMode: appWindow.darkMode elevated: false - Item { - anchors.fill: parent - visible: appWindow.currentPage === "Environment" - - ListView { - id: environmentList - anchors.fill: parent - anchors.margins: 12 - clip: true - model: environmentModel - spacing: 7 - 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: 69 - radius: 10 - color: selectedName === name - ? theme.selectedFill - : rowMouse.containsMouse ? theme.hoverFill : "transparent" - border.width: selectedName === name ? 1 : 0 - border.color: theme.selectedRim - - Rectangle { - visible: 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: 16 - anchors.right: parent.right - anchors.rightMargin: 16 - anchors.verticalCenter: parent.verticalCenter - spacing: 3 - Text { - text: name - color: textPrimary - font.family: "Inter" - font.pixelSize: 13 - font.weight: Font.Medium - } - Text { - text: value - color: theme.alpha(theme.textSecondary, 0.88) - font.family: "Inter" - font.pixelSize: 11 - elide: Text.ElideRight - width: parent.width - } - Text { - text: sourceFile ? sourceFile.split("/").pop() + ":" + sourceLine : "managed config" - color: theme.alpha(theme.textSecondary, 0.58) - font.family: "Inter" - font.pixelSize: 9 - } - } - - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - onClicked: selectedName = name - onDoubleClicked: { - selectedName = name - openEditEditor() - } - } - } - } - - Column { - anchors.centerIn: parent - width: Math.min(parent.width - 80, 520) - spacing: 14 - visible: environmentModel.count === 0 - - GlassPanel { - width: 76 - height: 76 - cornerRadius: 38 - anchors.horizontalCenter: parent.horizontalCenter - darkMode: appWindow.darkMode - elevated: true - border.color: theme.controlRim - - Rectangle { - anchors.centerIn: parent - width: 48 - height: 48 - radius: 24 - color: theme.alpha(theme.accent, darkMode ? 0.035 : 0.065) - } - UiIcon { - anchors.centerIn: parent - width: 28 - height: 28 - name: "document" - iconColor: darkMode ? "#dbe8ee" : "#34505c" - strokeWidth: 1.55 - } - } - Item { width: 1; height: 2 } - Text { - text: allEnvironment.length === 0 - ? "No environment variables yet" - : "No matching environment variables" - color: textPrimary - font.family: "Inter" - font.pixelSize: 19 - font.weight: Font.DemiBold - anchors.horizontalCenter: parent.horizontalCenter - } - Text { - width: parent.width - text: allEnvironment.length === 0 - ? "Environment variables let you define key-value pairs for Hyprland and apps launched in your session." - : "Try a different filter." - color: textMuted - font.family: "Inter" - font.pixelSize: 12 - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - } - Item { width: 1; height: 2 } - GlassButton { - visible: allEnvironment.length === 0 - text: "Add your first variable" - darkMode: appWindow.darkMode - accent: true - anchors.horizontalCenter: parent.horizontalCenter - onClicked: openAddEditor() - } - } - } - Loader { - id: lookFeelLoader + id: pageLoader anchors.fill: parent - active: appWindow.currentPage === "Look & Feel" - sourceComponent: Component { - LookFeelPage { - bridge: backend - darkMode: appWindow.darkMode - realtimeEnabled: appWindow.realtimeEnabled - onStatus: message => appWindow.statusMessage = message - onSaved: { - appWindow.loadUiState() - appWindow.loadEnvironment() - } - } - } + anchors.margins: appWindow.currentPage === "Look & Feel" ? 0 : 14 + sourceComponent: appWindow.currentPage === "Environment" + ? environmentComponent + : appWindow.currentPage === "Look & Feel" + ? lookFeelComponent + : placeholderComponent } + } + } + } - Column { - anchors.centerIn: parent - spacing: 12 - visible: appWindow.currentPage !== "Environment" && appWindow.currentPage !== "Look & Feel" - Text { - text: "QML migration in progress" - color: textPrimary - font.family: "Inter" - font.pixelSize: 19 - font.weight: Font.DemiBold - anchors.horizontalCenter: parent.horizontalCenter - } - Text { - width: 430 - text: "Use the existing GTK frontend for " + appWindow.currentPage + " while this page is ported." - color: textMuted - font.family: "Inter" - font.pixelSize: 12 - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - } - } + 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: lookFeelComponent + LookFeelPage { + bridge: backend + darkMode: appWindow.darkMode + realtimeEnabled: appWindow.realtimeEnabled + onStatus: message => appWindow.statusMessage = message + onSaved: { + appWindow.loadUiState() + appWindow.statusMessage = "Look & Feel saved." } + } + } - GlassPanel { - id: infoPanel - height: 58 - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.leftMargin: 25 - anchors.rightMargin: 25 - anchors.bottomMargin: 18 - cornerRadius: 11 - darkMode: appWindow.darkMode - elevated: false + Component { + id: placeholderComponent + Item { + Column { + anchors.centerIn: parent + width: Math.min(parent.width - 40, 460) + spacing: 12 UiIcon { - anchors.left: parent.left - anchors.leftMargin: 17 - anchors.verticalCenter: parent.verticalCenter - width: 18 - height: 18 - name: "info" - iconColor: backendHealthy - ? (darkMode ? "#dce6eb" : "#38535f") - : (darkMode ? "#e3aaa7" : "#ad4c49") + width: 28 + height: 28 + anchors.horizontalCenter: parent.horizontalCenter + name: pageHeader.pageIconName() + iconColor: theme.alpha(theme.textSecondary, 0.72) } + Text { - anchors.left: parent.left - anchors.leftMargin: 46 - anchors.right: learnMore.left - anchors.rightMargin: 16 - anchors.verticalCenter: parent.verticalCenter - text: statusMessage - color: theme.alpha(theme.textSecondary, 0.90) + text: appWindow.currentPage + color: theme.textPrimary font.family: "Inter" - font.pixelSize: 11 - elide: Text.ElideRight + font.pixelSize: 19 + font.weight: Font.DemiBold + anchors.horizontalCenter: parent.horizontalCenter } + Text { - id: learnMore - anchors.right: parent.right - anchors.rightMargin: 18 - anchors.verticalCenter: parent.verticalCenter - text: "Learn more ↗" - color: accent - opacity: 0.82 + width: parent.width + text: "This page is next in the QML migration. The existing GTK frontend still owns its behavior for now." + color: theme.textSecondary font.family: "Inter" - font.pixelSize: 11 - } - } - } - } - - Popup { - id: envEditor - property bool editing: false - property string originalName: "" - width: 470 - height: 326 - x: Math.round((appWindow.width - width) / 2) - y: Math.round((appWindow.height - height) / 2) - modal: true - focus: true - closePolicy: Popup.CloseOnEscape - padding: 0 - - Overlay.modal: Rectangle { color: theme.alpha(theme.background, darkMode ? 0.46 : 0.24) } - - background: Rectangle { - radius: 18 - color: theme.alpha(theme.familyShell, darkMode ? 0.92 : 0.95) - 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: textPrimary - font.family: "Inter" - font.pixelSize: 20 - font.weight: Font.DemiBold - } - Text { - text: "Changes are written through Hyprbind’s existing Rust writer and backup path." - color: textMuted - font.family: "Inter" - font.pixelSize: 11 - } - GlassField { - id: editorName - width: parent.width - darkMode: appWindow.darkMode - placeholderText: "Name — e.g. XCURSOR_SIZE" - } - GlassField { - id: editorValue - width: parent.width - darkMode: appWindow.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: appWindow.darkMode - onClicked: envEditor.close() - } - GlassButton { - id: editorSave - text: envEditor.editing ? "Save" : "Add" - darkMode: appWindow.darkMode - accent: true - enabled: editorName.text.trim().length > 0 - onClicked: { - editorError.text = "" - if (envEditor.editing) { - runAction( - backend.editEnvironment(envEditor.originalName, editorName.text, editorValue.text), - true - ) - } else { - runAction(backend.addEnvironment(editorName.text, editorValue.text), true) - } - } + font.pixelSize: 12 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter } } } From f21f6a5c98e71034bd21fd2822526683cb62c6cb Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:32:11 +0400 Subject: [PATCH 016/109] refine(qml): add copy success check icon --- qml/UiIcon.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qml/UiIcon.qml b/qml/UiIcon.qml index a377cf5..b88e3fc 100644 --- a/qml/UiIcon.qml +++ b/qml/UiIcon.qml @@ -36,6 +36,8 @@ Canvas { 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") { From 9f6eb68a79f8042dfc432ed042025b99e45c01af Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:32:23 +0400 Subject: [PATCH 017/109] build(qml): register responsive shell components --- build.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.rs b/build.rs index 0e8b7d0..cd3ce4c 100644 --- a/build.rs +++ b/build.rs @@ -5,6 +5,11 @@ fn main() { QmlModule::new("dev.hyprbinds.ui") .qml_files([ "qml/Main.qml", + "qml/AppTopBar.qml", + "qml/AppSidebar.qml", + "qml/PageHeader.qml", + "qml/StatusBar.qml", + "qml/EnvironmentPage.qml", "qml/GlassPanel.qml", "qml/GlassField.qml", "qml/GlassButton.qml", From a6c3138146283099cfd282c67d2892ce5816dbef Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:34:10 +0400 Subject: [PATCH 018/109] feat(qml): expose variable CRUD through Rust bridge --- src/qml_bridge.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/qml_bridge.rs b/src/qml_bridge.rs index 23f170d..ccca7b8 100644 --- a/src/qml_bridge.rs +++ b/src/qml_bridge.rs @@ -57,6 +57,27 @@ mod qobject { #[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; } } @@ -270,6 +291,96 @@ impl qobject::HyprbindBridge { 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 { From 27b2583d6e2156bb18f801c23853b051037957d0 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:34:49 +0400 Subject: [PATCH 019/109] feat(qml): add Variables page --- qml/VariablesPage.qml | 458 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 qml/VariablesPage.qml 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) + } + } + } + } + } + } +} From f93a5c47f966fffb435db80ab60da96a2511f3f6 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:35:25 +0400 Subject: [PATCH 020/109] feat(qml): wire Variables page into responsive shell --- qml/Main.qml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/qml/Main.qml b/qml/Main.qml index 256544a..3bd64d7 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -198,9 +198,11 @@ ApplicationWindow { anchors.margins: appWindow.currentPage === "Look & Feel" ? 0 : 14 sourceComponent: appWindow.currentPage === "Environment" ? environmentComponent - : appWindow.currentPage === "Look & Feel" - ? lookFeelComponent - : placeholderComponent + : appWindow.currentPage === "Variables" + ? variablesComponent + : appWindow.currentPage === "Look & Feel" + ? lookFeelComponent + : placeholderComponent } } } @@ -217,6 +219,17 @@ ApplicationWindow { } } + 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: lookFeelComponent LookFeelPage { From 9db3463210951c22e960ba54a03efec849c5efd3 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:35:39 +0400 Subject: [PATCH 021/109] build(qml): register Variables page --- build.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/build.rs b/build.rs index cd3ce4c..03090d0 100644 --- a/build.rs +++ b/build.rs @@ -10,6 +10,7 @@ fn main() { "qml/PageHeader.qml", "qml/StatusBar.qml", "qml/EnvironmentPage.qml", + "qml/VariablesPage.qml", "qml/GlassPanel.qml", "qml/GlassField.qml", "qml/GlassButton.qml", From 6fc724d5a9ce1dcd7fd03ad9100f4586d32cd9f2 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:36:01 +0400 Subject: [PATCH 022/109] feat(qml): add reusable overview summary card --- qml/SummaryCard.qml | 85 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 qml/SummaryCard.qml diff --git a/qml/SummaryCard.qml b/qml/SummaryCard.qml new file mode 100644 index 0000000..f075cf6 --- /dev/null +++ b/qml/SummaryCard.qml @@ -0,0 +1,85 @@ +import QtQuick + +GlassPanel { + id: root + + property bool darkMode: true + 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 + darkMode: root.darkMode + 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 + } +} From b6c9c8c8168281b3d28a9c5613a7536792d5a201 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:36:22 +0400 Subject: [PATCH 023/109] feat(qml): add functional Overview page --- qml/OverviewPage.qml | 174 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 qml/OverviewPage.qml diff --git a/qml/OverviewPage.qml b/qml/OverviewPage.qml new file mode 100644 index 0000000..e8f68b0 --- /dev/null +++ b/qml/OverviewPage.qml @@ -0,0 +1,174 @@ +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 + + 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 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + ColumnLayout { + id: contentColumn + width: parent.width + spacing: 14 + + GridLayout { + Layout.fillWidth: true + columns: root.width >= 760 ? 2 : 1 + 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: "diamond" + title: "WINDOW MATERIAL" + value: root.blurEnabled ? "Blur on" : "Blur off" + subtitle: "Rounding " + root.rounding + " · inactive opacity " + Number(root.inactiveOpacity).toFixed(2) + } + + SummaryCard { + Layout.fillWidth: true + darkMode: root.darkMode + iconName: "history" + title: "LATEST SNAPSHOT" + value: root.backupAvailable ? "Available" : "None yet" + subtitle: root.backupAvailable + ? (root.backupAge.length ? root.backupAge : "A restore point is available") + : "A snapshot appears after Hyprbind writes config" + } + } + + GlassPanel { + Layout.fillWidth: true + Layout.preferredHeight: 116 + darkMode: root.darkMode + cornerRadius: 15 + elevated: false + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 15 + + Rectangle { + width: 42 + height: 42 + radius: 13 + color: root.healthy ? theme.alpha(theme.accent, 0.07) : theme.dangerFill + border.width: 1 + border.color: root.healthy ? theme.alpha(theme.accent, 0.14) : theme.dangerRim + + UiIcon { + anchors.centerIn: parent + width: 20 + height: 20 + name: root.healthy ? "check" : "info" + iconColor: root.healthy ? theme.accent : theme.danger + } + } + + Column { + width: parent.width - 57 + spacing: 4 + Text { + width: parent.width + text: root.healthy ? "Configuration loaded cleanly" : "Configuration needs attention" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + width: parent.width + text: root.configPath.length ? root.configPath : "Hyprbind could not resolve the config path." + color: theme.alpha(theme.textSecondary, 0.80) + font.family: "Inter" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + } + } + } + } + } +} From 06560d67b2eb061d7516317444d33d445f177280 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:36:49 +0400 Subject: [PATCH 024/109] feat(qml): wire Overview page into responsive shell --- qml/Main.qml | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/qml/Main.qml b/qml/Main.qml index 3bd64d7..ebdc86e 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -196,18 +196,31 @@ ApplicationWindow { id: pageLoader anchors.fill: parent anchors.margins: appWindow.currentPage === "Look & Feel" ? 0 : 14 - sourceComponent: appWindow.currentPage === "Environment" - ? environmentComponent - : appWindow.currentPage === "Variables" - ? variablesComponent - : appWindow.currentPage === "Look & Feel" - ? lookFeelComponent - : placeholderComponent + sourceComponent: appWindow.currentPage === "Overview" + ? overviewComponent + : appWindow.currentPage === "Environment" + ? environmentComponent + : appWindow.currentPage === "Variables" + ? variablesComponent + : appWindow.currentPage === "Look & Feel" + ? lookFeelComponent + : placeholderComponent } } } } + 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: environmentComponent EnvironmentPage { From 599d1233cdcddcd6138c93f3e79ebd57e10e0422 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:37:01 +0400 Subject: [PATCH 025/109] build(qml): register Overview components --- build.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.rs b/build.rs index 03090d0..4f2a78b 100644 --- a/build.rs +++ b/build.rs @@ -9,6 +9,8 @@ fn main() { "qml/AppSidebar.qml", "qml/PageHeader.qml", "qml/StatusBar.qml", + "qml/OverviewPage.qml", + "qml/SummaryCard.qml", "qml/EnvironmentPage.qml", "qml/VariablesPage.qml", "qml/GlassPanel.qml", From 5be5a8d2dd0bb8bfe3fa6b7ba18c08a2032f3359 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:38:06 +0400 Subject: [PATCH 026/109] feat(qml): add Startup bridge --- src/qml_startup_bridge.rs | 154 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/qml_startup_bridge.rs diff --git a/src/qml_startup_bridge.rs b/src/qml_startup_bridge.rs new file mode 100644 index 0000000..3c503d3 --- /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!({ + "id": 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() })) +} From 4f5bc1988ea4d90a1b0528cd03c98ae6bfd73c9a Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:38:45 +0400 Subject: [PATCH 027/109] feat(qml): add Startup page --- qml/StartupPage.qml | 525 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 qml/StartupPage.qml diff --git a/qml/StartupPage.qml b/qml/StartupPage.qml new file mode 100644 index 0000000..695330c --- /dev/null +++ b/qml/StartupPage.qml @@ -0,0 +1,525 @@ +import QtQuick +import QtQuick.Controls + +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].id === 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.id + 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 id + 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 === id + ? theme.selectedFill + : rowMouse.containsMouse ? theme.hoverFill : "transparent" + border.width: root.selectedId === id ? 1 : 0 + border.color: theme.selectedRim + + Rectangle { + visible: root.selectedId === id + 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 = id + onDoubleClicked: { + root.selectedId = id + 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 + ) + } + } + } + } + } + } +} From ea503ed2656b9483dea9657d58c1ce0960ef7ed6 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:38:57 +0400 Subject: [PATCH 028/109] build(qml): register Startup page bridge --- build.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.rs b/build.rs index 4f2a78b..f121360 100644 --- a/build.rs +++ b/build.rs @@ -13,6 +13,7 @@ fn main() { "qml/SummaryCard.qml", "qml/EnvironmentPage.qml", "qml/VariablesPage.qml", + "qml/StartupPage.qml", "qml/GlassPanel.qml", "qml/GlassField.qml", "qml/GlassButton.qml", @@ -30,7 +31,7 @@ fn main() { .depend("QtQuick.Controls") .depend("QtQuick.Layouts"), ) - .files(["src/qml_bridge.rs"]) + .files(["src/qml_bridge.rs", "src/qml_startup_bridge.rs"]) .qt_module("Quick") .qt_module("QuickControls2") .build(); From 973e28d16ae93e400c9a53f9f865582260bd1f2c Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:39:35 +0400 Subject: [PATCH 029/109] refine(qml): harden compact path copy and page subtitles --- qml/PageHeader.qml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml index 73f6843..4679e80 100644 --- a/qml/PageHeader.qml +++ b/qml/PageHeader.qml @@ -47,6 +47,8 @@ Item { return "A quick view of your configuration and Hyprbind state." if (currentPage === "Variables") return "Reusable values referenced across binds, rules, and commands." + if (currentPage === "Startup") + return "Choose which commands run when Hyprland starts, reloads, or shuts down." return "This page is being migrated to the new interface." } @@ -93,15 +95,13 @@ Item { TextInput { id: pathText - visible: root.fullPath - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.right: copyPath.left - anchors.rightMargin: 8 + 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: true + selectByMouse: root.fullPath + opacity: root.fullPath ? 1 : 0 color: theme.alpha(theme.textPrimary, 0.80) font.family: "Inter" font.pixelSize: 11 From 89c2735363fcbd835a11539cdd653ecd33beae09 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:41:59 +0400 Subject: [PATCH 030/109] feat(qml): wire Startup page into responsive shell --- qml/Main.qml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/qml/Main.qml b/qml/Main.qml index ebdc86e..62bfac7 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -202,9 +202,11 @@ ApplicationWindow { ? environmentComponent : appWindow.currentPage === "Variables" ? variablesComponent - : appWindow.currentPage === "Look & Feel" - ? lookFeelComponent - : placeholderComponent + : appWindow.currentPage === "Startup" + ? startupComponent + : appWindow.currentPage === "Look & Feel" + ? lookFeelComponent + : placeholderComponent } } } @@ -243,6 +245,16 @@ ApplicationWindow { } } + Component { + id: startupComponent + StartupPage { + darkMode: appWindow.darkMode + onStatus: message => appWindow.statusMessage = message + onConfigResolved: path => appWindow.configPath = path + onHealthChanged: healthy => appWindow.backendHealthy = healthy + } + } + Component { id: lookFeelComponent LookFeelPage { From adee64979caff10e42130f0eac6e45989fd3632c Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:42:32 +0400 Subject: [PATCH 031/109] feat(qml): add Submaps bridge --- src/qml_submaps_bridge.rs | 110 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/qml_submaps_bridge.rs diff --git a/src/qml_submaps_bridge.rs b/src/qml_submaps_bridge.rs new file mode 100644 index 0000000..03d33c1 --- /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!({ + "id": 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() })) +} From 1e8d341cb07b951068196532d2d5be6aade416ec Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:43:10 +0400 Subject: [PATCH 032/109] feat(qml): add Submaps page --- qml/SubmapsPage.qml | 446 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 qml/SubmapsPage.qml diff --git a/qml/SubmapsPage.qml b/qml/SubmapsPage.qml new file mode 100644 index 0000000..68152c8 --- /dev/null +++ b/qml/SubmapsPage.qml @@ -0,0 +1,446 @@ +import QtQuick +import QtQuick.Controls + +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].id === 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 id + 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 === id + ? theme.selectedFill + : rowMouse.containsMouse ? theme.hoverFill : "transparent" + border.width: root.selectedId === id ? 1 : 0 + border.color: theme.selectedRim + + Rectangle { + visible: root.selectedId === id + 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 = id + } + } + } + + 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() + } + } + } + } +} From bf6254795ea50c2f1b248ae942d52df0119c01b4 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:43:30 +0400 Subject: [PATCH 033/109] build(qml): register Submaps page bridge --- build.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build.rs b/build.rs index f121360..520bc09 100644 --- a/build.rs +++ b/build.rs @@ -14,6 +14,7 @@ fn main() { "qml/EnvironmentPage.qml", "qml/VariablesPage.qml", "qml/StartupPage.qml", + "qml/SubmapsPage.qml", "qml/GlassPanel.qml", "qml/GlassField.qml", "qml/GlassButton.qml", @@ -31,7 +32,11 @@ fn main() { .depend("QtQuick.Controls") .depend("QtQuick.Layouts"), ) - .files(["src/qml_bridge.rs", "src/qml_startup_bridge.rs"]) + .files([ + "src/qml_bridge.rs", + "src/qml_startup_bridge.rs", + "src/qml_submaps_bridge.rs", + ]) .qt_module("Quick") .qt_module("QuickControls2") .build(); From 295e5454bede65f0b8577a674bd6e13f0e1625ac Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:43:58 +0400 Subject: [PATCH 034/109] feat(qml): wire Submaps page into responsive shell --- qml/Main.qml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/qml/Main.qml b/qml/Main.qml index 62bfac7..ed2db27 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -202,11 +202,13 @@ ApplicationWindow { ? environmentComponent : appWindow.currentPage === "Variables" ? variablesComponent - : appWindow.currentPage === "Startup" - ? startupComponent - : appWindow.currentPage === "Look & Feel" - ? lookFeelComponent - : placeholderComponent + : appWindow.currentPage === "Submaps" + ? submapsComponent + : appWindow.currentPage === "Startup" + ? startupComponent + : appWindow.currentPage === "Look & Feel" + ? lookFeelComponent + : placeholderComponent } } } @@ -245,6 +247,16 @@ ApplicationWindow { } } + 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 { From ced591c26c99976106cb777a29311cab15409059 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:44:28 +0400 Subject: [PATCH 035/109] refine(qml): add Submaps page description --- qml/PageHeader.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml index 4679e80..a445a2d 100644 --- a/qml/PageHeader.qml +++ b/qml/PageHeader.qml @@ -47,6 +47,8 @@ Item { return "A quick view of your configuration and Hyprbind state." 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." return "This page is being migrated to the new interface." From c76dad61c34e9bf0790902850234641ece19461a Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:45:01 +0400 Subject: [PATCH 036/109] feat(qml): expose health diagnostics to QML --- src/qml_health_bridge.rs | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/qml_health_bridge.rs diff --git a/src/qml_health_bridge.rs b/src/qml_health_bridge.rs new file mode 100644 index 0000000..1bec240 --- /dev/null +++ b/src/qml_health_bridge.rs @@ -0,0 +1,61 @@ +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; + } +} + +#[derive(Default)] +pub struct HealthBridgeRust; + +impl qobject::HealthBridge { + 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 failures = checks + .iter() + .filter(|item| item.severity == crate::health::Severity::Fail) + .count(); + let warnings = checks + .iter() + .filter(|item| item.severity == crate::health::Severity::Warn) + .count(); + to_qstring(json!({ + "ok": true, + "checks": rows, + "failures": failures, + "warnings": warnings, + "total": checks.len(), + })) + } +} + +fn to_qstring(value: serde_json::Value) -> QString { + QString::from(value.to_string()) +} From 7d7f00c4d56522568e1970b61f80ea75ed960f14 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:55:38 +0400 Subject: [PATCH 037/109] feat(qml): add responsive health diagnostics page --- qml/HealthPage.qml | 325 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 qml/HealthPage.qml diff --git a/qml/HealthPage.qml b/qml/HealthPage.qml new file mode 100644 index 0000000..a37dc99 --- /dev/null +++ b/qml/HealthPage.qml @@ -0,0 +1,325 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + property bool darkMode: true + property var allChecks: [] + property string severityFilter: "ALL" + property int total: 0 + property int warnings: 0 + property int failures: 0 + readonly property bool compactToolbar: width < 760 + + signal status(string message) + signal healthChanged(bool healthy) + + HyprbindTheme { id: theme; darkMode: root.darkMode } + HealthBridge { id: healthBridge } + ListModel { id: checkModel } + + 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 + warnings = 0 + failures = 1 + healthChanged(false) + status(payload.error || "Could not run health diagnostics.") + rebuildModel() + return + } + allChecks = payload.checks || [] + total = payload.total || allChecks.length + 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 + } + + Component.onCompleted: load() + + Column { + anchors.fill: parent + spacing: 12 + + Grid { + id: summaryGrid + width: parent.width + columns: root.width >= 760 ? 3 : 1 + spacing: 10 + + SummaryCard { + width: summaryGrid.columns === 3 ? (summaryGrid.width - 20) / 3 : summaryGrid.width + darkMode: root.darkMode + iconName: "heart" + title: "CHECKS" + value: root.total.toString() + subtitle: "Desktop and session diagnostics" + accent: root.failures === 0 + } + SummaryCard { + width: summaryGrid.columns === 3 ? (summaryGrid.width - 20) / 3 : summaryGrid.width + darkMode: root.darkMode + iconName: "info" + title: "WARNINGS" + value: root.warnings.toString() + subtitle: root.warnings === 1 ? "One item needs attention" : "Items worth reviewing" + } + SummaryCard { + width: summaryGrid.columns === 3 ? (summaryGrid.width - 20) / 3 : summaryGrid.width + 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 { + 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 + width: parent.width + height: parent.height - y + 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: healthList.width + height: contentColumn.implicitHeight + 24 + radius: 12 + color: hoverHandler.hovered ? theme.hoverFill : "transparent" + border.width: 1 + border.color: theme.divider + + HoverHandler { id: hoverHandler } + + Column { + id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 14 + anchors.rightMargin: 14 + anchors.topMargin: 12 + spacing: 5 + + Row { + width: parent.width + spacing: 9 + + Text { + text: category.toUpperCase() + color: theme.alpha(theme.textSecondary, 0.66) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 0.7 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + width: Math.max(0, parent.width - severityBadge.width - 90) + text: title + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + id: severityBadge + width: severityText.implicitWidth + 16 + height: 24 + radius: 8 + color: theme.alpha(root.severityColor(severity), 0.08) + border.width: 1 + border.color: theme.alpha(root.severityColor(severity), 0.20) + anchors.verticalCenter: parent.verticalCenter + + 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 + } + } + } + + Text { + width: parent.width + text: detail + color: theme.alpha(theme.textSecondary, 0.88) + font.family: "Inter" + font.pixelSize: 11 + wrapMode: Text.Wrap + } + + Text { + visible: fixHint.length > 0 + width: parent.width + text: fixHint + color: theme.alpha(theme.textSecondary, 0.68) + font.family: "Inter" + font.pixelSize: 10 + wrapMode: Text.Wrap + } + + GlassPanel { + visible: fixCommand.length > 0 + width: parent.width + height: 36 + cornerRadius: 9 + darkMode: root.darkMode + elevated: false + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 11 + anchors.rightMargin: 11 + 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 + 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 { + text: "No diagnostics match this filter" + color: theme.textPrimary + font.family: "Inter" + font.pixelSize: 16 + font.weight: Font.Medium + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + } +} From 759d510bac37b0bdf5916d4972794654ea4945ed Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:55:49 +0400 Subject: [PATCH 038/109] feat(qml): expose journal logs to QML --- src/qml_logs_bridge.rs | 64 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/qml_logs_bridge.rs 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()) +} From baa04985083aa2698f13492ae5539c2310063133 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:56:23 +0400 Subject: [PATCH 039/109] feat(qml): add responsive journal logs page --- qml/LogsPage.qml | 277 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 qml/LogsPage.qml diff --git a/qml/LogsPage.qml b/qml/LogsPage.qml new file mode 100644 index 0000000..c76c53f --- /dev/null +++ b/qml/LogsPage.qml @@ -0,0 +1,277 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + property bool darkMode: true + property var allEntries: [] + property string severityFilter: "ALL" + property string loadError: "" + readonly property bool compactToolbar: width < 820 + + 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({ + id: 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 + + GlassField { + id: searchField + anchors.left: parent.left + anchors.right: root.compactToolbar ? parent.right : filterFlow.left + anchors.rightMargin: root.compactToolbar ? 0 : 10 + anchors.top: parent.top + 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 + } + } + + Flow { + id: filterFlow + width: root.compactToolbar ? parent.width : implicitWidth + 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: "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() + } + } + } + } + } + + 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 id + 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.Wrap : 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 + } + } + } +} From 956813e2c0eb5d4939eb164239ee44524b180fef Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:56:34 +0400 Subject: [PATCH 040/109] build(qml): register health and logs pages --- build.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.rs b/build.rs index 520bc09..282e8ad 100644 --- a/build.rs +++ b/build.rs @@ -15,6 +15,8 @@ fn main() { "qml/VariablesPage.qml", "qml/StartupPage.qml", "qml/SubmapsPage.qml", + "qml/HealthPage.qml", + "qml/LogsPage.qml", "qml/GlassPanel.qml", "qml/GlassField.qml", "qml/GlassButton.qml", @@ -36,6 +38,8 @@ fn main() { "src/qml_bridge.rs", "src/qml_startup_bridge.rs", "src/qml_submaps_bridge.rs", + "src/qml_health_bridge.rs", + "src/qml_logs_bridge.rs", ]) .qt_module("Quick") .qt_module("QuickControls2") From 184e22c70cb6571a4fd7d379b8c10c67b6336d40 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:57:14 +0400 Subject: [PATCH 041/109] feat(qml): wire health and logs pages --- qml/Main.qml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/qml/Main.qml b/qml/Main.qml index ed2db27..4311783 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -83,6 +83,11 @@ ApplicationWindow { statusMessage = payload.message || "Restored the latest config snapshot." } + onCurrentPageChanged: { + backendHealthy = true + statusMessage = "Ready." + } + Component.onCompleted: loadUiState() Rectangle { @@ -208,7 +213,11 @@ ApplicationWindow { ? startupComponent : appWindow.currentPage === "Look & Feel" ? lookFeelComponent - : placeholderComponent + : appWindow.currentPage === "Health" + ? healthComponent + : appWindow.currentPage === "Logs" + ? logsComponent + : placeholderComponent } } } @@ -281,6 +290,24 @@ ApplicationWindow { } } + 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: placeholderComponent Item { From 6cc5cc75b8587b121563d6926c22d7b1c2d60165 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:57:50 +0400 Subject: [PATCH 042/109] refine(qml): keep page header context responsive --- qml/PageHeader.qml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml index a445a2d..161c4ae 100644 --- a/qml/PageHeader.qml +++ b/qml/PageHeader.qml @@ -13,6 +13,7 @@ Item { readonly property bool fullPath: width >= 880 readonly property bool showSubtitle: width >= 560 + readonly property bool configContext: currentPage !== "Overview" && currentPage !== "Health" && currentPage !== "Logs" readonly property int edgeMargin: width < 700 ? 16 : 24 property bool copied: false @@ -51,6 +52,10 @@ Item { 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 === "Health") + return "Inspect session, Hyprland, portals, audio, and desktop integration." + if (currentPage === "Logs") + return "Search the recent user-session journal without leaving Hyprbind." return "This page is being migrated to the new interface." } @@ -93,7 +98,7 @@ Item { anchors.verticalCenter: parent.verticalCenter darkMode: root.darkMode elevated: false - visible: root.configPath.length > 0 + visible: root.configContext && root.configPath.length > 0 TextInput { id: pathText From 9014ce1585be80cc249c8017d4b5b95d64ce824f Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:59:00 +0400 Subject: [PATCH 043/109] ci: add QML migration build gate --- .github/workflows/qml-migration.yml | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/qml-migration.yml diff --git a/.github/workflows/qml-migration.yml b/.github/workflows/qml-migration.yml new file mode 100644 index 0000000..8c04775 --- /dev/null +++ b/.github/workflows/qml-migration.yml @@ -0,0 +1,47 @@ +name: QML Migration Check + +on: + push: + branches: + - 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 \ + 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 From fef415e6954598de6d3ecca76cef29a40f0ea49c Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:01:02 +0400 Subject: [PATCH 044/109] fix(qml): avoid reserved id role in logs delegate --- qml/LogsPage.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/LogsPage.qml b/qml/LogsPage.qml index c76c53f..91e6805 100644 --- a/qml/LogsPage.qml +++ b/qml/LogsPage.qml @@ -51,7 +51,7 @@ Item { const haystack = (item.message + " " + item.identifier + " " + item.unit + " " + item.severity).toLowerCase() if (severityMatches(item.severity) && (!query || haystack.indexOf(query) >= 0)) { logModel.append({ - id: item.id, + entryId: item.id, timestamp: item.timestamp, severity: item.severity, message: item.message, @@ -149,7 +149,7 @@ Item { delegate: Rectangle { id: row - required property int id + required property int entryId required property string timestamp required property string severity required property string message From 3cbf2b039b6bcbc10717a26eaa4162f8876883ac Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:01:28 +0400 Subject: [PATCH 045/109] fix(qml): expose startup entryId role --- src/qml_startup_bridge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qml_startup_bridge.rs b/src/qml_startup_bridge.rs index 3c503d3..190e1e5 100644 --- a/src/qml_startup_bridge.rs +++ b/src/qml_startup_bridge.rs @@ -55,7 +55,7 @@ impl qobject::StartupBridge { .iter() .map(|item| { json!({ - "id": item.id, + "entryId": item.id, "command": item.command, "when": item.when, "workspace": item.workspace, From d2fe96d768970bb507a68673edce4e9406cbf86f Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:01:44 +0400 Subject: [PATCH 046/109] fix(qml): expose submap entryId role --- src/qml_submaps_bridge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qml_submaps_bridge.rs b/src/qml_submaps_bridge.rs index 03d33c1..5e798d4 100644 --- a/src/qml_submaps_bridge.rs +++ b/src/qml_submaps_bridge.rs @@ -40,7 +40,7 @@ impl qobject::SubmapsBridge { .iter() .map(|item| { json!({ - "id": item.id, + "entryId": item.id, "name": item.name, "reset": item.reset, "bindCount": item.bind_count, From 350a170daa47bd3302ea22be314d2d7debe4032c Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:02:28 +0400 Subject: [PATCH 047/109] fix(qml): use startup entryId role safely --- qml/StartupPage.qml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/qml/StartupPage.qml b/qml/StartupPage.qml index 695330c..6840017 100644 --- a/qml/StartupPage.qml +++ b/qml/StartupPage.qml @@ -54,7 +54,7 @@ Item { function selectedEntry() { for (let i = 0; i < allEntries.length; ++i) { - if (allEntries[i].id === selectedId) + if (allEntries[i].entryId === selectedId) return allEntries[i] } return null @@ -95,7 +95,7 @@ Item { editorWorkspace.text = item.workspace || "" editorError.text = "" startupEditor.editing = true - startupEditor.entryId = item.id + startupEditor.entryId = item.entryId startupEditor.selectedWhen = item.when || "start" startupEditor.open() editorCommand.forceActiveFocus() @@ -195,7 +195,7 @@ Item { delegate: Rectangle { id: startupRow - required property int id + required property int entryId required property string command required property string when required property string workspace @@ -205,14 +205,14 @@ Item { width: startupList.width height: 74 radius: 11 - color: root.selectedId === id + color: root.selectedId === entryId ? theme.selectedFill : rowMouse.containsMouse ? theme.hoverFill : "transparent" - border.width: root.selectedId === id ? 1 : 0 + border.width: root.selectedId === entryId ? 1 : 0 border.color: theme.selectedRim Rectangle { - visible: root.selectedId === id + visible: root.selectedId === entryId anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top @@ -307,9 +307,9 @@ Item { id: rowMouse anchors.fill: parent hoverEnabled: true - onClicked: root.selectedId = id + onClicked: root.selectedId = entryId onDoubleClicked: { - root.selectedId = id + root.selectedId = entryId root.openEditEditor() } } From de4826bad4b808c7d2503d49950f398046db57a8 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:03:09 +0400 Subject: [PATCH 048/109] fix(qml): use submap entryId role safely --- qml/SubmapsPage.qml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qml/SubmapsPage.qml b/qml/SubmapsPage.qml index 68152c8..bc8ddff 100644 --- a/qml/SubmapsPage.qml +++ b/qml/SubmapsPage.qml @@ -54,7 +54,7 @@ Item { function selectedSubmap() { for (let i = 0; i < allSubmaps.length; ++i) { - if (allSubmaps[i].id === selectedId) + if (allSubmaps[i].entryId === selectedId) return allSubmaps[i] } return null @@ -180,7 +180,7 @@ Item { delegate: Rectangle { id: submapRow - required property int id + required property int entryId required property string name required property string reset required property int bindCount @@ -190,14 +190,14 @@ Item { width: submapsList.width height: 72 radius: 11 - color: root.selectedId === id + color: root.selectedId === entryId ? theme.selectedFill : rowMouse.containsMouse ? theme.hoverFill : "transparent" - border.width: root.selectedId === id ? 1 : 0 + border.width: root.selectedId === entryId ? 1 : 0 border.color: theme.selectedRim Rectangle { - visible: root.selectedId === id + visible: root.selectedId === entryId anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top @@ -292,7 +292,7 @@ Item { id: rowMouse anchors.fill: parent hoverEnabled: true - onClicked: root.selectedId = id + onClicked: root.selectedId = entryId } } } From 895117fe6e475c8a076eda3fcc6db40fd556a471 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:05:31 +0400 Subject: [PATCH 049/109] ci: install libudev for hidapi build --- .github/workflows/qml-migration.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/qml-migration.yml b/.github/workflows/qml-migration.yml index 8c04775..a89a4bc 100644 --- a/.github/workflows/qml-migration.yml +++ b/.github/workflows/qml-migration.yml @@ -28,6 +28,7 @@ jobs: pkg-config \ libgtk-4-dev \ libhidapi-dev \ + libudev-dev \ qt6-base-dev \ qt6-declarative-dev \ qt6-declarative-dev-tools \ From 92dc412a5d38c41a96a7123698655ce08dff5894 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:12 +0400 Subject: [PATCH 050/109] fix(qml): use inherited glass darkMode in status bar --- qml/StatusBar.qml | 2 -- 1 file changed, 2 deletions(-) diff --git a/qml/StatusBar.qml b/qml/StatusBar.qml index a2688d2..82a8b34 100644 --- a/qml/StatusBar.qml +++ b/qml/StatusBar.qml @@ -3,7 +3,6 @@ import QtQuick GlassPanel { id: root - property bool darkMode: true property bool healthy: true property string message: "Ready." @@ -11,7 +10,6 @@ GlassPanel { height: 50 cornerRadius: 11 - darkMode: root.darkMode elevated: false UiIcon { From 91f095a233c731185daa5d9e04edf5f1f6165048 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:24 +0400 Subject: [PATCH 051/109] fix(qml): use inherited glass darkMode in summary cards --- qml/SummaryCard.qml | 2 -- 1 file changed, 2 deletions(-) diff --git a/qml/SummaryCard.qml b/qml/SummaryCard.qml index f075cf6..b3b13d6 100644 --- a/qml/SummaryCard.qml +++ b/qml/SummaryCard.qml @@ -3,7 +3,6 @@ import QtQuick GlassPanel { id: root - property bool darkMode: true property string iconName: "info" property string title: "Summary" property string value: "—" @@ -14,7 +13,6 @@ GlassPanel { implicitHeight: 126 cornerRadius: 15 - darkMode: root.darkMode elevated: false Rectangle { From 5baa41ccbab6dbe2ba4ad2d5eb20c97d0790e884 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:34 +0400 Subject: [PATCH 052/109] fix(qml): use inherited glass darkMode in settings cards --- qml/SettingsCard.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/qml/SettingsCard.qml b/qml/SettingsCard.qml index 9726f61..49afa30 100644 --- a/qml/SettingsCard.qml +++ b/qml/SettingsCard.qml @@ -3,7 +3,6 @@ import QtQuick GlassPanel { id: root property string title: "Section" - property bool darkMode: true default property alias rows: rowColumn.data HyprbindTheme { id: theme; darkMode: root.darkMode } From 1f549e3ffbac87ee52906487be9095ef356bf8c9 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:18:11 +0400 Subject: [PATCH 053/109] fix(qml): use supported text wrapping mode --- qml/HealthPage.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qml/HealthPage.qml b/qml/HealthPage.qml index a37dc99..fead8f8 100644 --- a/qml/HealthPage.qml +++ b/qml/HealthPage.qml @@ -262,7 +262,7 @@ Item { color: theme.alpha(theme.textSecondary, 0.88) font.family: "Inter" font.pixelSize: 11 - wrapMode: Text.Wrap + wrapMode: Text.WordWrap } Text { @@ -272,7 +272,7 @@ Item { color: theme.alpha(theme.textSecondary, 0.68) font.family: "Inter" font.pixelSize: 10 - wrapMode: Text.Wrap + wrapMode: Text.WordWrap } GlassPanel { From 60239b7c1c1924afe1d1576f6150e19c54f91bb1 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:18:38 +0400 Subject: [PATCH 054/109] fix(qml): use supported log wrapping mode --- qml/LogsPage.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qml/LogsPage.qml b/qml/LogsPage.qml index 91e6805..067f849 100644 --- a/qml/LogsPage.qml +++ b/qml/LogsPage.qml @@ -237,7 +237,7 @@ Item { font.pixelSize: 11 elide: Text.ElideRight maximumLineCount: root.width < 620 ? 2 : 1 - wrapMode: root.width < 620 ? Text.Wrap : Text.NoWrap + wrapMode: root.width < 620 ? Text.WordWrap : Text.NoWrap } } From f399b9fdb6d48991535d243000745d192d8ff5d3 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:19:33 +0400 Subject: [PATCH 055/109] fix(qml): register generated bridge modules --- src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index e4cf7c3..b36c977 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,10 @@ mod nav; mod overview_ui; mod palette; mod qml_bridge; +mod qml_health_bridge; +mod qml_logs_bridge; +mod qml_startup_bridge; +mod qml_submaps_bridge; mod settings_config; mod spec; mod startup; From f7cc7bb72b426c9e5b9ad56a029eab87e183c078 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:25:04 +0400 Subject: [PATCH 056/109] fix(qml): import native module in submaps page --- qml/SubmapsPage.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/qml/SubmapsPage.qml b/qml/SubmapsPage.qml index bc8ddff..cf8c90c 100644 --- a/qml/SubmapsPage.qml +++ b/qml/SubmapsPage.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import dev.hyprbinds.ui Item { id: root From 39a1d769a32d5c3531e286da619f3ca9d2dc861b Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:25:42 +0400 Subject: [PATCH 057/109] fix(qml): import native module in startup page --- qml/StartupPage.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/qml/StartupPage.qml b/qml/StartupPage.qml index 6840017..592036b 100644 --- a/qml/StartupPage.qml +++ b/qml/StartupPage.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import dev.hyprbinds.ui Item { id: root From 8f32cc255de6a0c73ba755223b455f7289f0e446 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:26:34 +0400 Subject: [PATCH 058/109] fix(qml): import native module in health page --- qml/HealthPage.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/qml/HealthPage.qml b/qml/HealthPage.qml index fead8f8..4679f48 100644 --- a/qml/HealthPage.qml +++ b/qml/HealthPage.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import dev.hyprbinds.ui Item { id: root From f3cf208447d8eb4f4ee9cadf9d3a1d48458b67f8 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:27:01 +0400 Subject: [PATCH 059/109] fix(qml): import native module in logs page --- qml/LogsPage.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/qml/LogsPage.qml b/qml/LogsPage.qml index 067f849..553d56b 100644 --- a/qml/LogsPage.qml +++ b/qml/LogsPage.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import dev.hyprbinds.ui Item { id: root From 3ac2ee5417b070d96339874194beb48a7db06df9 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:11 +0400 Subject: [PATCH 060/109] fix(qml): stabilize responsive health layout --- qml/HealthPage.qml | 296 ++++++++++++++++++++++++++------------------- 1 file changed, 172 insertions(+), 124 deletions(-) diff --git a/qml/HealthPage.qml b/qml/HealthPage.qml index 4679f48..3e7e9ee 100644 --- a/qml/HealthPage.qml +++ b/qml/HealthPage.qml @@ -11,7 +11,16 @@ Item { property int total: 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) @@ -37,6 +46,7 @@ Item { rebuildModel() return } + allChecks = payload.checks || [] total = payload.total || allChecks.length warnings = payload.warnings || 0 @@ -79,17 +89,23 @@ Item { Component.onCompleted: load() Column { - anchors.fill: parent + id: topArea + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top spacing: 12 Grid { id: summaryGrid width: parent.width - columns: root.width >= 760 ? 3 : 1 - spacing: 10 + columns: root.summaryColumns + spacing: root.summaryGap + height: Math.ceil(3 / root.summaryColumns) * root.summaryCardHeight + + (Math.ceil(3 / root.summaryColumns) - 1) * root.summaryGap SummaryCard { - width: summaryGrid.columns === 3 ? (summaryGrid.width - 20) / 3 : summaryGrid.width + width: root.summaryCardWidth + height: root.summaryCardHeight darkMode: root.darkMode iconName: "heart" title: "CHECKS" @@ -97,16 +113,20 @@ Item { subtitle: "Desktop and session diagnostics" accent: root.failures === 0 } + SummaryCard { - width: summaryGrid.columns === 3 ? (summaryGrid.width - 20) / 3 : summaryGrid.width + 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: summaryGrid.columns === 3 ? (summaryGrid.width - 20) / 3 : summaryGrid.width + width: root.summaryCardWidth + height: root.summaryCardHeight darkMode: root.darkMode iconName: "heart" title: "FAILURES" @@ -117,6 +137,7 @@ Item { } Item { + id: toolbar width: parent.width height: root.compactToolbar ? 90 : 44 @@ -157,6 +178,7 @@ Item { { label: "Warnings", value: "WARN" }, { label: "OK", value: "OK" } ] + delegate: GlassButton { required property var modelData text: modelData.label @@ -170,157 +192,183 @@ Item { } } } + } - ListView { - id: healthList - width: parent.width - height: parent.height - y - 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: healthList.width - height: contentColumn.implicitHeight + 24 - radius: 12 - color: hoverHandler.hovered ? theme.hoverFill : "transparent" - border.width: 1 - border.color: theme.divider - - HoverHandler { id: hoverHandler } - - Column { - id: contentColumn - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.leftMargin: 14 - anchors.rightMargin: 14 - anchors.topMargin: 12 - spacing: 5 + 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 } - Row { - width: parent.width - spacing: 9 + 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.66) + color: theme.alpha(theme.textSecondary, 0.64) font.family: "Inter" font.pixelSize: 9 font.weight: Font.DemiBold font.letterSpacing: 0.7 - anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight } Text { - width: Math.max(0, parent.width - severityBadge.width - 90) + width: parent.width text: title color: theme.textPrimary font.family: "Inter" font.pixelSize: 13 font.weight: Font.Medium elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - - Rectangle { - id: severityBadge - width: severityText.implicitWidth + 16 - height: 24 - radius: 8 - color: theme.alpha(root.severityColor(severity), 0.08) - border.width: 1 - border.color: theme.alpha(root.severityColor(severity), 0.20) - anchors.verticalCenter: parent.verticalCenter - - 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 - } } } - Text { - width: parent.width - text: detail - color: theme.alpha(theme.textSecondary, 0.88) - font.family: "Inter" - font.pixelSize: 11 - 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 - wrapMode: Text.WordWrap - } - - GlassPanel { - visible: fixCommand.length > 0 - width: parent.width - height: 36 - cornerRadius: 9 - darkMode: root.darkMode - elevated: false + 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 { - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 11 - anchors.rightMargin: 11 - anchors.verticalCenter: parent.verticalCenter - text: fixCommand - color: theme.alpha(theme.textPrimary, 0.76) - font.family: "monospace" - font.pixelSize: 10 - elide: Text.ElideRight + 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 } } } - } - Column { - anchors.centerIn: parent - visible: checkModel.count === 0 - spacing: 10 + Rectangle { + width: parent.width + height: 1 + color: theme.divider + opacity: 0.72 + } - UiIcon { - width: 28 - height: 28 - anchors.horizontalCenter: parent.horizontalCenter - name: "heart" - iconColor: theme.alpha(theme.textSecondary, 0.66) + 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 { - text: "No diagnostics match this filter" - color: theme.textPrimary + visible: fixHint.length > 0 + width: parent.width + text: fixHint + color: theme.alpha(theme.textSecondary, 0.68) font.family: "Inter" - font.pixelSize: 16 - font.weight: Font.Medium - anchors.horizontalCenter: parent.horizontalCenter + font.pixelSize: 10 + lineHeight: 1.2 + wrapMode: Text.WordWrap + } + + GlassPanel { + visible: fixCommand.length > 0 + width: parent.width + height: visible ? 38 : 0 + cornerRadius: 9 + darkMode: root.darkMode + elevated: false + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 12 + 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 + } + } } } From d2a0599d646f1130f9572de7269bca117ff49e19 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:55 +0400 Subject: [PATCH 061/109] refactor(qml): refine window control glyphs --- qml/UiIcon.qml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/qml/UiIcon.qml b/qml/UiIcon.qml index b88e3fc..f338227 100644 --- a/qml/UiIcon.qml +++ b/qml/UiIcon.qml @@ -12,6 +12,7 @@ Canvas { onNameChanged: requestPaint() onIconColorChanged: requestPaint() + onStrokeWidthChanged: requestPaint() onWidthChanged: requestPaint() onHeightChanged: requestPaint() @@ -54,11 +55,14 @@ Canvas { } 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(5, 5); c.lineTo(15, 15); c.moveTo(15, 5); c.lineTo(5, 15) + c.moveTo(6.1, 6.1); c.lineTo(13.9, 13.9); c.moveTo(13.9, 6.1); c.lineTo(6.1, 13.9) } else if (name === "minimize") { - c.moveTo(5, 11); c.lineTo(15, 11) + c.moveTo(6, 12.3); c.lineTo(14, 12.3) } else if (name === "maximize") { - c.rect(5, 5, 10, 10) + c.roundedRect(6, 6, 8, 8, 1) + } else if (name === "restore-window") { + c.roundedRect(5.5, 7, 7.6, 7.5, 0.9) + c.moveTo(8, 7); c.lineTo(8, 5.5); c.lineTo(14.5, 5.5); c.lineTo(14.5, 12) } 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") { From 4f0e58879573b6041cc9104a94d0c2bc26141ea0 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:51:20 +0400 Subject: [PATCH 062/109] refactor(qml): polish titlebar window controls --- qml/AppTopBar.qml | 63 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml index 8601b56..6a36904 100644 --- a/qml/AppTopBar.qml +++ b/qml/AppTopBar.qml @@ -207,7 +207,7 @@ Rectangle { anchors.right: parent.right anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter - spacing: 1 + spacing: 2 Repeater { model: [ @@ -215,34 +215,63 @@ Rectangle { { icon: "maximize", tip: "Maximize" }, { icon: "close", tip: "Close" } ] + delegate: Button { + id: windowButton required property var modelData - implicitWidth: root.compact ? 36 : 40 + readonly property bool isClose: modelData.icon === "close" + readonly property bool isMaximize: modelData.icon === "maximize" + readonly property bool isRestoring: isMaximize && 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: UiIcon { - width: 15 - height: 15 - anchors.centerIn: parent - name: modelData.icon - iconColor: modelData.icon === "close" && parent.hovered - ? "#ffd9d6" : theme.textSecondary + contentItem: Item { + UiIcon { + anchors.centerIn: parent + width: 15 + height: 15 + name: windowButton.effectiveIcon + strokeWidth: 1.35 + iconColor: windowButton.isClose && windowButton.hovered + ? "#ffe4e1" + : windowButton.hovered + ? theme.alpha(theme.textPrimary, 0.96) + : theme.alpha(theme.textSecondary, 0.82) + } } background: Rectangle { - radius: 8 - color: modelData.icon === "close" && parent.hovered - ? Qt.rgba(0.73, 0.20, 0.22, 0.44) - : parent.hovered ? theme.hoverFill : "transparent" + radius: 9 + color: windowButton.isClose && windowButton.hovered + ? Qt.rgba(0.72, 0.18, 0.20, windowButton.pressed ? 0.58 : 0.44) + : windowButton.pressed + ? theme.pressedFill + : 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") + if (modelData.icon === "minimize") { root.appWindow.showMinimized() - else if (modelData.icon === "maximize") { + } else if (modelData.icon === "maximize") { if (root.appWindow.visibility === Window.Maximized) root.appWindow.showNormal() else @@ -251,8 +280,10 @@ Rectangle { root.appWindow.close() } } + ToolTip.visible: hovered - ToolTip.text: modelData.tip + ToolTip.delay: 450 + ToolTip.text: effectiveTip } } } From 0d0c19d5f2be2bbae166aca8b5bc31c67eb77061 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:52:10 +0400 Subject: [PATCH 063/109] fix(qml): use valid pressed control token --- qml/AppTopBar.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml index 6a36904..770e271 100644 --- a/qml/AppTopBar.qml +++ b/qml/AppTopBar.qml @@ -256,7 +256,7 @@ Rectangle { color: windowButton.isClose && windowButton.hovered ? Qt.rgba(0.72, 0.18, 0.20, windowButton.pressed ? 0.58 : 0.44) : windowButton.pressed - ? theme.pressedFill + ? theme.controlPressed : windowButton.hovered ? theme.hoverFill : "transparent" From b4b50abd09dd30aa543d8fba29bc603c3aad461a Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:58:22 +0400 Subject: [PATCH 064/109] fix(qml): add Hyprland-native maximize bridge --- src/qml_window_bridge.rs | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/qml_window_bridge.rs diff --git a/src/qml_window_bridge.rs b/src/qml_window_bridge.rs new file mode 100644 index 0000000..d3a0f55 --- /dev/null +++ b/src/qml_window_bridge.rs @@ -0,0 +1,53 @@ +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 { + let output = match std::process::Command::new("hyprctl") + .args(["dispatch", "fullscreen", "1"]) + .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(); + if output.status.success() + && !stdout.to_ascii_lowercase().contains("error") + && !stderr.to_ascii_lowercase().contains("error") + { + 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()) +} From 00b11d7f38ed725251602e1ee77009a5bbe4499e Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:59:18 +0400 Subject: [PATCH 065/109] feat(qml): add shared catalog bridge for remaining pages --- src/qml_catalog_bridge.rs | 351 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 src/qml_catalog_bridge.rs diff --git a/src/qml_catalog_bridge.rs b/src/qml_catalog_bridge.rs new file mode 100644 index 0000000..f0ea74b --- /dev/null +++ b/src/qml_catalog_bridge.rs @@ -0,0 +1,351 @@ +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, + "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() })) +} From f0143ce4b5e10eee1bd33eecccfd57f03c9650d2 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:59:41 +0400 Subject: [PATCH 066/109] feat(qml): add shared glass text editor --- qml/GlassTextArea.qml | 46 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 qml/GlassTextArea.qml diff --git a/qml/GlassTextArea.qml b/qml/GlassTextArea.qml new file mode 100644 index 0000000..ca641c4 --- /dev/null +++ b/qml/GlassTextArea.qml @@ -0,0 +1,46 @@ +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: editor.activeFocus ? theme.searchFocusedFill : 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: editor.activeFocus ? theme.searchSpecularFocus : theme.searchSpecular + } + } + + TextArea { + id: editor + color: theme.textPrimary + placeholderTextColor: theme.alpha(theme.textSecondary, 0.58) + selectionColor: theme.alpha(theme.accent, 0.28) + selectedTextColor: theme.textPrimary + font.family: "monospace" + font.pixelSize: 11 + wrapMode: TextEdit.NoWrap + padding: 12 + background: null + } +} From 05fec3f9fbbcfab9d395ad9558754cf55915d131 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:00:23 +0400 Subject: [PATCH 067/109] feat(qml): migrate binds page --- qml/BindsPage.qml | 344 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 qml/BindsPage.qml diff --git a/qml/BindsPage.qml b/qml/BindsPage.qml new file mode 100644 index 0000000..804cf09 --- /dev/null +++ b/qml/BindsPage.qml @@ -0,0 +1,344 @@ +import QtQuick +import QtQuick.Controls +import dev.hyprbinds.ui + +Item { + id: root + + property bool darkMode: true + property var allItems: [] + property var submaps: [] + property int selectedId: -1 + property string configPath: "" + readonly property bool compactToolbar: width < 700 + + 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() + if (!query || haystack.indexOf(query) >= 0) + 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 ? 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 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() } + } + } + + 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 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() } + } + } + } +} From ade3f08d18d73fa9aaf13196e6ccd117844e208e Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:01 +0400 Subject: [PATCH 068/109] feat(qml): migrate rule catalog pages --- qml/RuleCatalogPage.qml | 232 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 qml/RuleCatalogPage.qml 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() } + } + } + } +} From 02e5e7e3fad6e756da30867faf114a7c0cb131b9 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:39 +0400 Subject: [PATCH 069/109] feat(qml): migrate spec-backed settings pages --- qml/SpecCatalogPage.qml | 227 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 qml/SpecCatalogPage.qml 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() } + } + } + } +} From 14e7f346d33cb53a34075f3b34075051e450b4f2 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:56 +0400 Subject: [PATCH 070/109] feat(qml): migrate config page --- qml/ConfigPage.qml | 103 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 qml/ConfigPage.qml diff --git a/qml/ConfigPage.qml b/qml/ConfigPage.qml new file mode 100644 index 0000000..91c05be --- /dev/null +++ b/qml/ConfigPage.qml @@ -0,0 +1,103 @@ +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 + + 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 { + width: parent.width + height: root.width < 620 ? 82 : 46 + + Text { + id: hint + anchors.left: parent.left + anchors.right: root.width < 620 ? parent.right : actions.left + anchors.rightMargin: root.width < 620 ? 0 : 14 + anchors.top: parent.top + anchors.verticalCenter: root.width < 620 ? undefined : parent.verticalCenter + 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 + } + + Row { + id: actions + anchors.right: parent.right + anchors.top: root.width < 620 ? hint.bottom : parent.top + anchors.topMargin: root.width < 620 ? 7 : 3 + spacing: 7 + 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() } + } + } + + GlassTextArea { + id: configEditor + width: parent.width + height: parent.height - y + darkMode: root.darkMode + placeholderText: "{}" + } + } +} From d7d090e6d514732da92c1a80ec4edd73c0646b71 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:02:32 +0400 Subject: [PATCH 071/109] build(qml): register remaining migration pages and bridges --- build.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build.rs b/build.rs index 282e8ad..e3acd3a 100644 --- a/build.rs +++ b/build.rs @@ -15,10 +15,15 @@ fn main() { "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/GlassPanel.qml", "qml/GlassField.qml", + "qml/GlassTextArea.qml", "qml/GlassButton.qml", "qml/IconButton.qml", "qml/NavItem.qml", @@ -40,6 +45,8 @@ fn main() { "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") From 178f5a382bca2b10a54e6623d7e5613f5e43bdc6 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:03:03 +0400 Subject: [PATCH 072/109] build(qml): register catalog and window bridges --- src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.rs b/src/main.rs index b36c977..a6bbdfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,10 +24,12 @@ 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; From f149b6e0b2014c96db5cf970f13ca9a5804ca98d Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:03:35 +0400 Subject: [PATCH 073/109] fix(qml): make maximize reliable on Hyprland --- qml/AppTopBar.qml | 158 +++++++++++++++------------------------------- 1 file changed, 51 insertions(+), 107 deletions(-) diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml index 770e271..07c39ea 100644 --- a/qml/AppTopBar.qml +++ b/qml/AppTopBar.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Window +import dev.hyprbinds.ui Rectangle { id: root @@ -10,6 +11,7 @@ Rectangle { property bool realtimeEnabled: false property bool backupAvailable: false property string backupAge: "" + property bool compositorMaximized: false signal toggleThemeRequested() signal toggleRealtimeRequested() @@ -17,6 +19,7 @@ Rectangle { signal reloadRequested() HyprbindTheme { id: theme; darkMode: root.darkMode } + WindowControlBridge { id: windowBridge } readonly property bool showTagline: width >= 1180 readonly property bool showActionLabels: width >= 1010 @@ -25,6 +28,27 @@ Rectangle { 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 @@ -34,18 +58,16 @@ Rectangle { } MouseArea { + z: 0 anchors.fill: parent + acceptedButtons: Qt.LeftButton onPressed: root.appWindow.startSystemMove() - onDoubleClicked: { - if (root.appWindow.visibility === Window.Maximized) - root.appWindow.showNormal() - else - root.appWindow.showMaximized() - } + onDoubleClicked: root.toggleMaximize() } Row { id: brandRow + z: 2 anchors.left: parent.left anchors.leftMargin: root.compact ? 14 : 20 anchors.right: actionRow.left @@ -82,6 +104,7 @@ Rectangle { Row { id: actionRow + z: 3 anchors.right: windowControls.left anchors.rightMargin: root.compact ? 8 : 14 anchors.verticalCenter: parent.verticalCenter @@ -94,35 +117,16 @@ Rectangle { padding: 0 flat: true hoverEnabled: true - contentItem: Row { anchors.centerIn: parent spacing: 7 - UiIcon { - width: 18 - height: 18 - name: root.darkMode ? "moon" : "sun" - iconColor: theme.textPrimary - anchors.verticalCenter: parent.verticalCenter - } - Text { - visible: root.showActionLabels - text: root.darkMode ? "Dark mode" : "Light mode" - color: theme.textPrimary - font.family: "Inter" - font.pixelSize: 12 - anchors.verticalCenter: parent.verticalCenter - } - } - - background: Rectangle { - radius: 10 - color: themeButton.hovered ? theme.hoverFill : "transparent" - border.width: themeButton.hovered ? 1 : 0 - border.color: theme.controlRim + UiIcon { width: 18; height: 18; name: root.darkMode ? "moon" : "sun"; iconColor: theme.textPrimary; anchors.verticalCenter: parent.verticalCenter } + Text { visible: root.showActionLabels; text: root.darkMode ? "Dark mode" : "Light mode"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter } } + background: Rectangle { radius: 10; color: themeButton.hovered ? theme.hoverFill : "transparent"; border.width: themeButton.hovered ? 1 : 0; border.color: theme.controlRim } onClicked: root.toggleThemeRequested() ToolTip.visible: hovered + ToolTip.delay: 450 ToolTip.text: root.darkMode ? "Switch to light theme" : "Switch to dark theme" } @@ -133,58 +137,25 @@ Rectangle { padding: 0 flat: true hoverEnabled: true - contentItem: Row { anchors.centerIn: parent spacing: 7 - UiIcon { - width: 17 - height: 17 - name: "realtime" - iconColor: root.realtimeEnabled ? theme.accent : theme.textSecondary - anchors.verticalCenter: parent.verticalCenter - } - Text { - visible: root.showActionLabels - text: "Realtime" - color: root.realtimeEnabled ? theme.textPrimary : theme.textSecondary - font.family: "Inter" - font.pixelSize: 12 - anchors.verticalCenter: parent.verticalCenter - } - Rectangle { - visible: root.showActionLabels - width: 5 - height: 5 - radius: 2.5 - color: root.realtimeEnabled ? theme.accent : theme.alpha(theme.textSecondary, 0.28) - anchors.verticalCenter: parent.verticalCenter - } - } - - background: Rectangle { - radius: 10 - color: realtimeButton.hovered ? theme.hoverFill : "transparent" - border.width: root.realtimeEnabled ? 1 : 0 - border.color: theme.alpha(theme.accent, 0.20) + UiIcon { width: 17; height: 17; name: "realtime"; iconColor: root.realtimeEnabled ? theme.accent : theme.textSecondary; anchors.verticalCenter: parent.verticalCenter } + Text { visible: root.showActionLabels; text: "Realtime"; color: root.realtimeEnabled ? theme.textPrimary : theme.textSecondary; font.family: "Inter"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter } + Rectangle { visible: root.showActionLabels; width: 5; height: 5; radius: 2.5; color: root.realtimeEnabled ? theme.accent : theme.alpha(theme.textSecondary, 0.28); anchors.verticalCenter: parent.verticalCenter } } + background: Rectangle { radius: 10; color: realtimeButton.hovered ? theme.hoverFill : "transparent"; border.width: root.realtimeEnabled ? 1 : 0; border.color: theme.alpha(theme.accent, 0.20) } onClicked: root.toggleRealtimeRequested() ToolTip.visible: hovered + ToolTip.delay: 450 ToolTip.text: root.realtimeEnabled ? "Disable live preview" : "Enable live preview on supported pages" } - Rectangle { - width: 1 - height: 23 - color: theme.divider - anchors.verticalCenter: parent.verticalCenter - } + 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" + tooltip: root.backupAvailable ? (root.backupAge.length ? "Restore latest snapshot · " + root.backupAge : "Restore latest snapshot") : "No snapshot available" darkMode: root.darkMode implicitWidth: 36 implicitHeight: 36 @@ -192,18 +163,12 @@ Rectangle { onClicked: root.restoreRequested() } - IconButton { - iconName: "reload" - tooltip: "Reload current page" - darkMode: root.darkMode - implicitWidth: 36 - implicitHeight: 36 - onClicked: root.reloadRequested() - } + 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 @@ -221,7 +186,7 @@ Rectangle { required property var modelData readonly property bool isClose: modelData.icon === "close" readonly property bool isMaximize: modelData.icon === "maximize" - readonly property bool isRestoring: isMaximize && root.appWindow.visibility === Window.Maximized + 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 @@ -231,10 +196,7 @@ Rectangle { flat: true hoverEnabled: true scale: pressed ? 0.94 : 1.0 - - Behavior on scale { - NumberAnimation { duration: 90; easing.type: Easing.OutCubic } - } + Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } contentItem: Item { UiIcon { @@ -242,12 +204,8 @@ Rectangle { width: 15 height: 15 name: windowButton.effectiveIcon - strokeWidth: 1.35 - iconColor: windowButton.isClose && windowButton.hovered - ? "#ffe4e1" - : windowButton.hovered - ? theme.alpha(theme.textPrimary, 0.96) - : theme.alpha(theme.textSecondary, 0.82) + strokeWidth: 1.30 + iconColor: windowButton.isClose && windowButton.hovered ? "#ffe4e1" : windowButton.hovered ? theme.alpha(theme.textPrimary, 0.96) : theme.alpha(theme.textSecondary, 0.82) } } @@ -255,30 +213,16 @@ 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" + : 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 } - } + Behavior on color { ColorAnimation { duration: 100 } } } onClicked: { - if (modelData.icon === "minimize") { - root.appWindow.showMinimized() - } else if (modelData.icon === "maximize") { - if (root.appWindow.visibility === Window.Maximized) - root.appWindow.showNormal() - else - root.appWindow.showMaximized() - } else { - root.appWindow.close() - } + if (modelData.icon === "minimize") root.appWindow.showMinimized() + else if (modelData.icon === "maximize") root.toggleMaximize() + else root.appWindow.close() } ToolTip.visible: hovered From 78712a42d6f93d0341f00c16171afba28bb22b19 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:03:54 +0400 Subject: [PATCH 074/109] refactor(qml): add compact sidebar navigation --- qml/NavItem.qml | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index 577686e..b4bd1bd 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -3,43 +3,51 @@ import QtQuick.Controls Button { id: control + property string iconName: "document" property bool selected: false property bool darkMode: true + property bool compact: false + hoverEnabled: true HyprbindTheme { id: theme; darkMode: control.darkMode } implicitHeight: 38 - leftPadding: 11 - rightPadding: 11 - scale: down ? 0.985 : 1 + leftPadding: compact ? 0 : 11 + rightPadding: compact ? 0 : 11 + scale: down ? 0.97 : 1 Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } - contentItem: Row { - spacing: 11 - anchors.verticalCenter: parent.verticalCenter + contentItem: Item { UiIcon { width: 18 height: 18 name: control.iconName iconColor: control.selected ? theme.textPrimary : theme.alpha(theme.textSecondary, 0.88) anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: control.compact ? parent.horizontalCenter : undefined + anchors.left: control.compact ? undefined : parent.left } + Text { + visible: !control.compact + anchors.left: parent.left + anchors.leftMargin: 29 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter text: control.text color: control.selected ? theme.textPrimary : theme.alpha(theme.textPrimary, 0.86) font.family: "Inter" font.pixelSize: 13 font.weight: control.selected ? Font.Medium : Font.Normal - anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight } } background: Rectangle { radius: 12 antialiasing: true - color: control.selected ? theme.selectedFill - : control.hovered ? theme.hoverFill : "transparent" + color: control.selected ? theme.selectedFill : control.hovered ? theme.hoverFill : "transparent" border.width: control.selected ? 1 : 0 border.color: theme.selectedRim Behavior on color { ColorAnimation { duration: 145; easing.type: Easing.OutCubic } } @@ -48,11 +56,15 @@ Button { visible: control.selected anchors.left: parent.left anchors.right: parent.right - anchors.leftMargin: 15 - anchors.rightMargin: 15 + anchors.leftMargin: control.compact ? 10 : 15 + anchors.rightMargin: control.compact ? 10 : 15 anchors.top: parent.top height: 1 color: theme.selectedSpecular } } + + ToolTip.visible: control.compact && control.hovered + ToolTip.delay: 450 + ToolTip.text: control.text } From 5796be056cdb63aae20444bfd3c055f80dae1de2 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:04:14 +0400 Subject: [PATCH 075/109] refactor(qml): make sidebar collapse to icon rail --- qml/AppSidebar.qml | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/qml/AppSidebar.qml b/qml/AppSidebar.qml index ca7caad..e7af082 100644 --- a/qml/AppSidebar.qml +++ b/qml/AppSidebar.qml @@ -6,6 +6,7 @@ Rectangle { property bool darkMode: true property string currentPage: "Environment" + readonly property bool compact: width < 110 signal pageSelected(string page) HyprbindTheme { id: theme; darkMode: root.darkMode } @@ -21,6 +22,7 @@ Rectangle { GlassField { id: navSearch + visible: !root.compact anchors.top: parent.top anchors.topMargin: 14 anchors.left: parent.left @@ -56,8 +58,8 @@ Rectangle { Flickable { id: navFlick - anchors.top: navSearch.bottom - anchors.topMargin: 10 + 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 @@ -65,13 +67,12 @@ Rectangle { clip: true contentHeight: navColumn.implicitHeight boundsBehavior: Flickable.StopAtBounds - - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.vertical: ScrollBar { policy: root.compact ? ScrollBar.AlwaysOff : ScrollBar.AsNeeded } Column { id: navColumn width: navFlick.width - spacing: 3 + spacing: root.compact ? 2 : 3 Repeater { model: [ @@ -109,11 +110,20 @@ Rectangle { id: groupColumn width: navColumn.width property var groupData: modelData - spacing: 3 + spacing: root.compact ? 2 : 3 + + Item { width: 1; height: index === 0 ? 2 : (root.compact ? 7 : 9) } - Item { width: 1; height: index === 0 ? 3 : 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" @@ -121,7 +131,7 @@ Rectangle { font.weight: Font.DemiBold font.letterSpacing: 1.15 leftPadding: 20 - height: 22 + height: visible ? 22 : 0 verticalAlignment: Text.AlignVCenter } @@ -129,13 +139,14 @@ Rectangle { model: groupColumn.groupData.items delegate: NavItem { property var itemData: modelData - width: groupColumn.width - 24 + width: root.compact ? 48 : groupColumn.width - 24 anchors.horizontalCenter: parent.horizontalCenter text: itemData.name iconName: itemData.iconName darkMode: root.darkMode + compact: root.compact selected: root.currentPage === itemData.name - visible: !navSearch.text || itemData.name.toLowerCase().indexOf(navSearch.text.toLowerCase()) >= 0 + visible: root.compact || !navSearch.text || itemData.name.toLowerCase().indexOf(navSearch.text.toLowerCase()) >= 0 height: visible ? implicitHeight : 0 onClicked: root.pageSelected(itemData.name) } From ee7d69a17e53138a0c17205d6cea1b3a4447aa9b Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:05:00 +0400 Subject: [PATCH 076/109] feat(qml): route every sidebar page to migrated UI --- qml/Main.qml | 185 +++++++++++++-------------------------------------- 1 file changed, 45 insertions(+), 140 deletions(-) diff --git a/qml/Main.qml b/qml/Main.qml index 4311783..1bc0619 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -8,8 +8,8 @@ ApplicationWindow { width: 1440 height: 900 - minimumWidth: 760 - minimumHeight: 560 + minimumWidth: 620 + minimumHeight: 500 visible: true title: "Hyprbind" color: "transparent" @@ -24,8 +24,8 @@ ApplicationWindow { property bool backupAvailable: false property string backupAge: "" - readonly property int sidebarWidth: width < 900 ? 210 : width < 1180 ? 240 : 286 - readonly property int contentMargin: width < 900 ? 12 : width < 1180 ? 18 : 24 + 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 } @@ -37,8 +37,7 @@ ApplicationWindow { function loadUiState() { const payload = parsePayload(backend.uiSnapshot()) - if (!payload.ok) - return + if (!payload.ok) return darkMode = payload.darkMode !== false backupAvailable = payload.hasBackup === true backupAge = payload.backupAge || "" @@ -57,9 +56,7 @@ ApplicationWindow { function toggleRealtime() { realtimeEnabled = !realtimeEnabled - statusMessage = realtimeEnabled - ? "Live preview enabled on supported pages." - : "Live preview disabled." + statusMessage = realtimeEnabled ? "Live preview enabled on supported pages." : "Live preview disabled." } function reloadCurrentPage() { @@ -68,7 +65,7 @@ ApplicationWindow { pageLoader.item.load() return } - statusMessage = "This page does not expose reload yet." + statusMessage = "This page does not expose reload." } function restoreLastBackup() { @@ -78,8 +75,7 @@ ApplicationWindow { return } loadUiState() - if (pageLoader.item && typeof pageLoader.item.load === "function") - pageLoader.item.load() + if (pageLoader.item && typeof pageLoader.item.load === "function") pageLoader.item.load() statusMessage = payload.message || "Restored the latest config snapshot." } @@ -191,7 +187,6 @@ ApplicationWindow { anchors.bottom: statusBar.top anchors.leftMargin: appWindow.contentMargin anchors.rightMargin: appWindow.contentMargin - anchors.topMargin: 0 anchors.bottomMargin: 10 cornerRadius: 14 darkMode: appWindow.darkMode @@ -200,81 +195,40 @@ ApplicationWindow { Loader { id: pageLoader anchors.fill: parent - anchors.margins: appWindow.currentPage === "Look & Feel" ? 0 : 14 - sourceComponent: appWindow.currentPage === "Overview" - ? overviewComponent - : appWindow.currentPage === "Environment" - ? environmentComponent - : appWindow.currentPage === "Variables" - ? variablesComponent - : appWindow.currentPage === "Submaps" - ? submapsComponent - : appWindow.currentPage === "Startup" - ? startupComponent - : appWindow.currentPage === "Look & Feel" - ? lookFeelComponent - : appWindow.currentPage === "Health" - ? healthComponent - : appWindow.currentPage === "Logs" - ? logsComponent - : placeholderComponent + 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 + : logsComponent } } } } - 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: environmentComponent - EnvironmentPage { - bridge: backend - 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: submapsComponent - SubmapsPage { - darkMode: appWindow.darkMode - onStatus: message => appWindow.statusMessage = message - onConfigResolved: path => appWindow.configPath = path - onHealthChanged: healthy => appWindow.backendHealthy = healthy - } - } + 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: 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 @@ -283,66 +237,17 @@ ApplicationWindow { darkMode: appWindow.darkMode realtimeEnabled: appWindow.realtimeEnabled onStatus: message => appWindow.statusMessage = message - onSaved: { - appWindow.loadUiState() - appWindow.statusMessage = "Look & Feel saved." - } - } - } - - 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 + onSaved: { appWindow.loadUiState(); appWindow.statusMessage = "Look & Feel saved." } } } - Component { - id: placeholderComponent - Item { - Column { - anchors.centerIn: parent - width: Math.min(parent.width - 40, 460) - spacing: 12 - - UiIcon { - width: 28 - height: 28 - anchors.horizontalCenter: parent.horizontalCenter - name: pageHeader.pageIconName() - iconColor: theme.alpha(theme.textSecondary, 0.72) - } - - Text { - text: appWindow.currentPage - color: theme.textPrimary - font.family: "Inter" - font.pixelSize: 19 - font.weight: Font.DemiBold - anchors.horizontalCenter: parent.horizontalCenter - } + 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 } } - Text { - width: parent.width - text: "This page is next in the QML migration. The existing GTK frontend still owns its behavior for now." - color: theme.textSecondary - font.family: "Inter" - font.pixelSize: 12 - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - } - } - } - } + 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 } } } From 6690bed8226625eac71445c2b7571333b3bd407c Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:05:33 +0400 Subject: [PATCH 077/109] refactor(qml): complete page header metadata --- qml/PageHeader.qml | 50 +++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml index 161c4ae..8ce823f 100644 --- a/qml/PageHeader.qml +++ b/qml/PageHeader.qml @@ -11,7 +11,7 @@ Item { HyprbindTheme { id: theme; darkMode: root.darkMode } - readonly property bool fullPath: width >= 880 + readonly property bool fullPath: width >= 920 readonly property bool showSubtitle: width >= 560 readonly property bool configContext: currentPage !== "Overview" && currentPage !== "Health" && currentPage !== "Logs" readonly property int edgeMargin: width < 700 ? 16 : 24 @@ -35,28 +35,31 @@ Item { if (currentPage === "Gestures") return "gesture" if (currentPage === "Health") return "heart" if (currentPage === "Logs") return "logs" + if (currentPage === "Layer rules") return "layers" if (currentPage.indexOf("rule") >= 0) return "window" return "document" } 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 === "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 === "Health") - return "Inspect session, Hyprland, portals, audio, and desktop integration." - if (currentPage === "Logs") - return "Search the recent user-session journal without leaving Hyprbind." - return "This page is being migrated to the new interface." + 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." + return "Hyprland configuration." } GlassPanel { @@ -135,9 +138,7 @@ Item { background: Rectangle { radius: 10 - color: root.copied - ? theme.alpha(theme.accent, 0.12) - : copyPath.hovered ? theme.hoverFill : "transparent" + color: root.copied ? theme.alpha(theme.accent, 0.12) : copyPath.hovered ? theme.hoverFill : "transparent" border.width: root.copied ? 1 : 0 border.color: theme.alpha(theme.accent, 0.24) Behavior on color { ColorAnimation { duration: 140 } } @@ -166,12 +167,7 @@ Item { PropertyAnimation { target: copyPath; property: "scale"; to: 1.0; duration: 110; easing.type: Easing.OutCubic } } - Timer { - id: copiedReset - interval: 1100 - repeat: false - onTriggered: root.copied = false - } + Timer { id: copiedReset; interval: 1100; repeat: false; onTriggered: root.copied = false } Column { id: titleColumn From 987dec6e9f61d7c68d3f8f63172845beb1ac755e Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:20:06 +0400 Subject: [PATCH 078/109] fix(qml): use Lua-safe Hyprland maximize dispatch --- src/qml_window_bridge.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/qml_window_bridge.rs b/src/qml_window_bridge.rs index d3a0f55..93dc08a 100644 --- a/src/qml_window_bridge.rs +++ b/src/qml_window_bridge.rs @@ -24,8 +24,11 @@ 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(["dispatch", "fullscreen", "1"]) + .args(["eval", EXPR]) .output() { Ok(output) => output, @@ -34,15 +37,22 @@ impl qobject::WindowControlBridge { 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() - && !stdout.to_ascii_lowercase().contains("error") - && !stderr.to_ascii_lowercase().contains("error") + && !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() }, + if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "Hyprland maximize request failed".into() + }, ) } } From c9ddd2c77ebe1df5df6d88f1350f20f248100a5b Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:27:30 +0400 Subject: [PATCH 079/109] fix(qml): preserve sidebar icon geometry --- qml/NavItem.qml | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index b4bd1bd..7398896 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -19,20 +19,31 @@ Button { Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } contentItem: Item { - UiIcon { - width: 18 - height: 18 - name: control.iconName - iconColor: control.selected ? theme.textPrimary : theme.alpha(theme.textSecondary, 0.88) - anchors.verticalCenter: parent.verticalCenter - anchors.horizontalCenter: control.compact ? parent.horizontalCenter : undefined - anchors.left: control.compact ? undefined : parent.left + id: content + 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.textPrimary + : theme.alpha(theme.textSecondary, 0.88) + } } Text { visible: !control.compact - anchors.left: parent.left - anchors.leftMargin: 29 + anchors.left: iconSlot.right + anchors.leftMargin: 10 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: control.text @@ -47,7 +58,8 @@ Button { background: Rectangle { radius: 12 antialiasing: true - color: control.selected ? theme.selectedFill : control.hovered ? theme.hoverFill : "transparent" + color: control.selected ? theme.selectedFill + : control.hovered ? theme.hoverFill : "transparent" border.width: control.selected ? 1 : 0 border.color: theme.selectedRim Behavior on color { ColorAnimation { duration: 145; easing.type: Easing.OutCubic } } From 13a3715b7b08e8d2571821ff609a38ba5f217059 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:27:58 +0400 Subject: [PATCH 080/109] fix(qml): stabilize logs toolbar layout --- qml/LogsPage.qml | 59 ++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/qml/LogsPage.qml b/qml/LogsPage.qml index 553d56b..ce0b146 100644 --- a/qml/LogsPage.qml +++ b/qml/LogsPage.qml @@ -9,7 +9,7 @@ Item { property var allEntries: [] property string severityFilter: "ALL" property string loadError: "" - readonly property bool compactToolbar: width < 820 + readonly property bool compactToolbar: width < 900 signal status(string message) signal healthChanged(bool healthy) @@ -82,36 +82,11 @@ Item { anchors.top: parent.top height: root.compactToolbar ? 92 : 44 - GlassField { - id: searchField - anchors.left: parent.left - anchors.right: root.compactToolbar ? parent.right : filterFlow.left - anchors.rightMargin: root.compactToolbar ? 0 : 10 - anchors.top: parent.top - 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 - } - } - - Flow { - id: filterFlow - width: root.compactToolbar ? parent.width : implicitWidth - anchors.right: parent.right - anchors.top: root.compactToolbar ? searchField.bottom : parent.top - anchors.topMargin: root.compactToolbar ? 6 : 0 + Row { + id: filterRow spacing: 6 + y: root.compactToolbar ? 49 : 0 + x: root.compactToolbar ? 0 : Math.max(0, toolbar.width - width) Repeater { model: [ @@ -133,6 +108,30 @@ Item { } } } + + 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 { From 7442473b10b4c1bcbeda7d8867625f2c15219a3e Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:28:22 +0400 Subject: [PATCH 081/109] refine(qml): stabilize slider handle geometry --- qml/SettingSlider.qml | 93 +++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/qml/SettingSlider.qml b/qml/SettingSlider.qml index 04e5f47..5a11f9d 100644 --- a/qml/SettingSlider.qml +++ b/qml/SettingSlider.qml @@ -63,6 +63,9 @@ Item { 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 @@ -76,83 +79,86 @@ Item { onMoved: root.edited(value) background: Item { - x: slider.leftPadding + x: slider.leftPadding + slider.trackInset y: slider.topPadding + slider.availableHeight / 2 - height / 2 - width: slider.availableWidth - height: 12 + width: Math.max(0, slider.availableWidth - slider.handleBoxWidth) + height: 10 Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - height: 4 - radius: 2 - color: theme.alpha(theme.textSecondary, slider.hovered ? 0.14 : 0.095) + 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: 2 - anchors.rightMargin: 2 + anchors.leftMargin: 1 + anchors.rightMargin: 1 height: 1 radius: 1 - color: theme.alpha(theme.foreground, slider.hovered ? 0.035 : 0.022) + color: theme.alpha(theme.foreground, slider.hovered ? 0.036 : 0.022) } - - Behavior on color { ColorAnimation { duration: 140 } } } Rectangle { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - width: Math.max(3, slider.visualPosition * parent.width) - height: 4 - radius: 2 + 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: 2 - anchors.rightMargin: 2 + anchors.leftMargin: 1 + anchors.rightMargin: 1 height: 1 radius: 1 - color: theme.alpha(theme.foreground, slider.pressed ? 0.09 : 0.055) + color: theme.alpha(theme.foreground, slider.pressed ? 0.08 : 0.045) } - Behavior on color { ColorAnimation { duration: 130 } } + Behavior on color { ColorAnimation { duration: 120 } } } } handle: Item { - x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) - y: slider.topPadding + slider.availableHeight / 2 - height / 2 - width: 28 + 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: slider.hovered || slider.pressed ? 22 : 18 - height: slider.hovered || slider.pressed ? 30 : 26 - radius: 11 - color: theme.alpha(theme.accent, slider.pressed ? 0.055 : slider.hovered ? 0.032 : 0) - - Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } - Behavior on height { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } - Behavior on color { ColorAnimation { duration: 120 } } + 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: slider.pressed ? 12 : 11 - height: slider.pressed ? 23 : 21 - radius: width / 2 + 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.13) - : theme.mix(theme.surfaceHigh, theme.foreground, 0.075) + ? 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, @@ -160,27 +166,28 @@ Item { ) Rectangle { - anchors.horizontalCenter: parent.horizontalCenter + anchors.left: parent.left + anchors.right: parent.right anchors.top: parent.top + anchors.leftMargin: 2 + anchors.rightMargin: 2 anchors.topMargin: 2 - width: Math.max(3, parent.width - 5) height: 1 radius: 1 - color: theme.alpha(theme.foreground, slider.pressed ? 0.18 : 0.11) + color: theme.alpha(theme.foreground, slider.pressed ? 0.19 : 0.11) } Rectangle { anchors.centerIn: parent width: 2 - height: 9 + height: 8 radius: 1 - color: theme.alpha(theme.accent, slider.pressed ? 0.74 : slider.hovered ? 0.58 : 0.42) + color: theme.alpha(theme.accent, slider.pressed ? 0.76 : slider.hovered ? 0.60 : 0.44) } - Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } - Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } } - Behavior on color { ColorAnimation { duration: 120 } } - Behavior on border.color { ColorAnimation { duration: 120 } } + Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 110 } } + Behavior on border.color { ColorAnimation { duration: 110 } } } } } From 37075b9d6289a6ba85a6c5151038249d5578ca61 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:31:01 +0400 Subject: [PATCH 082/109] fix(qml): make look and feel toolbar deterministic --- qml/LookFeelPage.qml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/qml/LookFeelPage.qml b/qml/LookFeelPage.qml index 8141d1f..b5c230e 100644 --- a/qml/LookFeelPage.qml +++ b/qml/LookFeelPage.qml @@ -178,14 +178,14 @@ Item { spacing: 14 Item { + id: toolbar Layout.fillWidth: true Layout.preferredHeight: root.compactToolbar ? 78 : 40 Row { id: presetsRow - anchors.left: parent.left - anchors.top: parent.top - anchors.verticalCenter: root.compactToolbar ? undefined : parent.verticalCenter + x: 0 + y: root.compactToolbar ? 0 : Math.round((toolbar.height - height) / 2) spacing: 8 Text { @@ -203,9 +203,11 @@ Item { } Row { - anchors.right: parent.right - anchors.bottom: root.compactToolbar ? parent.bottom : undefined - anchors.verticalCenter: root.compactToolbar ? undefined : parent.verticalCenter + 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 { From 9f5ac115859283a7ab75527127b2f31d994f23f8 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:31:17 +0400 Subject: [PATCH 083/109] fix(qml): make config toolbar deterministic --- qml/ConfigPage.qml | 50 +++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/qml/ConfigPage.qml b/qml/ConfigPage.qml index 91c05be..21e076a 100644 --- a/qml/ConfigPage.qml +++ b/qml/ConfigPage.qml @@ -9,6 +9,7 @@ Item { 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) @@ -64,32 +65,49 @@ Item { spacing: 10 Item { + id: toolbar width: parent.width - height: root.width < 620 ? 82 : 46 + 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 - anchors.left: parent.left - anchors.right: root.width < 620 ? parent.right : actions.left - anchors.rightMargin: root.width < 620 ? 0 : 14 - anchors.top: parent.top - anchors.verticalCenter: root.width < 620 ? undefined : parent.verticalCenter + 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 } - - Row { - id: actions - anchors.right: parent.right - anchors.top: root.width < 620 ? hint.bottom : parent.top - anchors.topMargin: root.width < 620 ? 7 : 3 - spacing: 7 - 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() } - } } GlassTextArea { From 6ff5dd6d6eddbcd7be54d08c5b7114d4aa0115e5 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:44:19 +0400 Subject: [PATCH 084/109] refine(qml): make window glyphs crisp at high dpi --- qml/UiIcon.qml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qml/UiIcon.qml b/qml/UiIcon.qml index f338227..197eae5 100644 --- a/qml/UiIcon.qml +++ b/qml/UiIcon.qml @@ -45,7 +45,7 @@ Canvas { 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.roundedRect(6.5, 11.2, 7, 4.2, 1) + 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 === "terminal") { @@ -55,14 +55,14 @@ Canvas { } 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.1, 6.1); c.lineTo(13.9, 13.9); c.moveTo(13.9, 6.1); c.lineTo(6.1, 13.9) + 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(6, 12.3); c.lineTo(14, 12.3) + c.moveTo(5.6, 12.5); c.lineTo(14.4, 12.5) } else if (name === "maximize") { - c.roundedRect(6, 6, 8, 8, 1) + c.rect(5.5, 5.5, 9, 9) } else if (name === "restore-window") { - c.roundedRect(5.5, 7, 7.6, 7.5, 0.9) - c.moveTo(8, 7); c.lineTo(8, 5.5); c.lineTo(14.5, 5.5); c.lineTo(14.5, 12) + 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") { From 40c930154aee347d26b36195abb6fd9f9afd4b2c Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:04:24 +0400 Subject: [PATCH 085/109] refine(qml): compose overview dashboard --- qml/OverviewPage.qml | 276 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 218 insertions(+), 58 deletions(-) diff --git a/qml/OverviewPage.qml b/qml/OverviewPage.qml index e8f68b0..f0d31ee 100644 --- a/qml/OverviewPage.qml +++ b/qml/OverviewPage.qml @@ -17,6 +17,9 @@ Item { 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) @@ -59,7 +62,7 @@ Item { anchors.fill: parent clip: true contentWidth: width - contentHeight: contentColumn.implicitHeight + contentHeight: contentColumn.implicitHeight + 4 boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } @@ -68,9 +71,172 @@ Item { width: parent.width spacing: 14 + GlassPanel { + Layout.fillWidth: true + Layout.preferredHeight: root.compactHero ? 224 : 156 + darkMode: root.darkMode + cornerRadius: 17 + elevated: false + tint: root.healthy ? theme.accent : theme.danger + + GridLayout { + anchors.fill: parent + anchors.margins: 18 + columns: root.compactHero ? 1 : 2 + columnSpacing: 28 + rowSpacing: 14 + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 15 + + Rectangle { + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + radius: 14 + 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 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + Layout.fillWidth: true + 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 { + Layout.fillWidth: true + 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 + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 9 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 47 + radius: 12 + color: theme.alpha(theme.controlFill, 0.72) + border.width: 1 + border.color: theme.controlRim + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 13 + anchors.rightMargin: 13 + spacing: 2 + + Text { + width: parent.width + text: "CONFIG SOURCE" + color: theme.alpha(theme.textSecondary, 0.64) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + Text { + width: parent.width + text: root.configPath.length ? root.configPath : "Config path unresolved" + color: theme.alpha(theme.textPrimary, 0.88) + font.family: "monospace" + font.pixelSize: 10 + elide: Text.ElideMiddle + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 47 + radius: 12 + color: theme.alpha(theme.controlFill, 0.72) + border.width: 1 + border.color: root.backupAvailable ? theme.alpha(theme.accent, 0.12) : theme.controlRim + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 13 + anchors.rightMargin: 13 + spacing: 10 + + Text { + text: "RECOVERY" + color: theme.alpha(theme.textSecondary, 0.64) + font.family: "Inter" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + Item { Layout.fillWidth: true } + Text { + text: root.backupAvailable + ? (root.backupAge.length ? root.backupAge : "Snapshot available") + : "No snapshot yet" + color: root.backupAvailable ? theme.alpha(theme.accent, 0.90) : 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.width >= 760 ? 2 : 1 + columns: root.metricColumns columnSpacing: 14 rowSpacing: 14 @@ -96,76 +262,70 @@ Item { SummaryCard { Layout.fillWidth: true darkMode: root.darkMode - iconName: "diamond" - title: "WINDOW MATERIAL" - value: root.blurEnabled ? "Blur on" : "Blur off" - subtitle: "Rounding " + root.rounding + " · inactive opacity " + Number(root.inactiveOpacity).toFixed(2) + 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 } + } - SummaryCard { + 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 - darkMode: root.darkMode - iconName: "history" - title: "LATEST SNAPSHOT" - value: root.backupAvailable ? "Available" : "None yet" - subtitle: root.backupAvailable - ? (root.backupAge.length ? root.backupAge : "A restore point is available") - : "A snapshot appears after Hyprbind writes config" + Layout.preferredHeight: 1 + color: theme.divider } } GlassPanel { Layout.fillWidth: true - Layout.preferredHeight: 116 + Layout.preferredHeight: root.width < 640 ? 246 : 112 darkMode: root.darkMode cornerRadius: 15 elevated: false - Row { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 18 - anchors.rightMargin: 18 - spacing: 15 - - Rectangle { - width: 42 - height: 42 - radius: 13 - color: root.healthy ? theme.alpha(theme.accent, 0.07) : theme.dangerFill - border.width: 1 - border.color: root.healthy ? theme.alpha(theme.accent, 0.14) : theme.dangerRim - - UiIcon { - anchors.centerIn: parent - width: 20 - height: 20 - name: root.healthy ? "check" : "info" - iconColor: root.healthy ? theme.accent : theme.danger - } + 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 } } - Column { - width: parent.width - 57 - spacing: 4 - Text { - width: parent.width - text: root.healthy ? "Configuration loaded cleanly" : "Configuration needs attention" - color: theme.textPrimary - font.family: "Inter" - font.pixelSize: 15 - font.weight: Font.DemiBold - elide: Text.ElideRight - } - Text { - width: parent.width - text: root.configPath.length ? root.configPath : "Hyprbind could not resolve the config path." - color: theme.alpha(theme.textSecondary, 0.80) - font.family: "Inter" - font.pixelSize: 11 - elide: Text.ElideMiddle - } + 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 } } } } From 60e2b124b90f1f2588614f79f9d8d05b5798b18e Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:05:14 +0400 Subject: [PATCH 086/109] refine(qml): replace config copy control --- qml/PageHeader.qml | 89 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml index 8ce823f..a7bb583 100644 --- a/qml/PageHeader.qml +++ b/qml/PageHeader.qml @@ -120,7 +120,7 @@ Item { Button { id: copyPath - width: root.fullPath ? 40 : parent.width + width: root.fullPath ? 42 : parent.width height: parent.height anchors.right: parent.right padding: 0 @@ -128,20 +128,78 @@ Item { hoverEnabled: true scale: 1 - contentItem: UiIcon { - width: 18 - height: 18 - anchors.centerIn: parent - name: root.copied ? "check" : "copy" - iconColor: root.copied ? theme.accent : theme.textSecondary + 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.12) : copyPath.hovered ? theme.hoverFill : "transparent" - border.width: root.copied ? 1 : 0 - border.color: theme.alpha(theme.accent, 0.24) - Behavior on color { ColorAnimation { duration: 140 } } + 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: { @@ -156,18 +214,19 @@ Item { } ToolTip.visible: hovered + ToolTip.delay: 420 ToolTip.text: root.copied ? "Copied" : "Copy config path" } } SequentialAnimation { id: copyPulse - PropertyAnimation { target: copyPath; property: "scale"; to: 0.88; duration: 70; easing.type: Easing.OutCubic } - PropertyAnimation { target: copyPath; property: "scale"; to: 1.08; duration: 110; easing.type: Easing.OutBack } - PropertyAnimation { target: copyPath; property: "scale"; to: 1.0; duration: 110; easing.type: Easing.OutCubic } + 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: 1100; repeat: false; onTriggered: root.copied = false } + Timer { id: copiedReset; interval: 1050; repeat: false; onTriggered: root.copied = false } Column { id: titleColumn From b4bd62ca36f69f5d3326c4a197135aca8dadadbb Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:06:03 +0400 Subject: [PATCH 087/109] refine(qml): unify titlebar action hover states --- qml/AppTopBar.qml | 144 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 131 insertions(+), 13 deletions(-) diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml index 07c39ea..064b423 100644 --- a/qml/AppTopBar.qml +++ b/qml/AppTopBar.qml @@ -108,45 +108,156 @@ Rectangle { anchors.right: windowControls.left anchors.rightMargin: root.compact ? 8 : 14 anchors.verticalCenter: parent.verticalCenter - spacing: root.compact ? 5 : 9 + spacing: root.compact ? 5 : 7 Button { id: themeButton - implicitWidth: root.showActionLabels ? 105 : 36 + implicitWidth: root.showActionLabels ? 108 : 36 implicitHeight: 36 padding: 0 flat: true hoverEnabled: true + scale: pressed ? 0.965 : 1 + Behavior on scale { NumberAnimation { duration: 85; easing.type: Easing.OutCubic } } + contentItem: Row { anchors.centerIn: parent spacing: 7 - UiIcon { width: 18; height: 18; name: root.darkMode ? "moon" : "sun"; iconColor: theme.textPrimary; anchors.verticalCenter: parent.verticalCenter } - Text { visible: root.showActionLabels; text: root.darkMode ? "Dark mode" : "Light mode"; color: theme.textPrimary; font.family: "Inter"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter } + + UiIcon { + width: 17 + height: 17 + name: root.darkMode ? "moon" : "sun" + iconColor: themeButton.hovered ? theme.alpha(theme.accent, 0.90) : theme.alpha(theme.textPrimary, 0.88) + anchors.verticalCenter: parent.verticalCenter + Behavior on iconColor { ColorAnimation { duration: 120 } } + } + + Text { + visible: root.showActionLabels + text: root.darkMode ? "Dark mode" : "Light mode" + color: themeButton.hovered ? theme.textPrimary : theme.alpha(theme.textPrimary, 0.88) + font.family: "Inter" + font.pixelSize: 12 + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + Behavior on color { ColorAnimation { duration: 120 } } + } + } + + background: Rectangle { + radius: 10 + color: themeButton.pressed + ? theme.controlPressed + : themeButton.hovered + ? theme.controlHover + : theme.alpha(theme.controlFill, 0.17) + border.width: 1 + border.color: themeButton.hovered + ? theme.alpha(theme.foreground, root.darkMode ? 0.085 : 0.12) + : theme.alpha(theme.foreground, root.darkMode ? 0.022 : 0.05) + Behavior on color { ColorAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 120 } } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 9 + anchors.rightMargin: 9 + height: 1 + radius: 1 + color: theme.alpha(theme.foreground, themeButton.hovered ? 0.045 : 0.018) + Behavior on color { ColorAnimation { duration: 120 } } + } } - background: Rectangle { radius: 10; color: themeButton.hovered ? theme.hoverFill : "transparent"; border.width: themeButton.hovered ? 1 : 0; border.color: theme.controlRim } + onClicked: root.toggleThemeRequested() - ToolTip.visible: hovered + ToolTip.visible: hovered && !root.showActionLabels ToolTip.delay: 450 ToolTip.text: root.darkMode ? "Switch to light theme" : "Switch to dark theme" } Button { id: realtimeButton - implicitWidth: root.showActionLabels ? 105 : 36 + implicitWidth: root.showActionLabels ? 108 : 36 implicitHeight: 36 padding: 0 flat: true hoverEnabled: true + scale: pressed ? 0.965 : 1 + Behavior on scale { NumberAnimation { duration: 85; easing.type: Easing.OutCubic } } + contentItem: Row { anchors.centerIn: parent spacing: 7 - UiIcon { width: 17; height: 17; name: "realtime"; iconColor: root.realtimeEnabled ? theme.accent : theme.textSecondary; anchors.verticalCenter: parent.verticalCenter } - Text { visible: root.showActionLabels; text: "Realtime"; color: root.realtimeEnabled ? theme.textPrimary : theme.textSecondary; font.family: "Inter"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter } - Rectangle { visible: root.showActionLabels; width: 5; height: 5; radius: 2.5; color: root.realtimeEnabled ? theme.accent : theme.alpha(theme.textSecondary, 0.28); anchors.verticalCenter: parent.verticalCenter } + + UiIcon { + width: 17 + height: 17 + name: "realtime" + iconColor: root.realtimeEnabled + ? theme.accent + : realtimeButton.hovered ? theme.alpha(theme.textPrimary, 0.88) : theme.alpha(theme.textSecondary, 0.82) + anchors.verticalCenter: parent.verticalCenter + Behavior on iconColor { ColorAnimation { duration: 120 } } + } + + Text { + visible: root.showActionLabels + text: "Realtime" + color: root.realtimeEnabled || realtimeButton.hovered ? theme.textPrimary : theme.alpha(theme.textSecondary, 0.86) + font.family: "Inter" + font.pixelSize: 12 + font.weight: root.realtimeEnabled ? Font.Medium : Font.Normal + anchors.verticalCenter: parent.verticalCenter + Behavior on color { ColorAnimation { duration: 120 } } + } + + Rectangle { + visible: root.showActionLabels + width: 5 + height: 5 + radius: 2.5 + color: root.realtimeEnabled ? theme.accent : theme.alpha(theme.textSecondary, realtimeButton.hovered ? 0.42 : 0.25) + anchors.verticalCenter: parent.verticalCenter + Behavior on color { ColorAnimation { duration: 120 } } + } + } + + background: Rectangle { + radius: 10 + color: realtimeButton.pressed + ? theme.controlPressed + : root.realtimeEnabled + ? theme.alpha(theme.mix(theme.surfaceHigh, theme.accent, 0.12), realtimeButton.hovered ? 0.34 : 0.24) + : realtimeButton.hovered ? theme.controlHover : theme.alpha(theme.controlFill, 0.13) + border.width: 1 + border.color: root.realtimeEnabled + ? theme.alpha(theme.accent, realtimeButton.hovered ? 0.22 : 0.14) + : realtimeButton.hovered + ? theme.alpha(theme.foreground, root.darkMode ? 0.075 : 0.11) + : theme.alpha(theme.foreground, root.darkMode ? 0.018 : 0.045) + Behavior on color { ColorAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 120 } } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 9 + anchors.rightMargin: 9 + height: 1 + radius: 1 + color: root.realtimeEnabled + ? theme.alpha(theme.mix(theme.foreground, theme.accent, 0.18), realtimeButton.hovered ? 0.060 : 0.035) + : theme.alpha(theme.foreground, realtimeButton.hovered ? 0.040 : 0.014) + Behavior on color { ColorAnimation { duration: 120 } } + } } - background: Rectangle { radius: 10; color: realtimeButton.hovered ? theme.hoverFill : "transparent"; border.width: root.realtimeEnabled ? 1 : 0; border.color: theme.alpha(theme.accent, 0.20) } + onClicked: root.toggleRealtimeRequested() - ToolTip.visible: hovered + ToolTip.visible: hovered && !root.showActionLabels ToolTip.delay: 450 ToolTip.text: root.realtimeEnabled ? "Disable live preview" : "Enable live preview on supported pages" } @@ -163,7 +274,14 @@ Rectangle { onClicked: root.restoreRequested() } - IconButton { iconName: "reload"; tooltip: "Reload current page"; darkMode: root.darkMode; implicitWidth: 36; implicitHeight: 36; onClicked: root.reloadRequested() } + IconButton { + iconName: "reload" + tooltip: "Reload current page" + darkMode: root.darkMode + implicitWidth: 36 + implicitHeight: 36 + onClicked: root.reloadRequested() + } } Row { From 83d0ac6b2ae301d1bf4747a8cf2c836e2c18c79a Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:22:10 +0400 Subject: [PATCH 088/109] refactor(qml): centralize page icon identity --- qml/HyprbindTheme.qml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/qml/HyprbindTheme.qml b/qml/HyprbindTheme.qml index fdcbefb..508429a 100644 --- a/qml/HyprbindTheme.qml +++ b/qml/HyprbindTheme.qml @@ -15,6 +15,28 @@ QtObject { ) } + 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" + return "document" + } + readonly property color background: darkMode ? "#101416" : "#e9eef0" readonly property color surface: darkMode ? "#1a2023" : "#f4f7f8" readonly property color surfaceHigh: darkMode ? "#252d31" : "#ffffff" From d128ddf624e406616df916fbc8eecc217b5730a7 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:22:33 +0400 Subject: [PATCH 089/109] refactor(qml): use shared page icon mapping --- qml/AppSidebar.qml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/qml/AppSidebar.qml b/qml/AppSidebar.qml index e7af082..75a4f98 100644 --- a/qml/AppSidebar.qml +++ b/qml/AppSidebar.qml @@ -77,32 +77,32 @@ Rectangle { Repeater { model: [ { title: "START HERE", items: [ - { name: "Overview", iconName: "home" } + { name: "Overview" } ]}, { title: "KEYBOARD & CONFIG", items: [ - { name: "Binds", iconName: "keyboard" }, - { name: "Variables", iconName: "braces" }, - { name: "Environment", iconName: "document" }, - { name: "Submaps", iconName: "grid" }, - { name: "Startup", iconName: "power" } + { name: "Binds" }, + { name: "Variables" }, + { name: "Environment" }, + { name: "Submaps" }, + { name: "Startup" } ]}, { title: "RULES", items: [ - { name: "Window rules", iconName: "window" }, - { name: "Workspace rules", iconName: "grid" }, - { name: "Layer rules", iconName: "layers" } + { name: "Window rules" }, + { name: "Workspace rules" }, + { name: "Layer rules" } ]}, { title: "APPEARANCE & INPUT", items: [ - { name: "Look & Feel", iconName: "diamond" }, - { name: "Config", iconName: "settings" }, - { name: "Monitors", iconName: "monitor" }, - { name: "Devices", iconName: "device" }, - { name: "Animations", iconName: "sparkle" }, - { name: "Curves", iconName: "curve" }, - { name: "Gestures", iconName: "gesture" } + { name: "Look & Feel" }, + { name: "Config" }, + { name: "Monitors" }, + { name: "Devices" }, + { name: "Animations" }, + { name: "Curves" }, + { name: "Gestures" } ]}, { title: "SYSTEM", items: [ - { name: "Health", iconName: "heart" }, - { name: "Logs", iconName: "logs" } + { name: "Health" }, + { name: "Logs" } ]} ] @@ -142,7 +142,7 @@ Rectangle { width: root.compact ? 48 : groupColumn.width - 24 anchors.horizontalCenter: parent.horizontalCenter text: itemData.name - iconName: itemData.iconName + iconName: theme.pageIcon(itemData.name) darkMode: root.darkMode compact: root.compact selected: root.currentPage === itemData.name From 29370109238300b5b881ec2639e89c9aac21977a Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:23:05 +0400 Subject: [PATCH 090/109] refactor(qml): align header icons with navigation identity --- qml/PageHeader.qml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml index a7bb583..4941f85 100644 --- a/qml/PageHeader.qml +++ b/qml/PageHeader.qml @@ -19,27 +19,6 @@ Item { height: width < 700 ? 82 : 94 - function pageIconName() { - if (currentPage === "Environment") return "terminal" - if (currentPage === "Look & Feel") return "diamond" - if (currentPage === "Overview") return "home" - if (currentPage === "Variables") return "braces" - if (currentPage === "Binds") return "keyboard" - if (currentPage === "Submaps") return "grid" - if (currentPage === "Startup") return "power" - if (currentPage === "Config") return "settings" - if (currentPage === "Monitors") return "monitor" - if (currentPage === "Devices") return "device" - if (currentPage === "Animations") return "sparkle" - if (currentPage === "Curves") return "curve" - if (currentPage === "Gestures") return "gesture" - if (currentPage === "Health") return "heart" - if (currentPage === "Logs") return "logs" - if (currentPage === "Layer rules") return "layers" - if (currentPage.indexOf("rule") >= 0) return "window" - return "document" - } - 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." @@ -86,7 +65,7 @@ Item { anchors.centerIn: parent width: root.width < 700 ? 21 : 24 height: width - name: root.pageIconName() + name: theme.pageIcon(root.currentPage) iconColor: theme.accent strokeWidth: 1.75 } From 1e2d0ccb4ecbffca4b7f5a2f719130d4b8b4c4f9 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:23:22 +0400 Subject: [PATCH 091/109] refine(qml): simplify navigation interaction states --- qml/NavItem.qml | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index 7398896..4fdd9c6 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -15,8 +15,6 @@ Button { implicitHeight: 38 leftPadding: compact ? 0 : 11 rightPadding: compact ? 0 : 11 - scale: down ? 0.97 : 1 - Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } } contentItem: Item { id: content @@ -35,8 +33,11 @@ Button { height: 18 name: control.iconName iconColor: control.selected - ? theme.textPrimary - : theme.alpha(theme.textSecondary, 0.88) + ? theme.alpha(theme.accent, 0.92) + : control.hovered + ? theme.alpha(theme.textPrimary, 0.92) + : theme.alpha(theme.textSecondary, 0.86) + Behavior on iconColor { ColorAnimation { duration: 110 } } } } @@ -47,33 +48,31 @@ Button { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: control.text - color: control.selected ? theme.textPrimary : theme.alpha(theme.textPrimary, 0.86) + color: control.selected + ? theme.textPrimary + : control.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 + Behavior on color { ColorAnimation { duration: 110 } } } } background: Rectangle { radius: 12 antialiasing: true - color: control.selected ? theme.selectedFill - : control.hovered ? theme.hoverFill : "transparent" - border.width: control.selected ? 1 : 0 - border.color: theme.selectedRim - Behavior on color { ColorAnimation { duration: 145; easing.type: Easing.OutCubic } } - - Rectangle { - visible: control.selected - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: control.compact ? 10 : 15 - anchors.rightMargin: control.compact ? 10 : 15 - anchors.top: parent.top - height: 1 - color: theme.selectedSpecular - } + color: control.down + ? (control.selected + ? theme.alpha(theme.mix(theme.surfaceHigh, theme.accent, 0.12), 0.58) + : theme.alpha(theme.foreground, root.darkMode ? 0.040 : 0.070)) + : control.selected + ? theme.selectedFill + : control.hovered ? theme.hoverFill : "transparent" + border.width: 0 + Behavior on color { ColorAnimation { duration: 115; easing.type: Easing.OutCubic } } } ToolTip.visible: control.compact && control.hovered From 3c6e2cc830f0daac2226ccdaf57a93c76e665215 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:23:39 +0400 Subject: [PATCH 092/109] fix(qml): reference navigation theme state correctly --- qml/NavItem.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index 4fdd9c6..b21a95b 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -67,7 +67,7 @@ Button { color: control.down ? (control.selected ? theme.alpha(theme.mix(theme.surfaceHigh, theme.accent, 0.12), 0.58) - : theme.alpha(theme.foreground, root.darkMode ? 0.040 : 0.070)) + : theme.alpha(theme.foreground, control.darkMode ? 0.040 : 0.070)) : control.selected ? theme.selectedFill : control.hovered ? theme.hoverFill : "transparent" From 193e375943bd0b795212d1c75241e72ad752e59c Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:24:19 +0400 Subject: [PATCH 093/109] refine(qml): give topbar actions cleaner hover breathing --- qml/AppTopBar.qml | 202 +++++++++++++++++++++------------------------- 1 file changed, 92 insertions(+), 110 deletions(-) diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml index 064b423..bae4d4a 100644 --- a/qml/AppTopBar.qml +++ b/qml/AppTopBar.qml @@ -108,68 +108,60 @@ Rectangle { anchors.right: windowControls.left anchors.rightMargin: root.compact ? 8 : 14 anchors.verticalCenter: parent.verticalCenter - spacing: root.compact ? 5 : 7 + spacing: root.compact ? 4 : 6 Button { id: themeButton - implicitWidth: root.showActionLabels ? 108 : 36 - implicitHeight: 36 + implicitWidth: root.showActionLabels ? 122 : 42 + implicitHeight: 38 padding: 0 flat: true hoverEnabled: true - scale: pressed ? 0.965 : 1 - Behavior on scale { NumberAnimation { duration: 85; easing.type: Easing.OutCubic } } - - contentItem: Row { - anchors.centerIn: parent - spacing: 7 - - UiIcon { - width: 17 - height: 17 - name: root.darkMode ? "moon" : "sun" - iconColor: themeButton.hovered ? theme.alpha(theme.accent, 0.90) : theme.alpha(theme.textPrimary, 0.88) - anchors.verticalCenter: parent.verticalCenter - Behavior on iconColor { ColorAnimation { duration: 120 } } - } + scale: pressed ? 0.975 : 1 + Behavior on scale { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } } + + contentItem: Item { + Row { + anchors.centerIn: parent + spacing: 8 - Text { - visible: root.showActionLabels - text: root.darkMode ? "Dark mode" : "Light mode" - color: themeButton.hovered ? theme.textPrimary : theme.alpha(theme.textPrimary, 0.88) - font.family: "Inter" - font.pixelSize: 12 - font.weight: Font.Medium - anchors.verticalCenter: parent.verticalCenter - Behavior on color { ColorAnimation { duration: 120 } } + 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.controlPressed + ? theme.alpha(theme.controlPressed, 0.82) : themeButton.hovered - ? theme.controlHover - : theme.alpha(theme.controlFill, 0.17) - border.width: 1 - border.color: themeButton.hovered - ? theme.alpha(theme.foreground, root.darkMode ? 0.085 : 0.12) - : theme.alpha(theme.foreground, root.darkMode ? 0.022 : 0.05) - Behavior on color { ColorAnimation { duration: 120; easing.type: Easing.OutCubic } } - Behavior on border.color { ColorAnimation { duration: 120 } } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.leftMargin: 9 - anchors.rightMargin: 9 - height: 1 - radius: 1 - color: theme.alpha(theme.foreground, themeButton.hovered ? 0.045 : 0.018) - Behavior on color { ColorAnimation { duration: 120 } } - } + ? theme.alpha(theme.controlHover, 0.86) + : "transparent" + border.width: 0 + Behavior on color { ColorAnimation { duration: 115; easing.type: Easing.OutCubic } } } onClicked: root.toggleThemeRequested() @@ -180,80 +172,70 @@ Rectangle { Button { id: realtimeButton - implicitWidth: root.showActionLabels ? 108 : 36 - implicitHeight: 36 + implicitWidth: root.showActionLabels ? 122 : 42 + implicitHeight: 38 padding: 0 flat: true hoverEnabled: true - scale: pressed ? 0.965 : 1 - Behavior on scale { NumberAnimation { duration: 85; easing.type: Easing.OutCubic } } - - contentItem: Row { - anchors.centerIn: parent - spacing: 7 - - UiIcon { - width: 17 - height: 17 - name: "realtime" - iconColor: root.realtimeEnabled - ? theme.accent - : realtimeButton.hovered ? theme.alpha(theme.textPrimary, 0.88) : theme.alpha(theme.textSecondary, 0.82) - anchors.verticalCenter: parent.verticalCenter - Behavior on iconColor { ColorAnimation { duration: 120 } } - } + scale: pressed ? 0.975 : 1 + Behavior on scale { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } } - Text { - visible: root.showActionLabels - text: "Realtime" - color: root.realtimeEnabled || realtimeButton.hovered ? theme.textPrimary : theme.alpha(theme.textSecondary, 0.86) - font.family: "Inter" - font.pixelSize: 12 - font.weight: root.realtimeEnabled ? Font.Medium : Font.Normal - anchors.verticalCenter: parent.verticalCenter - Behavior on color { ColorAnimation { duration: 120 } } - } + contentItem: Item { + Row { + anchors.centerIn: parent + spacing: 8 + + UiIcon { + width: 17 + height: 17 + name: "realtime" + iconColor: root.realtimeEnabled + ? theme.accent + : realtimeButton.hovered + ? theme.alpha(theme.textPrimary, 0.90) + : theme.alpha(theme.textSecondary, 0.82) + anchors.verticalCenter: parent.verticalCenter + Behavior on iconColor { ColorAnimation { duration: 115 } } + } - Rectangle { - visible: root.showActionLabels - width: 5 - height: 5 - radius: 2.5 - color: root.realtimeEnabled ? theme.accent : theme.alpha(theme.textSecondary, realtimeButton.hovered ? 0.42 : 0.25) - anchors.verticalCenter: parent.verticalCenter - Behavior on color { ColorAnimation { duration: 120 } } + Text { + visible: root.showActionLabels + text: "Realtime" + color: root.realtimeEnabled || realtimeButton.hovered + ? theme.textPrimary + : theme.alpha(theme.textSecondary, 0.84) + font.family: "Inter" + font.pixelSize: 12 + font.weight: root.realtimeEnabled ? 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.realtimeEnabled + ? theme.accent + : theme.alpha(theme.textSecondary, realtimeButton.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: realtimeButton.pressed - ? theme.controlPressed - : root.realtimeEnabled - ? theme.alpha(theme.mix(theme.surfaceHigh, theme.accent, 0.12), realtimeButton.hovered ? 0.34 : 0.24) - : realtimeButton.hovered ? theme.controlHover : theme.alpha(theme.controlFill, 0.13) - border.width: 1 - border.color: root.realtimeEnabled - ? theme.alpha(theme.accent, realtimeButton.hovered ? 0.22 : 0.14) + ? theme.alpha(theme.controlPressed, 0.82) : realtimeButton.hovered - ? theme.alpha(theme.foreground, root.darkMode ? 0.075 : 0.11) - : theme.alpha(theme.foreground, root.darkMode ? 0.018 : 0.045) - Behavior on color { ColorAnimation { duration: 120; easing.type: Easing.OutCubic } } - Behavior on border.color { ColorAnimation { duration: 120 } } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.leftMargin: 9 - anchors.rightMargin: 9 - height: 1 - radius: 1 - color: root.realtimeEnabled - ? theme.alpha(theme.mix(theme.foreground, theme.accent, 0.18), realtimeButton.hovered ? 0.060 : 0.035) - : theme.alpha(theme.foreground, realtimeButton.hovered ? 0.040 : 0.014) - Behavior on color { ColorAnimation { duration: 120 } } - } + ? theme.alpha(theme.controlHover, 0.82) + : "transparent" + border.width: 0 + Behavior on color { ColorAnimation { duration: 115; easing.type: Easing.OutCubic } } } onClicked: root.toggleRealtimeRequested() From 8465928572f91244883dcfc25c501ef0b0c858f2 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:51:06 +0400 Subject: [PATCH 094/109] refine(qml): rebalance light theme material palette --- qml/HyprbindTheme.qml | 101 ++++++++++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 34 deletions(-) diff --git a/qml/HyprbindTheme.qml b/qml/HyprbindTheme.qml index 508429a..f0377e5 100644 --- a/qml/HyprbindTheme.qml +++ b/qml/HyprbindTheme.qml @@ -37,48 +37,81 @@ QtObject { return "document" } - readonly property color background: darkMode ? "#101416" : "#e9eef0" - readonly property color surface: darkMode ? "#1a2023" : "#f4f7f8" + readonly property color background: darkMode ? "#101416" : "#edf2f4" + readonly property color surface: darkMode ? "#1a2023" : "#f7fafb" readonly property color surfaceHigh: darkMode ? "#252d31" : "#ffffff" - readonly property color foreground: darkMode ? "#eef2f3" : "#172126" - readonly property color muted: darkMode ? "#bec6ca" : "#526068" - readonly property color accent: "#37d5e9" + 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, 0.78) - readonly property color familyShell: mix(surfaceHigh, background, darkMode ? 0.54 : 0.30) - readonly property color shellFill: alpha(familyShell, darkMode ? 0.62 : 0.74) - readonly property color shellRim: alpha(foreground, darkMode ? 0.095 : 0.13) - readonly property color shellTopSpecular: alpha(foreground, darkMode ? 0.034 : 0.11) - readonly property color shellAccentWash: alpha(accent, 0.018) - readonly property color shellBottomShade: alpha(accent, darkMode ? 0.028 : 0.018) - readonly property color shellInnerLine: alpha(foreground, darkMode ? 0.052 : 0.09) - readonly property color toolbarFill: alpha(mix(surfaceHigh, background, 0.44), darkMode ? 0.30 : 0.40) - readonly property color sidebarFill: alpha(mix(surfaceHigh, background, 0.55), darkMode ? 0.42 : 0.50) - readonly property color contentFill: alpha(mix(surface, background, 0.62), darkMode ? 0.18 : 0.28) - readonly property color raisedFill: alpha(mix(surfaceHigh, background, 0.58), darkMode ? 0.40 : 0.52) - readonly property color quietRim: alpha(foreground, darkMode ? 0.065 : 0.11) - readonly property color divider: alpha(foreground, darkMode ? 0.036 : 0.075) + readonly property color textSecondary: alpha(muted, darkMode ? 0.78 : 0.88) - readonly property color controlFill: alpha(mix(surfaceHigh, background, 0.60), darkMode ? 0.42 : 0.50) - readonly property color controlHover: alpha(mix(surfaceHigh, accent, 0.08), darkMode ? 0.50 : 0.56) - readonly property color controlPressed: alpha(mix(surfaceHigh, accent, 0.12), darkMode ? 0.56 : 0.62) - readonly property color controlRim: alpha(foreground, darkMode ? 0.055 : 0.10) - readonly property color controlRimActive: alpha(accent, darkMode ? 0.14 : 0.22) - readonly property color controlInnerRim: alpha(foreground, darkMode ? 0.025 : 0.055) + readonly property color familyShell: darkMode + ? mix(surfaceHigh, background, 0.54) + : mix(surfaceHigh, background, 0.20) + readonly property color shellFill: alpha(familyShell, darkMode ? 0.62 : 0.96) + readonly property color shellRim: alpha(foreground, darkMode ? 0.095 : 0.14) + readonly property color shellTopSpecular: darkMode + ? alpha(foreground, 0.034) + : alpha(surfaceHigh, 0.52) + readonly property color shellAccentWash: alpha(accent, darkMode ? 0.018 : 0.010) + readonly property color shellBottomShade: alpha(accent, darkMode ? 0.028 : 0.010) + readonly property color shellInnerLine: darkMode + ? alpha(foreground, 0.052) + : alpha(surfaceHigh, 0.62) - readonly property color searchFill: alpha(mix(surfaceHigh, background, 0.62), darkMode ? 0.42 : 0.52) - readonly property color searchFocusedFill: alpha(mix(surfaceHigh, accent, 0.055), darkMode ? 0.50 : 0.58) + readonly property color toolbarFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.44), 0.30) + : alpha(mix(surfaceHigh, background, 0.16), 0.91) + readonly property color sidebarFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.55), 0.42) + : alpha(mix(surfaceHigh, background, 0.24), 0.94) + readonly property color contentFill: darkMode + ? alpha(mix(surface, background, 0.62), 0.18) + : alpha(mix(surface, background, 0.18), 0.91) + readonly property color raisedFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.58), 0.40) + : alpha(mix(surfaceHigh, background, 0.10), 0.96) + readonly property color quietRim: alpha(foreground, darkMode ? 0.065 : 0.105) + readonly property color divider: alpha(foreground, darkMode ? 0.036 : 0.070) + + readonly property color controlFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.60), 0.42) + : alpha(mix(surfaceHigh, background, 0.10), 0.88) + readonly property color controlHover: darkMode + ? alpha(mix(surfaceHigh, accent, 0.08), 0.50) + : alpha(mix(surfaceHigh, accent, 0.055), 0.94) + readonly property color controlPressed: darkMode + ? alpha(mix(surfaceHigh, accent, 0.12), 0.56) + : alpha(mix(surfaceHigh, accent, 0.075), 0.97) + readonly property color controlRim: alpha(foreground, darkMode ? 0.055 : 0.095) + readonly property color controlRimActive: alpha(accent, darkMode ? 0.14 : 0.26) + readonly property color controlInnerRim: alpha(foreground, darkMode ? 0.025 : 0.045) + + readonly property color searchFill: darkMode + ? alpha(mix(surfaceHigh, background, 0.62), 0.42) + : alpha(mix(surfaceHigh, background, 0.08), 0.91) + readonly property color searchFocusedFill: searchFill readonly property color searchRim: alpha(foreground, darkMode ? 0.055 : 0.10) - readonly property color searchFocusRim: alpha(accent, darkMode ? 0.14 : 0.24) - readonly property color searchSpecular: alpha(foreground, darkMode ? 0.026 : 0.075) - readonly property color searchSpecularFocus: alpha(mix(foreground, accent, 0.10), darkMode ? 0.040 : 0.10) + readonly property color searchFocusRim: alpha(foreground, darkMode ? 0.12 : 0.16) + readonly property color searchSpecular: darkMode + ? alpha(foreground, 0.026) + : alpha(surfaceHigh, 0.54) + readonly property color searchSpecularFocus: searchSpecular - readonly property color hoverFill: alpha(foreground, darkMode ? 0.028 : 0.060) - readonly property color selectedFill: alpha(mix(surfaceHigh, accent, 0.10), darkMode ? 0.54 : 0.60) + readonly property color hoverFill: darkMode + ? alpha(foreground, 0.028) + : alpha(foreground, 0.052) + readonly property color selectedFill: darkMode + ? alpha(mix(surfaceHigh, accent, 0.10), 0.54) + : alpha(mix(surfaceHigh, accent, 0.075), 0.92) readonly property color selectedRim: alpha(accent, darkMode ? 0.10 : 0.18) - readonly property color selectedSpecular: alpha(foreground, darkMode ? 0.028 : 0.070) + readonly property color selectedSpecular: darkMode + ? alpha(foreground, 0.028) + : alpha(surfaceHigh, 0.48) + readonly property color danger: darkMode ? "#dca3a0" : "#9c3f3b" - readonly property color dangerFill: alpha(danger, darkMode ? 0.055 : 0.075) + readonly property color dangerFill: alpha(danger, darkMode ? 0.055 : 0.070) readonly property color dangerRim: alpha(danger, darkMode ? 0.14 : 0.20) } From 66dd248d077971f1b230df5f85f67d3b18bc4011 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:51:23 +0400 Subject: [PATCH 095/109] refine(qml): keep text fields neutral on focus --- qml/GlassField.qml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/qml/GlassField.qml b/qml/GlassField.qml index 7cf2761..5aae7c4 100644 --- a/qml/GlassField.qml +++ b/qml/GlassField.qml @@ -11,7 +11,7 @@ TextField { rightPadding: 14 color: theme.textPrimary placeholderTextColor: theme.alpha(theme.textSecondary, 0.72) - selectionColor: theme.alpha(theme.accent, 0.26) + selectionColor: theme.alpha(theme.foreground, control.darkMode ? 0.14 : 0.16) selectedTextColor: theme.textPrimary font.family: "Inter" font.pixelSize: 13 @@ -20,7 +20,7 @@ TextField { background: Rectangle { radius: 14 antialiasing: true - color: control.activeFocus ? theme.searchFocusedFill : theme.searchFill + color: theme.searchFill border.width: 1 border.color: control.activeFocus ? theme.searchFocusRim : theme.searchRim @@ -32,10 +32,9 @@ TextField { anchors.top: parent.top height: 1 radius: 1 - color: control.activeFocus ? theme.searchSpecularFocus : theme.searchSpecular + color: theme.searchSpecular } - Behavior on color { ColorAnimation { duration: 185; easing.type: Easing.OutCubic } } - Behavior on border.color { ColorAnimation { duration: 185; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 135; easing.type: Easing.OutCubic } } } } From 0c79d0b364a5880ec3c0bfbc488d49576357d1a1 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:51:39 +0400 Subject: [PATCH 096/109] refine(qml): keep text areas neutral on edit --- qml/GlassTextArea.qml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/qml/GlassTextArea.qml b/qml/GlassTextArea.qml index ca641c4..f39e762 100644 --- a/qml/GlassTextArea.qml +++ b/qml/GlassTextArea.qml @@ -16,7 +16,7 @@ ScrollView { background: Rectangle { radius: 11 - color: editor.activeFocus ? theme.searchFocusedFill : theme.searchFill + color: theme.searchFill border.width: 1 border.color: editor.activeFocus ? theme.searchFocusRim : theme.searchRim @@ -27,15 +27,17 @@ ScrollView { anchors.rightMargin: 12 anchors.top: parent.top height: 1 - color: editor.activeFocus ? theme.searchSpecularFocus : theme.searchSpecular + 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.accent, 0.28) + selectionColor: theme.alpha(theme.foreground, control.darkMode ? 0.14 : 0.16) selectedTextColor: theme.textPrimary font.family: "monospace" font.pixelSize: 11 From 95c382d447c3a4d1d4bbf46c72f2626ec583e155 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:51:56 +0400 Subject: [PATCH 097/109] refactor(qml): remove hidden button states from sidebar navigation --- qml/NavItem.qml | 51 +++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index b21a95b..87a80e9 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -1,23 +1,38 @@ import QtQuick import QtQuick.Controls -Button { +Item { id: control + property string text: "" property string iconName: "document" property bool selected: false property bool darkMode: true property bool compact: false - hoverEnabled: true - HyprbindTheme { id: theme; darkMode: control.darkMode } + signal clicked() implicitHeight: 38 - leftPadding: compact ? 0 : 11 - rightPadding: compact ? 0 : 11 + HoverHandler { id: hover } + TapHandler { acceptedButtons: Qt.LeftButton; onTapped: control.clicked() } + + HyprbindTheme { id: theme; darkMode: control.darkMode } - contentItem: Item { + Rectangle { + anchors.fill: parent + radius: 12 + antialiasing: true + color: control.selected + ? theme.selectedFill + : hover.hovered ? theme.hoverFill : "transparent" + Behavior on color { ColorAnimation { duration: 95; easing.type: Easing.OutCubic } } + } + + Item { id: content + anchors.fill: parent + anchors.leftMargin: control.compact ? 0 : 11 + anchors.rightMargin: control.compact ? 0 : 11 clip: true Item { @@ -34,10 +49,10 @@ Button { name: control.iconName iconColor: control.selected ? theme.alpha(theme.accent, 0.92) - : control.hovered + : hover.hovered ? theme.alpha(theme.textPrimary, 0.92) : theme.alpha(theme.textSecondary, 0.86) - Behavior on iconColor { ColorAnimation { duration: 110 } } + Behavior on iconColor { ColorAnimation { duration: 95 } } } } @@ -50,32 +65,18 @@ Button { text: control.text color: control.selected ? theme.textPrimary - : control.hovered + : 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 - Behavior on color { ColorAnimation { duration: 110 } } + Behavior on color { ColorAnimation { duration: 95 } } } } - background: Rectangle { - radius: 12 - antialiasing: true - color: control.down - ? (control.selected - ? theme.alpha(theme.mix(theme.surfaceHigh, theme.accent, 0.12), 0.58) - : theme.alpha(theme.foreground, control.darkMode ? 0.040 : 0.070)) - : control.selected - ? theme.selectedFill - : control.hovered ? theme.hoverFill : "transparent" - border.width: 0 - Behavior on color { ColorAnimation { duration: 115; easing.type: Easing.OutCubic } } - } - - ToolTip.visible: control.compact && control.hovered + ToolTip.visible: control.compact && hover.hovered ToolTip.delay: 450 ToolTip.text: control.text } From 4493a445b9c7fe8ffcaa4a13910284f77eb7d20a Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:52:49 +0400 Subject: [PATCH 098/109] refine(qml): simplify overview hero composition --- qml/OverviewPage.qml | 257 ++++++++++++++++++++++--------------------- 1 file changed, 132 insertions(+), 125 deletions(-) diff --git a/qml/OverviewPage.qml b/qml/OverviewPage.qml index f0d31ee..b6f5eb3 100644 --- a/qml/OverviewPage.qml +++ b/qml/OverviewPage.qml @@ -72,145 +72,152 @@ Item { spacing: 14 GlassPanel { + id: hero Layout.fillWidth: true - Layout.preferredHeight: root.compactHero ? 224 : 156 + Layout.preferredHeight: root.compactHero ? 238 : 156 darkMode: root.darkMode cornerRadius: 17 elevated: false tint: root.healthy ? theme.accent : theme.danger - GridLayout { - anchors.fill: parent - anchors.margins: 18 - columns: root.compactHero ? 1 : 2 - columnSpacing: 28 - rowSpacing: 14 + 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 + } + } - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 15 - - Rectangle { - Layout.preferredWidth: 48 - Layout.preferredHeight: 48 - radius: 14 - 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 } - ColumnLayout { - Layout.fillWidth: true - spacing: 4 - - Text { - Layout.fillWidth: true - 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 { - Layout.fillWidth: true - 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 - } + 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 } } + } - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - spacing: 9 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 47 - radius: 12 - color: theme.alpha(theme.controlFill, 0.72) - border.width: 1 - border.color: theme.controlRim - - Column { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 13 - anchors.rightMargin: 13 - spacing: 2 - - Text { - width: parent.width - text: "CONFIG SOURCE" - color: theme.alpha(theme.textSecondary, 0.64) - font.family: "Inter" - font.pixelSize: 9 - font.weight: Font.DemiBold - font.letterSpacing: 0.8 - } - Text { - width: parent.width - text: root.configPath.length ? root.configPath : "Config path unresolved" - color: theme.alpha(theme.textPrimary, 0.88) - font.family: "monospace" - font.pixelSize: 10 - elide: Text.ElideMiddle - } - } + 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 } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 47 - radius: 12 - color: theme.alpha(theme.controlFill, 0.72) - border.width: 1 - border.color: root.backupAvailable ? theme.alpha(theme.accent, 0.12) : theme.controlRim - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 13 - anchors.rightMargin: 13 - spacing: 10 - - Text { - text: "RECOVERY" - color: theme.alpha(theme.textSecondary, 0.64) - font.family: "Inter" - font.pixelSize: 9 - font.weight: Font.DemiBold - font.letterSpacing: 0.8 - } - Item { Layout.fillWidth: true } - Text { - text: root.backupAvailable - ? (root.backupAge.length ? root.backupAge : "Snapshot available") - : "No snapshot yet" - color: root.backupAvailable ? theme.alpha(theme.accent, 0.90) : theme.textSecondary - font.family: "Inter" - font.pixelSize: 10 - font.weight: Font.Medium - elide: Text.ElideRight - } - } + 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 } } } From 4ed8d9f3adc869b56ec6049209132660e9081b3a Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:04:53 +0400 Subject: [PATCH 099/109] refine(qml): make sidebar selection a single visual event --- qml/NavItem.qml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index 87a80e9..5b5a739 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -25,7 +25,10 @@ Item { color: control.selected ? theme.selectedFill : hover.hovered ? theme.hoverFill : "transparent" - Behavior on color { ColorAnimation { duration: 95; easing.type: Easing.OutCubic } } + Behavior on color { + enabled: !control.selected + ColorAnimation { duration: 85; easing.type: Easing.OutCubic } + } } Item { @@ -52,7 +55,10 @@ Item { : hover.hovered ? theme.alpha(theme.textPrimary, 0.92) : theme.alpha(theme.textSecondary, 0.86) - Behavior on iconColor { ColorAnimation { duration: 95 } } + Behavior on iconColor { + enabled: !control.selected + ColorAnimation { duration: 85 } + } } } @@ -72,7 +78,10 @@ Item { font.pixelSize: 13 font.weight: control.selected ? Font.Medium : Font.Normal elide: Text.ElideRight - Behavior on color { ColorAnimation { duration: 95 } } + Behavior on color { + enabled: !control.selected + ColorAnimation { duration: 85 } + } } } From b334cf18c8d546b97f0ee2e3f107dca9432b73fd Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:05:18 +0400 Subject: [PATCH 100/109] refine(qml): finish coherent light material palette --- qml/HyprbindTheme.qml | 64 +++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/qml/HyprbindTheme.qml b/qml/HyprbindTheme.qml index f0377e5..7231594 100644 --- a/qml/HyprbindTheme.qml +++ b/qml/HyprbindTheme.qml @@ -37,81 +37,81 @@ QtObject { return "document" } - readonly property color background: darkMode ? "#101416" : "#edf2f4" - readonly property color surface: darkMode ? "#1a2023" : "#f7fafb" + 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.88) + 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.20) - readonly property color shellFill: alpha(familyShell, darkMode ? 0.62 : 0.96) - readonly property color shellRim: alpha(foreground, darkMode ? 0.095 : 0.14) + : 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.52) - readonly property color shellAccentWash: alpha(accent, darkMode ? 0.018 : 0.010) - readonly property color shellBottomShade: alpha(accent, darkMode ? 0.028 : 0.010) + : 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.62) + : alpha(surfaceHigh, 0.76) readonly property color toolbarFill: darkMode ? alpha(mix(surfaceHigh, background, 0.44), 0.30) - : alpha(mix(surfaceHigh, background, 0.16), 0.91) + : 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.24), 0.94) + : 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.18), 0.91) + : 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.10), 0.96) - readonly property color quietRim: alpha(foreground, darkMode ? 0.065 : 0.105) - readonly property color divider: alpha(foreground, darkMode ? 0.036 : 0.070) + : 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.10), 0.88) + : 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.055), 0.94) + : 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.075), 0.97) - readonly property color controlRim: alpha(foreground, darkMode ? 0.055 : 0.095) - readonly property color controlRimActive: alpha(accent, darkMode ? 0.14 : 0.26) - readonly property color controlInnerRim: alpha(foreground, darkMode ? 0.025 : 0.045) + : 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.08), 0.91) + : alpha(mix(surfaceHigh, background, 0.04), 0.96) readonly property color searchFocusedFill: searchFill - readonly property color searchRim: alpha(foreground, darkMode ? 0.055 : 0.10) - readonly property color searchFocusRim: alpha(foreground, darkMode ? 0.12 : 0.16) + 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.54) + : alpha(surfaceHigh, 0.66) readonly property color searchSpecularFocus: searchSpecular readonly property color hoverFill: darkMode ? alpha(foreground, 0.028) - : alpha(foreground, 0.052) + : alpha(foreground, 0.048) readonly property color selectedFill: darkMode ? alpha(mix(surfaceHigh, accent, 0.10), 0.54) - : alpha(mix(surfaceHigh, accent, 0.075), 0.92) - readonly property color selectedRim: alpha(accent, darkMode ? 0.10 : 0.18) + : 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.48) + : alpha(surfaceHigh, 0.56) readonly property color danger: darkMode ? "#dca3a0" : "#9c3f3b" - readonly property color dangerFill: alpha(danger, darkMode ? 0.055 : 0.070) - readonly property color dangerRim: alpha(danger, darkMode ? 0.14 : 0.20) + readonly property color dangerFill: alpha(danger, darkMode ? 0.055 : 0.065) + readonly property color dangerRim: alpha(danger, darkMode ? 0.14 : 0.18) } From 459aa28b876fabe0a54e633c44bb1c5a3b8e07a7 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:24:52 +0400 Subject: [PATCH 101/109] fix(qml): eliminate overlapping nav selection transitions --- qml/NavItem.qml | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index 5b5a739..c3f775c 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -13,21 +13,39 @@ Item { signal clicked() implicitHeight: 38 + HoverHandler { id: hover } - TapHandler { acceptedButtons: Qt.LeftButton; onTapped: control.clicked() } + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: control.clicked() + } HyprbindTheme { id: theme; darkMode: control.darkMode } + // Selection and hover are deliberately separate layers. + // Selection must switch atomically so the previous and next item + // can never both display an active transition at the same time. Rectangle { anchors.fill: parent radius: 12 antialiasing: true - color: control.selected - ? theme.selectedFill - : hover.hovered ? theme.hoverFill : "transparent" - Behavior on color { - enabled: !control.selected - ColorAnimation { duration: 85; easing.type: Easing.OutCubic } + visible: control.selected + color: theme.selectedFill + } + + Rectangle { + anchors.fill: parent + radius: 12 + antialiasing: true + visible: !control.selected + color: theme.hoverFill + opacity: hover.hovered ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 85 + easing.type: Easing.OutCubic + } } } @@ -55,10 +73,6 @@ Item { : hover.hovered ? theme.alpha(theme.textPrimary, 0.92) : theme.alpha(theme.textSecondary, 0.86) - Behavior on iconColor { - enabled: !control.selected - ColorAnimation { duration: 85 } - } } } @@ -78,10 +92,6 @@ Item { font.pixelSize: 13 font.weight: control.selected ? Font.Medium : Font.Normal elide: Text.ElideRight - Behavior on color { - enabled: !control.selected - ColorAnimation { duration: 85 } - } } } From 499c9d45fbcaed9ef416a53e3a706c48fcbbdaab Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:36:42 +0400 Subject: [PATCH 102/109] fix(qml): make sidebar state transitions atomic --- qml/NavItem.qml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index c3f775c..9e5ae3c 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -16,15 +16,13 @@ Item { HoverHandler { id: hover } TapHandler { + enabled: !control.selected acceptedButtons: Qt.LeftButton onTapped: control.clicked() } HyprbindTheme { id: theme; darkMode: control.darkMode } - // Selection and hover are deliberately separate layers. - // Selection must switch atomically so the previous and next item - // can never both display an active transition at the same time. Rectangle { anchors.fill: parent radius: 12 @@ -37,16 +35,8 @@ Item { anchors.fill: parent radius: 12 antialiasing: true - visible: !control.selected + visible: !control.selected && hover.hovered color: theme.hoverFill - opacity: hover.hovered ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: 85 - easing.type: Easing.OutCubic - } - } } Item { From e6de76d5522baf4adf7627f258dc53b944286ab6 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:37:03 +0400 Subject: [PATCH 103/109] fix(qml): suppress redundant sidebar selections --- qml/AppSidebar.qml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qml/AppSidebar.qml b/qml/AppSidebar.qml index 75a4f98..264e2bd 100644 --- a/qml/AppSidebar.qml +++ b/qml/AppSidebar.qml @@ -148,7 +148,10 @@ Rectangle { selected: root.currentPage === itemData.name visible: root.compact || !navSearch.text || itemData.name.toLowerCase().indexOf(navSearch.text.toLowerCase()) >= 0 height: visible ? implicitHeight : 0 - onClicked: root.pageSelected(itemData.name) + onClicked: { + if (root.currentPage !== itemData.name) + root.pageSelected(itemData.name) + } } } } From 9156f7bd37c09d15e78ce58d409eadd3f89d64a6 Mon Sep 17 00:00:00 2001 From: magetsu002 <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:52:31 +0400 Subject: [PATCH 104/109] chore(qml): finalize navigation polish --- qml/NavItem.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qml/NavItem.qml b/qml/NavItem.qml index 9e5ae3c..f5fe873 100644 --- a/qml/NavItem.qml +++ b/qml/NavItem.qml @@ -23,6 +23,9 @@ Item { 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 From bf6ba3ab4935283b3eaea518cadbb64698a97bb2 Mon Sep 17 00:00:00 2001 From: magetsu002 <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:35:54 +0400 Subject: [PATCH 105/109] fix: make QML primary launch path --- DEPENDENCIES.md | 18 ++++++++++++------ src/main.rs | 12 +++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) 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/src/main.rs b/src/main.rs index a6bbdfd..61e2e56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,12 +197,14 @@ fn main() { return; } - if args.iter().any(|a| a == "--qml") { - qml_bridge::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(); return; } - let app = Application::builder().application_id(APP_ID).build(); - app.connect_activate(ui::build_ui); - app.run(); + // QML is the primary UI. `--qml` remains accepted for compatibility, + // while `--gtk` provides the explicit legacy/developer fallback. + qml_bridge::run(); } From 9c37259b881b8cf08737b2e15584e254be678b85 Mon Sep 17 00:00:00 2001 From: Magetsu!! <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:36:28 +0400 Subject: [PATCH 106/109] ci: validate QML migration on pull requests --- .github/workflows/qml-migration.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/qml-migration.yml b/.github/workflows/qml-migration.yml index a89a4bc..2a154a3 100644 --- a/.github/workflows/qml-migration.yml +++ b/.github/workflows/qml-migration.yml @@ -1,8 +1,10 @@ name: QML Migration Check on: + pull_request: push: branches: + - main - feat/qml-ui-migration workflow_dispatch: From d90c65e013fa92c9b04ceaebc73638a039e83bdc Mon Sep 17 00:00:00 2001 From: magetsu002 <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:47:34 +0400 Subject: [PATCH 107/109] fix(qml): close migration parity gaps --- build.rs | 2 + qml/AppSidebar.qml | 6 +- qml/AppTopBar.qml | 30 +++++----- qml/DeveloperToolsPage.qml | 92 +++++++++++++++++++++++++++++++ qml/HyprbindTheme.qml | 2 + qml/ImportExportPage.qml | 109 +++++++++++++++++++++++++++++++++++++ qml/LookFeelPage.qml | 8 +-- qml/Main.qml | 41 +++++++++++--- qml/PageHeader.qml | 4 +- qml/UiIcon.qml | 3 + src/qml_bridge.rs | 108 ++++++++++++++++++++++++++++++++++++ 11 files changed, 374 insertions(+), 31 deletions(-) create mode 100644 qml/DeveloperToolsPage.qml create mode 100644 qml/ImportExportPage.qml diff --git a/build.rs b/build.rs index e3acd3a..20230e0 100644 --- a/build.rs +++ b/build.rs @@ -21,6 +21,8 @@ fn main() { "qml/ConfigPage.qml", "qml/HealthPage.qml", "qml/LogsPage.qml", + "qml/ImportExportPage.qml", + "qml/DeveloperToolsPage.qml", "qml/GlassPanel.qml", "qml/GlassField.qml", "qml/GlassTextArea.qml", diff --git a/qml/AppSidebar.qml b/qml/AppSidebar.qml index 264e2bd..fff536c 100644 --- a/qml/AppSidebar.qml +++ b/qml/AppSidebar.qml @@ -5,7 +5,7 @@ Rectangle { id: root property bool darkMode: true - property string currentPage: "Environment" + property string currentPage: "Overview" readonly property bool compact: width < 110 signal pageSelected(string page) @@ -102,7 +102,9 @@ Rectangle { ]}, { title: "SYSTEM", items: [ { name: "Health" }, - { name: "Logs" } + { name: "Logs" }, + { name: "Import / Export" }, + { name: "Developer tools" } ]} ] diff --git a/qml/AppTopBar.qml b/qml/AppTopBar.qml index bae4d4a..435ce38 100644 --- a/qml/AppTopBar.qml +++ b/qml/AppTopBar.qml @@ -8,13 +8,13 @@ Rectangle { required property var appWindow property bool darkMode: true - property bool realtimeEnabled: false + property bool livePreviewEnabled: false property bool backupAvailable: false property string backupAge: "" property bool compositorMaximized: false signal toggleThemeRequested() - signal toggleRealtimeRequested() + signal toggleLivePreviewRequested() signal restoreRequested() signal reloadRequested() @@ -171,8 +171,8 @@ Rectangle { } Button { - id: realtimeButton - implicitWidth: root.showActionLabels ? 122 : 42 + id: livePreviewButton + implicitWidth: root.showActionLabels ? 136 : 42 implicitHeight: 38 padding: 0 flat: true @@ -189,9 +189,9 @@ Rectangle { width: 17 height: 17 name: "realtime" - iconColor: root.realtimeEnabled + iconColor: root.livePreviewEnabled ? theme.accent - : realtimeButton.hovered + : livePreviewButton.hovered ? theme.alpha(theme.textPrimary, 0.90) : theme.alpha(theme.textSecondary, 0.82) anchors.verticalCenter: parent.verticalCenter @@ -200,13 +200,13 @@ Rectangle { Text { visible: root.showActionLabels - text: "Realtime" - color: root.realtimeEnabled || realtimeButton.hovered + 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.realtimeEnabled ? Font.Medium : Font.Normal + font.weight: root.livePreviewEnabled ? Font.Medium : Font.Normal anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 115 } } } @@ -216,9 +216,9 @@ Rectangle { width: 5 height: 5 radius: 2.5 - color: root.realtimeEnabled + color: root.livePreviewEnabled ? theme.accent - : theme.alpha(theme.textSecondary, realtimeButton.hovered ? 0.42 : 0.24) + : theme.alpha(theme.textSecondary, livePreviewButton.hovered ? 0.42 : 0.24) anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 115 } } } @@ -229,19 +229,19 @@ Rectangle { anchors.fill: parent anchors.margins: 2 radius: 10 - color: realtimeButton.pressed + color: livePreviewButton.pressed ? theme.alpha(theme.controlPressed, 0.82) - : realtimeButton.hovered + : livePreviewButton.hovered ? theme.alpha(theme.controlHover, 0.82) : "transparent" border.width: 0 Behavior on color { ColorAnimation { duration: 115; easing.type: Easing.OutCubic } } } - onClicked: root.toggleRealtimeRequested() + onClicked: root.toggleLivePreviewRequested() ToolTip.visible: hovered && !root.showActionLabels ToolTip.delay: 450 - ToolTip.text: root.realtimeEnabled ? "Disable live preview" : "Enable live preview on supported pages" + 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 } diff --git a/qml/DeveloperToolsPage.qml b/qml/DeveloperToolsPage.qml new file mode 100644 index 0000000..73dc589 --- /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 developerModeChanged(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 + } + developerModeChanged(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/HyprbindTheme.qml b/qml/HyprbindTheme.qml index 7231594..c109ce2 100644 --- a/qml/HyprbindTheme.qml +++ b/qml/HyprbindTheme.qml @@ -34,6 +34,8 @@ QtObject { 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" } 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/LookFeelPage.qml b/qml/LookFeelPage.qml index b5c230e..0393ada 100644 --- a/qml/LookFeelPage.qml +++ b/qml/LookFeelPage.qml @@ -7,7 +7,7 @@ Item { required property var bridge property bool darkMode: true - property bool realtimeEnabled: false + property bool livePreviewEnabled: false readonly property bool compactToolbar: width < 760 readonly property bool singleColumnCards: width < 860 @@ -75,7 +75,7 @@ Item { } function preview() { - if (!loaded || !realtimeEnabled) + if (!loaded || !livePreviewEnabled) return previewTimer.restart() } @@ -212,8 +212,8 @@ Item { Text { anchors.verticalCenter: parent.verticalCenter - text: root.realtimeEnabled ? "Live preview on" : "Live preview off" - color: root.realtimeEnabled ? theme.alpha(theme.accent, 0.90) : theme.alpha(theme.textSecondary, 0.60) + 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 } diff --git a/qml/Main.qml b/qml/Main.qml index 1bc0619..56eafb8 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -15,12 +15,13 @@ ApplicationWindow { color: "transparent" flags: Qt.Window | Qt.FramelessWindowHint - property string currentPage: "Environment" + property string currentPage: "Overview" property string configPath: "" property string statusMessage: "Ready." property bool backendHealthy: true property bool darkMode: true - property bool realtimeEnabled: false + property bool livePreviewEnabled: false + property bool developerMode: false property bool backupAvailable: false property string backupAge: "" @@ -39,6 +40,7 @@ ApplicationWindow { const payload = parsePayload(backend.uiSnapshot()) if (!payload.ok) return darkMode = payload.darkMode !== false + developerMode = payload.developerMode === true backupAvailable = payload.hasBackup === true backupAge = payload.backupAge || "" } @@ -54,9 +56,9 @@ ApplicationWindow { statusMessage = payload.message || (next ? "Dark theme enabled." : "Light theme enabled.") } - function toggleRealtime() { - realtimeEnabled = !realtimeEnabled - statusMessage = realtimeEnabled ? "Live preview enabled on supported pages." : "Live preview disabled." + function toggleLivePreview() { + livePreviewEnabled = !livePreviewEnabled + statusMessage = livePreviewEnabled ? "Look & Feel live preview enabled." : "Look & Feel live preview disabled." } function reloadCurrentPage() { @@ -128,11 +130,11 @@ ApplicationWindow { anchors.top: parent.top appWindow: appWindow darkMode: appWindow.darkMode - realtimeEnabled: appWindow.realtimeEnabled + livePreviewEnabled: appWindow.livePreviewEnabled backupAvailable: appWindow.backupAvailable backupAge: appWindow.backupAge onToggleThemeRequested: appWindow.toggleDarkMode() - onToggleRealtimeRequested: appWindow.toggleRealtime() + onToggleLivePreviewRequested: appWindow.toggleLivePreview() onRestoreRequested: appWindow.restoreLastBackup() onReloadRequested: appWindow.reloadCurrentPage() } @@ -213,7 +215,9 @@ ApplicationWindow { : appWindow.currentPage === "Curves" ? curvesComponent : appWindow.currentPage === "Gestures" ? gesturesComponent : appWindow.currentPage === "Health" ? healthComponent - : logsComponent + : appWindow.currentPage === "Logs" ? logsComponent + : appWindow.currentPage === "Import / Export" ? importExportComponent + : developerToolsComponent } } } @@ -235,7 +239,7 @@ ApplicationWindow { LookFeelPage { bridge: backend darkMode: appWindow.darkMode - realtimeEnabled: appWindow.realtimeEnabled + livePreviewEnabled: appWindow.livePreviewEnabled onStatus: message => appWindow.statusMessage = message onSaved: { appWindow.loadUiState(); appWindow.statusMessage = "Look & Feel saved." } } @@ -250,4 +254,23 @@ ApplicationWindow { 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 + onDeveloperModeChanged: enabled => appWindow.developerMode = enabled + } + } } diff --git a/qml/PageHeader.qml b/qml/PageHeader.qml index 4941f85..a221a2f 100644 --- a/qml/PageHeader.qml +++ b/qml/PageHeader.qml @@ -13,7 +13,7 @@ Item { readonly property bool fullPath: width >= 920 readonly property bool showSubtitle: width >= 560 - readonly property bool configContext: currentPage !== "Overview" && currentPage !== "Health" && currentPage !== "Logs" + 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 @@ -38,6 +38,8 @@ Item { 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." } diff --git a/qml/UiIcon.qml b/qml/UiIcon.qml index 197eae5..b3ef4fb 100644 --- a/qml/UiIcon.qml +++ b/qml/UiIcon.qml @@ -48,6 +48,9 @@ Canvas { 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") { diff --git a/src/qml_bridge.rs b/src/qml_bridge.rs index ccca7b8..c532c85 100644 --- a/src/qml_bridge.rs +++ b/src/qml_bridge.rs @@ -78,6 +78,30 @@ mod qobject { #[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; } } @@ -98,6 +122,7 @@ impl qobject::HyprbindBridge { to_qstring(json!({ "ok": true, "darkMode": prefs.dark_mode, + "developerMode": prefs.developer_mode, "hasBackup": has_backup, "backupAge": backup_age, })) @@ -112,6 +137,74 @@ impl qobject::HyprbindBridge { }) } + 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."); @@ -528,6 +621,21 @@ 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}; From 9c651a056e47fb00d16f359d13cccb0aa51c0fb2 Mon Sep 17 00:00:00 2001 From: magetsu002 <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:03:45 +0400 Subject: [PATCH 108/109] fix(qml): validate launch compatibility paths --- qml/DeveloperToolsPage.qml | 4 ++-- qml/Main.qml | 2 +- src/main.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/qml/DeveloperToolsPage.qml b/qml/DeveloperToolsPage.qml index 73dc589..1d46d56 100644 --- a/qml/DeveloperToolsPage.qml +++ b/qml/DeveloperToolsPage.qml @@ -9,7 +9,7 @@ Item { property bool developerMode: false signal status(string message) - signal developerModeChanged(bool enabled) + signal developerModeCommitted(bool enabled) HyprbindTheme { id: theme; darkMode: root.darkMode } @@ -24,7 +24,7 @@ Item { status(result.error || "Could not update developer mode.") return } - developerModeChanged(enabled) + developerModeCommitted(enabled) status(result.message) } diff --git a/qml/Main.qml b/qml/Main.qml index 56eafb8..53875e0 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -270,7 +270,7 @@ ApplicationWindow { darkMode: appWindow.darkMode developerMode: appWindow.developerMode onStatus: message => appWindow.statusMessage = message - onDeveloperModeChanged: enabled => appWindow.developerMode = enabled + onDeveloperModeCommitted: enabled => appWindow.developerMode = enabled } } } diff --git a/src/main.rs b/src/main.rs index 61e2e56..fc902af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,7 +200,7 @@ fn main() { if args.iter().any(|a| a == "--gtk") { let app = Application::builder().application_id(APP_ID).build(); app.connect_activate(ui::build_ui); - app.run(); + app.run_with_args(&["hyprbinds"]); return; } From b321bf43c43715efde9d1b44e08f4c62c4f6b6b8 Mon Sep 17 00:00:00 2001 From: magetsu002 <148205878+magetsu002@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:40:04 +0400 Subject: [PATCH 109/109] fix(qml): restore primary UI parity --- qml/BindsPage.qml | 119 +++++++++++++++++++++++++++++++++++++- qml/HealthPage.qml | 82 +++++++++++++++++++++++++- src/qml_catalog_bridge.rs | 3 + src/qml_health_bridge.rs | 18 +++--- 4 files changed, 208 insertions(+), 14 deletions(-) diff --git a/qml/BindsPage.qml b/qml/BindsPage.qml index 804cf09..481b8da 100644 --- a/qml/BindsPage.qml +++ b/qml/BindsPage.qml @@ -8,9 +8,13 @@ Item { 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 < 700 + readonly property bool compactToolbar: width < 760 signal status(string message) signal configResolved(string path) @@ -48,7 +52,12 @@ Item { 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() - if (!query || haystack.indexOf(query) >= 0) + 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) } } @@ -123,7 +132,7 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - height: root.compactToolbar ? 96 : 52 + height: root.compactToolbar ? 145 : 101 GlassField { id: searchField @@ -160,6 +169,109 @@ Item { 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 { @@ -181,6 +293,7 @@ Item { 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 diff --git a/qml/HealthPage.qml b/qml/HealthPage.qml index 3e7e9ee..4cb0a76 100644 --- a/qml/HealthPage.qml +++ b/qml/HealthPage.qml @@ -9,6 +9,7 @@ Item { property var allChecks: [] property string severityFilter: "ALL" property int total: 0 + property int okCount: 0 property int warnings: 0 property int failures: 0 @@ -29,6 +30,14 @@ Item { 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 } } @@ -39,6 +48,7 @@ Item { if (!payload.ok) { allChecks = [] total = 0 + okCount = 0 warnings = 0 failures = 1 healthChanged(false) @@ -49,6 +59,7 @@ Item { allChecks = payload.checks || [] total = payload.total || allChecks.length + okCount = payload.okCount || 0 warnings = payload.warnings || 0 failures = payload.failures || 0 healthChanged(failures === 0) @@ -86,6 +97,32 @@ Item { 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 { @@ -136,6 +173,34 @@ Item { } } + 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 @@ -324,16 +389,27 @@ Item { GlassPanel { visible: fixCommand.length > 0 width: parent.width - height: visible ? 38 : 0 + 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: parent.right + anchors.right: copyFixButton.left anchors.leftMargin: 12 - anchors.rightMargin: 12 + anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter text: fixCommand color: theme.alpha(theme.textPrimary, 0.76) diff --git a/src/qml_catalog_bridge.rs b/src/qml_catalog_bridge.rs index f0ea74b..0d33b36 100644 --- a/src/qml_catalog_bridge.rs +++ b/src/qml_catalog_bridge.rs @@ -57,6 +57,9 @@ impl qobject::CatalogBridge { "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, diff --git a/src/qml_health_bridge.rs b/src/qml_health_bridge.rs index 1bec240..947d5dd 100644 --- a/src/qml_health_bridge.rs +++ b/src/qml_health_bridge.rs @@ -16,6 +16,10 @@ mod qobject { #[qinvokable] #[rust_name = "snapshot"] fn snapshot(self: &HealthBridge) -> QString; + + #[qinvokable] + #[rust_name = "system_info"] + fn systemInfo(self: &HealthBridge) -> QString; } } @@ -23,6 +27,10 @@ mod qobject { 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 @@ -38,17 +46,11 @@ impl qobject::HealthBridge { }) }) .collect::>(); - let failures = checks - .iter() - .filter(|item| item.severity == crate::health::Severity::Fail) - .count(); - let warnings = checks - .iter() - .filter(|item| item.severity == crate::health::Severity::Warn) - .count(); + 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(),