commit 08e2910b9d521aeceb775d1b0c74b54b332475c9 Author: 44r0n7 <44r0n7+gitea@pm.me> Date: Mon Mar 30 22:51:56 2026 -0400 Initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e75534f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Repository Instructions + +## Project Map +- Read `PROJECT_MAP.md` before doing substantive work in this repo. +- Treat `PROJECT_MAP.md` as the primary orientation file for code structure, hot paths, feature ownership, and known issues. +- Update `PROJECT_MAP.md` when commands, modules, feature areas, persistence contracts, or major known issues change. +- After any meaningful structural or user-facing change, append a short one-line entry to the `PROJECT_MAP.md` change log. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..684a501 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3868 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.0", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "assert_fs" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" +dependencies = [ + "bitflags 2.11.0", + "libloading", + "winapi", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-build-tools" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7029c2651d9b5d5a3eea93ec8a1995665c6d3a69ce9bf6042ad9064d134736d8" +dependencies = [ + "gio", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.11.0", + "ignore", + "walkdir", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.11.0", + "com", + "libc", + "libloading", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libadwaita" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680988058c2558baf3f548a370e4e78da3bf7f08469daa822ac414842c912db" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "mangotune" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_fs", + "bytemuck", + "env_logger", + "gio", + "glib", + "glib-build-tools", + "gtk4", + "indexmap", + "libadwaita", + "log", + "nix", + "notify", + "once_cell", + "pollster", + "rayon", + "regex", + "serde", + "serde_json", + "sysinfo", + "tempfile", + "thiserror 1.0.69", + "tokio", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "wgpu", + "winit", + "xdg", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[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.4+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.0", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.9.12+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[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 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.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio 1.1.1", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.11.0", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.11.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.0", + "block2", + "bytemuck", + "calloop", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d2d455c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,73 @@ +[package] +name = "mangotune" +version = "0.1.0" +edition = "2021" +authors = ["MangoTune Contributors"] +description = "A modern MangoHud configurator for Linux" +license = "MIT" +repository = "https://github.com/your-org/mangotune" + +[[bin]] +name = "mangotune" +path = "src/main.rs" + +[[bin]] +name = "mangotune-preview" +path = "src/bin/mangotune-preview/main.rs" + +[dependencies] +# Unix signal support for launcher reload and preview self-signalling +nix = { version = "0.29", features = ["signal", "process"] } +# GUI +gtk4 = { version = "0.9", features = ["v4_12"] } +libadwaita = { version = "0.7", features = ["v1_5"] } +glib = "0.20" +gio = "0.20" + +# Async runtime (for subprocess management, file watching, preview socket server) +tokio = { version = "1", features = ["full"] } + +# Serialization (for GSettings schema, internal state) +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +# Config file parsing +indexmap = "2" + +# Error handling +anyhow = "1" +thiserror = "1" + +# Filesystem watching (live reload when config changes externally) +notify = "6" + +# XDG base directory resolution +xdg = "2" + +# Regex (for config line parsing) +regex = "1" +once_cell = "1" + +# Process detection (checking if gamemode is running, etc.) +sysinfo = "0.31" + +# Logging/tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +log = "0.4" +env_logger = "0.11" + +# Preview renderer +wgpu = "22" +winit = { version = "0.30", features = ["rwh_06"] } +pollster = "0.3" +bytemuck = { version = "1", features = ["derive"] } +rayon = "1.10" + +[build-dependencies] +glib-build-tools = "0.20" + +[dev-dependencies] +tempfile = "3" +assert_fs = "1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..513bdb0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) MangoTune Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5e17d59 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +PREFIX ?= /usr +BINDIR = $(PREFIX)/bin +DATADIR = $(PREFIX)/share +APPID = com.mangotune.MangoTune +DOCDIR = $(DATADIR)/doc/mangotune + +.PHONY: build install uninstall + +build: + cargo build --release + +install: + install -Dm755 target/release/mangotune $(DESTDIR)$(BINDIR)/mangotune + install -Dm755 target/release/mangotune-preview $(DESTDIR)$(BINDIR)/mangotune-preview + install -Dm644 data/$(APPID).desktop $(DESTDIR)$(DATADIR)/applications/$(APPID).desktop + install -Dm644 data/$(APPID).gschema.xml $(DESTDIR)$(DATADIR)/glib-2.0/schemas/$(APPID).gschema.xml + install -Dm644 data/$(APPID).metainfo.xml $(DESTDIR)$(DATADIR)/metainfo/$(APPID).metainfo.xml + install -Dm644 data/icons/$(APPID).svg $(DESTDIR)$(DATADIR)/icons/hicolor/scalable/apps/$(APPID).svg + install -Dm644 LICENSE $(DESTDIR)$(DOCDIR)/LICENSE + install -Dm644 THIRD_PARTY_LICENSES.md $(DESTDIR)$(DOCDIR)/THIRD_PARTY_LICENSES.md + glib-compile-schemas $(DESTDIR)$(DATADIR)/glib-2.0/schemas/ + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/mangotune + rm -f $(DESTDIR)$(BINDIR)/mangotune-preview + rm -f $(DESTDIR)$(DATADIR)/applications/$(APPID).desktop + rm -f $(DESTDIR)$(DATADIR)/glib-2.0/schemas/$(APPID).gschema.xml + rm -f $(DESTDIR)$(DATADIR)/metainfo/$(APPID).metainfo.xml + rm -f $(DESTDIR)$(DATADIR)/icons/hicolor/scalable/apps/$(APPID).svg + rm -f $(DESTDIR)$(DOCDIR)/LICENSE + rm -f $(DESTDIR)$(DOCDIR)/THIRD_PARTY_LICENSES.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md new file mode 100644 index 0000000..ac4dc23 --- /dev/null +++ b/PROJECT_MAP.md @@ -0,0 +1,245 @@ +# PROJECT_MAP.md +# Auto-generated project index. Update this file whenever you add, remove, or significantly change a file or feature. +# Last updated: 2026-03-28 - the old single-file Studio preview binary has been replaced with a socket-driven `mangotune-preview` companion under `src/bin/mangotune-preview/`, MangoTune now passes studio defaults through env vars and hot-reloads the preview over a Unix socket instead of only relying on CLI args/restarts, the dashboard/help/docs have been updated to match the new preview contract, MangoHud option help still comes from a verified `docs/MANGOHUD_OPTION_BEHAVIOR.md` reference cross-checked against a local upstream source snapshot plus upstream README/config docs, the schema/validator follow MangoHud semantics more closely (unsigned `offset_x`/`offset_y`, flexible xkb-style keybind validation, legacy warning for `benchmark_percentiles`, upstream `preset` as a list-valued special option, the full documented option surface now represented in the live schema, threshold-color toggles now declare the supporting metric/value/color dependencies they actually need, MangoTune strips the mistaken standalone `gpu_load` / `cpu_load` keys because MangoHud itself rejects them as top-level options, rewrites stale profile aliases like `compact`/`stretch` into modern `hud_compact`/`horizontal_stretch`, default-on MangoHud flags now serialize as explicit `=0` when disabled, and negative legacy offsets are normalized back to unsigned values), Studio preview sessions still calibrate against the actual scaled window size after launch, preview-only right-anchor emulation remains in place because fully native right-side positions regressed horizontal preview visibility, horizontal auto-layout previews still get a bounded width estimate, ordinary Studio config edits now rewrite the temp `MANGOHUD_CONFIGFILE` and rely on MangoHud's file watcher instead of restarting the window, those live preview writes are GTK-debounced to reduce churn, preset/profile/revert-style config replacements now use the same live-apply path where possible, the save menu now includes reset-to-defaults, restore-latest-safety-backup, and a real `Switch Active Config…` action, resetting still rebuilds a sane MangoTune baseline config instead of leaving an effectively empty config, explicit config switching now changes the actual editable target in `state.config.path` with save/discard guarding instead of overloading the layer-stack strip, the unreliable preview-docking preference has been removed from the UI, backup-style helper `.conf` files are filtered out of the detected per-app layer list, the top config strip now refreshes into a read-only layer-stack summary with detected conflict counts instead of a static status label or fake selector, Studio preview now exposes direct renderer controls for scene, FPS cap, particle count, particle size, repeated GPU passes, CPU interaction steps, VRAM pressure, VSync, and pause state instead of the older derived `load + advanced` model, with lower defaults (`Dark` scene, `120 FPS` cap, `1000` particles, `0.03` particle size, `1` pass, `0` VRAM pressure, `0` CPU interaction) so the built-in preview starts safely on faster GPUs and stays visually spread instead of collapsing into a tiny edge-on cluster; the particle renderer now uses standard alpha blending instead of additive bloom so larger particles remain readable against the darker scene, and the scene selector is exposed in the dashboard while also updating the running preview over the socket, app toasts now clear older queued messages and use shorter lifetimes by default, palette-style color fields get swatch controls outside the dedicated colors page too, unset swatches render as neutral placeholders instead of alarming red defaults, comma-separated palette entry fields now live in a fixed editor cluster with stable width instead of collapsing around short values, swatch edits now commit through the same validation/save-state path as text edits, section badges now share a tighter mono sizing system, the dashboard profile picker remembers the last selected profile and mirrors that choice into the save-name field, numeric controls now use compact explicit spin-button fields with typed/pasted junk filtered at the editable layer, the Layer Conflicts page now explains what counts as a true layer collision and gives a concrete repro recipe, the dashboard save menu now offers explicit config-target switching while the top strip stays a read-only layer-stack summary keyed to the actual active file, the new `HUD Order` page under `Tools` reorders currently enabled visible HUD blocks by moving their real config lines up and down with live preview refresh, and the dashboard preset buttons now load the real `Benchmark` / `Competitive` / `Performance` / `Streaming` profile files into the current workspace when those profiles exist, with the older built-in mappings kept only as fallback safety + +--- + +## 🗺️ Project Overview +MangoTune is a Rust GTK4/libadwaita desktop app for Linux gamers who want to configure MangoHud safely without editing `.conf` files by hand. It combines schema-aware validation, config-layer awareness, quick launch/testing, and desktop integrations in a native Linux GUI. + +--- + +## 🏗️ Architecture Summary +`src/main.rs` starts the libadwaita app in `src/app.rs`, which creates the main window in `src/window.rs`. The window builds page widgets from `src/ui/pages/`, while config parsing/validation/resolution lives under `src/config/`, managed live-preview state now lives in `src/preview/`, system detection under `src/system/`, launcher/test-process logic under `src/launcher/`, and launcher-string/platform helpers under `src/integrations/`. + +Representative flow: +startup detection/config load -> `window.rs` builds navigation + shared state -> page/widget edits mutate in-memory `AnnotatedConfig` -> validator updates inline state/save availability -> parser writes MangoHud config to disk when saved. + +Dashboard preview flow: +dashboard control change -> in-memory config updates -> `src/preview/mod.rs` writes a temporary preview-only config -> MangoHud Studio preview reparses the temp file in place for ordinary edits, while true preview-window changes still relaunch without touching the user’s saved config. + +--- + +## 📁 File & Folder Map + +| Path | Role | Notes | +|------|------|-------| +| `Cargo.toml` | Rust manifest | App metadata, GTK/libadwaita/runtime dependencies | +| `Cargo.lock` | Dependency lockfile | Pinned crate graph | +| `build.rs` | Build helper | Compiles GSettings schema/assets | +| `README.md` | User-facing intro | Install, rationale, launcher/config-layer behavior | +| `LICENSE` | Project license | MIT | +| `THIRD_PARTY_LICENSES.md` | Dependency licensing | Third-party notice bundle | +| `Makefile` | Install/build convenience | Release install helpers for both `mangotune` and `mangotune-preview` | +| `ROADMAP.md` | Tactical roadmap | Tracks current UX direction, near-term milestones, and preview-renderer follow-up | +| `docs/MANGOHUD_OPTION_BEHAVIOR.md` | Verified MangoHud reference | Cross-checked against a local upstream source snapshot plus upstream `README.md` and `data/MangoHud.conf`; covers parsing/defaults/positioning rules, special-case options like `preset`, and the full currently documented option surface MangoTune now represents | +| `docs/MANGOHUD_POSITION_LAB.md` | Direct MangoHud test notes | Documents the native MangoHud position lab, expected semantics, isolated-Xorg workflow on `arch.lan`, and the right-alignment test matrix | +| `data/` | Desktop app assets | Desktop entry, metainfo, schema, icon, CSS | +| `data/com.mangotune.MangoTune.gschema.xml` | GSettings schema | Window/app preferences, launcher defaults, remembered profile selection, and studio renderer tuning defaults such as load/FPS/VSync | +| `data/style.css` | App styling | Global CSS hooks for badges, compact dashboard cards, tool-page shells, custom control-row styling, and the newer sharper redesign-inspired visual treatment across shell chrome, sidebar/search/header controls, split/content surfaces, stock-ish GTK surfaces, custom unsaved-changes dialog chrome, section headers, chips, cards, rows, inputs, and callouts, now with a tighter shared mono badge system plus less rounding and denser spacing across the main shells, darker themed container seams between header/sidebar/content areas, plus a distinct active-conflict tint in the layer-stack strip | +| `docs/plan/` | Planning/spec docs | Architecture, design system, schema, phase plans | +| `src/main.rs` | Binary entry point | Runs `MangoTuneApp` | +| `src/bin/mangotune-preview/` | Companion preview renderer | Socket-driven Studio preview app split into `main.rs`/`renderer.rs`/`scene.rs`/`socket_api.rs`/`workload.rs`, with direct runtime controls for FPS cap, VSync, VRAM pressure, particle count, particle size, repeated surface passes (`gpu_passes`), pause state, and CPU interaction steps; the renderer still uses the heavier volumetric particle path and parallel CPU interaction work, but MangoTune now exposes the real knobs directly instead of deriving them from a synthetic `load` slider | +| `src/bin/preview_probe.rs` | Dev preview harness | Loads a saved profile, launches a controlled preview session, and makes local screenshot-based alignment debugging reproducible | +| `scripts/mangohud-position-lab.sh` | Direct MangoHud launcher | Runs MangoHud directly against `glxgears`/`vkcube`, records logs, captures window geometry, saves both app-window and full-root screenshots, and crops the root capture back to the app bounds when overlays are composited separately | +| `scripts/mangohud-position-matrix.sh` | Direct matrix runner | Generates margin-on/off variants from the standard sparse/full right-alignment profiles and captures them through MangoHud directly | +| `src/lib.rs` | Library root | Re-exports core modules for app/tests | +| `src/debug_log.rs` | In-app diagnostics buffer | Shared recent-log ring buffer used by the app shell and config parser, with copy/clear support for the Debug page | +| `src/app.rs` | Application bootstrap | GTK/libadwaita app setup, CSS load, startup detection | +| `src/window.rs` | Main shell | App state, header/actions, sidebar, accordion-style section navigation, async config-bar layer discovery, visible-page rebuilds with scroll-position preservation, calmer config-bar language that now frames the strip as a detected read-only layer stack summary instead of an editable target selector, periodic layer-summary refresh with real detected-conflict counts, narrower default sizing and a slimmer sidebar so the app fits better on typical 16:9 desktops, dashboard-first startup without restoring the last visited page, a clearer header-led save/reload/revert/close flow, reset-to-defaults and restore-latest-safety-backup menu actions alongside safety backups, an explicit `Switch Active Config…` flow that lets users move the real editable target between the writable global/per-app XDG configs with save/discard guarding, a sane MangoTune baseline for reset instead of an empty config, selective app-preference reset for preview/backup defaults, a save-menu `Auto backup on save` toggle instead of a mostly-empty Preferences screen, explicit shell styling hooks for the header/sidebar/config strip, and an Adwaita in-app unsaved-changes alert sheet instead of a standalone popup window | +| `src/preview/` | Managed live preview | Preview-session state, temp preview config writing, start/reload/restart/stop flow | +| `src/preview/mod.rs` | Preview controller | Uses a temporary preview config and launches the actual preview/test process directly, including persisted studio load/FPS/VSync settings, live socket-driven Studio runtime updates for load/FPS/VSync/VRAM/particle/pause controls, temp-config live-apply for ordinary Studio edits without signaling/restarting the process, safer studio restart behavior for window-size changes, legacy flag normalization before preview writes, explicit `horizontal_stretch=0` serialization for stretch-off sessions, effective HUD-width-based preview sizing, preview-only bounded widths for horizontal auto-layout sessions, per-metric auto-width estimation with separate sparse compact/non-compact tuning, preview-only right-anchor emulation for horizontal right-aligned layouts, and Studio-only post-launch window-size calibration so fractional desktop scaling does not throw off width-sensitive preview placement | +| `src/config/normalize.rs` | Legacy option cleanup | Shared normalization for old flag/bool encodings like `key=0`/`key=1`, normalization of old negative offsets back to unsigned values, plus cleanup for the mistaken standalone `gpu_load` / `cpu_load` keys; reused by save/load/profile/preview flows | +| `src/config/` | MangoHud config domain | Parser, schema, validation, resolver, help/types | +| `src/config/parser.rs` | Config I/O | Comment-preserving parse/write, duplicate-key collapse on parse, whitespace-normalized values, safer stale-backup handling, in-memory mutation, and explicit zero serialization for MangoHud flags that default on upstream (`horizontal_stretch`, `frametime`, `frame_timing`, `fps`, `gpu_stats`, `cpu_stats`, `legacy_layout`, `text_outline`) | +| `src/config/schema.rs` | Schema catalog | Typed definitions for MangoHud options and categories, now aligned more closely with upstream semantics including unsigned edge offsets, list-valued `preset`, and most practical upstream options that were previously missing | +| `src/config/validator.rs` | Validation rules | Value validation, dependency/conflict checks, flexible xkb-style keybind parsing, and warning-level handling for legacy `benchmark_percentiles` | +| `src/config/resolver.rs` | Layer discovery | Finds config sources and effective priority order, with user-facing layer labels tuned for clearer “built-in defaults” vs “saved global config” wording, backup-style helper `.conf` files filtered out of the detected per-app list, and reusable layer-conflict detection used by both the inspector page and the top shell summary | +| `src/config/types.rs` | Shared config types | `AnnotatedConfig`, `ConfigValue`, categories, validation types | +| `src/config/help.rs` | Help text map | UI summaries/default/tooltips plus friendly display titles and polished summaries per key, now loaded from `docs/MANGOHUD_OPTION_BEHAVIOR.md` instead of the older planning doc | +| `src/system/` | Environment detection | MangoHud/tool/display/GPU detection and XDG paths | +| `src/system/detect.rs` | Runtime detection | Checks MangoHud/tools/display server/GPU vendor | +| `src/system/paths.rs` | XDG path logic | Resolves MangoHud config locations | +| `src/launcher/` | Test launch flow | Spawns/stops/reloads MangoHud test processes | +| `src/launcher/runner.rs` | Process management | Launch, stop, reload, and retained X11 window-management helpers from the preview-lab/docking experiments | +| `src/integrations/` | External launchers/tools | Steam/Lutris/Heroic/GameMode helpers | +| `src/profiles/` | Profile storage | Save/list/load/delete real MangoHud `.conf` profile files under MangoTune's XDG config directory, including normalization of legacy values when applying one into the active target | +| `src/ui/` | UI layer | Page builders and reusable widgets | +| `src/ui/toast.rs` | Toast helper | Shared short-lived toast helper that dismisses older queued messages and gives errors/warnings a slightly longer lifetime | +| `src/ui/widgets/tool_page.rs` | Shared page/window scaffolds | Owns the shared page frame/header, section shells, callouts, and now the reusable utility-window shell used by raw editor and small modal-style utility windows so spacing/frame rules stay consistent | +| `src/ui/pages/mod.rs` | Page registry | Sidebar structure, navigation-page factory, current-config snapshots, shared live-preview helper, calmer section naming/order, a debounce-backed temp-config apply path for ordinary Studio edits, explicit restart flow that can still pass the current preview width into preview refreshes, and a saveable-config gate so invalid intermediate edits do not crash the preview | +| `src/ui/pages/overview.rs` | Dashboard page | Dashboard now uses the same flat page-header scaffold and outer spacing as the rest of the app, while focusing on quick everyday tuning: `Layout & Position`, `Make It Readable`, `Show These Metrics`, a compact profile utility strip with icon actions, and a single-line status strip at the bottom. Live Preview and Presets now live on their own dedicated pages under `Start`, while the dashboard keeps MangoHud-faithful unsigned offsets and the most common metric/appearance toggles | +| `src/ui/pages/live_preview.rs` | Live Preview page | Dedicated `Start` page for Studio preview launch/apply/restart/stop, preview window size, scene selection, and direct runtime tuning controls without touching the saved MangoHud config, using the same flat page-header scaffold and spacing rules as the rest of the app | +| `src/ui/pages/presets_page.rs` | Presets page | Dedicated `Start` page for loading the practical starter presets/profile-backed overlay shapes without mixing them into the preview workflow, using the same flat page-header scaffold and spacing rules as the rest of the app | +| `src/ui/pages/debug.rs` | Debug page | Tool-page style diagnostics surface with a live recent-log view, current in-memory config snapshot, and clipboard actions for support/debugging | +| `src/ui/pages/conflicts.rs` | Layer conflict view | Inline cascade/conflict inspection with filter toggles, explicit empty states, and a built-in explanation/repro recipe clarifying that this page only shows layer-on-layer overrides, not validator-style logical conflicts inside a single file | +| `src/ui/pages/appearance.rs` | Layout page | Appearance-layout schema rows | +| `src/ui/pages/colors.rs` | Color page | Appearance-color schema rows regrouped into clearer palette sections, plus moved-in FPS/GPU/CPU signal palettes and friendlier color titles/subtitles sourced from schema help | +| `src/ui/pages/typography.rs` | Typography page | Appearance-typography schema rows, including secondary-size controls for supporting text | +| `src/ui/pages/performance.rs` | Display config page | FPS/frametime/general display options, plus runtime/render-stack details and Gamescope/FSR/HDR controls, with styling controls moved out to `Colors and Theme` | +| `src/ui/pages/gpu.rs` | GPU config page | GPU-specific metrics options, with signal/load colors moved out to `Colors and Theme` | +| `src/ui/pages/cpu.rs` | CPU config page | CPU metrics options, with signal/load colors moved out to `Colors and Theme` | +| `src/ui/pages/memory.rs` | Memory config page | RAM/VRAM swap display options | +| `src/ui/pages/io_network.rs` | IO/network page | Disk/network display options | +| `src/ui/pages/media_player.rs` | Media player page | Media integration metrics | +| `src/ui/pages/battery.rs` | Battery page | Battery-related overlay options | +| `src/ui/pages/keybindings.rs` | Keybinding page | Toggle/shortcut options | +| `src/ui/pages/fps_limits.rs` | FPS limits page | MangoHud frame cap/list options | +| `src/ui/pages/logging.rs` | Logging page | Logging and dump options | +| `src/ui/pages/blacklist.rs` | Blacklist page | Injection/config-exclusion options plus an advanced special-directives section for `help` and `inherit` | +| `src/ui/pages/opengl_quirks.rs` | OpenGL page | OpenGL-specific workarounds | +| `src/ui/pages/raw_editor.rs` | Raw editor | Tool-page style raw-config launcher with workspace stats, lighter-use guidance, and a richer editor pop-out now using the shared utility-window shell instead of hand-rolled framing | +| `src/ui/pages/integrations.rs` | Integrations page | Tool-page style Steam/Lutris/Heroic/GameMode helpers with live Steam command generation | +| `src/ui/widgets/toggle_row.rs` | Schema row factory | Builds switch/combo/entry rows plus explicit compact spin-button suffix controls for numeric schema fields, applies verified friendly titles/help text, surfaces inline validation, refreshes live preview after edits, routes palette-style comma-separated color fields to richer swatch controls, treats free-text entries as commit-based preview updates that only fire after an actual edit, and filters typed/pasted junk out of numeric controls at both the key and editable layers instead of only validating later | +| `src/ui/widgets/hotkey_row.rs` | Hotkey row widget | Keybinding editor rows with a modal edit dialog now using the shared utility-window shell instead of a one-off padded box | +| `src/ui/widgets/tool_page.rs` | Tool-page shell builder | Shared custom page hero/section/callout helpers used to move deep pages away from stock preferences layouts, including top-aligned section badges, explicit section-header wrappers, and chip rows that follow the redesign-inspired shell language | +| `src/ui/widgets/color_row.rs` | Color control widget | Hex + color-dialog row with shared live-preview refresh, plus palette-list swatches for comma-separated color fields like `fps_color`, `gpu_load_color`, and `cpu_load_color`; text editing now validates live but only refreshes preview on commit/focus-leave after a real edit, unset swatches now render in a neutral placeholder state, and palette entry fields live in a fixed editor cluster instead of resizing around current content | +| `src/ui/widgets/hotkey_row.rs` | Hotkey widget | Keybinding capture/edit UI with shared live-preview refresh | +| `src/ui/widgets/cascade_view.rs` | Conflict widget | Cascade/layer visualization plus filter-aware visibility helpers | +| `src/ui/widgets/validation_label.rs` | Validation helper | Inline row error presentation | +| `src/ui/widgets/color_utils.rs` | Shared color helpers | Hex/RGBA conversion utilities shared by dashboard and color rows | + +--- + +## 🧭 Hot Paths +- Add or change a major window/workflow: `src/window.rs`, `src/ui/pages/mod.rs`, affected page module(s), `data/style.css` +- Change a MangoHud option’s validation/type/category: `src/config/schema.rs`, `src/config/help.rs`, `src/config/validator.rs`, affected page in `src/ui/pages/` +- Change config parsing or write behavior: `src/config/parser.rs`, `src/config/types.rs`, parser tests +- Change launcher/test-preview behavior: `src/preview/mod.rs`, `src/bin/mangotune-preview.rs`, `src/launcher/runner.rs`, `src/ui/pages/overview.rs`, `data/com.mangotune.MangoTune.gschema.xml`, `Makefile` +- Change direct MangoHud validation flow: `scripts/mangohud-position-lab.sh`, `scripts/mangohud-position-matrix.sh`, `docs/MANGOHUD_POSITION_LAB.md` +- Change profile behavior: `src/profiles/mod.rs`, `src/ui/pages/overview.rs` +- Change config-layer/conflict behavior: `src/config/resolver.rs`, `src/ui/pages/conflicts.rs`, `src/window.rs` +- Change desktop app settings/preferences: `data/com.mangotune.MangoTune.gschema.xml`, `build.rs`, `src/window.rs` +- Change integration strings/behavior: `src/integrations/*.rs`, `src/ui/pages/integrations.rs` + +--- + +## 🔑 Key Concepts & Domain Terms +- `AnnotatedConfig`: in-memory MangoHud config preserving file lines plus indexed option state +- Config layer/cascade: MangoHud config priority stack (env > app-local > per-app > global > defaults) +- `SchemaEntry`: typed definition for one MangoHud option, including category/dependencies/conflicts +- Validation state: per-key result map used to block invalid saves and surface inline errors +- Managed live preview: a preview-only MangoHud config generated from unsaved in-memory edits and applied to a real preview process +- Companion renderer: `mangotune-preview`, a dedicated animated preview executable for MangoHud tuning +- Test launcher: helper flow that launches a real process with MangoHud enabled for verification +- Profile: a saved real MangoHud `.conf` file stored under MangoTune’s XDG config dir and applied into the active config target on demand + +--- + +## 🔗 Persistence / Data Contracts +- MangoHud global config default: `~/.config/MangoHud/MangoHud.conf` (resolved via `src/system/paths.rs`) +- Profile storage: `~/.config/mangotune/profiles/*.conf` unless `MANGOTUNE_PROFILES_DIR` overrides it +- GSettings schema: `com.mangotune.MangoTune` +- Preferred preview scene setting: GSettings key `preview-scene` now defaults to `studio` +- Studio preview tuning settings: GSettings keys `preview-studio-scene`, `preview-fps-cap`, `preview-vsync`, `preview-vram-pressure`, `preview-particle-count`, `preview-particle-size`, `preview-gpu-passes`, and `preview-interaction-steps` (`preview-load` remains only as a legacy compatibility key) +- Companion renderer binary: `mangotune-preview` is discovered via env override, sibling binary, or `PATH` +- Launcher environment contract: MangoTune sets `MANGOHUD=1` and `MANGOHUD_CONFIGFILE=` for launched preview/test processes +- Preview temp config contract: live preview writes a temporary MangoHud config under the system temp dir instead of modifying the user’s saved config, and forces `no_display` off for preview visibility +- Config file format contract: MangoHud `.conf` syntax with comment preservation and “last duplicate wins” behavior +- Game config hint DB: bundled at `data/game_config_db.toml`, generated/maintained via `scripts/build_game_config_db.py`, documented in `docs/GAME_CONFIG_DB.md`, and used by the per-app config creation flow to search games and suggest likely executable/config names + +--- + +## 🖥️ User-Facing Command / Feature Surface +- Main dashboard with live preview, workspace status, visual position picking, smarter edge-gap controls, quick appearance controls, starter presets, common metrics, profile save/restore/delete, page-jump shortcuts, preview window size controls, and a denser compact layout that uses less vertical space +- Header-led save flow with `Save Config` as the single primary save surface, plus secondary actions for saving a copy, discarding unsaved changes, and creating a safety backup +- Sidebar navigation with search plus accordion-style section expansion, calmer section naming/order, and a less heavy “config layer” top bar +- Startup now always opens on the dashboard instead of restoring the last visited page +- Deep settings pages with a custom control-deck shell instead of plain stock preferences groups for the main appearance/display/behavior surfaces +- Config layer selector + conflict visibility +- Appearance controls: layout, colors, typography +- Metrics controls: performance, GPU, CPU, memory, I/O/network, media, battery +- Behavior controls: keybindings, FPS limits, logging, blacklist +- Advanced tools: OpenGL quirks, raw editor +- Live preview/testing: managed Studio-only preview from unsaved edits using `mangotune-preview`, with dashboard-level defaults, auto-refreshing status, dashboard window-size controls, preview-only `no_display` override, explicit `horizontal_stretch=0` writes when stretch is disabled, effective-HUD-width window sizing for horizontal auto-layout, preview-only right-offset compensation for right-aligned horizontal layouts, Studio-specific post-launch width calibration for scaled desktops, temp-config live-apply for ordinary Studio config edits, debounced live preview writes for high-frequency controls, safer studio restart behavior for true window-size changes, wheel-safe dashboard controls, live runtime updates for Studio load/FPS/VSync, advanced Studio runtime controls for particle density/VRAM pressure/pause, and an in-window control strip plus visible key hints for the studio renderer +- Integrations: Steam, Lutris, Heroic, GameMode +- Profiles: save the active config target as a profile, then apply a profile back into the active target + +--- + +## 🧪 Test Coverage Map +- Unit tests live inline in Rust modules such as `src/config/parser.rs`, `src/launcher/runner.rs`, `src/profiles/mod.rs`, `src/integrations/steam.rs`, and `src/ui/pages/overview.rs` +- Parser/schema/validator/resolver changes should get module tests updated where the logic lives +- Launcher/preview behavior changes usually require tests in `src/launcher/runner.rs`, `src/preview/mod.rs`, `src/bin/mangotune-preview.rs` when practical, and any overview helpers that drive preview state +- UI shell/page layout currently has light automated coverage; visual/interaction changes need manual GTK testing in addition to `cargo test` + +--- + +## 🧩 Feature Areas & Ownership Map +- **App shell and navigation**: `src/app.rs`, `src/window.rs`, `src/ui/pages/mod.rs`, `data/style.css` +- **MangoHud config engine**: `src/config/` +- **Runtime/system detection**: `src/system/` +- **Preview/test launching**: `src/preview/`, `src/bin/mangotune-preview.rs`, `src/launcher/`, `src/ui/pages/overview.rs` +- **Profiles**: `src/profiles/`, `src/ui/pages/overview.rs` +- **Desktop integrations**: `src/integrations/`, `src/ui/pages/integrations.rs` +- **Page-specific UI**: `src/ui/pages/` +- **Reusable controls**: `src/ui/widgets/` + +--- + +## ⚠️ Known Issues / Tech Debt +- The dedicated preview renderer is still early and could use even richer scene/load presets plus deeper in-preview UI polish +- The preview renderer still needs more ambitious scene design and richer in-window guidance beyond the current first pass +- UI test coverage is limited; large UX changes currently rely heavily on manual validation + +--- + +## 🔄 Update Triggers +Update this file when: +- a command, route, page, or feature area is added/removed/renamed +- a meaningful source file/module is added/removed/renamed +- a persistence format, config location rule, or launcher contract changes +- architecture changes materially +- a major known issue is discovered or resolved + +Do not update this file for: +- tiny wording tweaks +- purely local refactors with no ownership/structure impact +- dependency bumps with no workflow impact + +--- + +## 📝 Change Log (agent session) +- 2026-03-22 Initial map created +- 2026-03-22 Added `src/preview/mod.rs`, replaced Overview with a dashboard-first live-preview workflow, and removed the old launch-bar widget +- 2026-03-22 Added `ROADMAP.md`, persisted the preferred preview scene, and expanded the dashboard with common metric toggles +- 2026-03-22 Added searchable sidebar navigation, dashboard page-jump actions, and the `mangotune-preview` companion renderer +- 2026-03-22 Added dashboard-level controls for the studio preview renderer's scene, load factor, and FPS cap +- 2026-03-22 Switched preview launching to direct-process tracking and added in-window studio controls with more distinct scene rendering +- 2026-03-22 Added automatic preview-state refresh in the dashboard plus safer reload fallback behavior +- 2026-03-22 Added one-click starter presets to the dashboard for common overlay setups +- 2026-03-22 Collapsed deeper sidebar sections by default while keeping search fully discoverable +- 2026-03-22 Added a dashboard workspace-status card with validation summary, save state, runtime details, and quick actions +- 2026-03-22 Made studio preview reload restart safely and changed the sidebar to auto-collapse other sections when opening a new one +- 2026-03-22 Forced preview visibility when saved configs use `no_display` and stopped pinning the scene selector after previews stop +- 2026-03-22 Added screen-clamped preview docking, dashboard controls for preview window size, and a shared custom shell for the main deep settings pages +- 2026-03-22 Fixed the validation-save deadlock, moved layer conflicts inline, added profile deletion, and made dashboard preview controls safer to scroll/use +- 2026-03-22 Moved raw editor and integrations onto the custom shell and fixed Steam launch-option generation to follow the selected method +- 2026-03-23 Gave right-aligned previews extra width and stopped side-docking them to reduce off-screen HUD placement failures +- 2026-03-23 Added explicit empty states for filtered conflicts/integration detection and surfaced profile-folder open failures instead of failing silently +- 2026-03-23 Extracted shared color conversion helpers and deduplicated the saved-snapshot restore path in window actions +- 2026-03-23 Moved config-layer discovery off the synchronous shell path and hardened parser round-trips against duplicate keys and accidental value whitespace +- 2026-03-23 Rebuilt visible pages from shared state on navigation, refreshed live preview from non-dashboard editors, widened right-aligned horizontal preview safety margins, narrowed default shell sizing, and made stale `.mangotune.bak` paths non-fatal during save +- 2026-03-23 Normalized legacy flag values across preview/profile/reload paths and refreshed the visible page + preview after revert/reload/profile restore so stale `horizontal_stretch=0`-style states don’t linger +- 2026-03-23 Added a preview-only bounded width fallback for horizontal auto-layout previews, renamed/moved the sidebar layer viewer section, softened config-bar labels, narrowed the default shell width again, and simplified raw-editor guidance +- 2026-03-23 Preserved scroll position during visible-page rebuilds, removed the extra dashboard guidance card, tightened dashboard spacing, and narrowed sidebar/window defaults further +- 2026-03-23 Fixed disabled `horizontal_stretch` serialization, made preview reload use the current computed width, reset `horizontal_stretch` in dashboard presets, and stopped dashboard polling timers from leaking across page rebuilds +- 2026-03-24 Corrected the MangoHud behavior references against the deep-research report and local upstream source, including `preset`, `benchmark_percentiles`, `inherit`, and keybind/default caveats +- 2026-03-24 Aligned MangoTune's live schema and validation with the corrected MangoHud reference by making offsets unsigned, broadening keybind parsing, warning on legacy `benchmark_percentiles`, treating `preset` as a list-valued upstream option, and adding the missing practical upstream options that belong in the GUI +- 2026-03-24 Surfaced the last documented upstream options in the UI too, including runtime/Gamescope sections on the Performance page, secondary typography sizing, and an advanced Blacklist subsection for special directives like `help` and `inherit` +- 2026-03-24 Stopped text-entry fields from reloading the preview on every keystroke, added a saveable-config gate before preview reloads, and tightened threshold-color dependencies so incomplete threshold setups do not immediately destabilize the preview +- 2026-03-23 Added preview-only right-offset compensation for horizontal right-aligned layouts after stretch-off was confirmed fixed but right alignment still failed +- 2026-03-23 Added Studio-only post-launch preview-size calibration so right-aligned horizontal HUD placement can use the actual scaled Studio window width instead of only the requested logical width +- 2026-03-24 Expanded the MangoHud source-truth docs with explicit `full` exclusions, offset parsing quirks, and isolated-X capture limitations after direct-lab verification on `arch.lan` +- 2026-03-24 Switched shared control rows, colors, and keybindings to friendly display titles sourced from the verified MangoHud reference instead of raw key-shaped labels +- 2026-03-24 Added polished UI-facing summaries for common MangoHud options so shared rows and keybinding descriptions read like product copy instead of schema text +- 2026-03-24 Simplified the shell save flow by making the header the single primary save surface, softening layer-bar wording, and always starting the app on the dashboard +- 2026-03-24 Moved FPS/GPU/CPU color editors out of the telemetry pages and consolidated them under `Colors and Theme` so metric pages focus on what is shown while appearance pages own styling +- 2026-03-24 Regrouped `Colors and Theme` into clearer sections for core palette, hardware labels, signal thresholds, and extra integrations/status colors +- 2026-03-25 Replaced the placeholder hotkey toggler with a real modal keybind editor, guarded color-entry/swatch synchronization to avoid recursive GTK edit loops, and corrected GPU/CPU threshold-color dependencies to point at `gpu_stats` / `cpu_stats` +- 2026-03-25 Switched ordinary Studio preview edits from restart-driven reloads to debounced temp-config rewrites so MangoHud can reparse changes in place without relaunching the window +- 2026-03-25 Changed dashboard profile restore and presets to use the same Studio live-apply path instead of forcing a preview restart, removed the redundant preview-renderer label from the dashboard card, and added save-menu actions for reset-to-defaults and restore-latest-safety-backup +- 2026-03-26 Removed the unreliable preview-docking preference from the UI, switched dashboard offset controls back to MangoHud’s real unsigned offset semantics, normalized legacy negative offsets on load, and started writing explicit `=0` for upstream default-on flags like `frametime` and `frame_timing` +- 2026-03-24 Simplified preview-only right-anchor emulation by dropping fake vertical offsets and adding a small no-margin right-edge safety for horizontal right-aligned layouts +- 2026-03-24 Tried preserving native MangoHud right-side positions directly, but reverted that change after it made horizontal previews disappear again; preview-side right-anchor emulation remains the last usable path while MangoHud-native behavior is being investigated separately +- 2026-03-24 Added a direct MangoHud position lab and matrix runner so margin/compact/right-anchor behavior can be validated outside MangoTune on a real X11 session, including an isolated-Xorg workflow for `arch.lan` diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d837fd --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# MangoTune + +MangoTune is a GTK4 + libadwaita desktop configurator for MangoHud on Linux. + +It focuses on correctness and maintainability over ad-hoc editing: schema-aware validation, +config-layer conflict visibility, and comment-preserving writes. + +Recent UX direction: + +- Dashboard-first landing screen instead of a plain settings browser +- Managed live preview using MangoTune’s built-in Studio renderer +- Companion `mangotune-preview` renderer for a dedicated studio-style preview scene +- Dashboard controls for Studio load, FPS cap, VSync, and runtime tuning +- Dashboard controls for Studio preview window width/height +- Socket-driven studio preview reloads without touching the user’s real config +- One-click starter presets for minimal, competitive, balanced, and streamer-style overlays +- Sidebar search plus collapsed-by-default deeper sections so the first-run shell feels less crowded +- Workspace-status card for save state, validation summary, runtime info, and quick actions +- Accordion-style sidebar sections and safer studio-preview refresh behavior +- Common appearance tuning controls up front, with advanced pages still available in the sidebar +- A custom control-deck shell for the main deep settings pages instead of untouched stock preferences layouts +- Inline layer-conflict inspection instead of a second popup window +- Dashboard profile save, restore, and delete actions + +## Why use MangoTune instead of GOverlay + +- Strong option validation across MangoHud types and value ranges +- Config layer resolver showing which value wins (global, per-app, env, app-local) +- Safer writes with backup and atomic replacement behavior +- Managed live preview using the built-in Studio renderer without forcing you to save first +- Native Rust + GTK4/libadwaita app with predictable behavior + +## Install + +### Distro package (when available) + +Use your distribution package manager for `mangotune`. + +### Build from source + +Requirements: + +- Rust stable toolchain +- GTK4 and libadwaita development packages +- `glib-compile-schemas` + +Build and run: + +```bash +cargo run +``` + +Release build and install: + +```bash +cargo build --release +sudo make install +``` + +## Config priority model + +MangoTune follows MangoHud's effective config layering. Higher priority overrides lower priority: + +1. `MANGOHUD_CONFIG` / `MANGOHUD_CONFIGFILE` (environment) +2. App-local `MangoHud.conf` +3. Per-app XDG config (`~/.config/MangoHud/.conf`) +4. Global XDG config (`~/.config/MangoHud/MangoHud.conf`) +5. Compiled defaults + +Use the **Layer Conflicts** page to inspect shadowed values and winning layers. + +## Live preview + +From the dashboard, start a live preview window using MangoTune’s built-in Studio renderer. + +How it works: + +- MangoTune writes your current in-memory config to a temporary preview-only file +- It launches the Studio preview against that temporary file +- Reload/restart lets you iterate on appearance settings without touching your saved config +- The dashboard tunes the Studio renderer directly: load factor, FPS cap, VSync, particle density, VRAM pressure, and pause state +- The dashboard tunes Studio preview window width/height +- Studio preview changes are written to a temporary config and sent into the preview over its control socket +- The dashboard preview status auto-refreshes and failed hot reloads fall back to restart more safely +- Preview sessions temporarily force the HUD visible even if your saved config uses `no_display` +- Docked preview windows now clamp back inside the screen instead of disappearing off the edge more easily +- Right-aligned HUD previews now get extra width and skip side-docking so the overlay is less likely to vanish off-screen + +Environment set by launcher: + +- `MANGOHUD=1` +- `MANGOHUD_CONFIGFILE=` + +Studio preview behavior: + +- direct-process tracking so live preview reload/restart targets the real preview app +- dashboard-driven load/FPS/VSync defaults that are passed into the preview at launch +- socket API for runtime load/FPS/VSync/config reload commands +- advanced runtime controls for particle density, VRAM pressure, and pause state + +`mangotune-preview` also supports standalone use: + +```bash +cargo run --bin mangotune-preview +``` + +Standalone preview examples: + +```bash +MANGOHUD=1 \ +MANGOHUD_CONFIGFILE=/tmp/mangotune-preview.conf \ +MANGOTUNE_SOCKET=/tmp/mangotune-preview.sock \ +MANGOTUNE_PREVIEW_SCENE=motion \ +MANGOTUNE_PREVIEW_LOAD=2.5 \ +MANGOTUNE_PREVIEW_FPS_CAP=500 \ +cargo run --bin mangotune-preview +``` + +Notes: + +- `0` FPS cap means uncapped +- standalone preview needs a graphical session (`DISPLAY` or Wayland variables available) +- standalone control happens through the Unix socket declared in `MANGOTUNE_SOCKET` + +## Contributing + +- Run formatting and checks before proposing changes: + +```bash +cargo fmt +cargo clippy --all-targets -- -D warnings +cargo test +``` + +- Keep behavior aligned with the planning docs in `docs/plan/`. +- Add tests for parser, validation, resolver, integrations, and launcher changes. + +## License + +MangoTune is licensed under the MIT License. See `LICENSE`. + +Dependency licenses are documented in `THIRD_PARTY_LICENSES.md`. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..5922711 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,118 @@ +# ROADMAP.md +# Tactical roadmap for MangoTune. Update this when UX direction, major milestones, or technical priorities change. +# Last updated: 2026-03-25 - the old Studio preview binary has been replaced with a socket-driven renderer, so the remaining roadmap is mostly renderer polish, consistent hot-reload behavior, and manual validation + +--- + +## Current Status +- Dashboard-first overview is in place +- Managed live preview works through real test apps and a companion preview renderer using a temporary preview config +- Common appearance tuning is now surfaced up front +- Profiles are available from the landing page +- Studio preview scene, load, and FPS-cap controls now live in the dashboard and persist between runs +- Studio preview now uses a socket-driven companion renderer instead of the older CLI-only shader preview +- Dashboard preview state now auto-refreshes instead of only updating on button presses +- Dashboard now includes one-click starter presets for minimal, competitive, balanced, and streamer-style HUD setups +- Sidebar sections now collapse by default for the deeper areas, while search still reveals everything +- Dashboard now also surfaces save state, validation summary, runtime detection, and quick actions in one place +- Sidebar section opening now collapses the others so only one section stays expanded at a time +- Dashboard now also controls preview window width/height for Studio, `vkcube`, and `glxgears` +- Main appearance/display/behavior pages have started moving onto a custom control-deck shell instead of stock preferences-page layouts +- Dashboard scrolling no longer accidentally tweaks nearby preview controls +- Profiles can now be deleted from the landing page +- The conflicts experience is now inline instead of opening a second window +- Save is now blocked safely when validation errors exist instead of letting the user fall into a deadlock path +- Raw Editor and Integrations now use the same custom control-deck language as the main settings pages +- Right-aligned HUD previews now get extra width and avoid side-docking so they are less likely to disappear off-screen + +--- + +## Immediate Focus + +### 1. Dashboard Coverage +- Expand the dashboard so it covers the most common MangoHud workflows +- Keep common metric toggles, preview controls, and visual tuning on the first screen +- Reduce how often users need to open deep schema pages +- Keep refining preset bundles so they feel like strong default starting points rather than generic samples +- Keep refining the workspace/status card so validation and save-state feedback stay obvious + +### 2. Live Preview Reliability +- Keep improving the managed preview flow around real MangoHud processes +- Make reload/restart behavior more explicit when a change does not hot-apply cleanly +- Preserve useful preview preferences between runs +- Keep tightening the studio-renderer workflow and preview-state feedback +- Continue validating MangoHud hot-reload behavior against more real-world options and drivers +- Keep checking whether any non-studio scenes still need reload-to-restart fallback tweaks +- Keep refining preview docking/visibility behavior on different monitors and layouts, especially for right-edge HUD placements +- Keep validating that all starter presets behave correctly when the preview is docked to the side + +### 3. Navigation Simplification +- Make the sidebar feel less intimidating +- Re-group advanced pages more clearly +- Keep full MangoHud coverage without dumping all complexity on first use +- Keep tuning which sections should be collapsed/open by default as the dashboard grows + +--- + +## Next Milestones + +### Milestone A — Better Dashboard +- Add quick metric toggles for common FPS/GPU/CPU/memory items +- Add smarter dashboard guidance and page jump-offs +- Improve validation and dependency handling for dashboard controls +- Consider promoting more edge-aware controls like right/bottom gap handling into other relevant pages, not only the dashboard + +### Milestone B — Preview Studio +- Expand the new dedicated preview renderer/workflow +- Show clearer preview state: running, reloaded, restart recommended, stopped +- Refine studio tuning UX now that renderer load/scene/FPS controls are exposed from the main app +- Consider richer scene presets or staged load profiles +- Unify Studio hot-reload behavior with the temp-config + signal/socket model used by the rest of previewing + +### Milestone C — Custom Preview Renderer +- Evolve the new `mangotune-preview` Rust binary beyond the initial particle scene +- Keep it lightweight but configurable enough to create realistic MangoHud load +- Target dark/bright/high-motion/readability scenes +- Default to an uncapped renderer or a very high cap, with a safe default around 500 FPS if needed + +### Milestone D — Full UX Polish +- Revisit the remaining settings pages and bring them closer to the new visual direction +- Reduce generic libadwaita-preferences feel +- Add more helpful inline guidance for advanced features and config-layer behavior +- Keep tightening copy, spacing, and visual hierarchy across the remaining advanced workflows +- Replace manual MangoHud keybind text entry with real key-chord capture UX in the Keybindings page +- Re-verify `docs/MANGOHUD_OPTION_BEHAVIOR.md` against upstream whenever MangoHud releases change option semantics + +--- + +## Longer-Term Ideas +- Scene presets tuned for different classes of hardware +- Better Wayland-friendly preview/window management behavior +- Optional import/export/share flow for profile bundles +- More guided onboarding for first-time MangoHud users + +--- + +## Risks / Constraints +- A truly embedded real-time preview inside GTK may remain compositor- and platform-sensitive +- MangoHud hot reload behavior may vary by option, so restart paths must remain first-class +- The app still needs to preserve access to the full MangoHud option surface even while simplifying the main workflow + +--- + +## Change Log +- 2026-03-22 Initial roadmap created after dashboard-first redesign and managed preview foundation +- 2026-03-22 Added the `mangotune-preview` companion renderer and shifted preview work toward deeper renderer integration +- 2026-03-22 Added dashboard-level studio renderer tuning with persisted scene, load, and FPS-cap preferences +- 2026-03-22 Switched preview launching to track real processes directly and added in-window studio controls plus more distinct scene designs +- 2026-03-22 Added automatic preview-status refresh in the dashboard and safer reload-to-restart fallback behavior +- 2026-03-22 Added one-click starter presets to the dashboard for common overlay styles +- 2026-03-22 Collapsed deeper sidebar sections by default to reduce intimidation without removing discoverability +- 2026-03-22 Added a dashboard workspace-status card with validation summary, save state, runtime info, and quick actions +- 2026-03-22 Made studio preview reload restart safely instead of sending it down a brittle signal path and turned the sidebar into a one-open-at-a-time accordion +- 2026-03-22 Added dashboard preview-size controls, clamped preview docking inside the screen, and started moving deep settings pages onto a custom control-deck shell +- 2026-03-22 Fixed the preset/save deadlock path, added dashboard profile deletion plus smarter width/offset controls, and made layer conflicts an inline page +- 2026-03-22 Brought Raw Editor and Integrations onto the same redesigned shell and fixed Steam launch generation to react live to method changes +- 2026-03-23 Added a right-aligned preview safety path that widens the preview and skips side-docking when the HUD is anchored on the right +- 2026-03-25 Added real hotkey capture UX as a roadmap item instead of treating typed MangoHud syntax as the final keybinding experience +- 2026-03-25 Replaced the old Studio preview binary with a socket-driven `mangotune-preview` renderer that takes dashboard defaults from env and reload commands over a Unix socket diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..109bea3 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,198 @@ +# Third-Party Licenses + +This list is generated from `cargo metadata` license fields. +Please verify transitive dependency licenses before distribution. + +| Crate | Version | License | +|---|---:|---| +| aho-corasick | 1.1.4 | Unlicense OR MIT | +| anstyle | 1.0.13 | MIT OR Apache-2.0 | +| anyhow | 1.0.102 | MIT OR Apache-2.0 | +| assert_fs | 1.1.3 | MIT OR Apache-2.0 | +| autocfg | 1.5.0 | Apache-2.0 OR MIT | +| bitflags | 1.3.2 | MIT/Apache-2.0 | +| bitflags | 2.11.0 | MIT OR Apache-2.0 | +| bstr | 1.12.1 | MIT OR Apache-2.0 | +| bytes | 1.11.1 | MIT | +| cairo-rs | 0.20.12 | MIT | +| cairo-sys-rs | 0.20.10 | MIT | +| cfg-expr | 0.20.7 | MIT OR Apache-2.0 | +| cfg-if | 1.0.4 | MIT OR Apache-2.0 | +| cfg_aliases | 0.2.1 | MIT | +| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | +| crossbeam-channel | 0.5.15 | MIT OR Apache-2.0 | +| crossbeam-deque | 0.8.6 | MIT OR Apache-2.0 | +| crossbeam-epoch | 0.9.18 | MIT OR Apache-2.0 | +| crossbeam-utils | 0.8.21 | MIT OR Apache-2.0 | +| difflib | 0.4.0 | MIT | +| doc-comment | 0.3.4 | MIT | +| either | 1.15.0 | MIT OR Apache-2.0 | +| equivalent | 1.0.2 | Apache-2.0 OR MIT | +| errno | 0.3.14 | MIT OR Apache-2.0 | +| fastrand | 2.3.0 | Apache-2.0 OR MIT | +| field-offset | 0.3.6 | MIT OR Apache-2.0 | +| filetime | 0.2.27 | MIT/Apache-2.0 | +| foldhash | 0.1.5 | Zlib | +| fsevent-sys | 4.1.0 | MIT | +| futures-channel | 0.3.32 | MIT OR Apache-2.0 | +| futures-core | 0.3.32 | MIT OR Apache-2.0 | +| futures-executor | 0.3.32 | MIT OR Apache-2.0 | +| futures-io | 0.3.32 | MIT OR Apache-2.0 | +| futures-macro | 0.3.32 | MIT OR Apache-2.0 | +| futures-task | 0.3.32 | MIT OR Apache-2.0 | +| futures-util | 0.3.32 | MIT OR Apache-2.0 | +| gdk-pixbuf | 0.20.10 | MIT | +| gdk-pixbuf-sys | 0.20.10 | MIT | +| gdk4 | 0.9.6 | MIT | +| gdk4-sys | 0.9.6 | MIT | +| getrandom | 0.4.2 | MIT OR Apache-2.0 | +| gio | 0.20.12 | MIT | +| gio-sys | 0.20.10 | MIT | +| glib | 0.20.12 | MIT | +| glib-build-tools | 0.20.0 | MIT | +| glib-macros | 0.20.12 | MIT | +| glib-sys | 0.20.10 | MIT | +| globset | 0.4.18 | Unlicense OR MIT | +| globwalk | 0.9.1 | MIT | +| gobject-sys | 0.20.10 | MIT | +| graphene-rs | 0.20.10 | MIT | +| graphene-sys | 0.20.10 | MIT | +| gsk4 | 0.9.6 | MIT | +| gsk4-sys | 0.9.6 | MIT | +| gtk4 | 0.9.7 | MIT | +| gtk4-macros | 0.9.5 | MIT | +| gtk4-sys | 0.9.6 | MIT | +| hashbrown | 0.15.5 | MIT OR Apache-2.0 | +| hashbrown | 0.16.1 | MIT OR Apache-2.0 | +| heck | 0.5.0 | MIT OR Apache-2.0 | +| id-arena | 2.3.0 | MIT/Apache-2.0 | +| ignore | 0.4.25 | Unlicense OR MIT | +| indexmap | 2.13.0 | Apache-2.0 OR MIT | +| inotify | 0.9.6 | ISC | +| inotify-sys | 0.1.5 | ISC | +| itoa | 1.0.17 | MIT OR Apache-2.0 | +| kqueue | 1.1.1 | MIT | +| kqueue-sys | 1.0.4 | MIT | +| lazy_static | 1.5.0 | MIT OR Apache-2.0 | +| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | +| libadwaita | 0.7.2 | MIT | +| libadwaita-sys | 0.7.2 | MIT | +| libc | 0.2.182 | MIT OR Apache-2.0 | +| libredox | 0.1.14 | MIT | +| linux-raw-sys | 0.12.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| log | 0.4.29 | MIT OR Apache-2.0 | +| mangotune | 0.1.0 | MIT | +| matchers | 0.2.0 | MIT | +| memchr | 2.8.0 | Unlicense OR MIT | +| memoffset | 0.9.1 | MIT | +| mio | 0.8.11 | MIT | +| mio | 1.1.1 | MIT | +| nix | 0.29.0 | MIT | +| notify | 6.1.1 | CC0-1.0 | +| ntapi | 0.4.3 | Apache-2.0 OR MIT | +| nu-ansi-term | 0.50.3 | MIT | +| once_cell | 1.21.3 | MIT OR Apache-2.0 | +| pango | 0.20.12 | MIT | +| pango-sys | 0.20.10 | MIT | +| pin-project-lite | 0.2.17 | Apache-2.0 OR MIT | +| pkg-config | 0.3.32 | MIT OR Apache-2.0 | +| plain | 0.2.3 | MIT/Apache-2.0 | +| predicates | 3.1.4 | MIT OR Apache-2.0 | +| predicates-core | 1.0.10 | MIT OR Apache-2.0 | +| predicates-tree | 1.0.13 | MIT OR Apache-2.0 | +| prettyplease | 0.2.37 | MIT OR Apache-2.0 | +| proc-macro-crate | 3.5.0 | MIT OR Apache-2.0 | +| proc-macro2 | 1.0.106 | MIT OR Apache-2.0 | +| quote | 1.0.45 | MIT OR Apache-2.0 | +| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | +| rayon | 1.11.0 | MIT OR Apache-2.0 | +| rayon-core | 1.13.0 | MIT OR Apache-2.0 | +| redox_syscall | 0.7.3 | MIT | +| regex | 1.12.3 | MIT OR Apache-2.0 | +| regex-automata | 0.4.14 | MIT OR Apache-2.0 | +| regex-syntax | 0.8.10 | MIT OR Apache-2.0 | +| rustc_version | 0.4.1 | MIT OR Apache-2.0 | +| rustix | 1.1.4 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| same-file | 1.0.6 | Unlicense/MIT | +| semver | 1.0.27 | MIT OR Apache-2.0 | +| serde | 1.0.228 | MIT OR Apache-2.0 | +| serde_core | 1.0.228 | MIT OR Apache-2.0 | +| serde_derive | 1.0.228 | MIT OR Apache-2.0 | +| serde_json | 1.0.149 | MIT OR Apache-2.0 | +| serde_spanned | 1.0.4 | MIT OR Apache-2.0 | +| sharded-slab | 0.1.7 | MIT | +| signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | +| slab | 0.4.12 | MIT | +| smallvec | 1.15.1 | MIT OR Apache-2.0 | +| syn | 2.0.117 | MIT OR Apache-2.0 | +| sysinfo | 0.31.4 | MIT | +| system-deps | 7.0.7 | MIT OR Apache-2.0 | +| target-lexicon | 0.13.3 | Apache-2.0 WITH LLVM-exception | +| tempfile | 3.26.0 | MIT OR Apache-2.0 | +| termtree | 0.5.1 | MIT | +| thiserror | 1.0.69 | MIT OR Apache-2.0 | +| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | +| thread_local | 1.1.9 | MIT OR Apache-2.0 | +| tokio | 1.50.0 | MIT | +| tokio-macros | 2.6.1 | MIT | +| toml | 0.9.12+spec-1.1.0 | MIT OR Apache-2.0 | +| toml_datetime | 0.7.5+spec-1.1.0 | MIT OR Apache-2.0 | +| toml_datetime | 1.0.0+spec-1.1.0 | MIT OR Apache-2.0 | +| toml_edit | 0.25.4+spec-1.1.0 | MIT OR Apache-2.0 | +| toml_parser | 1.0.9+spec-1.1.0 | MIT OR Apache-2.0 | +| toml_writer | 1.0.6+spec-1.1.0 | MIT OR Apache-2.0 | +| tracing | 0.1.44 | MIT | +| tracing-attributes | 0.1.31 | MIT | +| tracing-core | 0.1.36 | MIT | +| tracing-log | 0.2.0 | MIT | +| tracing-subscriber | 0.3.22 | MIT | +| unicode-ident | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 | +| unicode-xid | 0.2.6 | MIT OR Apache-2.0 | +| valuable | 0.1.1 | MIT | +| version-compare | 0.2.1 | MIT | +| walkdir | 2.5.0 | Unlicense/MIT | +| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wasip2 | 1.0.2+wasi-0.2.9 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wasip3 | 0.4.0+wasi-0.3.0-rc-2026-01-06 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wasm-encoder | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wasm-metadata | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wasmparser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| winapi | 0.3.9 | MIT/Apache-2.0 | +| winapi-i686-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | +| winapi-util | 0.1.11 | Unlicense OR MIT | +| winapi-x86_64-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | +| windows | 0.57.0 | MIT OR Apache-2.0 | +| windows-core | 0.57.0 | MIT OR Apache-2.0 | +| windows-implement | 0.57.0 | MIT OR Apache-2.0 | +| windows-interface | 0.57.0 | MIT OR Apache-2.0 | +| windows-link | 0.2.1 | MIT OR Apache-2.0 | +| windows-result | 0.1.2 | MIT OR Apache-2.0 | +| windows-sys | 0.48.0 | MIT OR Apache-2.0 | +| windows-sys | 0.59.0 | MIT OR Apache-2.0 | +| windows-sys | 0.61.2 | MIT OR Apache-2.0 | +| windows-targets | 0.48.5 | MIT OR Apache-2.0 | +| windows-targets | 0.52.6 | MIT OR Apache-2.0 | +| windows_aarch64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | +| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | +| windows_aarch64_msvc | 0.48.5 | MIT OR Apache-2.0 | +| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 | +| windows_i686_gnu | 0.48.5 | MIT OR Apache-2.0 | +| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 | +| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 | +| windows_i686_msvc | 0.48.5 | MIT OR Apache-2.0 | +| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 | +| windows_x86_64_gnu | 0.48.5 | MIT OR Apache-2.0 | +| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 | +| windows_x86_64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | +| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | +| windows_x86_64_msvc | 0.48.5 | MIT OR Apache-2.0 | +| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | +| winnow | 0.7.15 | MIT | +| wit-bindgen | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wit-bindgen-core | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wit-bindgen-rust | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wit-bindgen-rust-macro | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wit-component | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| wit-parser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| xdg | 2.5.2 | Apache-2.0 OR MIT | +| zmij | 1.0.21 | MIT | diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f2e2be9 --- /dev/null +++ b/build.rs @@ -0,0 +1,17 @@ +fn main() { + use std::process::Command; + + println!("cargo:rerun-if-changed=data/com.mangotune.MangoTune.gschema.xml"); + match Command::new("glib-compile-schemas").arg("data").status() { + Ok(status) if status.success() => {} + Ok(status) => { + println!( + "cargo:warning=glib-compile-schemas exited with {} (continuing build)", + status + ); + } + Err(err) => { + println!("cargo:warning=failed to run glib-compile-schemas: {err} (continuing build)"); + } + } +} diff --git a/data/com.mangotune.MangoTune.desktop b/data/com.mangotune.MangoTune.desktop new file mode 100644 index 0000000..b97568c --- /dev/null +++ b/data/com.mangotune.MangoTune.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=MangoTune +Comment=MangoHud Overlay Configurator +Exec=mangotune +Icon=com.mangotune.MangoTune +Terminal=false +Type=Application +Categories=Settings;System; +Keywords=MangoHud;overlay;gaming;performance; +StartupNotify=true +StartupWMClass=mangotune diff --git a/data/com.mangotune.MangoTune.gschema.xml b/data/com.mangotune.MangoTune.gschema.xml new file mode 100644 index 0000000..8b60d16 --- /dev/null +++ b/data/com.mangotune.MangoTune.gschema.xml @@ -0,0 +1,85 @@ + + + + + '' + Last edited config file path + + + 1040 + Window width + + + 780 + Window height + + + 'overview' + Currently active sidebar page + + + '' + Last selected profile name + + + false + Whether raw editor tab is visible + + + true + Create a backup copy before saving + + + 1280 + Default width for test launcher windows + + + 720 + Default height for test launcher windows + + + false + Dock launched test windows to the right side when possible + + + 'studio' + Preferred live preview scene + + + 'dark-arena' + Preferred scene inside the MangoTune studio preview renderer + + + 2.5 + Preferred load factor for the MangoTune studio preview renderer + + + 120 + Preferred FPS cap for the MangoTune studio preview renderer + + + false + Whether the MangoTune studio preview uses VSync + + + 0 + Preferred VRAM pressure for the MangoTune studio preview renderer + + + 1000 + Preferred particle count for the MangoTune studio preview renderer + + + 0.03 + Preferred particle size for the MangoTune studio preview renderer + + + 1 + Preferred repeated GPU passes for the MangoTune studio preview renderer + + + 0 + Preferred CPU interaction steps for the MangoTune studio preview renderer + + + diff --git a/data/com.mangotune.MangoTune.metainfo.xml b/data/com.mangotune.MangoTune.metainfo.xml new file mode 100644 index 0000000..193c30c --- /dev/null +++ b/data/com.mangotune.MangoTune.metainfo.xml @@ -0,0 +1,38 @@ + + + com.mangotune.MangoTune + MangoTune + Desktop MangoHud configurator for Linux + CC0-1.0 + MIT + + MangoTune Contributors + + +

+ MangoTune is a GTK4 + libadwaita application for editing MangoHud configuration files with + schema-aware validation, config layer conflict inspection, and integration helpers. +

+

+ It preserves comments and formatting while letting you edit options in structured pages, + run test launches with MangoHud enabled, and inspect per-layer effective values. +

+
+ com.mangotune.MangoTune.desktop + https://github.com/your-org/mangotune + https://github.com/your-org/mangotune/issues + + mangotune + + + Settings + System + + + 360 + + + + + +
diff --git a/data/game_config_db.toml b/data/game_config_db.toml new file mode 100644 index 0000000..3200fe2 --- /dev/null +++ b/data/game_config_db.toml @@ -0,0 +1,2091 @@ +# MangoTune game/executable hint database +# verification = "verified" means manually confirmed +# verification = "heuristic" means derived from local library scan and should be treated as a suggestion + +[[game]] +appid = 1147940 +title = "3dSen" +aliases = ["3dSen"] +candidates = ["3dSen"] +preferred = "3dSen" +verification = "heuristic" + +[[game]] +appid = 3694480 +title = "A Game About Feeding A Black Hole" +aliases = ["A Game About Feeeding A Black Hole"] +candidates = ["A Game About Feeding A Black Hole"] +preferred = "A Game About Feeding A Black Hole" +verification = "heuristic" + +[[game]] +appid = 752590 +title = "A Plague Tale: Innocence" +aliases = ["A Plague Tale Innocence"] +candidates = ["APlagueTaleInnocence_x64"] +preferred = "APlagueTaleInnocence_x64" +verification = "heuristic" + +[[game]] +appid = 2180700 +title = "ABI-DOS" +aliases = ["ABI-DOS"] +candidates = ["ABI-DOS"] +preferred = "ABI-DOS" +verification = "heuristic" + +[[game]] +appid = 2922410 +title = "Achilles: Survivor" +aliases = ["Achilles Survivor"] +candidates = ["AchillesSurvivorGame", "StagedBuild_AchillesSurvivor"] +preferred = "AchillesSurvivorGame" +verification = "heuristic" + +[[game]] +appid = 563560 +title = "Alien Swarm: Reactive Drop" +aliases = ["Alien Swarm Reactive Drop"] +candidates = ["reactivedrop"] +preferred = "reactivedrop" +verification = "heuristic" + +[[game]] +appid = 270880 +title = "American Truck Simulator" +aliases = ["American Truck Simulator"] +candidates = ["AmericanTruckSimulator"] +preferred = "AmericanTruckSimulator" +verification = "heuristic" + +[[game]] +appid = 1421750 +title = "APICO Demo" +aliases = ["APICO Demo"] +candidates = ["APICO"] +preferred = "APICO" +verification = "heuristic" + +[[game]] +appid = 2224640 +title = "Artisan TD" +aliases = ["Artisan TD"] +candidates = ["Artisan TD"] +preferred = "Artisan TD" +verification = "heuristic" + +[[game]] +appid = 244210 +title = "Assetto Corsa" +aliases = ["assettocorsa"] +candidates = ["AssettoCorsa", "assetto_corsa", "AssettoCorsa.exe"] +preferred = "AssettoCorsa" +verification = "heuristic" + +[[game]] +appid = 1700890 +title = "Asteroids: Recharged" +aliases = ["Asteroids Recharged"] +candidates = ["Asteroids Recharged"] +preferred = "Asteroids Recharged" +verification = "heuristic" + +[[game]] +appid = 1614550 +title = "Astro Colony" +aliases = ["Astro Colony"] +candidates = ["AstroColony", "AstroColony-Win64-Shipping", "AstroColony-WindowsNoEditor"] +preferred = "AstroColony" +verification = "heuristic" + +[[game]] +appid = 2353250 +title = "Astrometica" +aliases = ["ASTROMETICA"] +candidates = ["Astrometica", "Astrometica_DX12", "Astrometica_V2_Clone-Windows", "Astrometica_V2_Clone-Win64-Shipping", "Astrometica_Teaser_V2_With_MenuMusic_No_Logo"] +preferred = "Astrometica" +verification = "heuristic" + +[[game]] +appid = 361420 +title = "ASTRONEER" +aliases = ["ASTRONEER"] +candidates = ["Astro", "astroneer_loadingscreen"] +preferred = "Astro" +verification = "heuristic" + +[[game]] +appid = 1975520 +title = "Astronomics" +aliases = ["Astronomics"] +candidates = ["Astronomics", "astronomics_localization"] +preferred = "Astronomics" +verification = "heuristic" + +[[game]] +appid = 4313770 +title = "ATMOSFAR Demo" +aliases = ["ATMOSFAR Demo"] +candidates = ["ATMOSFARDemo"] +preferred = "ATMOSFARDemo" +verification = "heuristic" + +[[game]] +appid = 2636210 +title = "Automate It Demo" +aliases = ["AutomateItDemo"] +candidates = ["AutomateIt"] +preferred = "AutomateIt" +verification = "heuristic" + +[[game]] +appid = 979120 +title = "Autonauts" +aliases = ["Autonauts"] +candidates = ["Autonauts"] +preferred = "Autonauts" +verification = "heuristic" + +[[game]] +appid = 332200 +title = "Axiom Verge" +aliases = ["Axiom Verge"] +candidates = ["AxiomVerge", "Axiom Verge", "AxiomVerge.exe.config", "AxiomVerge.exe"] +preferred = "AxiomVerge" +verification = "heuristic" + +[[game]] +appid = 1695830 +title = "Baldur's Gate: Dark Alliance" +aliases = ["Baldur's Gate - Dark Alliance"] +candidates = ["Baldur's Gate - Dark Alliance"] +preferred = "Baldur's Gate - Dark Alliance" +verification = "heuristic" + +[[game]] +appid = 208650 +title = "Batman™: Arkham Knight" +aliases = ["Batman Arkham Knight"] +candidates = ["Batman"] +preferred = "Batman" +verification = "heuristic" + +[[game]] +appid = 284160 +title = "BeamNG.drive" +aliases = ["BeamNG.drive"] +candidates = ["BeamNG.drive", "drive", "BeamNG", "BeamNG.drive.x64"] +preferred = "BeamNG.drive" +verification = "heuristic" + +[[game]] +appid = 2623050 +title = "Berzerk: Recharged" +aliases = ["Berzerk Recharged"] +candidates = ["Berzerk"] +preferred = "Berzerk" +verification = "heuristic" + +[[game]] +appid = 960990 +title = "Beyond: Two Souls" +aliases = ["BEYOND Two Souls"] +candidates = ["BeyondTwoSouls_Steam"] +preferred = "BeyondTwoSouls_Steam" +verification = "heuristic" + +[[game]] +appid = 8870 +title = "BioShock Infinite" +aliases = ["BioShock Infinite"] +candidates = ["BioShockInfinite", "BioshockInfinite_Credits", "BioShock_Infinite_PC_Theme_4_1366x768", "BioShock_Infinite_PC_Theme_2_1366x768", "BioShock_Infinite_PC_Theme_5_1366x768", "BioShock_Infinite_PC_Theme_1_1366x768", "BioShock_Infinite_PC_Theme_3_1366x768", "BioShock_Infinite_PC_Theme_4_1024x768"] +preferred = "BioShockInfinite" +verification = "heuristic" + +[[game]] +appid = 1812820 +title = "Bitburner" +aliases = ["Bitburner"] +candidates = ["bitburner"] +preferred = "bitburner" +verification = "heuristic" + +[[game]] +appid = 2358720 +title = "Black Myth: Wukong" +aliases = ["BlackMythWukong"] +candidates = ["BlackMythWukong"] +preferred = "BlackMythWukong" +verification = "heuristic" + +[[game]] +appid = 1714180 +title = "Black Widow: Recharged" +aliases = ["Black Widow Recharged"] +candidates = ["BlackWidowRecharged"] +preferred = "BlackWidowRecharged" +verification = "heuristic" + +[[game]] +appid = 322680 +title = "BLACKHOLE" +aliases = ["BLACKHOLE"] +candidates = ["BLACKHOLE", "A3_BLACKHOLE_ATI", "A3_BLACKHOLE_TEAM", "A3_BLACKHOLE_COVER", "BLACKHOLE_GDS_2015", "BLACKHOLE_PROTOTYPE", "BLACKHOLE_GDS_2015_cz", "BLACKHOLE_GDS_2015_en"] +preferred = "BLACKHOLE" +verification = "heuristic" + +[[game]] +appid = 3045080 +title = "Block Shop Demo" +aliases = ["Block Shop Demo"] +candidates = ["BlockShopDemo"] +preferred = "BlockShopDemo" +verification = "heuristic" + +[[game]] +appid = 1490840 +title = "Bloodshore" +aliases = ["Bloodshore"] +candidates = ["Bloodshore"] +preferred = "Bloodshore" +verification = "heuristic" + +[[game]] +appid = 3576870 +title = "Blossom: The Seed Of Life" +aliases = ["Blossom The Seed Of Life"] +candidates = ["Blossom The Seed Of Life"] +preferred = "Blossom The Seed Of Life" +verification = "heuristic" + +[[game]] +appid = 449960 +title = "Book of Demons" +aliases = ["Return 2 Games"] +candidates = ["demons", "03 - Thing Trunk - Book of Demons Intro", "04 - Thing Trunk - Book of Demons Town Theme", "09 - Thing Trunk - Book of Demons Hell & Battle", "05 - Thing Trunk - Book of Demons Maze & Battle", "10 - Thing Trunk - Book of Demons Hell II & Battle", "06 - Thing Trunk - Book of Demons Maze II & Battle", "14 - Thing Trunk - Book of Demons Cook Incantation"] +preferred = "demons" +verification = "heuristic" + +[[game]] +appid = 397540 +title = "Borderlands 3" +aliases = ["Borderlands 3"] +candidates = ["Borderlands3"] +preferred = "Borderlands3" +verification = "heuristic" + +[[game]] +appid = 1714190 +title = "Breakout: Recharged" +aliases = ["Breakout Recharged"] +candidates = ["breakout_recharged"] +preferred = "breakout_recharged" +verification = "heuristic" + +[[game]] +appid = 3499150 +title = "Breathedge 2 Demo" +aliases = ["Breathedge 2 Demo"] +candidates = ["Breathedge2"] +preferred = "Breathedge2" +verification = "heuristic" + +[[game]] +appid = 1942280 +title = "Brotato" +aliases = ["Brotato"] +candidates = ["Brotato", "BrotatoAbyssalTerrors"] +preferred = "Brotato" +verification = "heuristic" + +[[game]] +appid = 452060 +title = "Caveblazers" +aliases = ["Caveblazers"] +candidates = ["Caveblazers"] +preferred = "Caveblazers" +verification = "heuristic" + +[[game]] +appid = 2213980 +title = "Caverns of Mars: Recharged" +aliases = ["Caverns Of Mars Recharged"] +candidates = ["MarsRecharged"] +preferred = "MarsRecharged" +verification = "heuristic" + +[[game]] +appid = 1630960 +title = "Centipede: Recharged" +aliases = ["Centipede Recharged"] +candidates = ["Centipede Recharged"] +preferred = "Centipede Recharged" +verification = "heuristic" + +[[game]] +appid = 746850 +title = "Cloudpunk" +aliases = ["Cloudpunk"] +candidates = ["Cloudpunk", "Cloudpunk - City of Ghosts"] +preferred = "Cloudpunk" +verification = "heuristic" + +[[game]] +appid = 3294190 +title = "Code Trainer Demo" +aliases = ["Code Trainer Demo"] +candidates = ["code-trainer"] +preferred = "code-trainer" +verification = "heuristic" + +[[game]] +appid = 1213210 +title = "Command & Conquer™ Remastered Collection" +aliases = ["CnCRemastered"] +candidates = ["CnCRemastered", "CONQUER"] +preferred = "CnCRemastered" +verification = "heuristic" + +[[game]] +appid = 3238670 +title = "Conquest Dark" +aliases = ["Conquest Dark"] +candidates = ["Conquest Dark"] +preferred = "Conquest Dark" +verification = "heuristic" + +[[game]] +appid = 2235020 +title = "Contra: Operation Galuga" +aliases = ["Contra Operation Galuga"] +candidates = ["ContraOperationGaluga"] +preferred = "ContraOperationGaluga" +verification = "heuristic" + +[[game]] +appid = 1396240 +title = "Contraband Police: Prologue" +aliases = ["Contraband Police Prologue"] +candidates = ["ContrabandPolice"] +preferred = "ContrabandPolice" +verification = "heuristic" + +[[game]] +appid = 870780 +title = "Control Ultimate Edition" +aliases = ["Control"] +candidates = ["Control", "Control_DX11", "Control_DX12"] +preferred = "Control" +verification = "heuristic" + +[[game]] +appid = 1203360 +title = "Core Defense" +aliases = ["Core Defense"] +candidates = ["core-defense"] +preferred = "core-defense" +verification = "heuristic" + +[[game]] +appid = 799600 +title = "Cosmoteer: Starship Architect & Commander" +aliases = ["Cosmoteer"] +candidates = ["Cosmoteer", "star", "Cosmoteer.deps", "Cosmoteer.runtimeconfig"] +preferred = "Cosmoteer" +verification = "heuristic" + +[[game]] +appid = 730 +title = "Counter-Strike 2" +aliases = ["counter strike 2", "counter-strike 2", "cs2", "csgo"] +candidates = ["cs2", "csgo", "wine-cs2"] +preferred = "cs2" +verification = "verified" + +[[game]] +appid = 248390 +title = "Craft The World" +aliases = ["CraftTheWorld"] +candidates = ["world", "craft", "CraftTheWorld_gameplay", "CraftTheWorld_noKalimba"] +preferred = "world" +verification = "heuristic" + +[[game]] +appid = 3229390 +title = "Cursebane Demo" +aliases = ["Cursebane Demo"] +candidates = ["Cursebane"] +preferred = "Cursebane" +verification = "heuristic" + +[[game]] +appid = 1091500 +title = "Cyberpunk 2077" +aliases = ["Cyberpunk 2077", "cyberpunk", "cyberpunk 2077"] +candidates = ["Cyberpunk2077", "wine-Cyberpunk2077", "cyberpunk2077_addresses"] +preferred = "Cyberpunk2077" +verification = "verified" + +[[game]] +appid = 3439340 +title = "DARKEST DAYS Demo" +aliases = ["DARKEST DAYS Demo"] +candidates = ["DARKESTDAYSDemo"] +preferred = "DARKESTDAYSDemo" +verification = "heuristic" + +[[game]] +appid = 1259420 +title = "Days Gone" +aliases = ["Days Gone"] +candidates = ["DaysGone"] +preferred = "DaysGone" +verification = "heuristic" + +[[game]] +appid = 934700 +title = "Dead Island 2" +aliases = ["Dead Island 2"] +candidates = ["DeadIsland"] +preferred = "DeadIsland" +verification = "heuristic" + +[[game]] +appid = 548430 +title = "Deep Rock Galactic" +aliases = ["Deep Rock Galactic"] +candidates = ["DeepRockGalactic"] +preferred = "DeepRockGalactic" +verification = "heuristic" + +[[game]] +appid = 2321470 +title = "Deep Rock Galactic: Survivor" +aliases = ["Deep Rock Survivor"] +candidates = ["DeepRockSurvivor"] +preferred = "DeepRockSurvivor" +verification = "heuristic" + +[[game]] +appid = 1450900 +title = "Desynced" +aliases = ["Desynced"] +candidates = ["Desynced"] +preferred = "Desynced" +verification = "heuristic" + +[[game]] +appid = 1222140 +title = "Detroit: Become Human" +aliases = ["Detroit Become Human"] +candidates = ["DetroitBecomeHuman"] +preferred = "DetroitBecomeHuman" +verification = "heuristic" + +[[game]] +appid = 2344520 +title = "Diablo® IV" +aliases = ["Diablo IV"] +candidates = ["Diablo IV", "diablo_iv_loader", "diablo_iv_loader_p"] +preferred = "Diablo IV" +verification = "heuristic" + +[[game]] +appid = 3604430 +title = "Digger: Galactic Treasures" +aliases = ["Digger Galactic Treasures"] +candidates = ["Digger"] +preferred = "Digger" +verification = "heuristic" + +[[game]] +appid = 233610 +title = "Distance" +aliases = ["Distance"] +candidates = ["Distance"] +preferred = "Distance" +verification = "heuristic" + +[[game]] +appid = 1650980 +title = "Dome Keeper Demo" +aliases = ["Dome Keeper Demo"] +candidates = ["domekeeper"] +preferred = "domekeeper" +verification = "heuristic" + +[[game]] +appid = 2280 +title = "DOOM + DOOM II" +aliases = ["Ultimate Doom"] +candidates = ["doom"] +preferred = "doom" +verification = "heuristic" + +[[game]] +appid = 1148590 +title = "DOOM 64" +aliases = ["Doom 64"] +candidates = ["Doom64", "DOOM64_x64"] +preferred = "Doom64" +verification = "heuristic" + +[[game]] +appid = 3017860 +title = "DOOM: The Dark Ages" +aliases = ["DOOMTheDarkAges"] +candidates = ["DOOMTheDarkAges", "DOOMTheDarkAgesWhiteOnBlack"] +preferred = "DOOMTheDarkAges" +verification = "heuristic" + +[[game]] +appid = 570 +title = "Dota 2" +aliases = ["dota", "dota2"] +candidates = ["dota", "wine-dota2"] +preferred = "dota" +verification = "verified" + +[[game]] +appid = 2625420 +title = "Drive Beyond Horizons" +aliases = ["Drive Beyond Horizons"] +candidates = ["DriveBeyondHorizons", "DriveBeyondHorizons-Windows", "DriveBeyondHorizons-Win64-Shipping"] +preferred = "DriveBeyondHorizons" +verification = "heuristic" + +[[game]] +appid = 406940 +title = "Dub Dash" +aliases = ["Dub Dash"] +candidates = ["DubDash", "DubDash.exe"] +preferred = "DubDash" +verification = "heuristic" + +[[game]] +appid = 493900 +title = "Dungeons 3" +aliases = ["Dungeons 3"] +candidates = ["Dungeons3"] +preferred = "Dungeons3" +verification = "heuristic" + +[[game]] +appid = 534380 +title = "Dying Light 2: Reloaded Edition" +aliases = ["Dying Light 2"] +candidates = ["DyingLight2"] +preferred = "DyingLight2" +verification = "heuristic" + +[[game]] +appid = 3273240 +title = "DYNASTY WARRIORS: ORIGINS Demo" +aliases = ["DWORIGINS Demo"] +candidates = ["DWORIGINS_Demo"] +preferred = "DWORIGINS_Demo" +verification = "heuristic" + +[[game]] +appid = 846770 +title = "DYSMANTLE" +aliases = ["DYSMANTLE"] +candidates = ["Dysmantle"] +preferred = "Dysmantle" +verification = "heuristic" + +[[game]] +appid = 1366540 +title = "Dyson Sphere Program" +aliases = ["Dyson Sphere Program"] +candidates = ["DysonSphereProgram"] +preferred = "DysonSphereProgram" +verification = "heuristic" + +[[game]] +appid = 2430390 +title = "DYSPLACED" +aliases = ["Dysplaced"] +candidates = ["Dysplaced"] +preferred = "Dysplaced" +verification = "heuristic" + +[[game]] +appid = 359320 +title = "Elite Dangerous" +aliases = ["Elite Dangerous"] +candidates = ["EliteDangerous64"] +preferred = "EliteDangerous64" +verification = "heuristic" + +[[game]] +appid = 1203620 +title = "Enshrouded" +aliases = ["Enshrouded"] +candidates = ["enshrouded", "enshrouded_018", "enshrouded_022", "enshrouded_024", "enshrouded_030", "enshrouded_016", "enshrouded_021", "enshrouded_019"] +preferred = "enshrouded" +verification = "heuristic" + +[[game]] +appid = 311690 +title = "Enter the Gungeon" +aliases = ["Enter the Gungeon"] +candidates = ["EntertheGungeon"] +preferred = "EntertheGungeon" +verification = "heuristic" + +[[game]] +appid = 2978930 +title = "ESC (Electronics Security Company) Demo" +aliases = ["ESC (Electronics Security Company) Demo"] +candidates = ["ESCElectronicsSecurityCompanyDemo"] +preferred = "ESCElectronicsSecurityCompanyDemo" +verification = "heuristic" + +[[game]] +appid = 1128920 +title = "EVERSPACE™ 2" +aliases = ["EVERSPACE™ 2"] +candidates = ["Everspace2"] +preferred = "Everspace2" +verification = "heuristic" + +[[game]] +appid = 948420 +title = "EXAPUNKS: TEC Redshift Player" +aliases = ["EXAPUNKS TEC Redshift Player"] +candidates = ["EXAPUNKS"] +preferred = "EXAPUNKS" +verification = "heuristic" + +[[game]] +appid = 1209490 +title = "Exit the Gungeon" +aliases = ["Exit the Gungeon"] +candidates = ["ExittheGungeon"] +preferred = "ExittheGungeon" +verification = "heuristic" + +[[game]] +appid = 427520 +title = "Factorio" +aliases = ["Factorio"] +candidates = ["factorio", "factorio-logo", "factorio-icon", "FactoriopediaID", "factorio-icon@2x", "factoriopedia-util", "factoriopedia-simulations"] +preferred = "factorio" +verification = "heuristic" + +[[game]] +appid = 371660 +title = "Far Cry Primal" +aliases = ["Far Cry Primal"] +candidates = ["FarCryPrimal"] +preferred = "FarCryPrimal" +verification = "heuristic" + +[[game]] +appid = 3110070 +title = "Fever Meme" +aliases = ["Fever Meme"] +candidates = ["FeverMeme"] +preferred = "FeverMeme" +verification = "heuristic" + +[[game]] +appid = 383870 +title = "Firewatch" +aliases = ["Firewatch"] +candidates = ["Firewatch"] +preferred = "Firewatch" +verification = "heuristic" + +[[game]] +appid = 1293830 +title = "Forza Horizon 4" +aliases = ["ForzaHorizon4"] +candidates = ["ForzaHorizon4", "ForzaHorizon4_Misc_WelcomePack"] +preferred = "ForzaHorizon4" +verification = "heuristic" + +[[game]] +appid = 212680 +title = "FTL: Faster Than Light" +aliases = ["FTL Faster Than Light", "faster than light", "ftl"] +candidates = ["FTL", "wine-FTLGame", "FTLFasterThanLight"] +preferred = "FTL" +verification = "verified" + +[[game]] +appid = 2255190 +title = "FUMES Demo" +aliases = ["FUMES Demo"] +candidates = ["FUMES"] +preferred = "FUMES" +verification = "heuristic" + +[[game]] +appid = 258970 +title = "Gauntlet™ " +aliases = ["Gauntlet"] +candidates = ["gauntlet", "WBIE_Gauntlet_Darkness_Calls_ENG"] +preferred = "gauntlet" +verification = "heuristic" + +[[game]] +appid = 3240220 +title = "Grand Theft Auto V Enhanced" +aliases = ["Grand Theft Auto V Enhanced"] +candidates = ["GrandTheftAutoVEnhanced"] +preferred = "GrandTheftAutoVEnhanced" +verification = "heuristic" + +[[game]] +appid = 1546990 +title = "Grand Theft Auto: Vice City - The Definitive Edition" +aliases = ["GTA Vice City - The Definitive Edition"] +candidates = ["ViceCity"] +preferred = "ViceCity" +verification = "heuristic" + +[[game]] +appid = 1905430 +title = "Gravitar: Recharged" +aliases = ["Gravitar Recharged"] +candidates = ["GravitarRecharged"] +preferred = "GravitarRecharged" +verification = "heuristic" + +[[game]] +appid = 2218400 +title = "Greedland" +aliases = ["Greedland"] +candidates = ["NewGreedland", "StagedBuild_NewGreedland", "NewGreedland-Win64-Shipping"] +preferred = "NewGreedland" +verification = "heuristic" + +[[game]] +appid = 815370 +title = "Green Hell" +aliases = ["Green Hell"] +candidates = ["GreenHell-1920x1080", "GreenHell-3840x2160", "Green Hell - Artbook", "GreenHell-Poster-60x80", "GreenHell-S&T-1920x1080", "GreenHell-S&T-3840x2160", "GreenHell-SoA2-1920x1080", "GreenHell-SoA3-1920x1080"] +preferred = "GreenHell-1920x1080" +verification = "heuristic" + +[[game]] +appid = 1123050 +title = "GRIME" +aliases = ["GRIME"] +candidates = ["GRIME", "Huey.GRIME", "Huey.GRIME.AfterScripts"] +preferred = "GRIME" +verification = "heuristic" + +[[game]] +appid = 3816930 +title = "Grind Survivors" +aliases = ["Grind Survivors"] +candidates = ["GrindSurvivors", "GrindSurvivors-Windows", "GrindSurvivors-Win64-Shipping"] +preferred = "GrindSurvivors" +verification = "heuristic" + +[[game]] +appid = 962130 +title = "Grounded" +aliases = ["Grounded"] +candidates = ["Grounded"] +preferred = "Grounded" +verification = "heuristic" + +[[game]] +appid = 3099000 +title = "Guns of Fury" +aliases = ["Guns of Fury"] +candidates = ["Guns_of_Fury"] +preferred = "Guns_of_Fury" +verification = "heuristic" + +[[game]] +appid = 1145360 +title = "Hades" +aliases = ["Hades", "hades"] +candidates = ["Hades", "wine-Hades", "RemBGHades", "HadesField", "HadesUpgrade", "RemBGHadesIntro", "ShadeSwordRun_Bink", "ShadeSpearRun_Bink", "ShadeSwordIdle_Bink"] +preferred = "Hades" +verification = "verified" + +[[game]] +appid = 2218750 +title = "Halls of Torment" +aliases = ["Halls of Torment"] +candidates = ["HallsOfTorment"] +preferred = "HallsOfTorment" +verification = "heuristic" + +[[game]] +appid = 1161580 +title = "Hardspace: Shipbreaker" +aliases = ["Hardspace Shipbreaker"] +candidates = ["Shipbreaker"] +preferred = "Shipbreaker" +verification = "heuristic" + +[[game]] +appid = 1782460 +title = "Hell Clock" +aliases = ["Hell Clock"] +candidates = ["HellClock", "Hell Clock"] +preferred = "HellClock" +verification = "heuristic" + +[[game]] +appid = 1201540 +title = "HELLCARD" +aliases = ["HELLCARD"] +candidates = ["HELLCARD", "Hellcard-keyart", "r2g_hellcard_steam", "Hellcard_Main_Theme", "Hellcard Camp 2160p", "Hellcard Sage 2400p", "Hellcard Camp 2400p", "Hellcard Sage 2160p"] +preferred = "HELLCARD" +verification = "heuristic" + +[[game]] +appid = 3264410 +title = "Heroes of Hammerwatch II Demo" +aliases = ["Heroes of Hammerwatch II Demo"] +candidates = ["HeroesofHammerwatchIIDemo"] +preferred = "HeroesofHammerwatchIIDemo" +verification = "heuristic" + +[[game]] +appid = 1583230 +title = "High On Life" +aliases = ["HighOnLife"] +candidates = ["HighOnLife"] +preferred = "HighOnLife" +verification = "heuristic" + +[[game]] +appid = 367520 +title = "Hollow Knight" +aliases = ["Hollow Knight"] +candidates = ["hollow_knight"] +preferred = "hollow_knight" +verification = "heuristic" + +[[game]] +appid = 1030300 +title = "Hollow Knight: Silksong" +aliases = ["Hollow Knight Silksong"] +candidates = ["Hollow Knight Silksong"] +preferred = "Hollow Knight Silksong" +verification = "heuristic" + +[[game]] +appid = 3611990 +title = "Hordes of Fate : A Hand of Fate Adventure Demo" +aliases = ["HordesOfFateDemo"] +candidates = ["HordesOfFate"] +preferred = "HordesOfFate" +verification = "heuristic" + +[[game]] +appid = 2157710 +title = "Hordes of Hunger" +aliases = ["HoH"] +candidates = ["HordesOfHunger"] +preferred = "HordesOfHunger" +verification = "heuristic" + +[[game]] +appid = 389140 +title = "Horizon Chase Turbo" +aliases = ["Horizon Chase"] +candidates = ["HorizonChase"] +preferred = "HorizonChase" +verification = "heuristic" + +[[game]] +appid = 1271700 +title = "HOT WHEELS UNLEASHED™" +aliases = ["HOT WHEELS UNLEASHED"] +candidates = ["hotwheels"] +preferred = "hotwheels" +verification = "heuristic" + +[[game]] +appid = 3691220 +title = "House Builder 2 Demo" +aliases = ["House Builder 2 Demo"] +candidates = ["HouseBuilder2"] +preferred = "HouseBuilder2" +verification = "heuristic" + +[[game]] +appid = 613100 +title = "House Flipper" +aliases = ["House Flipper"] +candidates = ["HouseFlipper"] +preferred = "HouseFlipper" +verification = "heuristic" + +[[game]] +appid = 283160 +title = "House of the Dying Sun" +aliases = ["DyingSun", "dyingsun", "house of the dying sun"] +candidates = ["dyingsun", "wine-dyingsun"] +preferred = "dyingsun" +verification = "verified" + +[[game]] +appid = 598550 +title = "Huntdown" +aliases = ["Huntdown"] +candidates = ["Huntdown"] +preferred = "Huntdown" +verification = "heuristic" + +[[game]] +appid = 300570 +title = "Infinifactory" +aliases = ["Infinifactory"] +candidates = ["infinifactory", "Matthew S Burns - Infinifactory OST", "Matthew S Burns - Infinifactory OST - 15 Home", "Matthew S Burns - Infinifactory OST - 13 Attacked", "Matthew S Burns - Infinifactory OST - 05 Asteroids", "Matthew S Burns - Infinifactory OST - 10 The Heist", "Matthew S Burns - Infinifactory OST - 02 Skydock 19", "Matthew S Burns - Infinifactory OST - 06 The Harvest"] +preferred = "infinifactory" +verification = "heuristic" + +[[game]] +appid = 3388180 +title = "IT Specialist Simulator Demo" +aliases = ["IT Specialist Simulator DEMO"] +candidates = ["IT Specialist Simulator DEMO"] +preferred = "IT Specialist Simulator DEMO" +verification = "heuristic" + +[[game]] +appid = 1426210 +title = "It Takes Two" +aliases = ["ItTakesTwo"] +candidates = ["ItTakesTwo", "ItTakesTwo_Trial"] +preferred = "ItTakesTwo" +verification = "heuristic" + +[[game]] +appid = 1740930 +title = "JellyCar Worlds" +aliases = ["JellyCar Worlds"] +candidates = ["JellyCar Worlds"] +preferred = "JellyCar Worlds" +verification = "heuristic" + +[[game]] +appid = 2820820 +title = "Jotunnslayer: Hordes of Hel" +aliases = ["Jotunnslayer Hordes of Hel"] +candidates = ["Jotunnslayer"] +preferred = "Jotunnslayer" +verification = "heuristic" + +[[game]] +appid = 3027930 +title = "Karate Survivor" +aliases = ["Karate Survivor"] +candidates = ["KarateSurvivor"] +preferred = "KarateSurvivor" +verification = "heuristic" + +[[game]] +appid = 406350 +title = "KartKraft" +aliases = ["KartKraft"] +candidates = ["KartKraft"] +preferred = "KartKraft" +verification = "heuristic" + +[[game]] +appid = 3244390 +title = "Keep Driving Demo" +aliases = ["Keep Driving Demo"] +candidates = ["KeepDriving"] +preferred = "KeepDriving" +verification = "heuristic" + +[[game]] +appid = 1242980 +title = "KeyWe" +aliases = ["KeyWe"] +candidates = ["KeyWe"] +preferred = "KeyWe" +verification = "heuristic" + +[[game]] +appid = 110800 +title = "L.A. Noire" +aliases = ["L.A.Noire"] +candidates = ["LANoire"] +preferred = "LANoire" +verification = "heuristic" + +[[game]] +appid = 899770 +title = "Last Epoch" +aliases = ["Last Epoch"] +candidates = ["Last Epoch"] +preferred = "Last Epoch" +verification = "heuristic" + +[[game]] +appid = 584980 +title = "Late Shift" +aliases = ["Late Shift"] +candidates = ["LateShift"] +preferred = "LateShift" +verification = "heuristic" + +[[game]] +appid = 207170 +title = "Legend of Grimrock" +aliases = ["Legend of Grimrock"] +candidates = ["grimrock"] +preferred = "grimrock" +verification = "heuristic" + +[[game]] +appid = 251730 +title = "Legend of Grimrock 2" +aliases = ["Legend of Grimrock 2"] +candidates = ["grimrock2", "Legend of Grimrock 2 Manual", "Legend of Grimrock 2 Main Theme", "Legend of Grimrock 2 Graph Paper", "Legend of Grimrock 2 Graph Paper Printer Friendly"] +preferred = "grimrock2" +verification = "heuristic" + +[[game]] +appid = 554620 +title = "Life is Strange: Before the Storm" +aliases = ["Life is Strange - Before the Storm"] +candidates = ["Life is Strange - Before the Storm"] +preferred = "Life is Strange - Before the Storm" +verification = "heuristic" + +[[game]] +appid = 936790 +title = "Life is Strange: True Colors" +aliases = ["LifeIsStrange3"] +candidates = ["LifeIsStrange3"] +preferred = "LifeIsStrange3" +verification = "heuristic" + +[[game]] +appid = 3729870 +title = "Little Rocket Lab Demo" +aliases = ["Little Rocket Lab Demo"] +candidates = ["LittleRocketLab"] +preferred = "LittleRocketLab" +verification = "heuristic" + +[[game]] +appid = 1532200 +title = "Mars First Logistics" +aliases = ["Mars First Logistics"] +candidates = ["Mars First Logistics"] +preferred = "Mars First Logistics" +verification = "heuristic" + +[[game]] +appid = 1088850 +title = "Marvel's Guardians of the Galaxy" +aliases = ["Marvel's Guardians of the Galaxy"] +candidates = ["MarvelsGuardiansoftheGalaxy"] +preferred = "MarvelsGuardiansoftheGalaxy" +verification = "heuristic" + +[[game]] +appid = 1328670 +title = "Mass Effect™ Legendary Edition" +aliases = ["Mass Effect Legendary Edition"] +candidates = ["MassEffectLegendaryEdition"] +preferred = "MassEffectLegendaryEdition" +verification = "heuristic" + +[[game]] +appid = 1449560 +title = "Metro Exodus Enhanced Edition" +aliases = ["Metro Exodus Enhanced Edition"] +candidates = ["MetroExodus"] +preferred = "MetroExodus" +verification = "heuristic" + +[[game]] +appid = 743130 +title = "MewnBase" +aliases = ["MewnBase"] +candidates = ["MewnBase", "mewnbase_fi", "mewnbase_es", "mewnbase_cs", "mewnbase_ca", "mewnbase_de", "mewnbase_bg", "mewnbase_pl"] +preferred = "MewnBase" +verification = "heuristic" + +[[game]] +appid = 3846220 +title = "MineMogul Demo" +aliases = ["MineMogul Demo"] +candidates = ["MineMogul"] +preferred = "MineMogul" +verification = "heuristic" + +[[game]] +appid = 1603180 +title = "Mining Mechs" +aliases = ["Mining Mechs"] +candidates = ["Mining Mechs"] +preferred = "Mining Mechs" +verification = "heuristic" + +[[game]] +appid = 2114990 +title = "Missile Command: Recharged" +aliases = ["Missile Command Recharged"] +candidates = ["Missile Command Recharged"] +preferred = "Missile Command Recharged" +verification = "heuristic" + +[[game]] +appid = 2472160 +title = "Mix Universe Demo" +aliases = ["Mix Universe Demo"] +candidates = ["MixUniverseDemo"] +preferred = "MixUniverseDemo" +verification = "heuristic" + +[[game]] +appid = 3364880 +title = "Moral Dilemma: The Interview" +aliases = ["Moral Dilemma The Interview"] +candidates = ["MoralDilemmaTheInterview"] +preferred = "MoralDilemmaTheInterview" +verification = "heuristic" + +[[game]] +appid = 1169040 +title = "Necesse" +aliases = ["Necesse"] +candidates = ["Necesse"] +preferred = "Necesse" +verification = "heuristic" + +[[game]] +appid = 1222680 +title = "Need for Speed™ Heat " +aliases = ["Need for Speed Heat"] +candidates = ["NeedForSpeedHeat", "NeedForSpeedHeatTrial"] +preferred = "NeedForSpeedHeat" +verification = "heuristic" + +[[game]] +appid = 3047370 +title = "Net.Attack()" +aliases = ["ForeachHack"] +candidates = ["ForeachHack"] +preferred = "ForeachHack" +verification = "heuristic" + +[[game]] +appid = 383840 +title = "Nimbatus - The Space Drone Constructor" +aliases = ["Nimbatus"] +candidates = ["Nimbatus"] +preferred = "Nimbatus" +verification = "heuristic" + +[[game]] +appid = 2086430 +title = "NIMRODS" +aliases = ["NIMRODS"] +candidates = ["NIMRODS"] +preferred = "NIMRODS" +verification = "heuristic" + +[[game]] +appid = 275850 +title = "No Man's Sky" +aliases = ["No Man's Sky"] +candidates = ["NoMansSky"] +preferred = "NoMansSky" +verification = "heuristic" + +[[game]] +appid = 1371980 +title = "No Rest for the Wicked" +aliases = ["NoRestForTheWicked"] +candidates = ["NoRestForTheWicked"] +preferred = "NoRestForTheWicked" +verification = "heuristic" + +[[game]] +appid = 881100 +title = "Noita" +aliases = ["Noita"] +candidates = ["noita", "noita_dev", "noita-mods", "Noita-ModdingAgreement-v100"] +preferred = "noita" +verification = "heuristic" + +[[game]] +appid = 1802390 +title = "Occupy Mars: Co-Op Playtest" +aliases = ["Occupy Mars The Game Playtest"] +candidates = ["OccupyMarsTheGamePlaytest"] +preferred = "OccupyMarsTheGamePlaytest" +verification = "heuristic" + +[[game]] +appid = 415030 +title = "One More Dungeon" +aliases = ["OneMoreDungeon"] +candidates = ["OneMoreDungeon"] +preferred = "OneMoreDungeon" +verification = "heuristic" + +[[game]] +appid = 558990 +title = "Opus Magnum" +aliases = ["Opus Magnum"] +candidates = ["OpusMagnum"] +preferred = "OpusMagnum" +verification = "heuristic" + +[[game]] +appid = 3767740 +title = "Outhold" +aliases = ["Outhold"] +candidates = ["outhold_windows"] +preferred = "outhold_windows" +verification = "heuristic" + +[[game]] +appid = 448850 +title = "Overload" +aliases = ["Overload"] +candidates = ["Overload", "OverloadDX9", "OverloadOpenVR", "OverloadOculus", "Overload_DLC02", "Overload_DLC01", "OverloadSafeMode", "OverloadFullscreenExclusive"] +preferred = "Overload" +verification = "heuristic" + +[[game]] +appid = 12810 +title = "Overlord II" +aliases = ["Overlord II"] +candidates = ["Overlord"] +preferred = "Overlord" +verification = "heuristic" + +[[game]] +appid = 1458140 +title = "Pacific Drive" +aliases = ["Pacific Drive"] +candidates = ["PacificDrive"] +preferred = "PacificDrive" +verification = "heuristic" + +[[game]] +appid = 2181850 +title = "PaperKlay Demo" +aliases = ["PaperKlay Demo"] +candidates = ["PaperKlay"] +preferred = "PaperKlay" +verification = "heuristic" + +[[game]] +appid = 2737300 +title = "Parking Garage Rally Circuit" +aliases = ["Parking Garage Rally Circuit"] +candidates = ["garagerally"] +preferred = "garagerally" +verification = "heuristic" + +[[game]] +appid = 238960 +title = "Path of Exile" +aliases = ["path of exile", "poe"] +candidates = ["PathOfExileSteam", "wine-PathOfExileSteam"] +preferred = "PathOfExileSteam" +verification = "verified" + +[[game]] +appid = 493340 +title = "Planet Coaster" +aliases = ["Planet Coaster"] +candidates = ["PlanetCoaster", "Planet Coaster [Wide]", "Planet Coaster [Square]", "CSpinCustomBBoards_PlanetCoaster"] +preferred = "PlanetCoaster" +verification = "heuristic" + +[[game]] +appid = 620 +title = "Portal 2" +aliases = ["portal 2", "portal2"] +candidates = ["portal2", "wine-portal2"] +preferred = "portal2" +verification = "verified" + +[[game]] +appid = 374040 +title = "Portal Knights" +aliases = ["Portal Knights"] +candidates = ["portal_knights_x64", "portal_knights.d3d11"] +preferred = "portal_knights_x64" +verification = "heuristic" + +[[game]] +appid = 601360 +title = "Portal: Revolution" +aliases = ["Portal Revolution"] +candidates = ["revolution"] +preferred = "revolution" +verification = "heuristic" + +[[game]] +appid = 2751000 +title = "Prince of Persia The Lost Crown" +aliases = ["Prince of Persia The Lost Crown"] +candidates = ["TheLostCrown"] +preferred = "TheLostCrown" +verification = "heuristic" + +[[game]] +appid = 1214520 +title = "Pro Gymnast" +aliases = ["Pro Gymnast"] +candidates = ["ProGymnast"] +preferred = "ProGymnast" +verification = "heuristic" + +[[game]] +appid = 1322420 +title = "Pro Gymnast Demo" +aliases = ["Pro Gymnast Demo"] +candidates = ["ProGymnast"] +preferred = "ProGymnast" +verification = "heuristic" + +[[game]] +appid = 1358800 +title = "projectM Music Visualizer" +aliases = ["projectM"] +candidates = ["projectMSDL"] +preferred = "projectMSDL" +verification = "heuristic" + +[[game]] +appid = 3658110 +title = "Proton 10.0" +aliases = ["Proton 10.0"] +candidates = ["Proton100"] +preferred = "Proton100" +verification = "heuristic" + +[[game]] +appid = 1826330 +title = "Proton EasyAntiCheat Runtime" +aliases = ["Proton EasyAntiCheat Runtime"] +candidates = ["easyanticheat"] +preferred = "easyanticheat" +verification = "heuristic" + +[[game]] +appid = 1493710 +title = "Proton Experimental" +aliases = ["Proton - Experimental"] +candidates = ["Proton-Experimental"] +preferred = "Proton-Experimental" +verification = "heuristic" + +[[game]] +appid = 2180100 +title = "Proton Hotfix" +aliases = ["Proton Hotfix"] +candidates = ["ProtonHotfix"] +preferred = "ProtonHotfix" +verification = "heuristic" + +[[game]] +appid = 824720 +title = "Pure Rock Crawling" +aliases = ["Pure Rock Crawling"] +candidates = ["PureRockCrawling", "PureRockCrawling-Win64-Shipping", "PureRockCrawling-WindowsNoEditor"] +preferred = "PureRockCrawling" +verification = "heuristic" + +[[game]] +appid = 2460490 +title = "Quantum: Recharged" +aliases = ["Quantum Recharged"] +candidates = ["Quantum Recharged"] +preferred = "Quantum Recharged" +verification = "heuristic" + +[[game]] +appid = 3457960 +title = "QuantumPulse 2A Demo" +aliases = ["QuantumPulse 2A Demo"] +candidates = ["QuantumPulse2ADemo"] +preferred = "QuantumPulse2ADemo" +verification = "heuristic" + +[[game]] +appid = 1442820 +title = "R-Type Final 2" +aliases = ["R-Type Final 2"] +candidates = ["RTypeFinal2", "RTypeFinal2-Win64-Shipping"] +preferred = "RTypeFinal2" +verification = "heuristic" + +[[game]] +appid = 3985950 +title = "R.I.P. - Reincarnation Insurance Program" +aliases = ["Reincarnation Insurance Program"] +candidates = ["ReincarnationInsuranceProgram"] +preferred = "ReincarnationInsuranceProgram" +verification = "heuristic" + +[[game]] +appid = 2928520 +title = "Recycling Center Simulator" +aliases = ["Recycling Center Simulator"] +candidates = ["RecyclingCenterSimulator"] +preferred = "RecyclingCenterSimulator" +verification = "heuristic" + +[[game]] +appid = 1174180 +title = "Red Dead Redemption 2" +aliases = ["Red Dead Redemption 2"] +candidates = ["RedDeadRedemption2"] +preferred = "RedDeadRedemption2" +verification = "heuristic" + +[[game]] +appid = 517710 +title = "Redout: Enhanced Edition" +aliases = ["Redout"] +candidates = ["redout", "redout-Win64-Shipping", "redout-WindowsNoEditor"] +preferred = "redout" +verification = "heuristic" + +[[game]] +appid = 3401490 +title = "Replicube" +aliases = ["Replicube"] +candidates = ["Replicube"] +preferred = "Replicube" +verification = "heuristic" + +[[game]] +appid = 1239690 +title = "Retrowave" +aliases = ["Retrowave"] +candidates = ["Retrowave"] +preferred = "Retrowave" +verification = "heuristic" + +[[game]] +appid = 2787320 +title = "Revenge of the Savage Planet" +aliases = ["Revenge of the Savage Planet"] +candidates = ["RevengeoftheSavagePlanet"] +preferred = "RevengeoftheSavagePlanet" +verification = "heuristic" + +[[game]] +appid = 2290180 +title = "Riders Republic" +aliases = ["RidersRepublic"] +candidates = ["RidersRepublic", "RidersRepublic_BE"] +preferred = "RidersRepublic" +verification = "heuristic" + +[[game]] +appid = 2874680 +title = "Rift Riff Demo" +aliases = ["Rift Riff Demo"] +candidates = ["Rift Riff"] +preferred = "Rift Riff" +verification = "heuristic" + +[[game]] +appid = 391220 +title = "Rise of the Tomb Raider" +aliases = ["Rise of the Tomb Raider"] +candidates = ["RiseOfTheTombRaider"] +preferred = "RiseOfTheTombRaider" +verification = "heuristic" + +[[game]] +appid = 1681430 +title = "RoboCop: Rogue City" +aliases = ["Robocop Rogue City"] +candidates = ["RoboCop"] +preferred = "RoboCop" +verification = "heuristic" + +[[game]] +appid = 1843760 +title = "Rogue Tower" +aliases = ["Rogue Tower"] +candidates = ["Rogue Tower"] +preferred = "Rogue Tower" +verification = "heuristic" + +[[game]] +appid = 1599660 +title = "Sackboy™: A Big Adventure" +aliases = ["Sackboy"] +candidates = ["Sackboy", "Sackboy-Win64-Shipping"] +preferred = "Sackboy" +verification = "heuristic" + +[[game]] +appid = 526870 +title = "Satisfactory" +aliases = ["Satisfactory"] +candidates = ["Satisfactory_Intro_01", "Satisfactory_Intro_v2_NoAudio_BotLoad"] +preferred = "Satisfactory_Intro_01" +verification = "heuristic" + +[[game]] +appid = 3164500 +title = "Schedule I" +aliases = ["Schedule I"] +candidates = ["Schedule I"] +preferred = "Schedule I" +verification = "heuristic" + +[[game]] +appid = 2347910 +title = "Seal: WHAT the FUN" +aliases = ["Seal WhatTheFun"] +candidates = ["SealWhatTheFun"] +preferred = "SealWhatTheFun" +verification = "heuristic" + +[[game]] +appid = 750920 +title = "Shadow of the Tomb Raider" +aliases = ["Shadow of the Tomb Raider"] +candidates = ["ShadowoftheTombRaider"] +preferred = "ShadowoftheTombRaider" +verification = "heuristic" + +[[game]] +appid = 2162800 +title = "shapez 2" +aliases = ["shapez 2"] +candidates = ["shapez 2", "shapez 2_s"] +preferred = "shapez 2" +verification = "heuristic" + +[[game]] +appid = 3008500 +title = "shapez 2 Extended Soundtrack" +aliases = ["shapez 2 Soundtrack"] +candidates = [] +preferred = "shapez2Soundtrack" +verification = "heuristic" + +[[game]] +appid = 812040 +title = "Shortest Trip to Earth" +aliases = ["Shortest Trip to Earth"] +candidates = ["ShortestTriptoEarth"] +preferred = "ShortestTriptoEarth" +verification = "heuristic" + +[[game]] +appid = 3465270 +title = "Solarpunk Demo" +aliases = ["Solarpunk Demo"] +candidates = ["Solarpunk"] +preferred = "Solarpunk" +verification = "heuristic" + +[[game]] +appid = 3601140 +title = "Sonic Racing: CrossWorlds Demo" +aliases = ["SonicRacingCrossWorldsDemo"] +candidates = ["SonicRacingCrossWorldsDemo"] +preferred = "SonicRacingCrossWorldsDemo" +verification = "heuristic" + +[[game]] +appid = 1326470 +title = "Sons Of The Forest" +aliases = ["Sons Of The Forest"] +candidates = ["SonsOfTheForest"] +preferred = "SonsOfTheForest" +verification = "heuristic" + +[[game]] +appid = 2066020 +title = "Soulstone Survivors" +aliases = ["Soulstone Survivors"] +candidates = ["Soulstone Survivors"] +preferred = "Soulstone Survivors" +verification = "heuristic" + +[[game]] +appid = 3448620 +title = "Spell Wizard Demo" +aliases = ["Spell Wizard Demo"] +candidates = ["Spell Wizard Demo"] +preferred = "Spell Wizard Demo" +verification = "heuristic" + +[[game]] +appid = 446840 +title = "Splasher" +aliases = ["Splasher"] +candidates = ["Splasher"] +preferred = "Splasher" +verification = "heuristic" + +[[game]] +appid = 2001120 +title = "Split Fiction" +aliases = ["Split Fiction"] +candidates = ["SplitFiction"] +preferred = "SplitFiction" +verification = "heuristic" + +[[game]] +appid = 3433680 +title = "Sprout Crafter" +aliases = ["Sprout_Crafter"] +candidates = ["Sprout Crafter"] +preferred = "Sprout Crafter" +verification = "heuristic" + +[[game]] +appid = 3487900 +title = "Star Crafter Demo" +aliases = ["Star Crafter Demo"] +candidates = ["StarCrafterDemo"] +preferred = "StarCrafterDemo" +verification = "heuristic" + +[[game]] +appid = 9900 +title = "Star Trek Online" +aliases = ["Star Trek Online"] +candidates = ["Star Trek Online"] +preferred = "Star Trek Online" +verification = "heuristic" + +[[game]] +appid = 2653940 +title = "Star Trek: Resurgence" +aliases = ["Star Trek Resurgence"] +candidates = ["StarTrekResurgence"] +preferred = "StarTrekResurgence" +verification = "heuristic" + +[[game]] +appid = 1750770 +title = "Starcom: Unknown Space" +aliases = ["Starcom Unknown Space"] +candidates = ["Starcom Unknown Space"] +preferred = "Starcom Unknown Space" +verification = "heuristic" + +[[game]] +appid = 1716740 +title = "Starfield" +aliases = ["Starfield"] +candidates = ["Starfield", "Starfield_es", "Starfield_de", "Starfield_fr", "Starfield_ja", "Starfield_pl", "Starfield_it", "Starfield_ptbr"] +preferred = "Starfield" +verification = "heuristic" + +[[game]] +appid = 1631270 +title = "StarRupture" +aliases = ["StarRupture"] +candidates = ["StarRuptureGameSteam", "StagedBuild_StarRupture", "StarRuptureGameSteam-Win64-Shipping"] +preferred = "StarRuptureGameSteam" +verification = "heuristic" + +[[game]] +appid = 1070560 +title = "Steam Linux Runtime 1.0 (scout)" +aliases = ["SteamLinuxRuntime"] +candidates = ["SteamLinuxRuntime"] +preferred = "SteamLinuxRuntime" +verification = "heuristic" + +[[game]] +appid = 1391110 +title = "Steam Linux Runtime 2.0 (soldier)" +aliases = ["SteamLinuxRuntime_soldier"] +candidates = ["SteamLinuxRuntime_soldier"] +preferred = "SteamLinuxRuntime_soldier" +verification = "heuristic" + +[[game]] +appid = 1628350 +title = "Steam Linux Runtime 3.0 (sniper)" +aliases = ["SteamLinuxRuntime_sniper"] +candidates = ["SteamLinuxRuntime_sniper"] +preferred = "SteamLinuxRuntime_sniper" +verification = "heuristic" + +[[game]] +appid = 228980 +title = "Steamworks Common Redistributables" +aliases = ["Steamworks Shared"] +candidates = ["SteamworksShared"] +preferred = "SteamworksShared" +verification = "heuristic" + +[[game]] +appid = 2134770 +title = "SteamWorld Build" +aliases = ["SteamWorld Build"] +candidates = ["SteamWorld Build"] +preferred = "SteamWorld Build" +verification = "heuristic" + +[[game]] +appid = 3125250 +title = "STORROR Parkour Pro" +aliases = ["STORROR Parkour Pro"] +candidates = ["STORRORParkourPRO", "STORRORParkourPRO-Windows", "StagedBuild_STORRORParkourPRO", "STORRORParkourPRO-Win64-Shipping", "STORROR_PARKOUR_PRO_-_2022_DEV_TEASER_-_Trim"] +preferred = "STORRORParkourPRO" +verification = "heuristic" + +[[game]] +appid = 848450 +title = "Subnautica: Below Zero" +aliases = ["SubnauticaZero"] +candidates = ["SubnauticaZero"] +preferred = "SubnauticaZero" +verification = "heuristic" + +[[game]] +appid = 2830150 +title = "Super Mining Mechs" +aliases = ["Super Mining Mechs"] +candidates = ["Super Mining Mechs"] +preferred = "Super Mining Mechs" +verification = "heuristic" + +[[game]] +appid = 1522870 +title = "Supraland Six Inches Under" +aliases = ["Supraland Six Inches Under"] +candidates = ["SupralandSixInchesUnder"] +preferred = "SupralandSixInchesUnder" +verification = "heuristic" + +[[game]] +appid = 1869290 +title = "Supraworld" +aliases = ["Supraworld"] +candidates = ["Supraworld", "StagedBuild_Supraworld", "Supraworld-Win64-Shipping"] +preferred = "Supraworld" +verification = "heuristic" + +[[game]] +appid = 1375900 +title = "Swarm Grinder" +aliases = ["Swarm Grinder"] +candidates = ["Swarm Grinder", "Swarm Grinder_s"] +preferred = "Swarm Grinder" +verification = "heuristic" + +[[game]] +appid = 440 +title = "Team Fortress 2" +aliases = ["team fortress 2", "tf2"] +candidates = ["tf", "wine-tf2"] +preferred = "tf" +verification = "verified" + +[[game]] +appid = 1167630 +title = "Teardown" +aliases = ["Teardown"] +candidates = ["teardown", "teardown_modtest", "teardown_palette"] +preferred = "teardown" +verification = "heuristic" + +[[game]] +appid = 3273320 +title = "Tears Apart Demo" +aliases = ["Tears Apart Demo"] +candidates = ["Tears Apart"] +preferred = "Tears Apart" +verification = "heuristic" + +[[game]] +appid = 1486920 +title = "Tempest Rising" +aliases = ["Tempest Rising"] +candidates = ["Tempest"] +preferred = "Tempest" +verification = "heuristic" + +[[game]] +appid = 954740 +title = "Terminator: Resistance" +aliases = ["Terminator Resistance"] +candidates = ["Terminator", "Terminator_Resistance_Zero_Day_Exploit_Comic"] +preferred = "Terminator" +verification = "heuristic" + +[[game]] +appid = 105600 +title = "Terraria" +aliases = ["Terraria", "terraria"] +candidates = ["Terraria", "wine-Terraria", "Terraria.bin", "TerrariaMusic", "TerrariaServer", "TerrariaServer.bin"] +preferred = "Terraria" +verification = "verified" + +[[game]] +appid = 2313330 +title = "TerraTech Worlds" +aliases = ["TerraTech Worlds"] +candidates = ["TerraTech Worlds EULA"] +preferred = "TerraTech Worlds EULA" +verification = "heuristic" + +[[game]] +appid = 242530 +title = "The Chaos Engine" +aliases = ["Chaos engine"] +candidates = ["CHAOS", "TheChaosEngineSteam", "The Chaos Engine Remastered", "ChaosEngineLogo"] +preferred = "CHAOS" +verification = "heuristic" + +[[game]] +appid = 1107790 +title = "The Complex" +aliases = ["The Complex"] +candidates = ["TheComplex"] +preferred = "TheComplex" +verification = "heuristic" + +[[game]] +appid = 2060160 +title = "The Farmer Was Replaced" +aliases = ["The Farmer Was Replaced"] +candidates = ["TheFarmerWasReplaced"] +preferred = "TheFarmerWasReplaced" +verification = "heuristic" + +[[game]] +appid = 1783560 +title = "The Last Caretaker" +aliases = ["Voyage"] +candidates = ["Voyage", "StagedBuild_Voyage", "VoyageSteam-Win64-Shipping"] +preferred = "Voyage" +verification = "heuristic" + +[[game]] +appid = 1888930 +title = "The Last of Us™ Part I" +aliases = ["The Last of Us Part I"] +candidates = ["TheLastofUsPartI"] +preferred = "TheLastofUsPartI" +verification = "heuristic" + +[[game]] +appid = 1857080 +title = "The Last Starship" +aliases = ["The Last Starship"] +candidates = ["LastStarship"] +preferred = "LastStarship" +verification = "heuristic" + +[[game]] +appid = 305620 +title = "The Long Dark" +aliases = ["TheLongDark", "long dark", "the long dark"] +candidates = ["tld", "wine-tld", "TheLongDark"] +preferred = "tld" +verification = "verified" + +[[game]] +appid = 1284190 +title = "The Planet Crafter" +aliases = ["The Planet Crafter"] +candidates = ["Planet Crafter"] +preferred = "Planet Crafter" +verification = "heuristic" + +[[game]] +appid = 490110 +title = "The Precinct" +aliases = ["The Precinct"] +candidates = ["Precinct"] +preferred = "Precinct" +verification = "heuristic" + +[[game]] +appid = 1577120 +title = "The Quarry" +aliases = ["The Quarry"] +candidates = ["TheQuarry", "TheQuarry-Win64-Shipping"] +preferred = "TheQuarry" +verification = "heuristic" + +[[game]] +appid = 780310 +title = "The Riftbreaker" +aliases = ["Riftbreaker"] +candidates = ["riftbreaker_win_release", "riftbreaker_dll_win_release"] +preferred = "riftbreaker_win_release" +verification = "heuristic" + +[[game]] +appid = 2717880 +title = "The Rogue Prince of Persia" +aliases = ["The Rogue Prince Of Persia"] +candidates = ["The Rogue Prince of Persia"] +preferred = "The Rogue Prince of Persia" +verification = "heuristic" + +[[game]] +appid = 2904000 +title = "The Spell Brigade" +aliases = ["The Spell Brigade"] +candidates = ["TheSpellBrigade"] +preferred = "TheSpellBrigade" +verification = "heuristic" + +[[game]] +appid = 1062090 +title = "Timberborn" +aliases = ["Timberborn"] +candidates = ["Timberborn", "Timberborn.Bots", "Timberborn.Ruins", "Timberborn.Intro", "Timberborn.Goods", "Timberborn_Intro", "Timberborn.Fields", "Timberborn.CoreUI"] +preferred = "Timberborn" +verification = "heuristic" + +[[game]] +appid = 1599020 +title = "Tinykin" +aliases = ["Tinykin"] +candidates = ["Tinykin"] +preferred = "Tinykin" +verification = "heuristic" + +[[game]] +appid = 475150 +title = "Titan Quest Anniversary Edition" +aliases = ["Titan Quest Anniversary Edition"] +candidates = ["TitanQuestAnniversaryEdition"] +preferred = "TitanQuestAnniversaryEdition" +verification = "heuristic" + +[[game]] +appid = 1154030 +title = "Titan Quest II" +aliases = ["Titan Quest II"] +candidates = ["TitanQuestII"] +preferred = "TitanQuestII" +verification = "heuristic" + +[[game]] +appid = 232910 +title = "TrackMania² Stadium" +aliases = ["ManiaPlanet_TMStadium"] +candidates = ["TrackMania", "TMStadium", "ManiaPlanet"] +preferred = "TrackMania" +verification = "heuristic" + +[[game]] +appid = 2261210 +title = "Trade Bots: A Technical Analysis Simulation Demo" +aliases = ["Trade Bots A Technical Analysis Simulation Demo"] +candidates = ["trade-bots"] +preferred = "trade-bots" +verification = "heuristic" + +[[game]] +appid = 1328350 +title = "Turbo Overkill" +aliases = ["Turbo Overkill"] +candidates = ["Turbo Overkill"] +preferred = "Turbo Overkill" +verification = "heuristic" + +[[game]] +appid = 1659420 +title = "UNCHARTED™: Legacy of Thieves Collection" +aliases = ["Uncharted Legacy of Thieves Collection"] +candidates = ["UnchartedLegacyofThievesCollection"] +preferred = "UnchartedLegacyofThievesCollection" +verification = "heuristic" + +[[game]] +appid = 1111930 +title = "Underspace" +aliases = ["Underspace"] +candidates = ["Underspace"] +preferred = "Underspace" +verification = "heuristic" + +[[game]] +appid = 3606890 +title = "Upload Labs" +aliases = ["Upload Labs"] +candidates = ["Upload Labs"] +preferred = "Upload Labs" +verification = "heuristic" + +[[game]] +appid = 243450 +title = "Urban Trial Freestyle" +aliases = ["Urban Trial Freestyle"] +candidates = ["UrbanTrialFreestyle"] +preferred = "UrbanTrialFreestyle" +verification = "heuristic" + +[[game]] +appid = 892970 +title = "Valheim" +aliases = ["Valheim", "valheim"] +candidates = ["valheim", "wine-valheim", "assembly_valheim"] +preferred = "valheim" +verification = "verified" + +[[game]] +appid = 1794680 +title = "Vampire Survivors" +aliases = ["Vampire Survivors", "vampire survivors"] +candidates = ["VampireSurvivors", "wine-VampireSurvivors", "VampireSurvivors.Runtime"] +preferred = "VampireSurvivors" +verification = "verified" + +[[game]] +appid = 2187290 +title = "Wall World" +aliases = ["Wall World"] +candidates = ["WallWorld"] +preferred = "WallWorld" +verification = "heuristic" + +[[game]] +appid = 552500 +title = "Warhammer: Vermintide 2" +aliases = ["Warhammer Vermintide 2"] +candidates = ["vermintide2"] +preferred = "vermintide2" +verification = "heuristic" + +[[game]] +appid = 3328850 +title = "WarpedSpace Demo" +aliases = ["WarpedSpace Demo"] +candidates = ["WarpedSpaceDemo"] +preferred = "WarpedSpaceDemo" +verification = "heuristic" + +[[game]] +appid = 2019990 +title = "Yars: Recharged" +aliases = ["Yars Recharged"] +candidates = ["YarsRecharged"] +preferred = "YarsRecharged" +verification = "heuristic" + +[[game]] +appid = 2163330 +title = "Yet Another Zombie Survivors" +aliases = ["Yet Another Zombie Survivors"] +candidates = ["Yet Another Zombie Survivors"] +preferred = "Yet Another Zombie Survivors" +verification = "heuristic" diff --git a/data/icons/com.mangotune.MangoTune.svg b/data/icons/com.mangotune.MangoTune.svg new file mode 100644 index 0000000..45986de --- /dev/null +++ b/data/icons/com.mangotune.MangoTune.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/data/style.css b/data/style.css new file mode 100644 index 0000000..d09b62c --- /dev/null +++ b/data/style.css @@ -0,0 +1,1509 @@ +/* ── MangoTune design tokens ───────────────────────────────────────────── */ +@define-color mt_bg #0f1117; +@define-color mt_panel #13151e; +@define-color mt_panel_alt #1a1d2a; +@define-color mt_border #1e2130; +@define-color mt_border_soft #2a2d3e; +@define-color mt_text #c8cad4; +@define-color mt_text_dim #7a7e96; +@define-color mt_text_faint #454860; +@define-color mt_accent #e8a940; +@define-color mt_accent_bg #2a1e06; +@define-color mt_accent_border #3d2d0a; +@define-color mt_success #4caf7a; +@define-color mt_success_bg #0e2518; +@define-color mt_success_border #1a3d29; +@define-color mt_info #6ea8e7; +@define-color mt_info_bg #112236; +@define-color mt_info_border #1d4268; +@define-color mt_danger #e05555; +@define-color mt_danger_bg #2a1010; +@define-color mt_danger_border #3d1818; + + +/* ════════════════════════════════════════════════════════════════════════ + SHARED BADGE / CHIP PATTERN + All badges: 2px radius, mono font, 0.72em, no shadow. + ════════════════════════════════════════════════════════════════════════ */ + +.layer-badge-env, +.layer-badge-perapp, +.layer-badge-global, +.conflict-badge, +.dashboard-chip, +.dashboard-preset-badge, +.tool-chip, +.tool-section-badge { + border-radius: 2px; + padding: 1px 5px; + min-height: 16px; + font-size: 0.69em; + font-weight: 500; + line-height: 1.0; + letter-spacing: 0.04em; + font-family: "IBM Plex Mono", monospace; + box-shadow: none; +} + +.layer-badge-env { + background-color: @mt_danger_bg; + color: @mt_danger; + border: 1px solid @mt_danger_border; +} + +.layer-badge-perapp { + background-color: @mt_accent_bg; + color: @mt_accent; + border: 1px solid @mt_accent_border; +} + +.layer-badge-global { + background-color: @mt_success_bg; + color: @mt_success; + border: 1px solid @mt_success_border; +} + +.conflict-badge { + background-color: @mt_danger_bg; + color: @mt_danger; + border: 1px solid @mt_danger_border; +} + +.dashboard-chip, +.dashboard-preset-badge, +.tool-chip { + background-color: @mt_accent_bg; + color: @mt_accent; + border: 1px solid @mt_accent_border; +} + +.tool-chip-row { + margin-top: 2px; +} + +.tool-chip-flow { + margin-top: 2px; +} + +.tool-page-chip { + background-color: @mt_accent_bg; + color: @mt_accent; + border: 1px solid @mt_accent_border; + border-radius: 2px; + padding: 1px 5px; + min-height: 16px; + font-size: 0.69em; + font-weight: 500; + line-height: 1.0; + letter-spacing: 0.04em; + font-family: "IBM Plex Mono", monospace; + box-shadow: none; +} + +.tool-section-badge { + background-color: alpha(@mt_accent, 0.08); + border: 1px solid alpha(@mt_accent, 0.18); + color: @mt_accent; + margin-top: 1px; + margin-bottom: 1px; +} + +.search-target-flash { + background-color: alpha(@mt_accent, 0.14); + box-shadow: inset 0 0 0 2px alpha(@mt_accent, 0.75); +} + +.control-row.search-target-flash, +preferencesgroup list row.search-target-flash { + background: alpha(@mt_accent, 0.14); + box-shadow: inset 3px 0 0 0 @mt_accent, inset 0 0 0 1px alpha(@mt_accent, 0.65); +} + +.control-row.search-target-flash .title, +.control-row.search-target-flash .subtitle, +preferencesgroup list row.search-target-flash .title, +preferencesgroup list row.search-target-flash .subtitle { + color: @mt_text; +} + +preferencesgroup list row.search-target-flash > box, +preferencesgroup list row.search-target-flash > revealer > box, +preferencesgroup list row.search-target-flash box { + background: transparent; +} + +.hud-order-list { + margin-top: 2px; +} + +.hud-order-row { + background: @mt_panel; + border-left: 1px solid @mt_border; + border-right: 1px solid @mt_border; + border-top: 1px solid @mt_border; + border-bottom: 0; + border-radius: 2px; + padding: 8px 10px; +} + +.hud-order-list > box:last-child.hud-order-row { + border-bottom: 1px solid @mt_border; +} + +.hud-order-row-dragging { + opacity: 0.72; + border-color: alpha(@mt_accent, 0.34); +} + +.hud-order-row-drop-before { + box-shadow: inset 0 2px 0 0 alpha(@mt_accent, 0.95); +} + +.hud-order-row-drop-after { + box-shadow: inset 0 -2px 0 0 alpha(@mt_accent, 0.95); +} + +.hud-order-index { + min-width: 18px; +} + +.hud-order-key { + color: @mt_text_dim; + font-size: 0.76em; + font-family: "IBM Plex Mono", monospace; + margin-right: 8px; + min-width: 84px; +} + +.hud-order-handle { + color: @mt_text_dim; +} + +.config-hint-results-scroll { + border: 1px solid @mt_border; + border-radius: 2px; + background: @mt_panel; +} + +.config-hint-results { + padding: 6px; +} + +.config-hint-row { + padding: 6px 8px; + border: 1px solid @mt_border; + border-radius: 2px; + background: @mt_panel_alt; +} + +.config-hint-title { + color: @mt_text; + font-weight: 600; +} + +.config-hint-subtitle { + color: @mt_text_dim; + font-size: 0.85em; +} + +.config-hint-candidates { + margin-top: 2px; +} + +.config-hint-candidate { + min-height: 20px; + padding: 0 6px; + border-radius: 2px; + border: 1px solid @mt_border; + background: @mt_panel; + color: @mt_text; + font-size: 0.82em; +} + +.config-hint-candidate:hover { + background: alpha(@mt_accent, 0.08); + border-color: alpha(@mt_accent, 0.32); +} + +.config-hint-candidate-primary { + background: alpha(@mt_accent, 0.09); + border-color: alpha(@mt_accent, 0.34); + color: @mt_accent; +} + + +/* ════════════════════════════════════════════════════════════════════════ + APP SHELL — header bar, config bar, sidebar + ════════════════════════════════════════════════════════════════════════ */ + +.app-headerbar { + background: @mt_panel; + border-bottom: 1px solid @mt_border; +} + +.app-window, +.app-toolbar-view, +.app-content-shell, +.app-split-view, +.app-navigation-view { + background: @mt_bg; +} + +.app-toolbar-view > contents, +.app-content-shell > widget, +.app-navigation-view > widget { + background: @mt_bg; +} + +.shell-window-title .title { + color: @mt_text; + font-weight: 600; +} + +.shell-window-title .subtitle { + color: @mt_text_dim; + font-size: 0.82em; +} + +.config-bar { + background-color: @mt_panel; + border-bottom: 1px solid @mt_border; + padding: 4px 10px; +} + +.shell-strip { + border: 1px solid @mt_border; + border-radius: 2px; +} + +.shell-strip-label { + color: @mt_text_dim; + font-size: 0.72em; + font-weight: 500; + letter-spacing: 0.10em; + text-transform: uppercase; + font-family: "IBM Plex Mono", monospace; +} + +.shell-status-label { + color: @mt_text_dim; + font-size: 0.82em; +} + +.shell-strip-icon, +.shell-conflict-label { + color: @mt_text_dim; +} + +.shell-conflict-active { + color: @mt_danger; +} + +.shell-target-select, +.shell-strip-button, +.shell-save-button, +.shell-menu-button { + border-radius: 2px; +} + +.shell-target-select, +.shell-target-select > button, +.shell-target-summary, +.shell-strip-button, +.shell-save-button > button, +.shell-menu-button { + background: @mt_bg; + border: 1px solid @mt_border_soft; + color: @mt_text; +} + +.shell-strip-button:hover, +.shell-target-select > button:hover, +.shell-target-summary:hover, +.shell-save-button > button:hover, +.shell-menu-button:hover { + background: @mt_panel_alt; + border-color: alpha(@mt_accent, 0.34); + color: @mt_accent; +} + +.shell-target-select > button label, +.shell-target-select > button image, +.shell-save-button > button label, +.shell-save-button > button image, +.shell-menu-button > button image { + color: inherit; +} + +.shell-target-summary { + padding: 5px 8px; +} + +.shell-target-summary-label { + color: @mt_text; + font-weight: 600; +} + + +/* ════════════════════════════════════════════════════════════════════════ + SIDEBAR NAVIGATION + ════════════════════════════════════════════════════════════════════════ */ + +.navigation-shell { + background: @mt_panel; + border-right: 1px solid @mt_border; + padding: 12px 8px 12px 8px; +} + +.navigation-search { + background: @mt_bg; + border: 1px solid @mt_border_soft; + border-radius: 3px; + color: @mt_text; + font-family: "IBM Plex Mono", monospace; + font-size: 0.84em; +} + +.navigation-search:focus-within { + border-color: alpha(@mt_accent, 0.32); + box-shadow: inset 0 0 0 1px alpha(@mt_accent, 0.18); +} + +.navigation-search image, +.navigation-search placeholder { + color: @mt_text_dim; +} + +.navigation-search text { + color: @mt_text; +} + +.navigation-sidebar { + background: transparent; +} + +.navigation-sidebar row, +.navigation-row, +.navigation-section-row { + background: transparent; + border-radius: 2px; + margin: 1px 0; + color: @mt_text_dim; + transition: background 120ms ease, color 120ms ease; +} + +.navigation-sidebar row:hover, +.navigation-section-row:hover { + background: @mt_panel_alt; + color: @mt_text; +} + +.navigation-sidebar row:selected, +.navigation-sidebar row:focus-within { + background: alpha(@mt_accent, 0.08); + color: @mt_accent; + box-shadow: inset 0 0 0 1px alpha(@mt_accent, 0.28); +} + +.navigation-row-box, +.navigation-sidebar row box { + color: inherit; +} + +.navigation-row-label, +.navigation-row-icon, +.navigation-sidebar row label, +.navigation-sidebar row image { + color: inherit; +} + +.navigation-sidebar row:selected label, +.navigation-sidebar row:selected image, +.navigation-sidebar row:focus-within label, +.navigation-sidebar row:focus-within image { + color: @mt_accent; +} + +.navigation-section-button { + border-radius: 2px; + color: @mt_text_dim; + font-size: 0.78em; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + font-family: "IBM Plex Mono", monospace; +} + +.navigation-section-button:hover { + background: @mt_panel_alt; + color: @mt_text; +} + + +/* ════════════════════════════════════════════════════════════════════════ + DASHBOARD PAGE + ════════════════════════════════════════════════════════════════════════ */ + +.dashboard-page { + background: @mt_bg; +} + +.dashboard-page-compact { + border-spacing: 0; +} + +.dashboard-row > * { + min-width: 0; +} + +.dashboard-primary-card, +.dashboard-secondary-card, +.dashboard-position-card, +.dashboard-footer-card, +.dashboard-full-width-card, +.dashboard-stack, +.dashboard-inline-grid, +.preview-group, +.preview-size-row, +.preview-scene-control, +.tool-page-clamp { + min-width: 0; +} + +.dashboard-inline-grid { + margin-top: 2px; +} + +.dashboard-inline-section { + margin-top: 2px; + padding-top: 8px; + border-top: 1px solid @mt_border_soft; +} + +.dashboard-profiles-compact > * { + min-width: 0; +} + +.dashboard-profiles-strip > * { + min-width: 0; +} + +.profile-action-button { + min-width: 34px; + min-height: 34px; + padding: 0; +} + +.dashboard-hero { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; +} + +.dashboard-hero-title { + font-size: 1.3em; + font-weight: 600; + letter-spacing: 0.01em; + color: @mt_text; +} + +.dashboard-hero-subtitle { + color: @mt_text_dim; + font-size: 0.88em; +} + +.dashboard-card { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; + padding: 8px; + box-shadow: none; +} + +.dashboard-card-compact { + border-radius: 2px; + padding: 6px; + box-shadow: none; +} + +.dashboard-card-title { + font-size: 1.0em; + font-weight: 500; + color: @mt_text; +} + +.dashboard-card-subtitle { + color: @mt_text_dim; + font-size: 0.88em; +} + +.dashboard-card > * + *, +.dashboard-card-compact > * + * { + margin-top: 2px; +} + +.dashboard-inline-row { + min-height: 30px; +} + +.dashboard-status-panel { + border-radius: 2px; + padding: 6px 8px; + background: @mt_bg; + border: 1px solid @mt_border; +} + +.dashboard-preview-subsection { + margin-top: 0; +} + +.preview-group-body { + margin-top: 2px; +} + +.preview-fixed-spin { + min-width: 132px; +} + +.preview-profile-grid { + margin-top: 2px; +} + +.preview-profile-button { + min-height: 30px; +} + +.dashboard-status-line { + font-size: 0.88em; + font-weight: 500; + color: @mt_text_dim; +} + +.dashboard-field-label { + font-weight: 500; + font-size: 0.92em; + color: @mt_text; +} + +.dashboard-value-label { + color: @mt_accent; + font-weight: 500; + font-family: "IBM Plex Mono", monospace; + font-size: 0.88em; + min-width: 48px; +} + +.dashboard-scale trough { + min-height: 5px; + border-radius: 999px; +} + +.dashboard-scale highlight { + border-radius: 999px; +} + +.dashboard-toggle { + background: @mt_panel_alt; + border: 1px solid @mt_border; + color: @mt_text; + border-radius: 2px; + padding: 1px 5px; + font-size: 0.86em; + box-shadow: none; +} + +.dashboard-toggle:hover { + background: alpha(@mt_accent, 0.08); + border-color: alpha(@mt_accent, 0.30); + color: @mt_text; +} + +.dashboard-toggle:checked, +.dashboard-toggle:active { + background: @mt_accent_bg; + border-color: @mt_accent_border; + color: @mt_accent; +} + +.dashboard-toggle:disabled { + opacity: 0.55; +} + +.dashboard-toggle label, +.dashboard-toggle image { + color: inherit; +} + +.metric-toggle { + min-width: 64px; + border-radius: 2px; + min-height: 26px; + font-size: 0.86em; +} + +.metric-group { + margin-top: 1px; + margin-bottom: 1px; +} + +.metric-group-title { + min-width: 110px; +} + +.metric-group-flow { + margin-top: 0; +} + +.dashboard-preset-button { + background: @mt_panel_alt; + color: @mt_text; + border-radius: 2px; + padding: 6px 8px; + border: 1px solid @mt_border; + box-shadow: none; +} + +.dashboard-preset-button:hover { + background: @mt_panel_alt; + border-color: alpha(@mt_accent, 0.30); + color: @mt_text; +} + +.dashboard-preset-button label { + color: inherit; +} + +.dashboard-studio-box { + margin-top: 0; +} + +.dashboard-preview-actions > button { + min-height: 32px; +} + +.dashboard-color-button { + min-width: 40px; + min-height: 40px; + border-radius: 2px; +} + +.page-jump { + border-radius: 2px; + font-size: 0.82em; + color: @mt_text_dim; +} + +.page-jump:hover { + color: @mt_accent; + border-color: alpha(@mt_accent, 0.28); +} + +.preview-status { + padding: 6px 10px; + border-radius: 2px; + font-weight: 500; + font-size: 0.78em; + font-family: "IBM Plex Mono", monospace; +} + +.preview-status-live { + background: @mt_success_bg; + color: @mt_success; + border: 1px solid @mt_success_border; +} + +.preview-status-idle { + background: @mt_panel_alt; + color: @mt_text_dim; + border: 1px solid @mt_border; +} + +.position-grid { + padding: 8px; + border-radius: 2px; + background: @mt_bg; + border: 1px solid @mt_border; +} + +.position-grid-compact { + padding: 6px; +} + +.position-node { + min-width: 38px; + min-height: 38px; + border-radius: 2px; + font-weight: 500; +} + +.dashboard-status-strip { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; +} + +.dashboard-status-summary { + padding: 7px 10px; + background: @mt_panel; + min-width: 0; + color: @mt_text_dim; + font-size: 0.80em; + font-family: "IBM Plex Mono", monospace; + line-height: 1.2; +} + +.position-node-active { + background: @mt_accent_bg; + color: @mt_accent; + border-color: @mt_accent_border; +} + +.position-center { + color: @mt_text_faint; + font-weight: 500; + font-size: 0.78em; + font-family: "IBM Plex Mono", monospace; +} + + +/* ════════════════════════════════════════════════════════════════════════ + TOOL PAGES (deep settings: Performance, GPU, CPU, etc.) + ════════════════════════════════════════════════════════════════════════ */ + +.tool-page { + background: @mt_bg; +} + +.tool-page-start { + background: @mt_bg; +} + +.tool-page-body { + background: @mt_bg; +} + +.tool-page-hero { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; + padding: 9px 13px; + box-shadow: none; + margin: 0 0 6px; +} + +.tool-page-hero-flat { + background: transparent; + border: none; + padding: 0; + margin: 0 0 2px; +} + +.tool-page-eyebrow { + color: @mt_accent; + font-size: 0.70em; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + font-family: "IBM Plex Mono", monospace; +} + +.tool-page-title { + font-size: 1.20em; + font-weight: 600; + letter-spacing: 0.01em; + color: @mt_text; +} + +.tool-page-subtitle { + color: @mt_text_dim; + font-size: 0.90em; +} + +.tool-section-shell { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; + padding: 0; + box-shadow: none; + margin: 0 0 6px; +} + +.cascade-scroller { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; +} + +.tool-section-header { + padding: 7px 11px 5px; + border-bottom: 1px solid @mt_border; + background: @mt_bg; + border-radius: 2px 2px 0 0; +} + +.tool-section-title { + font-size: 0.78em; + font-weight: 500; + color: @mt_accent; + font-family: "IBM Plex Mono", monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.tool-section-subtitle { + color: @mt_text_dim; + font-size: 0.84em; +} + +.tool-section-group { + background: transparent; +} + +.tool-callout { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; + padding: 9px 11px; + margin: 0 0 8px; +} + +.tool-callout-warning { + border-color: alpha(@mt_accent, 0.22); + background: alpha(@mt_accent, 0.06); +} + +.tool-callout-title { + font-weight: 500; + color: @mt_text; + font-size: 0.92em; +} + +.tool-callout-subtitle { + color: @mt_text_dim; + font-size: 0.88em; +} + +.utility-window-shell { + background: @mt_bg; +} + +.utility-window-header { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; + padding: 8px 10px; +} + +.utility-window-title { + color: @mt_text; + font-size: 1.02em; + font-weight: 600; +} + +.utility-window-subtitle { + color: @mt_text_dim; + font-size: 0.88em; +} + +.utility-window-body { + background: transparent; +} + +.utility-window-footer { + margin-top: 2px; +} + +.utility-window-scroller { + background: @mt_panel; + border: 1px solid @mt_border; + border-radius: 2px; +} + + +/* ════════════════════════════════════════════════════════════════════════ + CONTROL ROWS + ════════════════════════════════════════════════════════════════════════ */ + +.control-row { + background: transparent; + border-radius: 0; + margin: 0 10px; + padding: 6px 4px; + border-top: 1px solid @mt_panel_alt; +} + +.control-row.error { + background: alpha(@mt_danger, 0.05); + border-top: 1px solid @mt_danger_border; +} + +.control-field { + min-height: 34px; + background: @mt_bg; + color: @mt_text; + border: 1px solid @mt_border_soft; + border-radius: 2px; + font-family: "IBM Plex Mono", monospace; + font-size: 0.88em; +} + +.control-shortcut { + padding: 6px 10px; + background: @mt_bg; + border: 1px solid @mt_border_soft; + border-radius: 2px; + color: @mt_text; + font-family: "IBM Plex Mono", monospace; + font-size: 0.88em; +} + +.control-row entry, +.control-row text, +.control-row entry selection { + color: @mt_text; +} + +.control-button { + border-radius: 2px; +} + +.control-check { + border-radius: 2px; +} + +.control-row spinbutton, +.control-spin, +.control-row dropdown > button, +.control-row combobox > box > button { + background: @mt_bg; + border: 1px solid @mt_border_soft; + color: @mt_text; + border-radius: 2px; + font-size: 0.88em; +} + +.control-row spinbutton entry, +.control-spin entry, +.control-row spinbutton text, +.control-row dropdown > button label, +.control-row dropdown > button image, +.control-row combobox > box > button label, +.control-row combobox > box > button image { + color: inherit; +} + +.threshold-triplet-entry { + min-width: 64px; +} + +.control-row spinbutton button { + background: @mt_panel_alt; + border: 1px solid transparent; + color: @mt_text_dim; + border-radius: 2px; +} + +.control-row spinbutton button:hover, +.control-row dropdown > button:hover, +.control-row combobox > box > button:hover { + background: @mt_panel_alt; + color: @mt_accent; + border-color: alpha(@mt_accent, 0.28); +} + +.control-row switch:checked, +preferencesgroup list row switch:checked, +switch:checked { + background-color: alpha(@mt_accent, 0.18); + border-color: alpha(@mt_accent, 0.40); +} + +.control-row switch slider, +preferencesgroup list row switch slider, +switch slider { + background: @mt_text; +} + + +/* ════════════════════════════════════════════════════════════════════════ + COLOR CONTROLS + ════════════════════════════════════════════════════════════════════════ */ + +.color-swatch-button { + min-width: 26px; + min-height: 26px; + border-radius: 2px; + border: 1px solid @mt_border_soft; + padding: 0; +} + +.color-swatch-unset { + opacity: 0.55; + border-color: alpha(@mt_text, 0.14); +} + +.color-control { + min-width: 88px; +} + +.stacked-color-editor { + min-width: 52px; +} + +.stacked-color-entry { + min-width: 50px; +} + +.stacked-color-swatch { + min-width: 50px; + min-height: 14px; +} + +.color-list-editor { + min-width: 0; +} + +.color-list-column { + min-width: 52px; +} + +.color-list-cell-entry { + min-width: 50px; +} + +.color-list-swatch { + min-width: 50px; + min-height: 14px; +} + + +/* ════════════════════════════════════════════════════════════════════════ + MISC UTILITY + ════════════════════════════════════════════════════════════════════════ */ + +.option-shadowed { + text-decoration: line-through; + opacity: 0.45; +} + + +/* ════════════════════════════════════════════════════════════════════════ + STOCK GTK / ADWAITA OVERRIDES + These target widget types that have no custom CSS class hooks in the + Rust code. Ordered from broadest to most specific. + ════════════════════════════════════════════════════════════════════════ */ + +/* ── Toasts ───────────────────────────────────────────────────────────── + AdwToast renders as .toast inside the overlay. We tighten the timeout + via CSS appearance (actual duration must be set in Rust with + toast.set_timeout(2)), but we can at least flatten the corners and + bring the colours in line with the design language. */ +toast { + background-color: @mt_panel; + border: 1px solid @mt_border_soft; + border-radius: 3px; + color: @mt_text; + font-size: 0.88em; +} + +toast > widget > label { + color: @mt_text; +} + +toast button { + border-radius: 2px; + color: @mt_accent; +} + +/* ── Context menus / popovers ─────────────────────────────────────────── + Stock GtkPopoverMenu. Flatten corners, darken bg to match panel. */ +popover.menu, +popover.background.menu { + background-color: @mt_panel; + border: 1px solid @mt_border_soft; + border-radius: 2px; + padding: 0; + margin: 0; +} + +popover.background > contents, +popover.menu > contents, +popover.background.menu > contents { + background: transparent; + border-radius: 0; + padding: 0; + margin: 0; +} + +popover.menu > arrow, +popover.background.menu > arrow { + margin: 0; + min-width: 8px; + min-height: 8px; +} + +popover.menu > contents > box, +popover.menu > contents > scrolledwindow, +popover.menu > contents > scrolledwindow > viewport, +popover.menu > contents > scrolledwindow > viewport > box, +popover.background.menu > contents > box, +popover.background.menu > contents > scrolledwindow, +popover.background.menu > contents > scrolledwindow > viewport, +popover.background.menu > contents > scrolledwindow > viewport > box, +popover.background > contents > box, +popover.background > contents > scrolledwindow, +popover.background > contents > scrolledwindow > viewport, +popover.background > contents > scrolledwindow > viewport > box { + margin: 0; + padding: 0; + border: 0; + border-radius: 0; +} + +popover.menu list, +popover.menu listview, +popover.background.menu list, +popover.background.menu listview { + background: transparent; + margin: 0; + padding: 0; + border: 0; +} + +popover.menu list > row, +popover.menu listview > row, +popover.background.menu list > row, +popover.background.menu listview > row { + border-radius: 0; + min-height: 0; + min-width: 0; + margin: 0; + padding: 1px 2px; +} + +popover.menu list > row > box, +popover.menu listview > row > box, +popover.background.menu list > row > box, +popover.background.menu listview > row > box { + border-spacing: 2px; +} + +popover.menu modelbutton, +popover.background.menu modelbutton { + border-radius: 0; + color: @mt_text; + min-height: 0; + padding: 0 2px; + font-size: 0.80em; +} + +popover.menu modelbutton:hover, +popover.background.menu modelbutton:hover { + background-color: @mt_panel_alt; + color: @mt_text; +} + +popover.menu modelbutton:disabled, +popover.background.menu modelbutton:disabled { + color: @mt_text_faint; + opacity: 0.6; +} + +popover.menu separator, +popover.background.menu separator { + background-color: @mt_border; + margin: 0; +} + +popover.compact-menu-popover { + background-color: transparent; + border: none; + border-radius: 0; + padding: 0; + margin: 0; +} + +popover.compact-menu-popover > arrow { + margin: 0; + min-width: 0; + min-height: 0; + opacity: 0; +} + +popover.compact-menu-popover > contents { + background: transparent; + border-radius: 0; + padding: 0; + margin: 0; +} + +.compact-menu-box { + margin: 0; + padding: 6px; + background-color: @mt_panel; + border: 1px solid @mt_border_soft; + border-radius: 2px; + box-shadow: inset 0 0 0 1px alpha(@mt_border, 0.85); +} + +.compact-menu-row { + border-radius: 2px; + min-height: 0; + margin: 0; + padding: 0; +} + +.compact-menu-row:hover { + background-color: @mt_panel_alt; +} + +.compact-menu-row:active { + background-color: alpha(@mt_accent, 0.22); +} + +.compact-menu-row-leading, +.compact-menu-row-mark { + color: @mt_accent; +} + +.compact-menu-row-title { + color: @mt_text; + font-size: 0.82em; +} + +.compact-menu-row-subtitle, +.compact-menu-row-trailing { + color: @mt_text_dim; + font-size: 0.70em; +} + +.compact-menu-separator { + min-height: 1px; + margin: 0; + background-color: @mt_border; +} + +/* ── Unsaved changes / alert dialogs ──────────────────────────────────── + libadwaita::AlertDialog renders as .alert-dialog. */ +.alert-dialog { + background-color: @mt_panel; + border: 1px solid @mt_border_soft; + border-radius: 4px; +} + +.alert-dialog .dialog-body { + color: @mt_text_dim; + font-size: 0.92em; +} + +.alert-dialog .response-area { + border-top: 1px solid @mt_border; + padding: 6px; +} + +.alert-dialog .response-area button { + border-radius: 2px; + padding: 6px 14px; + font-size: 0.90em; +} + +.alert-dialog .response-area button.suggested-action { + background-color: @mt_info_bg; + border: 1px solid @mt_info_border; + color: @mt_info; +} + +.alert-dialog .response-area button.suggested-action:hover { + background-color: alpha(@mt_info, 0.16); +} + +.alert-dialog .response-area button.destructive-action { + background-color: @mt_danger_bg; + border: 1px solid @mt_danger_border; + color: @mt_danger; +} + +.alert-dialog .response-area button.destructive-action:hover { + background-color: alpha(@mt_danger, 0.14); +} + +.unsaved-dialog-shell { + background: @mt_bg; +} + +.unsaved-dialog-card { + background: @mt_panel; + border: 1px solid @mt_border_soft; + border-radius: 4px; + padding: 18px; + min-width: 320px; +} + +.unsaved-dialog-title { + color: @mt_text; + font-size: 1.16em; + font-weight: 600; + margin-bottom: 10px; +} + +.unsaved-dialog-body { + color: @mt_text_dim; + font-size: 0.92em; + margin-bottom: 16px; +} + +.unsaved-dialog-actions { + margin-top: 2px; +} + +.unsaved-dialog-actions button { + min-height: 38px; +} + +/* ── Explicit preferences/sheets we own in Rust ──────────────────────── */ +.preferences-shell { + background: @mt_bg; +} + +.preferences-page { + background: @mt_bg; +} + +.preferences-group { + background: transparent; +} + +.preferences-row { + border-radius: 0; +} + +/* ── Preferences window ───────────────────────────────────────────────── + gtk4::Window > AdwPreferencesPage > AdwPreferencesGroup + No custom CSS classes — target by type. */ +preferencespage { + background-color: @mt_bg; +} + +preferencesgroup > box > box > label.title { + font-size: 0.78em; + font-weight: 500; + color: @mt_accent; + font-family: "IBM Plex Mono", monospace; + letter-spacing: 0.10em; + text-transform: uppercase; +} + +preferencesgroup > box > box > label.subtitle { + font-size: 0.84em; + color: @mt_text_dim; +} + +preferencesgroup list { + background-color: @mt_panel; + border: 1px solid @mt_border; + border-radius: 3px; +} + +preferencesgroup list row { + background-color: transparent; + border-radius: 0; + padding: 6px 10px; + border-top: 1px solid @mt_panel_alt; +} + +preferencesgroup list row:first-child { + border-top: none; +} + +preferencesgroup list row:hover { + background-color: alpha(@mt_text, 0.03); +} + +/* SwitchRow title/subtitle labels */ +preferencesgroup list row .title { + font-size: 0.92em; + color: @mt_text; +} + +preferencesgroup list row .subtitle { + font-size: 0.84em; + color: @mt_text_dim; +} + +/* Switch inside SwitchRow */ +preferencesgroup list row switch { + border-radius: 999px; +} + +preferencesgroup list row switch:checked { + background-color: alpha(@mt_accent, 0.18); + border-color: alpha(@mt_accent, 0.40); +} + +preferencesgroup list row switch slider { + border-radius: 999px; +} + +/* ActionRow (e.g. Open Profiles Folder row) */ +preferencesgroup list row button { + border-radius: 2px; + background: @mt_bg; + border: 1px solid @mt_border_soft; + color: @mt_text; + padding: 4px 12px; + font-size: 0.88em; +} + +preferencesgroup list row button:hover { + border-color: alpha(@mt_accent, 0.34); + color: @mt_accent; +} + +/* ── Stock AdwSwitchRow / GtkSwitch used elsewhere ────────────────────── + The toggle rows on the preferences page and anywhere a raw GtkSwitch + appears outside the custom .control-row context. */ +switch { + border-radius: 999px; + min-width: 42px; + min-height: 22px; +} + +/* ── Stock Adwaita list rows used in sidebar accordion ────────────────── + Expander rows (section expand buttons). */ +expander-row { + border-radius: 3px; +} + +expander-row > box > box > label { + font-size: 0.78em; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + font-family: "IBM Plex Mono", monospace; + color: @mt_text_dim; +} + +/* ── GtkScrolledWindow scrollbars ─────────────────────────────────────── + Thin, low-contrast scrollbars throughout. */ +scrollbar { + background-color: transparent; +} + +scrollbar slider { + background-color: @mt_border_soft; + border-radius: 999px; + min-width: 4px; + min-height: 4px; + margin: 2px; +} + +scrollbar slider:hover { + background-color: @mt_text_faint; +} + +/* ── GtkSeparator ───────────────────────────────────────────────────────*/ +separator { + background-color: @mt_border; + min-height: 1px; + min-width: 1px; +} diff --git a/docs/GAME_CONFIG_DB.md b/docs/GAME_CONFIG_DB.md new file mode 100644 index 0000000..d560024 --- /dev/null +++ b/docs/GAME_CONFIG_DB.md @@ -0,0 +1,129 @@ +# Game Config DB + +This document describes MangoTune's bundled game/executable hint database used by the +`Create New Per-App Config…` flow. + +## Purpose + +The database exists to make per-app MangoHud config creation easier without pretending +MangoTune can always infer the correct process name automatically. + +It is used for: + +- searching by game title or alias +- suggesting likely MangoHud config names / executable stems +- prefilling the per-app config name when the user clicks a result + +It is **not** the source of truth for MangoHud runtime matching. MangoHud still matches +the real process or executable name. + +## File Location + +Bundled database: + +- `data/game_config_db.toml` + +Maintenance script: + +- `scripts/build_game_config_db.py` + +Loader/search logic: + +- `src/integrations/game_db.rs` + +UI integration: + +- `src/window.rs` + +## Format + +The database is TOML and uses repeated `[[game]]` tables. + +Example: + +```toml +[[game]] +appid = 730 +title = "Counter-Strike 2" +aliases = ["cs2", "counter strike 2", "counter-strike 2", "csgo"] +candidates = ["cs2", "csgo", "wine-cs2"] +preferred = "cs2" +verification = "verified" +``` + +Fields: + +- `appid` + - Steam app ID when known +- `title` + - game display name +- `aliases` + - extra search terms or alternate names +- `candidates` + - possible MangoHud config names / executable stems +- `preferred` + - the main suggested config name the UI fills when clicked +- `verification` + - trust level for the entry + +## Verification Levels + +Allowed values today: + +- `verified` + - manually confirmed from a trustworthy source such as SteamDB launch config pages +- `heuristic` + - derived from local Steam library scanning and should be treated as a suggestion + +The app currently uses the same search/display behavior for both, but the field exists so +maintainers can gradually improve the database quality over time. + +## How The Database Is Built + +The maintenance script currently combines: + +1. Local Steam library data + - reads Steam library roots from `libraryfolders.vdf` + - reads installed games from `appmanifest_*.acf` + - scans install directories for likely executable stems + +2. Curated overrides + - hand-maintained entries in `scripts/build_game_config_db.py` + - these are where known-good preferred names should be corrected and verified + +The generated file is committed to the repo and used at runtime. MangoTune does not fetch +online data at runtime. + +## Updating It + +When adding or refreshing entries: + +1. Edit the curated overrides in `scripts/build_game_config_db.py` +2. Run: + +```bash +python scripts/build_game_config_db.py +``` + +3. Review the generated `data/game_config_db.toml` +4. Commit both the script changes and the TOML changes + +## Curation Guidance + +Prefer adding or correcting entries when: + +- a game's local-scan preferred name is obviously wrong +- SteamDB or another reliable source exposes a clear launch executable +- a game has known Proton/native naming differences worth surfacing as candidates + +Do not add contributor names to the data file. Git history already tracks authorship. + +## UX Contract + +The per-app config creation dialog should continue to support both: + +- direct manual typing of the exact executable name +- database-backed search by game title / alias + +The database is there to help the user get to the right name faster, not to replace manual +control. diff --git a/docs/MANGOHUD_OPTION_BEHAVIOR.md b/docs/MANGOHUD_OPTION_BEHAVIOR.md new file mode 100644 index 0000000..0f50634 --- /dev/null +++ b/docs/MANGOHUD_OPTION_BEHAVIOR.md @@ -0,0 +1,240 @@ +# MangoHud Option Behavior Reference + +Reference for MangoTune, verified against a local MangoHud source snapshot (`f2e45e9`) on 2026-03-24 and cross-checked against upstream `README.md` and `data/MangoHud.conf`. + +Primary source files: +- `/tmp/MangoHud-src/src/overlay_params.h` for the authoritative option list (`OVERLAY_PARAMS`) +- `/tmp/MangoHud-src/src/overlay_params.cpp` for parsing, defaults, preset handling, and env/config precedence +- `/tmp/MangoHud-src/src/overlay.cpp` for positioning, margin, and horizontal sizing behavior +- `/tmp/MangoHud-src/src/config.cpp` for config-file line parsing + +## Parsing Rules + +- Config-file lines are trimmed and `# ...` comments are stripped before value parsing. +- A bare key with no `=` is treated as value `1`. +- `OVERLAY_PARAM_BOOL(...)` options are effectively flags: bare key or `=1` enables, `=0` disables. +- `offset_x` and `offset_y` are parsed as unsigned integers, even though upstream stores them in signed `int` fields later. +- Some custom options are still boolean-like in practice, such as `no_display`, `full`, and `help`. +- Upstream currently declares `io_read` and `io_write` twice. In normal parsing the earlier boolean branch wins, so they behave as flags in practice. + +## Positioning And Layout Rules + +- Base edge margin is `10px` in `position_layer()`. +- That margin becomes `0px` if `hud_no_margin` is enabled or either `offset_x > 0` or `offset_y > 0`. +- Right-side anchors (`top-right`, `middle-right`, `bottom-right`) are native in MangoHud. +- `horizontal_stretch` defaults to `true`. +- Horizontal layouts start with full display width and may shrink after layout when `horizontal_stretch` is disabled. +- `top-center` and `bottom-center` get an extra horizontal shift in horizontal non-stretch mode. + +## Precedence And Special Cases + +- Base defaults come from `set_param_defaults()`. +- Built-in presets are applied before later explicit options. +- `preset` is an upstream-supported special option handled before normal option assignment, even though it does not live inside the main `OVERLAY_PARAMS` macro list. +- If `MANGOHUD_CONFIG` is set, MangoHud normally uses env options directly. +- `read_cfg=1` tells MangoHud to also read the config file even when env config is present. +- `full=1` is a bulk enable mode, not a simple single-feature toggle. +- `full=1` immediately disables a curated exclusion list before later explicit options are applied again, including `histogram`, `fps_only`, `horizontal`, `hud_no_margin`, `hud_compact`, `mangoapp_steam`, `dynamic_frame_timing`, `hide_engine_names`, and `hide_fps_superscript`. +- `fps_only=1` later forces `legacy_layout` off. +- Non-horizontal auto width (`width=0`) is derived later from font size, scale, and column count, with extra widening for I/O rows and `no_small_font`. + +## MangoTune Coverage Audit + +- Verified upstream options in `OVERLAY_PARAMS`: `186` +- Additional upstream special options handled outside `OVERLAY_PARAMS`: `preset`, `inherit` +- Duplicate upstream declarations: `io_read`, `io_write` +- MangoTune schema entries today: `187` +- MangoTune now represents the full currently documented option surface in its live schema, including the special-case keys `preset`, `inherit`, and `help` +- Some of those options intentionally live in advanced UI sections because they are niche directives or special-case upstream behaviors rather than normal daily tuning knobs + +## Verified Option Table + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `af` | Signed integer | `-1` | Anisotropic filtering; -1=unset | +| `alpha` | Float | `1.0` | Overall HUD transparency | +| `arch` | Flag (bare key or =1/=0) | `false` | MangoHud architecture | +| `autostart_log` | Unsigned integer | | Seconds before auto-start | +| `background_alpha` | Float | `0.5` | Background transparency for the HUD window. | +| `background_color` | Hex color (RRGGBB) | `020202` | Hex color used for the Background readout or accent. | +| `battery` | Flag (bare key or =1/=0) | `false` | Shows battery status when available. | +| `battery_color` | Hex color (RRGGBB) | `FF9078` | Hex color used for the Battery readout or accent. | +| `battery_icon` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `battery` | +| `battery_time` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `battery` | +| `battery_watt` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `battery` | +| `benchmark_percentiles` | String list (legacy / deprecated) | `"97","AVG"` in current source defaults | Current source seeds `97,AVG`, but the parser logs that this option is obsolete and recommends `fps_metrics`. Upstream README still documents a broader default set (`97,AVG,1,0.1`), so published docs and current source are not fully aligned. | +| `bicubic` | Flag (bare key or =1/=0) | `false` | Force bicubic filtering | +| `blacklist` | Delimited string list | | App names to suppress overlay | +| `cellpadding_y` | Float | `-0.085` | Vertical cell padding tweak used by the ImGui table layout. | +| `control` | String | `-1` | Creates/listens on a control socket path; `%p` is expanded to the current process ID. | +| `core_bars` | Flag (bare key or =1/=0) | `false` | Graphical core bars | +| `core_load` | Flag (bare key or =1/=0) | `false` | Per-core load bars | +| `core_load_change` | Flag (bare key or =1/=0) | `false` | Color per-core load | +| `core_type` | Flag (bare key or =1/=0) | `false` | Show P/E core type labels | +| `cpu_color` | Hex color (RRGGBB) | `2E97CB` | Hex color used for the Cpu readout or accent. | +| `cpu_custom_temp_sensor` | hwmon name,input pair | | Custom CPU temperature source in `hwmon_name,hwmon_input` form. | +| `cpu_efficiency` | Flag (bare key or =1/=0) | `false` | Shows CPU efficiency / perf-per-watt style data when available. | +| `cpu_load_change` | Flag (bare key or =1/=0) | `false` | Color CPU load | +| `cpu_load_color` | List of 3 hex colors | `0x39f900,0xfdfd09,0xb22222` | Three hex colors | +| `cpu_load_value` | Comma-separated integers | `60,90` | Two thresholds | +| `cpu_mhz` | Flag (bare key or =1/=0) | `false` | Average MHz across cores | +| `cpu_power` | Flag (bare key or =1/=0) | `false` | Shows CPU package or socket power draw when available. | +| `cpu_stats` | Flag (bare key or =1/=0) | `true` | Master CPU section toggle | +| `cpu_temp` | Flag (bare key or =1/=0) | `false` | Shows CPU temperature. | +| `cpu_text` | String | | Custom CPU label | +| `custom_text` | String-bearing special option | `""` / unset | Declared in the macro list like a bool, but in practice MangoHud parses and uses it as a text payload line. | +| `custom_text_center` | String-bearing special option | `""` / unset | Declared in the macro list like a bool, but in practice MangoHud parses and uses it as centered text. | +| `debug` | Flag (bare key or =1/=0) | `false` | Gamescope app frametimes graph | +| `device_battery` | Delimited string list | | List of device battery kinds such as `controller`, `mouse`, or `headset`. | +| `device_battery_icon` | Flag (bare key or =1/=0) | `false` | Adds icons to device battery entries. | +| `display_server` | Flag (bare key or =1/=0) | `false` | Wayland/X11 indicator | +| `duration` | Flag (bare key or =1/=0) | `false` | Enables benchmark-duration style tracking; base default is off even under `full`. | +| `dx_api` | Flag (bare key or =1/=0) | `false` | Displays the DirectX API/backend label where supported. | +| `dynamic_frame_timing` | Flag (bare key or =1/=0) | `false` | Dynamic scale frametime graph | +| `engine_color` | Hex color (RRGGBB) | `EB5B5B` | Hex color used for the Engine readout or accent. | +| `engine_short_names` | Flag (bare key or =1/=0) | `false` | Uses shorter engine names when engine info is displayed. | +| `engine_version` | Flag (bare key or =1/=0) | `false` | Displays the engine version string when MangoHud can detect it. | +| `exec` | String-bearing special option | `""` / unset | Runs a shell command and shows its output in the next column; only works with `legacy_layout=0`. Like `custom_text`, it is handled more like a string payload than a normal bool-style flag. | +| `exec_name` | Flag (bare key or =1/=0) | `false` | Show executable name | +| `fan` | Flag (bare key or =1/=0) | `false` | Steam Deck fan RPM | +| `fcat` | Flag (bare key or =1/=0) | `false` | Enable FCAT overlay | +| `fcat_overlay_width` | Unsigned integer | `24` | DEPENDS ON: `fcat` | +| `fcat_screen_edge` | Unsigned integer | `0` | DEPENDS ON: `fcat` | +| `fex_stats` | Option list | | Tokenized option list enabling selected FEX-Emu stat groups when MangoHud is built with FEX support. | +| `flip_efficiency` | Flag (bare key or =1/=0) | `false` | Joules per frame | +| `font_file` | Filesystem path | | TTF/OTF path — validated to exist if set | +| `font_file_text` | Filesystem path | | Custom label text for the Font File section. | +| `font_glyph_ranges` | Glyph range flags | | Comma-separated glyph packs such as `japanese`, `cyrillic`, or `latin_ext_a`. | +| `font_scale` | Float | `1.0` | Global font scale multiplier. | +| `font_scale_media_player` | Float | `0.55` | Separate font scale for media-player text. | +| `font_size` | Float | `24` | Main font size in pixels. | +| `font_size_secondary` | Float | | Secondary text size used by some secondary labels; parsed separately from the main `font_size`. | +| `font_size_text` | Float | | For text elements | +| `fps` | Flag (bare key or =1/=0) | `true` | Show FPS counter — enabled by default | +| `fps_color` | List of 3 hex colors | `0xb22222,0xfdfd09,0x39f900` | Three hex colors: low,mid,high | +| `fps_color_change` | Flag (bare key or =1/=0) | `false` | Enable FPS color thresholds | +| `fps_limit` | Comma-separated numbers | `0` | 0 = unlimited; comma-separated list e.g. `0,30,60` | +| `fps_limit_method` | Enum (`early` or `late`) | `FPS_LIMIT_METHOD_LATE` | Limit strategy: `late` is the default, `early` waits earlier in the frame. | +| `fps_metrics` | Metric list | | e.g. `avg,0.01,1,97` — `AVG` or decimal percentiles | +| `fps_only` | Flag (bare key or =1/=0) | `false` | CONFLICTS WITH: all other display params | +| `fps_sampling_period` | String | `500ms` | Sampling period in milliseconds in config/env input; MangoHud stores it internally in nanoseconds. | +| `fps_text` | String | | Custom label for FPS row | +| `fps_value` | Comma-separated integers | `30,60` | Two thresholds: warn,ok | +| `frame_count` | Flag (bare key or =1/=0) | `false` | Show frame counter | +| `frame_timing` | Flag (bare key or =1/=0) | `true` | Frametime graph — enabled by default | +| `frame_timing_detailed` | Flag (bare key or =1/=0) | `false` | More detailed frametime graph | +| `frametime` | Flag (bare key or =1/=0) | `true` | Show frametime — enabled by default | +| `frametime_color` | Hex color (RRGGBB) | `00FF00` | Hex color used for the Frametime readout or accent. | +| `fsr` | Flag (bare key or =1/=0) | `false` | FSR status | +| `fsr_steam_sharpness` | Float | `-1` | Float sharpness control for Steam FSR integration. MangoHud default is `-1`, which means “leave unset / use app or Steam default”. | +| `ftrace` | Tracepoint spec list | | Tracepoint specification list used only when MangoHud is built with ftrace support. | +| `full` | Boolean-like (0/1) | `false` | Enables almost every boolean option, then explicitly disables a curated exclusion list. Use with caution because it can enable many unrelated HUD sections at once. | +| `gamemode` | Flag (bare key or =1/=0) | `false` | GameMode running status | +| `gl_bind_framebuffer` | Unsigned integer | | Rebind framebuffer before draw | +| `gl_dont_flip` | Unsigned integer | | Don't swap origin for GL_UPPER_LEFT | +| `gl_size_query` | Enum (`drawable`, `viewport`, `scissorbox`) | | Default: glXQueryDrawable | +| `gl_vsync` | Signed integer | `-2` | Signed integer for OpenGL swap interval; `-2` means “unset” in MangoHud defaults. | +| `gpu_color` | Hex color (RRGGBB) | `2E9762` | Hex color used for the Gpu readout or accent. | +| `gpu_core_clock` | Flag (bare key or =1/=0) | `false` | Shows GPU core clock speed. | +| `gpu_efficiency` | Flag (bare key or =1/=0) | `false` | Shows GPU efficiency / perf-per-watt style data when available. | +| `gpu_fan` | Flag (bare key or =1/=0) | `false` | RPM on AMD, percent on NVIDIA | +| `gpu_junction_temp` | Flag (bare key or =1/=0) | `false` | Shows GPU hotspot / junction temperature when available. | +| `gpu_list` | GPU index list | | Comma-separated GPU indices to show. Useful on multi-GPU systems. | +| `gpu_load_change` | Flag (bare key or =1/=0) | `false` | Color GPU load | +| `gpu_load_color` | List of 3 hex colors | `0x39f900,0xfdfd09,0xb22222` | Three hex colors | +| `gpu_load_value` | Comma-separated integers | `60,90` | Two load thresholds | +| `gpu_mem_clock` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `vram` | +| `gpu_mem_temp` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `vram` | +| `gpu_name` | Flag (bare key or =1/=0) | `false` | Show GPU model name | +| `gpu_power` | Flag (bare key or =1/=0) | `false` | Shows GPU power draw. | +| `gpu_power_limit` | Flag (bare key or =1/=0) | `false` | Displays the current GPU power limit. | +| `gpu_stats` | Flag (bare key or =1/=0) | `true` | Master GPU section toggle | +| `gpu_temp` | Flag (bare key or =1/=0) | `false` | Shows GPU temperature. | +| `gpu_text` | Delimited string list | | Parsed as a tokenized string list in source, so multiple GPU labels can be supplied for multi-GPU setups. | +| `gpu_voltage` | Flag (bare key or =1/=0) | `false` | AMD ONLY | +| `graphs` | Flag (bare key or =1/=0) | `false` | Valid values: `gpu_load,cpu_load,gpu_core_clock,gpu_mem_clock,vram,ram,cpu_temp,gpu_temp` | +| `hdr` | Flag (bare key or =1/=0) | `false` | HDR status | +| `height` | Unsigned integer | `140` | HUD window height. Horizontal layouts start with this height before any later width adjustments. | +| `help` | Boolean-like (prints help and exits) | `0` | Printing helper: causes MangoHud to print supported env-style params to stderr. Not a normal overlay display toggle. | +| `hide_engine_names` | Flag (bare key or =1/=0) | `false` | Hides engine names even if engine-related display items are enabled. | +| `hide_fps_superscript` | Flag (bare key or =1/=0) | `false` | Removes the small `FPS` superscript styling from the FPS readout. | +| `hide_fsr_sharpness` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `fsr` | +| `histogram` | Flag (bare key or =1/=0) | `false` | CONFLICTS WITH frame_timing | +| `horizontal` | Flag (bare key or =1/=0) | `false` | Switches the HUD into a horizontal/table layout. MangoHud initially sizes it to full display width, then may shrink it after layout based on content width. | +| `horizontal_separator_color` | Hex color (RRGGBB) | `AD64C1` | Hex color used for the Horizontal Separator readout or accent. | +| `horizontal_stretch` | Flag (bare key or =1/=0) | `true` | Defaults to true. When disabled, MangoHud shrinks horizontal HUD width after rendering to match content instead of stretching across the screen. | +| `hud_compact` | Flag (bare key or =1/=0) | `false` | Compact mode | +| `hud_no_margin` | Flag (bare key or =1/=0) | `false` | Removes MangoHud’s normal 10px edge margin. The same margin is also removed automatically whenever `offset_x` or `offset_y` is greater than zero. | +| `inherit` | Preset-file directive | `inherit` line inside `presets.conf` | Mainly used inside preset definitions, where a literal `inherit` line re-applies parent preset behavior. It is not a typical everyday overlay toggle. | +| `io_color` | Hex color (RRGGBB) | `A491D3` | Hex color used for the Io readout or accent. | +| `io_read` | Flag (bare key or =1/=0) | `false` | Shows per-process I/O read throughput. Note: upstream declares this key twice (`BOOL` and `CUSTOM`), but the boolean branch wins first, so practical config behavior is flag-like. | +| `io_write` | Flag (bare key or =1/=0) | `false` | Shows per-process I/O write throughput. Note: upstream declares this key twice (`BOOL` and `CUSTOM`), but the boolean branch wins first, so practical config behavior is flag-like. | +| `legacy_layout` | Flag (bare key or =1/=0) | `true` | Uses MangoHud’s older default ordering/layout behavior. `fps_only` forces this off. | +| `log_duration` | Unsigned integer | | Seconds | +| `log_interval` | Unsigned integer | `0` | ms; 0 = default | +| `log_versioning` | Flag (bare key or =1/=0) | `false` | Appends version info to generated log filenames. | +| `mangoapp_steam` | Flag (bare key or =1/=0) | `false` | mangoapp only | +| `media_player` | Flag (bare key or =1/=0) | `false` | Enable media player metadata | +| `media_player_color` | Hex color (RRGGBB) | `FFFFFF` | Hex color used for the Media Player readout or accent. | +| `media_player_format` | Semicolon-separated format list | `"{title}","{artist}","{album}"` | Semicolon-separated format strings. MangoHud formats `{title}`, `{artist}`, and `{album}` tokens from MPRIS metadata. | +| `media_player_name` | String | `""` | e.g. `spotify` — DEPENDS ON: `media_player` | +| `network` | Delimited string list | | Comma-separated network interfaces; empty/default means MangoHud decides what to show. | +| `network_color` | Hex color (RRGGBB) | `E07B85` | Hex color used for the Network readout or accent. | +| `no_display` | Boolean-like (0/1) | `0` | Boolean-style option. Starts MangoHud hidden, but toggle/reload keys still work. | +| `no_small_font` | Unsigned integer | | Disable small font for secondary info | +| `offset_x` | Unsigned integer | `0` | Unsigned X offset. Positive values push left-anchored HUDs right and right-anchored HUDs farther right. Any positive offset also removes the normal 10px edge margin. | +| `offset_y` | Unsigned integer | `0` | Unsigned Y offset. Positive values push the HUD downward. Any positive offset also removes the normal 10px edge margin. | +| `output_file` | Filesystem path | | Explicit output filename for benchmark/log output. | +| `output_folder` | Filesystem path | | Must be writable directory | +| `pci_dev` | String | | Format: `domain:bus:slot.function` e.g. `0000:03:00.0` | +| `permit_upload` | Unsigned integer | `0` | Upload to flightlessmango.com | +| `picmip` | Signed integer | `-17` | Mip-map LoD bias; negative=sharper | +| `preset` | Comma-separated preset list | `-1,0,1,2,3,4` in upstream docs/examples | Upstream special option handled before normal assignment. Supports built-in presets and user presets via `presets.conf`. | +| `position` | Enum position | `top-left` | Anchor position. Right anchors are native in MangoHud; `top-center` and `bottom-center` get extra horizontal adjustment when `horizontal` is on and `horizontal_stretch` is off. | +| `present_mode` | Flag (bare key or =1/=0) | `false` | Displays the current presentation mode in the HUD. | +| `proc_vram` | Flag (bare key or =1/=0) | `false` | Per-process VRAM | +| `procmem` | Flag (bare key or =1/=0) | `false` | Per-process resident memory | +| `procmem_shared` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `procmem` | +| `procmem_virt` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `procmem` | +| `ram` | Flag (bare key or =1/=0) | `false` | Shows system RAM usage. | +| `ram_color` | Hex color (RRGGBB) | `C26693` | Hex color used for the Ram readout or accent. | +| `ram_temp` | Flag (bare key or =1/=0) | `false` | Displays RAM temperature if MangoHud can read it from available sensors. | +| `read_cfg` | Flag (bare key or =1/=0) | `false` | Only matters when using `MANGOHUD_CONFIG` in the environment: it tells MangoHud to also read the config file instead of using env options alone. | +| `refresh_rate` | Flag (bare key or =1/=0) | `false` | Current refresh rate | +| `reload_cfg` | Keybind | `Shift_L+F4` on Linux | Reload keybind. Useful for external editors or MangoTune preview sessions. | +| `reset_fps_metrics` | Keybind | `Shift_R+F9` on Linux | Keybind that resets FPS metrics / percentiles. | +| `resolution` | Flag (bare key or =1/=0) | `false` | Current display resolution | +| `retro` | Flag (bare key or =1/=0) | `false` | Disable linear filtering (blocky textures) | +| `round_corners` | Unsigned integer | `0` | Rounded-corner radius for the HUD background. | +| `show_fps_limit` | Flag (bare key or =1/=0) | `false` | Display current FPS limit value | +| `swap` | Flag (bare key or =1/=0) | `false` | Shows swap usage. | +| `table_columns` | Unsigned integer | `3` | Number of table columns. MangoHud later clamps it to the range `1..64`. | +| `temp_fahrenheit` | Flag (bare key or =1/=0) | `false` | Use °F instead of °C | +| `text_color` | Hex color (RRGGBB) | `FFFFFF` | Hex color used for the Text readout or accent. | +| `text_outline` | Flag (bare key or =1/=0) | `true` | Draws an outline around text for readability. | +| `text_outline_color` | Hex color (RRGGBB) | `000000` | Hex color used for the Text Outline readout or accent. | +| `text_outline_thickness` | Float | `1.5` | Thickness of the text outline stroke. | +| `throttling_status` | Flag (bare key or =1/=0) | `false` | GPU throttling indicator | +| `throttling_status_graph` | Flag (bare key or =1/=0) | `false` | Show throttling on frametime graph | +| `time` | Flag (bare key or =1/=0) | `false` | Current time | +| `time_format` | String | `"%T"` | strftime format | +| `time_no_label` | Flag (bare key or =1/=0) | `false` | DEPENDS ON: `time` | +| `toggle_fps_limit` | Keybind | `Shift_L+F1` on Linux | Switches between configured FPS limit entries. | +| `toggle_hud` | Keybind | `Shift_R+F12` on Linux | Keybind list parsed via xkb key names on Linux / key codes on Windows. | +| `toggle_hud_position` | Keybind | `Shift_R+F11` on Linux | Cycles through positions with a keybind. Upstream README also spells this as `R_Shift+F11`; `data/MangoHud.conf` uses `Shift_R+F11`. | +| `toggle_logging` | Keybind | `Shift_L+F2` on Linux | Keybind that starts or stops logging. | +| `toggle_preset` | Keybind | `Shift_R+F10` on Linux | Cycles through presets with a keybind. | +| `trilinear` | Flag (bare key or =1/=0) | `false` | Force trilinear filtering | +| `upload_log` | Keybind | `Shift_L+F3` on Linux | Keybind that uploads the current log. | +| `upload_logs` | Keybind | `Control_L+F3` on Linux | DEPENDS ON: `permit_upload=1` | +| `version` | Flag (bare key or =1/=0) | `false` | Show MangoHud version in overlay | +| `vkbasalt` | Flag (bare key or =1/=0) | `false` | vkBasalt running status | +| `vram` | Flag (bare key or =1/=0) | `false` | Required by gpu_mem_clock, gpu_mem_temp | +| `vram_color` | Hex color (RRGGBB) | `AD64C1` | Hex color used for the Vram readout or accent. | +| `vsync` | Unsigned integer | `-1` | Unsigned value in the parser, but MangoHud seeds the default to `-1` before parsing config/env overrides. | +| `vulkan_driver` | Flag (bare key or =1/=0) | `false` | Show Vulkan driver string | +| `vulkan_present_mode` | String | | String name for the Vulkan present mode preference; MangoHud accepts names like `fifo`, `mailbox`, or full `VK_PRESENT_MODE_*_KHR`. | +| `width` | Unsigned integer | `0` | Explicit window width. `0` means MangoHud decides automatically; for non-horizontal layouts MangoHud later derives a width from font size, scale, and column count. | +| `wine` | Flag (bare key or =1/=0) | `false` | Wine/Proton version | +| `wine_color` | Hex color (RRGGBB) | `EB5B5B` | Hex color used for the Wine readout or accent. | +| `winesync` | Flag (bare key or =1/=0) | `false` | Wine sync method | diff --git a/docs/MANGOHUD_POSITION_LAB.md b/docs/MANGOHUD_POSITION_LAB.md new file mode 100644 index 0000000..8840677 --- /dev/null +++ b/docs/MANGOHUD_POSITION_LAB.md @@ -0,0 +1,159 @@ +# MangoHud Position Lab + +This is a direct MangoHud test harness for debugging positioning behavior outside MangoTune's +preview pipeline. + +## Why + +When right-aligned horizontal preview behavior looks wrong, the first question is whether the +problem is in MangoTune's preview math or MangoHud itself. This lab runs MangoHud directly +against a simple test app so the behavior can be compared without preview-only overrides. + +## Script + +Use: + +```bash +scripts/mangohud-position-lab.sh [glxgears|vkcube] [output_dir] +``` + +Examples: + +```bash +scripts/mangohud-position-lab.sh ~/.config/mangotune/profiles/zz_test_right_sparse_top.conf glxgears +scripts/mangohud-position-lab.sh ~/.config/mangotune/profiles/zz_test_right_full_top.conf vkcube /tmp/mh-lab/full-top +``` + +The script: +- launches MangoHud directly +- records stdout/stderr to `run.log` +- tries to dump the X window tree to `xwininfo.tree` +- tries to capture window geometry and a screenshot when `xdotool`/`import` are available +- honors `DISPLAY`, `XAUTHORITY`, `WAIT_SECS`, `WIDTH`, and `HEIGHT` from the environment + +## Matrix runner + +For the standard right-alignment repro set, use: + +```bash +scripts/mangohud-position-matrix.sh [vkcube|glxgears] [output_dir] [profile_dir] +``` + +Examples: + +```bash +scripts/mangohud-position-matrix.sh vkcube /tmp/mh-matrix +DISPLAY=:1 XAUTHORITY=/root/.Xauthority scripts/mangohud-position-matrix.sh vkcube /tmp/mh-matrix /home/aaron/mangotune-test-profiles +``` + +This generates both margin-on and margin-off variants for the standard right-alignment test +profiles and captures each case directly through MangoHud. + +## Suggested Test Matrix + +Run at least these profile families: + +1. `zz_test_right_full_top.conf` +2. `zz_test_right_sparse_top.conf` +3. `zz_test_right_sparse_middle.conf` +4. `zz_test_right_sparse_compact_top.conf` +5. `zz_test_right_sparse_compact_middle.conf` + +Then compare: +- `hud_no_margin` on vs off +- `hud_compact` on vs off +- sparse vs fuller layouts +- `top-right` vs `middle-right` + +Recommended validation order: + +1. `full` top-right, margin on +2. `full` top-right, margin off +3. `sparse` top-right, margin on +4. `sparse` top-right, margin off +5. `sparse_compact` top-right, margin off +6. `sparse` middle-right, margin off +7. `sparse_compact` middle-right, margin off + +## Isolated Xorg on arch.lan + +When local testing would interrupt the user desktop, `arch.lan` can run a separate Xorg on +another VT and use that for direct MangoHud captures: + +```bash +ssh aaron@arch.lan +sudo -n xinit /bin/bash -lc 'xsetroot -solid black; while :; do sleep 3600; done' \ + -- /usr/bin/Xorg :1 vt8 -nolisten tcp -noreset +``` + +Then from another shell: + +```bash +ssh aaron@arch.lan ' + DISPLAY=:1 XAUTHORITY=/root/.Xauthority \ + /path/to/mangotune/scripts/mangohud-position-matrix.sh \ + vkcube /tmp/mh-matrix /home/aaron/mangotune-test-profiles +' +``` + +That keeps the direct MangoHud investigation isolated from the main desktop session. + +## Expected MangoHud Semantics + +From MangoHud's upstream README: +- `position=` supports `top-right`, `middle-right`, and `bottom-right` directly +- `horizontal` enables horizontal layout +- `horizontal_stretch` stretches the background to the screen width in horizontal mode +- `hud_no_margin` removes margins around MangoHud +- `offset_x` and `offset_y` are generic HUD position offsets +- `width=` overrides the automatically computed HUD width + +That means MangoTune should be conservative about inventing extra right-anchor behavior. If +MangoHud itself has a quirk here, MangoTune should document or surface it rather than baking in +fragile compensations unless absolutely necessary. + +## Upstream source findings + +From MangoHud upstream `src/overlay.cpp` and `src/overlay_params.cpp`: + +- Default edge margin is `10.0f`. +- The margin is forced to `0.0f` whenever **any** of these are true: + - `offset_x > 0` + - `offset_y > 0` + - `hud_no_margin` is enabled +- For right-side anchors, MangoHud positions the HUD with: + +```cpp +x = display_width - window_size.x - margin + offset_x +``` + +That means: +- right anchors are native in MangoHud +- positive `offset_x` pushes a right-anchored HUD farther right, not inward +- because `offset_x` is parsed as unsigned, MangoHud cannot use a negative right-anchor offset + to nudge the HUD left from `top-right` / `middle-right` / `bottom-right` + +Also note: + +- `horizontal_stretch` defaults to `true` upstream, so `horizontal_stretch=0` must be written + explicitly when the user disables it +- `offset_x` / `offset_y` are parsed as unsigned values upstream + +These semantics should be treated as source-of-truth before adding any MangoTune-side +compensation. + +## Capture caveat + +On the isolated remote Xorg lab, direct screenshot methods can still miss MangoHud's overlay +entirely while capturing the app window correctly. This includes: + +- ImageMagick `import` against the app window +- ImageMagick `import -window root` against the full desktop +- `ffmpeg -f x11grab` style X11 captures + +So: + +- direct launch/log/geometry capture is reliable there +- direct pixel capture of the overlay is not yet a trustworthy automated oracle +- if visual confirmation matters, a manual screenshot on a real desktop session is still the + most reliable check diff --git a/docs/WINDOW_GEOMETRY_INVESTIGATION.md b/docs/WINDOW_GEOMETRY_INVESTIGATION.md new file mode 100644 index 0000000..31ea53e --- /dev/null +++ b/docs/WINDOW_GEOMETRY_INVESTIGATION.md @@ -0,0 +1,187 @@ +# MangoTune Window Geometry Investigation + +This documents the March 28, 2026 investigation into the main-window width drift / off-screen placement issue. + +## Symptom + +- On XFCE/X11, MangoTune can open slightly off-screen on the right and bottom. +- Switching pages such as `Live Preview` and `Debug` can make the already-open window grow wider. +- XFCE appears to keep the same top-left corner when that happens, so the right edge drifts farther off-screen. + +## Environment Observed + +- Session type: `X11` +- Desktop: `XFCE` +- Monitor/work area observed during testing: + - screen: `3440x1440` + - `_NET_WORKAREA`: `3440x1395` + +## Concrete Live Findings + +### Startup geometry + +Observed X11 state from a live MangoTune window: + +- `WM_NORMAL_HINTS` minimum size: `1068 x 650` +- actual window geometry at startup: + - `x=2397` + - `y=311` + - `width=1068` + - `height=1109` + +That puts the window slightly off-screen already: + +- right edge: about `25 px` off-screen +- bottom edge: about `20 px` off-screen + +### Page-switch growth + +Repeated live repro: + +1. start from `Dashboard` +2. switch to `Live Preview` +3. switch to `Debug` +4. switch back to `Dashboard` + +After that sequence: + +- `WM_NORMAL_HINTS` minimum size stays `1068 x 650` +- actual window width grows from `1068` to `1288` +- top-left position stays fixed at the same `x/y` + +Important conclusion: + +- page switching is **not** changing the X11 minimum-size hint +- page switching **is** causing the actual shown window width to grow + +So this is not just a bad startup min-width hint. There is a second issue: post-navigation width growth. + +## What Was Tested + +### 1. Clamp startup size to monitor geometry + +Result: + +- helped startup placement +- did **not** stop the post-navigation width growth + +### 2. Shared page clamp / scroller tightening + +Changes tested: + +- Adwaita `Clamp` around page bodies +- `ScrolledWindow` with: + - `min_content_width = 0` + - `propagate_natural_width = false` + - `propagate_natural_height = false` + +Result: + +- sensible as a page-system cleanup +- did **not** stop the post-navigation width growth + +### 3. Tighten likely culprit pages + +Pages specifically investigated: + +- `Live Preview` +- `Debug` + +Changes tested included: + +- wrapping text views by character +- `min_content_width = 0` +- narrowing preview controls / button grids + +Result: + +- reduced some page pressure +- did **not** stop the actual width growth after navigation + +### 4. Reapply current window size after page refresh + +Tried preserving the current window size with `set_default_size(...)` after swapping the page child. + +Result: + +- did **not** stop the width growth in the live XFCE/X11 repro + +### 5. Invalid GTK CSS audit + +Found unsupported CSS properties that GTK was ignoring, such as web-style layout rules: + +- `display` +- `flex` +- `gap` +- `align-items` +- `overflow` +- `@media` + +Result after cleanup: + +- real improvement +- startup min width dropped in one rebuilt run from about `1068` to about `848` +- startup centering improved +- but the post-navigation width drift still remained + +So invalid CSS was a real issue, but not the full root cause. + +### 6. Lazy-page / placeholder experiment + +Tried making `NavigationView` keep placeholder children and attach only the visible real page, to test whether Adwaita was sizing itself to the widest visited page. + +Result: + +- **no improvement** +- the same page-switch sequence still produced the same width jump + +This experiment was reverted. + +### 7. Temporary startup-width mitigation + +A temporary mitigation was added after the investigation: + +- MangoTune now starts with a wider baseline window width of about `1300 px` +- this is intentionally around the widest observed page width (`Debug`/post-navigation width was about `1288 px`) +- goal: make the width drift much less noticeable in normal use + +Important: + +- this is **not** a root-cause fix +- it only reduces how visible the drift is by starting near the eventual grown width +- it should be treated as a stopgap until the actual GTK/Adwaita page-transition sizing cause is understood + +## Best Current Diagnosis + +What is known: + +- startup sizing had real app-side issues and was partially improved +- the remaining drift is **not** explained by the X11 minimum-size hint changing +- the remaining drift is **not** explained by previously visited `NavigationView` pages staying alive + +Most likely remaining cause: + +- a lower-level GTK/Adwaita toplevel layout negotiation behavior on page transitions +- with XFCE preserving the window's top-left position instead of re-centering or clamping it fully back on-screen + +In short: + +- MangoTune still has a real app-side geometry issue +- but the current remaining one appears deeper than a single page/widget declaring a large min width + +## Current Recommendation + +Do not keep churning speculative layout changes blindly. + +If this issue is revisited later, the next investigation should focus on: + +1. whether Adwaita `NavigationView` / `NavigationPage` transitions are causing the toplevel to renegotiate size after the page becomes visible +2. whether GTK4 exposes a stronger post-show geometry control than `set_default_size(...)` +3. whether this needs an explicit X11-specific geometry preservation path on page navigation + +## Status + +- startup placement: improved +- page-switch width drift: unresolved +- temporary mitigation: start the window near the widest observed page width so the drift is less visible +- lazy-page workaround: tested and reverted diff --git a/docs/plan/README.md b/docs/plan/README.md new file mode 100644 index 0000000..316cee0 --- /dev/null +++ b/docs/plan/README.md @@ -0,0 +1,159 @@ +# MangoTune — Agent Master Plan + +## Project Summary + +MangoTune is a GTK4 + libadwaita desktop application written in Rust for Linux. +It is a superior replacement for GOverlay — a GUI configurator for MangoHud, +with first-class support for config conflict detection, strict validation, visual +config-layer stacking (like CSS cascade), live preview via test launchers, and +integrations with GameMode, Steam, Lutris, and Heroic Games Launcher. + +## Agent Instructions — READ FIRST + +1. Read this file completely before doing anything. +2. Read `docs/architecture.md` for the full module map and dependency graph. +3. Read `docs/mangohud_schema.md` for the complete MangoHud option reference. +4. Read `docs/design_system.md` for all UI/UX rules and GTK4 widget patterns. +5. Read `phases/phase_XX.md` for the specific phase you are implementing. +6. Each module has its own spec file in `modules/`. Read the relevant spec before writing code. +7. Never modify files outside your assigned phase without noting it in a comment block. +8. After completing a phase, verify against the acceptance criteria in that phase file. + +## Directory Layout (this plan repo) + +``` +mangotune-plan/ +├── README.md ← YOU ARE HERE — read first +├── docs/ +│ ├── architecture.md ← module map, crate deps, file tree +│ ├── mangohud_schema.md ← every MangoHud config option, types, constraints +│ ├── config_resolution.md ← how MangoHud config files are discovered & prioritized +│ ├── design_system.md ← GTK4/libadwaita UI rules, widget patterns, UX decisions +│ └── integrations.md ← GameMode, Steam, Lutris, Heroic specs +├── modules/ +│ ├── config_parser.md ← parser/writer module spec +│ ├── config_schema.md ← schema/type system module spec +│ ├── config_validator.md ← validation engine module spec +│ ├── config_resolver.md ← multi-file conflict resolver module spec +│ ├── system_detect.md ← system detection module spec +│ ├── launcher.md ← test launcher module spec +│ └── ui_pages.md ← all UI page/widget specs +├── phases/ +│ ├── phase_01.md ← Project scaffold, Cargo.toml, build system +│ ├── phase_02.md ← Config parser + schema + validator (no UI) +│ ├── phase_03.md ← Config resolver + system detection (no UI) +│ ├── phase_04.md ← GTK4 app skeleton, main window, navigation +│ ├── phase_05.md ← Performance & GPU/CPU pages (core UI) +│ ├── phase_06.md ← Appearance, layout, colors, typography pages +│ ├── phase_07.md ← Conflict resolver UI (cascade stack view) +│ ├── phase_08.md ← Keybindings, logging, FPS limits pages +│ ├── phase_09.md ← Test launcher (vkcube, glxgears, custom) +│ ├── phase_10.md ← Integrations (GameMode, Steam, Lutris, Heroic) +│ └── phase_11.md ← Polish, packaging, .desktop file, final QA +└── ui/ + ├── widget_toggle.md ← toggle switch widget spec + ├── widget_color.md ← color picker widget spec + ├── widget_hotkey.md ← hotkey capture widget spec + ├── widget_cascade.md ← config cascade/layer stack widget spec + └── widget_validation.md ← inline validation error display spec +``` + +## Target Source Tree (the actual Rust project) + +``` +mangotune/ +├── Cargo.toml +├── Cargo.lock +├── build.rs +├── data/ +│ ├── com.mangotune.MangoTune.gschema.xml +│ ├── com.mangotune.MangoTune.desktop +│ └── icons/ +│ └── com.mangotune.MangoTune.svg +├── src/ +│ ├── main.rs ← entry point only — app init + run +│ ├── app.rs ← Application struct, GtkApplication setup +│ ├── window.rs ← MainWindow: AdwApplicationWindow +│ ├── config/ +│ │ ├── mod.rs +│ │ ├── parser.rs ← read/write .conf files, preserve comments +│ │ ├── schema.rs ← typed schema: all ~120 MangoHud options +│ │ ├── validator.rs ← validation logic, dependency checks +│ │ ├── resolver.rs ← discover all config files, build priority stack +│ │ └── types.rs ← shared enums/structs (ConfigValue, OptionType, etc.) +│ ├── system/ +│ │ ├── mod.rs +│ │ ├── detect.rs ← detect MangoHud version, tools, GPU, display server +│ │ └── paths.rs ← XDG path resolution, known config locations +│ ├── launcher/ +│ │ ├── mod.rs +│ │ └── runner.rs ← spawn vkcube/glxgears/custom with MANGOHUD=1 +│ ├── integrations/ +│ │ ├── mod.rs +│ │ ├── gamemode.rs +│ │ ├── steam.rs +│ │ ├── lutris.rs +│ │ └── heroic.rs +│ └── ui/ +│ ├── mod.rs +│ ├── pages/ +│ │ ├── mod.rs +│ │ ├── overview.rs +│ │ ├── performance.rs +│ │ ├── gpu.rs +│ │ ├── cpu.rs +│ │ ├── memory.rs +│ │ ├── io_network.rs +│ │ ├── media_player.rs +│ │ ├── battery.rs +│ │ ├── appearance.rs +│ │ ├── colors.rs +│ │ ├── typography.rs +│ │ ├── keybindings.rs +│ │ ├── fps_limits.rs +│ │ ├── logging.rs +│ │ ├── blacklist.rs +│ │ ├── opengl_quirks.rs +│ │ ├── raw_editor.rs +│ │ └── conflicts.rs +│ └── widgets/ +│ ├── mod.rs +│ ├── toggle_row.rs +│ ├── color_row.rs +│ ├── hotkey_row.rs +│ ├── cascade_view.rs +│ ├── validation_label.rs +│ └── launch_bar.rs +``` + +## Core Design Principles + +- **Strict validation** — the Save button is disabled if any field contains an invalid value. + Inline error labels appear below each offending field. No config is written to disk in an + invalid state under any circumstances. +- **Comment preservation** — the parser round-trips existing files preserving all comments, + whitespace, and ordering. Only changed lines are modified. +- **Dependency awareness** — the schema encodes option dependencies (e.g. `gpu_mem_clock` + requires `vram=1`). Enabling a dependent option auto-enables its parent and shows a notice. +- **Config cascade visibility** — the conflict page shows all detected config files as a + vertical stack ordered by MangoHud's actual priority (env > app-local > per-app XDG > global). + Each option that is overridden shows which file wins and which files are shadowed. +- **No hardcoded paths** — all paths resolve via XDG spec, `$HOME`, and runtime detection. +- **Graceful degradation** — if MangoHud is not installed, the app opens but shows a clear + install prompt. Missing optional tools (vkcube, glxgears, GameMode) are indicated per-feature, + not as global errors. + +## Key Differentiators vs GOverlay + +| Feature | GOverlay | MangoTune | +|-------------------------------|----------------|---------------| +| Config conflict detection | None | Visual cascade| +| Option dependency validation | None | Full schema | +| Comment preservation | Destroys them | Preserved | +| Multi-file editing | Global only | All layers | +| Strict type validation | None | Blocks save | +| Wayland support | Broken | Native GTK4 | +| HiDPI | Buggy | GTK4 native | +| XDG compliance | Fixed in v1.7 | From day one | +| Toolkit | Qt (AppImage) | GTK4/Adwaita | +| Live test launcher | vkcube only | vkcube+glx+custom | diff --git a/docs/plan/all_module_specs.md b/docs/plan/all_module_specs.md new file mode 100644 index 0000000..3fa0dbc --- /dev/null +++ b/docs/plan/all_module_specs.md @@ -0,0 +1,230 @@ +# Module Specs + +--- + +# Module: config/parser.rs + +**Purpose:** Read and write MangoHud .conf files preserving all comments and whitespace. + +**Core constraint:** A config written by MangoTune must be byte-for-byte identical to +the original file except for the lines that were explicitly changed. All comments, blank +lines, section headers, and ordering must survive a read-write-read cycle unchanged. + +## Parsing State Machine + +``` +for each line in file: + if line.trim().is_empty() → ConfigLine::Blank + if line.starts_with('#'): + strip leading '# ' or '#' + try parse as "key=value" or "key" + if valid MangoHud key format: → ConfigLine::CommentedOption { key, value } + else: → ConfigLine::Comment(original_line) + else: + split on first '=' if present + → ConfigLine::Option { key, value, raw: original_line } +``` + +## Write Strategy + +When serializing back to disk: +- For each ConfigLine in order: write it back +- For changed options: find the line by key and update ONLY that line's value portion +- For new options (key didn't exist): append at end of file +- For disabled options: prepend "# " to the line + +## Key sanitization +- Keys are trimmed of whitespace +- Keys are case-sensitive (MangoHud is case-sensitive) +- Keys must match `^[a-zA-Z_][a-zA-Z0-9_]*$` to be recognized as options + +--- + +# Module: config/validator.rs + +**Purpose:** Stateless validation engine. Every function is pure (no side effects). + +## Validation Priority +When multiple validation rules apply, return the most severe result (Error > Warning > Ok). + +## Regex patterns (compile once with once_cell::Lazy) + +```rust +static COLOR_RE: Lazy = Lazy::new(|| Regex::new(r"^[0-9A-Fa-f]{6}$").unwrap()); +static PCI_DEV_RE: Lazy = Lazy::new(|| { + Regex::new(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$").unwrap() +}); +static KEYBIND_RE: Lazy = Lazy::new(|| { + Regex::new(r"^((Shift|Control|Alt|Super)_[LR]\+)*(F[1-9]|F1[0-2]|[A-Z])$").unwrap() +}); +static FTRACE_RE: Lazy = Lazy::new(|| { + Regex::new(r"^(histogram|linegraph|label)/[a-zA-Z0-9_]+(/[a-zA-Z0-9_]+)?(\+(histogram|linegraph|label)/[a-zA-Z0-9_]+(/[a-zA-Z0-9_]+)?)*$").unwrap() +}); +``` + +## Special cases + +`fps_metrics` validation: +- Split by comma +- Each element must be: "AVG" (case-insensitive) OR a decimal between 0.0 and 100.0 + +`font_glyph_ranges` validation: +- Valid values: `["korean", "chinese", "chinese_simplified", "japanese", + "cyrillic", "thai", "vietnamese", "latin_ext_a", "latin_ext_b"]` + +`graphs` validation: +- Valid values: `["gpu_load", "cpu_load", "gpu_core_clock", "gpu_mem_clock", + "vram", "ram", "cpu_temp", "gpu_temp"]` + +`time_format` validation: +- Must be a valid strftime format string +- Validate by attempting to format a known date with the string using the `time` crate + (add `time = "0.3"` to dev-dependencies if not already present, or use chrono) +- If format produces empty string or contains '?': ValidationResult::Warning + +--- + +# Module: system/detect.rs + +**Purpose:** One-shot async system probe run at startup. Results are immutable after detection. + +## MangoHud version parsing + +`mangohud --version` outputs something like: +- `MangoHud 0.7.2` +- `v0.7.1-3-gabcdef` + +Parse with: `^(?:MangoHud\s+)?v?(\d+\.\d+[\.\d]*)` to extract version string. + +## GPU vendor detection (primary method: /sys/class/drm) + +``` +/sys/class/drm/card0/device/vendor → e.g. "0x1002\n" +0x1002 → AMD +0x10de → NVIDIA +0x8086 → Intel +``` + +If multiple GPUs found, use the first discrete GPU (non-Intel if Intel also present). +Store all detected GPUs in a `Vec` so the UI can show a GPU selector. + +## Fallback GPU detection (if /sys fails) + +Parse `lspci -nn 2>/dev/null` output — look for lines containing: +- "VGA compatible controller" +- "3D controller" +- "Display controller" + +Extract vendor from PCI ID in brackets: `[10de:xxxx]` → NVIDIA, `[1002:xxxx]` → AMD. + +## SystemInfo::unknown() constructor + +Returns a SystemInfo with all fields set to "not detected" / false. +Used when detect_system() fails (should not happen in normal operation). + +--- + +# Module: launcher/runner.rs + +**Purpose:** Manage child processes for test applications. + +## Environment setup + +Always set these environment variables for launched processes: +``` +MANGOHUD=1 +MANGOHUD_CONFIGFILE={absolute_path_to_config} +``` + +Also preserve the user's existing environment (don't replace it — add to it). +Use `std::process::Command::env()` not `env_clear()`. + +## Terminal detection + +For `show_terminal=true`, detect the user's terminal in this order: +1. `$MANGOTUNE_TERMINAL` env var (user override) +2. `$TERM_PROGRAM` +3. Try `which` for: `gnome-terminal`, `kgx` (GNOME Console), `konsole`, + `xfce4-terminal`, `mate-terminal`, `lxterminal`, `xterm` +4. If none found: launch without terminal, show toast warning + +Terminal command construction: +- gnome-terminal: `gnome-terminal -- {command}` +- kgx: `kgx -e {command}` +- konsole: `konsole -e {command}` +- xterm: `xterm -e {command}` + +## Process monitoring + +After launch, spawn a tokio task that: +1. Waits for process exit via `child.wait()` +2. On exit: sends a message via channel back to UI thread +3. UI removes the "running process" row + +## SIGUSR1 for config reload + +MangoHud reloads config on SIGUSR1. +```rust +use nix::sys::signal::{kill, Signal}; +use nix::unistd::Pid; + +pub async fn reload_config(pid: u32) -> anyhow::Result<()> { + kill(Pid::from_raw(pid as i32), Signal::SIGUSR1)?; + Ok(()) +} +``` +Add `nix = { version = "0.29", features = ["signal"] }` to Cargo.toml. + +--- + +# Module: ui/widgets/cascade_view.rs + +**Purpose:** Visual CSS-cascade-style display of config layers and conflicts. + +## Data model + +```rust +pub struct CascadeViewModel { + pub layers: Vec, + pub filter: CascadeFilter, +} + +pub struct LayerViewModel { + pub source: LayerSource, + pub priority: u8, + pub label: String, + pub is_editable: bool, + pub options: Vec, +} + +pub struct OptionViewModel { + pub key: String, + pub value: String, + pub state: OptionState, + pub overridden_by: Option, // layer label that wins +} + +pub enum OptionState { + Effective, // this layer's value is used at runtime + Shadowed, // overridden by a higher-priority layer + Winning, // this layer provides the winning value (overrides lower) +} + +pub enum CascadeFilter { + All, + ConflictsOnly, + ShadowedOnly, +} +``` + +## Widget construction + +```rust +pub fn build_cascade_view(model: CascadeViewModel) -> gtk4::Widget +``` + +Returns a scrollable `GtkScrolledWindow` containing a `GtkBox` (vertical) of +`AdwPreferencesGroup` widgets, one per layer in `model.layers`. + +The widget must be efficiently rebuildable when the filter changes. +Connect filter button signals to rebuild/filter the view in-place. diff --git a/docs/plan/architecture.md b/docs/plan/architecture.md new file mode 100644 index 0000000..6489734 --- /dev/null +++ b/docs/plan/architecture.md @@ -0,0 +1,184 @@ +# Architecture — MangoTune + +## Rust Edition & MSRV + +- Rust edition: **2021** +- Minimum Supported Rust Version: **1.75.0** +- Build target: `x86_64-unknown-linux-gnu` (primary), `aarch64-unknown-linux-gnu` (secondary) + +## Cargo.toml Dependencies + +```toml +[package] +name = "mangotune" +version = "0.1.0" +edition = "2021" +authors = ["MangoTune Contributors"] +description = "A modern MangoHud configurator for Linux" +license = "GPL-3.0" +repository = "https://github.com/your-org/mangotune" + +[[bin]] +name = "mangotune" +path = "src/main.rs" + +[dependencies] +# GUI +gtk4 = { version = "0.9", features = ["v4_12"] } +libadwaita = { version = "0.7", features = ["v1_5"] } +glib = "0.20" +gio = "0.20" + +# Async runtime (for subprocess management, file watching) +tokio = { version = "1", features = ["rt-multi-thread", "process", "fs", "sync", "time"] } + +# Serialization (for GSettings schema, internal state) +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Config file parsing +indexmap = "2" # preserve insertion order in parsed configs + +# Error handling +anyhow = "1" +thiserror = "1" + +# Filesystem watching (live reload when config changes externally) +notify = "6" + +# XDG base directory resolution +xdg = "2" + +# Regex (for config line parsing) +regex = "1" +once_cell = "1" + +# Process detection (checking if gamemode is running, etc.) +sysinfo = "0.31" + +# Logging/tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[build-dependencies] +# For compiling GSettings schema and other build-time assets +glib-build-tools = "0.20" + +[dev-dependencies] +tempfile = "3" +assert_fs = "1" +``` + +## System Dependencies (must be present at build time) + +| Package | Ubuntu/Debian | Fedora/RHEL | Arch | +|--------------------------|---------------------------|---------------------------------|----------------| +| GTK4 dev headers | `libgtk-4-dev` | `gtk4-devel` | `gtk4` | +| libadwaita dev headers | `libadwaita-1-dev` | `libadwaita-devel` | `libadwaita` | +| GLib dev headers | `libglib2.0-dev` | `glib2-devel` | `glib2` | +| pkg-config | `pkg-config` | `pkgconf` | `pkgconf` | + +Runtime (optional, detected at launch): +- `mangohud` — the actual overlay +- `vkcube` — from `vulkan-tools` package +- `glxgears` — from `mesa-utils` package +- `gamemoded` — from `gamemode` package +- `gamemodectl` — from `gamemode` package + +## Module Dependency Graph + +``` +main.rs + └── app.rs (GtkApplication) + └── window.rs (AdwApplicationWindow) + ├── ui/pages/*.rs ← all pages + │ ├── config/resolver.rs ← discovers config stack + │ ├── config/validator.rs ← validates on every change + │ ├── config/parser.rs ← reads/writes files + │ └── config/schema.rs ← option definitions + ├── ui/widgets/*.rs ← reusable widgets + ├── system/detect.rs ← run at startup + ├── system/paths.rs ← XDG resolution + ├── launcher/runner.rs ← test process management + └── integrations/*.rs ← GameMode/Steam/Lutris/Heroic +``` + +## Data Flow + +``` +Startup: + system::detect::run() + → SystemInfo { mangohud_version, gpu_vendor, display_server, available_tools } + + config::resolver::discover() + → Vec { path, source_type, priority, exists } + + For each ConfigLayer: + config::parser::read(path) + → RawConfig { lines: Vec } + + config::schema::annotate(raw) + → AnnotatedConfig { options: IndexMap } + +User edits a field: + ui::widgets::* emits change signal + → config::validator::check(key, value, &schema) + → ValidationResult::Ok | ValidationResult::Error(msg) | ValidationResult::Warning(msg) + + If Ok: update in-memory AnnotatedConfig + check for dependency side-effects + update cascade_view to show which layer owns the value + enable Save button if no errors anywhere + +Save: + config::validator::check_all(&config) ← full pass before any write + → if any Error: abort, show error summary toast + → if all Ok: config::parser::write(path, config) + preserving all comment lines unchanged +``` + +## Threading Model + +- **Main thread**: GTK4 event loop only. No blocking calls. +- **Tokio thread pool**: file I/O, subprocess spawning, filesystem watcher. +- Communication: `glib::MainContext::channel()` for sending results back to GTK main thread. +- Never call GTK functions from tokio threads. + +## GSettings Schema + +Used for persisting app preferences (window size, last-opened config path, theme preference). +NOT used for MangoHud config itself — that is always written directly to .conf files. + +Schema ID: `com.mangotune.MangoTune` +Keys: +- `last-config-path` (string) +- `window-width` (int, default 1200) +- `window-height` (int, default 780) +- `active-page` (string, default "performance") +- `show-raw-editor` (bool, default false) + +## Error Handling Strategy + +- All I/O operations return `anyhow::Result`. +- UI layer converts errors to `AdwToast` notifications (non-blocking). +- Critical startup errors (GTK init failure) use `eprintln!` + `process::exit(1)`. +- Validation errors are `thiserror` enums, displayed inline, never panicked on. +- Never use `.unwrap()` or `.expect()` in production paths. Use `?` or match. + +## Config File Format Notes + +MangoHud .conf files follow these rules (the parser must handle all of them): +1. Lines starting with `#` are comments — preserve verbatim. +2. Empty lines — preserve verbatim. +3. `key=value` — option with value. +4. `key` alone (no `=`) — boolean flag, presence = enabled. +5. `# key` — commented-out option (disabled). +6. `# key=value` — commented-out option with default value shown. +7. Inline comments after values are NOT standard and should be treated as part of value. +8. Duplicate keys: last occurrence wins (MangoHud behavior). +9. Encoding: UTF-8. + +The parser must distinguish between: +- An option that is absent from the file (use MangoHud's compiled default) +- An option explicitly set to 0 or empty (user explicitly disabled) +- An option present as a bare key (user enabled a flag) diff --git a/docs/plan/config_resolution.md b/docs/plan/config_resolution.md new file mode 100644 index 0000000..1fa9e2e --- /dev/null +++ b/docs/plan/config_resolution.md @@ -0,0 +1,149 @@ +# Config Resolution & Priority System + +## MangoHud's Config Priority Order (highest to lowest) + +When MangoHud loads, it resolves configuration from multiple sources. Later sources +override earlier ones. **Highest priority wins for any given option.** + +``` +Priority 5 (HIGHEST) — Environment variable override + $MANGOHUD_CONFIG="key=value,key2=value2" + Also: $MANGOHUD_CONFIGFILE="/path/to/custom.conf" + +Priority 4 — App-local config (same directory as the game executable) + {game_directory}/MangoHud.conf + +Priority 3 — Per-app XDG config (named after the process) + $XDG_CONFIG_HOME/MangoHud/{appname}.conf + (default: ~/.config/MangoHud/{appname}.conf) + +Priority 2 — Global XDG user config + $XDG_CONFIG_HOME/MangoHud/MangoHud.conf + (default: ~/.config/MangoHud/MangoHud.conf) + +Priority 1 (LOWEST) — MangoHud compiled defaults + (no file, built into the library) +``` + +## Discovery Algorithm for `config::resolver` + +``` +fn discover() -> Vec: + +1. Check environment: + a. Read $MANGOHUD_CONFIGFILE — if set and path exists, record as Priority 5b + b. Read $MANGOHUD_CONFIG — if set, parse inline key=value pairs as Priority 5a + Note: 5a overrides 5b which overrides all file-based configs + +2. Determine XDG config home: + a. Use $XDG_CONFIG_HOME if set and non-empty + b. Otherwise use $HOME/.config + c. If neither available: warn and skip file-based discovery + +3. Enumerate known config files in priority order: + a. {XDG_CONFIG_HOME}/MangoHud/MangoHud.conf (global) + b. {XDG_CONFIG_HOME}/MangoHud/*.conf (all per-app configs found) + c. Scan common game directories for MangoHud.conf: + - $HOME/.steam/steam/steamapps/common/*/ + - $HOME/.local/share/Steam/steamapps/common/*/ + - $HOME/Games/*/ + - $HOME/.var/app/com.valvesoftware.Steam/data/Steam/steamapps/common/*/ + (Flatpak Steam) + +4. For each discovered config file: + - Record: path, source_type, priority_rank, file_exists, last_modified + - Parse if exists + +5. Build conflict map: + For each option key found in more than one layer: + - Record which layer provides the winning value + - Record which layers are shadowed + - Mark as "conflict" in the UI +``` + +## ConfigLayer Struct + +```rust +pub struct ConfigLayer { + pub path: Option, // None for env-var inline configs + pub source_type: LayerSource, + pub priority: u8, // 1=lowest (compiled default) to 5=highest (env) + pub exists: bool, + pub is_editable: bool, // false for env-var layers + pub last_modified: Option, + pub config: Option, +} + +pub enum LayerSource { + CompiledDefault, + GlobalXdg, // ~/.config/MangoHud/MangoHud.conf + PerAppXdg(String), // ~/.config/MangoHud/{appname}.conf — stores appname + AppLocal(PathBuf), // {game_dir}/MangoHud.conf + EnvFile(PathBuf), // $MANGOHUD_CONFIGFILE + EnvInline(String), // $MANGOHUD_CONFIG inline value +} +``` + +## Conflict Detection Rules + +A **conflict** exists when: +- An option is explicitly set in two or more layers with different values. +- OR an env var (`$MANGOHUD_CONFIG` or `$MANGOHUD_CONFIGFILE`) is set AND any file-based config also sets the same option — the env always wins but the user may not realize it. + +A **shadow** occurs when: +- A lower-priority layer sets an option that a higher-priority layer also sets. + The lower-priority setting is "shadowed" (has no effect at runtime). + +## UI: Visual Cascade (CSS Specificity Style) + +The Conflicts page (`ui/pages/conflicts.rs`) renders a vertical stack of layers, +highest priority at top. For each layer: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🔴 ENV: $MANGOHUD_CONFIG [not editable]│ +│ gpu_stats=0 fps_limit=120 text_color=FF0000 │ +├─────────────────────────────────────────────────────────┤ +│ 🟡 Per-App: ~/.config/MangoHud/cs2.conf [Edit] │ +│ fps_limit=60 ← SHADOWED by ENV above │ +│ gpu_temp=1 cpu_temp=1 ram=1 │ +├─────────────────────────────────────────────────────────┤ +│ 🟢 Global: ~/.config/MangoHud/MangoHud.conf [Edit] │ +│ fps_limit=0 ← SHADOWED by cs2.conf and ENV │ +│ font_size=24 position=top-left background_alpha=0.5 │ +└─────────────────────────────────────────────────────────┘ +``` + +Color coding: +- 🔴 Red badge = env var override (cannot edit in app, show value only) +- 🟡 Yellow badge = per-app or app-local config +- 🟢 Green badge = global config +- Grey strikethrough text = shadowed (ineffective) option + +Clicking an option in any layer: +- If editable layer: jumps to that option in the config editor with that layer selected +- If env-var layer: shows tooltip explaining how to unset the env var + +## Creating New Config Files + +When user clicks "+ New Config": +1. Ask: Global, Per-App (enter app name), or App-Local (browse for directory)? +2. If Per-App: validate app name (alphanumeric + hyphens/underscores only) +3. Create file with header comment block: +``` +### MangoHud configuration - managed by MangoTune +### Created: {date} +### App: {appname or "global"} +``` +4. Add to resolver's layer stack immediately. +5. Set as the active editing target. + +## Config File Write Safety + +Before writing any config to disk: +1. Run full validation pass — abort if any errors. +2. Create a backup: `{original_path}.mangotune.bak` (overwrite if exists). +3. Write to `{original_path}.mangotune.tmp`. +4. On success: atomically rename tmp → original. +5. On failure: restore from backup, show error toast. +6. Never write a partial/corrupt file. diff --git a/docs/plan/design_system.md b/docs/plan/design_system.md new file mode 100644 index 0000000..a458e5b --- /dev/null +++ b/docs/plan/design_system.md @@ -0,0 +1,341 @@ +# Design System — MangoTune GTK4 / libadwaita + +## Guiding Principles + +1. **Libadwaita first** — use `Adw::*` widgets wherever they exist before falling back to GTK4. + This ensures correct dark/light mode, accent color, and GNOME HIG compliance automatically. +2. **Every field validates on change** — instant inline feedback, never wait for save. +3. **Save button state is truth** — it is only sensitive when all fields are valid AND there + are unsaved changes. It is insensitive otherwise. Never disable fields; always show why. +4. **Contextual help is inline** — use `subtitle` on `AdwActionRow` for brief descriptions. + Longer explanations go in an `AdwTooltip`. No separate help dialogs. +5. **Destructive actions require confirmation** — deleting a config file uses `AdwAlertDialog`. + +--- + +## Main Window Structure + +``` +AdwApplicationWindow "MangoTune" + AdwToolbarView + ┌── [top] AdwHeaderBar + │ Title: "MangoTune" + │ Start: AdwSplitButton "Save" (primary action) + │ End: menu button (gear icon → preferences, about) + │ + ├── [top] ConfigBarWidget (custom, below header) + │ Shows: current file being edited + │ Dropdown: select from all discovered config layers + │ Conflict indicator: if any conflicts detected + │ + └── [content] AdwOverlaySplitView + Sidebar: NavigationSidebar (AdwNavigationSidebar or custom ListBox) + Content: AdwNavigationView (manages page stack) +``` + +## Header Bar + +- Title: "MangoTune" +- Subtitle: name of current config file being edited (short path) +- Primary button: `AdwSplitButton` labeled "Save" + - Main click: save current file + - Dropdown arrow: "Save As…", "Revert to Saved", "Create Backup" +- End: `Gtk::MenuButton` with gear icon + - Menu items: Preferences, Keyboard Shortcuts, About MangoTune + +## Config File Selector Bar + +Custom widget rendered between HeaderBar and the sidebar/content split. +Appearance: an `AdwBanner` variant or custom `GtkBox` with background `@card_bg_color`. + +Contents (left to right): +- Icon indicating layer type (globe for global, app icon for per-app, warning for env) +- Dropdown (`GtkDropDown`) showing all discovered layers with their priority +- Conflict badge: `GtkLabel` with `.error` or `.warning` CSS class if conflicts exist +- Right side: "View All Layers" button → navigates to Conflicts page + +Layer display format in dropdown: +``` +[●] ~/.config/MangoHud/MangoHud.conf (global) +[◉] ~/.config/MangoHud/cs2.conf (per-app: cs2) ← currently editing +[⚠] $MANGOHUD_CONFIG (env override — read only) +``` + +--- + +## Sidebar Navigation + +Use `AdwNavigationSidebar` if available in libadwaita 1.4+, otherwise `GtkListBox` with +`.navigation-sidebar` CSS class. + +Sections (use `GtkSeparator` between groups): + +**Config** +- Overview (house icon) +- Layer Conflicts (warning icon, badge with conflict count if > 0) + +**Display** +- Performance (speedometer icon) +- GPU (chip icon) +- CPU (cpu icon) +- Memory (memory icon) +- I/O & Network (network icon) +- Media Player (music note icon) +- Battery (battery icon) + +**Appearance** +- Layout & Position (layout icon) +- Colors & Theme (palette icon) +- Typography (text icon) + +**Behavior** +- Keybindings (keyboard icon) +- FPS Limits (gauge icon) +- Logging (file icon) +- Blacklist (block icon) + +**Advanced** +- OpenGL Quirks (warning icon) +- Raw Editor (code icon) + +**Tools** +- Test Launcher (play icon) +- Integrations (plugin icon) + +--- + +## Page Layout Pattern + +Every config page follows this structure: + +``` +AdwPreferencesPage + title: "GPU Metrics" + icon-name: "processor-symbolic" (or custom) + + AdwPreferencesGroup + title: "GPU Statistics" + description: "Core GPU monitoring options" + + AdwSwitchRow ← for Flag/Bool options + AdwSpinRow ← for Int options + AdwEntryRow ← for String/Path options + AdwComboRow ← for Enum options + AdwExpanderRow ← for groups with sub-options (e.g. load color thresholds) + └── nested rows inside + + AdwPreferencesGroup + title: "Advanced" + ... +``` + +--- + +## Widget Patterns Per Option Type + +### Flag / Bool → `AdwSwitchRow` +``` +AdwSwitchRow { + title: "GPU Temperature", + subtitle: "Show GPU core temperature (gpu_temp)", + active: , +} +``` +On toggle: validate, update model, check dependencies. + +### Int with range → `AdwSpinRow` +``` +AdwSpinRow { + title: "Font Size", + subtitle: "font_size — valid range: 8–72", + value: 24.0, + adjustment: Gtk::Adjustment { lower: 8, upper: 72, step-increment: 1 }, +} +``` + +### Float with range → `AdwSpinRow` (digits: 2 or 3) + +### Enum → `AdwComboRow` +``` +AdwComboRow { + title: "HUD Position", + subtitle: "position", + model: StringList ["top-left", "top-right", "bottom-left", ...], +} +``` + +### String (free text) → `AdwEntryRow` +``` +AdwEntryRow { + title: "Custom GPU Label", + text: "", + // validation on ::changed signal +} +``` +Validation error: add `.error` CSS class to the row, set subtitle to error message. + +### Path → `AdwEntryRow` + browse button +``` +AdwActionRow { + title: "Font File", + AdwEntryRow + GtkButton "Browse…" +} +``` +Browse opens `GtkFileDialog` filtered to `.ttf,.otf`. +Validate path exists after selection. + +### Color → `AdwActionRow` with color swatch button +``` +AdwActionRow { + title: "GPU Color", + subtitle: "gpu_color — hex RRGGBB", + [suffix] GtkButton (color swatch, shows current color) + → opens AdwDialog with color picker + → also shows a GtkEntry for manual hex input +} +``` + +### Hotkey / Keybind → Custom `KeybindRow` widget +``` +AdwActionRow { + title: "Toggle HUD", + subtitle: "toggle_hud", + [suffix] GtkShortcutLabel (shows current binding) + [suffix] GtkButton "Edit" → opens capture dialog +} +``` +Capture dialog: fullscreen-ish `AdwDialog`, listens for keypress, shows "Press a key combination…", +captures and validates the combination, shows preview, OK/Cancel. + +### CommaSeparatedStrings (controlled set) → `AdwExpanderRow` with checkboxes +Example: `graphs`, `font_glyph_ranges`, `device_battery` +``` +AdwExpanderRow { + title: "Graphs", + subtitle: "Select which graphs to display", + [child per valid value] AdwSwitchRow or CheckButton row +} +``` + +### CommaSeparatedStrings (free) → `AdwEntryRow` with validation +Example: `blacklist`, `network` + +### FpsLimitList → Custom widget +A `GtkFlowBox` of chips showing current FPS values (0, 30, 60, etc.) +with + button to add and × to remove each. Each value validated as non-negative int. + +--- + +## Inline Validation Display + +When a field has an error: +1. The `AdwActionRow` or `AdwEntryRow` gets `.error` CSS class applied. +2. The row's subtitle changes to the error message (red text via `.error` on a child label). +3. A validation summary appears at top of page: `AdwBanner` with "N fields have errors — fix to enable saving". +4. The Save button in the header becomes insensitive. + +When a dependency warning fires (e.g. user enables `gpu_mem_clock` without `vram`): +1. Show `AdwAlertDialog`: "Enabling 'GPU Memory Clock' also requires 'VRAM display' to be enabled. Enable it now?" +2. Buttons: "Enable Both" (suggested-action), "Cancel". + +--- + +## Conflict/Layer Cascade Page + +This is the most distinctive page in the app. + +Layout: vertical stack of `AdwPreferencesGroup` cards, one per discovered layer, +ordered top-to-bottom = highest-to-lowest priority. + +Each layer card header shows: +- Priority badge (e.g. "ENV", "PER-APP", "GLOBAL") with color coding +- File path or env var name +- "Edit" button (disabled for env layers) +- "Open in Files" button (for file layers) + +Inside each layer card: a `GtkListBox` showing every option set in that layer. +Options that are shadowed by a higher-priority layer: +- Shown with strikethrough text +- A label "overridden by {LAYER}" in muted color + +Options that are unique to this layer (no conflict): normal display. +Options that this layer wins on (it overrides lower layers): bold text. + +Filter bar at top of page: +- "Show all options" / "Show conflicts only" / "Show shadowed only" toggle buttons + +--- + +## Test Launcher Panel + +Shown as a persistent bottom bar (collapsed by default) OR as a dedicated page. +Decision: dedicated page (cleaner, avoids layout complications). + +Layout: +``` +AdwPreferencesPage "Test Launcher" + AdwPreferencesGroup "Quick Test" + description: "Launch a test application with MangoHud active to preview your config" + + AdwActionRow "vkcube (Vulkan)" + subtitle: "vulkan-tools — tests Vulkan overlay" + [suffix] status: "installed" / "not found" + [suffix] GtkButton "Launch" + + AdwActionRow "glxgears (OpenGL)" + subtitle: "mesa-utils — tests OpenGL overlay" + [suffix] status indicator + [suffix] GtkButton "Launch" + + AdwActionRow "Custom Application" + subtitle: "Launch any app with MangoHud injected" + [suffix] GtkEntry (command) + [suffix] GtkButton "Launch" + + AdwPreferencesGroup "Launch Options" + AdwSwitchRow "Auto-reload config on save" + subtitle: "Sends SIGUSR1 to running MangoHud processes on save" + AdwSwitchRow "Show terminal output" + subtitle: "Opens a terminal window showing app stdout/stderr" + + AdwPreferencesGroup "Running Process" + (only visible when a test process is active) + AdwActionRow showing process name + PID + [suffix] GtkButton "Stop" +``` + +When Launch is clicked: +1. Check tool is installed (which vkcube, which glxgears). +2. If not found: `AdwToast` "vkcube not found. Install vulkan-tools package." +3. If found: spawn process with `MANGOHUD=1 MANGOHUD_CONFIGFILE={current_path} {command}`. +4. Show running process row. +5. Monitor process — remove row when it exits. + +--- + +## Theming + +- Follow system theme (light/dark) automatically via libadwaita. +- Do NOT hardcode colors. Use only named GTK/Adwaita CSS variables: + `@accent_color`, `@destructive_color`, `@warning_color`, `@success_color`, + `@card_bg_color`, `@window_bg_color`, `@headerbar_bg_color`, etc. +- MangoTune-specific CSS: only for the cascade view layer badges and color swatch button. + Place in `data/style.css`, loaded at runtime via `GtkCssProvider`. + +--- + +## Accessibility + +- All interactive widgets must have accessible labels. +- Color information must never be the sole indicator of state (always pair with icon or text). +- Keyboard navigation must work for all pages (GTK4 handles most of this by default). +- Use `gtk_accessible_update_property` where needed for dynamic content. + +--- + +## Window Size & Responsiveness + +- Default: 1200 × 780 +- Minimum: 900 × 600 +- The `AdwOverlaySplitView` collapses the sidebar at narrow widths (< 980px) automatically. +- Persist window size via GSettings `window-width` / `window-height`. diff --git a/docs/plan/integrations.md b/docs/plan/integrations.md new file mode 100644 index 0000000..5eabc0e --- /dev/null +++ b/docs/plan/integrations.md @@ -0,0 +1,274 @@ +# Integrations Spec + +## Overview + +MangoTune implements four integrations accessible from the Integrations page. +Each integration is independent — a missing tool shows a "not available" state +for that section only, without affecting the rest of the app. + +--- + +## 1. GameMode Integration + +**What is GameMode?** A daemon by Feral Interactive that applies CPU governor, +scheduler, and I/O priority optimizations when games run. MangoHud can display +whether GameMode is currently active via the `gamemode=1` config option. + +### Detection +```rust +// src/integrations/gamemode.rs + +pub struct GameModeStatus { + pub daemon_installed: bool, // gamemoded binary found + pub ctl_installed: bool, // gamemodectl binary found + pub daemon_running: bool, // gamemoded process in process list + pub current_clients: u32, // number of active gamemoded clients +} + +fn detect() -> GameModeStatus +``` +Detection steps: +1. `which gamemoded` — sets `daemon_installed` +2. `which gamemodectl` — sets `ctl_installed` +3. Check process list via `sysinfo` for `gamemoded` — sets `daemon_running` +4. If ctl installed: run `gamemodectl status` and parse client count + +### UI (on Integrations page) +``` +AdwPreferencesGroup "GameMode" + description: "Feral Interactive GameMode performance optimization daemon" + + AdwActionRow "Status" + subtitle: "gamemoded process" + [suffix] label: "Running (3 clients)" / "Stopped" / "Not installed" + + AdwSwitchRow "Show GameMode status in overlay" + subtitle: "Sets gamemode=1 in current config" + (bound to the gamemode config option) + + AdwActionRow "Enable GameMode for all Steam games" + subtitle: "Adds %command% to default Steam launch options helper" + [suffix] GtkButton "Configure" +``` + +### No direct daemon control +MangoTune does NOT start/stop gamemoded. It only shows status and helps the user +configure the launch options. Provide a tooltip: "Start/stop GameMode via your +system service manager (systemctl --user start gamemoded)." + +--- + +## 2. Steam Launch Option Helper + +**Purpose:** Generate the correct launch option string for Steam games so that +MangoHud (and optionally GameMode) is injected automatically. + +### Detection +```rust +pub struct SteamStatus { + pub installed: bool, + pub flatpak: bool, // true if running as Flatpak + pub running: bool, + pub steam_root: Option, + pub localconfig_path: Option, +} +``` +Detection steps: +1. Check `which steam` and `flatpak list | grep com.valvesoftware.Steam` +2. Find Steam root: + - Native: `~/.steam/steam/` or `~/.local/share/Steam/` + - Flatpak: `~/.var/app/com.valvesoftware.Steam/.steam/steam/` +3. Find `userdata/{userId}/config/localconfig.vdf` + +### Launch Option Generator UI +``` +AdwPreferencesGroup "Steam Launch Options" + + AdwComboRow "Inject method" + options: [ + "mangohud {command}", ← standard + "MANGOHUD=1 %command%", ← env var method + "MANGOHUD_CONFIGFILE=~/.config/... %command%", ← explicit config + "gamemoderun mangohud %command%", ← with GameMode + "gamemoderun mangemoderun mangohud %command%", ← with GameMode (flatpak) + ] + + AdwEntryRow (read-only) + title: "Generated launch option" + [Shows generated string based on above selection] + [suffix] GtkButton "Copy to clipboard" + + AdwActionRow "Instructions" + subtitle: "In Steam: right-click game → Properties → Launch Options → paste above" +``` + +### Important note for Flatpak Steam +If Flatpak Steam is detected, the generated command uses the correct Flatpak-aware +prefix and warns the user that MangoHud must also be installed inside the Flatpak +sandbox or as a Flatpak extension. + +### DO NOT write to localconfig.vdf +MangoTune does NOT modify Steam's localconfig.vdf directly — too fragile and risky. +The user copies the generated string manually. This is deliberate and safe. + +--- + +## 3. Lutris Integration + +**Purpose:** Help users configure MangoHud for games managed by Lutris. + +### Detection +```rust +pub struct LutrisStatus { + pub installed: bool, + pub flatpak: bool, + pub config_dir: Option, // ~/.config/lutris/ + pub games: Vec, +} + +pub struct LutrisGame { + pub name: String, + pub slug: String, + pub config_path: PathBuf, // ~/.config/lutris/games/{slug}.yml + pub runner: String, +} +``` +Detection steps: +1. `which lutris` or `flatpak list | grep net.lutris.Lutris` +2. Enumerate `~/.config/lutris/games/*.yml` — parse YAML for name, slug, runner fields. + Use a simple line-by-line parser (avoid heavy YAML dep — these files are simple). + +### UI +``` +AdwPreferencesGroup "Lutris" + + AdwActionRow "Status" + [suffix] "Installed" / "Not found" + + (if installed): + AdwComboRow "Game" + [lists all detected Lutris games] + + AdwSwitchRow "Enable MangoHud for selected game" + subtitle: "Adds mangohud to the game's Lutris runner configuration" + + AdwActionRow "Open game config in Lutris" + [suffix] GtkButton "Open Lutris" + + AdwPreferencesGroup (informational) + AdwActionRow + subtitle: "MangoTune can enable MangoHud in Lutris game configs. + Per-game MangoHud config files will be placed at: + ~/.config/MangoHud/{game-slug}.conf" + [suffix] GtkButton "Create per-game config" +``` + +### Config modification approach for Lutris +When "Enable MangoHud for selected game" is toggled ON: +1. Read `~/.config/lutris/games/{slug}.yml` +2. Find or create the `system:` section +3. Set `mangohud: true` under the `system:` key +4. Write back (preserve all other content, modify only the mangohud line) +5. Show toast: "MangoHud enabled for {game name}. Restart Lutris if it's open." + +When toggled OFF: set `mangohud: false`. + +--- + +## 4. Heroic Games Launcher Integration + +**Purpose:** Help users configure MangoHud for games managed by Heroic (Epic Games, +GOG, and Amazon Prime on Linux). + +### Detection +```rust +pub struct HeroicStatus { + pub installed: bool, + pub flatpak: bool, + pub config_dir: Option, + pub games: Vec, +} + +pub struct HeroicGame { + pub title: String, + pub app_name: String, // Heroic's internal ID + pub store: HeroicStore, // Epic, GOG, Amazon + pub config_path: PathBuf, // ~/.config/heroic/GamesConfig/{app_name}.json +} + +pub enum HeroicStore { Epic, Gog, Amazon } +``` +Detection steps: +1. `which heroic` or `flatpak list | grep com.heroicgameslauncher.hgl` +2. Config dirs to check: + - Native: `~/.config/heroic/` + - Flatpak: `~/.var/app/com.heroicgameslauncher.hgl/config/heroic/` +3. Enumerate `GamesConfig/*.json` — each file = one game config. +4. Parse JSON: extract `title`, `appName`, store type from file content. + +### Heroic Game Config JSON structure (relevant fields) +```json +{ + "appName": "AppId", + "title": "Game Title", + "enviromentOptions": [ + { "key": "MANGOHUD", "value": "1" } + ], + "wrapperOptions": [ + { "exe": "mangohud", "args": "" } + ] +} +``` + +### UI +``` +AdwPreferencesGroup "Heroic Games Launcher" + + AdwActionRow "Status" + [suffix] "Installed (Flatpak)" / "Not found" + + AdwComboRow "Game" + [lists games grouped by store if > 5 games] + + AdwSwitchRow "Enable MangoHud via wrapper" + subtitle: "Adds mangohud as a wrapper in Heroic game settings" + + AdwSwitchRow "Enable MangoHud via environment" + subtitle: "Sets MANGOHUD=1 in game environment variables" + + AdwActionRow "Per-game config" + subtitle: "~/.config/MangoHud/{app_name}.conf" + [suffix] GtkButton "Create / Edit" +``` + +### Config modification approach for Heroic +When "Enable MangoHud via wrapper" is toggled ON: +1. Read `~/.config/heroic/GamesConfig/{app_name}.json` (handle Flatpak path too) +2. Parse JSON using `serde_json` +3. Add `{ "exe": "mangohud", "args": "" }` to `wrapperOptions` if not present +4. Write back with pretty-printing +5. Show toast: "MangoHud wrapper enabled. Restart Heroic if it's open." + +When toggled OFF: remove the mangohud entry from `wrapperOptions`. + +Environment method similarly adds/removes `{ "key": "MANGOHUD", "value": "1" }`. + +--- + +## Integration Page Layout + +``` +AdwPreferencesPage "Integrations" + icon-name: "insert-object-symbolic" + + [One AdwPreferencesGroup per integration, as described above] + + AdwPreferencesGroup "Global MangoHud Enable" + description: "Enable MangoHud system-wide for all applications" + AdwExpanderRow "Auto-enable method" + AdwSwitchRow "Via ~/.config/environment.d/mangohud.conf (recommended, user-scoped)" + AdwSwitchRow "Via ~/.bashrc (shell sessions only)" + AdwSwitchRow "Via /etc/environment (system-wide, requires sudo)" + [Shows currently active method with green checkmark] + [Warning: "System-wide enable may break some applications. Per-game is preferred."] +``` diff --git a/docs/plan/mangohud_schema.md b/docs/plan/mangohud_schema.md new file mode 100644 index 0000000..3fc21f9 --- /dev/null +++ b/docs/plan/mangohud_schema.md @@ -0,0 +1,439 @@ +# MangoHud Schema Reference + +Superseded as the user-facing/help-source reference by +[`docs/MANGOHUD_OPTION_BEHAVIOR.md`](/home/aaron/Programming/mangotune/docs/MANGOHUD_OPTION_BEHAVIOR.md). +Keep this file only as schema-planning context for `src/config/schema.rs`. + +Source: Official MangoHud repository `data/MangoHud.conf` cross-referenced with +the MangoHud README and source code. Last verified against MangoHud 0.7.x. + +This file is no longer the authoritative help text source and does not currently +cover every upstream MangoHud option. + +## Schema Entry Structure + +Each option in `schema.rs` must encode: +```rust +pub struct SchemaEntry { + pub key: &'static str, + pub option_type: OptionType, + pub default: DefaultValue, + pub description: &'static str, + pub category: Category, + pub dependencies: &'static [&'static str], // options that must be enabled for this to work + pub conflicts_with: &'static [&'static str], // mutually exclusive options + pub gpu_vendor_only: Option, // None = all, Some(Amd) = AMD only + pub gamescope_only: bool, + pub mangoapp_only: bool, +} + +pub enum OptionType { + Flag, // bare key, no value — presence means enabled + Bool, // key=0 or key=1 + Int { min: i64, max: i64 }, + Float { min: f64, max: f64 }, + String { max_len: usize }, + Color, // 6-char hex RRGGBB, no # + Enum { variants: &'static [&'static str] }, + FpsLimitList, // comma-separated ints e.g. "0,30,60" + KeyBind, // e.g. "Shift_R+F12" + CommaSeparatedInts, + CommaSeparatedFloats, + CommaSeparatedStrings, + Path, // filesystem path, validated to exist or be writable +} +``` + +--- + +## Category: PERFORMANCE + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `fps_limit` | FpsLimitList | `0` | 0 = unlimited; comma-separated list e.g. `0,30,60` | +| `fps_limit_method` | Enum[`early`,`late`] | `""` | early = wait before present | +| `vsync` | Int[-1..3] | `-1` | -1=unset; 0=adaptive; 1=off; 2=mailbox; 3=on | +| `gl_vsync` | Int[-2..N] | `-2` | OpenGL only; -2=unset; 0=off; >=1=wait N vblanks | +| `picmip` | Int[-17..16] | `-17` | Mip-map LoD bias; negative=sharper | +| `af` | Int[-1..16] | `-1` | Anisotropic filtering; -1=unset | +| `bicubic` | Flag | absent | Force bicubic filtering | +| `trilinear` | Flag | absent | Force trilinear filtering | +| `retro` | Flag | absent | Disable linear filtering (blocky textures) | + +--- + +## Category: DISPLAY — FPS & FRAMETIME + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `fps` | Flag | present | Show FPS counter — enabled by default | +| `fps_only` | Flag | absent | CONFLICTS WITH: all other display params | +| `fps_sampling_period` | Int[100..60000] | `500` | ms | +| `fps_color_change` | Flag | absent | Enable FPS color thresholds | +| `fps_value` | CommaSeparatedInts | `30,60` | Two thresholds: warn,ok | +| `fps_color` | CommaSeparatedStrings | `B22222,FDFD09,39F900` | Three hex colors: low,mid,high | +| `fps_text` | String | `""` | Custom label for FPS row | +| `fps_metrics` | CommaSeparatedStrings | `""` | e.g. `avg,0.01,1,97` — `AVG` or decimal percentiles | +| `frametime` | Flag | present | Show frametime — enabled by default | +| `frame_count` | Flag | absent | Show frame counter | +| `frame_timing` | Flag | present | Frametime graph — enabled by default | +| `frame_timing_detailed` | Flag | absent | More detailed frametime graph | +| `dynamic_frame_timing` | Flag | absent | Dynamic scale frametime graph | +| `histogram` | Flag | absent | CONFLICTS WITH frame_timing | +| `throttling_status` | Flag | present | GPU throttling indicator | +| `throttling_status_graph` | Flag | absent | Show throttling on frametime graph | +| `show_fps_limit` | Flag | absent | Display current FPS limit value | + +--- + +## Category: DISPLAY — GPU + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `gpu_stats` | Flag | present | Master GPU section toggle | +| `gpu_temp` | Flag | absent | | +| `gpu_junction_temp` | Flag | absent | | +| `gpu_core_clock` | Flag | absent | | +| `gpu_mem_temp` | Flag | absent | DEPENDS ON: `vram` | +| `gpu_mem_clock` | Flag | absent | DEPENDS ON: `vram` | +| `gpu_power` | Flag | absent | | +| `gpu_power_limit` | Flag | absent | | +| `gpu_text` | String[32] | `""` | Custom GPU label | +| `gpu_load_change` | Flag | absent | Color GPU load | +| `gpu_load_value` | CommaSeparatedInts | `60,90` | Two load thresholds | +| `gpu_load_color` | CommaSeparatedStrings | `39F900,FDFD09,B22222` | Three hex colors | +| `gpu_fan` | Flag | absent | RPM on AMD, percent on NVIDIA | +| `gpu_voltage` | Flag | absent | AMD ONLY | +| `gpu_list` | CommaSeparatedInts | `""` | Select GPUs by index e.g. `0,1` | +| `gpu_efficiency` | Flag | absent | | +| `gpu_name` | Flag | absent | Show GPU model name | +| `vulkan_driver` | Flag | absent | Show Vulkan driver string | +| `engine_version` | Flag | absent | | +| `engine_short_names` | Flag | absent | | +| `present_mode` | Flag | absent | | +| `pci_dev` | String | `""` | Format: `domain:bus:slot.function` e.g. `0000:03:00.0` | + +--- + +## Category: DISPLAY — CPU + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `cpu_stats` | Flag | present | Master CPU section toggle | +| `cpu_temp` | Flag | absent | | +| `cpu_power` | Flag | absent | | +| `cpu_text` | String[32] | `""` | Custom CPU label | +| `cpu_mhz` | Flag | absent | Average MHz across cores | +| `cpu_load_change` | Flag | absent | Color CPU load | +| `cpu_load_value` | CommaSeparatedInts | `60,90` | Two thresholds | +| `cpu_load_color` | CommaSeparatedStrings | `39F900,FDFD09,B22222` | Three hex colors | +| `cpu_efficiency` | Flag | absent | | +| `core_load` | Flag | absent | Per-core load bars | +| `core_load_change` | Flag | absent | Color per-core load | +| `core_bars` | Flag | absent | Graphical core bars | +| `core_type` | Flag | absent | Show P/E core type labels | + +--- + +## Category: DISPLAY — MEMORY + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `vram` | Flag | absent | Required by gpu_mem_clock, gpu_mem_temp | +| `ram` | Flag | absent | | +| `swap` | Flag | absent | | +| `procmem` | Flag | absent | Per-process resident memory | +| `procmem_shared` | Flag | absent | DEPENDS ON: `procmem` | +| `procmem_virt` | Flag | absent | DEPENDS ON: `procmem` | +| `proc_vram` | Flag | absent | Per-process VRAM | + +--- + +## Category: DISPLAY — I/O & NETWORK + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `io_read` | Flag | absent | Per-app I/O read | +| `io_write` | Flag | absent | Per-app I/O write | +| `network` | CommaSeparatedStrings | `""` | Network interfaces e.g. `eth0,wlo1`; empty = all | + +--- + +## Category: DISPLAY — MISC INDICATORS + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `wine` | Flag | absent | Wine/Proton version | +| `winesync` | Flag | absent | Wine sync method | +| `exec_name` | Flag | absent | Show executable name | +| `arch` | Flag | absent | MangoHud architecture | +| `gamemode` | Flag | absent | GameMode running status | +| `vkbasalt` | Flag | absent | vkBasalt running status | +| `engine_version` | Flag | absent | | +| `version` | Flag | absent | Show MangoHud version in overlay | +| `resolution` | Flag | absent | Current display resolution | +| `display_server` | Flag | absent | Wayland/X11 indicator | +| `temp_fahrenheit` | Flag | absent | Use °F instead of °C | +| `flip_efficiency` | Flag | absent | Joules per frame | +| `fex_stats` | Flag | absent | FEX-Emu stats (ARM64 only) | + +--- + +## Category: DISPLAY — GRAPHS + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `graphs` | CommaSeparatedStrings | `""` | Valid values: `gpu_load,cpu_load,gpu_core_clock,gpu_mem_clock,vram,ram,cpu_temp,gpu_temp` | + +--- + +## Category: DISPLAY — BATTERY + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `battery` | Flag | absent | | +| `battery_icon` | Flag | absent | DEPENDS ON: `battery` | +| `device_battery` | CommaSeparatedStrings | `""` | e.g. `gamepad,mouse` | +| `device_battery_icon` | Flag | absent | | +| `battery_watt` | Flag | absent | DEPENDS ON: `battery` | +| `battery_time` | Flag | absent | DEPENDS ON: `battery` | + +--- + +## Category: DISPLAY — MEDIA PLAYER + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `media_player` | Flag | absent | Enable media player metadata | +| `media_player_name` | String | `""` | e.g. `spotify` — DEPENDS ON: `media_player` | +| `media_player_format` | String | `""` | e.g. `{title};{artist};{album}` — DEPENDS ON: `media_player` | + +--- + +## Category: DISPLAY — GAMESCOPE + +All options in this category: `gamescope_only = true` + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `fsr` | Flag | absent | FSR status | +| `hide_fsr_sharpness` | Flag | absent | DEPENDS ON: `fsr` | +| `hdr` | Flag | absent | HDR status | +| `refresh_rate` | Flag | absent | Current refresh rate | +| `debug` | Flag | absent | Gamescope app frametimes graph | + +--- + +## Category: DISPLAY — STEAM DECK + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `fan` | Flag | absent | Steam Deck fan RPM | +| `mangoapp_steam` | Flag | absent | mangoapp only | + +--- + +## Category: DISPLAY — TIME & MISC TEXT + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `time` | Flag | absent | Current time | +| `time_no_label` | Flag | absent | DEPENDS ON: `time` | +| `time_format` | String | `"%T"` | strftime format | +| `custom_text_center` | String | `""` | Centered header text | +| `custom_text` | String | `""` | Custom text line | +| `exec` | String | `""` | Shell command — output shown in next column | + +--- + +## Category: APPEARANCE — LAYOUT + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `legacy_layout` | Bool | `0` | | +| `preset` | Int[-1..4] | `-1` | -1=default; 0=off; 1=fps only; 2=horizontal; 3=extended; 4=high detail | +| `full` | Flag | absent | Enable most toggleable params | +| `no_display` | Flag | absent | Start hidden | +| `horizontal` | Flag | absent | Horizontal layout | +| `horizontal_stretch` | Flag | absent | DEPENDS ON: `horizontal` | +| `hud_compact` | Flag | absent | Compact mode | +| `hud_no_margin` | Flag | absent | Remove margins | +| `position` | Enum[`top-left`,`top-right`,`bottom-left`,`bottom-right`,`top-center`,`middle-left`,`middle-right`,`bottom-center`] | `top-left` | | +| `offset_x` | Int[0..9999] | `0` | pixels | +| `offset_y` | Int[0..9999] | `0` | pixels | +| `width` | Int[0..9999] | `0` | 0 = auto | +| `height` | Int[0..9999] | `140` | | +| `table_columns` | Int[1..10] | `3` | | +| `cellpadding_y` | Float[-2.0..2.0] | `-0.085` | | +| `round_corners` | Int[0..50] | `0` | | +| `background_alpha` | Float[0.0..1.0] | `0.5` | | +| `alpha` | Float[0.0..1.0] | `1.0` | Overall HUD transparency | + +--- + +## Category: APPEARANCE — COLORS + +All color values: 6-char hex string RRGGBB (no `#`). Validate: must match `^[0-9A-Fa-f]{6}$`. + +| Key | Default | +|-----|---------| +| `text_color` | `FFFFFF` | +| `gpu_color` | `2E9762` | +| `cpu_color` | `2E97CB` | +| `vram_color` | `AD64C1` | +| `ram_color` | `C26693` | +| `engine_color` | `EB5B5B` | +| `io_color` | `A491D3` | +| `frametime_color` | `00FF00` | +| `background_color` | `020202` | +| `media_player_color` | `FFFFFF` | +| `wine_color` | `EB5B5B` | +| `battery_color` | `FF9078` | +| `network_color` | `E07B85` | +| `horizontal_separator_color` | `AD64C1` | + +Also: +| Key | Type | Default | +|-----|------|---------| +| `text_outline` | Flag | present (default on) | +| `text_outline_color` | Color | `000000` | +| `text_outline_thickness` | Float[0.5..5.0] | `1.5` | + +--- + +## Category: APPEARANCE — TYPOGRAPHY + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `font_size` | Int[8..72] | `24` | | +| `font_scale` | Float[0.1..5.0] | `1.0` | | +| `font_size_text` | Int[8..72] | `24` | For text elements | +| `font_scale_media_player` | Float[0.1..5.0] | `0.55` | | +| `no_small_font` | Flag | absent | Disable small font for secondary info | +| `font_file` | Path | `""` | TTF/OTF path — validated to exist if set | +| `font_file_text` | Path | `""` | | +| `font_glyph_ranges` | CommaSeparatedStrings | `""` | Valid: `korean,chinese,chinese_simplified,japanese,cyrillic,thai,vietnamese,latin_ext_a,latin_ext_b` | + +--- + +## Category: BEHAVIOR — KEYBINDINGS + +All keybind values: format is `Key` or `Modifier+Key` or `Mod1+Mod2+Key`. +Valid modifiers: `Shift_L`, `Shift_R`, `Control_L`, `Control_R`, `Alt_L`, `Alt_R`, `Super_L`, `Super_R`. + +| Key | Default | +|-----|---------| +| `toggle_hud` | `Shift_R+F12` | +| `toggle_hud_position` | `Shift_R+F11` | +| `toggle_preset` | `Shift_R+F10` | +| `toggle_fps_limit` | `Shift_L+F1` | +| `toggle_logging` | `Shift_L+F2` | +| `reload_cfg` | `Shift_L+F4` | +| `upload_log` | `Shift_L+F3` | +| `reset_fps_metrics` | `Shift_R+F9` | + +--- + +## Category: BEHAVIOR — LOGGING + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `autostart_log` | Int[0..3600] | absent | Seconds before auto-start | +| `log_duration` | Int[1..86400] | absent | Seconds | +| `log_interval` | Int[0..10000] | `0` | ms; 0 = default | +| `output_folder` | Path | `""` | Must be writable directory | +| `output_file` | String | `""` | | +| `permit_upload` | Bool | `0` | Upload to flightlessmango.com | +| `benchmark_percentiles` | CommaSeparatedStrings | `97,AVG` | | +| `log_versioning` | Flag | absent | | +| `upload_logs` | Flag | absent | DEPENDS ON: `permit_upload=1` | + +--- + +## Category: BEHAVIOR — MISC + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `blacklist` | CommaSeparatedStrings | `""` | App names to suppress overlay | +| `control` | String | `-1` | Socket name; -1=disabled; `%p` replaced with PID | + +--- + +## Category: WORKAROUNDS — OPENGL + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `gl_size_query` | Enum[`viewport`,`scissorbox`,`disabled`] | `""` | Default: glXQueryDrawable | +| `gl_bind_framebuffer` | Int[0..999] | absent | Rebind framebuffer before draw | +| `gl_dont_flip` | Bool | absent | Don't swap origin for GL_UPPER_LEFT | + +--- + +## Category: ADVANCED — FCAT + +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `fcat` | Flag | absent | Enable FCAT overlay | +| `fcat_overlay_width` | Int[20..200] | `24` | DEPENDS ON: `fcat` | +| `fcat_screen_edge` | Int[0..3] | `0` | DEPENDS ON: `fcat` | + +--- + +## Category: ADVANCED — FTRACE + +| Key | Type | Notes | +|-----|------|-------| +| `ftrace` | String | Complex format: `type/event[+type/event2]`; validated by regex | + +ftrace format regex: `^(histogram|linegraph|label)/[a-zA-Z0-9_]+(\/[a-zA-Z0-9_]+)?(\+(histogram|linegraph|label)/[a-zA-Z0-9_]+(\/[a-zA-Z0-9_]+)?)*$` + +--- + +## Validation Rules Summary + +### Cross-option dependencies (enabling B requires A to also be enabled): +``` +gpu_mem_clock → requires vram +gpu_mem_temp → requires vram +hide_fsr_sharpness → requires fsr +battery_icon → requires battery +battery_watt → requires battery +battery_time → requires battery +media_player_name → requires media_player +media_player_format → requires media_player +procmem_shared → requires procmem +procmem_virt → requires procmem +upload_logs → requires permit_upload = 1 +horizontal_stretch → requires horizontal +time_no_label → requires time +``` + +### Mutual exclusions (A and B cannot both be active): +``` +fps_only ↔ (any other display param) +histogram ↔ frame_timing +``` + +### Vendor restrictions: +``` +gpu_voltage → AMD only (warn if non-AMD GPU detected) +``` + +### Gamescope-only (warn if not running gamescope): +``` +fsr, hide_fsr_sharpness, hdr, refresh_rate, debug +``` + +### Value format validations: +- All Color fields: must match `^[0-9A-Fa-f]{6}$` +- All Path fields: if non-empty, path must exist (for input files) or parent dir must be writable (for output) +- `fps_limit`: each comma-separated value must be non-negative integer +- `font_glyph_ranges`: each value must be in the valid set +- `graphs`: each value must be in the valid set +- `device_battery`: each value must be in `[gamepad, mouse, controller, headset]` +- `fps_metrics`: each value must be `AVG` or a valid decimal between 0.0 and 100.0 +- `benchmark_percentiles`: same as fps_metrics +- `time_format`: must be a valid strftime format string +- `pci_dev`: must match `^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$` +- `control`: must be `-1` or a valid socket name string +- Keybind fields: must match `^(Shift_[LR]\+|Control_[LR]\+|Alt_[LR]\+|Super_[LR]\+)*(F[1-9]|F1[0-2]|[A-Z])$` diff --git a/docs/plan/phase_01.md b/docs/plan/phase_01.md new file mode 100644 index 0000000..f125dc1 --- /dev/null +++ b/docs/plan/phase_01.md @@ -0,0 +1,154 @@ +# Phase 01 — Project Scaffold + +## Goal +Create the complete Rust project skeleton with correct Cargo.toml, build system, +directory structure, and verify that the project compiles (empty/stub implementations). + +## Prerequisites +- Rust toolchain installed (rustup, cargo) +- System dependencies installed (see docs/architecture.md — System Dependencies section) +- Verify with: `pkg-config --exists gtk4 libadwaita-1 && echo "OK"` + +## Steps + +### 1. Create project +```bash +cargo new --bin mangotune +cd mangotune +``` + +### 2. Create full directory structure +```bash +mkdir -p src/{config,system,launcher,integrations,ui/{pages,widgets}} +mkdir -p data/icons +``` + +### 3. Write Cargo.toml +Copy the exact dependency block from `docs/architecture.md` → Cargo.toml section. +Do not add or remove any dependencies without updating docs/architecture.md. + +### 4. Write build.rs +```rust +// build.rs +fn main() { + glib_build_tools::compile_schemas("data"); +} +``` + +### 5. Create GSettings schema +File: `data/com.mangotune.MangoTune.gschema.xml` +```xml + + + + + '' + Last edited config file path + + + 1200 + Window width + + + 780 + Window height + + + 'performance' + Currently active sidebar page + + + false + Whether raw editor tab is visible + + + +``` + +### 6. Create .desktop file +File: `data/com.mangotune.MangoTune.desktop` +```ini +[Desktop Entry] +Name=MangoTune +Comment=MangoHud Overlay Configurator +Exec=mangotune +Icon=com.mangotune.MangoTune +Terminal=false +Type=Application +Categories=Settings;System; +Keywords=MangoHud;overlay;gaming;performance; +StartupNotify=true +StartupWMClass=mangotune +``` + +### 7. Create all source stub files +Each file listed in docs/architecture.md → Target Source Tree must be created +as a stub with correct module declarations and a `todo!()` placeholder where needed. + +Required stub content for each module file: +```rust +// src/config/parser.rs +//! MangoHud config file parser and writer. +//! See: mangotune-plan/modules/config_parser.md for full spec. + +pub struct Parser; + +impl Parser { + pub fn new() -> Self { todo!() } +} +``` + +### 8. Wire up main.rs +```rust +// src/main.rs +mod app; +mod config; +mod system; +mod launcher; +mod integrations; +mod ui; + +fn main() { + tracing_subscriber::fmt::init(); + let app = app::MangoTuneApp::new(); + std::process::exit(app.run()); +} +``` + +### 9. Wire up app.rs stub +```rust +// src/app.rs +use gtk4::prelude::*; +use libadwaita::prelude::*; + +pub struct MangoTuneApp { + app: libadwaita::Application, +} + +impl MangoTuneApp { + pub fn new() -> Self { + let app = libadwaita::Application::builder() + .application_id("com.mangotune.MangoTune") + .build(); + MangoTuneApp { app } + } + + pub fn run(&self) -> i32 { + self.app.run().into() + } +} +``` + +### 10. Verify compilation +```bash +cargo check 2>&1 +``` +Must produce zero errors (warnings acceptable at this stage). + +## Acceptance Criteria +- [ ] `cargo check` exits with code 0 +- [ ] All directories from the target source tree exist +- [ ] All source stub files exist with correct module declarations +- [ ] `data/` contains gschema.xml and .desktop file +- [ ] `build.rs` compiles the schema without error +- [ ] No `.unwrap()` calls in non-stub code (stubs with `todo!()` are exempt) diff --git a/docs/plan/phase_02.md b/docs/plan/phase_02.md new file mode 100644 index 0000000..65ead66 --- /dev/null +++ b/docs/plan/phase_02.md @@ -0,0 +1,290 @@ +# Phase 02 — Config Parser, Schema & Validator + +## Goal +Implement the complete non-UI config stack: parser, typed schema, and validation engine. +This phase has NO GTK4 code. All modules are pure Rust with full unit tests. + +## Files to implement (fully, not stubs) +- `src/config/types.rs` +- `src/config/parser.rs` +- `src/config/schema.rs` +- `src/config/validator.rs` +- `src/config/mod.rs` + +--- + +## src/config/types.rs + +Define all shared types used across the config subsystem. + +```rust +use std::path::PathBuf; + +/// A single line from a MangoHud config file, preserving its original text. +pub enum ConfigLine { + Comment(String), // lines starting with # + Blank, // empty lines + Option { key: String, value: Option, raw: String }, + CommentedOption { key: String, value: Option, raw: String }, +} + +/// The current state of an option in the in-memory config. +#[derive(Debug, Clone, PartialEq)] +pub enum ConfigValue { + Absent, // not present in file; use MangoHud compiled default + Flag, // bare key with no value (presence = enabled) + Value(String), // key=value + Disabled, // was commented out explicitly +} + +/// Type system for schema entries. +#[derive(Debug, Clone)] +pub enum OptionType { + Flag, + Bool, + Int { min: i64, max: i64 }, + Float { min: f64, max: f64 }, + Str { max_len: usize }, + Color, + Enum { variants: Vec }, + FpsLimitList, + KeyBind, + CommaSepInts, + CommaSepFloats, + CommaSepStrings { valid_values: Option> }, + Path { must_exist: bool, must_be_writable: bool }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GpuVendor { Any, AmdOnly, NvidiaOnly, IntelOnly } + +#[derive(Debug, Clone, PartialEq)] +pub enum Category { + Performance, + DisplayFps, + DisplayGpu, + DisplayCpu, + DisplayMemory, + DisplayIoNetwork, + DisplayMisc, + DisplayGraphs, + DisplayBattery, + DisplayMediaPlayer, + DisplayGamescope, + DisplaySteamDeck, + DisplayTimeText, + AppearanceLayout, + AppearanceColors, + AppearanceTypography, + BehaviorKeybindings, + BehaviorFpsLimits, + BehaviorLogging, + BehaviorMisc, + WorkaroundsOpengl, + AdvancedFcat, + AdvancedFtrace, +} + +/// A single schema entry — defines everything about one MangoHud option. +#[derive(Debug, Clone)] +pub struct SchemaEntry { + pub key: &'static str, + pub option_type: OptionType, + pub description: &'static str, + pub category: Category, + pub dependencies: &'static [&'static str], + pub conflicts_with: &'static [&'static str], + pub gpu_vendor_only: GpuVendor, + pub gamescope_only: bool, +} + +/// Validation result for a single option. +#[derive(Debug, Clone, PartialEq)] +pub enum ValidationResult { + Ok, + Warning(String), // save is allowed but issue shown + Error(String), // save is BLOCKED +} + +/// The full in-memory representation of a parsed config file. +pub struct AnnotatedConfig { + /// Ordered list of lines as they appear in the file. + /// Used for writing back to disk (preserves comments/order). + pub lines: Vec, + /// Fast lookup map: key → (line_index, current_value). + pub options: indexmap::IndexMap, + /// Source path, if backed by a file. + pub path: Option, + /// Whether this config has unsaved in-memory changes. + pub dirty: bool, +} +``` + +--- + +## src/config/parser.rs + +Rules to implement (from docs/architecture.md → Config File Format Notes): +1. Lines starting with `#` → `ConfigLine::Comment` (preserve verbatim) +2. Empty/whitespace-only lines → `ConfigLine::Blank` +3. `key=value` → `ConfigLine::Option { key, value: Some(value) }` +4. `key` alone → `ConfigLine::Option { key, value: None }` (flag) +5. `# key` → `ConfigLine::CommentedOption { key, value: None }` +6. `# key=value` → `ConfigLine::CommentedOption { key, value: Some(value) }` +7. On duplicate keys: last occurrence wins (update options map accordingly) +8. All parsing: UTF-8, trim trailing whitespace from values + +### Public API +```rust +impl Parser { + /// Parse a config file from disk. + pub fn read(path: &Path) -> anyhow::Result + + /// Parse config from a string (for env var inline and tests). + pub fn parse_str(content: &str, path: Option) -> AnnotatedConfig + + /// Write an AnnotatedConfig back to disk. + /// - Creates backup at {path}.mangotune.bak + /// - Writes to {path}.mangotune.tmp + /// - Atomically renames to {path} + /// - Returns Err if any step fails (restores from backup on failure) + pub fn write(config: &AnnotatedConfig) -> anyhow::Result<()> + + /// Update a specific key's value in the config lines. + /// If key exists: update that line in-place. + /// If key doesn't exist: append to end of file. + /// If setting to Absent/Disabled: comment out the line. + pub fn set_value(config: &mut AnnotatedConfig, key: &str, value: ConfigValue) + + /// Serialize config to a string (for preview or clipboard copy). + pub fn to_string(config: &AnnotatedConfig) -> String +} +``` + +### Tests required (in src/config/parser.rs at bottom, `#[cfg(test)]`) +- Parse a file with all line types (comment, blank, key=value, bare key, commented option) +- Round-trip: parse → write → parse → values must match +- set_value: update existing key preserves surrounding lines +- set_value: add new key appends at end +- set_value: disable key comments it out +- Duplicate key: last value wins +- UTF-8 edge cases: file with non-ASCII comments + +--- + +## src/config/schema.rs + +Implement `MANGOHUD_SCHEMA: &[SchemaEntry]` — a static slice containing one entry +per option documented in `docs/mangohud_schema.md`. + +Every single option in that document must have a corresponding entry here. +Count: approximately 120 entries. + +```rust +use once_cell::sync::Lazy; +pub static MANGOHUD_SCHEMA: Lazy> = Lazy::new(|| vec![ + SchemaEntry { + key: "fps", + option_type: OptionType::Flag, + description: "Show FPS counter (enabled by default)", + category: Category::DisplayFps, + dependencies: &[], + conflicts_with: &["fps_only"], + gpu_vendor_only: GpuVendor::Any, + gamescope_only: false, + }, + // ... all ~120 entries +]); + +/// Fast lookup by key. +pub fn get_schema_entry(key: &str) -> Option<&'static SchemaEntry> +``` + +Also implement: +```rust +/// Return all schema entries for a given category. +pub fn entries_for_category(category: &Category) -> Vec<&'static SchemaEntry> + +/// Return all dependency keys for a given key (recursive). +pub fn all_dependencies(key: &str) -> Vec<&'static str> +``` + +--- + +## src/config/validator.rs + +```rust +/// Validate a single value against its schema entry. +/// Returns Ok, Warning, or Error. +pub fn validate_value( + key: &str, + value: &ConfigValue, + schema: &SchemaEntry, +) -> ValidationResult + +/// Validate an entire config against the full schema. +/// Returns a map of key → ValidationResult for every key that has an issue. +/// Keys with ValidationResult::Ok are NOT included (only problems). +pub fn validate_all( + config: &AnnotatedConfig, +) -> HashMap + +/// Check dependency satisfaction. +/// Returns list of (dependent_key, missing_required_key) pairs. +pub fn check_dependencies(config: &AnnotatedConfig) -> Vec<(String, String)> + +/// Check for mutual exclusions. +/// Returns list of (key_a, key_b) pairs where both are active but they conflict. +pub fn check_conflicts(config: &AnnotatedConfig) -> Vec<(String, String)> + +/// True if config is fully valid (no Errors). Warnings are allowed. +pub fn is_saveable(config: &AnnotatedConfig) -> bool +``` + +### Validation logic per type (implement all): +- `Flag`: always valid if present; no value to validate +- `Bool`: value must be "0" or "1" +- `Int { min, max }`: must parse as i64, must be in [min, max] +- `Float { min, max }`: must parse as f64, must be in [min, max] +- `Str { max_len }`: must be ≤ max_len bytes +- `Color`: must match regex `^[0-9A-Fa-f]{6}$` +- `Enum { variants }`: value must be in variants list (case-sensitive) +- `FpsLimitList`: comma-separated, each part must be non-negative integer +- `KeyBind`: must match the keybind regex from mangohud_schema.md +- `CommaSepInts`: each comma-part must parse as integer +- `CommaSepFloats`: each comma-part must parse as f64 +- `CommaSepStrings { valid_values: Some(_) }`: each part must be in valid set +- `CommaSepStrings { valid_values: None }`: any non-empty string OK +- `Path { must_exist, must_be_writable }`: validate with std::fs + +### Tests required +- Each validation type: valid input, invalid input, boundary values +- Dependency check: config with missing dependency detected +- Conflict check: fps_only + fps = conflict detected +- is_saveable: returns false if any Error present, true if only Warnings + +--- + +## src/config/mod.rs + +```rust +pub mod types; +pub mod parser; +pub mod schema; +pub mod validator; +pub mod resolver; // stub only in this phase + +pub use types::*; +``` + +--- + +## Acceptance Criteria +- [ ] `cargo test --lib` passes all tests with 0 failures +- [ ] Schema contains entries for ALL ~120 options from docs/mangohud_schema.md +- [ ] Parser round-trips without losing comments or blank lines +- [ ] Validator blocks saves on invalid Color, out-of-range Int, unknown Enum value +- [ ] Dependency checker catches gpu_mem_clock without vram +- [ ] Conflict checker catches fps_only with any other display param +- [ ] No `.unwrap()` in production code paths +- [ ] No `todo!()` remaining in any of the 4 implemented files diff --git a/docs/plan/phase_03.md b/docs/plan/phase_03.md new file mode 100644 index 0000000..3a3b37c --- /dev/null +++ b/docs/plan/phase_03.md @@ -0,0 +1,234 @@ +# Phase 03 — Config Resolver & System Detection + +## Goal +Implement the config file discovery/priority system and system detection module. +No GTK4 code. All modules are pure Rust with unit tests. + +## Files to implement +- `src/config/resolver.rs` +- `src/system/detect.rs` +- `src/system/paths.rs` +- `src/system/mod.rs` + +--- + +## src/system/paths.rs + +```rust +use std::path::PathBuf; +use xdg::BaseDirectories; + +pub struct XdgPaths { + pub config_home: PathBuf, // $XDG_CONFIG_HOME or ~/.config + pub mangohud_dir: PathBuf, // {config_home}/MangoHud/ + pub global_config: PathBuf, // {mangohud_dir}/MangoHud.conf + pub data_home: PathBuf, +} + +impl XdgPaths { + pub fn resolve() -> anyhow::Result +} + +/// Return all possible Steam root directories (native + Flatpak variants). +pub fn steam_roots() -> Vec + +/// Return all possible Heroic config directories. +pub fn heroic_config_dirs() -> Vec + +/// Return all possible Lutris config directories. +pub fn lutris_config_dirs() -> Vec + +/// Expand ~ in paths (since std::fs doesn't do this). +pub fn expand_tilde(path: &str) -> PathBuf +``` + +--- + +## src/system/detect.rs + +Run at application startup. Returns a `SystemInfo` struct that is passed to the +rest of the app and refreshed on demand. + +```rust +#[derive(Debug, Clone)] +pub struct SystemInfo { + pub mangohud: MangoHudInfo, + pub gpu: GpuInfo, + pub display_server: DisplayServer, + pub tools: AvailableTools, + pub integrations: IntegrationAvailability, +} + +#[derive(Debug, Clone)] +pub struct MangoHudInfo { + pub installed: bool, + pub version: Option, // parsed from `mangohud --version` + pub lib_path: Option, // path to libMangoHud.so + pub flatpak: bool, +} + +#[derive(Debug, Clone)] +pub struct GpuInfo { + pub vendor: GpuVendor, + pub name: Option, + pub pci_id: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DisplayServer { + Wayland, + X11, + XwaylandUnderWayland, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct AvailableTools { + pub vkcube: Option, + pub glxgears: Option, + pub gamemodectl: Option, + pub gamemoded: Option, +} + +#[derive(Debug, Clone)] +pub struct IntegrationAvailability { + pub steam: bool, + pub steam_flatpak: bool, + pub lutris: bool, + pub lutris_flatpak: bool, + pub heroic: bool, + pub heroic_flatpak: bool, + pub gamemode: bool, +} + +/// Run all detection. Runs in a tokio task, sends result back via channel. +pub async fn detect_system() -> anyhow::Result +``` + +### Detection implementation details: + +**MangoHud detection:** +1. Try `which mangohud` → path +2. Run `mangohud --version 2>&1` → parse version string +3. Check library existence at common paths: + - `/usr/lib/x86_64-linux-gnu/mangohud/libMangoHud.so` + - `/usr/lib/mangohud/libMangoHud.so` + - `/usr/local/lib/mangohud/libMangoHud.so` + - `~/.local/lib/mangohud/libMangoHud.so` +4. Check Flatpak: `flatpak list 2>/dev/null | grep -i mangohud` + +**GPU detection:** +1. Read `/sys/class/drm/card*/device/vendor` — `0x1002`=AMD, `0x10de`=NVIDIA, `0x8086`=Intel +2. Read `/sys/class/drm/card*/device/device` for device ID +3. Cross-reference with `lspci -nn 2>/dev/null` for name + +**Display server detection:** +1. Check `$WAYLAND_DISPLAY` — if set → Wayland +2. Check `$DISPLAY` — if set → X11 (or XwaylandUnderWayland if also Wayland) +3. Check `$XDG_SESSION_TYPE` + +**Tool detection:** `which` for each tool via `std::process::Command` + +--- + +## src/config/resolver.rs + +Full implementation of the config layer discovery system. +See `docs/config_resolution.md` for the complete algorithm. + +```rust +use crate::config::types::*; +use crate::system::paths::XdgPaths; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct ConfigLayer { + pub path: Option, + pub source_type: LayerSource, + pub priority: u8, + pub exists: bool, + pub is_editable: bool, + pub config: Option, +} + +#[derive(Debug, Clone)] +pub enum LayerSource { + CompiledDefault, + GlobalXdg, + PerAppXdg(String), + AppLocal(PathBuf), + EnvFile(PathBuf), + EnvInline(String), +} + +/// A conflict: one option set in multiple layers. +#[derive(Debug, Clone)] +pub struct ConfigConflict { + pub key: String, + pub winning_layer_priority: u8, + pub winning_value: ConfigValue, + pub shadowed: Vec<(u8, ConfigValue)>, // (priority, value) of shadowed layers +} + +pub struct Resolver; + +impl Resolver { + /// Discover all config layers. Reads env vars and filesystem. + pub async fn discover(xdg: &XdgPaths) -> anyhow::Result> + + /// Build conflict map from a resolved layer stack. + /// Returns only options that appear in more than one layer with different values. + pub fn find_conflicts(layers: &[ConfigLayer]) -> Vec + + /// Return the effective value for a key (highest priority layer that sets it). + pub fn effective_value(key: &str, layers: &[ConfigLayer]) -> ConfigValue + + /// Return a display label for a layer source. + pub fn layer_label(source: &LayerSource) -> String + + /// Create a new per-app config at the XDG path. + pub fn create_per_app_config(app_name: &str, xdg: &XdgPaths) -> anyhow::Result + + /// Scan common game directories for app-local MangoHud.conf files. + async fn scan_game_dirs() -> Vec +} +``` + +### Discovery order (implement exactly as in docs/config_resolution.md): +1. `$MANGOHUD_CONFIGFILE` → EnvFile (priority 5) +2. `$MANGOHUD_CONFIG` → EnvInline (priority 5, wins over EnvFile) +3. `{xdg}/MangoHud/MangoHud.conf` → GlobalXdg (priority 2) +4. `{xdg}/MangoHud/*.conf` (excluding MangoHud.conf) → PerAppXdg (priority 3) +5. Steam/game dirs scan → AppLocal (priority 4) + +### Tests required +- discover: correctly reads $XDG_CONFIG_HOME override +- discover: env var $MANGOHUD_CONFIG parsed as inline layer at priority 5 +- find_conflicts: detects option set in both global and per-app configs +- effective_value: returns env value when set, file value otherwise +- create_per_app_config: creates file with correct header comment + +--- + +## src/system/mod.rs + +```rust +pub mod detect; +pub mod paths; + +pub use detect::{SystemInfo, MangoHudInfo, GpuInfo, DisplayServer, AvailableTools}; +pub use paths::XdgPaths; +``` + +--- + +## Acceptance Criteria +- [ ] `cargo test --lib` passes all new tests +- [ ] Resolver correctly discovers layers in priority order +- [ ] Env var layers detected with correct priority (5 = highest) +- [ ] Conflict detection identifies options shadowed by higher-priority layers +- [ ] System detection identifies GPU vendor from `/sys/class/drm` +- [ ] MangoHud version extracted from `--version` output +- [ ] Display server (Wayland/X11) correctly detected from environment +- [ ] All paths resolve via XDG (no hardcoded `/home/username`) +- [ ] No `.unwrap()` in production code diff --git a/docs/plan/phase_04.md b/docs/plan/phase_04.md new file mode 100644 index 0000000..5713f54 --- /dev/null +++ b/docs/plan/phase_04.md @@ -0,0 +1,268 @@ +# Phase 04 — GTK4 App Skeleton & Main Window + +## Goal +Build the complete application shell: window, header bar, config selector bar, +sidebar navigation, and empty page placeholders. All navigation must work. +No option editing yet — pages show "Coming soon" content. + +## Files to implement +- `src/app.rs` (replace stub) +- `src/window.rs` (replace stub) +- `src/ui/mod.rs` +- `src/ui/pages/mod.rs` +- `src/ui/widgets/mod.rs` +- `data/style.css` + +--- + +## src/app.rs + +```rust +use gtk4::prelude::*; +use libadwaita::prelude::*; +use crate::window::MainWindow; +use crate::system::detect; + +pub struct MangoTuneApp { + app: libadwaita::Application, +} + +impl MangoTuneApp { + pub fn new() -> Self { + let app = libadwaita::Application::builder() + .application_id("com.mangotune.MangoTune") + .flags(gio::ApplicationFlags::FLAGS_NONE) + .build(); + + let app_clone = app.clone(); + app.connect_activate(move |_| { + // Load CSS + let provider = gtk4::CssProvider::new(); + provider.load_from_data(include_str!("../data/style.css")); + gtk4::style_context_add_provider_for_display( + &gdk::Display::default().expect("No display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + // Run system detection async, then build window + let ctx = glib::MainContext::default(); + ctx.spawn_local(async move { + let system_info = detect::detect_system().await + .unwrap_or_else(|_| detect::SystemInfo::unknown()); + let window = MainWindow::new(&app_clone, system_info); + window.present(); + }); + }); + + MangoTuneApp { app } + } + + pub fn run(&self) -> i32 { + self.app.run().into() + } +} +``` + +--- + +## src/window.rs + +```rust +use gtk4::prelude::*; +use libadwaita::prelude::*; +use crate::system::detect::SystemInfo; +use crate::ui::pages; + +pub struct MainWindow { + pub window: libadwaita::ApplicationWindow, +} + +impl MainWindow { + pub fn new(app: &libadwaita::Application, system_info: SystemInfo) -> Self { + let window = libadwaita::ApplicationWindow::builder() + .application(app) + .title("MangoTune") + .default_width(1200) + .default_height(780) + .build(); + + // Restore window size from GSettings + // Build layout: + // AdwToolbarView + // top: AdwHeaderBar + // top: ConfigBarWidget (custom) + // content: AdwOverlaySplitView + // sidebar: navigation list + // content: AdwNavigationView (page stack) + + let toolbar_view = libadwaita::ToolbarView::new(); + let header = build_header_bar(); + let config_bar = build_config_bar(&system_info); + let split_view = build_split_view(&system_info); + + toolbar_view.add_top_bar(&header); + toolbar_view.add_top_bar(&config_bar); + toolbar_view.set_content(Some(&split_view)); + + window.set_content(Some(&toolbar_view)); + MainWindow { window } + } + + pub fn present(&self) { self.window.present(); } +} +``` + +### Header Bar implementation +``` +AdwHeaderBar + title-widget: AdwWindowTitle { title: "MangoTune", subtitle: "No config loaded" } + start: AdwSplitButton "Save" (insensitive by default) + dropdown items: "Save As…", "Revert to Saved", "Create Backup" + end: GtkMenuButton (gear icon) + popover menu: "Preferences", "Keyboard Shortcuts", "About MangoTune" +``` + +### Config Bar implementation +Custom `GtkBox` with `@card_bg_color` background: +``` +GtkBox (horizontal, spacing=8, margin=6) + GtkImage (config type icon — globe/per-app/warning) + GtkLabel "Editing:" + GtkDropDown ← lists all discovered config layers + model: StringList populated from resolver + GtkLabel "⚠ 2 conflicts" (hidden if no conflicts) + GtkButton "View All Layers" → navigate to conflicts page +``` + +### Split View implementation +``` +AdwOverlaySplitView + sidebar-width-fraction: 0.22 + min-sidebar-width: 180 + max-sidebar-width: 260 + show-sidebar: true + collapsed at width < 980 + + sidebar: NavigationSidebar (GtkListBox with .navigation-sidebar CSS class) + content: AdwNavigationView ← all pages pushed here +``` + +### Navigation Sidebar +The sidebar is a `GtkListBox` with rows grouped by sections. +See docs/design_system.md → Sidebar Navigation for the full list of sections and items. + +Each row stores the page ID as data. On row activation: +- Call `navigation_view.push_by_tag(page_id)` +- Highlight the active row + +Section headers: `GtkLabel` with `.heading` CSS class, not selectable. + +### Page Stubs (all pages in this phase return placeholder content) +Create all page files listed in `src/ui/pages/` with a stub that returns: +```rust +pub fn build_page() -> libadwaita::PreferencesPage { + let page = libadwaita::PreferencesPage::new(); + page.set_title("Page Name"); + let group = libadwaita::PreferencesGroup::new(); + group.set_title("Coming Soon"); + group.set_description(Some("This page will be implemented in a future phase.")); + page.add(&group); + page +} +``` + +### About Dialog +`AdwAboutDialog` with: +``` +application-name: "MangoTune" +application-icon: "com.mangotune.MangoTune" +version: env!("CARGO_PKG_VERSION") +comments: "A modern, accurate MangoHud configurator for Linux" +license-type: Gtk::License::Gpl30 +website: "https://github.com/your-org/mangotune" +issue-url: "https://github.com/your-org/mangotune/issues" +developers: ["MangoTune Contributors"] +``` + +--- + +## data/style.css + +Only custom styles that libadwaita/GTK4 don't provide natively. + +```css +/* Config layer priority badges */ +.layer-badge-env { + background-color: @destructive_color; + color: @destructive_fg_color; + border-radius: 4px; + padding: 2px 6px; + font-size: 0.75em; + font-weight: bold; +} + +.layer-badge-perapp { + background-color: @warning_color; + color: @warning_fg_color; + border-radius: 4px; + padding: 2px 6px; + font-size: 0.75em; + font-weight: bold; +} + +.layer-badge-global { + background-color: @success_color; + color: @success_fg_color; + border-radius: 4px; + padding: 2px 6px; + font-size: 0.75em; + font-weight: bold; +} + +/* Shadowed option text in cascade view */ +.option-shadowed { + text-decoration: line-through; + opacity: 0.5; +} + +/* Color swatch button */ +.color-swatch-button { + min-width: 28px; + min-height: 28px; + border-radius: 4px; + border: 1px solid @borders; + padding: 0; +} + +/* Config bar */ +.config-bar { + background-color: @card_bg_color; + border-bottom: 1px solid @borders; + padding: 6px 12px; +} + +/* Conflict count badge */ +.conflict-badge { + background-color: @destructive_color; + color: @destructive_fg_color; + border-radius: 8px; + padding: 1px 6px; + font-size: 0.75em; + font-weight: bold; +} +``` + +--- + +## Acceptance Criteria +- [ ] App launches without crashing +- [ ] Window appears at 1200×780 +- [ ] Header bar shows "MangoTune" title and (insensitive) Save button +- [ ] Config bar renders below header +- [ ] Sidebar shows all navigation sections with correct icons +- [ ] Clicking each sidebar item navigates to its placeholder page +- [ ] About dialog opens from gear menu +- [ ] System detection runs on startup (check tracing log output) +- [ ] App responds to window resize (sidebar collapses at narrow width) +- [ ] No GTK warnings or critical messages in stderr on launch diff --git a/docs/plan/phase_05_to_11.md b/docs/plan/phase_05_to_11.md new file mode 100644 index 0000000..5fa715a --- /dev/null +++ b/docs/plan/phase_05_to_11.md @@ -0,0 +1,628 @@ +# Phases 05–11 — Implementation Phases + +--- + +# Phase 05 — Core Config Pages: Performance, GPU, CPU, Memory + +## Goal +Implement the four most-used config pages with full working controls, +inline validation, and dependency handling. The Save button becomes functional. + +## Files to implement (replace stubs) +- `src/ui/pages/performance.rs` +- `src/ui/pages/gpu.rs` +- `src/ui/pages/cpu.rs` +- `src/ui/pages/memory.rs` +- `src/ui/widgets/toggle_row.rs` ← shared AdwSwitchRow wrapper +- `src/ui/widgets/validation_label.rs` ← inline error display + +## Application State Architecture + +Before implementing pages, establish the central app state that all pages share. +Add to `src/window.rs`: + +```rust +use std::sync::{Arc, Mutex}; +use crate::config::types::AnnotatedConfig; +use crate::config::validator; + +/// Shared mutable application state, passed via Arc> to all pages. +pub struct AppState { + pub config: AnnotatedConfig, + pub validation: HashMap, + pub dirty: bool, +} +``` + +Use `glib::MainContext::channel()` or `Arc>` + GObject signals +to communicate changes from widget callbacks to the save button sensitivity. + +## Page Implementation Pattern + +Every page follows this exact pattern: + +```rust +// src/ui/pages/gpu.rs + +pub fn build_page(state: Arc>) -> libadwaita::PreferencesPage { + let page = libadwaita::PreferencesPage::new(); + page.set_title("GPU"); + page.set_icon_name(Some("computer-symbolic")); + + // ── GROUP: GPU Statistics ────────────────────────────────────── + let group = libadwaita::PreferencesGroup::new(); + group.set_title("GPU Statistics"); + group.set_description(Some("Core GPU monitoring display options")); + + // gpu_stats (master toggle) + let gpu_stats_row = build_switch_row( + "GPU Statistics", + "gpu_stats — master GPU section toggle", + "gpu_stats", + &state, + ); + group.add(&gpu_stats_row); + + // gpu_temp + let gpu_temp_row = build_switch_row("Temperature", "gpu_temp", "gpu_temp", &state); + group.add(&gpu_temp_row); + + // ... etc for every option in Category::DisplayGpu + + page.add(&group); + page +} +``` + +## Widget Helpers to implement in this phase + +### `build_switch_row(title, subtitle, key, state)` → AdwSwitchRow +1. Read current value from `state.config` +2. Set initial active state +3. Connect `notify::active`: + a. Update `state.config` via `parser::set_value` + b. Run `validator::validate_value(key, value, schema_entry)` + c. If dependency triggered: show `AdwAlertDialog` "Enable {dep} too?" + d. If conflict triggered: show warning toast + e. Update save button sensitivity + +### `build_spin_row(title, subtitle, key, min, max, state)` → AdwSpinRow +1. Read current value, set initial +2. Connect `notify::value`: + a. Update state + b. Validate + c. Show/hide error on the row (add/remove `.error` CSS class) + d. Update save button + +### `build_combo_row(title, subtitle, key, variants, state)` → AdwComboRow + +### `build_entry_row(title, subtitle, key, state)` → AdwEntryRow + +### Validation error display on rows +```rust +fn set_row_error(row: &impl IsA, error: Option<&str>) { + if let Some(msg) = error { + row.add_css_class("error"); + // Update row subtitle to show error message + } else { + row.remove_css_class("error"); + // Restore original subtitle + } +} +``` + +## Save Button Wiring +In `window.rs`, connect save button: +1. Run `validator::validate_all(&state.config)` — full pass +2. If any Error: show `AdwToast` "Fix N errors before saving", abort +3. If all Ok: call `parser::write(&state.config)` +4. On success: show `AdwToast` "Config saved", set `state.dirty = false`, + make save button insensitive + +## GPU-specific notes +- `gpu_mem_clock` and `gpu_mem_temp`: when enabled, check if `vram` is also active. + If not, show `AdwAlertDialog`: "GPU Memory Clock requires VRAM display to be enabled. + Enable VRAM now?" → buttons: "Enable Both" / "Cancel". +- `gpu_voltage`: if GPU vendor != AMD, add an `AdwBanner` warning at top of group: + "gpu_voltage is only available on AMD GPUs. This option will have no effect." +- Color load thresholds (gpu_load_change, gpu_load_value, gpu_load_color): + use an `AdwExpanderRow` that expands to show threshold + color sub-rows. + +## Acceptance Criteria +- [ ] All four pages render with correct controls for every option in their category +- [ ] Toggle switches update in-memory state immediately +- [ ] Invalid values (e.g. font_size=999) show inline error and block save +- [ ] Save button is insensitive when no changes or when errors exist +- [ ] Save writes correct .conf format (key=value or bare key) +- [ ] Comments and blank lines preserved after save +- [ ] Dependency dialog appears when enabling gpu_mem_clock without vram +- [ ] Vendor warning shows for gpu_voltage on non-AMD systems + +--- + +# Phase 06 — Appearance, Colors, Typography, Layout Pages + +## Files to implement +- `src/ui/pages/appearance.rs` +- `src/ui/pages/colors.rs` +- `src/ui/pages/typography.rs` +- `src/ui/widgets/color_row.rs` + +## Color Row Widget (color_row.rs) + +The color row is a custom widget used for all color options. + +``` +AdwActionRow { + title: "GPU Color" + subtitle: "gpu_color — hex RRGGBB (no #)" + [suffix] GtkButton (color swatch) ← shows current color as background + [suffix] GtkEntry (6-char hex) ← manual entry +} +``` + +Color swatch button click → opens `AdwDialog`: +``` +AdwDialog "Choose Color" + GtkColorDialogButton ← native GTK4 color chooser + GtkEntry showing hex ← synced with the color chooser + [footer] GtkButton "Reset to Default" + [footer] GtkButton "Cancel" + [footer] GtkButton "Apply" (suggested-action) +``` + +Validation: hex entry must match `^[0-9A-Fa-f]{6}$`. Show error inline. +On Apply: update color swatch background, update state, validate. + +## Colors Page +One `AdwPreferencesGroup` per logical color section: +- "Text & Background" (text_color, background_color, text_outline*) +- "GPU" (gpu_color, gpu_load_color) +- "CPU" (cpu_color, cpu_load_color) +- "Memory" (vram_color, ram_color) +- "Other Components" (engine_color, io_color, frametime_color, etc.) +- "Media & Battery" (media_player_color, battery_color, wine_color, network_color) + +At top of page: `AdwBanner` with "Tip: Colors are 6-digit hex without #. Example: FF0000 for red." + +## Typography Page +- font_size: AdwSpinRow (8–72) +- font_scale: AdwSpinRow (0.1–5.0, 2 decimal digits) +- font_size_text: AdwSpinRow +- font_scale_media_player: AdwSpinRow +- no_small_font: AdwSwitchRow +- font_file: AdwEntryRow + "Browse…" button → GtkFileDialog +- font_file_text: same +- font_glyph_ranges: AdwExpanderRow with checkboxes for each valid range + +## Layout & Position Page +- position: AdwComboRow with visual position preview (simple ASCII art grid in subtitle) +- offset_x, offset_y: AdwSpinRow +- horizontal: AdwSwitchRow (enabling it disables position since horizontal has its own placement) +- horizontal_stretch: AdwSwitchRow (depends on horizontal) +- hud_compact: AdwSwitchRow +- hud_no_margin: AdwSwitchRow +- background_alpha: AdwSpinRow (0.0–1.0) + live preview strip showing the alpha +- alpha: AdwSpinRow +- width, height: AdwSpinRow +- table_columns: AdwSpinRow (1–10) +- cellpadding_y: AdwSpinRow (-2.0–2.0) +- round_corners: AdwSpinRow (0–50) +- preset: AdwComboRow with descriptions for each preset value + +## Acceptance Criteria +- [ ] All color rows show correct color swatches +- [ ] Invalid hex values blocked with inline error +- [ ] File browser for font_file filters to .ttf/.otf +- [ ] font_file path validated to exist on disk +- [ ] horizontal_stretch disables when horizontal is off +- [ ] All layout values saved and round-trip correctly + +--- + +# Phase 07 — Config Conflict Cascade View Page + +## Files to implement +- `src/ui/pages/conflicts.rs` +- `src/ui/widgets/cascade_view.rs` + +## This is the most visually distinctive page in the app. + +### Page Layout +``` +AdwPreferencesPage "Layer Conflicts" + + [top] GtkSearchBar + filter buttons: "All" / "Conflicts Only" / "Shadowed Only" + + [for each layer, ordered highest priority first]: + AdwPreferencesGroup + title: "{layer_label}" e.g. "ENV: $MANGOHUD_CONFIG" or "~/.config/MangoHud/cs2.conf" + header-suffix: GtkBox containing: + - GtkLabel badge (ENV / PER-APP / GLOBAL / APP-LOCAL) with CSS class + - GtkButton "Edit" (hidden for env layers) + - GtkButton "Open Folder" (for file layers) + - GtkButton "Delete Config" (destructive, requires AdwAlertDialog confirm) + + [for each option in this layer]: + AdwActionRow + title: option key (monospace font) + subtitle: option value + [if shadowed by higher layer]: + title gets .option-shadowed CSS class (strikethrough) + suffix: GtkLabel "overridden by {higher_layer_name}" with .dim-label + [if this layer wins over lower layers]: + title: bold + suffix: GtkImage "checkmark" icon + + [bottom if no conflicts detected]: + AdwStatusPage + icon-name: "emblem-ok-symbolic" + title: "No Conflicts" + description: "All config layers are consistent." +``` + +### Filter Logic +- "All": show every layer with all their options +- "Conflicts Only": show only layers that contain conflicting options (hidden = no conflicts) +- "Shadowed Only": show only shadowed options across all layers + +### Empty State (no layers found) +``` +AdwStatusPage + icon-name: "document-open-symbolic" + title: "No Config Files Found" + description: "MangoHud will use compiled defaults.\nCreate a config file to get started." + child: GtkButton "Create Global Config" (suggested-action) +``` + +### Clicking a key in any editable layer +Navigate to the relevant config page with that option highlighted (scroll to it). +Implement by passing a "highlight_key" parameter to page build functions. +The targeted option's row briefly flashes with a CSS animation. + +## Acceptance Criteria +- [ ] All layers shown in correct priority order (highest at top) +- [ ] ENV layers show as non-editable +- [ ] Shadowed options have strikethrough text and "overridden by X" label +- [ ] Winning options are visually distinct (bold) +- [ ] Filter buttons correctly show/hide options +- [ ] Delete config prompts for confirmation +- [ ] Clicking a key in an editable layer navigates to its editor page + +--- + +# Phase 08 — Keybindings, I/O, Network, Media, Battery, Logging, Misc Pages + +## Files to implement +- `src/ui/pages/keybindings.rs` +- `src/ui/pages/io_network.rs` +- `src/ui/pages/media_player.rs` +- `src/ui/pages/battery.rs` +- `src/ui/pages/fps_limits.rs` +- `src/ui/pages/logging.rs` +- `src/ui/pages/blacklist.rs` +- `src/ui/pages/opengl_quirks.rs` +- `src/ui/pages/raw_editor.rs` +- `src/ui/widgets/hotkey_row.rs` + +## Hotkey Row Widget + +Custom widget for capturing keybindings. + +``` +AdwActionRow + title: "Toggle HUD" + subtitle: "toggle_hud" + [suffix] GtkShortcutLabel ← displays current binding e.g. "⇧R + F12" + [suffix] GtkButton "Edit" → opens capture dialog + [suffix] GtkButton "✕" → clears binding (sets to empty = use default) +``` + +Capture dialog: +``` +AdwDialog "Capture Keybind" + AdwStatusPage + icon-name: "input-keyboard-symbolic" + title: "Press a key combination" + description: "Hold modifier keys (Shift, Ctrl, Alt) then press a key" + + [on keypress captured]: + Shows preview: "Shift_R + F12" + GtkButton "Accept" (suggested-action) + GtkButton "Try Again" + GtkButton "Cancel" +``` + +Validation: MangoHud only supports specific modifier+key combinations. +Valid keys: F1–F12 only (MangoHud doesn't accept alphanumeric hotkeys). +If invalid combination captured, show error label and keep "Accept" insensitive. + +## FPS Limits Page + +Special widget for fps_limit (comma-separated list of FPS values): + +``` +AdwPreferencesGroup "FPS Limit Values" + description: "Comma-separated list. 0 = unlimited. Toggle between values with Shift_L+F1." + + [custom widget: FpsChipList] + GtkFlowBox showing current values as removable chips: + [0] [30] [60] [+] + Each chip: GtkLabel + GtkButton "×" + "+" button: opens inline entry to add new value + Validation: each value must be non-negative integer + Values auto-sorted ascending when saved + +AdwPreferencesGroup "FPS Limit Method" + AdwComboRow "Method" (fps_limit_method: early/late/"") + +AdwPreferencesGroup "VSync" + AdwComboRow "Vulkan VSync" (vsync: -1/0/1/2/3 with labels) + AdwComboRow "OpenGL VSync" (gl_vsync with labels) +``` + +## Logging Page + +All logging options with particular attention to: +- `output_folder`: path must be validated as an existing writable directory + Use AdwEntryRow + "Browse…" button → GtkFileDialog in FOLDER mode +- `permit_upload`: when toggled off, also disable `upload_logs` +- `output_file`: free string entry + +## Raw Editor Page + +A `GtkTextView` showing the current config file content as raw text. +- Monospace font +- Syntax highlighting: comments in muted color, keys in accent color, values in text color + (use GtkTextTag for basic highlighting — no external syntax highlighter dep) +- Changes in raw editor update the in-memory AnnotatedConfig via re-parsing on focus-out +- Warning banner at top: "Changes here bypass validation. Errors may prevent MangoHud from loading." +- Show line count and option count in footer + +## Acceptance Criteria +- [ ] Hotkey capture works and validates MangoHud-compatible combinations +- [ ] FPS limit chips can be added and removed +- [ ] Logging output_folder validated as writable directory +- [ ] Raw editor shows current config content +- [ ] Raw editor changes re-parsed on focus-out +- [ ] All page options save correctly + +--- + +# Phase 09 — Test Launcher + +## Files to implement +- `src/launcher/runner.rs` (replace stub) +- `src/ui/pages/overview.rs` (implements the overview/dashboard) +- (Test launcher UI is part of a dedicated page — see docs/design_system.md) + +## runner.rs + +```rust +use tokio::process::{Command, Child}; +use std::path::PathBuf; + +pub struct LaunchConfig { + pub command: String, + pub args: Vec, + pub config_path: PathBuf, + pub show_terminal: bool, +} + +pub struct RunningProcess { + pub child: Child, + pub command: String, + pub pid: u32, +} + +impl Runner { + /// Check if a tool is available on PATH. + pub fn is_available(tool: &str) -> bool + + /// Launch a tool with MANGOHUD=1 and the specified config file. + pub async fn launch(config: LaunchConfig) -> anyhow::Result + + /// Stop a running process (SIGTERM, then SIGKILL after 3s). + pub async fn stop(process: RunningProcess) -> anyhow::Result<()> + + /// Send SIGUSR1 to reload config in a running MangoHud process. + pub async fn reload_config(pid: u32) -> anyhow::Result<()> +} +``` + +Launch environment: +``` +MANGOHUD=1 +MANGOHUD_CONFIGFILE={config_path} +``` + +If `show_terminal=true`: spawn via `xterm -e "{command}"` or detect default terminal +(`$TERM`, then try: `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm` in that order). + +## Overview Page + +Dashboard shown on first launch (app startup default page). +``` +AdwPreferencesPage "Overview" + + [if MangoHud not installed]: + AdwStatusPage + icon-name: "dialog-warning-symbolic" + title: "MangoHud Not Found" + description: "Install MangoHud to use this app.\n\n + Ubuntu/Debian: sudo apt install mangohud\n + Fedora: sudo dnf install mangohud\n + Arch: sudo pacman -S mangohud" + + [if MangoHud installed]: + AdwPreferencesGroup "System Status" + AdwActionRow "MangoHud" [suffix: version label + green checkmark] + AdwActionRow "Display" [suffix: "Wayland" or "X11"] + AdwActionRow "GPU" [suffix: GPU name] + AdwActionRow "Active Config" [suffix: current config path] + + AdwPreferencesGroup "Quick Actions" + AdwButtonRow "Launch vkcube test" → triggers launcher + AdwButtonRow "Open Config Folder" → xdg-open ~/.config/MangoHud/ + AdwButtonRow "View Config Conflicts" → navigate to conflicts page + AdwButtonRow "Reset to Defaults" (destructive — AdwAlertDialog confirm) +``` + +## Acceptance Criteria +- [ ] vkcube launches with MANGOHUD=1 and current config path +- [ ] glxgears launches with MANGOHUD=1 +- [ ] Custom app entry accepts any shell command +- [ ] Running process shown with PID and Stop button +- [ ] Stop sends SIGTERM, escalates to SIGKILL after 3s +- [ ] "Tool not found" toast shown if vkcube/glxgears missing +- [ ] Overview shows correct system info from detect::detect_system() + +--- + +# Phase 10 — Integrations Page + +## Files to implement +- `src/integrations/gamemode.rs` (replace stub) +- `src/integrations/steam.rs` (replace stub) +- `src/integrations/lutris.rs` (replace stub) +- `src/integrations/heroic.rs` (replace stub) +- `src/ui/pages/integrations.rs` (new file, not a stub page) + +## Implementation follows docs/integrations.md exactly. + +## Key implementation notes: + +### Threading +All file I/O and process detection in integrations must run on tokio, not the GTK main thread. +Pattern: +```rust +let (sender, receiver) = glib::MainContext::channel(glib::Priority::DEFAULT); +tokio::spawn(async move { + let status = gamemode::detect().await; + sender.send(status).ok(); +}); +receiver.attach(None, move |status| { + update_ui_with_status(status); + glib::ControlFlow::Break +}); +``` + +### Heroic JSON parsing +Parse `~/.config/heroic/GamesConfig/*.json` using serde_json. +Handle both native and Flatpak paths (try both, use whichever exists). +When writing back: preserve all fields not managed by MangoTune. +Use `serde_json::Value` for the full document to avoid losing unknown fields. + +### Lutris YAML parsing +Lutris game configs are simple YAML. Do NOT add a full YAML parser dependency. +Instead, use targeted line-by-line parsing: +- Find the `system:` section +- Find or add `mangohud: true/false` under it +- For reading: look for `mangohud: true` line in file + +## Acceptance Criteria +- [ ] GameMode section shows daemon running/stopped status +- [ ] Steam launch option generator produces correct command strings +- [ ] Flatpak Steam detected and generates different command +- [ ] Copy to clipboard button works for generated Steam command +- [ ] Lutris games enumerated from ~/.config/lutris/games/*.yml +- [ ] Enabling MangoHud for Lutris game writes correct YAML +- [ ] Heroic games enumerated from GamesConfig/*.json +- [ ] Enabling MangoHud for Heroic game writes correct JSON (no data loss) +- [ ] All integration sections show "Not installed" gracefully when tool absent + +--- + +# Phase 11 — Polish, Packaging & Final QA + +## Goals +Final quality pass, packaging, and ensuring the app is ready for distribution. + +## Tasks + +### 1. Window state persistence +- Save/restore window width+height via GSettings +- Save/restore last-edited config path +- Save/restore active sidebar page + +### 2. Keyboard shortcuts +Register app-level shortcuts: +- `Ctrl+S` → Save +- `Ctrl+Z` → Undo last change (basic: revert to last saved state) +- `Ctrl+Shift+Z` → Redo +- `Ctrl+R` → Reload config from disk +- `Ctrl+W` → Close (prompts if unsaved changes) +- `Ctrl+,` → Preferences +- `F5` → Refresh system detection + +Show shortcuts in AdwShortcutsWindow (accessible from gear menu). + +### 3. Unsaved changes guard +On window close attempt with unsaved changes: +``` +AdwAlertDialog "Unsaved Changes" + body: "You have unsaved changes to {config_name}. What would you like to do?" + buttons: + "Discard Changes" (destructive) + "Cancel" + "Save" (suggested-action) +``` + +### 4. External config change detection +Use the `notify` crate to watch config files for changes. +If a watched file changes on disk while app is open: +``` +AdwBanner (persistent until dismissed): + "Config file changed externally. Reload to see changes." + [button] "Reload" +``` + +### 5. AppStream metadata +Create `data/com.mangotune.MangoTune.metainfo.xml` following AppStream spec. + +### 6. Install targets (for Makefile / meson) +```makefile +PREFIX ?= /usr +BINDIR = $(PREFIX)/bin +DATADIR = $(PREFIX)/share + +install: + install -Dm755 target/release/mangotune $(DESTDIR)$(BINDIR)/mangotune + install -Dm644 data/com.mangotune.MangoTune.desktop \ + $(DESTDIR)$(DATADIR)/applications/com.mangotune.MangoTune.desktop + install -Dm644 data/com.mangotune.MangoTune.gschema.xml \ + $(DESTDIR)$(DATADIR)/glib-2.0/schemas/com.mangotune.MangoTune.gschema.xml + install -Dm644 data/icons/com.mangotune.MangoTune.svg \ + $(DESTDIR)$(DATADIR)/icons/hicolor/scalable/apps/com.mangotune.MangoTune.svg + glib-compile-schemas $(DESTDIR)$(DATADIR)/glib-2.0/schemas/ +``` + +### 7. README.md for the actual project +Write a user-facing README covering: +- What MangoTune is and why it's better than GOverlay +- Installation (distro packages + build from source) +- How config priority works +- How to use the test launcher +- How to contribute + +### 8. Final QA checklist +- [ ] `cargo clippy -- -D warnings` passes with zero warnings +- [ ] `cargo test` passes all tests +- [ ] App launches cleanly on X11 +- [ ] App launches cleanly on Wayland +- [ ] All 120+ MangoHud options save and load correctly (write a test config, load it, verify) +- [ ] Config with comments round-trips without destroying comments +- [ ] Validation blocks all invalid values across all types +- [ ] Dependency auto-enable works for all dependency pairs in schema +- [ ] Conflict detection finds overlapping options across all layer combinations +- [ ] Heroic integration writes JSON without data loss +- [ ] Lutris integration writes YAML without data loss +- [ ] vkcube + glxgears launch with correct environment +- [ ] App handles MangoHud not installed gracefully (no crash) +- [ ] Window resize / sidebar collapse works correctly +- [ ] All keyboard shortcuts function +- [ ] Unsaved changes guard fires on close +- [ ] External file change detection triggers reload banner +- [ ] About dialog shows correct version +- [ ] .desktop file launches app correctly +- [ ] GSettings schema installs and compiles correctly diff --git a/scripts/build_game_config_db.py b/scripts/build_game_config_db.py new file mode 100644 index 0000000..ff1f69b --- /dev/null +++ b/scripts/build_game_config_db.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +OUTPUT = ROOT / "data" / "game_config_db.toml" + +IGNORE_CANDIDATES = { + "steam_autocloud", + "unitycrashhandler64", + "unitycrashhandler32", + "crashhandler", + "crashreporter", + "launcher", + "launch", + "start", + "start_protected_game", + "eac_launcher", + "eosbootstrapper", + "eossdk-win64-shipping", + "notification_helper", + "steamerrorreporter", + "steam-launch-wrapper", + "proton", + "wine", + "wine64-preloader", + "wine-preloader", + "winetricks", + "dxsetup", + "vc_redist.x64", + "vc_redist.x86", + "game", + "app", + "boot", + "data", + "options", + "quickref", + "language", + "settings", + "config", + "global", + "system", + "resources", + "resource", + "readme", + "desc", + "icon", + "logo", + "catalog", + "test", + "tests", + "steam", + "lang", + "langs", + "level0", + "level1", + "level2", + "level3", + "level4", + "level5", + "level6", + "level7", + "level8", + "level9", + "unityplayer", + "gameassembly", + "baselib", + "il2cpp", + "openimagedenoise", + "epicwebhelper", + "steam_api", + "steam_api64", + "sonynp", + "unirx", + "demilib", + "tbb", + "tbb12", + "tbbmalloc", +} + +IGNORE_PREFIXES = ( + "pakchunk", + "steam_api", + "unitycrashhandler", + "crashreport", + "crashpad", + "openxr", + "fmod", + "lib", +) + +IGNORE_EXACT_CASEFOLD = { + "readme", + "readme.txt", + "eula", + "license", + "version", + "preview", + "developer", + "server", + "console", + "temp", + "meta", + "init", + "core", + "path", + "base", + "keys", + "header", + "music", + "sfx_ui", + "master", + "env", + "static", + "wiki", + "mod", + "fish", + "bees", + "note", + "shine", + "track", + "glyph", + "death", + "prog", + "build", + "run", + "dbdata", + "work", + "menus", + "items", + "skills", + "input", + "bgs", +} + +CURATED_OVERRIDES = { + "730": { + "title": "Counter-Strike 2", + "aliases": ["cs2", "counter strike 2", "counter-strike 2", "csgo"], + "candidates": ["cs2", "csgo", "wine-cs2"], + "preferred": "cs2", + "verification": "verified", + }, + "570": { + "title": "Dota 2", + "aliases": ["dota2", "dota"], + "candidates": ["dota", "wine-dota2"], + "preferred": "dota", + "verification": "verified", + }, + "440": { + "title": "Team Fortress 2", + "aliases": ["tf2", "team fortress 2"], + "candidates": ["tf", "wine-tf2"], + "preferred": "tf", + "verification": "verified", + }, + "620": { + "title": "Portal 2", + "aliases": ["portal2", "portal 2"], + "candidates": ["portal2", "wine-portal2"], + "preferred": "portal2", + "verification": "verified", + }, + "892970": { + "title": "Valheim", + "aliases": ["valheim"], + "candidates": ["valheim", "wine-valheim"], + "preferred": "valheim", + "verification": "verified", + }, + "238960": { + "title": "Path of Exile", + "aliases": ["path of exile", "poe"], + "candidates": ["PathOfExileSteam", "wine-PathOfExileSteam"], + "preferred": "PathOfExileSteam", + "verification": "verified", + }, + "105600": { + "title": "Terraria", + "aliases": ["terraria"], + "candidates": ["Terraria", "wine-Terraria"], + "preferred": "Terraria", + "verification": "verified", + }, + "1145360": { + "title": "Hades", + "aliases": ["hades"], + "candidates": ["Hades", "wine-Hades"], + "preferred": "Hades", + "verification": "verified", + }, + "212680": { + "title": "FTL: Faster Than Light", + "aliases": ["ftl", "faster than light"], + "candidates": ["FTL", "wine-FTLGame"], + "preferred": "FTL", + "verification": "verified", + }, + "283160": { + "title": "House of the Dying Sun", + "aliases": ["house of the dying sun", "dyingsun"], + "candidates": ["dyingsun", "wine-dyingsun"], + "preferred": "dyingsun", + "verification": "verified", + }, + "1794680": { + "title": "Vampire Survivors", + "aliases": ["vampire survivors"], + "candidates": ["VampireSurvivors", "wine-VampireSurvivors"], + "preferred": "VampireSurvivors", + "verification": "verified", + }, + "1091500": { + "title": "Cyberpunk 2077", + "aliases": ["cyberpunk", "cyberpunk 2077"], + "candidates": ["Cyberpunk2077", "wine-Cyberpunk2077"], + "preferred": "Cyberpunk2077", + "verification": "verified", + }, + "305620": { + "title": "The Long Dark", + "aliases": ["the long dark", "long dark"], + "candidates": ["tld", "wine-tld"], + "preferred": "tld", + "verification": "verified", + }, +} + + +def normalize(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "", text.lower()) + + +def parse_library_paths() -> list[Path]: + candidates = [ + Path.home() / ".local/share/Steam/steamapps/libraryfolders.vdf", + Path.home() / ".steam/steam/steamapps/libraryfolders.vdf", + ] + for candidate in candidates: + if candidate.exists(): + text = candidate.read_text(errors="ignore") + paths = [ + Path(match.group(1).replace("\\\\", "/")) + for match in re.finditer(r'"path"\s*\t\t"([^"]+)"', text) + ] + return list(dict.fromkeys(paths)) + return [] + + +def parse_manifest(path: Path) -> dict[str, str] | None: + text = path.read_text(errors="ignore") + appid = re.search(r'"appid"\s*\t+"(\d+)"', text) + name = re.search(r'"name"\s*\t+"([^"]+)"', text) + installdir = re.search(r'"installdir"\s*\t+"([^"]+)"', text) + if not appid or not name or not installdir: + return None + return { + "appid": appid.group(1), + "title": name.group(1), + "installdir": installdir.group(1), + } + + +def candidate_score(candidate: str, title: str, installdir: str) -> tuple[int, int]: + norm_candidate = normalize(candidate) + norm_title = normalize(title) + norm_installdir = normalize(installdir) + score = 0 + if norm_candidate == norm_installdir: + score += 100 + if norm_candidate == norm_title: + score += 90 + if len(norm_candidate) >= 4 and (norm_candidate in norm_title or norm_title in norm_candidate): + score += 50 + if len(norm_candidate) >= 4 and ( + norm_candidate in norm_installdir or norm_installdir in norm_candidate + ): + score += 35 + if candidate.lower().endswith((".sh", ".exe")): + score -= 10 + return (score, -len(candidate)) + + +def is_noise_candidate(stem: str) -> bool: + lowered = stem.casefold() + norm = normalize(stem) + if not norm or norm in IGNORE_CANDIDATES: + return True + if lowered in IGNORE_EXACT_CASEFOLD: + return True + if any(norm.startswith(prefix) for prefix in IGNORE_PREFIXES): + return True + if re.fullmatch(r"level\d+", lowered): + return True + if re.fullmatch(r"pakchunk\d+.*", lowered): + return True + if re.fullmatch(r"sfx[_-].*", lowered): + return True + if re.fullmatch(r"audiogroup\d+", lowered): + return True + if re.fullmatch(r"steamworks(_x64)?", lowered): + return True + return False + + +def collect_candidates(install_dir: Path, title: str, installdir: str) -> list[str]: + if not install_dir.exists(): + return [] + + candidates: list[str] = [] + for path in install_dir.rglob("*"): + if not path.is_file(): + continue + if len(path.relative_to(install_dir).parts) > 4: + continue + suffix = path.suffix.lower() + if suffix not in {".exe", ".x86_64", ".x86", ".sh", ".app", ""} and not os.access(path, os.X_OK): + continue + if suffix == "" and not os.access(path, os.X_OK): + continue + if suffix == ".app" and not os.access(path, os.X_OK): + continue + stem = path.stem if suffix else path.name + if is_noise_candidate(stem): + continue + if stem.lower().startswith(("unins", "setup", "install", "crash")): + continue + candidates.append(stem) + + unique: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + key = candidate.lower() + if key in seen: + continue + seen.add(key) + unique.append(candidate) + + if not unique: + fallback = re.sub(r"[^A-Za-z0-9_-]+", "", installdir) + if fallback: + unique.append(fallback) + + ranked = sorted(unique, key=lambda item: candidate_score(item, title, installdir), reverse=True) + strong = [item for item in ranked if candidate_score(item, title, installdir)[0] > 0] + + if strong: + return strong[:8] + + fallback = re.sub(r"[^A-Za-z0-9_-]+", "", installdir) + if fallback: + return [fallback] + + return ranked[:1] + + +def build_entries() -> list[dict[str, object]]: + manifests: list[Path] = [] + for library in parse_library_paths(): + steamapps = library / "steamapps" + if steamapps.exists(): + manifests.extend(sorted(steamapps.glob("appmanifest_*.acf"))) + + entries_by_appid: dict[str, dict[str, object]] = {} + for manifest in manifests: + parsed = parse_manifest(manifest) + if not parsed: + continue + appid = parsed["appid"] + install_dir = manifest.parent / "common" / parsed["installdir"] + candidates = collect_candidates(install_dir, parsed["title"], parsed["installdir"]) + preferred = candidates[0] if candidates else re.sub(r"[^A-Za-z0-9_-]+", "", parsed["installdir"]) + entries_by_appid[appid] = { + "appid": int(appid), + "title": parsed["title"], + "aliases": [parsed["installdir"]], + "candidates": candidates, + "preferred": preferred, + "verification": "heuristic", + } + + for appid, override in CURATED_OVERRIDES.items(): + base = entries_by_appid.get(appid, { + "appid": int(appid), + "title": override["title"], + "aliases": [], + "candidates": [], + "preferred": override["preferred"], + "verification": override["verification"], + }) + alias_set = {alias for alias in base.get("aliases", [])} + alias_set.update(override.get("aliases", [])) + candidate_list = [] + seen_candidates = set() + for candidate in [*override.get("candidates", []), *base.get("candidates", [])]: + key = candidate.lower() + if key in seen_candidates: + continue + seen_candidates.add(key) + candidate_list.append(candidate) + base.update({ + "title": override["title"], + "aliases": sorted(alias_set), + "candidates": candidate_list, + "preferred": override["preferred"], + "verification": override["verification"], + }) + entries_by_appid[appid] = base + + entries = list(entries_by_appid.values()) + entries.sort(key=lambda entry: entry["title"].lower()) + return entries + + +def toml_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace("\"", "\\\"") + + +def render_toml(entries: list[dict[str, object]]) -> str: + lines = [ + "# MangoTune game/executable hint database", + "# verification = \"verified\" means manually confirmed", + "# verification = \"heuristic\" means derived from local library scan and should be treated as a suggestion", + "", + ] + for entry in entries: + lines.append("[[game]]") + lines.append(f"appid = {entry['appid']}") + lines.append(f"title = \"{toml_escape(entry['title'])}\"") + aliases = ", ".join(f'\"{toml_escape(alias)}\"' for alias in entry.get("aliases", [])) + candidates = ", ".join( + f'\"{toml_escape(candidate)}\"' for candidate in entry.get("candidates", []) + ) + lines.append(f"aliases = [{aliases}]") + lines.append(f"candidates = [{candidates}]") + lines.append(f"preferred = \"{toml_escape(entry['preferred'])}\"") + lines.append(f"verification = \"{entry['verification']}\"") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + entries = build_entries() + OUTPUT.write_text(render_toml(entries)) + print(f"Wrote {len(entries)} entries to {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/mangohud-position-lab.sh b/scripts/mangohud-position-lab.sh new file mode 100755 index 0000000..b0bfb7c --- /dev/null +++ b/scripts/mangohud-position-lab.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + mangohud-position-lab.sh [app] [output_dir] + +Examples: + mangohud-position-lab.sh ~/.config/mangotune/profiles/zz_test_right_sparse_top.conf glxgears + mangohud-position-lab.sh ~/.config/mangotune/profiles/zz_test_right_full_top.conf vkcube /tmp/mh-lab + +Notes: + - This runs MangoHud directly, outside MangoTune's preview pipeline. + - OpenGL uses MANGOHUD_DLSYM=1. + - If xdotool/import are installed and a real X11 session is available, a screenshot + and basic window geometry will be written to the output directory. + - Override DISPLAY/XAUTHORITY/WAIT_SECS/WIDTH/HEIGHT from the environment when needed. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -lt 1 ]]; then + usage + exit 0 +fi + +CONFIG_PATH="$1" +APP="${2:-glxgears}" +OUTPUT_DIR="${3:-/tmp/mangohud-position-lab}" +DISPLAY_VALUE="${DISPLAY:-:0}" +XAUTH_VALUE="${XAUTHORITY:-$HOME/.Xauthority}" +WAIT_SECS="${WAIT_SECS:-4}" +WIDTH="${WIDTH:-1400}" +HEIGHT="${HEIGHT:-760}" + +mkdir -p "$OUTPUT_DIR" +rm -f "$OUTPUT_DIR"/run.log "$OUTPUT_DIR"/window.geom "$OUTPUT_DIR"/window.png \ + "$OUTPUT_DIR"/screen.png "$OUTPUT_DIR"/window_from_screen.png + +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "Config not found: $CONFIG_PATH" >&2 + exit 2 +fi + +cleanup() { + if [[ -n "${PID:-}" ]]; then + kill "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true + fi +} + +trap cleanup EXIT + +case "$APP" in + glxgears) + MATCH_NAME="glxgears" + DISPLAY="$DISPLAY_VALUE" \ + XAUTHORITY="$XAUTH_VALUE" \ + MANGOHUD_CONFIGFILE="$CONFIG_PATH" \ + MANGOHUD_DLSYM=1 \ + mangohud glxgears -geometry "${WIDTH}x${HEIGHT}" >"$OUTPUT_DIR/run.log" 2>&1 & + ;; + vkcube) + MATCH_NAME="Vkcube" + DISPLAY="$DISPLAY_VALUE" \ + XAUTHORITY="$XAUTH_VALUE" \ + MANGOHUD_CONFIGFILE="$CONFIG_PATH" \ + WGPU_BACKEND="${WGPU_BACKEND:-gl}" \ + mangohud vkcube --width "$WIDTH" --height "$HEIGHT" >"$OUTPUT_DIR/run.log" 2>&1 & + ;; + *) + echo "Unsupported app: $APP" >&2 + exit 2 + ;; +esac + +PID=$! +echo "pid=$PID" >>"$OUTPUT_DIR/run.log" +printf 'display=%s\nxauthority=%s\nwidth=%s\nheight=%s\n' \ + "$DISPLAY_VALUE" "$XAUTH_VALUE" "$WIDTH" "$HEIGHT" >>"$OUTPUT_DIR/run.log" +sleep "$WAIT_SECS" + +if command -v xwininfo >/dev/null 2>&1; then + DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" \ + xwininfo -root -tree >"$OUTPUT_DIR/xwininfo.tree" 2>/dev/null || true +fi + +if command -v xdotool >/dev/null 2>&1; then + WINDOW_ID="$(DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" xdotool search --name "$MATCH_NAME" 2>/dev/null | tail -n1 || true)" +else + WINDOW_ID="" +fi + +if [[ -n "$WINDOW_ID" ]]; then + echo "window_id=$WINDOW_ID" >>"$OUTPUT_DIR/run.log" + DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" \ + xdotool getwindowgeometry --shell "$WINDOW_ID" >"$OUTPUT_DIR/window.geom" 2>/dev/null || true + if command -v import >/dev/null 2>&1; then + DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" \ + import -window "$WINDOW_ID" "$OUTPUT_DIR/window.png" 2>/dev/null || true + DISPLAY="$DISPLAY_VALUE" XAUTHORITY="$XAUTH_VALUE" \ + import -window root "$OUTPUT_DIR/screen.png" 2>/dev/null || true + if [[ -f "$OUTPUT_DIR/window.geom" ]] && [[ -f "$OUTPUT_DIR/screen.png" ]] && command -v convert >/dev/null 2>&1; then + # Crop the full-screen capture back down to the app window bounds. This preserves overlays + # when the compositor draws them separately from the client window content. + # shellcheck disable=SC1090 + . "$OUTPUT_DIR/window.geom" + if [[ -n "${WIDTH:-}" && -n "${HEIGHT:-}" && -n "${X:-}" && -n "${Y:-}" ]]; then + convert "$OUTPUT_DIR/screen.png" -crop "${WIDTH}x${HEIGHT}+${X}+${Y}" +repage \ + "$OUTPUT_DIR/window_from_screen.png" 2>/dev/null || true + fi + fi + fi +fi + +echo "Wrote artifacts to $OUTPUT_DIR" diff --git a/scripts/mangohud-position-matrix.sh b/scripts/mangohud-position-matrix.sh new file mode 100755 index 0000000..490ad30 --- /dev/null +++ b/scripts/mangohud-position-matrix.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + mangohud-position-matrix.sh [app] [output_dir] [profile_dir] + +Examples: + mangohud-position-matrix.sh vkcube /tmp/mh-matrix + DISPLAY=:1 XAUTHORITY=/root/.Xauthority mangohud-position-matrix.sh glxgears /tmp/mh-matrix /home/aaron/mangotune-test-profiles + +What it does: + - finds the standard MangoTune right-alignment test profiles + - generates margin-on and margin-off variants + - runs MangoHud directly for each case using mangohud-position-lab.sh + - stores generated configs and capture artifacts under the output dir +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +APP="${1:-vkcube}" +OUTPUT_DIR="${2:-/tmp/mangohud-position-matrix}" +PROFILE_DIR="${3:-$HOME/.config/mangotune/profiles}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LAB_SCRIPT="$SCRIPT_DIR/mangohud-position-lab.sh" + +if [[ ! -x "$LAB_SCRIPT" ]]; then + chmod +x "$LAB_SCRIPT" 2>/dev/null || true +fi + +mkdir -p "$OUTPUT_DIR/generated" + +profiles=( + "zz_test_right_full_top" + "zz_test_right_full_middle" + "zz_test_right_sparse_top" + "zz_test_right_sparse_middle" + "zz_test_right_sparse_compact_top" + "zz_test_right_sparse_compact_middle" +) + +ensure_flag_state() { + local input_file="$1" + local output_file="$2" + local flag="$3" + local enabled="$4" + awk -v flag="$flag" -v enabled="$enabled" ' + BEGIN { saw = 0 } + { + trimmed = $0 + gsub(/^[[:space:]]+/, "", trimmed) + if (trimmed == flag || trimmed == "# " flag || trimmed == "#" flag) { + if (enabled == "1") { + print flag + } else { + print "# " flag + } + saw = 1 + next + } + print $0 + } + END { + if (!saw && enabled == "1") { + print flag + } + } + ' "$input_file" >"$output_file" +} + +for profile in "${profiles[@]}"; do + base="$PROFILE_DIR/$profile.conf" + [[ -f "$base" ]] || continue + + for margin_state in margin_on margin_off; do + generated="$OUTPUT_DIR/generated/${profile}_${margin_state}.conf" + case "$margin_state" in + margin_on) + ensure_flag_state "$base" "$generated" "hud_no_margin" 0 + ;; + margin_off) + ensure_flag_state "$base" "$generated" "hud_no_margin" 1 + ;; + esac + + case_out="$OUTPUT_DIR/${profile}_${margin_state}_${APP}" + "$LAB_SCRIPT" "$generated" "$APP" "$case_out" + done +done + +echo "Matrix artifacts written to $OUTPUT_DIR" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..48ecb1c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,56 @@ +use crate::window::MainWindow; +use gtk4::gdk; +use gtk4::prelude::*; +use mangotune::system::detect; + +pub struct MangoTuneApp { + app: libadwaita::Application, +} + +impl MangoTuneApp { + pub fn new() -> Self { + let app = libadwaita::Application::builder() + .application_id("com.mangotune.MangoTune") + .flags(gio::ApplicationFlags::FLAGS_NONE) + .build(); + + app.set_accels_for_action("win.save", &["s"]); + app.set_accels_for_action("win.undo", &["z"]); + app.set_accels_for_action("win.redo", &["z"]); + app.set_accels_for_action("win.reload-config", &["r"]); + app.set_accels_for_action("win.close-window", &["w"]); + app.set_accels_for_action("win.refresh-detection", &["F5"]); + + app.connect_startup(|_| { + let provider = gtk4::CssProvider::new(); + provider.load_from_string(include_str!("../data/style.css")); + if let Some(display) = gdk::Display::default() { + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + }); + + app.connect_activate(move |app| { + if let Some(window) = app.active_window() { + window.present(); + return; + } + + let system_info = glib::MainContext::default() + .block_on(async { detect::detect_system().await }) + .unwrap_or_else(|_| detect::SystemInfo::unknown()); + + let window = MainWindow::new(app, system_info); + window.present(); + }); + + Self { app } + } + + pub fn run(&self) -> i32 { + self.app.run().into() + } +} diff --git a/src/bin/mangotune-preview/main.rs b/src/bin/mangotune-preview/main.rs new file mode 100644 index 0000000..458394e --- /dev/null +++ b/src/bin/mangotune-preview/main.rs @@ -0,0 +1,162 @@ +mod renderer; +mod scene; +mod socket_api; +mod workload; + +use anyhow::Result; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Copy)] +pub enum PreviewScenePreset { + DarkArena, + BrightWash, + MotionStress, + StaticInspection, + NoiseField, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreviewPresentMode { + Fifo, + Immediate, +} + +impl PreviewPresentMode { + fn from_env() -> Self { + let enabled = std::env::var("MANGOTUNE_PREVIEW_VSYNC") + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false); + if enabled { + Self::Fifo + } else { + Self::Immediate + } + } +} + +impl PreviewScenePreset { + pub fn from_label(value: &str) -> Option { + match value { + "dark-arena" | "dark" => Some(Self::DarkArena), + "bright-wash" | "bright" => Some(Self::BrightWash), + "static-inspection" | "readability" => Some(Self::StaticInspection), + "motion-stress" | "motion" => Some(Self::MotionStress), + "noise-field" => Some(Self::NoiseField), + _ => None, + } + } + + fn from_env() -> Self { + std::env::var("MANGOTUNE_PREVIEW_SCENE") + .ok() + .as_deref() + .and_then(Self::from_label) + .unwrap_or(Self::DarkArena) + } +} + +#[derive(Debug, Clone)] +pub struct SimState { + pub gpu_load_target: f32, + pub fps_cap: u32, + pub vram_pressure_mb: u32, + pub particle_count: u32, + pub particle_size: f32, + pub gpu_passes: u32, + pub interaction_steps: u32, + pub present_mode: PreviewPresentMode, + pub paused: bool, + pub should_quit: bool, + pub scene_preset: PreviewScenePreset, +} + +impl Default for SimState { + fn default() -> Self { + let scene_preset = PreviewScenePreset::from_env(); + let gpu_load_target = std::env::var("MANGOTUNE_PREVIEW_LOAD") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0) + .clamp(0.0, 10.0) + / 10.0; + let particle_count = std::env::var("MANGOTUNE_PREVIEW_PARTICLE_COUNT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1_000) + .clamp(100, 500_000); + let vram_pressure_mb = std::env::var("MANGOTUNE_PREVIEW_VRAM_MB") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .clamp(0, 65_536); + let particle_size = std::env::var("MANGOTUNE_PREVIEW_PARTICLE_SIZE") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.03) + .clamp(0.01, 5.0); + let gpu_passes = std::env::var("MANGOTUNE_PREVIEW_GPU_PASSES") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1) + .clamp(1, 64); + let interaction_steps = std::env::var("MANGOTUNE_PREVIEW_INTERACTION_STEPS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .clamp(0, 256); + let fps_cap = std::env::var("MANGOTUNE_PREVIEW_FPS_CAP") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(120); + let present_mode = PreviewPresentMode::from_env(); + + Self { + gpu_load_target, + fps_cap, + vram_pressure_mb, + particle_count, + particle_size, + gpu_passes, + interaction_steps, + present_mode, + paused: false, + should_quit: false, + scene_preset, + } + } +} + +fn main() -> Result<()> { + let mut logger = env_logger::Builder::from_env( + env_logger::Env::default().filter_or("MANGOTUNE_LOG", "warn"), + ); + // Newer NVIDIA/Vulkan stacks may advertise extension present modes that this + // wgpu release does not recognize by name. We still request plain FIFO, so + // these probe-time warnings are just console noise. + logger.filter_module("wgpu_hal::vulkan::conv", log::LevelFilter::Error); + logger.init(); + + log::info!("mangotune-preview starting"); + log::info!("Control socket: {}", socket_api::socket_path()); + log::info!( + "Config file: {}", + std::env::var("MANGOHUD_CONFIGFILE") + .unwrap_or_else(|_| "(not set — MangoHud will use its default)".into()) + ); + + let state = Arc::new(Mutex::new(SimState::default())); + let state_for_socket = Arc::clone(&state); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(socket_api::run(state_for_socket)) + .expect("socket API crashed"); + }); + + renderer::run(state) +} diff --git a/src/bin/mangotune-preview/renderer.rs b/src/bin/mangotune-preview/renderer.rs new file mode 100644 index 0000000..8c26842 --- /dev/null +++ b/src/bin/mangotune-preview/renderer.rs @@ -0,0 +1,549 @@ +use crate::scene::Scene; +use crate::workload::Workload; +use crate::{PreviewPresentMode, PreviewScenePreset, SimState}; +use anyhow::Result; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use winit::{ + dpi::LogicalSize, + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::Window, +}; + +const SHADER_SRC: &str = r#" +struct Camera { + view_proj: mat4x4, + time_pad: vec4, +} +@group(0) @binding(0) var camera: Camera; + +struct VertIn { + @location(0) position: vec3, + @location(1) color: vec4, + @location(2) uv: vec2, +} + +struct VertOut { + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, + @location(2) local_uv: vec2, +} + +@vertex +fn vs_main(in: VertIn) -> VertOut { + var out: VertOut; + out.clip_pos = camera.view_proj * vec4(in.position, 1.0); + out.color = in.color; + out.local_uv = in.uv; + return out; +} + +fn hash3(p: vec3) -> f32 { + var p3 = fract(p * 0.1031); + p3 = p3 + vec3(dot(p3, p3.yzx + vec3(33.33))); + return fract((p3.x + p3.y) * p3.z); +} + +fn value_noise(p: vec3) -> f32 { + let i = floor(p); + let f = fract(p); + let u = f * f * (vec3(3.0) - 2.0 * f); + + return mix( + mix(mix(hash3(i + vec3(0.0, 0.0, 0.0)), hash3(i + vec3(1.0, 0.0, 0.0)), u.x), + mix(hash3(i + vec3(0.0, 1.0, 0.0)), hash3(i + vec3(1.0, 1.0, 0.0)), u.x), u.y), + mix(mix(hash3(i + vec3(0.0, 0.0, 1.0)), hash3(i + vec3(1.0, 0.0, 1.0)), u.x), + mix(hash3(i + vec3(0.0, 1.0, 1.0)), hash3(i + vec3(1.0, 1.0, 1.0)), u.x), u.y), + u.z + ); +} + +@fragment +fn fs_main(in: VertOut) -> @location(0) vec4 { + let uv = in.local_uv; + let r2 = dot(uv, uv); + if (r2 > 1.0) { + discard; + } + + let depth = sqrt(1.0 - r2); + let ray_entry = -depth; + let ray_exit = depth; + let ray_length = 2.0 * depth; + let step_size = ray_length / 64.0; + var density = 0.0; + + for (var step = 0u; step < 64u; step = step + 1u) { + let t = ray_entry + (f32(step) + 0.5) * step_size; + let p = vec3(uv, t); + let time = camera.time_pad.x; + let n1 = value_noise(p * 3.0 + vec3(time * 0.3, 0.0, 0.0)); + let n2 = value_noise(p * 6.0 - vec3(0.0, time * 0.5, 0.0)) * 0.5; + let n3 = value_noise(p * 12.0 + vec3(time * 0.7, time * 0.2, 0.0)) * 0.25; + let sample = (n1 + n2 + n3) / 1.75; + density = density + sample * (1.0 / 64.0); + } + + return vec4(in.color.rgb, clamp(density, 0.0, 1.0)); +} +"#; + +type Mat4 = [f32; 16]; + +fn mat4_identity() -> Mat4 { + [ + 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., + ] +} + +fn mat4_mul(a: &Mat4, b: &Mat4) -> Mat4 { + let mut out = [0f32; 16]; + for row in 0..4 { + for col in 0..4 { + out[col * 4 + row] = (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum(); + } + } + out +} + +fn perspective(fov_y_rad: f32, aspect: f32, near: f32, far: f32) -> Mat4 { + let f = 1.0 / (fov_y_rad * 0.5).tan(); + [ + f / aspect, + 0., + 0., + 0., + 0., + -f, + 0., + 0., + 0., + 0., + far / (near - far), + -1., + 0., + 0., + near * far / (near - far), + 0., + ] +} + +fn look_at(eye: [f32; 3], center: [f32; 3], up: [f32; 3]) -> Mat4 { + let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + let norm = |v: [f32; 3]| { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / l, v[1] / l, v[2] / l] + }; + let cross = |a: [f32; 3], b: [f32; 3]| { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] + }; + let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + + let f = norm(sub(center, eye)); + let s = norm(cross(f, up)); + let u = cross(s, f); + + let mut m = mat4_identity(); + m[0] = s[0]; + m[4] = s[1]; + m[8] = s[2]; + m[1] = u[0]; + m[5] = u[1]; + m[9] = u[2]; + m[2] = -f[0]; + m[6] = -f[1]; + m[10] = -f[2]; + m[12] = -dot(s, eye); + m[13] = -dot(u, eye); + m[14] = dot(f, eye); + m +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct CameraUniform { + view_proj: Mat4, + time_pad: [f32; 4], +} + +struct GpuState { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, + supported_present_modes: Vec, + pipeline: wgpu::RenderPipeline, + camera_buffer: wgpu::Buffer, + camera_bind_group: wgpu::BindGroup, + scene: Scene, + workload: Workload, +} + +impl GpuState { + async fn new(window: Arc) -> Result { + let size = window.inner_size(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + flags: wgpu::InstanceFlags::empty(), + ..Default::default() + }); + + let surface = instance.create_surface(Arc::clone(&window))?; + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .ok_or_else(|| anyhow::anyhow!("No suitable Vulkan adapter found"))?; + + let (device, queue) = adapter + .request_device( + &wgpu::DeviceDescriptor { + label: Some("mangotune_preview"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + }, + None, + ) + .await?; + + let surface_caps = surface.get_capabilities(&adapter); + let format = surface_caps + .formats + .iter() + .find(|f| f.is_srgb()) + .copied() + .unwrap_or(surface_caps.formats[0]); + + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: size.width, + height: size.height, + present_mode: choose_present_mode( + &surface_caps.present_modes, + PreviewPresentMode::from_env(), + ), + alpha_mode: surface_caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &config); + + let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("camera_uniform"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let camera_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("camera_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("camera_bg"), + layout: &camera_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }], + }); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("particle_shader"), + source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()), + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pipeline_layout"), + bind_group_layouts: &[&camera_bind_group_layout], + push_constant_ranges: &[], + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("particle_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: "vs_main", + buffers: &[crate::scene::ParticleVertex::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let scene = Scene::new(&device); + let workload = Workload::new(&device); + + Ok(Self { + surface, + device, + queue, + config, + supported_present_modes: surface_caps.present_modes.clone(), + pipeline, + camera_buffer, + camera_bind_group, + scene, + workload, + }) + } + + fn resize(&mut self, new_size: winit::dpi::PhysicalSize) { + if new_size.width > 0 && new_size.height > 0 { + self.config.width = new_size.width; + self.config.height = new_size.height; + self.surface.configure(&self.device, &self.config); + } + } + + fn apply_runtime_mode(&mut self, state: &SimState) { + let desired = choose_present_mode(&self.supported_present_modes, state.present_mode); + if self.config.present_mode != desired { + self.config.present_mode = desired; + self.surface.configure(&self.device, &self.config); + } + } + + fn render(&mut self, state: &SimState) -> Result<(), wgpu::SurfaceError> { + self.apply_runtime_mode(state); + let aspect = self.config.width as f32 / self.config.height as f32; + let t = self.scene.time; + let eye = match state.scene_preset { + PreviewScenePreset::DarkArena => [t.cos() * 5.8, 1.8, t.sin() * 5.8], + PreviewScenePreset::BrightWash => [t.cos() * 4.6, 1.6, t.sin() * 4.6], + PreviewScenePreset::MotionStress => [ + (t * 0.95).cos() * 6.8, + 2.2 + (t * 0.7).sin() * 0.8, + (t * 0.95).sin() * 6.8, + ], + PreviewScenePreset::StaticInspection => [0.0, 2.4, 7.2], + PreviewScenePreset::NoiseField => [ + (t * 0.45).cos() * 6.2, + 1.0 + (t * 0.25).sin() * 0.5, + (t * 0.6).sin() * 6.2, + ], + }; + let view = look_at(eye, [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + let proj = perspective(std::f32::consts::FRAC_PI_4, aspect, 0.1, 100.0); + let view_proj = mat4_mul(&proj, &view); + self.queue.write_buffer( + &self.camera_buffer, + 0, + bytemuck::bytes_of(&CameraUniform { + view_proj, + time_pad: [self.scene.time, 0.0, 0.0, 0.0], + }), + ); + + let output = self.surface.get_current_texture()?; + let view = output.texture.create_view(&Default::default()); + + let clear = match state.scene_preset { + PreviewScenePreset::DarkArena => wgpu::Color { + r: 0.03, + g: 0.04, + b: 0.09, + a: 1.0, + }, + PreviewScenePreset::BrightWash => wgpu::Color { + r: 0.78, + g: 0.82, + b: 0.82, + a: 1.0, + }, + PreviewScenePreset::MotionStress => wgpu::Color { + r: 0.08, + g: 0.05, + b: 0.11, + a: 1.0, + }, + PreviewScenePreset::StaticInspection => wgpu::Color { + r: 0.10, + g: 0.11, + b: 0.14, + a: 1.0, + }, + PreviewScenePreset::NoiseField => wgpu::Color { + r: 0.04, + g: 0.08, + b: 0.09, + a: 1.0, + }, + }; + + for pass_index in 0..state.gpu_passes { + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame_pass"), + }); + + { + let load_op = if pass_index == 0 { + wgpu::LoadOp::Clear(clear) + } else { + wgpu::LoadOp::Load + }; + + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("particle_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: load_op, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &self.camera_bind_group, &[]); + rpass.set_vertex_buffer(0, self.scene.vertex_buffer.slice(..)); + rpass.draw(0..self.scene.vertex_count(), 0..1); + } + + self.queue.submit(std::iter::once(encoder.finish())); + } + output.present(); + self.workload.update(&self.device, state); + Ok(()) + } +} + +fn choose_present_mode( + supported: &[wgpu::PresentMode], + requested: PreviewPresentMode, +) -> wgpu::PresentMode { + match requested { + PreviewPresentMode::Fifo => supported + .iter() + .copied() + .find(|mode| *mode == wgpu::PresentMode::Fifo) + .unwrap_or(wgpu::PresentMode::Fifo), + PreviewPresentMode::Immediate => supported + .iter() + .copied() + .find(|mode| { + matches!( + mode, + wgpu::PresentMode::Immediate + | wgpu::PresentMode::Mailbox + | wgpu::PresentMode::AutoNoVsync + ) + }) + .unwrap_or(wgpu::PresentMode::Fifo), + } +} + +#[allow(deprecated)] +pub fn run(state: Arc>) -> Result<()> { + let event_loop = EventLoop::new()?; + event_loop.set_control_flow(ControlFlow::Poll); + + let width = std::env::var("MANGOTUNE_PREVIEW_WIDTH") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(800); + let height = std::env::var("MANGOTUNE_PREVIEW_HEIGHT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(600); + + let window = Arc::new( + event_loop.create_window( + Window::default_attributes() + .with_title("MangoTune Preview") + .with_inner_size(LogicalSize::new(width, height)), + )?, + ); + + let mut gpu = pollster::block_on(GpuState::new(Arc::clone(&window)))?; + let mut last_frame = Instant::now(); + + event_loop.run(move |event, elwt| { + { + let sim = state.lock().expect("preview state"); + if sim.should_quit { + elwt.exit(); + return; + } + } + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => { + elwt.exit(); + } + Event::WindowEvent { + event: WindowEvent::Resized(size), + .. + } => { + gpu.resize(size); + } + Event::AboutToWait => { + let now = Instant::now(); + let dt = now.duration_since(last_frame).as_secs_f32().min(0.05); + last_frame = now; + + let sim = state.lock().expect("preview state").clone(); + + gpu.scene.update(dt, sim.particle_size, &sim, &gpu.queue); + match gpu.render(&sim) { + Ok(_) => {} + Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + gpu.resize(window.inner_size()); + } + Err(wgpu::SurfaceError::OutOfMemory) => elwt.exit(), + Err(err) => log::warn!("Surface error: {}", err), + } + } + _ => {} + } + })?; + + let _ = std::fs::remove_file(crate::socket_api::socket_path()); + Ok(()) +} diff --git a/src/bin/mangotune-preview/scene.rs b/src/bin/mangotune-preview/scene.rs new file mode 100644 index 0000000..6e77c3a --- /dev/null +++ b/src/bin/mangotune-preview/scene.rs @@ -0,0 +1,360 @@ +use crate::SimState; +use rayon::prelude::*; + +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct ParticleVertex { + pub position: [f32; 3], + pub color: [f32; 4], + pub uv: [f32; 2], +} + +impl ParticleVertex { + pub fn desc() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: (std::mem::size_of::<[f32; 3]>() + std::mem::size_of::<[f32; 4]>()) + as wgpu::BufferAddress, + shader_location: 2, + format: wgpu::VertexFormat::Float32x2, + }, + ], + } + } +} + +struct Particle { + pos: [f32; 3], + vel: [f32; 3], + phase: f32, +} + +#[derive(Clone, Copy)] +struct SceneProfile { + target_radius_base: f32, + target_pulse: f32, + shell_strength: f32, + swirl: f32, + z_wave: f32, + outer_limit: f32, +} + +impl Particle { + fn random(r: f32, seed: u64) -> Self { + let rng = Lcg::new(seed); + let (x, y, z) = rng.point_in_sphere(r); + Self { + pos: [x, y, z], + vel: [ + rng.range(-0.2, 0.2), + rng.range(-0.2, 0.2), + rng.range(-0.2, 0.2), + ], + phase: rng.range(0.0, std::f32::consts::TAU), + } + } + + fn update_with_profile( + &mut self, + dt: f32, + time: f32, + interaction_steps: u32, + profile: SceneProfile, + ) { + let radius = + (self.pos[0] * self.pos[0] + self.pos[1] * self.pos[1] + self.pos[2] * self.pos[2]) + .sqrt() + .max(0.001); + let target_radius = + profile.target_radius_base + profile.target_pulse * (self.phase + time * 0.15).sin(); + let shell_force = (target_radius - radius) * profile.shell_strength; + let ax = (self.pos[0] / radius) * shell_force; + let ay = (self.pos[1] / radius) * shell_force; + let az = (self.pos[2] / radius) * shell_force; + + let sx = -self.pos[1] * profile.swirl; + let sy = self.pos[0] * profile.swirl; + let sz = (time * 0.35 + self.phase).sin() * profile.z_wave; + + self.vel[0] += (ax + sx) * dt; + self.vel[1] += (ay + sy) * dt; + self.vel[2] += (az + sz) * dt; + + let damp = 0.98f32.powf(dt * 60.0); + self.vel[0] *= damp; + self.vel[1] *= damp; + self.vel[2] *= damp; + + self.pos[0] += self.vel[0] * dt; + self.pos[1] += self.vel[1] * dt; + self.pos[2] += self.vel[2] * dt; + + let radius_after = + (self.pos[0] * self.pos[0] + self.pos[1] * self.pos[1] + self.pos[2] * self.pos[2]) + .sqrt(); + if !(0.6..=profile.outer_limit).contains(&radius_after) { + let dir_x = self.pos[0] / radius_after.max(0.001); + let dir_y = self.pos[1] / radius_after.max(0.001); + let dir_z = self.pos[2] / radius_after.max(0.001); + let reset_radius = profile.target_radius_base + 0.4 * self.phase.sin(); + self.pos[0] = dir_x * reset_radius; + self.pos[1] = dir_y * reset_radius; + self.pos[2] = dir_z * reset_radius; + self.vel = [0.0, 0.0, 0.0]; + } + + for k in 0..interaction_steps { + let hash_input = + (self.pos[0] * 73.1 + self.pos[1] * 157.3 + self.pos[2] * 43.7 + k as f32) + .to_bits(); + let hashed = hash_input.wrapping_mul(2654435761).wrapping_add(k); + let neighbour_x = (hashed as f32 / u32::MAX as f32) * 2.0 - 1.0; + let neighbour_y = ((hashed.wrapping_mul(1664525)) as f32 / u32::MAX as f32) * 2.0 - 1.0; + let neighbour_z = + ((hashed.wrapping_mul(22695477)) as f32 / u32::MAX as f32) * 2.0 - 1.0; + let dx = neighbour_x - self.pos[0]; + let dy = neighbour_y - self.pos[1]; + let dz = neighbour_z - self.pos[2]; + let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(0.01); + self.vel[0] += (dx / dist) * 0.0001; + self.vel[1] += (dy / dist) * 0.0001; + self.vel[2] += (dz / dist) * 0.0001; + } + } + + fn to_vertices(&self, size: f32, time: f32) -> [ParticleVertex; 6] { + let hue = (time * 0.2 + self.phase) % std::f32::consts::TAU; + let color = hsv_to_rgb(hue / std::f32::consts::TAU, 0.8, 1.0); + + let h = size * 0.5; + let corners = [ + ([-h, -h], [-1.0, -1.0]), + ([h, -h], [1.0, -1.0]), + ([h, h], [1.0, 1.0]), + ([-h, -h], [-1.0, -1.0]), + ([h, h], [1.0, 1.0]), + ([-h, h], [-1.0, 1.0]), + ]; + + let mut verts = [ParticleVertex { + position: [0.0; 3], + color, + uv: [0.0; 2], + }; 6]; + for (i, ([cx, cy], uv)) in corners.iter().enumerate() { + verts[i].position = [self.pos[0] + cx, self.pos[1] + cy, self.pos[2]]; + verts[i].uv = *uv; + } + verts + } +} + +const MAX_PARTICLES: usize = 500_000; + +pub struct Scene { + particles: Vec, + vertex_data: Vec, + pub vertex_buffer: wgpu::Buffer, + pub time: f32, +} + +impl Scene { + pub fn new(device: &wgpu::Device) -> Self { + let initial_count = 50_000usize; + let particles: Vec = (0..initial_count) + .map(|i| { + Particle::random( + 3.0, + (i as u64).wrapping_mul(6364136223846793005).wrapping_add(1), + ) + }) + .collect(); + + let vertex_data = vec![ + ParticleVertex { + position: [0.0; 3], + color: [0.0; 4], + uv: [0.0; 2], + }; + MAX_PARTICLES * 6 + ]; + + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("particle_vertices"), + size: (MAX_PARTICLES * 6 * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Self { + particles, + vertex_data, + vertex_buffer, + time: 0.0, + } + } + + pub fn update(&mut self, dt: f32, particle_size: f32, state: &SimState, queue: &wgpu::Queue) { + if state.paused { + return; + } + + self.time += dt; + let target = (state.particle_count as usize).min(MAX_PARTICLES); + + while self.particles.len() < target { + let seed = (self.particles.len() as u64) + .wrapping_mul(6364136223846793005) + .wrapping_add(1); + self.particles.push(Particle::random(3.0, seed)); + } + self.particles.truncate(target); + + let t = self.time; + let scene_steps_bonus = match state.scene_preset { + crate::PreviewScenePreset::DarkArena => 0, + crate::PreviewScenePreset::BrightWash => 0, + crate::PreviewScenePreset::MotionStress => 4, + crate::PreviewScenePreset::StaticInspection => 0, + crate::PreviewScenePreset::NoiseField => 2, + }; + let steps = (state.interaction_steps + scene_steps_bonus).min(256); + let profile = match state.scene_preset { + crate::PreviewScenePreset::DarkArena => SceneProfile { + target_radius_base: 2.2, + target_pulse: 0.25, + shell_strength: 0.40, + swirl: 0.35, + z_wave: 0.08, + outer_limit: 4.0, + }, + crate::PreviewScenePreset::BrightWash => SceneProfile { + target_radius_base: 2.0, + target_pulse: 0.18, + shell_strength: 0.34, + swirl: 0.22, + z_wave: 0.06, + outer_limit: 3.8, + }, + crate::PreviewScenePreset::MotionStress => SceneProfile { + target_radius_base: 2.6, + target_pulse: 0.60, + shell_strength: 0.65, + swirl: 1.15, + z_wave: 0.32, + outer_limit: 4.8, + }, + crate::PreviewScenePreset::StaticInspection => SceneProfile { + target_radius_base: 1.9, + target_pulse: 0.06, + shell_strength: 0.55, + swirl: 0.05, + z_wave: 0.02, + outer_limit: 3.4, + }, + crate::PreviewScenePreset::NoiseField => SceneProfile { + target_radius_base: 3.0, + target_pulse: 0.45, + shell_strength: 0.28, + swirl: 0.65, + z_wave: 0.16, + outer_limit: 5.0, + }, + }; + self.particles.par_iter_mut().for_each(|p| { + p.update_with_profile(dt, t, steps, profile); + }); + let mut vi = 0; + for p in &self.particles { + let verts = p.to_vertices(particle_size, t); + for v in &verts { + self.vertex_data[vi] = *v; + vi += 1; + } + } + + let live_bytes = vi * std::mem::size_of::(); + if live_bytes > 0 { + queue.write_buffer( + &self.vertex_buffer, + 0, + bytemuck::cast_slice(&self.vertex_data[..vi]), + ); + } + } + + pub fn vertex_count(&self) -> u32 { + (self.particles.len() * 6) as u32 + } +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 4] { + let i = (h * 6.0).floor() as i32; + let f = h * 6.0 - i as f32; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + let (r, g, b) = match i % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + }; + [r, g, b, 1.0] +} + +struct Lcg { + state: std::cell::Cell, +} + +impl Lcg { + fn new(seed: u64) -> Self { + Self { + state: std::cell::Cell::new(seed), + } + } + + fn next_u32(&self) -> u32 { + let next = self + .state + .get() + .wrapping_mul(6364136223846793005) + .wrapping_add(1); + self.state.set(next); + (next >> 32) as u32 + } + + fn next_f32(&self) -> f32 { + self.next_u32() as f32 / u32::MAX as f32 + } + + fn range(&self, min: f32, max: f32) -> f32 { + min + (max - min) * self.next_f32() + } + + fn point_in_sphere(&self, radius: f32) -> (f32, f32, f32) { + loop { + let x = self.range(-radius, radius); + let y = self.range(-radius, radius); + let z = self.range(-radius, radius); + if x * x + y * y + z * z <= radius * radius { + return (x, y, z); + } + } + } +} diff --git a/src/bin/mangotune-preview/socket_api.rs b/src/bin/mangotune-preview/socket_api.rs new file mode 100644 index 0000000..f50e8d8 --- /dev/null +++ b/src/bin/mangotune-preview/socket_api.rs @@ -0,0 +1,193 @@ +use crate::SimState; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixListener; + +pub fn socket_path() -> String { + std::env::var("MANGOTUNE_SOCKET").unwrap_or_else(|_| "/tmp/mangotune_preview.sock".into()) +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +enum Command { + SetScene { scene: String }, + SetLoad { gpu_percent: f32 }, + SetFpsCap { fps: u32 }, + SetVsync { enabled: bool }, + SetVramPressure { mb: u32 }, + SetParticleCount { count: u32 }, + SetParticleSize { size: f32 }, + SetGpuPasses { passes: u32 }, + SetInteractionSteps { steps: u32 }, + Pause, + Resume, + GetState, + Quit, +} + +#[derive(Debug, Serialize)] +struct StateSnapshot { + gpu_load_target: f32, + fps_cap: u32, + vsync_enabled: bool, + vram_pressure_mb: u32, + particle_count: u32, + particle_size: f32, + gpu_passes: u32, + interaction_steps: u32, + paused: bool, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum Response { + Ok { ok: bool }, + OkWithState { ok: bool, state: StateSnapshot }, + Err { ok: bool, error: String }, +} + +impl Response { + fn ok() -> Self { + Response::Ok { ok: true } + } + fn err(msg: impl Into) -> Self { + Response::Err { + ok: false, + error: msg.into(), + } + } + fn with_state(state: StateSnapshot) -> Self { + Response::OkWithState { ok: true, state } + } +} + +pub async fn run(state: Arc>) -> Result<()> { + let path = socket_path(); + let _ = tokio::fs::remove_file(&path).await; + + let listener = UnixListener::bind(&path)?; + log::info!("Socket API listening on {}", path); + + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(err) = handle_connection(stream, state).await { + log::warn!("Socket connection error: {}", err); + } + }); + } + Err(err) => { + log::error!("Socket accept error: {}", err); + } + } + } +} + +async fn handle_connection( + stream: tokio::net::UnixStream, + state: Arc>, +) -> Result<()> { + let (read_half, mut write_half) = stream.into_split(); + let reader = BufReader::new(read_half); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + let response = match serde_json::from_str::(&line) { + Ok(cmd) => dispatch(cmd, &state), + Err(err) => Response::err(format!("parse error: {}", err)), + }; + + let mut bytes = serde_json::to_vec(&response)?; + bytes.push(b'\n'); + write_half.write_all(&bytes).await?; + } + + Ok(()) +} + +fn dispatch(cmd: Command, state: &Arc>) -> Response { + let mut sim = match state.lock() { + Ok(guard) => guard, + Err(_) => return Response::err("state lock poisoned"), + }; + + match cmd { + Command::SetScene { scene } => match crate::PreviewScenePreset::from_label(&scene) { + Some(preset) => { + sim.scene_preset = preset; + Response::ok() + } + None => Response::err(format!("unknown scene: {scene}")), + }, + Command::SetLoad { gpu_percent } => { + sim.gpu_load_target = (gpu_percent / 100.0).clamp(0.0, 1.0); + Response::ok() + } + Command::SetFpsCap { fps } => { + sim.fps_cap = fps; + Response::ok() + } + Command::SetVsync { enabled } => { + sim.present_mode = if enabled { + crate::PreviewPresentMode::Fifo + } else { + crate::PreviewPresentMode::Immediate + }; + Response::ok() + } + Command::SetVramPressure { mb } => { + sim.vram_pressure_mb = mb; + Response::ok() + } + Command::SetParticleCount { count } => { + sim.particle_count = count.clamp(100, 500_000); + Response::ok() + } + Command::SetParticleSize { size } => { + sim.particle_size = size.clamp(0.01, 5.0); + log::info!("Particle size -> {:.2}", sim.particle_size); + Response::ok() + } + Command::SetGpuPasses { passes } => { + sim.gpu_passes = passes.clamp(1, 64); + log::info!("GPU passes -> {}", sim.gpu_passes); + Response::ok() + } + Command::SetInteractionSteps { steps } => { + sim.interaction_steps = steps.clamp(0, 256); + Response::ok() + } + Command::Pause => { + sim.paused = true; + Response::ok() + } + Command::Resume => { + sim.paused = false; + Response::ok() + } + Command::GetState => Response::with_state(StateSnapshot { + gpu_load_target: sim.gpu_load_target, + fps_cap: sim.fps_cap, + vsync_enabled: sim.present_mode == crate::PreviewPresentMode::Fifo, + vram_pressure_mb: sim.vram_pressure_mb, + particle_count: sim.particle_count, + particle_size: sim.particle_size, + gpu_passes: sim.gpu_passes, + interaction_steps: sim.interaction_steps, + paused: sim.paused, + }), + Command::Quit => { + sim.should_quit = true; + Response::ok() + } + } +} diff --git a/src/bin/mangotune-preview/workload.rs b/src/bin/mangotune-preview/workload.rs new file mode 100644 index 0000000..077a734 --- /dev/null +++ b/src/bin/mangotune-preview/workload.rs @@ -0,0 +1,101 @@ +use crate::SimState; +use std::time::{Duration, Instant}; + +struct PressureBuffer { + #[allow(dead_code)] + buffer: wgpu::Buffer, + size_bytes: u64, +} + +pub struct Workload { + pressure_buffers: Vec, + current_vram_bytes: u64, + last_frame: Instant, +} + +const CHUNK_SIZE_MB: u64 = 16; +const CHUNK_SIZE_BYTES: u64 = CHUNK_SIZE_MB * 1024 * 1024; +const MAX_ADD_CHUNKS_PER_FRAME: usize = 8; +const MAX_REMOVE_CHUNKS_PER_FRAME: usize = 32; + +impl Workload { + pub fn new(_device: &wgpu::Device) -> Self { + Self { + pressure_buffers: Vec::new(), + current_vram_bytes: 0, + last_frame: Instant::now(), + } + } + + pub fn update(&mut self, device: &wgpu::Device, state: &SimState) { + self.adjust_vram_pressure(device, state.vram_pressure_mb); + self.enforce_fps_cap(state.fps_cap); + } + + fn adjust_vram_pressure(&mut self, device: &wgpu::Device, target_mb: u32) { + let target_bytes = target_mb as u64 * 1024 * 1024; + + let mut added = 0; + while added < MAX_ADD_CHUNKS_PER_FRAME + && self.current_vram_bytes + CHUNK_SIZE_BYTES <= target_bytes + { + self.add_chunk(device); + added += 1; + } + + let mut removed = 0; + while removed < MAX_REMOVE_CHUNKS_PER_FRAME && self.current_vram_bytes > target_bytes { + if let Some(chunk) = self.pressure_buffers.pop() { + self.current_vram_bytes -= chunk.size_bytes; + removed += 1; + } else { + break; + } + } + } + + fn add_chunk(&mut self, device: &wgpu::Device) { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("vram_pressure"), + size: CHUNK_SIZE_BYTES, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: true, + }); + + { + let mut mapped = buffer.slice(..).get_mapped_range_mut(); + for byte in mapped.iter_mut() { + *byte = 0xA5; + } + } + buffer.unmap(); + + self.pressure_buffers.push(PressureBuffer { + buffer, + size_bytes: CHUNK_SIZE_BYTES, + }); + self.current_vram_bytes += CHUNK_SIZE_BYTES; + } + + fn enforce_fps_cap(&mut self, fps_cap: u32) { + if fps_cap == 0 { + self.last_frame = Instant::now(); + return; + } + + let frame_budget = Duration::from_secs_f64(1.0 / fps_cap as f64); + let elapsed = self.last_frame.elapsed(); + + if elapsed < frame_budget { + let remaining = frame_budget - elapsed; + if fps_cap <= 10 && remaining > Duration::from_millis(2) { + std::thread::sleep(remaining - Duration::from_millis(1)); + } + while self.last_frame.elapsed() < frame_budget { + std::hint::spin_loop(); + } + } + + self.last_frame = Instant::now(); + } +} diff --git a/src/bin/preview_probe.rs b/src/bin/preview_probe.rs new file mode 100644 index 0000000..1c21c68 --- /dev/null +++ b/src/bin/preview_probe.rs @@ -0,0 +1,47 @@ +use mangotune::config::parser::Parser; +use mangotune::preview::{PreviewController, PreviewScene, PreviewStudioOptions, StudioScene}; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let profile = args + .next() + .map(PathBuf::from) + .expect("usage: preview_probe [studio-scene] [width] [height] [seconds]"); + let studio_scene = args + .next() + .as_deref() + .and_then(StudioScene::from_label) + .unwrap_or(StudioScene::BrightWash); + let width = args + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1400); + let height = args + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(760); + let seconds = args.next().and_then(|v| v.parse::().ok()).unwrap_or(8); + + let config = Parser::read(&profile)?; + let controller = PreviewController::new(); + let studio = PreviewStudioOptions { + scene: studio_scene, + fps_cap: Some(500), + vsync: false, + vram_pressure_mb: 64, + particle_count: 20_000, + particle_size: 0.25, + gpu_passes: 2, + interaction_steps: 8, + paused: false, + }; + + let pid = controller.start(PreviewScene::Studio, &config, width, height, false, studio)?; + eprintln!("preview pid={pid} profile={}", profile.display()); + thread::sleep(Duration::from_secs(seconds)); + let _ = controller.stop(); + Ok(()) +} diff --git a/src/config/help.rs b/src/config/help.rs new file mode 100644 index 0000000..732974d --- /dev/null +++ b/src/config/help.rs @@ -0,0 +1,506 @@ +use once_cell::sync::Lazy; +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OptionHelp { + pub summary: String, + pub notes: String, + pub option_type: String, + pub default_value: String, +} + +static OPTION_HELP: Lazy> = Lazy::new(|| { + parse_option_help(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/docs/MANGOHUD_OPTION_BEHAVIOR.md" + ))) +}); + +pub fn option_help_for_key(key: &str) -> Option { + OPTION_HELP.get(key).cloned() +} + +pub fn display_title_for_key(key: &str) -> String { + match key { + "af" => "Anisotropic filtering".to_string(), + "alpha" => "HUD alpha".to_string(), + "arch" => "System architecture".to_string(), + "autostart_log" => "Auto-start logging".to_string(), + "background_alpha" => "Background alpha".to_string(), + "background_color" => "Background color".to_string(), + "battery_icon" => "Battery icon".to_string(), + "battery_time" => "Battery time".to_string(), + "battery_watt" => "Battery wattage".to_string(), + "control" => "Control socket".to_string(), + "cpu_custom_temp_sensor" => "Custom CPU temperature sensor".to_string(), + "cpu_load_change" => "Enable CPU load threshold colors".to_string(), + "cpu_load_color" => "CPU load threshold colors".to_string(), + "cpu_load_value" => "CPU load threshold values".to_string(), + "cpu_mhz" => "CPU clock".to_string(), + "cpu_power" => "CPU power".to_string(), + "cpu_stats" => "CPU stats".to_string(), + "cpu_temp" => "CPU temperature".to_string(), + "cpu_text" => "CPU label".to_string(), + "custom_text" => "Custom text".to_string(), + "custom_text_center" => "Centered custom text".to_string(), + "device_battery" => "Device battery sources".to_string(), + "device_battery_icon" => "Device battery icons".to_string(), + "display_server" => "Display server".to_string(), + "dx_api" => "DirectX API".to_string(), + "engine_short_names" => "Short engine names".to_string(), + "engine_version" => "Engine version".to_string(), + "exec_name" => "Executable name".to_string(), + "fcat_overlay_width" => "FCAT overlay width".to_string(), + "flip_efficiency" => "Flip efficiency".to_string(), + "font_file" => "Font file".to_string(), + "font_file_text" => "Font file label".to_string(), + "font_glyph_ranges" => "Font glyph ranges".to_string(), + "font_scale" => "Font scale".to_string(), + "font_scale_media_player" => "Media player font scale".to_string(), + "font_size" => "Font size".to_string(), + "font_size_secondary" => "Secondary font size".to_string(), + "font_size_text" => "Custom text size".to_string(), + "fps" => "FPS counter".to_string(), + "fps_color" => "FPS threshold colors".to_string(), + "fps_color_change" => "Enable FPS threshold colors".to_string(), + "fps_limit" => "FPS limits".to_string(), + "fps_limit_method" => "FPS limit method".to_string(), + "fps_metrics" => "FPS metrics".to_string(), + "fps_only" => "FPS-only mode".to_string(), + "fps_sampling_period" => "FPS sampling period".to_string(), + "fps_text" => "FPS label".to_string(), + "fps_value" => "FPS threshold values".to_string(), + "frame_count" => "Frame count".to_string(), + "frame_timing" => "Frametime graph".to_string(), + "frame_timing_detailed" => "Detailed frametime graph".to_string(), + "frametime" => "Frametime readout".to_string(), + "frametime_color" => "Frametime color".to_string(), + "fsr_steam_sharpness" => "Steam FSR sharpness".to_string(), + "ftrace" => "Ftrace probes".to_string(), + "full" => "Full preset".to_string(), + "gl_bind_framebuffer" => "OpenGL framebuffer binding".to_string(), + "gl_dont_flip" => "OpenGL no-flip mode".to_string(), + "gl_size_query" => "OpenGL size query".to_string(), + "gl_vsync" => "OpenGL VSync".to_string(), + "gpu_core_clock" => "GPU core clock".to_string(), + "gpu_efficiency" => "GPU efficiency".to_string(), + "gpu_fan" => "GPU fan".to_string(), + "gpu_junction_temp" => "GPU hotspot temperature".to_string(), + "gpu_list" => "GPU list".to_string(), + "gpu_load_change" => "Enable GPU load threshold colors".to_string(), + "gpu_load_color" => "GPU load threshold colors".to_string(), + "gpu_load_value" => "GPU load threshold values".to_string(), + "gpu_mem_clock" => "GPU memory clock".to_string(), + "gpu_mem_temp" => "GPU memory temperature".to_string(), + "gpu_name" => "GPU name".to_string(), + "gpu_power" => "GPU power".to_string(), + "gpu_power_limit" => "GPU power limit".to_string(), + "gpu_stats" => "GPU stats".to_string(), + "gpu_temp" => "GPU temperature".to_string(), + "gpu_text" => "GPU labels".to_string(), + "gpu_voltage" => "GPU voltage".to_string(), + "hdr" => "HDR status".to_string(), + "hide_engine_names" => "Hide engine names".to_string(), + "hide_fps_superscript" => "Hide FPS superscript".to_string(), + "hide_fsr_sharpness" => "Hide FSR sharpness".to_string(), + "hud_compact" => "Compact HUD".to_string(), + "hud_no_margin" => "No HUD margin".to_string(), + "horizontal" => "Horizontal layout".to_string(), + "horizontal_separator_color" => "Separator color".to_string(), + "horizontal_stretch" => "Stretch horizontal HUD".to_string(), + "inherit" => "Preset inheritance".to_string(), + "io_read" => "Disk read throughput".to_string(), + "io_write" => "Disk write throughput".to_string(), + "legacy_layout" => "Legacy layout".to_string(), + "help" => "Print MangoHud help".to_string(), + "log_duration" => "Log duration".to_string(), + "log_interval" => "Log interval".to_string(), + "log_versioning" => "Versioned log names".to_string(), + "mangoapp_steam" => "MangoApp Steam mode".to_string(), + "media_player" => "Media player".to_string(), + "media_player_format" => "Media player format".to_string(), + "media_player_name" => "Media player source".to_string(), + "network" => "Network interfaces".to_string(), + "network_color" => "Network color".to_string(), + "no_display" => "Start hidden".to_string(), + "no_small_font" => "Disable small font".to_string(), + "offset_x" => "Horizontal offset".to_string(), + "offset_y" => "Vertical offset".to_string(), + "output_file" => "Output file".to_string(), + "output_folder" => "Output folder".to_string(), + "pci_dev" => "PCI device".to_string(), + "permit_upload" => "Allow uploads".to_string(), + "picmip" => "Mip-map bias".to_string(), + "preset" => "Preset list".to_string(), + "position" => "HUD position".to_string(), + "present_mode" => "Present mode".to_string(), + "proc_vram" => "Process VRAM".to_string(), + "procmem" => "Process memory".to_string(), + "procmem_shared" => "Shared process memory".to_string(), + "procmem_virt" => "Virtual process memory".to_string(), + "ram" => "RAM usage".to_string(), + "ram_temp" => "RAM temperature".to_string(), + "read_cfg" => "Also read config file".to_string(), + "refresh_rate" => "Refresh rate".to_string(), + "reload_cfg" => "Reload config".to_string(), + "reset_fps_metrics" => "Reset FPS metrics".to_string(), + "show_fps_limit" => "Show FPS limit".to_string(), + "table_columns" => "Table columns".to_string(), + "temp_fahrenheit" => "Use Fahrenheit".to_string(), + "text_color" => "Text color".to_string(), + "text_outline" => "Text outline".to_string(), + "text_outline_color" => "Text outline color".to_string(), + "text_outline_thickness" => "Text outline thickness".to_string(), + "time_no_label" => "Hide time label".to_string(), + "time_format" => "Time format".to_string(), + "toggle_fps_limit" => "Cycle FPS limit".to_string(), + "toggle_hud" => "Toggle HUD".to_string(), + "toggle_hud_position" => "Cycle HUD position".to_string(), + "toggle_logging" => "Toggle logging".to_string(), + "toggle_preset" => "Cycle preset".to_string(), + "upload_log" => "Upload log".to_string(), + "upload_logs" => "Upload all logs".to_string(), + "version" => "MangoHud version".to_string(), + "vkbasalt" => "vkBasalt status".to_string(), + "vram" => "VRAM usage".to_string(), + "vulkan_driver" => "Vulkan driver".to_string(), + "vulkan_present_mode" => "Preferred Vulkan present mode".to_string(), + "vsync" => "VSync".to_string(), + _ => default_display_title_for_key(key), + } +} + +pub fn display_summary_for_key(key: &str) -> String { + match key { + "position" => "Choose which edge or corner MangoHud anchors to on screen.".to_string(), + "horizontal" => { + "Lay the HUD out in a single horizontal strip instead of the default stacked layout." + .to_string() + } + "horizontal_stretch" => { + "Keep a horizontal HUD stretched wide instead of shrinking it to fit its contents." + .to_string() + } + "hud_no_margin" => { + "Remove MangoHud's usual edge padding so the HUD hugs the screen border." + .to_string() + } + "hud_compact" => { + "Use a tighter layout with less spacing between labels and values.".to_string() + } + "preset" => { + "Apply one or more MangoHud presets by name or built-in preset number." + .to_string() + } + "width" => { + "Set a fixed HUD width, or leave it at 0 to let MangoHud size it automatically." + .to_string() + } + "offset_x" => { + "Shift the HUD horizontally from its anchor point. Positive offsets also remove the normal edge margin." + .to_string() + } + "offset_y" => { + "Shift the HUD vertically from its anchor point. Positive offsets also remove the normal edge margin." + .to_string() + } + "background_alpha" => { + "Control how opaque the HUD background panel looks behind the text.".to_string() + } + "alpha" => "Control the overall opacity of the entire HUD.".to_string(), + "round_corners" => { + "Round the HUD background corners for a softer panel shape.".to_string() + } + "font_size" => "Set the base HUD text size in pixels.".to_string(), + "font_scale" => { + "Scale the whole HUD up or down without changing each font size separately." + .to_string() + } + "text_outline" => { + "Draw a stroke around text so it stays readable over bright or noisy scenes." + .to_string() + } + "text_outline_thickness" => { + "Control how thick the text outline appears.".to_string() + } + "fps" => "Show the live FPS counter.".to_string(), + "fps_color_change" => { + "Turn on color switching for the FPS readout based on the threshold values and colors below." + .to_string() + } + "fps_value" => { + "Enter comma-separated FPS cutoffs like 30,60. MangoHud matches these numbers against the FPS threshold colors in order." + .to_string() + } + "fps_color" => { + "Enter comma-separated hex colors like FF4D4D,FFD24D,66FF99 to pair with the FPS threshold values." + .to_string() + } + "frametime" => "Show the current frametime readout.".to_string(), + "frame_timing" => "Show the frametime graph.".to_string(), + "frame_timing_detailed" => "Use the more detailed frametime graph style.".to_string(), + "fps_limit" => { + "Define one or more FPS caps that MangoHud can cycle through.".to_string() + } + "fps_limit_method" => { + "Choose how MangoHud applies the FPS limiter.".to_string() + } + "gpu_stats" => { + "Show the main GPU stats block, including usage readouts on MangoHud builds that expose them there." + .to_string() + } + "gpu_load_change" => { + "Turn on color switching for the GPU load readout based on the threshold values and colors below." + .to_string() + } + "gpu_load_value" => { + "Enter comma-separated GPU usage percentages like 50,80. MangoHud matches these numbers against the GPU load threshold colors in order." + .to_string() + } + "gpu_load_color" => { + "Enter comma-separated hex colors like 66FF99,FFD24D,FF4D4D to pair with the GPU load threshold values." + .to_string() + } + "gpu_temp" => "Show GPU temperature.".to_string(), + "gpu_power" => "Show GPU power draw.".to_string(), + "gpu_mem_clock" => "Show GPU memory clock speed.".to_string(), + "gpu_mem_temp" => "Show GPU memory temperature.".to_string(), + "cpu_custom_temp_sensor" => { + "Override which hardware sensor MangoHud uses for CPU temperature.".to_string() + } + "cpu_load_change" => { + "Turn on color switching for the CPU load readout based on the threshold values and colors below." + .to_string() + } + "cpu_load_value" => { + "Enter comma-separated CPU usage percentages like 50,80. MangoHud matches these numbers against the CPU load threshold colors in order." + .to_string() + } + "cpu_load_color" => { + "Enter comma-separated hex colors like 66FF99,FFD24D,FF4D4D to pair with the CPU load threshold values." + .to_string() + } + "cpu_stats" => { + "Show the main CPU stats block, including usage readouts on MangoHud builds that expose them there." + .to_string() + } + "cpu_temp" => "Show CPU temperature.".to_string(), + "cpu_power" => "Show CPU power draw.".to_string(), + "ram" => "Show system RAM usage.".to_string(), + "ram_temp" => "Show RAM temperature when MangoHud can read it.".to_string(), + "vram" => "Show VRAM usage.".to_string(), + "dx_api" => "Show which DirectX API a Wine or Proton title is using.".to_string(), + "network" => "Show network activity for selected interfaces.".to_string(), + "io_read" => "Show disk read throughput.".to_string(), + "io_write" => "Show disk write throughput.".to_string(), + "media_player" => "Show now-playing media information when supported.".to_string(), + "battery" => "Show battery information when available.".to_string(), + "no_display" => { + "Start MangoHud hidden until you toggle it with your HUD keybind.".to_string() + } + "read_cfg" => { + "When using MANGOHUD_CONFIG in the environment, still read the config file too." + .to_string() + } + "inherit" => { + "Use MangoHud's preset inheritance directive when building layered presets." + .to_string() + } + "help" => { + "Print MangoHud's supported parameters and exit instead of showing the overlay." + .to_string() + } + "full" => { + "Turn on most MangoHud stats at once. Good for discovery, but usually too busy for daily use." + .to_string() + } + "fps_only" => { + "Show only the FPS-focused view and suppress most other HUD sections.".to_string() + } + "toggle_hud" => "Show or hide MangoHud while a game is running.".to_string(), + "toggle_hud_position" => { + "Cycle through MangoHud positions while a game is running.".to_string() + } + "toggle_preset" => "Cycle through MangoHud presets in game.".to_string(), + "toggle_fps_limit" => "Cycle between configured FPS caps in game.".to_string(), + "toggle_logging" => "Start or stop MangoHud logging.".to_string(), + "reload_cfg" => "Reload the current MangoHud config without restarting the game.".to_string(), + "upload_log" => "Upload the current MangoHud log.".to_string(), + "upload_logs" => "Upload all recent MangoHud logs.".to_string(), + "font_size_secondary" => { + "Set the smaller secondary text size used by some MangoHud labels.".to_string() + } + "vulkan_present_mode" => { + "Request a specific Vulkan present mode by name when the backend supports it." + .to_string() + } + "fsr_steam_sharpness" => { + "Set Steam FSR sharpness directly when the runtime exposes that control." + .to_string() + } + "hide_engine_names" => { + "Hide engine names even when engine-related readouts are enabled.".to_string() + } + "hide_fps_superscript" => { + "Remove the small FPS superscript styling from the counter.".to_string() + } + "duration" => "Track benchmark duration-style timing data.".to_string(), + _ => option_help_for_key(key) + .map(|help| help.summary) + .unwrap_or_else(|| fallback_summary(key)), + } +} + +fn parse_option_help(markdown: &str) -> HashMap { + let mut out = HashMap::new(); + + for line in markdown.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('|') { + continue; + } + + let cells = trimmed + .split('|') + .map(normalize_cell) + .collect::>(); + if cells.len() < 6 { + continue; + } + + // Leading/trailing separators create empty cells at [0] and [last]. + let key = cells[1].as_str(); + let option_type = cells[2].as_str(); + let default_value = cells[3].as_str(); + let notes = cells[4].as_str(); + + if key.is_empty() + || key.eq_ignore_ascii_case("key") + || key.chars().all(|ch| ch == '-') + || option_type.chars().all(|ch| ch == '-') + { + continue; + } + + out.entry(key.to_string()).or_insert_with(|| { + let normalized_notes = collapse_ws(notes); + OptionHelp { + summary: summarize_notes(key, &normalized_notes), + notes: normalized_notes, + option_type: collapse_ws(option_type), + default_value: collapse_ws(default_value), + } + }); + } + + out +} + +fn normalize_cell(cell: &str) -> String { + let trimmed = cell.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let without_backticks = trimmed.replace('`', ""); + let without_bold = without_backticks.replace("**", ""); + collapse_ws(&without_bold.replace("
", " ")) +} + +fn collapse_ws(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn summarize_notes(key: &str, notes: &str) -> String { + if notes.is_empty() { + return fallback_summary(key); + } + + let summary = notes + .split([';', '.']) + .next() + .map(str::trim) + .unwrap_or_default(); + if summary.len() >= 12 { + summary.to_string() + } else { + notes.to_string() + } +} + +fn fallback_summary(key: &str) -> String { + let text = display_title_for_key(key); + format!("Controls {text}") +} + +fn default_display_title_for_key(key: &str) -> String { + key.split('_') + .filter(|part| !part.is_empty()) + .map(|part| match part { + "fps" => "FPS".to_string(), + "cpu" => "CPU".to_string(), + "gpu" => "GPU".to_string(), + "vram" => "VRAM".to_string(), + "ram" => "RAM".to_string(), + "io" => "I/O".to_string(), + "gl" => "OpenGL".to_string(), + "fsr" => "FSR".to_string(), + "hdr" => "HDR".to_string(), + "vk" => "Vulkan".to_string(), + "api" => "API".to_string(), + other => { + let mut chars = other.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_markdown_table_rows() { + let markdown = r#" +| Key | Type | Default | Notes | +|-----|------|---------|-------| +| `fps` | Flag | present | Show FPS counter | +"#; + let parsed = parse_option_help(markdown); + let fps = parsed.get("fps").expect("fps help"); + assert_eq!(fps.option_type, "Flag"); + assert_eq!(fps.default_value, "present"); + assert_eq!(fps.summary, "Show FPS counter"); + } + + #[test] + fn loads_real_schema_doc() { + let fps = option_help_for_key("fps").expect("help for fps"); + assert!(!fps.notes.is_empty()); + assert!(!fps.option_type.is_empty()); + } + + #[test] + fn display_title_handles_common_technical_terms() { + assert_eq!(display_title_for_key("gpu_mem_clock"), "GPU memory clock"); + assert_eq!( + display_title_for_key("fps_limit_method"), + "FPS limit method" + ); + assert_eq!( + display_title_for_key("toggle_hud_position"), + "Cycle HUD position" + ); + } + + #[test] + fn display_summary_prefers_friendly_ui_copy() { + assert!(display_summary_for_key("full").contains("too busy")); + assert!(display_summary_for_key("position").contains("anchors")); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..e26544d --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,9 @@ +pub mod help; +pub mod normalize; +pub mod parser; +pub mod resolver; +pub mod schema; +pub mod types; +pub mod validator; + +pub use types::*; diff --git a/src/config/normalize.rs b/src/config/normalize.rs new file mode 100644 index 0000000..a37b4e9 --- /dev/null +++ b/src/config/normalize.rs @@ -0,0 +1,179 @@ +use crate::config::parser::Parser; +use crate::config::schema::get_schema_entry; +use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue, OptionType}; + +const INVALID_TOP_LEVEL_KEYS: &[&str] = &["gpu_load", "cpu_load"]; +const LEGACY_OPTION_ALIASES: &[(&str, &str)] = &[ + ("compact", "hud_compact"), + ("stretch", "horizontal_stretch"), +]; + +pub fn normalize_legacy_option_values(config: &mut AnnotatedConfig) -> usize { + let mut changes = 0usize; + + for (legacy_key, canonical_key) in LEGACY_OPTION_ALIASES { + let Some((line_idx, legacy_value)) = config.options.get(*legacy_key).cloned() else { + continue; + }; + + if !config.options.contains_key(*canonical_key) { + Parser::set_value(config, canonical_key, legacy_value); + } + + config.options.shift_remove(*legacy_key); + if let Some(line) = config.lines.get_mut(line_idx) { + *line = ConfigLine::Blank; + } + config.dirty = true; + changes += 1; + } + + for key in INVALID_TOP_LEVEL_KEYS { + if let Some((line_idx, _)) = config.options.shift_remove(*key) { + if let Some(line) = config.lines.get_mut(line_idx) { + *line = ConfigLine::Blank; + } + config.dirty = true; + changes += 1; + } + } + + let keys = config.options.keys().cloned().collect::>(); + + for key in keys { + let Some(schema) = get_schema_entry(&key) else { + continue; + }; + let Some((_, current)) = config.options.get(&key).cloned() else { + continue; + }; + + match (&schema.option_type, current) { + (_, ConfigValue::Value(raw)) if matches!(key.as_str(), "offset_x" | "offset_y") => { + if let Ok(parsed) = raw.trim().parse::() { + if parsed < 0 { + Parser::set_value( + config, + &key, + ConfigValue::Value(parsed.abs().to_string()), + ); + changes += 1; + } + } + } + (OptionType::Flag, ConfigValue::Value(raw)) => { + let normalized = raw.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "1" | "true" | "yes" | "on") { + Parser::set_value(config, &key, ConfigValue::Flag); + changes += 1; + } else if matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") { + Parser::set_value(config, &key, ConfigValue::Disabled); + changes += 1; + } + } + (OptionType::Bool, ConfigValue::Flag) => { + Parser::set_value(config, &key, ConfigValue::Value("1".to_string())); + changes += 1; + } + (OptionType::Bool, ConfigValue::Value(raw)) => { + let normalized = raw.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "true" | "yes" | "on") { + Parser::set_value(config, &key, ConfigValue::Value("1".to_string())); + changes += 1; + } else if matches!(normalized.as_str(), "false" | "no" | "off") { + Parser::set_value(config, &key, ConfigValue::Value("0".to_string())); + changes += 1; + } + } + _ => {} + } + } + + changes +} + +#[cfg(test)] +mod tests { + use super::normalize_legacy_option_values; + use crate::config::parser::Parser; + use crate::config::types::ConfigValue; + + #[test] + fn normalizes_flag_zero_to_disabled() { + let mut config = Parser::parse_str("horizontal_stretch=0\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 1); + assert!(matches!( + config.options.get("horizontal_stretch").map(|item| &item.1), + Some(ConfigValue::Disabled) + )); + } + + #[test] + fn normalizes_flag_one_to_flag() { + let mut config = Parser::parse_str("horizontal_stretch=1\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 1); + assert!(matches!( + config.options.get("horizontal_stretch").map(|item| &item.1), + Some(ConfigValue::Flag) + )); + } + + #[test] + fn strips_invalid_load_keys_from_config() { + let mut config = Parser::parse_str("gpu_load\ncpu_load\nfps\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 2); + assert!(!config.options.contains_key("gpu_load")); + assert!(!config.options.contains_key("cpu_load")); + let serialized = Parser::to_string(&config); + assert!(!serialized.contains("gpu_load")); + assert!(!serialized.contains("cpu_load")); + } + + #[test] + fn normalizes_negative_offsets_to_positive_values() { + let mut config = Parser::parse_str("offset_x=-12\noffset_y=-7\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 2); + assert!(matches!( + config.options.get("offset_x").map(|item| &item.1), + Some(ConfigValue::Value(value)) if value == "12" + )); + assert!(matches!( + config.options.get("offset_y").map(|item| &item.1), + Some(ConfigValue::Value(value)) if value == "7" + )); + } + + #[test] + fn aliases_compact_to_hud_compact() { + let mut config = Parser::parse_str("compact\nfps\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 1); + assert!(!config.options.contains_key("compact")); + assert!(matches!( + config.options.get("hud_compact").map(|item| &item.1), + Some(ConfigValue::Flag) + )); + let serialized = Parser::to_string(&config); + assert!(!serialized.contains("\ncompact\n")); + assert!(serialized.contains("hud_compact")); + } + + #[test] + fn aliases_stretch_to_horizontal_stretch_without_overwriting_existing_key() { + let mut config = Parser::parse_str("stretch=0\nhorizontal_stretch=0\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 2); + assert!(!config.options.contains_key("stretch")); + assert!(matches!( + config.options.get("horizontal_stretch").map(|item| &item.1), + Some(ConfigValue::Disabled) + )); + let serialized = Parser::to_string(&config); + assert!(!serialized.lines().any(|line| line.trim() == "stretch=0")); + assert_eq!(serialized.matches("horizontal_stretch=0").count(), 1); + } +} diff --git a/src/config/parser.rs b/src/config/parser.rs new file mode 100644 index 0000000..aea2587 --- /dev/null +++ b/src/config/parser.rs @@ -0,0 +1,776 @@ +//! MangoHud config file parser and writer. + +use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue}; +use anyhow::{Context, Result}; +use indexmap::IndexMap; +use once_cell::sync::Lazy; +use regex::Regex; +use std::fs; +use std::path::{Path, PathBuf}; + +static KEY_RE: Lazy = + Lazy::new(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").expect("valid key regex")); + +pub struct Parser; + +impl Parser { + pub fn new() -> Self { + Self + } + + /// Parse a config file from disk. + pub fn read(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read config file {}", path.display()))?; + Ok(Self::parse_str(&content, Some(path.to_path_buf()))) + } + + /// Parse config from a string (for env var inline and tests). + pub fn parse_str(content: &str, path: Option) -> AnnotatedConfig { + let mut lines = Vec::new(); + let mut options: IndexMap = IndexMap::new(); + + for raw_line in content.lines() { + let idx = lines.len(); + if raw_line.trim().is_empty() { + lines.push(ConfigLine::Blank); + continue; + } + + if let Some(comment) = raw_line.strip_prefix('#') { + let candidate = comment.trim_start(); + if let Some((key, value)) = parse_option_candidate(candidate) { + let line = ConfigLine::CommentedOption { + key: key.clone(), + value: value.clone(), + raw: raw_line.to_string(), + }; + if let Some((old_idx, _)) = + options.insert(key.clone(), (idx, ConfigValue::Disabled)) + { + if let Some(old_line) = lines.get_mut(old_idx) { + *old_line = ConfigLine::Blank; + } + } + lines.push(line); + } else { + lines.push(ConfigLine::Comment(raw_line.to_string())); + } + continue; + } + + if let Some((key, value)) = parse_option_candidate(raw_line) { + let cfg_value = match value.clone() { + Some(v) => ConfigValue::Value(v), + None => ConfigValue::Flag, + }; + let line = ConfigLine::Option { + key: key.clone(), + value, + raw: raw_line.to_string(), + }; + if let Some((old_idx, _)) = options.insert(key.clone(), (idx, cfg_value.clone())) { + if let Some(old_line) = lines.get_mut(old_idx) { + *old_line = ConfigLine::Blank; + } + } + lines.push(line); + continue; + } + + lines.push(ConfigLine::Comment(raw_line.to_string())); + } + + AnnotatedConfig { + lines, + options, + path, + dirty: false, + } + } + + /// Write an AnnotatedConfig back to disk safely. + pub fn write(config: &AnnotatedConfig) -> Result<()> { + let path = config + .path + .as_ref() + .context("cannot write config without a backing file path")?; + let backup_path = PathBuf::from(format!("{}.mangotune.bak", path.display())); + let tmp_path = PathBuf::from(format!("{}.mangotune.tmp", path.display())); + let content = Self::to_string(config); + debug_log(&format!("parser::write begin path={}", path.display())); + let mut backup_created = false; + + if path.exists() { + if backup_path.exists() { + debug_log(&format!( + "parser::write removing stale backup {}", + backup_path.display() + )); + if let Err(err) = remove_existing_path(&backup_path) { + debug_log(&format!( + "parser::write could not remove stale backup {}: {err}", + backup_path.display() + )); + } + } + debug_log(&format!( + "parser::write copy backup {} -> {}", + path.display(), + backup_path.display() + )); + match fs::copy(path, &backup_path) { + Ok(_) => backup_created = true, + Err(err) => debug_log(&format!( + "parser::write backup skipped for {}: {err}", + backup_path.display() + )), + } + } + + debug_log(&format!("parser::write write temp {}", tmp_path.display())); + let write_res = fs::write(&tmp_path, content) + .with_context(|| format!("failed writing temp config {}", tmp_path.display())) + .and_then(|_| { + debug_log(&format!( + "parser::write rename temp {} -> {}", + tmp_path.display(), + path.display() + )); + fs::rename(&tmp_path, path).with_context(|| { + format!( + "failed to atomically replace config {} with {}", + path.display(), + tmp_path.display() + ) + }) + }); + + if let Err(err) = write_res { + debug_log(&format!("parser::write failure: {err}")); + let _ = fs::remove_file(&tmp_path); + if backup_created && backup_path.exists() { + debug_log(&format!( + "parser::write restoring backup {} -> {}", + backup_path.display(), + path.display() + )); + let _ = fs::copy(&backup_path, path); + } + return Err(err); + } + + debug_log(&format!("parser::write success path={}", path.display())); + Ok(()) + } + + /// Update a specific key's value in the config lines. + pub fn set_value(config: &mut AnnotatedConfig, key: &str, value: ConfigValue) { + if let Some((line_idx, _)) = config.options.get(key).cloned() { + if let Some(line) = config.lines.get_mut(line_idx) { + let prior = extract_prior_value(line); + *line = line_from_value(key, &value, prior); + match &value { + ConfigValue::Absent => { + config.options.shift_remove(key); + } + ConfigValue::Disabled => { + config + .options + .insert(key.to_string(), (line_idx, ConfigValue::Disabled)); + } + ConfigValue::Flag => { + config + .options + .insert(key.to_string(), (line_idx, ConfigValue::Flag)); + } + ConfigValue::Value(v) => { + config + .options + .insert(key.to_string(), (line_idx, ConfigValue::Value(v.clone()))); + } + } + config.dirty = true; + return; + } + } + + match value { + ConfigValue::Absent => { + config.options.shift_remove(key); + } + ConfigValue::Flag | ConfigValue::Value(_) | ConfigValue::Disabled => { + let line = line_from_value(key, &value, None); + let idx = config.lines.len(); + config.lines.push(line); + config.options.insert(key.to_string(), (idx, value)); + config.dirty = true; + } + } + } + + /// Serialize config to a string. + pub fn to_string(config: &AnnotatedConfig) -> String { + let mut out = String::new(); + for line in &config.lines { + match line { + ConfigLine::Comment(raw) => out.push_str(raw), + ConfigLine::Blank => {} + ConfigLine::Option { key, value, .. } => { + out.push_str(key); + if let Some(v) = value { + out.push('='); + out.push_str(v); + } + } + ConfigLine::CommentedOption { key, value, .. } => { + out.push_str("# "); + out.push_str(key); + if let Some(v) = value { + out.push('='); + out.push_str(v); + } + } + } + out.push('\n'); + } + out + } + + pub fn move_option_before( + config: &mut AnnotatedConfig, + moving_key: &str, + anchor_key: &str, + ) -> bool { + move_option_relative(config, moving_key, anchor_key, true) + } + + pub fn move_option_after( + config: &mut AnnotatedConfig, + moving_key: &str, + anchor_key: &str, + ) -> bool { + move_option_relative(config, moving_key, anchor_key, false) + } + + pub fn move_option_group_before( + config: &mut AnnotatedConfig, + moving_keys: &[String], + anchor_keys: &[String], + ) -> bool { + move_option_group_relative(config, moving_keys, anchor_keys, true) + } + + pub fn move_option_group_after( + config: &mut AnnotatedConfig, + moving_keys: &[String], + anchor_keys: &[String], + ) -> bool { + move_option_group_relative(config, moving_keys, anchor_keys, false) + } +} + +fn remove_existing_path(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect existing backup {}", path.display()))?; + if metadata.is_dir() { + fs::remove_dir_all(path) + .with_context(|| format!("failed to remove backup directory {}", path.display()))?; + } else { + fs::remove_file(path) + .with_context(|| format!("failed to remove backup file {}", path.display()))?; + } + Ok(()) +} + +fn debug_log(message: &str) { + crate::debug_log::record(message); +} + +fn move_option_relative( + config: &mut AnnotatedConfig, + moving_key: &str, + anchor_key: &str, + insert_before: bool, +) -> bool { + if moving_key == anchor_key { + return false; + } + let Some((moving_idx, _)) = config.options.get(moving_key).cloned() else { + return false; + }; + let Some((anchor_idx, _)) = config.options.get(anchor_key).cloned() else { + return false; + }; + if moving_idx >= config.lines.len() || anchor_idx >= config.lines.len() { + return false; + } + + let moving_line = config.lines.remove(moving_idx); + let mut insertion_idx = anchor_idx; + if moving_idx < anchor_idx { + insertion_idx = insertion_idx.saturating_sub(1); + } + if !insert_before { + insertion_idx += 1; + } + insertion_idx = insertion_idx.min(config.lines.len()); + config.lines.insert(insertion_idx, moving_line); + rebuild_option_index(config); + config.dirty = true; + true +} + +fn move_option_group_relative( + config: &mut AnnotatedConfig, + moving_keys: &[String], + anchor_keys: &[String], + insert_before: bool, +) -> bool { + use std::collections::HashSet; + + if moving_keys.is_empty() || anchor_keys.is_empty() { + return false; + } + + let moving_set = moving_keys + .iter() + .map(String::as_str) + .collect::>(); + let anchor_set = anchor_keys + .iter() + .map(String::as_str) + .collect::>(); + if !moving_set.is_disjoint(&anchor_set) { + return false; + } + + let mut moving_entries = moving_keys + .iter() + .filter_map(|key| { + config + .options + .get(key) + .cloned() + .map(|(line_idx, _)| (line_idx, key.clone())) + }) + .collect::>(); + let mut anchor_entries = anchor_keys + .iter() + .filter_map(|key| { + config + .options + .get(key) + .cloned() + .map(|(line_idx, _)| (line_idx, key.clone())) + }) + .collect::>(); + + if moving_entries.is_empty() || anchor_entries.is_empty() { + return false; + } + + moving_entries.sort_by_key(|(idx, _)| *idx); + anchor_entries.sort_by_key(|(idx, _)| *idx); + + let moving_indices = moving_entries + .iter() + .map(|(idx, _)| *idx) + .collect::>(); + let moving_lines = moving_indices + .iter() + .map(|idx| config.lines[*idx].clone()) + .collect::>(); + + for idx in moving_indices.iter().rev() { + config.lines.remove(*idx); + } + + let anchor_target_idx = if insert_before { + anchor_entries.first().map(|(idx, _)| *idx).unwrap_or(0) + } else { + anchor_entries.last().map(|(idx, _)| *idx + 1).unwrap_or(0) + }; + let removed_before_anchor = moving_indices + .iter() + .filter(|idx| **idx < anchor_target_idx) + .count(); + let insertion_idx = anchor_target_idx + .saturating_sub(removed_before_anchor) + .min(config.lines.len()); + + for (offset, line) in moving_lines.into_iter().enumerate() { + config.lines.insert(insertion_idx + offset, line); + } + + rebuild_option_index(config); + config.dirty = true; + true +} + +fn rebuild_option_index(config: &mut AnnotatedConfig) { + let mut options: IndexMap = IndexMap::new(); + let mut duplicate_indices = Vec::new(); + for (idx, line) in config.lines.iter().enumerate() { + let Some((key, value)) = option_state_from_line(line) else { + continue; + }; + if let Some((old_idx, _)) = options.insert(key, (idx, value)) { + duplicate_indices.push(old_idx); + } + } + for old_idx in duplicate_indices { + if let Some(old_line) = config.lines.get_mut(old_idx) { + *old_line = ConfigLine::Blank; + } + } + config.options = options; +} + +fn option_state_from_line(line: &ConfigLine) -> Option<(String, ConfigValue)> { + match line { + ConfigLine::Option { key, value, .. } => Some(( + key.clone(), + match value { + Some(v) => ConfigValue::Value(v.clone()), + None => ConfigValue::Flag, + }, + )), + ConfigLine::CommentedOption { key, .. } => Some((key.clone(), ConfigValue::Disabled)), + ConfigLine::Comment(_) | ConfigLine::Blank => None, + } +} + +impl Default for Parser { + fn default() -> Self { + Self::new() + } +} + +fn parse_option_candidate(line: &str) -> Option<(String, Option)> { + if let Some((lhs, rhs)) = line.split_once('=') { + let key = lhs.trim(); + if !KEY_RE.is_match(key) { + return None; + } + let value = rhs.trim().to_string(); + return Some((key.to_string(), Some(value))); + } + + let key = line.trim(); + if KEY_RE.is_match(key) { + return Some((key.to_string(), None)); + } + + None +} + +fn extract_prior_value(line: &ConfigLine) -> Option { + match line { + ConfigLine::Option { value, .. } | ConfigLine::CommentedOption { value, .. } => { + value.clone() + } + ConfigLine::Comment(_) | ConfigLine::Blank => None, + } +} + +fn line_from_value(key: &str, value: &ConfigValue, prior_value: Option) -> ConfigLine { + match value { + ConfigValue::Flag => ConfigLine::Option { + key: key.to_string(), + value: None, + raw: key.to_string(), + }, + ConfigValue::Value(v) => ConfigLine::Option { + key: key.to_string(), + value: Some(v.clone()), + raw: format!("{key}={v}"), + }, + ConfigValue::Disabled if disabled_flag_requires_explicit_zero(key) => ConfigLine::Option { + key: key.to_string(), + value: Some("0".to_string()), + raw: format!("{key}=0"), + }, + ConfigValue::Disabled | ConfigValue::Absent => { + let raw = match &prior_value { + Some(v) if !v.is_empty() => format!("# {key}={v}"), + _ => format!("# {key}"), + }; + ConfigLine::CommentedOption { + key: key.to_string(), + value: prior_value, + raw, + } + } + } +} + +pub fn flag_defaults_to_enabled(key: &str) -> bool { + matches!( + key, + "cpu_stats" + | "fps" + | "frame_timing" + | "frametime" + | "gpu_stats" + | "horizontal_stretch" + | "legacy_layout" + | "text_outline" + ) +} + +fn disabled_flag_requires_explicit_zero(key: &str) -> bool { + flag_defaults_to_enabled(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::schema::get_schema_entry; + use crate::config::schema::MANGOHUD_SCHEMA; + use crate::config::types::{OptionType, ValidationResult}; + use crate::config::validator; + use tempfile::tempdir; + + #[test] + fn parse_all_line_types() { + let content = "# comment\n\nfps=60\nframetime\n# gpu_temp\n# cpu_color=FF0000\n"; + let parsed = Parser::parse_str(content, None); + assert_eq!(parsed.lines.len(), 6); + assert_eq!( + parsed.options.get("fps").map(|v| &v.1), + Some(&ConfigValue::Value("60".into())) + ); + assert_eq!( + parsed.options.get("frametime").map(|v| &v.1), + Some(&ConfigValue::Flag) + ); + assert_eq!( + parsed.options.get("gpu_temp").map(|v| &v.1), + Some(&ConfigValue::Disabled) + ); + assert_eq!( + parsed.options.get("cpu_color").map(|v| &v.1), + Some(&ConfigValue::Disabled) + ); + } + + #[test] + fn round_trip_parse_write_parse_values_match() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("MangoHud.conf"); + let original = "# header\nfps=60\n# gpu_temp\nframetime\n"; + fs::write(&path, original).expect("write fixture"); + + let parsed = Parser::read(&path).expect("read"); + Parser::write(&parsed).expect("write"); + let reparsed = Parser::read(&path).expect("re-read"); + + assert_eq!(parsed.options, reparsed.options); + assert_eq!(parsed.lines, reparsed.lines); + } + + #[test] + fn set_value_updates_existing_key_preserving_surrounding_lines() { + let mut cfg = Parser::parse_str("# top\nfps=60\n# bottom\n", None); + Parser::set_value(&mut cfg, "fps", ConfigValue::Value("120".into())); + let out = Parser::to_string(&cfg); + assert!(out.contains("# top\nfps=120\n# bottom\n")); + } + + #[test] + fn set_value_adds_new_key_at_end() { + let mut cfg = Parser::parse_str("fps=60\n", None); + Parser::set_value(&mut cfg, "gpu_temp", ConfigValue::Flag); + let out = Parser::to_string(&cfg); + assert!(out.ends_with("fps=60\ngpu_temp\n")); + } + + #[test] + fn set_value_disable_key_comments_it_out() { + let mut cfg = Parser::parse_str("gpu_temp\n", None); + Parser::set_value(&mut cfg, "gpu_temp", ConfigValue::Disabled); + let out = Parser::to_string(&cfg); + assert_eq!(out, "# gpu_temp\n"); + } + + #[test] + fn set_value_disable_horizontal_stretch_writes_explicit_zero() { + let mut cfg = Parser::parse_str("horizontal_stretch\n", None); + Parser::set_value(&mut cfg, "horizontal_stretch", ConfigValue::Disabled); + let out = Parser::to_string(&cfg); + assert_eq!(out, "horizontal_stretch=0\n"); + } + + #[test] + fn set_value_disable_default_on_flags_writes_explicit_zero() { + let mut cfg = + Parser::parse_str("fps\nframetime\nframe_timing\ngpu_stats\ncpu_stats\n", None); + Parser::set_value(&mut cfg, "fps", ConfigValue::Disabled); + Parser::set_value(&mut cfg, "frametime", ConfigValue::Disabled); + Parser::set_value(&mut cfg, "frame_timing", ConfigValue::Disabled); + Parser::set_value(&mut cfg, "gpu_stats", ConfigValue::Disabled); + Parser::set_value(&mut cfg, "cpu_stats", ConfigValue::Disabled); + let out = Parser::to_string(&cfg); + assert!(out.contains("fps=0\n")); + assert!(out.contains("frametime=0\n")); + assert!(out.contains("frame_timing=0\n")); + assert!(out.contains("gpu_stats=0\n")); + assert!(out.contains("cpu_stats=0\n")); + } + + #[test] + fn duplicate_key_last_value_wins() { + let cfg = Parser::parse_str("fps=30\nfps=60\n", None); + assert_eq!( + cfg.options.get("fps").map(|v| &v.1), + Some(&ConfigValue::Value("60".into())) + ); + } + + #[test] + fn duplicate_key_does_not_round_trip_old_line() { + let cfg = Parser::parse_str("fps=30\nfps=60\n", None); + assert_eq!(Parser::to_string(&cfg), "\nfps=60\n"); + } + + #[test] + fn trims_leading_and_trailing_value_whitespace() { + let cfg = Parser::parse_str("fps= 60 \n", None); + assert_eq!( + cfg.options.get("fps").map(|v| &v.1), + Some(&ConfigValue::Value("60".into())) + ); + } + + #[test] + fn write_recovers_when_stale_backup_path_is_a_directory() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("MangoHud.conf"); + let backup = dir.path().join("MangoHud.conf.mangotune.bak"); + + fs::write(&path, "fps=60\n").expect("write config"); + fs::create_dir(&backup).expect("create stale backup dir"); + + let parsed = Parser::read(&path).expect("read"); + Parser::write(&parsed).expect("write with stale backup dir"); + + let written = fs::read_to_string(&path).expect("read output"); + assert_eq!(written, "fps=60\n"); + assert!(backup.is_file()); + } + + #[test] + fn move_option_before_reorders_lines() { + let mut cfg = Parser::parse_str("fps\ngpu_stats\ncpu_stats\n", None); + assert!(Parser::move_option_before( + &mut cfg, + "cpu_stats", + "gpu_stats" + )); + assert_eq!(Parser::to_string(&cfg), "fps\ncpu_stats\ngpu_stats\n"); + } + + #[test] + fn move_option_after_reorders_lines() { + let mut cfg = Parser::parse_str("fps\ngpu_stats\ncpu_stats\n", None); + assert!(Parser::move_option_after(&mut cfg, "fps", "gpu_stats")); + assert_eq!(Parser::to_string(&cfg), "gpu_stats\nfps\ncpu_stats\n"); + } + + #[test] + fn utf8_comments_are_preserved() { + let cfg = Parser::parse_str("# Привет мир\nfps=60\n", None); + match &cfg.lines[0] { + ConfigLine::Comment(text) => assert_eq!(text, "# Привет мир"), + _ => panic!("first line should be comment"), + } + } + + #[test] + fn full_schema_representative_round_trip_and_validate() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("MangoHud.conf"); + let mut content = String::from("# generated-by-test\n"); + + for entry in MANGOHUD_SCHEMA.iter() { + if let Some(line) = representative_line_for_entry(entry, dir.path()) { + content.push_str(&line); + content.push('\n'); + } + } + + fs::write(&path, &content).expect("write fixture"); + let parsed = Parser::read(&path).expect("read"); + for (key, (_, value)) in &parsed.options { + if let Some(schema) = get_schema_entry(key) { + let result = validator::validate_value(key, value, schema); + assert!( + !matches!(result, ValidationResult::Error(_)), + "generated value should type-validate for key '{}': {:?}", + key, + result + ); + } + } + + Parser::write(&parsed).expect("write"); + let reparsed = Parser::read(&path).expect("re-read"); + assert_eq!(parsed.options, reparsed.options); + } + + fn representative_line_for_entry( + entry: &crate::config::types::SchemaEntry, + temp_root: &std::path::Path, + ) -> Option { + let value = match entry.key { + "fps_metrics" | "benchmark_percentiles" => ConfigValue::Value("AVG,95".to_string()), + "time_format" => ConfigValue::Value("%H:%M:%S".to_string()), + "pci_dev" => ConfigValue::Value("0000:01:00.0".to_string()), + "ftrace" => ConfigValue::Value("histogram/foo/bar".to_string()), + "control" => ConfigValue::Value("-1".to_string()), + "fps_color" | "gpu_load_color" | "cpu_load_color" => { + ConfigValue::Value("FF4D4D,FFD24D,66FF99".to_string()) + } + _ => match &entry.option_type { + OptionType::Flag => ConfigValue::Flag, + OptionType::Bool => ConfigValue::Value("1".to_string()), + OptionType::Int { min, .. } => ConfigValue::Value(min.to_string()), + OptionType::Float { min, .. } => ConfigValue::Value(min.to_string()), + OptionType::Str { .. } => ConfigValue::Value("sample".to_string()), + OptionType::Color => ConfigValue::Value("A1B2C3".to_string()), + OptionType::Enum { variants } => { + ConfigValue::Value(variants.first().cloned().unwrap_or_default()) + } + OptionType::FpsLimitList => ConfigValue::Value("0,60,120".to_string()), + OptionType::KeyBind => ConfigValue::Value("Shift_R+F12".to_string()), + OptionType::CommaSepInts => ConfigValue::Value("1,2,3".to_string()), + OptionType::CommaSepFloats => ConfigValue::Value("0.5,1.5".to_string()), + OptionType::CommaSepStrings { valid_values } => { + let value = valid_values + .as_ref() + .and_then(|values| values.first().cloned()) + .unwrap_or_else(|| "sample".to_string()); + ConfigValue::Value(value) + } + OptionType::Path { + must_exist, + must_be_writable: _, + } => { + let path = if *must_exist { + temp_root.to_path_buf() + } else { + temp_root.join("generated-path") + }; + ConfigValue::Value(path.display().to_string()) + } + }, + }; + + match value { + ConfigValue::Flag => Some(entry.key.to_string()), + ConfigValue::Value(v) => Some(format!("{}={v}", entry.key)), + ConfigValue::Absent | ConfigValue::Disabled => None, + } + } +} diff --git a/src/config/resolver.rs b/src/config/resolver.rs new file mode 100644 index 0000000..1f40b77 --- /dev/null +++ b/src/config/resolver.rs @@ -0,0 +1,454 @@ +use crate::config::parser::Parser; +use crate::config::types::{AnnotatedConfig, ConfigValue}; +use crate::system::paths::XdgPaths; +use anyhow::{anyhow, Context, Result}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +#[derive(Debug, Clone)] +pub struct ConfigLayer { + pub path: Option, + pub source_type: LayerSource, + pub priority: u8, + pub exists: bool, + pub is_editable: bool, + pub config: Option, +} + +#[derive(Debug, Clone)] +pub enum LayerSource { + CompiledDefault, + GlobalXdg, + PerAppXdg(String), + AppLocal(PathBuf), + EnvFile(PathBuf), + EnvInline(String), +} + +#[derive(Debug, Clone)] +pub struct ConfigConflict { + pub key: String, + pub winning_layer_priority: u8, + pub winning_value: ConfigValue, + pub shadowed: Vec<(u8, ConfigValue)>, +} + +pub struct Resolver; + +impl Resolver { + pub async fn discover(xdg: &XdgPaths) -> Result> { + let mut layers = Vec::new(); + + if let Ok(raw) = env::var("MANGOHUD_CONFIGFILE") { + let path = expand_env_path(raw.trim()); + if path.exists() { + let parsed = Parser::read(&path).ok(); + layers.push(ConfigLayer { + path: Some(path.clone()), + source_type: LayerSource::EnvFile(path), + priority: 5, + exists: true, + is_editable: false, + config: parsed, + }); + } + } + + if let Ok(raw_inline) = env::var("MANGOHUD_CONFIG") { + let inline_text = normalize_env_inline(&raw_inline); + let parsed = Parser::parse_str(&inline_text, None); + layers.push(ConfigLayer { + path: None, + source_type: LayerSource::EnvInline(raw_inline), + priority: 5, + exists: true, + is_editable: false, + config: Some(parsed), + }); + } + + let global_exists = xdg.global_config.exists(); + layers.push(ConfigLayer { + path: Some(xdg.global_config.clone()), + source_type: LayerSource::GlobalXdg, + priority: 2, + exists: global_exists, + is_editable: true, + config: if global_exists { + Parser::read(&xdg.global_config).ok() + } else { + None + }, + }); + + if xdg.mangohud_dir.exists() { + let mut per_app = Vec::new(); + for entry in fs::read_dir(&xdg.mangohud_dir) + .with_context(|| format!("failed to list {}", xdg.mangohud_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("conf") { + continue; + } + if path.file_name().and_then(|f| f.to_str()) == Some("MangoHud.conf") { + continue; + } + if Self::should_ignore_per_app_candidate(&path) { + continue; + } + let app_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + per_app.push((app_name, path)); + } + per_app.sort_by(|a, b| a.0.cmp(&b.0)); + for (app_name, path) in per_app { + layers.push(ConfigLayer { + path: Some(path.clone()), + source_type: LayerSource::PerAppXdg(app_name), + priority: 3, + exists: true, + is_editable: true, + config: Parser::read(&path).ok(), + }); + } + } + + for path in Self::scan_game_dirs().await { + layers.push(ConfigLayer { + path: Some(path.clone()), + source_type: LayerSource::AppLocal(path.clone()), + priority: 4, + exists: true, + is_editable: true, + config: Parser::read(&path).ok(), + }); + } + + layers.push(ConfigLayer { + path: None, + source_type: LayerSource::CompiledDefault, + priority: 1, + exists: true, + is_editable: false, + config: None, + }); + + Ok(layers) + } + + fn should_ignore_per_app_candidate(path: &Path) -> bool { + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + return false; + }; + stem == "MangoHud_backup_mnghdc" + || stem.ends_with("_backup_mnghdc") + || stem.ends_with(".backup") + || stem.ends_with("-backup") + } + + pub fn find_conflicts(layers: &[ConfigLayer]) -> Vec { + let mut per_key: HashMap> = HashMap::new(); + for (idx, layer) in layers.iter().enumerate() { + let Some(config) = &layer.config else { + continue; + }; + for (key, (_, value)) in &config.options { + if matches!(value, ConfigValue::Absent | ConfigValue::Disabled) { + continue; + } + per_key + .entry(key.clone()) + .or_default() + .push((layer.priority, idx, value.clone())); + } + } + + let mut conflicts = Vec::new(); + for (key, mut values) in per_key { + if values.len() < 2 { + continue; + } + values.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); + let winning = values[0].clone(); + let shadowed: Vec<(u8, ConfigValue)> = values + .iter() + .skip(1) + .filter_map(|(prio, _, value)| { + if *value != winning.2 { + Some((*prio, value.clone())) + } else { + None + } + }) + .collect(); + if !shadowed.is_empty() { + conflicts.push(ConfigConflict { + key, + winning_layer_priority: winning.0, + winning_value: winning.2, + shadowed, + }); + } + } + conflicts.sort_by(|a, b| a.key.cmp(&b.key)); + conflicts + } + + pub fn effective_value(key: &str, layers: &[ConfigLayer]) -> ConfigValue { + let mut best: Option<(u8, usize, ConfigValue)> = None; + for (idx, layer) in layers.iter().enumerate() { + let Some(config) = &layer.config else { + continue; + }; + let Some((_, value)) = config.options.get(key) else { + continue; + }; + if matches!(value, ConfigValue::Absent | ConfigValue::Disabled) { + continue; + } + match &best { + Some((prio, best_idx, _)) + if *prio > layer.priority || (*prio == layer.priority && *best_idx > idx) => {} + _ => { + best = Some((layer.priority, idx, value.clone())); + } + } + } + best.map(|(_, _, v)| v).unwrap_or(ConfigValue::Absent) + } + + pub fn layer_label(source: &LayerSource) -> String { + match source { + LayerSource::CompiledDefault => "Built-in defaults".to_string(), + LayerSource::GlobalXdg => "Saved global config".to_string(), + LayerSource::PerAppXdg(app) => format!("Per-app ({app})"), + LayerSource::AppLocal(path) => format!("App-local ({})", path.display()), + LayerSource::EnvFile(path) => format!("ENV file ({})", path.display()), + LayerSource::EnvInline(_) => "ENV inline ($MANGOHUD_CONFIG)".to_string(), + } + } + + pub fn create_per_app_config(app_name: &str, xdg: &XdgPaths) -> Result { + if !is_valid_app_name(app_name) { + return Err(anyhow!( + "invalid app name '{app_name}', allowed: alphanumeric, '-' and '_'" + )); + } + + fs::create_dir_all(&xdg.mangohud_dir).with_context(|| { + format!("failed to create config dir {}", xdg.mangohud_dir.display()) + })?; + let path = xdg.mangohud_dir.join(format!("{app_name}.conf")); + if !path.exists() { + let now = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let header = format!( + "### MangoHud configuration - managed by MangoTune\n### Created: {now}\n### App: {app_name}\n" + ); + fs::write(&path, header) + .with_context(|| format!("failed to write {}", path.display()))?; + } + Ok(path) + } + + async fn scan_game_dirs() -> Vec { + let home = env::var("HOME").unwrap_or_else(|_| "/".to_string()); + let roots = vec![ + PathBuf::from(&home).join(".steam/steam/steamapps/common"), + PathBuf::from(&home).join(".local/share/Steam/steamapps/common"), + PathBuf::from(&home).join("Games"), + PathBuf::from(&home) + .join(".var/app/com.valvesoftware.Steam/data/Steam/steamapps/common"), + ]; + + let mut found = Vec::new(); + for root in roots { + if !root.exists() { + continue; + } + if let Ok(entries) = fs::read_dir(&root) { + for entry in entries.flatten() { + let cfg = entry.path().join("MangoHud.conf"); + if cfg.exists() { + found.push(cfg); + } + } + } + } + found.sort(); + found.dedup(); + found + } +} + +fn expand_env_path(path: &str) -> PathBuf { + if path == "~" { + return PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/".to_string())); + } + if let Some(rest) = path.strip_prefix("~/") { + return PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/".to_string())).join(rest); + } + PathBuf::from(path) +} + +fn normalize_env_inline(raw: &str) -> String { + raw.split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n") +} + +fn is_valid_app_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + use once_cell::sync::Lazy; + use std::sync::Mutex; + use tempfile::tempdir; + + static ENV_TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + + #[tokio::test] + async fn discover_reads_xdg_override() { + let dir = tempdir().expect("tempdir"); + let xdg_root = dir.path().join("xdg"); + let mangohud_dir = xdg_root.join("MangoHud"); + fs::create_dir_all(&mangohud_dir).expect("mkdir"); + fs::write(mangohud_dir.join("MangoHud.conf"), "fps=60\n").expect("write global"); + fs::write(mangohud_dir.join("testgame.conf"), "fps=120\n").expect("write per-app"); + + let paths = XdgPaths { + config_home: xdg_root.clone(), + mangohud_dir: mangohud_dir.clone(), + global_config: mangohud_dir.join("MangoHud.conf"), + data_home: dir.path().join("data"), + }; + let layers = Resolver::discover(&paths).await.expect("discover"); + assert!(layers + .iter() + .any(|layer| matches!(layer.source_type, LayerSource::GlobalXdg))); + assert!(layers.iter().any(|layer| { + matches!(layer.source_type, LayerSource::PerAppXdg(ref app) if app == "testgame") + })); + } + + #[test] + fn discover_reads_env_inline_layer() { + let _guard = ENV_TEST_LOCK.lock().expect("env test lock"); + let dir = tempdir().expect("tempdir"); + let paths = XdgPaths { + config_home: dir.path().join("cfg"), + mangohud_dir: dir.path().join("cfg/MangoHud"), + global_config: dir.path().join("cfg/MangoHud/MangoHud.conf"), + data_home: dir.path().join("data"), + }; + + env::set_var("MANGOHUD_CONFIG", "fps=144,gpu_stats=0"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let layers = runtime + .block_on(Resolver::discover(&paths)) + .expect("discover"); + env::remove_var("MANGOHUD_CONFIG"); + + let env_layer = layers + .iter() + .find(|layer| matches!(layer.source_type, LayerSource::EnvInline(_))) + .expect("env inline layer missing"); + assert_eq!(env_layer.priority, 5); + } + + #[test] + fn finds_conflicts_between_layers() { + let global = ConfigLayer { + path: None, + source_type: LayerSource::GlobalXdg, + priority: 2, + exists: true, + is_editable: true, + config: Some(Parser::parse_str("fps=60\n", None)), + }; + let per_app = ConfigLayer { + path: None, + source_type: LayerSource::PerAppXdg("game".to_string()), + priority: 3, + exists: true, + is_editable: true, + config: Some(Parser::parse_str("fps=120\n", None)), + }; + let conflicts = Resolver::find_conflicts(&[global, per_app]); + assert!(conflicts.iter().any(|conflict| conflict.key == "fps")); + } + + #[test] + fn effective_value_prefers_env_over_file() { + let file_layer = ConfigLayer { + path: None, + source_type: LayerSource::GlobalXdg, + priority: 2, + exists: true, + is_editable: true, + config: Some(Parser::parse_str("fps=60\n", None)), + }; + let env_layer = ConfigLayer { + path: None, + source_type: LayerSource::EnvInline("fps=144".to_string()), + priority: 5, + exists: true, + is_editable: false, + config: Some(Parser::parse_str("fps=144\n", None)), + }; + let value = Resolver::effective_value("fps", &[file_layer, env_layer]); + assert_eq!(value, ConfigValue::Value("144".to_string())); + } + + #[test] + fn ignores_backup_style_per_app_candidates() { + assert!(Resolver::should_ignore_per_app_candidate(&PathBuf::from( + "/tmp/MangoHud_backup_mnghdc.conf" + ))); + assert!(Resolver::should_ignore_per_app_candidate(&PathBuf::from( + "/tmp/game_backup_mnghdc.conf" + ))); + assert!(Resolver::should_ignore_per_app_candidate(&PathBuf::from( + "/tmp/game-backup.conf" + ))); + assert!(!Resolver::should_ignore_per_app_candidate(&PathBuf::from( + "/tmp/My-Default.conf" + ))); + } + + #[test] + fn create_per_app_config_writes_header() { + let dir = tempdir().expect("tempdir"); + let xdg = XdgPaths { + config_home: dir.path().join("cfg"), + mangohud_dir: dir.path().join("cfg/MangoHud"), + global_config: dir.path().join("cfg/MangoHud/MangoHud.conf"), + data_home: dir.path().join("data"), + }; + + let path = Resolver::create_per_app_config("my_game", &xdg).expect("create per app"); + let content = fs::read_to_string(path).expect("read file"); + assert!(content.contains("### MangoHud configuration - managed by MangoTune")); + assert!(content.contains("### App: my_game")); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..69ccde6 --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,1825 @@ +use crate::config::types::{Category, GpuVendor, OptionType, SchemaEntry}; +use once_cell::sync::Lazy; +use std::collections::HashSet; + +const GRAPH_VALUES: &[&str] = &[ + "gpu_load", + "cpu_load", + "gpu_core_clock", + "gpu_mem_clock", + "vram", + "ram", + "cpu_temp", + "gpu_temp", +]; +const GLYPH_VALUES: &[&str] = &[ + "korean", + "chinese", + "chinese_simplified", + "japanese", + "cyrillic", + "thai", + "vietnamese", + "latin_ext_a", + "latin_ext_b", +]; +const DEVICE_BATTERY_VALUES: &[&str] = &["gamepad", "mouse", "controller", "headset"]; + +fn enum_opt(variants: &[&str]) -> OptionType { + OptionType::Enum { + variants: variants.iter().map(|s| (*s).to_string()).collect(), + } +} + +fn csv_opt(valid_values: Option<&[&str]>) -> OptionType { + OptionType::CommaSepStrings { + valid_values: valid_values.map(|vals| vals.iter().map(|v| (*v).to_string()).collect()), + } +} + +fn entry( + key: &'static str, + option_type: OptionType, + category: Category, + dependencies: &'static [&'static str], + conflicts_with: &'static [&'static str], + gpu_vendor_only: GpuVendor, + gamescope_only: bool, +) -> SchemaEntry { + SchemaEntry { + key, + option_type, + description: key, + category, + dependencies, + conflicts_with, + gpu_vendor_only, + gamescope_only, + } +} + +pub static MANGOHUD_SCHEMA: Lazy> = Lazy::new(|| { + vec![ + // Performance + entry( + "fps_limit", + OptionType::FpsLimitList, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fps_limit_method", + enum_opt(&["", "early", "late"]), + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "vsync", + OptionType::Int { min: -1, max: 3 }, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gl_vsync", + OptionType::Int { min: -2, max: 999 }, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "picmip", + OptionType::Int { min: -17, max: 16 }, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "af", + OptionType::Int { min: -1, max: 16 }, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "bicubic", + OptionType::Flag, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "trilinear", + OptionType::Flag, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "retro", + OptionType::Flag, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display FPS + entry( + "fps", + OptionType::Flag, + Category::DisplayFps, + &[], + &["fps_only"], + GpuVendor::Any, + false, + ), + entry( + "fps_only", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fps_sampling_period", + OptionType::Int { + min: 100, + max: 60000, + }, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fps_color_change", + OptionType::Flag, + Category::DisplayFps, + &["fps", "fps_value", "fps_color"], + &[], + GpuVendor::Any, + false, + ), + entry( + "fps_value", + OptionType::CommaSepInts, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fps_color", + csv_opt(None), + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fps_text", + OptionType::Str { max_len: 256 }, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "hide_fps_superscript", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fps_metrics", + csv_opt(None), + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "frametime", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "frame_count", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "frame_timing", + OptionType::Flag, + Category::DisplayFps, + &[], + &["histogram"], + GpuVendor::Any, + false, + ), + entry( + "frame_timing_detailed", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "dynamic_frame_timing", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "histogram", + OptionType::Flag, + Category::DisplayFps, + &[], + &["frame_timing"], + GpuVendor::Any, + false, + ), + entry( + "throttling_status", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "throttling_status_graph", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "show_fps_limit", + OptionType::Flag, + Category::DisplayFps, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display GPU + entry( + "gpu_stats", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_temp", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_junction_temp", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_core_clock", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_mem_temp", + OptionType::Flag, + Category::DisplayGpu, + &["vram"], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_mem_clock", + OptionType::Flag, + Category::DisplayGpu, + &["vram"], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_power", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_power_limit", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_text", + OptionType::Str { max_len: 32 }, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_load_change", + OptionType::Flag, + Category::DisplayGpu, + &["gpu_stats", "gpu_load_value", "gpu_load_color"], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_load_value", + OptionType::CommaSepInts, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_load_color", + csv_opt(None), + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_fan", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_voltage", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::AmdOnly, + false, + ), + entry( + "gpu_list", + OptionType::CommaSepInts, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_efficiency", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_name", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "vulkan_driver", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "engine_version", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "engine_short_names", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "hide_engine_names", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "present_mode", + OptionType::Flag, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "pci_dev", + OptionType::Str { max_len: 32 }, + Category::DisplayGpu, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display CPU + entry( + "cpu_stats", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_temp", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_power", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_custom_temp_sensor", + OptionType::Str { max_len: 128 }, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_text", + OptionType::Str { max_len: 32 }, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_mhz", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_load_change", + OptionType::Flag, + Category::DisplayCpu, + &["cpu_stats", "cpu_load_value", "cpu_load_color"], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_load_value", + OptionType::CommaSepInts, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_load_color", + csv_opt(None), + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_efficiency", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "core_load", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "core_load_change", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "core_bars", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "core_type", + OptionType::Flag, + Category::DisplayCpu, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display Memory + entry( + "vram", + OptionType::Flag, + Category::DisplayMemory, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "ram", + OptionType::Flag, + Category::DisplayMemory, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "ram_temp", + OptionType::Flag, + Category::DisplayMemory, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "swap", + OptionType::Flag, + Category::DisplayMemory, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "procmem", + OptionType::Flag, + Category::DisplayMemory, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "procmem_shared", + OptionType::Flag, + Category::DisplayMemory, + &["procmem"], + &[], + GpuVendor::Any, + false, + ), + entry( + "procmem_virt", + OptionType::Flag, + Category::DisplayMemory, + &["procmem"], + &[], + GpuVendor::Any, + false, + ), + entry( + "proc_vram", + OptionType::Flag, + Category::DisplayMemory, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display IO & network + entry( + "io_read", + OptionType::Flag, + Category::DisplayIoNetwork, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "io_write", + OptionType::Flag, + Category::DisplayIoNetwork, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "network", + csv_opt(None), + Category::DisplayIoNetwork, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display Misc + entry( + "wine", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "winesync", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "exec_name", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "arch", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gamemode", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "vkbasalt", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "version", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "resolution", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "display_server", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "dx_api", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "temp_fahrenheit", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "flip_efficiency", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fex_stats", + OptionType::Flag, + Category::DisplayMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display Graphs + entry( + "graphs", + csv_opt(Some(GRAPH_VALUES)), + Category::DisplayGraphs, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display Battery + entry( + "battery", + OptionType::Flag, + Category::DisplayBattery, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "battery_icon", + OptionType::Flag, + Category::DisplayBattery, + &["battery"], + &[], + GpuVendor::Any, + false, + ), + entry( + "device_battery", + csv_opt(Some(DEVICE_BATTERY_VALUES)), + Category::DisplayBattery, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "device_battery_icon", + OptionType::Flag, + Category::DisplayBattery, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "battery_watt", + OptionType::Flag, + Category::DisplayBattery, + &["battery"], + &[], + GpuVendor::Any, + false, + ), + entry( + "battery_time", + OptionType::Flag, + Category::DisplayBattery, + &["battery"], + &[], + GpuVendor::Any, + false, + ), + // Display Media + entry( + "media_player", + OptionType::Flag, + Category::DisplayMediaPlayer, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "media_player_name", + OptionType::Str { max_len: 256 }, + Category::DisplayMediaPlayer, + &["media_player"], + &[], + GpuVendor::Any, + false, + ), + entry( + "media_player_format", + OptionType::Str { max_len: 256 }, + Category::DisplayMediaPlayer, + &["media_player"], + &[], + GpuVendor::Any, + false, + ), + // Display Gamescope + entry( + "fsr", + OptionType::Flag, + Category::DisplayGamescope, + &[], + &[], + GpuVendor::Any, + true, + ), + entry( + "hide_fsr_sharpness", + OptionType::Flag, + Category::DisplayGamescope, + &["fsr"], + &[], + GpuVendor::Any, + true, + ), + entry( + "fsr_steam_sharpness", + OptionType::Float { + min: -1.0, + max: 20.0, + }, + Category::DisplayGamescope, + &[], + &[], + GpuVendor::Any, + true, + ), + entry( + "hdr", + OptionType::Flag, + Category::DisplayGamescope, + &[], + &[], + GpuVendor::Any, + true, + ), + entry( + "refresh_rate", + OptionType::Flag, + Category::DisplayGamescope, + &[], + &[], + GpuVendor::Any, + true, + ), + entry( + "debug", + OptionType::Flag, + Category::DisplayGamescope, + &[], + &[], + GpuVendor::Any, + true, + ), + // Display Steam Deck + entry( + "fan", + OptionType::Flag, + Category::DisplaySteamDeck, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "mangoapp_steam", + OptionType::Flag, + Category::DisplaySteamDeck, + &[], + &[], + GpuVendor::Any, + false, + ), + // Display Time & Text + entry( + "time", + OptionType::Flag, + Category::DisplayTimeText, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "time_no_label", + OptionType::Flag, + Category::DisplayTimeText, + &["time"], + &[], + GpuVendor::Any, + false, + ), + entry( + "time_format", + OptionType::Str { max_len: 64 }, + Category::DisplayTimeText, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "custom_text_center", + OptionType::Str { max_len: 512 }, + Category::DisplayTimeText, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "custom_text", + OptionType::Str { max_len: 512 }, + Category::DisplayTimeText, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "exec", + OptionType::Str { max_len: 512 }, + Category::DisplayTimeText, + &[], + &[], + GpuVendor::Any, + false, + ), + // Appearance Layout + entry( + "legacy_layout", + OptionType::Bool, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "preset", + csv_opt(None), + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "full", + OptionType::Flag, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "no_display", + OptionType::Flag, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "horizontal", + OptionType::Flag, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "horizontal_stretch", + OptionType::Flag, + Category::AppearanceLayout, + &["horizontal"], + &[], + GpuVendor::Any, + false, + ), + entry( + "hud_compact", + OptionType::Flag, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "hud_no_margin", + OptionType::Flag, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "position", + enum_opt(&[ + "top-left", + "top-right", + "bottom-left", + "bottom-right", + "top-center", + "middle-left", + "middle-right", + "bottom-center", + ]), + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "offset_x", + OptionType::Int { min: 0, max: 9999 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "offset_y", + OptionType::Int { min: 0, max: 9999 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "width", + OptionType::Int { min: 0, max: 9999 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "height", + OptionType::Int { min: 0, max: 9999 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "table_columns", + OptionType::Int { min: 1, max: 10 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cellpadding_y", + OptionType::Float { + min: -2.0, + max: 2.0, + }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "round_corners", + OptionType::Int { min: 0, max: 50 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "background_alpha", + OptionType::Float { min: 0.0, max: 1.0 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "alpha", + OptionType::Float { min: 0.0, max: 1.0 }, + Category::AppearanceLayout, + &[], + &[], + GpuVendor::Any, + false, + ), + // Appearance Colors + entry( + "text_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gpu_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "cpu_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "vram_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "ram_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "engine_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "io_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "frametime_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "background_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "media_player_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "wine_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "battery_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "network_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "horizontal_separator_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "text_outline", + OptionType::Flag, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "text_outline_color", + OptionType::Color, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "text_outline_thickness", + OptionType::Float { min: 0.5, max: 5.0 }, + Category::AppearanceColors, + &[], + &[], + GpuVendor::Any, + false, + ), + // Appearance Typography + entry( + "font_size", + OptionType::Int { min: 8, max: 72 }, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "font_scale", + OptionType::Float { min: 0.1, max: 5.0 }, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "font_size_text", + OptionType::Int { min: 8, max: 72 }, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "font_size_secondary", + OptionType::Int { min: 8, max: 72 }, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "font_scale_media_player", + OptionType::Float { min: 0.1, max: 5.0 }, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "no_small_font", + OptionType::Flag, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "font_file", + OptionType::Path { + must_exist: true, + must_be_writable: false, + }, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "font_file_text", + OptionType::Path { + must_exist: true, + must_be_writable: false, + }, + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "font_glyph_ranges", + csv_opt(Some(GLYPH_VALUES)), + Category::AppearanceTypography, + &[], + &[], + GpuVendor::Any, + false, + ), + // Behavior keybindings + entry( + "toggle_hud", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "toggle_hud_position", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "toggle_preset", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "toggle_fps_limit", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "toggle_logging", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "reload_cfg", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "upload_log", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "reset_fps_metrics", + OptionType::KeyBind, + Category::BehaviorKeybindings, + &[], + &[], + GpuVendor::Any, + false, + ), + // Behavior logging + entry( + "autostart_log", + OptionType::Int { min: 0, max: 3600 }, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "log_duration", + OptionType::Int { min: 1, max: 86400 }, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "log_interval", + OptionType::Int { min: 0, max: 10000 }, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "output_folder", + OptionType::Path { + must_exist: true, + must_be_writable: true, + }, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "output_file", + OptionType::Str { max_len: 256 }, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "permit_upload", + OptionType::Bool, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "benchmark_percentiles", + csv_opt(None), + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "duration", + OptionType::Flag, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "log_versioning", + OptionType::Flag, + Category::BehaviorLogging, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "upload_logs", + OptionType::Flag, + Category::BehaviorLogging, + &["permit_upload"], + &[], + GpuVendor::Any, + false, + ), + // Behavior misc + entry( + "blacklist", + csv_opt(None), + Category::BehaviorMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "control", + OptionType::Str { max_len: 128 }, + Category::BehaviorMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "read_cfg", + OptionType::Flag, + Category::BehaviorMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "inherit", + OptionType::Flag, + Category::BehaviorMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "help", + OptionType::Flag, + Category::BehaviorMisc, + &[], + &[], + GpuVendor::Any, + false, + ), + // OpenGL + entry( + "gl_size_query", + enum_opt(&["", "viewport", "scissorbox", "disabled"]), + Category::WorkaroundsOpengl, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gl_bind_framebuffer", + OptionType::Int { min: 0, max: 999 }, + Category::WorkaroundsOpengl, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "gl_dont_flip", + OptionType::Bool, + Category::WorkaroundsOpengl, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "vulkan_present_mode", + OptionType::Str { max_len: 64 }, + Category::Performance, + &[], + &[], + GpuVendor::Any, + false, + ), + // FCAT + entry( + "fcat", + OptionType::Flag, + Category::AdvancedFcat, + &[], + &[], + GpuVendor::Any, + false, + ), + entry( + "fcat_overlay_width", + OptionType::Int { min: 20, max: 200 }, + Category::AdvancedFcat, + &["fcat"], + &[], + GpuVendor::Any, + false, + ), + entry( + "fcat_screen_edge", + OptionType::Int { min: 0, max: 3 }, + Category::AdvancedFcat, + &["fcat"], + &[], + GpuVendor::Any, + false, + ), + // Ftrace + entry( + "ftrace", + OptionType::Str { max_len: 512 }, + Category::AdvancedFtrace, + &[], + &[], + GpuVendor::Any, + false, + ), + ] +}); + +pub fn get_schema_entry(key: &str) -> Option<&'static SchemaEntry> { + MANGOHUD_SCHEMA.iter().find(|entry| entry.key == key) +} + +pub fn entries_for_category(category: &Category) -> Vec<&'static SchemaEntry> { + MANGOHUD_SCHEMA + .iter() + .filter(|entry| &entry.category == category) + .collect() +} + +pub fn all_dependencies(key: &str) -> Vec<&'static str> { + fn visit(key: &str, seen: &mut HashSet<&'static str>, out: &mut Vec<&'static str>) { + if let Some(entry) = get_schema_entry(key) { + for dep in entry.dependencies { + if seen.insert(*dep) { + out.push(*dep); + visit(dep, seen, out); + } + } + } + } + + let mut seen = HashSet::new(); + let mut out = Vec::new(); + visit(key, &mut seen, &mut out); + out +} diff --git a/src/config/types.rs b/src/config/types.rs new file mode 100644 index 0000000..28bb40c --- /dev/null +++ b/src/config/types.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; + +/// A single line from a MangoHud config file, preserving its original text. +#[derive(Debug, Clone, PartialEq)] +pub enum ConfigLine { + Comment(String), + Blank, + Option { + key: String, + value: Option, + raw: String, + }, + CommentedOption { + key: String, + value: Option, + raw: String, + }, +} + +/// The current state of an option in the in-memory config. +#[derive(Debug, Clone, PartialEq)] +pub enum ConfigValue { + Absent, + Flag, + Value(String), + Disabled, +} + +/// Type system for schema entries. +#[derive(Debug, Clone)] +pub enum OptionType { + Flag, + Bool, + Int { + min: i64, + max: i64, + }, + Float { + min: f64, + max: f64, + }, + Str { + max_len: usize, + }, + Color, + Enum { + variants: Vec, + }, + FpsLimitList, + KeyBind, + CommaSepInts, + CommaSepFloats, + CommaSepStrings { + valid_values: Option>, + }, + Path { + must_exist: bool, + must_be_writable: bool, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GpuVendor { + Any, + AmdOnly, + NvidiaOnly, + IntelOnly, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Category { + Performance, + DisplayFps, + DisplayGpu, + DisplayCpu, + DisplayMemory, + DisplayIoNetwork, + DisplayMisc, + DisplayGraphs, + DisplayBattery, + DisplayMediaPlayer, + DisplayGamescope, + DisplaySteamDeck, + DisplayTimeText, + AppearanceLayout, + AppearanceColors, + AppearanceTypography, + BehaviorKeybindings, + BehaviorFpsLimits, + BehaviorLogging, + BehaviorMisc, + WorkaroundsOpengl, + AdvancedFcat, + AdvancedFtrace, +} + +/// A single schema entry — defines everything about one MangoHud option. +#[derive(Debug, Clone)] +pub struct SchemaEntry { + pub key: &'static str, + pub option_type: OptionType, + pub description: &'static str, + pub category: Category, + pub dependencies: &'static [&'static str], + pub conflicts_with: &'static [&'static str], + pub gpu_vendor_only: GpuVendor, + pub gamescope_only: bool, +} + +/// Validation result for a single option. +#[derive(Debug, Clone, PartialEq)] +pub enum ValidationResult { + Ok, + Warning(String), + Error(String), +} + +/// The full in-memory representation of a parsed config file. +#[derive(Debug, Clone)] +pub struct AnnotatedConfig { + /// Ordered list of lines as they appear in the file. + pub lines: Vec, + /// Fast lookup map: key → (line_index, current_value). + pub options: indexmap::IndexMap, + /// Source path, if backed by a file. + pub path: Option, + /// Whether this config has unsaved in-memory changes. + pub dirty: bool, +} diff --git a/src/config/validator.rs b/src/config/validator.rs new file mode 100644 index 0000000..286d429 --- /dev/null +++ b/src/config/validator.rs @@ -0,0 +1,867 @@ +use crate::config::schema::get_schema_entry; +use crate::config::types::{ + AnnotatedConfig, Category, ConfigValue, OptionType, SchemaEntry, ValidationResult, +}; +use once_cell::sync::Lazy; +use regex::Regex; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::Path; + +static COLOR_RE: Lazy = + Lazy::new(|| Regex::new(r"^[0-9A-Fa-f]{6}$").expect("valid color regex")); +static PCI_DEV_RE: Lazy = Lazy::new(|| { + Regex::new(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$") + .expect("valid pci regex") +}); +static KEYBIND_RE: Lazy = + Lazy::new(|| Regex::new(r"^[^\s+]+(?:\+[^\s+]+)*$").expect("valid keybind regex")); +static FTRACE_RE: Lazy = Lazy::new(|| { + Regex::new(r"^(histogram|linegraph|label)/[a-zA-Z0-9_]+(/[a-zA-Z0-9_]+)?(\+(histogram|linegraph|label)/[a-zA-Z0-9_]+(/[a-zA-Z0-9_]+)?)*$") + .expect("valid ftrace regex") +}); + +pub fn validate_value(key: &str, value: &ConfigValue, schema: &SchemaEntry) -> ValidationResult { + if matches!(value, ConfigValue::Absent | ConfigValue::Disabled) { + return ValidationResult::Ok; + } + + let text = match value { + ConfigValue::Value(v) => Some(v.as_str()), + ConfigValue::Flag => None, + ConfigValue::Absent | ConfigValue::Disabled => None, + }; + + let type_result = match &schema.option_type { + OptionType::Flag => { + if matches!(value, ConfigValue::Flag) { + ValidationResult::Ok + } else { + ValidationResult::Error(format!("'{key}' must be set as a bare flag")) + } + } + OptionType::Bool => match text { + Some("0") | Some("1") => ValidationResult::Ok, + _ => ValidationResult::Error(format!("'{key}' must be 0 or 1")), + }, + OptionType::Int { min, max } => parse_int_in_range(key, text, *min, *max), + OptionType::Float { min, max } => parse_float_in_range(key, text, *min, *max), + OptionType::Str { max_len } => match text { + Some(v) if v.len() <= *max_len => ValidationResult::Ok, + Some(_) => ValidationResult::Error(format!("'{key}' exceeds max length {}", max_len)), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + OptionType::Color => match text { + Some(v) if COLOR_RE.is_match(v) => ValidationResult::Ok, + _ => { + ValidationResult::Error(format!("'{key}' must be a 6-character hex color (RRGGBB)")) + } + }, + OptionType::Enum { variants } => match text { + Some(v) if variants.iter().any(|item| item == v) => ValidationResult::Ok, + Some(v) => ValidationResult::Error(format!( + "'{key}' has invalid variant '{v}', expected one of: {}", + variants.join(", ") + )), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + OptionType::FpsLimitList => match text { + Some(v) => validate_non_negative_csv_ints(key, v), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + OptionType::KeyBind => match text { + Some(v) if KEYBIND_RE.is_match(v) => ValidationResult::Ok, + Some(_) => ValidationResult::Error(format!("'{key}' has an invalid keybind format")), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + OptionType::CommaSepInts => match text { + Some(v) => validate_csv_ints(key, v), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + OptionType::CommaSepFloats => match text { + Some(v) => validate_csv_floats(key, v), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + OptionType::CommaSepStrings { valid_values } => match text { + Some(v) => validate_csv_strings(key, v, valid_values.as_deref()), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + OptionType::Path { + must_exist, + must_be_writable, + } => match text { + Some(v) => validate_path(key, v, *must_exist, *must_be_writable), + None => ValidationResult::Error(format!("'{key}' requires a value")), + }, + }; + + if !matches!(type_result, ValidationResult::Ok) { + return type_result; + } + + // Key-specific validations. + match key { + "fps_metrics" => match text { + Some(v) => validate_percentile_list(key, v), + None => ValidationResult::Ok, + }, + "benchmark_percentiles" => match text { + Some(v) => match validate_percentile_list(key, v) { + ValidationResult::Ok => ValidationResult::Warning( + "benchmark_percentiles is a legacy MangoHud option; prefer fps_metrics." + .to_string(), + ), + other => other, + }, + None => ValidationResult::Ok, + }, + "time_format" => match text { + Some(v) => validate_time_format(v), + None => ValidationResult::Ok, + }, + "pci_dev" => match text { + Some(v) if v.is_empty() || PCI_DEV_RE.is_match(v) => ValidationResult::Ok, + Some(_) => { + ValidationResult::Error(format!("'{key}' must match domain:bus:slot.function")) + } + None => ValidationResult::Ok, + }, + "ftrace" => match text { + Some(v) if v.is_empty() || FTRACE_RE.is_match(v) => ValidationResult::Ok, + Some(_) => ValidationResult::Error("invalid ftrace format".to_string()), + None => ValidationResult::Ok, + }, + "control" => match text { + Some("-1") => ValidationResult::Ok, + Some(v) if !v.trim().is_empty() && !v.chars().any(char::is_whitespace) => { + ValidationResult::Ok + } + Some(_) => ValidationResult::Error( + "'control' must be -1 or a non-whitespace socket name".to_string(), + ), + None => ValidationResult::Ok, + }, + "fps_color" | "gpu_load_color" | "cpu_load_color" => match text { + Some(v) => validate_csv_colors(key, v), + None => ValidationResult::Ok, + }, + _ => ValidationResult::Ok, + } +} + +pub fn validate_all(config: &AnnotatedConfig) -> HashMap { + let mut issues = HashMap::new(); + + for (key, (_, value)) in &config.options { + if let Some(schema) = get_schema_entry(key) { + let result = validate_value(key, value, schema); + if !matches!(result, ValidationResult::Ok) { + issues.insert(key.clone(), result); + } + } + } + + for (dep_key, dep_missing) in check_dependencies(config) { + let dependency_result = dependency_validation_result(&dep_key, &dep_missing); + match issues.get(&dep_key) { + Some(ValidationResult::Error(_)) => {} + Some(ValidationResult::Warning(_)) + if matches!(dependency_result, ValidationResult::Error(_)) => + { + issues.insert(dep_key, dependency_result); + } + None => { + issues.insert(dep_key, dependency_result); + } + _ => {} + } + } + + for (a, b) in check_conflicts(config) { + issues + .entry(a.clone()) + .or_insert_with(|| ValidationResult::Error(format!("conflicts with '{b}'"))); + issues + .entry(b.clone()) + .or_insert_with(|| ValidationResult::Error(format!("conflicts with '{a}'"))); + } + + apply_threshold_shape_checks(config, &mut issues); + + issues +} + +fn dependency_validation_result(dependent_key: &str, missing_dependency: &str) -> ValidationResult { + let Some(schema) = get_schema_entry(dependent_key) else { + return ValidationResult::Error(format!("missing dependency '{missing_dependency}'")); + }; + + let hard_dependency = matches!(schema.option_type, OptionType::Flag | OptionType::Bool); + if hard_dependency { + ValidationResult::Error(format!("missing dependency '{missing_dependency}'")) + } else { + ValidationResult::Warning(format!("ignored until '{missing_dependency}' is enabled")) + } +} + +pub fn check_dependencies(config: &AnnotatedConfig) -> Vec<(String, String)> { + let mut missing = Vec::new(); + for (key, (_, value)) in &config.options { + if !is_active(value) { + continue; + } + let Some(schema) = get_schema_entry(key) else { + continue; + }; + for dep in schema.dependencies { + let dep_active = config + .options + .get(*dep) + .map(|(_, v)| is_active(v)) + .unwrap_or(false); + if !dep_active { + missing.push((key.clone(), (*dep).to_string())); + } + } + } + missing +} + +pub fn check_conflicts(config: &AnnotatedConfig) -> Vec<(String, String)> { + let mut conflicts = HashSet::new(); + let active: Vec<(&str, &ConfigValue)> = config + .options + .iter() + .filter(|(_, (_, v))| is_active(v)) + .map(|(k, (_, v))| (k.as_str(), v)) + .collect(); + + for (key, _) in &active { + if let Some(schema) = get_schema_entry(key) { + for conflict in schema.conflicts_with { + if active.iter().any(|(candidate, _)| candidate == conflict) { + conflicts.insert(sorted_pair(key, conflict)); + } + } + } + } + + // Special-case: fps_only conflicts with any other display category option. + let fps_only_active = active.iter().any(|(k, _)| *k == "fps_only"); + if fps_only_active { + for (key, _) in &active { + if *key == "fps_only" { + continue; + } + if let Some(entry) = get_schema_entry(key) { + if is_display_category(&entry.category) { + conflicts.insert(sorted_pair("fps_only", key)); + } + } + } + } + + conflicts.into_iter().collect() +} + +pub fn is_saveable(config: &AnnotatedConfig) -> bool { + !validate_all(config) + .values() + .any(|result| matches!(result, ValidationResult::Error(_))) +} + +fn sorted_pair(a: &str, b: &str) -> (String, String) { + if a <= b { + (a.to_string(), b.to_string()) + } else { + (b.to_string(), a.to_string()) + } +} + +fn is_display_category(category: &Category) -> bool { + matches!( + category, + Category::DisplayFps + | Category::DisplayGpu + | Category::DisplayCpu + | Category::DisplayMemory + | Category::DisplayIoNetwork + | Category::DisplayMisc + | Category::DisplayGraphs + | Category::DisplayBattery + | Category::DisplayMediaPlayer + | Category::DisplayGamescope + | Category::DisplaySteamDeck + | Category::DisplayTimeText + ) +} + +fn parse_int_in_range(key: &str, value: Option<&str>, min: i64, max: i64) -> ValidationResult { + match value.and_then(|v| v.parse::().ok()) { + Some(parsed) if parsed >= min && parsed <= max => ValidationResult::Ok, + Some(_) => ValidationResult::Error(format!("'{key}' must be in range [{min}, {max}]")), + None => ValidationResult::Error(format!("'{key}' must be a valid integer")), + } +} + +fn parse_float_in_range(key: &str, value: Option<&str>, min: f64, max: f64) -> ValidationResult { + match value.and_then(|v| v.parse::().ok()) { + Some(parsed) if parsed >= min && parsed <= max => ValidationResult::Ok, + Some(_) => ValidationResult::Error(format!("'{key}' must be in range [{min}, {max}]")), + None => ValidationResult::Error(format!("'{key}' must be a valid float")), + } +} + +fn validate_non_negative_csv_ints(key: &str, value: &str) -> ValidationResult { + for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let Ok(parsed) = part.parse::() else { + return ValidationResult::Error(format!("'{key}' contains non-integer '{part}'")); + }; + if parsed < 0 { + return ValidationResult::Error(format!("'{key}' cannot contain negative values")); + } + } + ValidationResult::Ok +} + +fn validate_csv_ints(key: &str, value: &str) -> ValidationResult { + for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if part.parse::().is_err() { + return ValidationResult::Error(format!("'{key}' contains non-integer '{part}'")); + } + } + ValidationResult::Ok +} + +fn validate_csv_floats(key: &str, value: &str) -> ValidationResult { + for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if part.parse::().is_err() { + return ValidationResult::Error(format!("'{key}' contains non-float '{part}'")); + } + } + ValidationResult::Ok +} + +fn validate_csv_strings( + key: &str, + value: &str, + valid_values: Option<&[String]>, +) -> ValidationResult { + if value.trim().is_empty() { + return ValidationResult::Ok; + } + + if let Some(allowed) = valid_values { + for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if !allowed.iter().any(|allowed| allowed == part) { + return ValidationResult::Error(format!("'{key}' contains invalid value '{part}'")); + } + } + } + + ValidationResult::Ok +} + +fn validate_csv_colors(key: &str, value: &str) -> ValidationResult { + for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if !COLOR_RE.is_match(part) { + return ValidationResult::Error(format!( + "'{key}' contains invalid color '{part}' (expected RRGGBB)" + )); + } + } + + ValidationResult::Ok +} + +fn validate_path( + key: &str, + value: &str, + must_exist: bool, + must_be_writable: bool, +) -> ValidationResult { + if value.trim().is_empty() { + return ValidationResult::Ok; + } + + let path = Path::new(value); + if must_exist && !path.exists() { + return ValidationResult::Error(format!("'{key}' path does not exist")); + } + + if must_be_writable { + let target = if path.exists() { + path.to_path_buf() + } else { + match path.parent() { + Some(parent) => parent.to_path_buf(), + None => return ValidationResult::Error(format!("'{key}' has invalid path parent")), + } + }; + + match fs::metadata(&target) { + Ok(metadata) if metadata.permissions().readonly() => { + return ValidationResult::Error(format!("'{key}' path is not writable")); + } + Ok(_) => {} + Err(_) => { + return ValidationResult::Error(format!("'{key}' cannot access path metadata")) + } + } + } + + ValidationResult::Ok +} + +fn validate_percentile_list(key: &str, value: &str) -> ValidationResult { + for part in value.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if part.eq_ignore_ascii_case("AVG") { + continue; + } + let Ok(parsed) = part.parse::() else { + return ValidationResult::Error(format!( + "'{key}' contains invalid percentile '{part}'" + )); + }; + if !(0.0..=100.0).contains(&parsed) { + return ValidationResult::Error(format!( + "'{key}' percentile '{part}' is out of range [0,100]" + )); + } + } + ValidationResult::Ok +} + +fn validate_time_format(value: &str) -> ValidationResult { + if value.is_empty() { + return ValidationResult::Ok; + } + + let mut chars = value.chars().peekable(); + let mut has_specifier = false; + let allowed = "aAbBcdDeFgGhHIjmMnprRStTuUVwWxXyYzZ%"; + + while let Some(ch) = chars.next() { + if ch != '%' { + continue; + } + let Some(next) = chars.next() else { + return ValidationResult::Warning("time_format ends with '%'".to_string()); + }; + has_specifier = true; + if !allowed.contains(next) { + return ValidationResult::Warning(format!( + "time_format uses unknown specifier '%{next}'" + )); + } + } + + if !has_specifier { + return ValidationResult::Warning("time_format has no strftime specifiers".to_string()); + } + ValidationResult::Ok +} + +fn is_active(value: &ConfigValue) -> bool { + match value { + ConfigValue::Absent | ConfigValue::Disabled => false, + ConfigValue::Flag => true, + ConfigValue::Value(v) => { + let trimmed = v.trim(); + !(trimmed.is_empty() || trimmed == "0") + } + } +} + +fn apply_threshold_shape_checks( + config: &AnnotatedConfig, + issues: &mut HashMap, +) { + for (toggle_key, value_key, color_key) in [ + ("fps_color_change", "fps_value", "fps_color"), + ("gpu_load_change", "gpu_load_value", "gpu_load_color"), + ("cpu_load_change", "cpu_load_value", "cpu_load_color"), + ] { + let toggle_active = config + .options + .get(toggle_key) + .map(|(_, value)| is_active(value)) + .unwrap_or(false); + if !toggle_active { + continue; + } + + let value_count = csv_value_count(config, value_key); + let color_count = csv_value_count(config, color_key); + if value_count == 0 || color_count == 0 || value_count == color_count { + continue; + } + + let message = format!( + "'{}' and '{}' must contain the same number of entries while '{}' is enabled", + value_key, color_key, toggle_key + ); + issues.insert( + value_key.to_string(), + ValidationResult::Error(message.clone()), + ); + issues.insert(color_key.to_string(), ValidationResult::Error(message)); + } +} + +fn csv_value_count(config: &AnnotatedConfig, key: &str) -> usize { + config + .options + .get(key) + .and_then(|(_, value)| match value { + ConfigValue::Value(text) => Some(text), + ConfigValue::Flag | ConfigValue::Absent | ConfigValue::Disabled => None, + }) + .map(|text| { + text.split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .count() + }) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::schema::MANGOHUD_SCHEMA; + use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue, OptionType, SchemaEntry}; + use indexmap::IndexMap; + use tempfile::tempdir; + + fn schema_for(option_type: OptionType) -> SchemaEntry { + SchemaEntry { + key: "test_key", + option_type, + description: "test", + category: Category::Performance, + dependencies: &[], + conflicts_with: &[], + gpu_vendor_only: crate::config::types::GpuVendor::Any, + gamescope_only: false, + } + } + + fn config_with(key: &str, value: ConfigValue) -> AnnotatedConfig { + let mut options = IndexMap::new(); + options.insert(key.to_string(), (0, value)); + AnnotatedConfig { + lines: vec![ConfigLine::Blank], + options, + path: None, + dirty: false, + } + } + + #[test] + fn validates_bool_and_int_boundaries() { + let bool_schema = schema_for(OptionType::Bool); + assert_eq!( + validate_value("b", &ConfigValue::Value("1".into()), &bool_schema), + ValidationResult::Ok + ); + assert!(matches!( + validate_value("b", &ConfigValue::Value("2".into()), &bool_schema), + ValidationResult::Error(_) + )); + + let int_schema = schema_for(OptionType::Int { min: 1, max: 3 }); + assert_eq!( + validate_value("i", &ConfigValue::Value("1".into()), &int_schema), + ValidationResult::Ok + ); + assert_eq!( + validate_value("i", &ConfigValue::Value("3".into()), &int_schema), + ValidationResult::Ok + ); + assert!(matches!( + validate_value("i", &ConfigValue::Value("4".into()), &int_schema), + ValidationResult::Error(_) + )); + } + + #[test] + fn validates_float_color_enum_keybind_and_lists() { + let float_schema = schema_for(OptionType::Float { min: 0.0, max: 1.0 }); + assert_eq!( + validate_value("f", &ConfigValue::Value("0.5".into()), &float_schema), + ValidationResult::Ok + ); + assert!(matches!( + validate_value("f", &ConfigValue::Value("1.5".into()), &float_schema), + ValidationResult::Error(_) + )); + + let color_schema = schema_for(OptionType::Color); + assert_eq!( + validate_value("c", &ConfigValue::Value("AABBCC".into()), &color_schema), + ValidationResult::Ok + ); + assert!(matches!( + validate_value("c", &ConfigValue::Value("GGGGGG".into()), &color_schema), + ValidationResult::Error(_) + )); + + let enum_schema = schema_for(OptionType::Enum { + variants: vec!["a".into(), "b".into()], + }); + assert_eq!( + validate_value("e", &ConfigValue::Value("a".into()), &enum_schema), + ValidationResult::Ok + ); + assert!(matches!( + validate_value("e", &ConfigValue::Value("c".into()), &enum_schema), + ValidationResult::Error(_) + )); + + let kb_schema = schema_for(OptionType::KeyBind); + assert_eq!( + validate_value("k", &ConfigValue::Value("Shift_R+F12".into()), &kb_schema), + ValidationResult::Ok + ); + assert_eq!( + validate_value("k", &ConfigValue::Value("R_Shift+F11".into()), &kb_schema), + ValidationResult::Ok + ); + assert_eq!( + validate_value("k", &ConfigValue::Value("Page_Up".into()), &kb_schema), + ValidationResult::Ok + ); + assert!(matches!( + validate_value("k", &ConfigValue::Value("bad key".into()), &kb_schema), + ValidationResult::Error(_) + )); + + let fps_schema = schema_for(OptionType::FpsLimitList); + assert_eq!( + validate_value( + "fps_limit", + &ConfigValue::Value("0,30,60".into()), + &fps_schema + ), + ValidationResult::Ok + ); + assert!(matches!( + validate_value( + "fps_limit", + &ConfigValue::Value("30,-1".into()), + &fps_schema + ), + ValidationResult::Error(_) + )); + } + + #[test] + fn dependency_and_conflict_checks_work() { + let mut cfg = AnnotatedConfig { + lines: vec![], + options: IndexMap::new(), + path: None, + dirty: false, + }; + cfg.options + .insert("gpu_mem_clock".to_string(), (0, ConfigValue::Flag)); + cfg.options + .insert("fps_only".to_string(), (1, ConfigValue::Flag)); + cfg.options + .insert("fps".to_string(), (2, ConfigValue::Flag)); + + let deps = check_dependencies(&cfg); + assert!(deps + .iter() + .any(|(dependent, required)| dependent == "gpu_mem_clock" && required == "vram")); + + let conflicts = check_conflicts(&cfg); + assert!(conflicts + .iter() + .any(|(a, b)| { (a == "fps" && b == "fps_only") || (a == "fps_only" && b == "fps") })); + } + + #[test] + fn threshold_flags_require_supporting_values() { + let mut cfg = AnnotatedConfig { + lines: vec![], + options: IndexMap::new(), + path: None, + dirty: false, + }; + cfg.options + .insert("fps_color_change".to_string(), (0, ConfigValue::Flag)); + cfg.options + .insert("fps".to_string(), (1, ConfigValue::Flag)); + + let deps = check_dependencies(&cfg); + assert!(deps.iter().any( + |(dependent, required)| dependent == "fps_color_change" && required == "fps_value" + )); + assert!(deps.iter().any( + |(dependent, required)| dependent == "fps_color_change" && required == "fps_color" + )); + } + + #[test] + fn threshold_flags_require_matching_value_and_color_counts() { + let mut cfg = AnnotatedConfig { + lines: vec![], + options: IndexMap::new(), + path: None, + dirty: false, + }; + cfg.options + .insert("fps".to_string(), (0, ConfigValue::Flag)); + cfg.options + .insert("fps_color_change".to_string(), (1, ConfigValue::Flag)); + cfg.options.insert( + "fps_value".to_string(), + (2, ConfigValue::Value("30,60".into())), + ); + cfg.options.insert( + "fps_color".to_string(), + (3, ConfigValue::Value("FF4D4D,FFD24D,66FF99".into())), + ); + + let issues = validate_all(&cfg); + assert!(matches!( + issues.get("fps_value"), + Some(ValidationResult::Error(_)) + )); + assert!(matches!( + issues.get("fps_color"), + Some(ValidationResult::Error(_)) + )); + } + + #[test] + fn threshold_color_lists_validate_each_hex_entry() { + let schema = schema_for(OptionType::CommaSepStrings { valid_values: None }); + let result = validate_value( + "fps_color", + &ConfigValue::Value("FF4D4D,BADHEX,66FF99".into()), + &schema, + ); + assert!(matches!(result, ValidationResult::Error(_))); + } + + #[test] + fn dormant_string_dependency_warns_instead_of_blocking_save() { + let mut cfg = AnnotatedConfig { + lines: vec![], + options: IndexMap::new(), + path: None, + dirty: false, + }; + cfg.options.insert( + "media_player_format".to_string(), + (0, ConfigValue::Value("{title};{artist}".into())), + ); + + let issues = validate_all(&cfg); + assert!(matches!( + issues.get("media_player_format"), + Some(ValidationResult::Warning(_)) + )); + assert!(is_saveable(&cfg)); + } + + #[test] + fn saveable_false_on_errors_true_on_warnings() { + let cfg = config_with("fps", ConfigValue::Value("bad".into())); + assert!(!is_saveable(&cfg)); + + let warning_schema = schema_for(OptionType::Str { max_len: 16 }); + let warning = validate_value( + "time_format", + &ConfigValue::Value("invalid".into()), + &warning_schema, + ); + assert!(matches!( + warning, + ValidationResult::Ok | ValidationResult::Warning(_) + )); + } + + #[test] + fn benchmark_percentiles_warns_but_validates() { + let schema = schema_for(OptionType::CommaSepStrings { valid_values: None }); + let result = validate_value( + "benchmark_percentiles", + &ConfigValue::Value("97,AVG".into()), + &schema, + ); + assert!(matches!(result, ValidationResult::Warning(_))); + } + + #[test] + fn schema_entries_are_unique() { + let mut seen = HashSet::new(); + for entry in MANGOHUD_SCHEMA.iter() { + assert!(seen.insert(entry.key), "duplicate schema key {}", entry.key); + } + } + + #[test] + fn every_schema_entry_accepts_representative_value() { + let temp = tempdir().expect("tempdir"); + for entry in MANGOHUD_SCHEMA.iter() { + let value = representative_value(entry, temp.path()); + let result = validate_value(entry.key, &value, entry); + assert!( + !matches!(result, ValidationResult::Error(_)), + "representative value should validate for key '{}': {:?}", + entry.key, + result + ); + } + } + + fn representative_value(entry: &SchemaEntry, temp_root: &std::path::Path) -> ConfigValue { + match entry.key { + "fps_metrics" | "benchmark_percentiles" => ConfigValue::Value("AVG,95".to_string()), + "time_format" => ConfigValue::Value("%H:%M:%S".to_string()), + "pci_dev" => ConfigValue::Value("0000:01:00.0".to_string()), + "ftrace" => ConfigValue::Value("histogram/foo/bar".to_string()), + "control" => ConfigValue::Value("-1".to_string()), + "fps_color" | "gpu_load_color" | "cpu_load_color" => { + ConfigValue::Value("FF4D4D,FFD24D,66FF99".to_string()) + } + _ => match &entry.option_type { + OptionType::Flag => ConfigValue::Flag, + OptionType::Bool => ConfigValue::Value("1".to_string()), + OptionType::Int { min, .. } => ConfigValue::Value(min.to_string()), + OptionType::Float { min, .. } => ConfigValue::Value(min.to_string()), + OptionType::Str { .. } => ConfigValue::Value("sample".to_string()), + OptionType::Color => ConfigValue::Value("A1B2C3".to_string()), + OptionType::Enum { variants } => { + ConfigValue::Value(variants.first().cloned().unwrap_or_default()) + } + OptionType::FpsLimitList => ConfigValue::Value("0,60,120".to_string()), + OptionType::KeyBind => ConfigValue::Value("Shift_R+F12".to_string()), + OptionType::CommaSepInts => ConfigValue::Value("1,2,3".to_string()), + OptionType::CommaSepFloats => ConfigValue::Value("0.5,1.5".to_string()), + OptionType::CommaSepStrings { valid_values } => { + let value = valid_values + .as_ref() + .and_then(|values| values.first().cloned()) + .unwrap_or_else(|| "sample".to_string()); + ConfigValue::Value(value) + } + OptionType::Path { + must_exist, + must_be_writable: _, + } => { + let path = if *must_exist { + temp_root.to_path_buf() + } else { + temp_root.join("generated-path") + }; + ConfigValue::Value(path.display().to_string()) + } + }, + } + } +} diff --git a/src/debug_log.rs b/src/debug_log.rs new file mode 100644 index 0000000..7f95542 --- /dev/null +++ b/src/debug_log.rs @@ -0,0 +1,49 @@ +use once_cell::sync::Lazy; +use std::collections::VecDeque; +use std::sync::Mutex; + +const MAX_LOG_LINES: usize = 500; + +static LOG_BUFFER: Lazy>> = + Lazy::new(|| Mutex::new(VecDeque::with_capacity(MAX_LOG_LINES))); + +pub fn record(message: &str) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let line = format!("[mangotune][{ts}] {message}"); + + #[cfg(debug_assertions)] + { + eprintln!("{line}"); + } + #[cfg(not(debug_assertions))] + { + tracing::debug!("{message}"); + } + + if let Ok(mut buffer) = LOG_BUFFER.lock() { + buffer.push_back(line); + while buffer.len() > MAX_LOG_LINES { + buffer.pop_front(); + } + } +} + +pub fn lines() -> Vec { + LOG_BUFFER + .lock() + .map(|buffer| buffer.iter().cloned().collect()) + .unwrap_or_default() +} + +pub fn text() -> String { + lines().join("\n") +} + +pub fn clear() { + if let Ok(mut buffer) = LOG_BUFFER.lock() { + buffer.clear(); + } +} diff --git a/src/integrations/game_db.rs b/src/integrations/game_db.rs new file mode 100644 index 0000000..30f7c6e --- /dev/null +++ b/src/integrations/game_db.rs @@ -0,0 +1,119 @@ +use once_cell::sync::Lazy; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct GameConfigHint { + pub appid: Option, + pub title: String, + #[serde(default)] + pub aliases: Vec, + #[serde(default)] + pub candidates: Vec, + pub preferred: String, + pub verification: String, +} + +#[derive(Debug, Deserialize)] +struct GameConfigDb { + game: Vec, +} + +static GAME_HINTS: Lazy> = Lazy::new(|| { + toml::from_str::(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/data/game_config_db.toml" + ))) + .expect("valid game config DB") + .game +}); + +fn normalize(text: &str) -> String { + text.chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(|ch| ch.to_lowercase()) + .collect() +} + +fn score_hint(query: &str, hint: &GameConfigHint) -> Option { + let query = normalize(query); + if query.is_empty() { + return None; + } + + let mut best = None; + let candidates = hint + .aliases + .iter() + .chain(hint.candidates.iter()) + .chain(std::iter::once(&hint.title)) + .chain(std::iter::once(&hint.preferred)); + for raw in candidates { + let normalized = normalize(raw); + if normalized.is_empty() { + continue; + } + let score = if normalized == query { + 1000 + } else if normalized.starts_with(&query) { + 850 + } else if normalized.contains(&query) { + 700 + } else if query.contains(&normalized) && normalized.len() >= 4 { + 450 + } else { + continue; + }; + best = Some(best.map_or(score, |old: i32| old.max(score))); + } + best.map(|score| { + score + + i32::try_from(hint.title.len()) + .unwrap_or(0) + .saturating_neg() + }) +} + +pub fn search_game_config_hints(query: &str, limit: usize) -> Vec { + let mut matches = GAME_HINTS + .iter() + .filter_map(|hint| score_hint(query, hint).map(|score| (score, hint.clone()))) + .collect::>(); + matches.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.title.to_lowercase().cmp(&b.1.title.to_lowercase())) + }); + if let Some((top_score, _)) = matches.first() { + let threshold = if *top_score >= 1000 { + 900 + } else if *top_score >= 850 { + 760 + } else { + 0 + }; + if threshold > 0 { + matches.retain(|(score, _)| *score >= threshold); + } + } + matches + .into_iter() + .map(|(_, hint)| hint) + .take(limit) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_candidate_matches() { + let results = search_game_config_hints("cs2", 5); + assert!(results.iter().any(|hint| hint.preferred == "cs2")); + } + + #[test] + fn title_search_matches() { + let results = search_game_config_hints("valheim", 5); + assert!(results.iter().any(|hint| hint.title == "Valheim")); + } +} diff --git a/src/integrations/gamemode.rs b/src/integrations/gamemode.rs new file mode 100644 index 0000000..d6fe9a9 --- /dev/null +++ b/src/integrations/gamemode.rs @@ -0,0 +1,86 @@ +use std::path::PathBuf; +use std::process::Command; +use sysinfo::{ProcessesToUpdate, System}; + +#[derive(Debug, Clone)] +pub struct GameModeStatus { + pub daemon_installed: bool, + pub ctl_installed: bool, + pub daemon_running: bool, + pub current_clients: u32, +} + +pub async fn detect() -> GameModeStatus { + let daemon_path = which("gamemoded"); + let ctl_path = which("gamemodectl"); + + let mut system = System::new_all(); + system.refresh_processes(ProcessesToUpdate::All); + let daemon_running = system + .processes() + .values() + .any(|process| process.name().to_string_lossy().contains("gamemoded")); + + let current_clients = if ctl_path.is_some() { + parse_clients(&run_cmd("gamemodectl", &["status"]).unwrap_or_default()) + } else { + 0 + }; + + GameModeStatus { + daemon_installed: daemon_path.is_some(), + ctl_installed: ctl_path.is_some(), + daemon_running, + current_clients, + } +} + +fn parse_clients(status: &str) -> u32 { + for line in status.lines() { + if !line.to_ascii_lowercase().contains("client") { + continue; + } + + if let Some(number) = line + .split(|ch: char| !ch.is_ascii_digit()) + .find(|part| !part.is_empty()) + .and_then(|part| part.parse::().ok()) + { + return number; + } + } + 0 +} + +fn run_cmd(program: &str, args: &[&str]) -> Option { + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn which(tool: &str) -> Option { + let output = Command::new("which").arg(tool).output().ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_client_count_variants() { + assert_eq!(parse_clients("gamemode is active with 3 clients"), 3); + assert_eq!(parse_clients("No clients"), 0); + assert_eq!(parse_clients("Clients: 11"), 11); + } +} diff --git a/src/integrations/heroic.rs b/src/integrations/heroic.rs new file mode 100644 index 0000000..5e859d7 --- /dev/null +++ b/src/integrations/heroic.rs @@ -0,0 +1,273 @@ +use crate::system::paths::heroic_config_dirs; +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone)] +pub struct HeroicStatus { + pub installed: bool, + pub flatpak: bool, + pub config_dir: Option, + pub games: Vec, +} + +#[derive(Debug, Clone)] +pub struct HeroicGame { + pub title: String, + pub app_name: String, + pub store: HeroicStore, + pub config_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HeroicStore { + Epic, + Gog, + Amazon, +} + +pub async fn detect() -> HeroicStatus { + let installed = which("heroic").is_some(); + let flatpak = flatpak_list_contains("com.heroicgameslauncher.hgl"); + let config_dir = heroic_config_dirs().into_iter().find(|path| path.exists()); + let games = config_dir + .as_ref() + .map(|path| read_games(path.as_path())) + .unwrap_or_default(); + + HeroicStatus { + installed, + flatpak, + config_dir, + games, + } +} + +pub fn set_wrapper_enabled(config_path: &Path, enabled: bool) -> Result<()> { + update_json(config_path, |doc| { + let wrappers = ensure_array(doc, "wrapperOptions"); + if enabled { + if !wrappers.iter().any(is_mangohud_wrapper) { + wrappers.push(json!({"exe": "mangohud", "args": ""})); + } + } else { + wrappers.retain(|entry| !is_mangohud_wrapper(entry)); + } + }) +} + +pub fn set_environment_enabled(config_path: &Path, enabled: bool) -> Result<()> { + update_json(config_path, |doc| { + let envs = ensure_array(doc, "enviromentOptions"); + if enabled { + if !envs.iter().any(is_mangohud_env) { + envs.push(json!({"key": "MANGOHUD", "value": "1"})); + } + } else { + envs.retain(|entry| !is_mangohud_env(entry)); + } + }) +} + +fn update_json(config_path: &Path, mut patch: F) -> Result<()> +where + F: FnMut(&mut Value), +{ + let content = fs::read_to_string(config_path) + .with_context(|| format!("failed to read {}", config_path.display()))?; + let mut value: Value = serde_json::from_str(&content) + .with_context(|| format!("invalid json in {}", config_path.display()))?; + + patch(&mut value); + + let output = serde_json::to_string_pretty(&value)?; + fs::write(config_path, format!("{output}\n")) + .with_context(|| format!("failed to write {}", config_path.display()))?; + Ok(()) +} + +fn read_games(config_dir: &Path) -> Vec { + let games_dir = config_dir.join("GamesConfig"); + let mut games = Vec::new(); + + if let Ok(entries) = fs::read_dir(games_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + let Ok(json): Result = serde_json::from_str(&content) else { + continue; + }; + + let app_name = json + .get("appName") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let title = json + .get("title") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| app_name.clone()); + + let store = infer_store(&json, &path); + + games.push(HeroicGame { + title, + app_name, + store, + config_path: path, + }); + } + } + + games.sort_by(|a, b| a.title.cmp(&b.title)); + games +} + +fn ensure_array<'a>(doc: &'a mut Value, key: &str) -> &'a mut Vec { + let map = doc + .as_object_mut() + .expect("heroic config must be an object"); + let entry = map + .entry(key.to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !entry.is_array() { + *entry = Value::Array(Vec::new()); + } + entry.as_array_mut().expect("array inserted") +} + +fn is_mangohud_wrapper(value: &Value) -> bool { + value + .get("exe") + .and_then(Value::as_str) + .map(|exe| exe == "mangohud") + .unwrap_or(false) +} + +fn is_mangohud_env(value: &Value) -> bool { + value + .get("key") + .and_then(Value::as_str) + .map(|key| key == "MANGOHUD") + .unwrap_or(false) +} + +fn infer_store(json: &Value, path: &Path) -> HeroicStore { + if let Some(store) = json.get("store").and_then(Value::as_str) { + return match store.to_ascii_lowercase().as_str() { + "gog" => HeroicStore::Gog, + "amazon" => HeroicStore::Amazon, + _ => HeroicStore::Epic, + }; + } + + let lower = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if lower.contains("gog") { + HeroicStore::Gog + } else if lower.contains("amazon") { + HeroicStore::Amazon + } else { + HeroicStore::Epic + } +} + +fn flatpak_list_contains(app_id: &str) -> bool { + let output = Command::new("flatpak").arg("list").output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + String::from_utf8_lossy(&output.stdout).contains(app_id) +} + +fn which(tool: &str) -> Option { + let output = Command::new("which").arg(tool).output().ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn wrapper_toggle_updates_json_without_data_loss() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("game.json"); + let source = json!({ + "appName": "abc", + "title": "My Game", + "other": { "nested": true }, + "wrapperOptions": [] + }); + fs::write(&path, serde_json::to_string_pretty(&source).expect("json")).expect("write"); + + set_wrapper_enabled(&path, true).expect("enable wrapper"); + let after_enable: Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read json")).expect("parse"); + assert!(after_enable["wrapperOptions"] + .as_array() + .expect("array") + .iter() + .any(is_mangohud_wrapper)); + assert_eq!(after_enable["other"]["nested"], Value::Bool(true)); + + set_wrapper_enabled(&path, false).expect("disable wrapper"); + let after_disable: Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read json")).expect("parse"); + assert!(after_disable["wrapperOptions"] + .as_array() + .expect("array") + .iter() + .all(|item| !is_mangohud_wrapper(item))); + } + + #[test] + fn env_toggle_updates_json() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("game.json"); + fs::write( + &path, + serde_json::to_string_pretty(&json!({ "enviromentOptions": [] })).expect("json"), + ) + .expect("write"); + + set_environment_enabled(&path, true).expect("enable env"); + let after_enable: Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read json")).expect("parse"); + assert!(after_enable["enviromentOptions"] + .as_array() + .expect("array") + .iter() + .any(is_mangohud_env)); + } +} diff --git a/src/integrations/lutris.rs b/src/integrations/lutris.rs new file mode 100644 index 0000000..b26c15c --- /dev/null +++ b/src/integrations/lutris.rs @@ -0,0 +1,180 @@ +use crate::system::paths::lutris_config_dirs; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone)] +pub struct LutrisStatus { + pub installed: bool, + pub flatpak: bool, + pub config_dir: Option, + pub games: Vec, +} + +#[derive(Debug, Clone)] +pub struct LutrisGame { + pub name: String, + pub slug: String, + pub config_path: PathBuf, + pub runner: String, +} + +pub async fn detect() -> LutrisStatus { + let installed = which("lutris").is_some(); + let flatpak = flatpak_list_contains("net.lutris.Lutris"); + + let config_dir = lutris_config_dirs().into_iter().find(|path| path.exists()); + let games = config_dir + .as_ref() + .map(|path| read_games(path.as_path())) + .unwrap_or_default(); + + LutrisStatus { + installed, + flatpak, + config_dir, + games, + } +} + +pub fn set_mangohud_enabled(game_config_path: &Path, enabled: bool) -> Result<()> { + let content = fs::read_to_string(game_config_path) + .with_context(|| format!("failed to read {}", game_config_path.display()))?; + let rewritten = rewrite_mangohud_in_yaml(&content, enabled); + fs::write(game_config_path, rewritten) + .with_context(|| format!("failed to write {}", game_config_path.display()))?; + Ok(()) +} + +fn read_games(config_dir: &Path) -> Vec { + let games_dir = config_dir.join("games"); + let mut games = Vec::new(); + + if let Ok(entries) = fs::read_dir(games_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("yml") { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + let slug = path + .file_stem() + .and_then(|item| item.to_str()) + .unwrap_or("unknown") + .to_string(); + let name = parse_yaml_value(&content, "name") + .or_else(|| parse_yaml_value(&content, "game")) + .unwrap_or_else(|| slug.clone()); + let runner = parse_yaml_value(&content, "runner").unwrap_or_default(); + games.push(LutrisGame { + name, + slug, + config_path: path, + runner, + }); + } + } + } + + games.sort_by(|a, b| a.name.cmp(&b.name)); + games +} + +fn parse_yaml_value(content: &str, key: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) { + return Some(value.trim().trim_matches('"').to_string()); + } + } + None +} + +fn rewrite_mangohud_in_yaml(content: &str, enabled: bool) -> String { + let mut lines: Vec = content.lines().map(ToString::to_string).collect(); + let desired_line = format!(" mangohud: {}", if enabled { "true" } else { "false" }); + + let mut system_idx = None; + for (idx, line) in lines.iter().enumerate() { + if line.trim() == "system:" { + system_idx = Some(idx); + break; + } + } + + match system_idx { + Some(start) => { + let mut section_end = lines.len(); + for (idx, line) in lines.iter().enumerate().skip(start + 1) { + if !line.starts_with(' ') && line.trim_end().ends_with(':') { + section_end = idx; + break; + } + } + + for line in lines.iter_mut().take(section_end).skip(start + 1) { + if line.trim_start().starts_with("mangohud:") { + *line = desired_line.clone(); + return format!("{}\n", lines.join("\n")); + } + } + + lines.insert(start + 1, desired_line); + } + None => { + if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) { + lines.push(String::new()); + } + lines.push("system:".to_string()); + lines.push(desired_line); + } + } + + format!("{}\n", lines.join("\n")) +} + +fn flatpak_list_contains(app_id: &str) -> bool { + let output = Command::new("flatpak").arg("list").output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + String::from_utf8_lossy(&output.stdout).contains(app_id) +} + +fn which(tool: &str) -> Option { + let output = Command::new("which").arg(tool).output().ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_yaml_in_existing_system_section() { + let input = "game:\n exe: foo\nsystem:\n mangohud: false\n foo: bar\n"; + let out = rewrite_mangohud_in_yaml(input, true); + assert!(out.contains("mangohud: true")); + assert!(out.contains("foo: bar")); + } + + #[test] + fn rewrite_yaml_adds_section_if_missing() { + let input = "game:\n exe: foo\n"; + let out = rewrite_mangohud_in_yaml(input, true); + assert!(out.contains("system:")); + assert!(out.contains("mangohud: true")); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs new file mode 100644 index 0000000..c8dccef --- /dev/null +++ b/src/integrations/mod.rs @@ -0,0 +1,16 @@ +pub mod game_db; +pub mod gamemode; +pub mod heroic; +pub mod lutris; +pub mod steam; + +pub use game_db::{search_game_config_hints, GameConfigHint}; +pub use gamemode::{detect as detect_gamemode, GameModeStatus}; +pub use heroic::{ + detect as detect_heroic, set_environment_enabled as set_heroic_env, + set_wrapper_enabled as set_heroic_wrapper, HeroicGame, HeroicStatus, HeroicStore, +}; +pub use lutris::{ + detect as detect_lutris, set_mangohud_enabled as set_lutris_mangohud, LutrisGame, LutrisStatus, +}; +pub use steam::{detect as detect_steam, generate_launch_option, SteamInjectMethod, SteamStatus}; diff --git a/src/integrations/steam.rs b/src/integrations/steam.rs new file mode 100644 index 0000000..76ab504 --- /dev/null +++ b/src/integrations/steam.rs @@ -0,0 +1,118 @@ +use crate::system::paths::steam_roots; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use sysinfo::{ProcessesToUpdate, System}; + +#[derive(Debug, Clone)] +pub struct SteamStatus { + pub installed: bool, + pub flatpak: bool, + pub running: bool, + pub steam_root: Option, + pub localconfig_path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SteamInjectMethod { + MangohudPrefix, + EnvVar, + ExplicitConfig, + GameMode, + GameModeFlatpak, +} + +pub async fn detect() -> SteamStatus { + let installed = which("steam").is_some(); + let flatpak = flatpak_list_contains("com.valvesoftware.Steam"); + + let mut system = System::new_all(); + system.refresh_processes(ProcessesToUpdate::All); + let running = system + .processes() + .values() + .any(|process| process.name().to_string_lossy().contains("steam")); + + let steam_root = steam_roots().into_iter().find(|path| path.exists()); + let localconfig_path = steam_root + .as_ref() + .and_then(|path| find_localconfig(path.as_path())); + + SteamStatus { + installed, + flatpak, + running, + steam_root, + localconfig_path, + } +} + +pub fn generate_launch_option(method: SteamInjectMethod, config_path: Option<&str>) -> String { + match method { + SteamInjectMethod::MangohudPrefix => "mangohud %command%".to_string(), + SteamInjectMethod::EnvVar => "MANGOHUD=1 %command%".to_string(), + SteamInjectMethod::ExplicitConfig => { + let config = config_path.unwrap_or("~/.config/MangoHud/MangoHud.conf"); + format!("MANGOHUD_CONFIGFILE={} %command%", config) + } + SteamInjectMethod::GameMode => "gamemoderun mangohud %command%".to_string(), + SteamInjectMethod::GameModeFlatpak => "gamemoderun mangohud %command%".to_string(), + } +} + +fn find_localconfig(root: &Path) -> Option { + let userdata = root.join("userdata"); + let entries = fs::read_dir(userdata).ok()?; + + for entry in entries.flatten() { + let candidate = entry.path().join("config/localconfig.vdf"); + if candidate.exists() { + return Some(candidate); + } + } + + None +} + +fn flatpak_list_contains(app_id: &str) -> bool { + let output = Command::new("flatpak").arg("list").output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + String::from_utf8_lossy(&output.stdout).contains(app_id) +} + +fn which(tool: &str) -> Option { + let output = Command::new("which").arg(tool).output().ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_option_generation_covers_all_modes() { + assert!( + generate_launch_option(SteamInjectMethod::MangohudPrefix, None) + .contains("mangohud %command%") + ); + assert!(generate_launch_option(SteamInjectMethod::EnvVar, None).contains("MANGOHUD=1")); + assert!( + generate_launch_option(SteamInjectMethod::ExplicitConfig, Some("/tmp/mh.conf")) + .contains("/tmp/mh.conf") + ); + assert!(generate_launch_option(SteamInjectMethod::GameMode, None).contains("gamemoderun")); + } +} diff --git a/src/launcher/mod.rs b/src/launcher/mod.rs new file mode 100644 index 0000000..2a13e02 --- /dev/null +++ b/src/launcher/mod.rs @@ -0,0 +1,3 @@ +pub mod runner; + +pub use runner::{LaunchConfig, Runner, RunningProcess}; diff --git a/src/launcher/runner.rs b/src/launcher/runner.rs new file mode 100644 index 0000000..b644608 --- /dev/null +++ b/src/launcher/runner.rs @@ -0,0 +1,564 @@ +use anyhow::{anyhow, Context, Result}; +use nix::sys::signal::{kill, Signal}; +use nix::unistd::Pid; +use std::collections::{HashSet, VecDeque}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +pub struct LaunchConfig { + pub command: String, + pub args: Vec, + pub config_path: PathBuf, + pub show_terminal: bool, + pub env: Vec<(String, String)>, +} + +pub struct RunningProcess { + pub child: Child, + pub command: String, + pub pid: u32, +} + +pub struct Runner; + +impl Runner { + /// Check if a tool is available on PATH. + pub fn is_available(tool: &str) -> bool { + which(tool).is_some() + } + + /// Launch a tool with MANGOHUD=1 and the specified config file. + pub fn launch(config: LaunchConfig) -> Result { + if config.command.trim().is_empty() { + return Err(anyhow!("launch command cannot be empty")); + } + + let mut command = if config.show_terminal { + build_terminal_command(&config).unwrap_or_else(|| build_direct_command(&config)) + } else { + build_direct_command(&config) + }; + + command + .env("MANGOHUD", "1") + .env("MANGOHUD_CONFIGFILE", &config.config_path) + .stdin(Stdio::null()); + for (key, value) in &config.env { + command.env(key, value); + } + + let child = command + .spawn() + .with_context(|| format!("failed to launch command '{}':", config.command))?; + + let pid = child.id(); + + Ok(RunningProcess { + child, + command: config.command, + pid, + }) + } + + /// Stop a running process (SIGTERM, then SIGKILL after 3s). + pub fn stop(mut process: RunningProcess) -> Result<()> { + let _ = kill(Pid::from_raw(process.child.id() as i32), Signal::SIGTERM); + + let deadline = Instant::now() + Duration::from_secs(3); + loop { + match process.child.try_wait() { + Ok(Some(_status)) => return Ok(()), + Ok(None) => { + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(err) => { + return Err(anyhow!("failed waiting for process exit: {err}")); + } + } + } + + let _ = kill(Pid::from_raw(process.child.id() as i32), Signal::SIGKILL); + let _ = process.child.wait(); + Ok(()) + } + + /// Send SIGUSR1 to reload config in a running MangoHud process. + pub fn reload_config(pid: u32) -> Result<()> { + kill(Pid::from_raw(pid as i32), Signal::SIGUSR1)?; + Ok(()) + } + + pub fn active_window_geometry() -> Option<(i32, i32, i32, i32)> { + active_window_geometry() + } + + pub fn window_geometry_for_pid( + pid: u32, + title_hint: Option<&str>, + ) -> Option<(i32, i32, i32, i32)> { + window_geometry_for_pid(pid, title_hint) + } + + /// Best-effort docking: place a launched window to the right of the currently + /// active window (typically MangoTune). Works on X11 when wmctrl is available. + pub async fn dock_right_of_active_window( + pid: u32, + width: i32, + height: i32, + command_hint: Option<&str>, + anchor_geometry: Option<(i32, i32, i32, i32)>, + ) -> Result<()> { + if std::env::var("WAYLAND_DISPLAY") + .ok() + .is_some_and(|value| !value.is_empty()) + { + return Err(anyhow!("docking is not supported on Wayland")); + } + if which("wmctrl").is_none() { + return Err(anyhow!("wmctrl is not installed")); + } + + let (base_x, base_y, base_width, _) = anchor_geometry + .or_else(active_window_geometry) + .unwrap_or((40, 40, 900, 600)); + let (target_x, target_y) = dock_target_position( + (base_x, base_y, base_width), + (width, height), + display_geometry().unwrap_or((1920, 1080)), + ); + + let window_id = wait_for_window_id(pid, command_hint).await?; + run_wmctrl_move(&window_id, target_x, target_y, width, height)?; + Ok(()) + } +} + +fn build_direct_command(config: &LaunchConfig) -> Command { + let mut cmd = Command::new(&config.command); + cmd.args(&config.args); + cmd +} + +fn build_terminal_command(config: &LaunchConfig) -> Option { + let shell_line = shell_command_line(&config.command, &config.args); + + if let Some(term) = std::env::var("MANGOTUNE_TERMINAL") + .ok() + .filter(|item| !item.trim().is_empty()) + { + return Some(build_shell_terminal_command(term.into(), &shell_line)); + } + + if let Some(term_program) = std::env::var("TERM_PROGRAM") + .ok() + .filter(|item| !item.trim().is_empty()) + { + if let Some(path) = which(&term_program) { + return Some(build_shell_terminal_command(path.into(), &shell_line)); + } + } + + for candidate in [ + "gnome-terminal", + "kgx", + "konsole", + "xfce4-terminal", + "mate-terminal", + "lxterminal", + "xterm", + ] { + if let Some(path) = which(candidate) { + return Some(build_shell_terminal_command(path.into(), &shell_line)); + } + } + + None +} + +fn build_shell_terminal_command(term: OsString, shell_line: &str) -> Command { + let term_str = term.to_string_lossy(); + let mut cmd = Command::new(&term); + + match term_str.as_ref() { + name if name.contains("gnome-terminal") => { + cmd.arg("--").arg("sh").arg("-lc").arg(shell_line); + } + name if name.contains("kgx") => { + cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line); + } + name if name.contains("konsole") => { + cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line); + } + name if name.contains("xterm") + || name.contains("xfce4-terminal") + || name.contains("mate-terminal") + || name.contains("lxterminal") => + { + cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line); + } + _ => { + cmd.arg("-e").arg("sh").arg("-lc").arg(shell_line); + } + } + + cmd +} + +fn shell_command_line(command: &str, args: &[String]) -> String { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(shell_escape(command)); + parts.extend(args.iter().map(|item| shell_escape(item))); + parts.join(" ") +} + +fn shell_escape(text: &str) -> String { + if text.is_empty() { + return "''".to_string(); + } + if text + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || "-_=./:".contains(ch)) + { + return text.to_string(); + } + format!("'{}'", text.replace('\'', "'\\''")) +} + +fn which(tool: &str) -> Option { + let output = std::process::Command::new("which") + .arg(tool) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +fn active_window_geometry() -> Option<(i32, i32, i32, i32)> { + let output = std::process::Command::new("xdotool") + .args(["getactivewindow", "getwindowgeometry", "--shell"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_active_window_geometry(&String::from_utf8_lossy(&output.stdout)) +} + +fn window_geometry_for_pid(pid: u32, title_hint: Option<&str>) -> Option<(i32, i32, i32, i32)> { + let output = std::process::Command::new("wmctrl") + .args(["-lpG"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_wmctrl_geometry_for_pid(&String::from_utf8_lossy(&output.stdout), pid, title_hint) +} + +fn display_geometry() -> Option<(i32, i32)> { + let output = std::process::Command::new("xdotool") + .args(["getdisplaygeometry"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_display_geometry(&String::from_utf8_lossy(&output.stdout)) +} + +async fn wait_for_window_id(pid: u32, command_hint: Option<&str>) -> Result { + let hint = command_hint.map(|value| value.to_ascii_lowercase()); + for _ in 0..20 { + if let Some(id) = window_id_for_pid(pid, hint.as_deref()) { + return Ok(id); + } + glib::timeout_future(Duration::from_millis(150)).await; + } + Err(anyhow!("failed to find window id for pid {pid}")) +} + +fn window_id_for_pid(pid: u32, command_hint: Option<&str>) -> Option { + let output = std::process::Command::new("wmctrl") + .arg("-lp") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let pids = process_tree_pids(pid); + let hint = command_hint.map(|value| value.to_ascii_lowercase()); + let mut title_match = None; + + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 5 { + continue; + } + if parts[2] + .parse::() + .ok() + .is_some_and(|item| pids.contains(&item)) + { + return Some(parts[0].to_string()); + } + + if let Some(hint) = hint.as_ref() { + let title = parts[4..].join(" ").to_ascii_lowercase(); + if title.contains(hint) && title_match.is_none() { + title_match = Some(parts[0].to_string()); + } + } + } + + title_match +} + +fn process_tree_pids(root_pid: u32) -> HashSet { + let mut seen = HashSet::new(); + let mut queue = VecDeque::new(); + seen.insert(root_pid); + queue.push_back(root_pid); + + while let Some(pid) = queue.pop_front() { + for child in child_pids(pid) { + if seen.insert(child) { + queue.push_back(child); + } + } + } + + seen +} + +fn child_pids(parent_pid: u32) -> Vec { + let output = std::process::Command::new("pgrep") + .arg("-P") + .arg(parent_pid.to_string()) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect() +} + +fn run_wmctrl_move(window_id: &str, x: i32, y: i32, width: i32, height: i32) -> Result<()> { + let geometry = format!("0,{x},{y},{width},{height}"); + let status = std::process::Command::new("wmctrl") + .args(["-ir", window_id, "-e", &geometry]) + .status() + .context("failed to execute wmctrl move command")?; + if !status.success() { + return Err(anyhow!("wmctrl failed to move window")); + } + Ok(()) +} + +fn dock_target_position( + anchor: (i32, i32, i32), + size: (i32, i32), + screen: (i32, i32), +) -> (i32, i32) { + let (base_x, base_y, base_width) = anchor; + let (width, height) = size; + let (screen_width, screen_height) = screen; + let gap = 16; + + let preferred_right = base_x + base_width + gap; + let left_candidate = base_x - gap - width; + let max_x = (screen_width - width).max(0); + let max_y = (screen_height - height).max(0); + + let x = if preferred_right + width <= screen_width { + preferred_right + } else if left_candidate >= 0 { + left_candidate + } else { + preferred_right.clamp(0, max_x) + }; + let y = base_y.clamp(0, max_y); + (x, y) +} + +fn parse_active_window_geometry(output: &str) -> Option<(i32, i32, i32, i32)> { + let mut x = None; + let mut y = None; + let mut width = None; + let mut height = None; + + for line in output.lines() { + let (key, value) = line.split_once('=')?; + match key { + "X" => x = value.parse::().ok(), + "Y" => y = value.parse::().ok(), + "WIDTH" => width = value.parse::().ok(), + "HEIGHT" => height = value.parse::().ok(), + _ => {} + } + } + + Some((x?, y?, width?, height?)) +} + +fn parse_display_geometry(output: &str) -> Option<(i32, i32)> { + let mut parts = output.split_whitespace(); + let width = parts.next()?.parse::().ok()?; + let height = parts.next()?.parse::().ok()?; + Some((width, height)) +} + +fn parse_wmctrl_geometry_for_pid( + output: &str, + pid: u32, + title_hint: Option<&str>, +) -> Option<(i32, i32, i32, i32)> { + let pids = process_tree_pids(pid); + let hint = title_hint.map(|value| value.to_ascii_lowercase()); + let mut best_hint_match: Option<((i32, i32, i32, i32), i32)> = None; + let mut best_pid_match: Option<((i32, i32, i32, i32), i32)> = None; + + for line in output.lines() { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 8 { + continue; + } + + let Some(window_pid) = parts[2].parse::().ok() else { + continue; + }; + if !pids.contains(&window_pid) { + continue; + } + + let Some(x) = parts[3].parse::().ok() else { + continue; + }; + let Some(y) = parts[4].parse::().ok() else { + continue; + }; + let Some(width) = parts[5].parse::().ok() else { + continue; + }; + let Some(height) = parts[6].parse::().ok() else { + continue; + }; + + let geometry = (x, y, width, height); + let area = width.saturating_mul(height); + let title = parts + .get(8..) + .map(|rest| rest.join(" ")) + .unwrap_or_default(); + let title_matches = hint + .as_ref() + .is_some_and(|needle| title.to_ascii_lowercase().contains(needle)); + + if title_matches { + match best_hint_match { + Some((_, current_area)) if current_area >= area => {} + _ => best_hint_match = Some((geometry, area)), + } + } else { + match best_pid_match { + Some((_, current_area)) if current_area >= area => {} + _ => best_pid_match = Some((geometry, area)), + } + } + } + + best_hint_match + .map(|(geometry, _)| geometry) + .or_else(|| best_pid_match.map(|(geometry, _)| geometry)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn launch_and_stop_basic_process() { + let dir = tempdir().expect("tempdir"); + let cfg = LaunchConfig { + command: "sh".to_string(), + args: vec!["-lc".to_string(), "sleep 1".to_string()], + config_path: dir.path().join("MangoHud.conf"), + show_terminal: false, + env: Vec::new(), + }; + + let running = Runner::launch(cfg).expect("launch"); + assert!(running.pid > 0); + Runner::stop(running).expect("stop"); + } + + #[test] + fn command_line_is_escaped() { + let line = shell_command_line("/usr/bin/echo", &["hello world".into(), "x".into()]); + assert!(line.contains("'hello world'")); + assert!(line.contains("/usr/bin/echo")); + } + + #[test] + fn availability_check_runs() { + let _ = Runner::is_available("sh"); + } + + #[test] + fn parses_xdotool_geometry_output() { + let sample = "WINDOW=123\nX=240\nY=120\nWIDTH=1024\nHEIGHT=640\nSCREEN=0\n"; + assert_eq!( + parse_active_window_geometry(sample), + Some((240, 120, 1024, 640)) + ); + } + + #[test] + fn parses_display_geometry_output() { + assert_eq!(parse_display_geometry("2560 1440\n"), Some((2560, 1440))); + } + + #[test] + fn parses_wmctrl_geometry_for_pid_preferring_title_match() { + let sample = "\ +0x01200007 0 4242 40 50 420 220 host Settings\n\ +0x0140000a 0 4242 120 80 920 780 host MangoTune\n\ +0x0160000c 0 7777 10 20 600 400 host Other App\n"; + assert_eq!( + parse_wmctrl_geometry_for_pid(sample, 4242, Some("MangoTune")), + Some((120, 80, 920, 780)) + ); + } + + #[test] + fn docks_left_when_right_side_would_be_offscreen() { + let pos = dock_target_position((1400, 100, 700), (900, 700), (1920, 1080)); + assert_eq!(pos, (484, 100)); + } + + #[test] + fn clamps_docked_position_inside_screen() { + let pos = dock_target_position((1700, 900, 500), (700, 400), (1920, 1080)); + assert_eq!(pos, (984, 680)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1ad7e7a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod debug_log; +pub mod integrations; +pub mod launcher; +pub mod preview; +pub mod profiles; +pub mod system; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..66053b0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,9 @@ +mod app; +mod ui; +mod window; + +fn main() { + tracing_subscriber::fmt::init(); + let app = app::MangoTuneApp::new(); + std::process::exit(app.run()); +} diff --git a/src/preview/mod.rs b/src/preview/mod.rs new file mode 100644 index 0000000..0af95e5 --- /dev/null +++ b/src/preview/mod.rs @@ -0,0 +1,1357 @@ +use crate::config::normalize::normalize_legacy_option_values; +use crate::config::parser::Parser; +use crate::config::types::{AnnotatedConfig, ConfigValue}; +use crate::launcher::{LaunchConfig, Runner, RunningProcess}; +use anyhow::{anyhow, Context, Result}; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[derive(Clone)] +pub struct PreviewController { + inner: Arc>, +} + +struct PreviewState { + current: Option, + last_request: Option, + temp_config_path: PathBuf, + temp_socket_path: PathBuf, +} + +struct RunningPreview { + process: RunningProcess, + request: PreviewLaunchRequest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreviewScene { + Studio, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StudioScene { + DarkArena, + BrightWash, + MotionStress, + StaticInspection, + NoiseField, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PreviewStudioOptions { + pub scene: StudioScene, + pub fps_cap: Option, + pub vsync: bool, + pub vram_pressure_mb: u32, + pub particle_count: u32, + pub particle_size: f32, + pub gpu_passes: u32, + pub interaction_steps: u32, + pub paused: bool, +} + +#[derive(Debug, Clone)] +struct PreviewLaunchRequest { + scene: PreviewScene, + width: i32, + height: i32, + show_terminal: bool, + studio: PreviewStudioOptions, + layout_width_override: Option, +} + +#[derive(Debug, Clone)] +pub struct PreviewSnapshot { + pub running: bool, + pub pid: Option, + pub scene: Option, + pub can_restart: bool, + pub status: String, +} + +impl PreviewScene { + pub fn label(self) -> &'static str { + "studio" + } + + pub fn available_scenes() -> Vec { + if find_preview_binary().is_some() { + vec![Self::Studio] + } else { + Vec::new() + } + } + + pub fn from_label(value: &str) -> Option { + (value == "studio").then_some(Self::Studio) + } + + fn command_hint(self) -> &'static str { + "mangotune-preview" + } + + fn launch( + self, + width: i32, + height: i32, + show_terminal: bool, + config_path: PathBuf, + socket_path: PathBuf, + studio: &PreviewStudioOptions, + ) -> Result { + Ok(LaunchConfig { + command: find_preview_binary() + .context("mangotune-preview binary not found; build/install it first")? + .display() + .to_string(), + args: Vec::new(), + config_path, + show_terminal, + env: vec![ + ( + "MANGOTUNE_SOCKET".to_string(), + socket_path.display().to_string(), + ), + ("MANGOTUNE_PREVIEW_WIDTH".to_string(), width.to_string()), + ("MANGOTUNE_PREVIEW_HEIGHT".to_string(), height.to_string()), + ( + "MANGOTUNE_PREVIEW_SCENE".to_string(), + studio.scene.label().to_string(), + ), + ( + "MANGOTUNE_PREVIEW_VRAM_MB".to_string(), + studio.vram_pressure_mb.to_string(), + ), + ( + "MANGOTUNE_PREVIEW_PARTICLE_COUNT".to_string(), + studio.particle_count.to_string(), + ), + ( + "MANGOTUNE_PREVIEW_PARTICLE_SIZE".to_string(), + format!("{:.2}", studio.particle_size), + ), + ( + "MANGOTUNE_PREVIEW_GPU_PASSES".to_string(), + studio.gpu_passes.to_string(), + ), + ( + "MANGOTUNE_PREVIEW_INTERACTION_STEPS".to_string(), + studio.interaction_steps.to_string(), + ), + ( + "MANGOTUNE_PREVIEW_FPS_CAP".to_string(), + studio.fps_cap.unwrap_or(0).to_string(), + ), + ( + "MANGOTUNE_PREVIEW_VSYNC".to_string(), + if studio.vsync { "1" } else { "0" }.to_string(), + ), + ], + }) + } +} + +impl StudioScene { + pub fn label(self) -> &'static str { + match self { + Self::DarkArena => "dark-arena", + Self::BrightWash => "bright-wash", + Self::MotionStress => "motion-stress", + Self::StaticInspection => "static-inspection", + Self::NoiseField => "noise-field", + } + } + + pub fn from_label(value: &str) -> Option { + match value { + "dark-arena" | "dark" => Some(Self::DarkArena), + "bright-wash" | "bright" => Some(Self::BrightWash), + "motion-stress" | "motion" => Some(Self::MotionStress), + "static-inspection" | "readability" => Some(Self::StaticInspection), + "noise-field" => Some(Self::NoiseField), + _ => None, + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::DarkArena, + Self::BrightWash, + Self::MotionStress, + Self::StaticInspection, + Self::NoiseField, + ] + } +} + +impl Default for PreviewStudioOptions { + fn default() -> Self { + Self { + scene: StudioScene::DarkArena, + fps_cap: Some(120), + vsync: false, + vram_pressure_mb: 0, + particle_count: 1_000, + particle_size: 0.03, + gpu_passes: 1, + interaction_steps: 0, + paused: false, + } + } +} + +impl Default for PreviewController { + fn default() -> Self { + Self::new() + } +} + +impl PreviewController { + pub fn new() -> Self { + let temp_config_path = + std::env::temp_dir().join(format!("mangotune-preview-{}.conf", std::process::id())); + let temp_socket_path = + std::env::temp_dir().join(format!("mangotune-preview-{}.sock", std::process::id())); + Self { + inner: Arc::new(Mutex::new(PreviewState { + current: None, + last_request: None, + temp_config_path, + temp_socket_path, + })), + } + } + + pub fn snapshot(&self) -> PreviewSnapshot { + let Ok(mut state) = self.inner.lock() else { + return PreviewSnapshot { + running: false, + pid: None, + scene: None, + can_restart: false, + status: "Preview state unavailable".to_string(), + }; + }; + + sync_process_state(&mut state); + if let Some(current) = &state.current { + return PreviewSnapshot { + running: true, + pid: Some(current.process.pid), + scene: Some(current.request.scene), + can_restart: true, + status: format!( + "Live preview running with {} (pid {})", + request_summary(¤t.request), + current.process.pid + ), + }; + } + + let status = state + .last_request + .as_ref() + .map(|request| { + format!( + "Preview stopped. Last setup: {} at {}x{}", + request_summary(request), + request.width, + request.height + ) + }) + .unwrap_or_else(|| "Preview not started".to_string()); + + PreviewSnapshot { + running: false, + pid: None, + scene: state.last_request.as_ref().map(|request| request.scene), + can_restart: state.last_request.is_some(), + status, + } + } + + pub fn start( + &self, + scene: PreviewScene, + config: &AnnotatedConfig, + width: i32, + height: i32, + show_terminal: bool, + studio: PreviewStudioOptions, + ) -> Result { + self.start_with_request( + config, + PreviewLaunchRequest { + scene, + width, + height, + show_terminal, + studio, + layout_width_override: None, + }, + ) + } + + pub fn restart(&self, config: &AnnotatedConfig) -> Result { + let request = { + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + sync_process_state(&mut state); + state + .last_request + .clone() + .context("preview has not been started yet")? + }; + + self.start_with_request(config, request) + } + + pub fn reload(&self, config: &AnnotatedConfig) -> Result { + self.reload_with_width(config, None) + } + + pub fn apply_live_config(&self, config: &AnnotatedConfig) -> Result { + let (temp_path, request, pid) = { + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + sync_process_state(&mut state); + + let current = state.current.as_ref().context("preview is not running")?; + ( + state.temp_config_path.clone(), + current.request.clone(), + current.process.pid, + ) + }; + + let layout_width = request.layout_width_override.unwrap_or(request.width); + write_preview_config(config, &temp_path, layout_width, request.scene)?; + + Ok(pid) + } + + pub fn reload_with_width( + &self, + config: &AnnotatedConfig, + current_preview_width: Option, + ) -> Result { + let (temp_path, request) = { + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + sync_process_state(&mut state); + + let current = state.current.as_ref().context("preview is not running")?; + (state.temp_config_path.clone(), current.request.clone()) + }; + + write_preview_config( + config, + &temp_path, + current_preview_width + .or(request.layout_width_override) + .unwrap_or(request.width), + request.scene, + )?; + let mut restart_request = request.clone(); + restart_request.layout_width_override = + current_preview_width.or(request.layout_width_override); + self.start_with_request(config, restart_request) + } + + pub fn stop(&self) -> Result { + let (running, socket_path, temp_config_path) = { + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + sync_process_state(&mut state); + ( + state.current.take(), + state.temp_socket_path.clone(), + state.temp_config_path.clone(), + ) + }; + + if let Some(running) = running { + if running.request.scene == PreviewScene::Studio { + let _ = send_studio_command(&socket_path, r#"{"cmd":"quit"}"#); + std::thread::sleep(Duration::from_millis(120)); + } + Runner::stop(running.process)?; + let _ = fs::remove_file(socket_path); + let _ = fs::remove_file(temp_config_path); + return Ok(true); + } + + Ok(false) + } + + pub fn running_scene(&self) -> Option { + let Ok(mut state) = self.inner.lock() else { + return None; + }; + sync_process_state(&mut state); + state.current.as_ref().map(|current| current.request.scene) + } + + pub fn update_studio_runtime(&self, studio: PreviewStudioOptions) -> Result<()> { + let socket_path = { + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + sync_process_state(&mut state); + let current = state.current.as_mut().context("preview is not running")?; + if current.request.scene != PreviewScene::Studio { + return Err(anyhow!("studio preview is not running")); + } + current.request.studio = studio.clone(); + state.last_request = Some(current.request.clone()); + state.temp_socket_path.clone() + }; + + apply_studio_runtime_settings(&socket_path, &studio) + } + + pub async fn dock_running_window(&self, anchor: Option<(i32, i32, i32, i32)>) -> Result<()> { + let request = { + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + sync_process_state(&mut state); + state + .current + .as_ref() + .map(|current| { + ( + current.process.pid, + current.request.scene, + current.request.width, + current.request.height, + ) + }) + .context("preview is not running")? + }; + + Runner::dock_right_of_active_window( + request.0, + request.2, + request.3, + Some(request.1.command_hint()), + anchor, + ) + .await + } + + fn start_with_request( + &self, + config: &AnnotatedConfig, + request: PreviewLaunchRequest, + ) -> Result { + let running = { + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + sync_process_state(&mut state); + state.current.take() + }; + + if let Some(running) = running { + let _ = Runner::stop(running.process); + } + + let (temp_path, socket_path) = { + let Ok(state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + ( + state.temp_config_path.clone(), + state.temp_socket_path.clone(), + ) + }; + + let layout_width = request.layout_width_override.unwrap_or(request.width); + write_preview_config(config, &temp_path, layout_width, request.scene)?; + if request.scene == PreviewScene::Studio { + let _ = fs::remove_file(&socket_path); + } + let process = Runner::launch(request.scene.launch( + request.width, + request.height, + request.show_terminal, + temp_path.clone(), + socket_path.clone(), + &request.studio, + )?)?; + let pid = process.pid; + + let mut request = request; + if request.scene == PreviewScene::Studio && request.layout_width_override.is_none() { + if let Some((actual_width, _actual_height)) = detect_studio_window_size(pid) { + if (actual_width - request.width).abs() >= 8 { + request.layout_width_override = Some(actual_width); + write_preview_config(config, &temp_path, actual_width, request.scene)?; + } + } + } + + if request.scene == PreviewScene::Studio { + wait_for_studio_socket(&socket_path, Duration::from_secs(3)) + .context("studio preview socket did not appear")?; + apply_studio_runtime_settings(&socket_path, &request.studio)?; + } + + let Ok(mut state) = self.inner.lock() else { + return Err(anyhow!("could not access preview state")); + }; + state.last_request = Some(request.clone()); + state.current = Some(RunningPreview { process, request }); + + Ok(pid) + } +} + +fn write_preview_config( + config: &AnnotatedConfig, + path: &PathBuf, + preview_width: i32, + _scene: PreviewScene, +) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create preview config dir {}", parent.display()))?; + } + + let mut preview_config = config.clone(); + normalize_legacy_option_values(&mut preview_config); + preview_config.path = Some(path.clone()); + preview_config.dirty = false; + // Preview sessions should start visible even if the saved config hides MangoHud by default. + Parser::set_value(&mut preview_config, "no_display", ConfigValue::Disabled); + apply_preview_layout_overrides(&mut preview_config, preview_width); + fs::write(path, Parser::to_string(&preview_config)) + .with_context(|| format!("failed to write preview config {}", path.display()))?; + Ok(()) +} + +fn apply_preview_layout_overrides(config: &mut AnnotatedConfig, preview_width: i32) { + if !is_flag_effectively_enabled(config, "horizontal") + || is_flag_effectively_enabled(config, "horizontal_stretch") + { + return; + } + + let effective_width = effective_preview_hud_width(config, preview_width); + if let Some(fallback_width) = effective_width { + Parser::set_value( + config, + "width", + ConfigValue::Value(fallback_width.to_string()), + ); + } + + if let Some((preview_position, preview_offset_x)) = + preview_right_alignment_override(config, preview_width, effective_width) + { + Parser::set_value( + config, + "position", + ConfigValue::Value(preview_position.to_string()), + ); + Parser::set_value( + config, + "offset_x", + ConfigValue::Value(preview_offset_x.to_string()), + ); + } +} + +fn is_flag_effectively_enabled(config: &AnnotatedConfig, key: &str) -> bool { + match config.options.get(key).map(|(_, value)| value) { + Some(ConfigValue::Flag) => true, + Some(ConfigValue::Disabled) | Some(ConfigValue::Absent) => false, + Some(ConfigValue::Value(raw)) => { + let normalized = raw.trim().to_ascii_lowercase(); + !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") + } + _ => false, + } +} + +pub fn effective_preview_hud_width(config: &AnnotatedConfig, preview_width: i32) -> Option { + let explicit_width = config + .options + .get("width") + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => value.parse::().ok(), + _ => None, + }) + .unwrap_or(0) + .clamp(0, 4096); + + if explicit_width > 0 { + Some(explicit_width) + } else if is_flag_effectively_enabled(config, "horizontal") + && !is_flag_effectively_enabled(config, "horizontal_stretch") + { + Some(estimate_preview_horizontal_width(config, preview_width)) + } else { + None + } +} + +fn preview_right_alignment_override( + config: &AnnotatedConfig, + preview_width: i32, + effective_width: Option, +) -> Option<(&'static str, i32)> { + let position = config + .options + .get("position") + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.as_str()), + _ => None, + })?; + let mapped_position = match position { + "top-right" => "top-left", + "middle-right" => "middle-left", + "bottom-right" => "bottom-left", + _ => return None, + }; + + let configured_offset = config + .options + .get("offset_x") + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => value.parse::().ok(), + _ => None, + }) + .unwrap_or(0) + .abs(); + + let hud_width = effective_width.unwrap_or((preview_width - 120).max(520)); + let preview_margin = if is_flag_effectively_enabled(config, "hud_no_margin") { + 0 + } else { + 20 + }; + let anchored_left = (preview_width - hud_width - preview_margin - configured_offset).max(0); + + Some((mapped_position, anchored_left)) +} + +fn estimate_preview_horizontal_width(config: &AnnotatedConfig, preview_width: i32) -> i32 { + let weighted_metrics = [ + ("fps_only", 148), + ("fps", 86), + ("frametime", 94), + ("frame_timing", 120), + ("frame_count", 78), + ("show_fps_limit", 88), + ("gpu_stats", 94), + ("gpu_temp", 72), + ("gpu_power", 76), + ("gpu_mem_clock", 86), + ("gpu_mem_temp", 86), + ("gpu_name", 142), + ("vulkan_driver", 92), + ("engine_version", 104), + ("present_mode", 100), + ("cpu_stats", 94), + ("cpu_temp", 72), + ("cpu_power", 76), + ("cpu_mhz", 74), + ("cpu_efficiency", 92), + ("core_load", 172), + ("vram", 88), + ("ram", 82), + ("swap", 74), + ("procmem", 96), + ("proc_vram", 92), + ("io_read", 82), + ("io_write", 86), + ("network", 94), + ("battery", 84), + ("battery_icon", 48), + ("device_battery", 88), + ("media_player", 220), + ("wine", 66), + ("winesync", 76), + ("gamemode", 76), + ("vkbasalt", 78), + ("version", 96), + ("resolution", 116), + ("display_server", 120), + ("time", 96), + ("custom_text", 180), + ("exec", 180), + ]; + + let active_metric_count = weighted_metrics + .iter() + .filter(|(key, _)| is_flag_effectively_enabled(config, key)) + .count() as i32; + + let metric_width = weighted_metrics + .iter() + .filter(|(key, _)| is_flag_effectively_enabled(config, key)) + .map(|(_, width)| *width) + .sum::(); + + let separator_width = active_metric_count.saturating_sub(1) * 18; + let compact_factor = if is_flag_effectively_enabled(config, "hud_compact") { + 0.92 + } else { + 1.0 + }; + let font_size = config + .options + .get("font_size") + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => value.parse::().ok(), + _ => None, + }) + .unwrap_or(24.0) + .clamp(8.0, 72.0); + let font_scale = config + .options + .get("font_scale") + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => value.parse::().ok(), + _ => None, + }) + .unwrap_or(1.0) + .clamp(0.5, 3.0); + let typography_factor = ((font_size / 24.0) * font_scale).clamp(0.78, 1.55); + let compact = is_flag_effectively_enabled(config, "hud_compact"); + let sparse_layout_reduction = match (compact, active_metric_count) { + // Sparse non-compact HUDs still render a touch wider than the raw weighted sum suggests, + // while compact sparse layouts end up noticeably narrower. Keep those branches separate + // so right-edge anchoring does not need extra positional heuristics. + (false, 0..=5) => 90, + (false, 6..=7) => 84, + (true, 0..=5) => 76, + (true, 6..=7) => 72, + _ => 0, + }; + + let estimated = + (((metric_width + separator_width + 72) as f32) * compact_factor * typography_factor) + .round() as i32 + - sparse_layout_reduction; + let max_width = (preview_width - 120).max(520); + estimated.clamp(360, max_width) +} + +fn sync_process_state(state: &mut PreviewState) { + let Some(current) = state.current.as_mut() else { + return; + }; + + match current.process.child.try_wait() { + Ok(Some(_)) | Err(_) => { + state.current = None; + } + Ok(None) => {} + } +} + +fn request_summary(request: &PreviewLaunchRequest) -> String { + format!( + "{} / {} / {} MB / {} particles / {:.2} size / {} pass{} / {} cpu step{}{}", + request.scene.label(), + request + .studio + .fps_cap + .map(|fps| format!("{fps} FPS cap")) + .unwrap_or_else(|| "uncapped".to_string()), + request.studio.vram_pressure_mb, + request.studio.particle_count, + request.studio.particle_size, + request.studio.gpu_passes, + if request.studio.gpu_passes == 1 { + "" + } else { + "es" + }, + request.studio.interaction_steps, + if request.studio.interaction_steps == 1 { + "" + } else { + "s" + }, + if request.studio.vsync { " / vsync" } else { "" }, + ) +} + +fn wait_for_studio_socket(path: &Path, timeout: Duration) -> Result<()> { + let deadline = std::time::Instant::now() + timeout; + while std::time::Instant::now() < deadline { + if path.exists() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(anyhow!("socket {} was not created", path.display())) +} + +fn send_studio_command(path: &Path, command: &str) -> Result { + let mut stream = UnixStream::connect(path) + .with_context(|| format!("failed to connect to studio socket {}", path.display()))?; + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .context("failed to set studio socket read timeout")?; + stream + .set_write_timeout(Some(Duration::from_secs(1))) + .context("failed to set studio socket write timeout")?; + stream + .write_all(command.as_bytes()) + .context("failed to write studio socket command")?; + stream + .write_all(b"\n") + .context("failed to terminate studio socket command")?; + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader + .read_line(&mut response) + .context("failed to read studio socket response")?; + if response.trim().is_empty() { + return Err(anyhow!("empty response from studio socket")); + } + if response.contains(r#""ok":false"#) { + return Err(anyhow!("studio socket error: {}", response.trim())); + } + Ok(response) +} + +fn apply_studio_runtime_settings(path: &Path, studio: &PreviewStudioOptions) -> Result<()> { + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_scene","scene":"{}"}}"#, + studio.scene.label() + ), + )?; + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_particle_count","count":{}}}"#, + studio.particle_count + ), + )?; + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_vram_pressure","mb":{}}}"#, + studio.vram_pressure_mb + ), + )?; + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_particle_size","size":{:.2}}}"#, + studio.particle_size + ), + )?; + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_gpu_passes","passes":{}}}"#, + studio.gpu_passes + ), + )?; + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_interaction_steps","steps":{}}}"#, + studio.interaction_steps + ), + )?; + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_vsync","enabled":{}}}"#, + if studio.vsync { "true" } else { "false" } + ), + )?; + send_studio_command( + path, + &format!( + r#"{{"cmd":"set_fps_cap","fps":{}}}"#, + studio.fps_cap.unwrap_or(0) + ), + )?; + send_studio_command( + path, + if studio.paused { + r#"{"cmd":"pause"}"# + } else { + r#"{"cmd":"resume"}"# + }, + )?; + Ok(()) +} + +fn find_preview_binary() -> Option { + if let Some(override_path) = std::env::var_os("MANGOTUNE_PREVIEW_BIN") { + let path = PathBuf::from(override_path); + if path.is_file() { + return Some(path); + } + } + + if let Ok(current_exe) = std::env::current_exe() { + if let Some(dir) = current_exe.parent() { + let candidate = dir.join("mangotune-preview"); + if candidate.is_file() { + return Some(candidate); + } + } + } + + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join("mangotune-preview")) + .find(|path| path.is_file()) + }) +} + +fn detect_studio_window_size(pid: u32) -> Option<(i32, i32)> { + let pid = pid.to_string(); + for _ in 0..50 { + if let Some(size) = detect_studio_window_size_for_pid(&pid) { + return Some(size); + } + if let Some(size) = detect_active_studio_window_size() { + return Some(size); + } + std::thread::sleep(Duration::from_millis(80)); + } + None +} + +fn detect_studio_window_size_for_pid(pid: &str) -> Option<(i32, i32)> { + let output = std::process::Command::new("xdotool") + .args([ + "search", + "--pid", + pid, + "--name", + "MangoTune Preview", + "getwindowgeometry", + "--shell", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_window_geometry_shell(&String::from_utf8_lossy(&output.stdout)) +} + +fn detect_active_studio_window_size() -> Option<(i32, i32)> { + let name_output = std::process::Command::new("xdotool") + .args(["getactivewindow", "getwindowname"]) + .output() + .ok()?; + if !name_output.status.success() { + return None; + } + let name = String::from_utf8_lossy(&name_output.stdout); + if !name.contains("MangoTune Preview") { + return None; + } + + let geometry_output = std::process::Command::new("xdotool") + .args(["getactivewindow", "getwindowgeometry", "--shell"]) + .output() + .ok()?; + if !geometry_output.status.success() { + return None; + } + parse_window_geometry_shell(&String::from_utf8_lossy(&geometry_output.stdout)) +} + +fn parse_window_geometry_shell(output: &str) -> Option<(i32, i32)> { + let mut width = None; + let mut height = None; + for line in output.lines() { + if let Some(value) = line.strip_prefix("WIDTH=") { + width = value.parse::().ok(); + } else if let Some(value) = line.strip_prefix("HEIGHT=") { + height = value.parse::().ok(); + } + } + match (width, height) { + (Some(width), Some(height)) => Some((width, height)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue}; + use indexmap::IndexMap; + use once_cell::sync::Lazy; + use std::os::unix::fs::PermissionsExt; + use std::sync::Mutex; + + static PREVIEW_ENV_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + + fn sample_config() -> AnnotatedConfig { + let mut options = IndexMap::new(); + options.insert( + "position".to_string(), + (0, ConfigValue::Value("top-left".to_string())), + ); + options.insert("no_display".to_string(), (1, ConfigValue::Flag)); + AnnotatedConfig { + lines: vec![ + ConfigLine::Option { + key: "position".to_string(), + value: Some("top-left".to_string()), + raw: "position=top-left".to_string(), + }, + ConfigLine::Option { + key: "no_display".to_string(), + value: None, + raw: "no_display".to_string(), + }, + ], + options, + path: None, + dirty: true, + } + } + + #[test] + fn studio_preview_scene_builds_expected_launch_config() { + let _guard = PREVIEW_ENV_LOCK.lock().expect("preview env lock"); + let fake_preview = std::env::temp_dir().join(format!( + "mangotune-preview-test-bin-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("thread") + )); + std::fs::write(&fake_preview, "#!/bin/sh\nexit 0\n").expect("write fake preview"); + let mut perms = std::fs::metadata(&fake_preview) + .expect("fake preview metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&fake_preview, perms).expect("chmod fake preview"); + std::env::set_var("MANGOTUNE_PREVIEW_BIN", &fake_preview); + + let studio = PreviewScene::Studio + .launch( + 1280, + 720, + false, + PathBuf::from("/tmp/test.conf"), + PathBuf::from("/tmp/test.sock"), + &PreviewStudioOptions::default(), + ) + .expect("launch config"); + assert!(studio.command.contains("mangotune-preview")); + assert!(studio.args.is_empty()); + assert!(studio.env.iter().any(|(k, _)| k == "MANGOTUNE_SOCKET")); + assert!(studio + .env + .iter() + .any(|(k, v)| k == "MANGOTUNE_PREVIEW_VSYNC" && v == "0")); + + std::env::remove_var("MANGOTUNE_PREVIEW_BIN"); + let _ = std::fs::remove_file(fake_preview); + } + + #[test] + fn studio_scene_round_trips_labels() { + for scene in StudioScene::all() { + assert_eq!(StudioScene::from_label(scene.label()), Some(*scene)); + } + assert_eq!(StudioScene::from_label("unknown"), None); + } + + #[test] + fn parses_window_geometry_shell_output() { + let output = "WINDOW=132120578\nX=4\nY=536\nWIDTH=1575\nHEIGHT=855\nSCREEN=0\n"; + assert_eq!(parse_window_geometry_shell(output), Some((1575, 855))); + } + + #[test] + fn request_summary_mentions_studio_tuning() { + let request = PreviewLaunchRequest { + scene: PreviewScene::Studio, + width: 1280, + height: 720, + show_terminal: false, + studio: PreviewStudioOptions { + scene: StudioScene::StaticInspection, + fps_cap: Some(500), + vsync: true, + vram_pressure_mb: 112, + particle_count: 64_000, + particle_size: 0.75, + gpu_passes: 6, + interaction_steps: 12, + paused: false, + }, + layout_width_override: None, + }; + assert_eq!( + request_summary(&request), + "studio / 500 FPS cap / 112 MB / 64000 particles / 0.75 size / 6 passes / 12 cpu steps / vsync" + ); + } + + #[test] + fn available_scenes_only_include_studio_when_binary_is_present() { + let scenes = PreviewScene::available_scenes(); + assert!( + scenes.is_empty() || scenes == vec![PreviewScene::Studio], + "unexpected preview scene list: {scenes:?}" + ); + } + + #[test] + fn writes_preview_config_without_mutating_source_path() { + let temp = std::env::temp_dir().join(format!( + "mangotune-preview-test-{}-{}.conf", + std::process::id(), + std::thread::current().name().unwrap_or("thread") + )); + + let config = sample_config(); + write_preview_config(&config, &temp, 1280, PreviewScene::Studio) + .expect("write preview config"); + let written = std::fs::read_to_string(&temp).expect("read preview config"); + assert!(written.contains("position=top-left")); + assert!(!written.contains("\nno_display\n")); + assert_eq!(config.path, None); + + let _ = std::fs::remove_file(temp); + } + + #[test] + fn preview_write_assigns_bounded_width_for_horizontal_auto_layout() { + let temp = std::env::temp_dir().join(format!( + "mangotune-preview-width-test-{}-{}.conf", + std::process::id(), + std::thread::current().name().unwrap_or("thread") + )); + + let mut config = sample_config(); + Parser::set_value(&mut config, "horizontal", ConfigValue::Flag); + Parser::set_value(&mut config, "width", ConfigValue::Value("0".to_string())); + Parser::set_value(&mut config, "gpu_stats", ConfigValue::Flag); + Parser::set_value(&mut config, "fps", ConfigValue::Flag); + + write_preview_config(&config, &temp, 1280, PreviewScene::Studio) + .expect("write preview config"); + let written = std::fs::read_to_string(&temp).expect("read preview config"); + assert!(written.contains("horizontal\n")); + let width = written + .lines() + .find_map(|line| line.strip_prefix("width=")) + .and_then(|value| value.parse::().ok()) + .expect("preview width"); + assert!((360..=900).contains(&width)); + + let _ = std::fs::remove_file(temp); + } + + #[test] + fn compact_competitive_style_layout_estimates_narrower_than_general_layout() { + let mut competitive = sample_config(); + Parser::set_value(&mut competitive, "horizontal", ConfigValue::Flag); + Parser::set_value( + &mut competitive, + "horizontal_stretch", + ConfigValue::Disabled, + ); + Parser::set_value(&mut competitive, "hud_compact", ConfigValue::Flag); + Parser::set_value(&mut competitive, "fps", ConfigValue::Flag); + Parser::set_value(&mut competitive, "frametime", ConfigValue::Flag); + Parser::set_value(&mut competitive, "gpu_stats", ConfigValue::Flag); + Parser::set_value(&mut competitive, "gpu_temp", ConfigValue::Flag); + Parser::set_value(&mut competitive, "vram", ConfigValue::Flag); + + let mut wider = competitive.clone(); + Parser::set_value(&mut wider, "cpu_stats", ConfigValue::Flag); + Parser::set_value(&mut wider, "cpu_temp", ConfigValue::Flag); + Parser::set_value(&mut wider, "ram", ConfigValue::Flag); + Parser::set_value(&mut wider, "present_mode", ConfigValue::Flag); + + let compact_width = estimate_preview_horizontal_width(&competitive, 1726); + let wider_width = estimate_preview_horizontal_width(&wider, 1726); + assert!(compact_width < wider_width); + } + + #[test] + fn sparse_noncompact_layout_estimates_narrower_than_full_noncompact_layout() { + let mut sparse = sample_config(); + Parser::set_value(&mut sparse, "horizontal", ConfigValue::Flag); + Parser::set_value(&mut sparse, "horizontal_stretch", ConfigValue::Disabled); + Parser::set_value(&mut sparse, "fps", ConfigValue::Flag); + Parser::set_value(&mut sparse, "frametime", ConfigValue::Flag); + Parser::set_value(&mut sparse, "gpu_stats", ConfigValue::Flag); + Parser::set_value(&mut sparse, "gpu_temp", ConfigValue::Flag); + Parser::set_value(&mut sparse, "vram", ConfigValue::Flag); + + let mut full = sparse.clone(); + Parser::set_value(&mut full, "gpu_power", ConfigValue::Flag); + Parser::set_value(&mut full, "cpu_stats", ConfigValue::Flag); + Parser::set_value(&mut full, "cpu_temp", ConfigValue::Flag); + Parser::set_value(&mut full, "cpu_power", ConfigValue::Flag); + Parser::set_value(&mut full, "ram", ConfigValue::Flag); + Parser::set_value(&mut full, "engine_short_names", ConfigValue::Flag); + + let sparse_width = estimate_preview_horizontal_width(&sparse, 1726); + let full_width = estimate_preview_horizontal_width(&full, 1726); + assert!(sparse_width < full_width); + } + + #[test] + fn sparse_right_aligned_profiles_get_stable_preview_width_estimates() { + let mut sparse = sample_config(); + Parser::set_value(&mut sparse, "horizontal", ConfigValue::Flag); + Parser::set_value(&mut sparse, "horizontal_stretch", ConfigValue::Disabled); + Parser::set_value(&mut sparse, "fps", ConfigValue::Flag); + Parser::set_value(&mut sparse, "frametime", ConfigValue::Flag); + Parser::set_value(&mut sparse, "gpu_stats", ConfigValue::Flag); + Parser::set_value(&mut sparse, "gpu_temp", ConfigValue::Flag); + Parser::set_value(&mut sparse, "vram", ConfigValue::Flag); + Parser::set_value( + &mut sparse, + "font_scale", + ConfigValue::Value("0.92".to_string()), + ); + + let mut sparse_compact = sparse.clone(); + Parser::set_value(&mut sparse_compact, "hud_compact", ConfigValue::Flag); + + assert_eq!(estimate_preview_horizontal_width(&sparse, 1575), 442); + assert_eq!( + estimate_preview_horizontal_width(&sparse_compact, 1575), + 413 + ); + } + + #[test] + fn preview_write_keeps_auto_width_when_horizontal_stretch_is_enabled() { + let temp = std::env::temp_dir().join(format!( + "mangotune-preview-stretch-test-{}-{}.conf", + std::process::id(), + std::thread::current().name().unwrap_or("thread") + )); + + let mut config = sample_config(); + Parser::set_value(&mut config, "horizontal", ConfigValue::Flag); + Parser::set_value(&mut config, "horizontal_stretch", ConfigValue::Flag); + Parser::set_value(&mut config, "width", ConfigValue::Value("0".to_string())); + + write_preview_config(&config, &temp, 1280, PreviewScene::Studio) + .expect("write preview config"); + let written = std::fs::read_to_string(&temp).expect("read preview config"); + assert!(written.contains("horizontal_stretch\n")); + assert!(written.contains("width=0\n")); + + let _ = std::fs::remove_file(temp); + } + + #[test] + fn preview_write_explicitly_disables_horizontal_stretch() { + let temp = std::env::temp_dir().join(format!( + "mangotune-preview-stretch-off-test-{}-{}.conf", + std::process::id(), + std::thread::current().name().unwrap_or("thread") + )); + + let mut config = sample_config(); + Parser::set_value(&mut config, "horizontal", ConfigValue::Flag); + Parser::set_value(&mut config, "horizontal_stretch", ConfigValue::Disabled); + Parser::set_value(&mut config, "width", ConfigValue::Value("0".to_string())); + Parser::set_value(&mut config, "fps", ConfigValue::Flag); + + write_preview_config(&config, &temp, 1280, PreviewScene::Studio) + .expect("write preview config"); + let written = std::fs::read_to_string(&temp).expect("read preview config"); + assert!(written.contains("horizontal_stretch=0\n")); + assert!(written.contains("width=")); + + let _ = std::fs::remove_file(temp); + } + + #[test] + fn preview_write_emulates_right_aligned_horizontal_layout_from_the_left() { + let temp = std::env::temp_dir().join(format!( + "mangotune-preview-right-offset-test-{}-{}.conf", + std::process::id(), + std::thread::current().name().unwrap_or("thread") + )); + + let mut config = sample_config(); + Parser::set_value( + &mut config, + "position", + ConfigValue::Value("top-right".to_string()), + ); + Parser::set_value(&mut config, "horizontal", ConfigValue::Flag); + Parser::set_value(&mut config, "horizontal_stretch", ConfigValue::Disabled); + Parser::set_value(&mut config, "width", ConfigValue::Value("0".to_string())); + Parser::set_value(&mut config, "offset_x", ConfigValue::Value("0".to_string())); + Parser::set_value(&mut config, "fps", ConfigValue::Flag); + + write_preview_config(&config, &temp, 1280, PreviewScene::Studio) + .expect("write preview config"); + let written = std::fs::read_to_string(&temp).expect("read preview config"); + assert!(written.contains("position=top-left\n")); + assert!(written.contains("offset_x=")); + assert!(!written.contains("offset_x=-")); + assert!(!written.contains("offset_y=")); + + let _ = std::fs::remove_file(temp); + } + + #[test] + fn no_margin_right_aligned_layout_removes_the_default_margin() { + let mut with_margin = sample_config(); + Parser::set_value( + &mut with_margin, + "position", + ConfigValue::Value("top-right".to_string()), + ); + Parser::set_value(&mut with_margin, "horizontal", ConfigValue::Flag); + Parser::set_value( + &mut with_margin, + "horizontal_stretch", + ConfigValue::Disabled, + ); + Parser::set_value(&mut with_margin, "fps", ConfigValue::Flag); + Parser::set_value(&mut with_margin, "gpu_stats", ConfigValue::Flag); + Parser::set_value(&mut with_margin, "vram", ConfigValue::Flag); + + let mut without_margin = with_margin.clone(); + Parser::set_value(&mut without_margin, "hud_no_margin", ConfigValue::Flag); + + let (_, with_margin_offset) = preview_right_alignment_override( + &with_margin, + 1575, + effective_preview_hud_width(&with_margin, 1575), + ) + .expect("with margin override"); + let (_, without_margin_offset) = preview_right_alignment_override( + &without_margin, + 1575, + effective_preview_hud_width(&without_margin, 1575), + ) + .expect("without margin override"); + + assert_eq!(without_margin_offset - with_margin_offset, 20); + } +} diff --git a/src/profiles/mod.rs b/src/profiles/mod.rs new file mode 100644 index 0000000..a6d9655 --- /dev/null +++ b/src/profiles/mod.rs @@ -0,0 +1,193 @@ +use crate::config::normalize::normalize_legacy_option_values; +use crate::config::parser::Parser; +use crate::config::types::AnnotatedConfig; +use anyhow::{anyhow, Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct ProfileInfo { + pub name: String, + pub path: PathBuf, +} + +pub fn profiles_dir() -> PathBuf { + if let Ok(override_dir) = std::env::var("MANGOTUNE_PROFILES_DIR") { + let path = PathBuf::from(override_dir); + if !path.as_os_str().is_empty() { + return path; + } + } + + if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(config_home).join("mangotune/profiles"); + } + + let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string()); + PathBuf::from(home).join(".config/mangotune/profiles") +} + +pub fn list_profiles() -> Result> { + let dir = profiles_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut profiles = Vec::new(); + for entry in fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("conf") { + continue; + } + let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + profiles.push(ProfileInfo { + name: stem.to_string(), + path, + }); + } + + profiles.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(profiles) +} + +pub fn save_profile(name: &str, config: &AnnotatedConfig) -> Result { + let safe_name = sanitize_name(name)?; + let dir = profiles_dir(); + fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; + + let profile_path = dir.join(format!("{safe_name}.conf")); + fs::write(&profile_path, Parser::to_string(config)) + .with_context(|| format!("failed to write {}", profile_path.display()))?; + Ok(profile_path) +} + +pub fn profile_exists(name: &str) -> Result { + let safe_name = sanitize_name(name)?; + let path = profiles_dir().join(format!("{safe_name}.conf")); + Ok(path.exists()) +} + +pub fn load_profile(name: &str, target_path: Option) -> Result { + let safe_name = sanitize_name(name)?; + let path = profiles_dir().join(format!("{safe_name}.conf")); + load_profile_from_path(&path, target_path) +} + +pub fn load_profile_from_path( + path: &Path, + target_path: Option, +) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read profile {}", path.display()))?; + let mut parsed = Parser::parse_str(&content, target_path); + normalize_legacy_option_values(&mut parsed); + parsed.dirty = true; + Ok(parsed) +} + +pub fn delete_profile(name: &str) -> Result<()> { + let safe_name = sanitize_name(name)?; + let path = profiles_dir().join(format!("{safe_name}.conf")); + delete_profile_path(&path) +} + +pub fn delete_profile_path(path: &Path) -> Result<()> { + if !path.exists() { + return Err(anyhow!("profile does not exist")); + } + fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?; + Ok(()) +} + +fn sanitize_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(anyhow!("profile name cannot be empty")); + } + + let safe: String = trimmed + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect(); + + if safe.trim_matches('_').is_empty() { + return Err(anyhow!("profile name must contain letters or numbers")); + } + + Ok(safe) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::{ConfigLine, ConfigValue}; + use indexmap::IndexMap; + use once_cell::sync::Lazy; + use std::sync::Mutex; + use tempfile::tempdir; + + static PROFILE_TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + + fn sample_config(path: Option) -> AnnotatedConfig { + let mut options = IndexMap::new(); + options.insert("fps".to_string(), (0, ConfigValue::Value("60".to_string()))); + + AnnotatedConfig { + lines: vec![ConfigLine::Option { + key: "fps".to_string(), + value: Some("60".to_string()), + raw: "fps=60".to_string(), + }], + options, + path, + dirty: false, + } + } + + #[test] + fn save_list_and_load_profile_round_trip() { + let _guard = PROFILE_TEST_LOCK.lock().expect("profile test lock"); + let temp = tempdir().expect("tempdir"); + std::env::set_var("MANGOTUNE_PROFILES_DIR", temp.path()); + + let cfg = sample_config(Some(PathBuf::from("/tmp/MangoHud.conf"))); + let saved = save_profile("my profile", &cfg).expect("save"); + assert!(saved.exists()); + + let listed = list_profiles().expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "my_profile"); + + let loaded = + load_profile("my profile", Some(PathBuf::from("/tmp/target.conf"))).expect("load"); + assert!(loaded.options.contains_key("fps")); + assert_eq!(loaded.path, Some(PathBuf::from("/tmp/target.conf"))); + + std::env::remove_var("MANGOTUNE_PROFILES_DIR"); + } + + #[test] + fn delete_profile_removes_saved_file() { + let _guard = PROFILE_TEST_LOCK.lock().expect("profile test lock"); + let temp = tempdir().expect("tempdir"); + std::env::set_var("MANGOTUNE_PROFILES_DIR", temp.path()); + + let cfg = sample_config(Some(PathBuf::from("/tmp/MangoHud.conf"))); + let saved = save_profile("delete me", &cfg).expect("save"); + assert!(saved.exists()); + + delete_profile_path(&saved).expect("delete"); + assert!(!saved.exists()); + + std::env::remove_var("MANGOTUNE_PROFILES_DIR"); + } +} diff --git a/src/system/detect.rs b/src/system/detect.rs new file mode 100644 index 0000000..f0952a9 --- /dev/null +++ b/src/system/detect.rs @@ -0,0 +1,408 @@ +use crate::config::types::GpuVendor; +use anyhow::Result; +use once_cell::sync::Lazy; +use regex::Regex; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +static VERSION_RE: Lazy = + Lazy::new(|| Regex::new(r"^(?:MangoHud\s+)?v?(\d+\.\d+[\.\d]*)").expect("valid version regex")); + +#[derive(Debug, Clone)] +pub struct SystemInfo { + pub mangohud: MangoHudInfo, + pub gpu: GpuInfo, + pub display_server: DisplayServer, + pub tools: AvailableTools, + pub integrations: IntegrationAvailability, +} + +#[derive(Debug, Clone)] +pub struct MangoHudInfo { + pub installed: bool, + pub version: Option, + pub lib_path: Option, + pub flatpak: bool, +} + +#[derive(Debug, Clone)] +pub struct GpuInfo { + pub vendor: GpuVendor, + pub name: Option, + pub pci_id: Option, + pub total_vram_mb: Option, + pub used_vram_mb: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DisplayServer { + Wayland, + X11, + XwaylandUnderWayland, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct AvailableTools { + pub vkcube: Option, + pub glxgears: Option, + pub gamemodectl: Option, + pub gamemoded: Option, +} + +#[derive(Debug, Clone)] +pub struct IntegrationAvailability { + pub steam: bool, + pub steam_flatpak: bool, + pub lutris: bool, + pub lutris_flatpak: bool, + pub heroic: bool, + pub heroic_flatpak: bool, + pub gamemode: bool, +} + +impl SystemInfo { + pub fn unknown() -> Self { + Self { + mangohud: MangoHudInfo { + installed: false, + version: None, + lib_path: None, + flatpak: false, + }, + gpu: GpuInfo { + vendor: GpuVendor::Any, + name: None, + pci_id: None, + total_vram_mb: None, + used_vram_mb: None, + }, + display_server: DisplayServer::Unknown, + tools: AvailableTools { + vkcube: None, + glxgears: None, + gamemodectl: None, + gamemoded: None, + }, + integrations: IntegrationAvailability { + steam: false, + steam_flatpak: false, + lutris: false, + lutris_flatpak: false, + heroic: false, + heroic_flatpak: false, + gamemode: false, + }, + } + } +} + +pub async fn detect_system() -> Result { + let mangohud_bin = which("mangohud"); + let version = mangohud_bin + .as_ref() + .and_then(|_| mangohud_version().ok()) + .flatten(); + let lib_path = detect_mangohud_library_path(); + let flatpak_list = flatpak_list(); + let flatpak_mangohud = flatpak_list + .as_deref() + .map(|s| s.to_lowercase().contains("mangohud")) + .unwrap_or(false); + + let tools = AvailableTools { + vkcube: which("vkcube"), + glxgears: which("glxgears"), + gamemodectl: which("gamemodectl"), + gamemoded: which("gamemoded"), + }; + let display_server = detect_display_server(); + let gpu = detect_gpu().unwrap_or(GpuInfo { + vendor: GpuVendor::Any, + name: None, + pci_id: None, + total_vram_mb: None, + used_vram_mb: None, + }); + + let integrations = IntegrationAvailability { + steam: which("steam").is_some(), + steam_flatpak: flatpak_list + .as_deref() + .map(|s| s.contains("com.valvesoftware.Steam")) + .unwrap_or(false), + lutris: which("lutris").is_some(), + lutris_flatpak: flatpak_list + .as_deref() + .map(|s| s.contains("net.lutris.Lutris")) + .unwrap_or(false), + heroic: which("heroic").is_some(), + heroic_flatpak: flatpak_list + .as_deref() + .map(|s| s.contains("com.heroicgameslauncher.hgl")) + .unwrap_or(false), + gamemode: tools.gamemoded.is_some() || tools.gamemodectl.is_some(), + }; + + Ok(SystemInfo { + mangohud: MangoHudInfo { + installed: mangohud_bin.is_some(), + version, + lib_path, + flatpak: flatpak_mangohud, + }, + gpu, + display_server, + tools, + integrations, + }) +} + +fn which(tool: &str) -> Option { + let output = Command::new("which").arg(tool).output().ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +fn mangohud_version() -> Result> { + let output = Command::new("mangohud").arg("--version").output()?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let text = if stdout.is_empty() { stderr } else { stdout }; + Ok(parse_version_from_output(&text)) +} + +fn parse_version_from_output(output: &str) -> Option { + VERSION_RE + .captures(output.trim()) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) +} + +fn detect_mangohud_library_path() -> Option { + let home = env::var("HOME").unwrap_or_else(|_| "/".to_string()); + let candidates = [ + "/usr/lib/x86_64-linux-gnu/mangohud/libMangoHud.so", + "/usr/lib/mangohud/libMangoHud.so", + "/usr/local/lib/mangohud/libMangoHud.so", + ]; + + for path in candidates { + if Path::new(path).exists() { + return Some(PathBuf::from(path)); + } + } + + let local = PathBuf::from(home).join(".local/lib/mangohud/libMangoHud.so"); + if local.exists() { + return Some(local); + } + None +} + +fn flatpak_list() -> Option { + let output = Command::new("flatpak").arg("list").output().ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn detect_display_server() -> DisplayServer { + let wayland = env::var("WAYLAND_DISPLAY").ok().filter(|v| !v.is_empty()); + let x11 = env::var("DISPLAY").ok().filter(|v| !v.is_empty()); + + match (wayland.is_some(), x11.is_some()) { + (true, true) => DisplayServer::XwaylandUnderWayland, + (true, false) => DisplayServer::Wayland, + (false, true) => DisplayServer::X11, + (false, false) => match env::var("XDG_SESSION_TYPE").ok().as_deref() { + Some("wayland") => DisplayServer::Wayland, + Some("x11") => DisplayServer::X11, + _ => DisplayServer::Unknown, + }, + } +} + +fn detect_gpu() -> Option { + let from_sys = detect_gpu_from_sysfs(); + if from_sys.is_some() { + return from_sys; + } + detect_gpu_from_lspci() +} + +fn detect_gpu_from_sysfs() -> Option { + let drm_path = Path::new("/sys/class/drm"); + let mut detected = Vec::new(); + + for entry in fs::read_dir(drm_path).ok()? { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with("card") || name.contains('-') { + continue; + } + let base = entry.path().join("device"); + let vendor = fs::read_to_string(base.join("vendor")).ok()?; + let device = fs::read_to_string(base.join("device")).ok(); + let vendor = vendor.trim().to_lowercase(); + let mapped = match vendor.as_str() { + "0x1002" => GpuVendor::AmdOnly, + "0x10de" => GpuVendor::NvidiaOnly, + "0x8086" => GpuVendor::IntelOnly, + _ => GpuVendor::Any, + }; + let total_vram_mb = detect_vram_mb_from_sysfs(&base, mapped.clone()) + .or_else(|| detect_vram_mb_from_nvidia_smi_total(mapped.clone())); + let used_vram_mb = detect_used_vram_mb_from_sysfs(&base, mapped.clone()) + .or_else(|| detect_vram_mb_from_nvidia_smi_used(mapped.clone())); + detected.push(GpuInfo { + vendor: mapped, + name: None, + pci_id: device.map(|d| d.trim().to_string()), + total_vram_mb, + used_vram_mb, + }); + } + + if detected.is_empty() { + return None; + } + detected.sort_by_key(|gpu| match gpu.vendor { + GpuVendor::NvidiaOnly => 0, + GpuVendor::AmdOnly => 1, + GpuVendor::IntelOnly => 2, + GpuVendor::Any => 3, + }); + detected.into_iter().next() +} + +fn detect_gpu_from_lspci() -> Option { + let output = Command::new("sh") + .arg("-c") + .arg("lspci -nn 2>/dev/null") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if !(line.contains("VGA compatible controller") + || line.contains("3D controller") + || line.contains("Display controller")) + { + continue; + } + let lower = line.to_lowercase(); + let vendor = if lower.contains("[10de:") { + GpuVendor::NvidiaOnly + } else if lower.contains("[1002:") { + GpuVendor::AmdOnly + } else if lower.contains("[8086:") { + GpuVendor::IntelOnly + } else { + GpuVendor::Any + }; + return Some(GpuInfo { + vendor: vendor.clone(), + name: Some(line.to_string()), + pci_id: None, + total_vram_mb: detect_vram_mb_from_nvidia_smi_total(vendor.clone()), + used_vram_mb: detect_vram_mb_from_nvidia_smi_used(vendor), + }); + } + None +} + +fn detect_vram_mb_from_sysfs(base: &Path, vendor: GpuVendor) -> Option { + match vendor { + GpuVendor::AmdOnly => { + let bytes = fs::read_to_string(base.join("mem_info_vram_total")).ok()?; + let bytes = bytes.trim().parse::().ok()?; + Some((bytes / (1024 * 1024)).clamp(0, u32::MAX as u64) as u32) + } + GpuVendor::IntelOnly => { + let bytes = fs::read_to_string(base.join("lmem_total_bytes")).ok()?; + let bytes = bytes.trim().parse::().ok()?; + Some((bytes / (1024 * 1024)).clamp(0, u32::MAX as u64) as u32) + } + _ => None, + } +} + +fn detect_used_vram_mb_from_sysfs(base: &Path, vendor: GpuVendor) -> Option { + match vendor { + GpuVendor::AmdOnly => { + let bytes = fs::read_to_string(base.join("mem_info_vram_used")).ok()?; + let bytes = bytes.trim().parse::().ok()?; + Some((bytes / (1024 * 1024)).clamp(0, u32::MAX as u64) as u32) + } + _ => None, + } +} + +fn detect_vram_mb_from_nvidia_smi_total(vendor: GpuVendor) -> Option { + if vendor != GpuVendor::NvidiaOnly { + return None; + } + let output = Command::new("nvidia-smi") + .args(["--query-gpu=memory.total", "--format=csv,noheader,nounits"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| line.trim().parse::().ok()) +} + +fn detect_vram_mb_from_nvidia_smi_used(vendor: GpuVendor) -> Option { + if vendor != GpuVendor::NvidiaOnly { + return None; + } + let output = Command::new("nvidia-smi") + .args(["--query-gpu=memory.used", "--format=csv,noheader,nounits"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| line.trim().parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_mangohud_version_output() { + assert_eq!( + parse_version_from_output("MangoHud 0.7.2"), + Some("0.7.2".to_string()) + ); + assert_eq!( + parse_version_from_output("v0.7.1-3-gabcdef"), + Some("0.7.1".to_string()) + ); + } + + #[test] + fn unknown_constructor_has_safe_defaults() { + let info = SystemInfo::unknown(); + assert!(!info.mangohud.installed); + assert_eq!(info.display_server, DisplayServer::Unknown); + } +} diff --git a/src/system/mod.rs b/src/system/mod.rs new file mode 100644 index 0000000..3a1efea --- /dev/null +++ b/src/system/mod.rs @@ -0,0 +1,5 @@ +pub mod detect; +pub mod paths; + +pub use detect::{AvailableTools, DisplayServer, GpuInfo, MangoHudInfo, SystemInfo}; +pub use paths::XdgPaths; diff --git a/src/system/paths.rs b/src/system/paths.rs new file mode 100644 index 0000000..d4be276 --- /dev/null +++ b/src/system/paths.rs @@ -0,0 +1,78 @@ +use anyhow::{Context, Result}; +use std::env; +use std::path::PathBuf; +use xdg::BaseDirectories; + +#[derive(Debug, Clone)] +pub struct XdgPaths { + pub config_home: PathBuf, + pub mangohud_dir: PathBuf, + pub global_config: PathBuf, + pub data_home: PathBuf, +} + +impl XdgPaths { + pub fn resolve() -> Result { + let xdg = BaseDirectories::new().context("failed to resolve XDG base directories")?; + let config_home = if let Ok(custom) = env::var("XDG_CONFIG_HOME") { + if !custom.trim().is_empty() { + expand_tilde(&custom) + } else { + xdg.get_config_home() + } + } else { + xdg.get_config_home() + }; + let data_home = xdg.get_data_home(); + let mangohud_dir = config_home.join("MangoHud"); + let global_config = mangohud_dir.join("MangoHud.conf"); + + Ok(Self { + config_home, + mangohud_dir, + global_config, + data_home, + }) + } +} + +pub fn steam_roots() -> Vec { + let home = home_dir(); + vec![ + home.join(".steam/steam"), + home.join(".local/share/Steam"), + home.join(".var/app/com.valvesoftware.Steam/data/Steam"), + ] +} + +pub fn heroic_config_dirs() -> Vec { + let home = home_dir(); + vec![ + home.join(".config/heroic"), + home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"), + ] +} + +pub fn lutris_config_dirs() -> Vec { + let home = home_dir(); + vec![ + home.join(".config/lutris"), + home.join(".var/app/net.lutris.Lutris/config/lutris"), + ] +} + +pub fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + return home_dir(); + } + if let Some(rest) = path.strip_prefix("~/") { + return home_dir().join(rest); + } + PathBuf::from(path) +} + +fn home_dir() -> PathBuf { + env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/")) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..7af8430 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod pages; +pub mod toast; +pub mod widgets; diff --git a/src/ui/pages/appearance.rs b/src/ui/pages/appearance.rs new file mode 100644 index 0000000..e75459c --- /dev/null +++ b/src/ui/pages/appearance.rs @@ -0,0 +1,17 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + tool_page::build_single_category_page( + ctx, + "Layout and Position", + "Appearance", + "Shape where MangoHud sits on screen, how wide it grows, and how tightly it hugs the game viewport.", + &["anchor", "spacing", "footprint"], + "Placement and spacing", + "Fine-tune anchor, margins, offsets, and compactness without digging through raw MangoHud keys.", + Some("Most used"), + Category::AppearanceLayout, + ) +} diff --git a/src/ui/pages/battery.rs b/src/ui/pages/battery.rs new file mode 100644 index 0000000..7709af1 --- /dev/null +++ b/src/ui/pages/battery.rs @@ -0,0 +1,17 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + tool_page::build_single_category_page( + ctx, + "Battery", + "Display", + "Add battery telemetry for laptops and wireless devices only when it adds signal instead of noise.", + &["laptops", "controllers", "power"], + "Battery indicators", + "Choose the battery metrics that actually help during gaming sessions without cluttering the overlay.", + Some("Optional"), + Category::DisplayBattery, + ) +} diff --git a/src/ui/pages/blacklist.rs b/src/ui/pages/blacklist.rs new file mode 100644 index 0000000..9b9fe6c --- /dev/null +++ b/src/ui/pages/blacklist.rs @@ -0,0 +1,39 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Blacklist", + "Behavior", + "Tell MangoHud where not to inject, and control the extra behavior flags that keep problematic apps from getting in the way.", + &["exceptions", "socket", "safety"], + ); + + tool_page::append_schema_key_section( + &body, + ctx, + "Injection and config behavior", + "These settings are mostly defensive. Change them when a launcher, desktop app, or special-case game needs different behavior.", + Some("Advanced"), + &["blacklist", "control", "read_cfg"], + ); + + tool_page::append_callout( + &body, + "Use sparingly", + "Special MangoHud directives", + "These options exist upstream, but they are niche. `help` makes MangoHud print supported parameters and exit instead of drawing the HUD, and `inherit` is mainly meant for preset definitions.", + Some("tool-callout-warning"), + ); + + tool_page::append_schema_key_section( + &body, + ctx, + "Special directives", + "Available here for completeness, but usually not something you want in a normal gaming config.", + Some("Edge cases"), + &["help", "inherit"], + ); + + page +} diff --git a/src/ui/pages/colors.rs b/src/ui/pages/colors.rs new file mode 100644 index 0000000..a744f50 --- /dev/null +++ b/src/ui/pages/colors.rs @@ -0,0 +1,144 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::{color_row, toggle_row, tool_page}; +use libadwaita::prelude::*; +use mangotune::config::help::{display_title_for_key, option_help_for_key}; +use mangotune::config::schema::get_schema_entry; +use mangotune::config::types::OptionType; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Colors and Theme", + "Appearance", + "Build a palette that reads clearly over real game scenes instead of settling for MangoHud’s stock look.", + &["palette", "contrast", "readability"], + ); + + tool_page::append_callout( + &body, + "Input format", + "Color fields use six-digit hex", + "Enter colors as RRGGBB without a leading #. The swatches update live so you can tune against the preview without guessing.", + None, + ); + + append_color_key_section( + &body, + ctx, + "Core palette", + "Set the main panel, text, and outline colors that define the overall look of the HUD.", + Some("Visual identity"), + &[ + "text_color", + "background_color", + "horizontal_separator_color", + "text_outline", + "text_outline_color", + "text_outline_thickness", + ], + ); + + append_color_key_section( + &body, + ctx, + "Hardware labels", + "Choose the accent colors for the main hardware labels so GPU, CPU, RAM, and VRAM stay easy to scan.", + Some("Hardware"), + &["gpu_color", "cpu_color", "vram_color", "ram_color", "engine_color"], + ); + + append_color_key_section( + &body, + ctx, + "Live readout accents", + "Adjust the colors used by FPS, GPU load, and CPU load readouts.", + Some("Live metrics"), + &["fps_color", "gpu_load_color", "cpu_load_color"], + ); + + append_color_key_section( + &body, + ctx, + "Status and integrations", + "Tune the accent colors for extra HUD modules like I/O, network, media players, Wine, and battery status.", + Some("Extras"), + &[ + "io_color", + "network_color", + "media_player_color", + "wine_color", + "battery_color", + "frametime_color", + ], + ); + + page +} + +fn append_color_key_section( + body: >k4::Box, + ctx: &PageBuildContext, + title: &str, + description: &str, + badge: Option<&str>, + keys: &[&str], +) { + let group = tool_page::append_custom_section(body, title, description, badge); + for key in keys { + if let Some(entry) = get_schema_entry(key) { + if matches!(entry.option_type, OptionType::Color) { + let row = color_row::build_color_row( + &friendly_color_title(entry.key), + &friendly_color_subtitle(entry.key), + entry.key, + ctx, + ); + group.add(&row); + } else { + toggle_row::add_schema_row(&group, entry, ctx); + } + } + } +} + +fn friendly_color_title(key: &str) -> String { + match key { + "engine_color" => "Engine / Vulkan".to_string(), + "gpu_color" => "GPU labels".to_string(), + "cpu_color" => "CPU labels".to_string(), + "vram_color" => "VRAM labels".to_string(), + "ram_color" => "RAM labels".to_string(), + "fps_color" => "FPS thresholds".to_string(), + "gpu_load_color" => "GPU load thresholds".to_string(), + "cpu_load_color" => "CPU load thresholds".to_string(), + "frametime_color" => "Frametime line".to_string(), + "horizontal_separator_color" => "Separator line".to_string(), + _ => display_title_for_key(key), + } +} + +fn friendly_color_subtitle(key: &str) -> String { + match key { + "fps_color" => "Colors paired with the FPS threshold values.".to_string(), + "gpu_load_color" => "Colors paired with the GPU load threshold values.".to_string(), + "cpu_load_color" => "Colors paired with the CPU load threshold values.".to_string(), + "text_color" => "Main text color used across the overlay.".to_string(), + "background_color" => "Panel fill color behind the HUD text.".to_string(), + "text_outline_color" => "Outline color used when text outlining is enabled.".to_string(), + "horizontal_separator_color" => "Divider line color between grouped readouts.".to_string(), + "gpu_color" => "Accent color for GPU labels and related readouts.".to_string(), + "cpu_color" => "Accent color for CPU labels and related readouts.".to_string(), + "vram_color" => "Accent color for VRAM labels and related readouts.".to_string(), + "ram_color" => "Accent color for RAM labels and related readouts.".to_string(), + "engine_color" => "Accent color for engine and API labels.".to_string(), + "io_color" => "Accent color for I/O readouts.".to_string(), + "network_color" => "Accent color for network readouts.".to_string(), + "media_player_color" => "Accent color for media-player readouts.".to_string(), + "wine_color" => "Accent color for Wine and Proton readouts.".to_string(), + "battery_color" => "Accent color for battery readouts.".to_string(), + "frametime_color" => "Color used by the frametime graph line.".to_string(), + _ => option_help_for_key(key) + .map(|help| help.summary) + .filter(|summary| !summary.is_empty()) + .unwrap_or_else(|| "Adjust this overlay color.".to_string()), + } +} diff --git a/src/ui/pages/conflicts.rs b/src/ui/pages/conflicts.rs new file mode 100644 index 0000000..abc8e93 --- /dev/null +++ b/src/ui/pages/conflicts.rs @@ -0,0 +1,435 @@ +use crate::ui::pages::{block_on_optional, PageBuildContext}; +use crate::ui::widgets::cascade_view::{ + build_cascade_view, has_visible_options, CascadeFilter, CascadeViewModel, LayerViewModel, + OptionState, OptionViewModel, +}; +use crate::ui::widgets::tool_page; +use gtk4::prelude::*; +use mangotune::config::resolver::{ConfigConflict, ConfigLayer, Resolver}; +use mangotune::config::types::ConfigValue; +use mangotune::system::paths::XdgPaths; +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; + +pub fn build_page(_ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Layer Conflicts", + "Config", + "See which config layer wins, which values are shadowed, and where your edits will actually land in MangoHud’s stack.", + &["cascade", "winning values", "shadowed options"], + ); + + let Some(xdg) = XdgPaths::resolve().ok() else { + tool_page::append_callout( + &body, + "Unavailable", + "Could not resolve XDG paths", + "MangoTune could not determine your MangoHud config locations, so the layer view is unavailable right now.", + Some("tool-callout-warning"), + ); + return page; + }; + + let Some(layers_result) = block_on_optional(Resolver::discover(&xdg)) else { + tool_page::append_callout( + &body, + "Unavailable", + "Could not inspect config layers", + "The layer discovery task could not run in this session, so the cascade view could not be built.", + Some("tool-callout-warning"), + ); + return page; + }; + + let Ok(mut layers) = layers_result else { + tool_page::append_callout( + &body, + "Unavailable", + "Could not read discovered config files", + "MangoTune found config locations but could not parse the current layer stack safely.", + Some("tool-callout-warning"), + ); + return page; + }; + + if layers.is_empty() { + tool_page::append_callout( + &body, + "Empty stack", + "No MangoHud config files were found", + "MangoHud will currently fall back to compiled defaults. Save a config to start building a visible layer stack here.", + None, + ); + return page; + } + + let mut conflicts: HashMap = HashMap::new(); + for item in Resolver::find_conflicts(&layers) { + conflicts.insert(item.key.clone(), item); + } + layers.sort_by(|a, b| b.priority.cmp(&a.priority)); + + let shadowed_count = layers + .iter() + .filter_map(|layer| layer.config.as_ref()) + .flat_map(|config| config.options.keys()) + .filter(|key| { + conflicts + .get(*key) + .is_some_and(|conflict| conflict.winning_layer_priority > 0) + }) + .count(); + + body.append(&build_summary_strip( + layers.len(), + conflicts.len(), + shadowed_count, + )); + + let (recipe_title, recipe_body) = build_conflict_recipe(); + tool_page::append_callout( + &body, + "What this page means", + &recipe_title, + &recipe_body, + None, + ); + + if conflicts_rc_is_empty(&conflicts) { + tool_page::append_callout( + &body, + "No active layer conflicts", + "Nothing is currently being overridden by a higher-priority layer.", + "Try a simple repro: set `fps=60` in the saved global config and `fps=120` in a per-app config, then come back here. The per-app value should win and the global one should show as shadowed.", + None, + ); + } else { + body.append(&build_conflict_list_section(&layers, &conflicts)); + } + + let filter_shell = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + filter_shell.add_css_class("tool-section-shell"); + + let filter_header = gtk4::Label::new(Some("Full layer cascade")); + filter_header.add_css_class("tool-section-title"); + filter_header.set_xalign(0.0); + filter_shell.append(&filter_header); + + let filter_subtitle = gtk4::Label::new(Some( + "Use this detailed view when you need to inspect every visible key in each layer. The simpler conflict list above is the quickest way to see what is actually being overridden.", + )); + filter_subtitle.add_css_class("tool-section-subtitle"); + filter_subtitle.set_wrap(true); + filter_subtitle.set_xalign(0.0); + filter_shell.append(&filter_subtitle); + + let filter_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + content.set_vexpand(true); + + let layers_rc = Rc::new(layers); + let conflicts_rc = Rc::new(conflicts); + let updating_filter = Rc::new(Cell::new(false)); + + let all_button = gtk4::ToggleButton::with_label("All"); + let conflicts_button = gtk4::ToggleButton::with_label("Conflicts"); + let shadowed_button = gtk4::ToggleButton::with_label("Shadowed"); + all_button.set_active(true); + for button in [&all_button, &conflicts_button, &shadowed_button] { + button.add_css_class("dashboard-toggle"); + filter_row.append(button); + } + + let apply_filter = { + let content = content.clone(); + let layers = layers_rc.clone(); + let conflicts = conflicts_rc.clone(); + let all_button = all_button.clone(); + let conflicts_button = conflicts_button.clone(); + let shadowed_button = shadowed_button.clone(); + let updating_filter = updating_filter.clone(); + Rc::new(move |filter: CascadeFilter| { + if updating_filter.get() { + return; + } + updating_filter.set(true); + all_button.set_active(matches!(filter, CascadeFilter::All)); + conflicts_button.set_active(matches!(filter, CascadeFilter::ConflictsOnly)); + shadowed_button.set_active(matches!(filter, CascadeFilter::ShadowedOnly)); + updating_filter.set(false); + rebuild_cascade_content(&content, &layers, &conflicts, filter); + }) + }; + + apply_filter(CascadeFilter::All); + + { + let apply_filter = apply_filter.clone(); + all_button.connect_clicked(move |_| { + apply_filter(CascadeFilter::All); + }); + } + + { + let apply_filter = apply_filter.clone(); + conflicts_button.connect_clicked(move |_| { + apply_filter(CascadeFilter::ConflictsOnly); + }); + } + + { + let apply_filter = apply_filter.clone(); + shadowed_button.connect_clicked(move |_| { + apply_filter(CascadeFilter::ShadowedOnly); + }); + } + + filter_shell.append(&filter_row); + filter_shell.append(&content); + body.append(&filter_shell); + page +} + +fn rebuild_cascade_content( + content: >k4::Box, + layers: &[ConfigLayer], + conflicts: &HashMap, + filter: CascadeFilter, +) { + while let Some(child) = content.first_child() { + content.remove(&child); + } + + let model = build_cascade_model(layers, conflicts, filter); + if !has_visible_options(&model) { + content.append(&build_empty_state(filter)); + return; + } + + let widget = build_cascade_view(model); + content.append(&widget); +} + +fn conflicts_rc_is_empty(conflicts: &HashMap) -> bool { + conflicts.is_empty() +} + +fn build_summary_strip( + layer_count: usize, + conflict_count: usize, + shadowed_count: usize, +) -> gtk4::Box { + let strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + strip.add_css_class("dashboard-row"); + + for (label, value) in [ + ("Layers", layer_count.to_string()), + ("Conflicting keys", conflict_count.to_string()), + ("Shadowed values", shadowed_count.to_string()), + ] { + let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + tile.add_css_class("dashboard-status-panel"); + tile.set_hexpand(true); + + let title = gtk4::Label::new(Some(label)); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + + let number = gtk4::Label::new(Some(&value)); + number.add_css_class("dashboard-card-title"); + number.set_xalign(0.0); + + tile.append(&title); + tile.append(&number); + strip.append(&tile); + } + + strip +} + +fn build_conflict_list_section( + layers: &[ConfigLayer], + conflicts: &HashMap, +) -> gtk4::Box { + let shell = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + shell.add_css_class("tool-section-shell"); + + let title = gtk4::Label::new(Some("Conflicting keys")); + title.add_css_class("tool-section-title"); + title.set_xalign(0.0); + shell.append(&title); + + let subtitle = gtk4::Label::new(Some( + "Each row shows one key that is set differently in more than one active layer, which value currently wins, and which lower-priority values are being ignored.", + )); + subtitle.add_css_class("tool-section-subtitle"); + subtitle.set_wrap(true); + subtitle.set_xalign(0.0); + shell.append(&subtitle); + + let list = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + let mut ordered = conflicts.values().collect::>(); + ordered.sort_by(|a, b| a.key.cmp(&b.key)); + for conflict in ordered { + list.append(&build_conflict_row(conflict, layers)); + } + shell.append(&list); + shell +} + +fn build_conflict_row(conflict: &ConfigConflict, layers: &[ConfigLayer]) -> gtk4::Box { + let row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + row.add_css_class("dashboard-status-panel"); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let key = gtk4::Label::new(Some(&conflict.key)); + key.add_css_class("dashboard-field-label"); + key.set_xalign(0.0); + key.set_hexpand(true); + + let winner_badge = gtk4::Label::new(Some("Winning value")); + winner_badge.add_css_class("tool-page-chip"); + header.append(&key); + header.append(&winner_badge); + row.append(&header); + + let winner = gtk4::Label::new(Some(&format!( + "{} from {}", + stringify_value(&conflict.winning_value), + layer_label_for_priority(layers, conflict.winning_layer_priority) + ))); + winner.add_css_class("tool-section-subtitle"); + winner.set_wrap(true); + winner.set_xalign(0.0); + row.append(&winner); + + for (priority, value) in &conflict.shadowed { + let shadowed = gtk4::Label::new(Some(&format!( + "Shadowed: {} from {}", + stringify_value(value), + layer_label_for_priority(layers, *priority) + ))); + shadowed.add_css_class("dim-label"); + shadowed.set_wrap(true); + shadowed.set_xalign(0.0); + row.append(&shadowed); + } + + row +} + +fn layer_label_for_priority(layers: &[ConfigLayer], priority: u8) -> String { + layers + .iter() + .find(|layer| layer.priority == priority) + .map(|layer| Resolver::layer_label(&layer.source_type)) + .unwrap_or_else(|| format!("priority {priority}")) +} + +fn build_empty_state(filter: CascadeFilter) -> gtk4::Widget { + let message = match filter { + CascadeFilter::All => "No visible layer data was available for the current MangoHud stack.", + CascadeFilter::ConflictsOnly => { + "No layer conflicts are active right now. That usually means the same key is not being set differently in two active layers." + } + CascadeFilter::ShadowedOnly => "Nothing is currently shadowed by a higher-priority layer.", + }; + + let subtitle = match filter { + CascadeFilter::All => "Save or load a MangoHud config to populate the cascade view.", + CascadeFilter::ConflictsOnly => { + "Try the All filter to inspect the full stack, or create a test conflict like fps=60 in the saved global config and fps=120 in a per-app config." + } + CascadeFilter::ShadowedOnly => { + "Try the Conflicts or All filters if you want to inspect effective values too." + } + }; + + let box_ = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + box_.add_css_class("tool-callout"); + + let title = gtk4::Label::new(Some(message)); + title.add_css_class("tool-callout-title"); + title.set_wrap(true); + title.set_xalign(0.0); + + let body = gtk4::Label::new(Some(subtitle)); + body.add_css_class("tool-callout-subtitle"); + body.set_wrap(true); + body.set_xalign(0.0); + + box_.append(&title); + box_.append(&body); + box_.upcast() +} + +fn build_conflict_recipe() -> (String, String) { + ( + "Layer conflicts only appear when two active config layers set the same key differently." + .to_string(), + "Quick check: set `fps=60` in the saved global config, set `fps=120` in a higher-priority per-app or app-local config, then reopen this page. The higher-priority layer should win and the lower one should show the value as shadowed." + .to_string(), + ) +} + +fn build_cascade_model( + layers: &[ConfigLayer], + conflicts: &HashMap, + filter: CascadeFilter, +) -> CascadeViewModel { + let mut layer_models = Vec::new(); + + for layer in layers { + let mut options = Vec::new(); + if let Some(config) = &layer.config { + for (key, (_, value)) in &config.options { + let (state, overridden_by) = if let Some(conflict) = conflicts.get(key) { + if layer.priority < conflict.winning_layer_priority { + ( + OptionState::Shadowed, + Some(layer_label_for_priority( + layers, + conflict.winning_layer_priority, + )), + ) + } else if layer.priority == conflict.winning_layer_priority { + (OptionState::Winning, None) + } else { + (OptionState::Effective, None) + } + } else { + (OptionState::Effective, None) + }; + + options.push(OptionViewModel { + key: key.clone(), + value: stringify_value(value).to_string(), + state, + overridden_by, + }); + } + } + + layer_models.push(LayerViewModel { + source: layer.source_type.clone(), + label: Resolver::layer_label(&layer.source_type), + is_editable: layer.is_editable, + options, + }); + } + + CascadeViewModel { + layers: layer_models, + filter, + } +} + +fn stringify_value(value: &ConfigValue) -> &str { + match value { + ConfigValue::Flag => "enabled", + ConfigValue::Value(value) => value, + ConfigValue::Disabled => "disabled", + ConfigValue::Absent => "absent", + } +} diff --git a/src/ui/pages/cpu.rs b/src/ui/pages/cpu.rs new file mode 100644 index 0000000..cd1a0fb --- /dev/null +++ b/src/ui/pages/cpu.rs @@ -0,0 +1,24 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "CPU", + "Display", + "Surface CPU load, clocks, temperatures, and per-core data without turning the overlay into a wall of numbers.", + &["utilization", "thermals", "thread load"], + ); + + tool_page::append_schema_category_section_filtered( + &body, + ctx, + "CPU telemetry", + "Choose the CPU metrics that help you diagnose stutter, heat, or bottlenecks during real play. CPU usage is part of the main stats block on MangoHud builds that expose it there. Visual styling lives in Colors and Theme.", + Some("Diagnostics"), + Category::DisplayCpu, + |entry| entry.key != "cpu_load_color", + ); + + page +} diff --git a/src/ui/pages/debug.rs b/src/ui/pages/debug.rs new file mode 100644 index 0000000..12f6818 --- /dev/null +++ b/src/ui/pages/debug.rs @@ -0,0 +1,226 @@ +use crate::ui::pages::{current_config_snapshot, PageBuildContext}; +use crate::ui::toast::show_toast; +use crate::ui::widgets::tool_page; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::parser::Parser; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Debug", + "Tools", + "Inspect MangoTune’s current workspace state, copy the effective config, and review recent internal logs without using the terminal.", + &["diagnostics", "clipboard", "support"], + ); + + tool_page::append_callout( + &body, + "Support", + "Use this page when something feels off", + "Copy the current config or recent MangoTune log before filing an issue or sending screenshots. This page reflects the in-memory workspace, not just what is already saved on disk.", + None, + ); + + let snapshot_group = tool_page::append_custom_section( + &body, + "Current workspace config", + "This is the exact config MangoTune is currently editing in memory.", + Some("Effective state"), + ); + + let snapshot_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let snapshot_status = gtk4::Label::new(None); + snapshot_status.add_css_class("dim-label"); + snapshot_status.set_xalign(0.0); + snapshot_status.set_hexpand(true); + let snapshot_refresh = gtk4::Button::with_label("Refresh"); + let snapshot_copy = gtk4::Button::with_label("Copy Config"); + snapshot_copy.add_css_class("suggested-action"); + snapshot_header.append(&snapshot_status); + snapshot_header.append(&snapshot_refresh); + snapshot_header.append(&snapshot_copy); + + let snapshot_buffer = gtk4::TextBuffer::new(None::<>k4::TextTagTable>); + let snapshot_view = gtk4::TextView::builder() + .buffer(&snapshot_buffer) + .editable(false) + .cursor_visible(false) + .monospace(true) + .wrap_mode(gtk4::WrapMode::Char) + .vexpand(true) + .hexpand(true) + .top_margin(12) + .bottom_margin(12) + .left_margin(12) + .right_margin(12) + .build(); + let snapshot_scrolled = gtk4::ScrolledWindow::builder() + .child(&snapshot_view) + .min_content_height(240) + .min_content_width(0) + .vexpand(true) + .hexpand(true) + .build(); + snapshot_scrolled.add_css_class("tool-section-shell"); + + let snapshot_box = gtk4::Box::new(gtk4::Orientation::Vertical, 10); + snapshot_box.append(&snapshot_header); + snapshot_box.append(&snapshot_scrolled); + snapshot_group.add(&snapshot_box); + + let log_group = tool_page::append_custom_section( + &body, + "Recent MangoTune log", + "This captures MangoTune’s internal debug trail in-app, so you do not need the terminal open to share useful diagnostics.", + Some("Recent activity"), + ); + + let log_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let log_status = gtk4::Label::new(None); + log_status.add_css_class("dim-label"); + log_status.set_xalign(0.0); + log_status.set_hexpand(true); + let log_refresh = gtk4::Button::with_label("Refresh"); + let log_clear = gtk4::Button::with_label("Clear"); + let log_copy = gtk4::Button::with_label("Copy Log"); + log_header.append(&log_status); + log_header.append(&log_refresh); + log_header.append(&log_clear); + log_header.append(&log_copy); + + let log_buffer = gtk4::TextBuffer::new(None::<>k4::TextTagTable>); + let log_view = gtk4::TextView::builder() + .buffer(&log_buffer) + .editable(false) + .cursor_visible(false) + .monospace(true) + .wrap_mode(gtk4::WrapMode::Char) + .vexpand(true) + .hexpand(true) + .top_margin(12) + .bottom_margin(12) + .left_margin(12) + .right_margin(12) + .build(); + let log_scrolled = gtk4::ScrolledWindow::builder() + .child(&log_view) + .min_content_height(240) + .min_content_width(0) + .vexpand(true) + .hexpand(true) + .build(); + log_scrolled.add_css_class("tool-section-shell"); + + let log_box = gtk4::Box::new(gtk4::Orientation::Vertical, 10); + log_box.append(&log_header); + log_box.append(&log_scrolled); + log_group.add(&log_box); + + refresh_snapshot_view(ctx, &snapshot_buffer, &snapshot_status); + refresh_log_view(&log_buffer, &log_status); + + { + let ctx = ctx.clone(); + let snapshot_buffer = snapshot_buffer.clone(); + let snapshot_status = snapshot_status.clone(); + snapshot_refresh.connect_clicked(move |_| { + refresh_snapshot_view(&ctx, &snapshot_buffer, &snapshot_status); + }); + } + + { + let ctx = ctx.clone(); + snapshot_copy.connect_clicked(move |_| { + let text = Parser::to_string(¤t_config_snapshot(&ctx)); + copy_to_clipboard(&ctx, &text, "Copied current workspace config"); + }); + } + + { + let log_buffer = log_buffer.clone(); + let log_status = log_status.clone(); + log_refresh.connect_clicked(move |_| { + refresh_log_view(&log_buffer, &log_status); + }); + } + + { + let ctx = ctx.clone(); + log_copy.connect_clicked(move |_| { + let text = mangotune::debug_log::text(); + copy_to_clipboard(&ctx, &text, "Copied recent MangoTune log"); + }); + } + + { + let ctx = ctx.clone(); + let log_buffer = log_buffer.clone(); + let log_status = log_status.clone(); + log_clear.connect_clicked(move |_| { + mangotune::debug_log::clear(); + refresh_log_view(&log_buffer, &log_status); + show_toast(&ctx.toast_overlay, "Cleared recent MangoTune log"); + }); + } + + { + let ctx = ctx.clone(); + let snapshot_buffer = snapshot_buffer.downgrade(); + let snapshot_status = snapshot_status.downgrade(); + let log_buffer = log_buffer.downgrade(); + let log_status = log_status.downgrade(); + glib::timeout_add_seconds_local(1, move || { + let (Some(snapshot_buffer), Some(snapshot_status), Some(log_buffer), Some(log_status)) = ( + snapshot_buffer.upgrade(), + snapshot_status.upgrade(), + log_buffer.upgrade(), + log_status.upgrade(), + ) else { + return glib::ControlFlow::Break; + }; + refresh_snapshot_view(&ctx, &snapshot_buffer, &snapshot_status); + refresh_log_view(&log_buffer, &log_status); + glib::ControlFlow::Continue + }); + } + + page +} + +fn refresh_snapshot_view(ctx: &PageBuildContext, buffer: >k4::TextBuffer, status: >k4::Label) { + let config = current_config_snapshot(ctx); + let text = Parser::to_string(&config); + let line_count = text.lines().count(); + let option_count = config.options.len(); + buffer.set_text(&text); + status.set_text(&format!( + "{} active options across {} lines | {}", + option_count, + line_count, + config + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "unsaved workspace".to_string()) + )); +} + +fn refresh_log_view(buffer: >k4::TextBuffer, status: >k4::Label) { + let lines = mangotune::debug_log::lines(); + let text = if lines.is_empty() { + "No recent MangoTune log lines yet.".to_string() + } else { + lines.join("\n") + }; + buffer.set_text(&text); + status.set_text(&format!("{} recent line(s)", lines.len())); +} + +fn copy_to_clipboard(ctx: &PageBuildContext, text: &str, success_message: &str) { + if let Some(display) = gtk4::gdk::Display::default() { + display.clipboard().set_text(text); + show_toast(&ctx.toast_overlay, success_message); + } else { + show_toast(&ctx.toast_overlay, "Clipboard unavailable"); + } +} diff --git a/src/ui/pages/fps_limits.rs b/src/ui/pages/fps_limits.rs new file mode 100644 index 0000000..ce093ef --- /dev/null +++ b/src/ui/pages/fps_limits.rs @@ -0,0 +1,22 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "FPS Limits", + "Behavior", + "Control MangoHud-side frame caps and sync hooks when you want the overlay to participate in how a game is paced.", + &["caps", "vsync", "frame pacing"], + ); + + tool_page::append_schema_key_section( + &body, + ctx, + "Frame caps and sync", + "These settings affect how MangoHud applies FPS limits and sync behavior for Vulkan and OpenGL titles.", + Some("Advanced"), + &["fps_limit", "fps_limit_method", "vsync", "gl_vsync"], + ); + + page +} diff --git a/src/ui/pages/gpu.rs b/src/ui/pages/gpu.rs new file mode 100644 index 0000000..6af9470 --- /dev/null +++ b/src/ui/pages/gpu.rs @@ -0,0 +1,33 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::{Category, GpuVendor}; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "GPU", + "Display", + "Surface the GPU metrics you actually care about when tuning thermals, clocks, load, and frame stability.", + &["utilization", "thermals", "vendor aware"], + ); + + if ctx.system_info.gpu.vendor != GpuVendor::AmdOnly { + tool_page::append_callout( + &body, + "Vendor note", + "Some GPU metrics are AMD-only", + "If you are not on AMD hardware, options like gpu_voltage may stay inert even though MangoHud exposes them.", + Some("tool-callout-warning"), + ); + } + + tool_page::append_schema_category_section_filtered( + &body, + ctx, + "GPU telemetry", + "Choose the clocks, temperatures, memory, and GPU stats readout that matter for your setup. GPU usage is part of the main stats block on MangoHud builds that expose it there. Visual styling lives in Colors and Theme.", + Some("Core signals"), + Category::DisplayGpu, + |entry| entry.key != "gpu_load_color", + ); + page +} diff --git a/src/ui/pages/hud_order.rs b/src/ui/pages/hud_order.rs new file mode 100644 index 0000000..6f43651 --- /dev/null +++ b/src/ui/pages/hud_order.rs @@ -0,0 +1,688 @@ +use crate::ui::pages::{refresh_live_preview_for_key, PageBuildContext}; +use crate::ui::toast::show_toast; +use crate::ui::widgets::tool_page; +use crate::window::{recompute_validation, refresh_save_button}; +use gtk4::prelude::*; +use mangotune::config::parser::Parser; +use mangotune::config::types::ConfigValue; +use std::rc::Rc; + +struct HudOrderGroupDef { + id: &'static str, + title: &'static str, + description: &'static str, + members: &'static [&'static str], +} + +const HUD_ORDER_GROUPS: &[HudOrderGroupDef] = &[ + HudOrderGroupDef { + id: "fps", + title: "FPS and pacing", + description: "FPS, frametime, graphs, limits, and pacing readouts.", + members: &[ + "fps_only", + "fps", + "frametime", + "frame_count", + "frame_timing", + "frame_timing_detailed", + "dynamic_frame_timing", + "histogram", + "show_fps_limit", + "throttling_status", + "throttling_status_graph", + ], + }, + HudOrderGroupDef { + id: "gpu", + title: "GPU", + description: "GPU usage, temperatures, clocks, power, and labels.", + members: &[ + "gpu_stats", + "gpu_temp", + "gpu_junction_temp", + "gpu_core_clock", + "gpu_mem_temp", + "gpu_mem_clock", + "gpu_power", + "gpu_power_limit", + "gpu_text", + "gpu_fan", + "gpu_voltage", + "gpu_list", + "gpu_efficiency", + "gpu_name", + "vulkan_driver", + "engine_version", + "engine_short_names", + "hide_engine_names", + "present_mode", + "pci_dev", + ], + }, + HudOrderGroupDef { + id: "vram", + title: "VRAM", + description: "VRAM usage and memory-related GPU readouts.", + members: &["vram", "gpu_mem_clock", "gpu_mem_temp"], + }, + HudOrderGroupDef { + id: "cpu", + title: "CPU", + description: "CPU load, temperature, power, clock, and core details.", + members: &[ + "cpu_stats", + "cpu_temp", + "cpu_power", + "cpu_custom_temp_sensor", + "cpu_text", + "cpu_mhz", + "cpu_efficiency", + "core_load", + "core_load_change", + "core_bars", + "core_type", + ], + }, + HudOrderGroupDef { + id: "ram", + title: "RAM", + description: "System RAM usage and RAM temperature.", + members: &["ram", "ram_temp"], + }, + HudOrderGroupDef { + id: "swap", + title: "Swap usage", + description: "Swap usage when memory pressure rises.", + members: &["swap"], + }, + HudOrderGroupDef { + id: "process-memory", + title: "Process memory", + description: "Per-process resident, shared, and virtual memory.", + members: &["procmem", "procmem_shared", "procmem_virt"], + }, + HudOrderGroupDef { + id: "process-vram", + title: "Process VRAM", + description: "Per-process VRAM usage for the current game.", + members: &["proc_vram"], + }, + HudOrderGroupDef { + id: "io-read", + title: "Disk read throughput", + description: "Read activity from the running process.", + members: &["io_read"], + }, + HudOrderGroupDef { + id: "io-write", + title: "Disk write throughput", + description: "Write activity from the running process.", + members: &["io_write"], + }, + HudOrderGroupDef { + id: "network", + title: "Network interfaces", + description: "Upload and download activity for selected interfaces.", + members: &["network"], + }, + HudOrderGroupDef { + id: "battery", + title: "Battery status", + description: "Battery level, optional icon, wattage, and remaining time.", + members: &["battery", "battery_icon", "battery_watt", "battery_time"], + }, + HudOrderGroupDef { + id: "device-battery", + title: "Device batteries", + description: "Controller, mouse, headset, and other device battery info.", + members: &["device_battery", "device_battery_icon"], + }, + HudOrderGroupDef { + id: "media-player", + title: "Media player", + description: "Now-playing text from the selected media player source.", + members: &["media_player", "media_player_name", "media_player_format"], + }, + HudOrderGroupDef { + id: "gamescope-fsr", + title: "Gamescope FSR", + description: "FSR status and sharpness information under Gamescope.", + members: &["fsr", "hide_fsr_sharpness", "fsr_steam_sharpness"], + }, + HudOrderGroupDef { + id: "gamescope-hdr", + title: "HDR status", + description: "HDR state reported by Gamescope.", + members: &["hdr"], + }, + HudOrderGroupDef { + id: "gamescope-refresh", + title: "Refresh rate", + description: "Display refresh rate when Gamescope exposes it.", + members: &["refresh_rate"], + }, + HudOrderGroupDef { + id: "gamescope-debug", + title: "Gamescope debug", + description: "Gamescope debug and frametime details.", + members: &["debug"], + }, + HudOrderGroupDef { + id: "steam-deck-fan", + title: "Fan speed", + description: "Steam Deck fan speed readout.", + members: &["fan"], + }, + HudOrderGroupDef { + id: "steam-deck-app", + title: "MangoApp Steam mode", + description: "Steam Deck specific MangoApp integration state.", + members: &["mangoapp_steam"], + }, + HudOrderGroupDef { + id: "wine", + title: "Wine version", + description: "Wine version information for the running game.", + members: &["wine"], + }, + HudOrderGroupDef { + id: "winesync", + title: "Wine sync", + description: "Wine synchronization mode details.", + members: &["winesync"], + }, + HudOrderGroupDef { + id: "exec-name", + title: "Executable name", + description: "The current game or process executable name.", + members: &["exec_name"], + }, + HudOrderGroupDef { + id: "arch", + title: "System architecture", + description: "CPU architecture and host platform details.", + members: &["arch"], + }, + HudOrderGroupDef { + id: "gamemode", + title: "GameMode", + description: "Whether GameMode is active.", + members: &["gamemode"], + }, + HudOrderGroupDef { + id: "vkbasalt", + title: "vkBasalt status", + description: "Whether vkBasalt is active.", + members: &["vkbasalt"], + }, + HudOrderGroupDef { + id: "version", + title: "MangoHud version", + description: "MangoHud version information.", + members: &["version"], + }, + HudOrderGroupDef { + id: "resolution", + title: "Resolution", + description: "Current output resolution.", + members: &["resolution"], + }, + HudOrderGroupDef { + id: "display-server", + title: "Display server", + description: "Display server and session information.", + members: &["display_server"], + }, + HudOrderGroupDef { + id: "dx-api", + title: "DirectX API", + description: "Detected DirectX API in use.", + members: &["dx_api"], + }, + HudOrderGroupDef { + id: "flip-efficiency", + title: "Flip efficiency", + description: "Joules-per-frame efficiency information.", + members: &["flip_efficiency"], + }, + HudOrderGroupDef { + id: "fex-stats", + title: "FEX stats", + description: "FEX emulator statistics.", + members: &["fex_stats"], + }, + HudOrderGroupDef { + id: "graphs", + title: "Graphs", + description: "Graph strips for selected performance metrics.", + members: &["graphs"], + }, + HudOrderGroupDef { + id: "time", + title: "Current time", + description: "Clock output with optional time formatting changes.", + members: &["time", "time_no_label", "time_format"], + }, + HudOrderGroupDef { + id: "custom-text-center", + title: "Centered custom text", + description: "Centered text line inserted into the HUD.", + members: &["custom_text_center"], + }, + HudOrderGroupDef { + id: "custom-text", + title: "Custom text", + description: "Custom text line inserted into the HUD.", + members: &["custom_text"], + }, + HudOrderGroupDef { + id: "exec", + title: "Command output", + description: "Shell command output rendered as a HUD line.", + members: &["exec"], + }, +]; + +#[derive(Clone)] +struct HudOrderItem { + id: &'static str, + title: &'static str, + description: &'static str, + active_keys: Vec, + primary_key: String, + first_line_idx: usize, +} + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "HUD Order", + "Tools", + "Reorder the visible HUD groups MangoHud renders in sequence. This changes the real option line order in your current config target.", + &["sequence", "render order", "live preview"], + ); + + tool_page::append_callout( + &body, + "How it works", + "Move whole visible HUD groups, not every child setting", + "GPU, CPU, FPS, memory, battery, media, and text blocks often combine multiple config keys into one visible unit. This page moves those units together so the preview matches what you see.", + None, + ); + + let section = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + section.add_css_class("tool-section-shell"); + + let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + let title = gtk4::Label::new(Some("Current visible HUD order")); + title.add_css_class("tool-section-title"); + title.set_xalign(0.0); + let subtitle = gtk4::Label::new(Some( + "Drag whole HUD groups above or below each other. MangoTune applies the same order to the live preview when it is running.", + )); + subtitle.add_css_class("tool-section-subtitle"); + subtitle.set_wrap(true); + subtitle.set_xalign(0.0); + header.append(&title); + header.append(&subtitle); + section.append(&header); + + let list = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + list.add_css_class("hud-order-list"); + section.append(&list); + body.append(§ion); + + let list_rc = Rc::new(list); + rebuild_order_list(&list_rc, ctx); + + page +} + +fn rebuild_order_list(list: &Rc, ctx: &PageBuildContext) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let items = ordered_hud_items(ctx); + if items.is_empty() { + let empty = gtk4::Label::new(Some( + "No enabled HUD groups are available to reorder yet. Turn on some metrics first, then come back here.", + )); + empty.add_css_class("dim-label"); + empty.set_wrap(true); + empty.set_xalign(0.0); + list.append(&empty); + return; + } + + for (index, item) in items.iter().enumerate() { + list.append(&build_order_row(ctx, list.clone(), item, index)); + } +} + +fn build_order_row( + ctx: &PageBuildContext, + list: Rc, + item: &HudOrderItem, + index: usize, +) -> gtk4::Box { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + row.add_css_class("hud-order-row"); + row.set_hexpand(true); + row.set_widget_name(item.id); + + let ordinal = gtk4::Label::new(Some(&(index + 1).to_string())); + ordinal.add_css_class("tool-chip"); + ordinal.add_css_class("hud-order-index"); + ordinal.set_valign(gtk4::Align::Center); + row.append(&ordinal); + + let copy = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + copy.set_hexpand(true); + + let title = gtk4::Label::new(Some(item.title)); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + + let subtitle = gtk4::Label::new(Some(item.description)); + subtitle.add_css_class("dim-label"); + subtitle.set_xalign(0.0); + + copy.append(&title); + copy.append(&subtitle); + row.append(©); + + let key_label = gtk4::Label::new(Some(item.id)); + key_label.add_css_class("hud-order-key"); + key_label.set_valign(gtk4::Align::Center); + row.append(&key_label); + + let drag_hint = gtk4::Label::new(Some("::")); + drag_hint.add_css_class("hud-order-handle"); + drag_hint.set_tooltip_text(Some("Drag to reorder")); + drag_hint.set_valign(gtk4::Align::Center); + row.append(&drag_hint); + + let drag_source = gtk4::DragSource::builder() + .actions(gtk4::gdk::DragAction::MOVE) + .build(); + { + let row = row.clone(); + let item_id = item.id.to_string(); + drag_source.connect_prepare(move |_, _, _| { + row.add_css_class("hud-order-row-dragging"); + Some(gtk4::gdk::ContentProvider::for_value(&item_id.to_value())) + }); + } + { + let row = row.clone(); + drag_source.connect_drag_end(move |_, _, _| { + row.remove_css_class("hud-order-row-dragging"); + }); + } + row.add_controller(drag_source); + + let drop_target = gtk4::DropTarget::new(String::static_type(), gtk4::gdk::DragAction::MOVE); + { + let ctx = ctx.clone(); + let list = list.clone(); + let row = row.clone(); + drop_target.connect_motion(move |_, _, y| { + apply_drop_marker(&ctx, &list, &row, y); + gtk4::gdk::DragAction::MOVE + }); + } + { + let list = list.clone(); + let row = row.clone(); + drop_target.connect_leave(move |_| { + clear_drop_markers(&list); + row.remove_css_class("hud-order-row-drop-before"); + row.remove_css_class("hud-order-row-drop-after"); + }); + } + { + let ctx = ctx.clone(); + let list = list.clone(); + let row = row.clone(); + let target_id = item.id.to_string(); + drop_target.connect_drop(move |_, value, _x, y| { + clear_drop_markers(&list); + row.remove_css_class("hud-order-row-drop-before"); + row.remove_css_class("hud-order-row-drop-after"); + let Ok(dragged_id) = value.get::() else { + return false; + }; + let (effective_target_id, before) = + effective_drop_target(&ctx, &target_id, y < (row.height() as f64 / 2.0)); + move_item(&ctx, &list, &dragged_id, &effective_target_id, before); + true + }); + } + row.add_controller(drop_target); + + row +} + +fn move_item( + ctx: &PageBuildContext, + list: &Rc, + item_id: &str, + target_id: &str, + before: bool, +) { + let items = ordered_hud_items(ctx); + let Some(current_idx) = items.iter().position(|item| item.id == item_id) else { + return; + }; + let Some(target_idx) = items.iter().position(|item| item.id == target_id) else { + return; + }; + if current_idx == target_idx { + return; + } + + let moving = &items[current_idx]; + let anchor = &items[target_idx]; + let mut disabled_legacy_layout = false; + let moved = if let Ok(mut state) = ctx.state.lock() { + if before { + let moved = Parser::move_option_group_before( + &mut state.config, + &moving.active_keys, + &anchor.active_keys, + ); + if moved && is_flag_effectively_enabled(state.config.options.get("legacy_layout")) { + Parser::set_value(&mut state.config, "legacy_layout", ConfigValue::Disabled); + disabled_legacy_layout = true; + } + moved + } else { + let moved = Parser::move_option_group_after( + &mut state.config, + &moving.active_keys, + &anchor.active_keys, + ); + if moved && is_flag_effectively_enabled(state.config.options.get("legacy_layout")) { + Parser::set_value(&mut state.config, "legacy_layout", ConfigValue::Disabled); + disabled_legacy_layout = true; + } + moved + } + } else { + false + }; + + if !moved { + return; + } + + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); + if ctx.preview.running_scene().is_some() { + let config = crate::ui::pages::current_config_snapshot(ctx); + let _ = ctx + .preview + .apply_live_config(&config) + .or_else(|_| ctx.preview.restart(&config)); + } else { + refresh_live_preview_for_key(ctx, Some(&moving.primary_key)); + } + rebuild_order_list(list, ctx); + show_toast( + &ctx.toast_overlay, + &if disabled_legacy_layout { + format!( + "Moved {} {} {} and turned off legacy layout", + moving.title, + if before { "before" } else { "after" }, + anchor.title + ) + } else { + format!( + "Moved {} {} {}", + moving.title, + if before { "before" } else { "after" }, + anchor.title + ) + }, + ); +} + +fn ordered_hud_items(ctx: &PageBuildContext) -> Vec { + let Ok(state) = ctx.state.lock() else { + return Vec::new(); + }; + + let mut items = HUD_ORDER_GROUPS + .iter() + .filter_map(|group| { + let mut active_members = group + .members + .iter() + .filter_map(|key| { + let (line_idx, value) = state.config.options.get(*key)?; + is_active_display_value(value).then_some((*line_idx, (*key).to_string())) + }) + .collect::>(); + + if active_members.is_empty() { + return None; + } + + active_members.sort_by_key(|(line_idx, _)| *line_idx); + let first_line_idx = active_members.first().map(|(line_idx, _)| *line_idx)?; + let primary_key = active_members + .first() + .map(|(_, key)| key.clone()) + .unwrap_or_else(|| group.members[0].to_string()); + let active_keys = active_members + .into_iter() + .map(|(_, key)| key) + .collect::>(); + + Some(HudOrderItem { + id: group.id, + title: group.title, + description: group.description, + active_keys, + primary_key, + first_line_idx, + }) + }) + .collect::>(); + + items.sort_by_key(|item| item.first_line_idx); + items +} + +fn clear_drop_markers(list: >k4::Box) { + let mut child = list.first_child(); + while let Some(widget) = child { + widget.remove_css_class("hud-order-row-drop-before"); + widget.remove_css_class("hud-order-row-drop-after"); + child = widget.next_sibling(); + } +} + +fn row_at(list: >k4::Box, idx: usize) -> Option { + let mut child = list.first_child(); + let mut current_idx = 0usize; + while let Some(widget) = child { + if current_idx == idx { + return Some(widget); + } + current_idx += 1; + child = widget.next_sibling(); + } + None +} + +fn effective_drop_target( + ctx: &PageBuildContext, + target_id: &str, + upper_half: bool, +) -> (String, bool) { + let items = ordered_hud_items(ctx); + let Some(target_idx) = items.iter().position(|item| item.id == target_id) else { + return (target_id.to_string(), true); + }; + + if upper_half { + return (target_id.to_string(), true); + } + + if let Some(next_item) = items.get(target_idx + 1) { + (next_item.id.to_string(), true) + } else { + (target_id.to_string(), false) + } +} + +fn apply_drop_marker(ctx: &PageBuildContext, list: >k4::Box, row: >k4::Box, y: f64) { + clear_drop_markers(list); + + let target_id = row.widget_name(); + let items = ordered_hud_items(ctx); + let Some(target_idx) = items.iter().position(|item| item.id == target_id) else { + return; + }; + + let upper_half = y < (row.height() as f64 / 2.0); + if upper_half { + row.add_css_class("hud-order-row-drop-before"); + return; + } + + if let Some(next_row) = row_at(list, target_idx + 1) { + next_row.add_css_class("hud-order-row-drop-before"); + } else { + row.add_css_class("hud-order-row-drop-after"); + } +} + +fn is_flag_effectively_enabled(value: Option<&(usize, ConfigValue)>) -> bool { + let Some((_, value)) = value else { + return true; + }; + + match value { + ConfigValue::Flag => true, + ConfigValue::Disabled | ConfigValue::Absent => false, + ConfigValue::Value(text) => { + let normalized = text.trim().to_ascii_lowercase(); + !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") + } + } +} + +fn is_active_display_value(value: &ConfigValue) -> bool { + match value { + ConfigValue::Flag => true, + ConfigValue::Value(raw) => !matches!( + raw.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" | "off" + ), + ConfigValue::Disabled | ConfigValue::Absent => false, + } +} diff --git a/src/ui/pages/integrations.rs b/src/ui/pages/integrations.rs new file mode 100644 index 0000000..dff6ba3 --- /dev/null +++ b/src/ui/pages/integrations.rs @@ -0,0 +1,448 @@ +use crate::ui::pages::{block_on_optional, PageBuildContext}; +use crate::ui::toast::show_toast; +use crate::ui::widgets::{toggle_row, tool_page}; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::schema::get_schema_entry; +use mangotune::integrations; +use mangotune::integrations::{ + GameModeStatus, HeroicStatus, LutrisStatus, SteamInjectMethod, SteamStatus, +}; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Integrations", + "Tools", + "Connect MangoHud to the launchers and helpers Linux gamers actually use, without digging through multiple config formats by hand.", + &["steam", "lutris", "heroic", "gamemode"], + ); + + tool_page::append_callout( + &body, + "Quick start", + "Use this page when you want MangoHud to show up before the game even launches", + "Steam gets a generated launch option, while Lutris and Heroic can patch detected game configs directly. GameMode can also be enabled from the same surface when supported.", + None, + ); + + append_gamemode_section(&body, ctx); + append_steam_section(&body, ctx); + append_lutris_section(&body, ctx); + append_heroic_section(&body, ctx); + + page +} + +fn append_gamemode_section(body: >k4::Box, ctx: &PageBuildContext) { + let group = tool_page::append_custom_section( + body, + "GameMode", + "Check daemon status and decide whether MangoHud should request GameMode when supported by your system.", + Some("Performance helper"), + ); + + let status = block_on_optional(integrations::detect_gamemode()); + let status_row = libadwaita::ActionRow::builder() + .title("Current status") + .subtitle(gamemode_status_text(status.as_ref())) + .build(); + status_row.add_css_class("control-row"); + group.add(&status_row); + + if let Some(entry) = get_schema_entry("gamemode") { + toggle_row::add_schema_row(&group, entry, ctx); + } + + if let Some(status) = status { + let detail_row = libadwaita::ActionRow::builder() + .title("Detection details") + .subtitle(gamemode_detail_text(&status)) + .build(); + detail_row.add_css_class("control-row"); + group.add(&detail_row); + } +} + +fn append_steam_section(body: >k4::Box, ctx: &PageBuildContext) { + let group = tool_page::append_custom_section( + body, + "Steam", + "Generate a launch option you can paste into a game’s Steam launch settings. MangoTune updates the command live as you switch strategies.", + Some("Launch option"), + ); + + let status = block_on_optional(integrations::detect_steam()); + let status_row = libadwaita::ActionRow::builder() + .title("Steam detection") + .subtitle(steam_status_text(status.as_ref())) + .build(); + status_row.add_css_class("control-row"); + group.add(&status_row); + + if let Some(status) = status.as_ref() { + let detail_row = libadwaita::ActionRow::builder() + .title("Library/config path") + .subtitle(steam_detail_text(status)) + .build(); + detail_row.add_css_class("control-row"); + group.add(&detail_row); + } + + let methods = steam_methods(); + let labels: Vec<&str> = methods.iter().map(|(label, _)| *label).collect(); + let model = gtk4::StringList::new(&labels); + + let method_row = libadwaita::ComboRow::builder() + .title("Injection method") + .subtitle( + "Switch between prefix, env-var, explicit config, and GameMode-style launch strings", + ) + .build(); + method_row.add_css_class("control-row"); + method_row.set_model(Some(&model)); + group.add(&method_row); + + let command_row = libadwaita::ActionRow::builder() + .title("Generated launch option") + .subtitle("Copy this into Steam game properties") + .build(); + command_row.add_css_class("control-row"); + + let entry = gtk4::Entry::new(); + entry.add_css_class("control-field"); + entry.set_hexpand(true); + entry.set_editable(false); + entry.set_text(&launch_option_for_method( + methods + .first() + .map(|(_, method)| *method) + .unwrap_or(SteamInjectMethod::MangohudPrefix), + ctx, + )); + + let copy = gtk4::Button::with_label("Copy"); + copy.add_css_class("control-button"); + let entry_copy = entry.clone(); + let overlay = ctx.toast_overlay.clone(); + copy.connect_clicked(move |_| { + if let Some(display) = gtk4::gdk::Display::default() { + display.clipboard().set_text(&entry_copy.text()); + show_toast(&overlay, "Copied Steam launch option"); + } + }); + + let entry_update = entry.clone(); + let ctx_update = ctx.clone(); + let methods_update = methods.clone(); + method_row.connect_selected_notify(move |combo| { + let idx = combo.selected() as usize; + let method = methods_update + .get(idx) + .map(|(_, method)| *method) + .unwrap_or(SteamInjectMethod::MangohudPrefix); + entry_update.set_text(&launch_option_for_method(method, &ctx_update)); + }); + + command_row.add_suffix(&entry); + command_row.add_suffix(©); + group.add(&command_row); +} + +fn append_lutris_section(body: >k4::Box, ctx: &PageBuildContext) { + let group = tool_page::append_custom_section( + body, + "Lutris", + "Detect installed Lutris YAML configs and patch the first discovered game directly when you want a quick enable path.", + Some("Config patching"), + ); + + let status = block_on_optional(integrations::detect_lutris()); + let status_row = libadwaita::ActionRow::builder() + .title("Lutris detection") + .subtitle(lutris_status_text(status.as_ref())) + .build(); + status_row.add_css_class("control-row"); + group.add(&status_row); + + if let Some(status) = status.as_ref() { + let detail_row = libadwaita::ActionRow::builder() + .title("Config root") + .subtitle( + status + .config_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "No config directory found".to_string()), + ) + .build(); + detail_row.add_css_class("control-row"); + group.add(&detail_row); + } + + append_lutris_quick_action(&group, status.as_ref(), ctx); + append_launcher_empty_state( + &group, + status.as_ref().map(|status| status.games.is_empty()).unwrap_or(true), + "No Lutris game configs detected yet", + "MangoTune can patch the first detected Lutris game once Lutris has written game YAML files on this machine.", + ); +} + +fn append_heroic_section(body: >k4::Box, ctx: &PageBuildContext) { + let group = tool_page::append_custom_section( + body, + "Heroic", + "Patch Heroic JSON configs so MangoHud launches through wrapper and environment entries without hand-editing per-game files.", + Some("Launcher patching"), + ); + + let status = block_on_optional(integrations::detect_heroic()); + let status_row = libadwaita::ActionRow::builder() + .title("Heroic detection") + .subtitle(heroic_status_text(status.as_ref())) + .build(); + status_row.add_css_class("control-row"); + group.add(&status_row); + + if let Some(status) = status.as_ref() { + let detail_row = libadwaita::ActionRow::builder() + .title("Config root") + .subtitle( + status + .config_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "No config directory found".to_string()), + ) + .build(); + detail_row.add_css_class("control-row"); + group.add(&detail_row); + } + + append_heroic_quick_action(&group, status.as_ref(), ctx); + append_launcher_empty_state( + &group, + status.as_ref().map(|status| status.games.is_empty()).unwrap_or(true), + "No Heroic game configs detected yet", + "MangoTune can patch the first detected Heroic game once Heroic has written per-game JSON configs on this machine.", + ); +} + +fn append_lutris_quick_action( + group: &libadwaita::PreferencesGroup, + status: Option<&LutrisStatus>, + ctx: &PageBuildContext, +) { + let Some(status) = status else { + return; + }; + let Some(game) = status.games.first() else { + return; + }; + + let row = libadwaita::ActionRow::builder() + .title(format!("Enable for {}", game.name)) + .subtitle(format!("{} • {}", game.runner, game.config_path.display())) + .build(); + row.add_css_class("control-row"); + + let button = gtk4::Button::with_label("Patch"); + button.add_css_class("suggested-action"); + let path = game.config_path.clone(); + let overlay = ctx.toast_overlay.clone(); + button.connect_clicked( + move |_| match integrations::set_lutris_mangohud(&path, true) { + Ok(_) => show_toast(&overlay, "Enabled MangoHud for Lutris game"), + Err(error) => show_toast(&overlay, &format!("Failed to patch Lutris config: {error}")), + }, + ); + row.add_suffix(&button); + group.add(&row); +} + +fn append_heroic_quick_action( + group: &libadwaita::PreferencesGroup, + status: Option<&HeroicStatus>, + ctx: &PageBuildContext, +) { + let Some(status) = status else { + return; + }; + let Some(game) = status.games.first() else { + return; + }; + + let row = libadwaita::ActionRow::builder() + .title(format!("Enable for {}", game.title)) + .subtitle(format!("{:?} • {}", game.store, game.config_path.display())) + .build(); + row.add_css_class("control-row"); + + let button = gtk4::Button::with_label("Patch"); + button.add_css_class("suggested-action"); + let path = game.config_path.clone(); + let overlay = ctx.toast_overlay.clone(); + button.connect_clicked(move |_| { + let wrapper_result = integrations::set_heroic_wrapper(&path, true); + let env_result = integrations::set_heroic_env(&path, true); + + match (wrapper_result, env_result) { + (Ok(_), Ok(_)) => { + show_toast(&overlay, "Enabled MangoHud for Heroic game"); + } + (wrapper, env) => { + let mut failures = Vec::new(); + if let Err(error) = wrapper { + failures.push(format!("wrapper: {error}")); + } + if let Err(error) = env { + failures.push(format!("env: {error}")); + } + show_toast( + &overlay, + &format!("Failed to patch Heroic config: {}", failures.join(", ")), + ); + } + } + }); + row.add_suffix(&button); + group.add(&row); +} + +fn append_launcher_empty_state( + group: &libadwaita::PreferencesGroup, + show: bool, + title: &str, + subtitle: &str, +) { + if !show { + return; + } + + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + group.add(&row); +} + +fn steam_methods() -> Vec<(&'static str, SteamInjectMethod)> { + vec![ + ("mangohud %command%", SteamInjectMethod::MangohudPrefix), + ("MANGOHUD=1 %command%", SteamInjectMethod::EnvVar), + ( + "MANGOHUD_CONFIGFILE=... %command%", + SteamInjectMethod::ExplicitConfig, + ), + ( + "gamemoderun mangohud %command%", + SteamInjectMethod::GameMode, + ), + ( + "gamemoderun mangohud %command% (flatpak)", + SteamInjectMethod::GameModeFlatpak, + ), + ] +} + +fn launch_option_for_method(method: SteamInjectMethod, ctx: &PageBuildContext) -> String { + let config_path = ctx.state.lock().ok().and_then(|state| { + state + .config + .path + .as_ref() + .map(|path| path.display().to_string()) + }); + integrations::generate_launch_option(method, config_path.as_deref()) +} + +fn gamemode_status_text(status: Option<&GameModeStatus>) -> String { + match status { + Some(s) if !s.daemon_installed => "Not installed".to_string(), + Some(s) if s.daemon_running => format!("Running with {} clients", s.current_clients), + Some(_) => "Installed but not running".to_string(), + None => "Detection unavailable".to_string(), + } +} + +fn gamemode_detail_text(status: &GameModeStatus) -> String { + let ctl = if status.ctl_installed { + "gamemodectl available" + } else { + "gamemodectl missing" + }; + format!("{ctl} • {} active clients", status.current_clients) +} + +fn steam_status_text(status: Option<&SteamStatus>) -> String { + match status { + Some(s) if !s.installed && !s.flatpak => "Not installed".to_string(), + Some(s) if s.flatpak && s.running => "Installed via Flatpak • running".to_string(), + Some(s) if s.flatpak => "Installed via Flatpak".to_string(), + Some(s) if s.running => "Installed • running".to_string(), + Some(_) => "Installed".to_string(), + None => "Detection unavailable".to_string(), + } +} + +fn steam_detail_text(status: &SteamStatus) -> String { + status + .localconfig_path + .as_ref() + .or(status.steam_root.as_ref()) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "No Steam config path detected".to_string()) +} + +fn lutris_status_text(status: Option<&LutrisStatus>) -> String { + match status { + Some(s) if !s.installed && !s.flatpak => "Not installed".to_string(), + Some(s) if s.flatpak => format!("Installed via Flatpak • {} detected games", s.games.len()), + Some(s) => format!("Installed • {} detected games", s.games.len()), + None => "Detection unavailable".to_string(), + } +} + +fn heroic_status_text(status: Option<&HeroicStatus>) -> String { + match status { + Some(s) if !s.installed && !s.flatpak => "Not installed".to_string(), + Some(s) if s.flatpak => format!("Installed via Flatpak • {} detected games", s.games.len()), + Some(s) => format!("Installed • {} detected games", s.games.len()), + None => "Detection unavailable".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steam_status_text_covers_running_flatpak() { + let status = SteamStatus { + installed: false, + flatpak: true, + running: true, + steam_root: None, + localconfig_path: None, + }; + assert_eq!( + steam_status_text(Some(&status)), + "Installed via Flatpak • running" + ); + } + + #[test] + fn gamemode_detail_mentions_ctl_and_clients() { + let status = GameModeStatus { + daemon_installed: true, + ctl_installed: true, + daemon_running: true, + current_clients: 2, + }; + assert_eq!( + gamemode_detail_text(&status), + "gamemodectl available • 2 active clients" + ); + } +} diff --git a/src/ui/pages/io_network.rs b/src/ui/pages/io_network.rs new file mode 100644 index 0000000..c2a459a --- /dev/null +++ b/src/ui/pages/io_network.rs @@ -0,0 +1,17 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + tool_page::build_single_category_page( + ctx, + "I/O and Network", + "Display", + "Track bandwidth, throughput, and storage activity when you need to prove a bottleneck instead of guessing at it.", + &["network", "disk", "throughput"], + "Streaming and storage signals", + "Useful when downloads, shader compiles, or disk stalls are part of the problem you are investigating.", + Some("Situational"), + Category::DisplayIoNetwork, + ) +} diff --git a/src/ui/pages/keybindings.rs b/src/ui/pages/keybindings.rs new file mode 100644 index 0000000..ac66f97 --- /dev/null +++ b/src/ui/pages/keybindings.rs @@ -0,0 +1,29 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::{hotkey_row, tool_page}; +use libadwaita::prelude::*; +use mangotune::config::help::display_title_for_key; +use mangotune::config::schema::entries_for_category; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Keybindings", + "Behavior", + "Set the shortcuts that let you toggle, cycle, or inspect MangoHud while you are in a real game.", + &["hotkeys", "toggle HUD", "in-game control"], + ); + + let group = tool_page::append_custom_section( + &body, + "Overlay shortcuts", + "These actions change how MangoHud behaves live in game. Keep them memorable and avoid conflicts with your usual binds.", + Some("In-game control"), + ); + + for entry in entries_for_category(&Category::BehaviorKeybindings) { + let row = hotkey_row::build_hotkey_row(&display_title_for_key(entry.key), entry.key, ctx); + group.add(&row); + } + + page +} diff --git a/src/ui/pages/live_preview.rs b/src/ui/pages/live_preview.rs new file mode 100644 index 0000000..99070ba --- /dev/null +++ b/src/ui/pages/live_preview.rs @@ -0,0 +1,15 @@ +use crate::ui::pages::{overview, PageBuildContext}; +use crate::ui::widgets::tool_page; +use gtk4::prelude::*; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_start_page( + "Live Preview", + "Start", + "Launch MangoTune's built-in Studio preview and tune its runtime controls without touching your saved MangoHud config.", + &["preview", "studio", "runtime tuning"], + ); + + body.append(&overview::build_preview_panel(ctx)); + page +} diff --git a/src/ui/pages/logging.rs b/src/ui/pages/logging.rs new file mode 100644 index 0000000..93945a5 --- /dev/null +++ b/src/ui/pages/logging.rs @@ -0,0 +1,17 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + tool_page::build_single_category_page( + ctx, + "Logging", + "Behavior", + "Enable logs and debug exports only when you actually need to troubleshoot MangoHud or share a reproducible issue.", + &["debugging", "uploads", "troubleshooting"], + "Logs and exports", + "These settings are mainly for investigation. Leave them quiet by default unless you are tracking down a problem.", + Some("Troubleshooting"), + Category::BehaviorLogging, + ) +} diff --git a/src/ui/pages/media_player.rs b/src/ui/pages/media_player.rs new file mode 100644 index 0000000..3eec942 --- /dev/null +++ b/src/ui/pages/media_player.rs @@ -0,0 +1,17 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + tool_page::build_single_category_page( + ctx, + "Media Player", + "Display", + "Expose now-playing info when it actually helps your setup, like couch gaming or a second-screen music flow.", + &["now playing", "optional", "media metadata"], + "Media overlays", + "These settings are niche by design. Turn them on when background media context belongs in your HUD.", + Some("Optional"), + Category::DisplayMediaPlayer, + ) +} diff --git a/src/ui/pages/memory.rs b/src/ui/pages/memory.rs new file mode 100644 index 0000000..44b7edd --- /dev/null +++ b/src/ui/pages/memory.rs @@ -0,0 +1,17 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + tool_page::build_single_category_page( + ctx, + "Memory", + "Display", + "Monitor VRAM, RAM, swap, and process-level memory pressure without flooding the overlay with low-value counters.", + &["VRAM", "RAM", "pressure"], + "Memory telemetry", + "Pick the memory signals that help explain stutter, asset streaming issues, or background pressure on your system.", + Some("Diagnostics"), + Category::DisplayMemory, + ) +} diff --git a/src/ui/pages/mod.rs b/src/ui/pages/mod.rs new file mode 100644 index 0000000..368e585 --- /dev/null +++ b/src/ui/pages/mod.rs @@ -0,0 +1,706 @@ +use crate::ui::widgets::validation_label; +use crate::window::AppState; +use gtk4::prelude::*; +use mangotune::config::help::{display_summary_for_key, display_title_for_key}; +use mangotune::config::schema::{entries_for_category, MANGOHUD_SCHEMA}; +use mangotune::config::types::AnnotatedConfig; +use mangotune::config::types::Category; +use mangotune::config::types::ValidationResult; +use mangotune::config::{schema::get_schema_entry, validator}; +use mangotune::preview::PreviewController; +use mangotune::system::detect::SystemInfo; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::runtime::Builder; + +pub mod appearance; +pub mod battery; +pub mod blacklist; +pub mod colors; +pub mod conflicts; +pub mod cpu; +pub mod debug; +pub mod fps_limits; +pub mod gpu; +pub mod hud_order; +pub mod integrations; +pub mod io_network; +pub mod keybindings; +pub mod live_preview; +pub mod logging; +pub mod media_player; +pub mod memory; +pub mod opengl_quirks; +pub mod overview; +pub mod performance; +pub mod presets_page; +pub mod raw_editor; +pub mod search_results; +pub mod typography; + +#[derive(Clone)] +pub struct PageBuildContext { + pub state: Arc>, + pub preview: PreviewController, + pub preview_reload_source: Rc>>, + pub validation_rows: Rc>>>, + pub option_rows: Rc>>>>, + pub pending_search_target: Rc>>, + pub current_search_query: Rc>, + pub save_button: libadwaita::SplitButton, + pub toast_overlay: libadwaita::ToastOverlay, + pub parent_window: libadwaita::ApplicationWindow, + pub system_info: SystemInfo, +} + +#[derive(Clone)] +pub struct ValidationRowBinding { + pub row: glib::WeakRef, + pub base_subtitle: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchResultItem { + pub key: &'static str, + pub title: String, + pub summary: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchResultGroup { + pub page_id: &'static str, + pub page_title: &'static str, + pub page_section: &'static str, + pub results: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct SidebarItem { + pub id: &'static str, + pub title: &'static str, + pub section: &'static str, + pub icon_name: &'static str, +} + +pub const SIDEBAR_ITEMS: &[SidebarItem] = &[ + SidebarItem { + id: "overview", + title: "Dashboard", + section: "Start", + icon_name: "go-home-symbolic", + }, + SidebarItem { + id: "live_preview", + title: "Live Preview", + section: "Start", + icon_name: "media-playback-start-symbolic", + }, + SidebarItem { + id: "presets_page", + title: "Presets", + section: "Start", + icon_name: "view-grid-symbolic", + }, + SidebarItem { + id: "performance", + title: "Performance", + section: "Display", + icon_name: "speedometer-symbolic", + }, + SidebarItem { + id: "gpu", + title: "GPU", + section: "Display", + icon_name: "video-display-symbolic", + }, + SidebarItem { + id: "cpu", + title: "CPU", + section: "Display", + icon_name: "computer-symbolic", + }, + SidebarItem { + id: "memory", + title: "Memory", + section: "Display", + icon_name: "drive-harddisk-symbolic", + }, + SidebarItem { + id: "io_network", + title: "I/O and Network", + section: "Display", + icon_name: "network-workgroup-symbolic", + }, + SidebarItem { + id: "media_player", + title: "Media Player", + section: "Display", + icon_name: "multimedia-player-symbolic", + }, + SidebarItem { + id: "battery", + title: "Battery", + section: "Display", + icon_name: "battery-good-symbolic", + }, + SidebarItem { + id: "appearance", + title: "Layout and Position", + section: "Appearance", + icon_name: "view-grid-symbolic", + }, + SidebarItem { + id: "colors", + title: "Colors and Theme", + section: "Appearance", + icon_name: "applications-graphics-symbolic", + }, + SidebarItem { + id: "typography", + title: "Typography", + section: "Appearance", + icon_name: "format-text-bold-symbolic", + }, + SidebarItem { + id: "keybindings", + title: "Keybindings", + section: "Behavior", + icon_name: "input-keyboard-symbolic", + }, + SidebarItem { + id: "fps_limits", + title: "FPS Limits", + section: "Behavior", + icon_name: "view-refresh-symbolic", + }, + SidebarItem { + id: "logging", + title: "Logging", + section: "Behavior", + icon_name: "text-x-log-symbolic", + }, + SidebarItem { + id: "blacklist", + title: "Blacklist", + section: "Behavior", + icon_name: "process-stop-symbolic", + }, + SidebarItem { + id: "opengl_quirks", + title: "OpenGL Quirks", + section: "Advanced", + icon_name: "applications-system-symbolic", + }, + SidebarItem { + id: "raw_editor", + title: "Raw Editor", + section: "Advanced", + icon_name: "text-x-script-symbolic", + }, + SidebarItem { + id: "hud_order", + title: "HUD Order", + section: "Tools", + icon_name: "view-sort-ascending-symbolic", + }, + SidebarItem { + id: "integrations", + title: "Integrations", + section: "Tools", + icon_name: "insert-object-symbolic", + }, + SidebarItem { + id: "debug", + title: "Debug", + section: "Tools", + icon_name: "utilities-terminal-symbolic", + }, + SidebarItem { + id: "conflicts", + title: "Layer Conflicts", + section: "Tools", + icon_name: "dialog-warning-symbolic", + }, +]; + +pub fn sidebar_search_text(item: &SidebarItem) -> String { + let mut parts = vec![ + item.title.to_string(), + item.section.to_string(), + item.id.replace('_', " "), + ]; + + match item.id { + "overview" => parts.extend( + [ + "layout position anchor offset hud width table columns readable typography metrics dashboard profiles quick tune colors preview", + "font size font scale background opacity overall alpha corner radius compact no margin horizontal outline gpu cpu ram vram fps frametime frame timing battery media", + ] + .into_iter() + .map(str::to_string), + ), + "live_preview" => parts.extend( + [ + "preview studio start apply restart stop scene vsync reset defaults balanced threshold test gpu stress cpu stress vram test", + "fps cap particle count particle size gpu passes cpu interaction vram pressure pause motion window size safe max", + ] + .into_iter() + .map(str::to_string), + ), + "presets_page" => parts.extend( + [ + "presets benchmark competitive performance streaming starter profiles starter overlays", + ] + .into_iter() + .map(str::to_string), + ), + "appearance" => add_category_terms(&mut parts, &[Category::AppearanceLayout]), + "colors" => add_category_terms(&mut parts, &[Category::AppearanceColors]), + "typography" => add_category_terms(&mut parts, &[Category::AppearanceTypography]), + "keybindings" => add_category_terms(&mut parts, &[Category::BehaviorKeybindings]), + "fps_limits" => add_category_terms(&mut parts, &[Category::BehaviorFpsLimits]), + "logging" => add_category_terms(&mut parts, &[Category::BehaviorLogging]), + "blacklist" => parts.extend( + [ + "blacklist exclude ignore process app applications executable blacklist list", + ] + .into_iter() + .map(str::to_string), + ), + "performance" => add_category_terms( + &mut parts, + &[ + Category::Performance, + Category::DisplayFps, + Category::DisplayMisc, + Category::DisplayGamescope, + ], + ), + "gpu" => add_category_terms(&mut parts, &[Category::DisplayGpu]), + "cpu" => add_category_terms(&mut parts, &[Category::DisplayCpu]), + "memory" => add_category_terms(&mut parts, &[Category::DisplayMemory]), + "io_network" => add_category_terms(&mut parts, &[Category::DisplayIoNetwork]), + "media_player" => add_category_terms(&mut parts, &[Category::DisplayMediaPlayer]), + "battery" => add_category_terms(&mut parts, &[Category::DisplayBattery]), + "opengl_quirks" => add_category_terms(&mut parts, &[Category::WorkaroundsOpengl]), + "raw_editor" => parts.extend( + [ + "raw editor config text lines comments source parse write edit manually", + ] + .into_iter() + .map(str::to_string), + ), + "hud_order" => parts.extend( + [ + "hud order render order sequence drag drop reorder gpu cpu ram vram fps pacing overlay groups", + ] + .into_iter() + .map(str::to_string), + ), + "integrations" => parts.extend( + [ + "integrations steam gamescope goverlay mangohud control socket external tools", + ] + .into_iter() + .map(str::to_string), + ), + "debug" => parts.extend( + [ + "debug diagnostics state preview logs layer stack validation config info system runtime", + ] + .into_iter() + .map(str::to_string), + ), + "conflicts" => parts.extend( + [ + "layer conflicts cascade shadowed overridden winning values priority stack config layers", + ] + .into_iter() + .map(str::to_string), + ), + _ => {} + } + + parts.join(" ").to_ascii_lowercase() +} + +pub fn build_page_widget(id: &str, ctx: &PageBuildContext) -> Option { + Some(match id { + "overview" => overview::build_page(ctx).upcast(), + "live_preview" => live_preview::build_page(ctx).upcast(), + "presets_page" => presets_page::build_page(ctx).upcast(), + "search_results" => search_results::build_page(ctx).upcast(), + "conflicts" => conflicts::build_page(ctx).upcast(), + "performance" => performance::build_page(ctx).upcast(), + "gpu" => gpu::build_page(ctx).upcast(), + "cpu" => cpu::build_page(ctx).upcast(), + "memory" => memory::build_page(ctx).upcast(), + "io_network" => io_network::build_page(ctx).upcast(), + "media_player" => media_player::build_page(ctx).upcast(), + "battery" => battery::build_page(ctx).upcast(), + "appearance" => appearance::build_page(ctx).upcast(), + "colors" => colors::build_page(ctx).upcast(), + "typography" => typography::build_page(ctx).upcast(), + "keybindings" => keybindings::build_page(ctx).upcast(), + "fps_limits" => fps_limits::build_page(ctx).upcast(), + "logging" => logging::build_page(ctx).upcast(), + "blacklist" => blacklist::build_page(ctx).upcast(), + "opengl_quirks" => opengl_quirks::build_page(ctx).upcast(), + "raw_editor" => raw_editor::build_page(ctx).upcast(), + "hud_order" => hud_order::build_page(ctx).upcast(), + "integrations" => integrations::build_page(ctx).upcast(), + "debug" => debug::build_page(ctx).upcast(), + _ => return None, + }) +} + +pub fn build_navigation_page( + id: &str, + ctx: &PageBuildContext, +) -> Option { + let page_widget = build_page_widget(id, ctx)?; + if id == "search_results" { + return Some(libadwaita::NavigationPage::with_tag( + &page_widget, + "Search Results", + "search_results", + )); + } + + let item = SIDEBAR_ITEMS.iter().find(|it| it.id == id)?; + Some(libadwaita::NavigationPage::with_tag( + &page_widget, + item.title, + item.id, + )) +} + +pub fn current_config_snapshot(ctx: &PageBuildContext) -> AnnotatedConfig { + ctx.state + .lock() + .map(|state| state.config.clone()) + .unwrap_or_else(|_| AnnotatedConfig { + lines: Vec::new(), + options: indexmap::IndexMap::new(), + path: None, + dirty: false, + }) +} + +pub fn register_validation_row(ctx: &PageBuildContext, key: &str, row: &R, base_subtitle: &str) +where + R: IsA + Clone + 'static, +{ + let action_row: libadwaita::ActionRow = row.clone().upcast(); + let weak = glib::WeakRef::new(); + weak.set(Some(&action_row)); + + let mut registry = ctx.validation_rows.borrow_mut(); + registry + .entry(key.to_string()) + .or_default() + .push(ValidationRowBinding { + row: weak, + base_subtitle: base_subtitle.to_string(), + }); + drop(registry); + + refresh_registered_validation_rows(ctx); +} + +pub fn register_option_row(ctx: &PageBuildContext, key: &str, widget: &W) +where + W: IsA + Clone + 'static, +{ + let widget: gtk4::Widget = widget.clone().upcast(); + let weak = glib::WeakRef::new(); + weak.set(Some(&widget)); + + ctx.option_rows + .borrow_mut() + .entry(key.to_string()) + .or_default() + .push(weak); + + if ctx + .pending_search_target + .borrow() + .as_deref() + .is_some_and(|pending| pending == key) + { + *ctx.pending_search_target.borrow_mut() = None; + schedule_reveal_and_flash(&widget); + } +} + +pub fn refresh_registered_validation_rows(ctx: &PageBuildContext) { + let validations = { + let Ok(state) = ctx.state.lock() else { + return; + }; + ctx.validation_rows + .borrow() + .keys() + .map(|key| (key.clone(), validation_for_key_with_state(&state, key))) + .collect::>() + }; + + let mut registry = ctx.validation_rows.borrow_mut(); + registry.retain(|key, bindings| { + let validation = validations + .get(key) + .cloned() + .unwrap_or(ValidationResult::Ok); + let error = validation_message(&validation); + bindings.retain(|binding| { + if let Some(row) = binding.row.upgrade() { + validation_label::set_action_row_error(&row, &binding.base_subtitle, error); + true + } else { + false + } + }); + !bindings.is_empty() + }); +} + +pub fn refresh_live_preview_for_key(ctx: &PageBuildContext, key: Option<&str>) { + if ctx.preview.running_scene().is_none() { + return; + } + + if let Some(source) = ctx.preview_reload_source.borrow_mut().take() { + source.remove(); + } + + let ctx_clone = ctx.clone(); + let _ = key; + let source = glib::timeout_add_local(Duration::from_millis(180), move || { + if ctx_clone.preview.running_scene().is_none() { + *ctx_clone.preview_reload_source.borrow_mut() = None; + return glib::ControlFlow::Break; + } + + let config = current_config_snapshot(&ctx_clone); + if !validator::is_saveable(&config) { + *ctx_clone.preview_reload_source.borrow_mut() = None; + return glib::ControlFlow::Break; + } + + let result = ctx_clone.preview.apply_live_config(&config); + if result.is_err() && ctx_clone.preview.running_scene().is_some() { + let _ = ctx_clone.preview.restart(&config); + } + + *ctx_clone.preview_reload_source.borrow_mut() = None; + glib::ControlFlow::Break + }); + *ctx.preview_reload_source.borrow_mut() = Some(source); +} + +pub fn search_results_for_query(query: &str) -> Vec { + let normalized_query = normalize_search_text(query); + if normalized_query.is_empty() { + return Vec::new(); + } + + SIDEBAR_ITEMS + .iter() + .filter_map(|item| { + let results = MANGOHUD_SCHEMA + .iter() + .filter(|entry| page_id_for_category(&entry.category) == Some(item.id)) + .filter_map(|entry| { + let title = display_title_for_key(entry.key); + let summary = display_summary_for_key(entry.key); + let haystack = [ + entry.key.to_ascii_lowercase(), + entry.key.replace('_', " ").to_ascii_lowercase(), + title.to_ascii_lowercase(), + normalize_search_text(&title), + summary.to_ascii_lowercase(), + normalize_search_text(&summary), + ] + .join(" "); + if haystack.contains(&normalized_query) { + Some(SearchResultItem { + key: entry.key, + title, + summary, + }) + } else { + None + } + }) + .collect::>(); + + if results.is_empty() { + None + } else { + Some(SearchResultGroup { + page_id: item.id, + page_title: item.title, + page_section: item.section, + results, + }) + } + }) + .collect() +} + +pub fn focus_pending_search_target(ctx: &PageBuildContext) { + let Some(key) = ctx.pending_search_target.borrow().clone() else { + return; + }; + let target = ctx + .option_rows + .borrow() + .get(&key) + .and_then(|widgets| widgets.iter().find_map(glib::WeakRef::upgrade)); + let Some(target) = target else { + return; + }; + *ctx.pending_search_target.borrow_mut() = None; + schedule_reveal_and_flash(&target); +} + +fn validation_for_key_with_state(state: &AppState, key: &str) -> ValidationResult { + if let Some(result) = state.validation.get(key) { + return result.clone(); + } + + if let Some((_, value)) = state.config.options.get(key) { + if let Some(schema) = get_schema_entry(key) { + return validator::validate_value(key, value, schema); + } + } + + ValidationResult::Ok +} + +fn validation_message(validation: &ValidationResult) -> Option<&str> { + match validation { + ValidationResult::Error(message) | ValidationResult::Warning(message) => { + Some(message.as_str()) + } + ValidationResult::Ok => None, + } +} + +fn add_category_terms(parts: &mut Vec, categories: &[Category]) { + for category in categories { + for entry in entries_for_category(category) { + parts.push(display_title_for_key(entry.key)); + parts.push(display_summary_for_key(entry.key)); + parts.push(entry.key.to_string()); + parts.push(entry.key.replace('_', " ")); + } + } +} + +fn page_id_for_category(category: &Category) -> Option<&'static str> { + Some(match category { + Category::Performance + | Category::DisplayFps + | Category::DisplayMisc + | Category::DisplayGamescope => "performance", + Category::DisplayGpu => "gpu", + Category::DisplayCpu => "cpu", + Category::DisplayMemory => "memory", + Category::DisplayIoNetwork => "io_network", + Category::DisplayBattery => "battery", + Category::DisplayMediaPlayer => "media_player", + Category::AppearanceLayout => "appearance", + Category::AppearanceColors => "colors", + Category::AppearanceTypography => "typography", + Category::BehaviorKeybindings => "keybindings", + Category::BehaviorFpsLimits => "fps_limits", + Category::BehaviorLogging => "logging", + Category::BehaviorMisc => "blacklist", + Category::WorkaroundsOpengl => "opengl_quirks", + Category::DisplayGraphs | Category::DisplaySteamDeck | Category::DisplayTimeText => { + "performance" + } + Category::AdvancedFcat | Category::AdvancedFtrace => return None, + }) +} + +fn normalize_search_text(input: &str) -> String { + input + .chars() + .map(|ch| match ch { + '_' | '-' | '/' => ' ', + _ => ch.to_ascii_lowercase(), + }) + .collect::() +} + +fn reveal_and_flash_widget(widget: >k4::Widget) { + let list_row = widget + .ancestor(gtk4::ListBoxRow::static_type()) + .and_then(|ancestor| ancestor.downcast::().ok()); + let anchor: gtk4::Widget = list_row + .as_ref() + .map(|row| row.clone().upcast()) + .unwrap_or_else(|| widget.clone()); + + if let Some(scrolled) = widget + .ancestor(gtk4::ScrolledWindow::static_type()) + .and_then(|ancestor| ancestor.downcast::().ok()) + { + let adjustment = scrolled.vadjustment(); + let Some(content) = scrolled.child() else { + return; + }; + let Some(bounds) = anchor.compute_bounds(&content) else { + return; + }; + let top = bounds.y() as f64; + let height = bounds.height() as f64; + let page_size = adjustment.page_size(); + let row_center = top + (height / 2.0); + let max_value = (adjustment.upper() - page_size).max(adjustment.lower()); + let centered_value = (row_center - (page_size / 2.0)).clamp(adjustment.lower(), max_value); + if (adjustment.value() - centered_value).abs() > 12.0 { + adjustment.set_value(centered_value); + } + } + + let widget_clone = widget.clone(); + let list_row_for_add = list_row.clone(); + glib::timeout_add_local_once(Duration::from_millis(70), move || { + widget_clone.add_css_class("search-target-flash"); + if let Some(row) = list_row_for_add.as_ref() { + row.add_css_class("search-target-flash"); + } + }); + + let widget_clone = widget.clone(); + glib::timeout_add_local_once(Duration::from_millis(2200), move || { + widget_clone.remove_css_class("search-target-flash"); + if let Some(row) = list_row { + row.remove_css_class("search-target-flash"); + } + }); +} + +fn schedule_reveal_and_flash(widget: >k4::Widget) { + let widget_clone = widget.clone(); + glib::timeout_add_local_once(Duration::from_millis(260), move || { + reveal_and_flash_widget(&widget_clone); + }); +} + +pub fn block_on_optional(future: F) -> Option +where + F: std::future::Future, +{ + if let Ok(handle) = tokio::runtime::Handle::try_current() { + return Some(handle.block_on(future)); + } + let runtime = Builder::new_current_thread().enable_all().build().ok()?; + Some(runtime.block_on(future)) +} diff --git a/src/ui/pages/opengl_quirks.rs b/src/ui/pages/opengl_quirks.rs new file mode 100644 index 0000000..5cde749 --- /dev/null +++ b/src/ui/pages/opengl_quirks.rs @@ -0,0 +1,17 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + tool_page::build_single_category_page( + ctx, + "OpenGL Quirks", + "Advanced", + "Reach for these workarounds when an OpenGL title behaves differently from Vulkan and needs compatibility help from MangoHud.", + &["OpenGL", "compatibility", "fallbacks"], + "OpenGL workarounds", + "These settings are mostly for edge cases. Use them when a game or launcher needs extra help rather than as a default baseline.", + Some("Compatibility"), + Category::WorkaroundsOpengl, + ) +} diff --git a/src/ui/pages/overview.rs b/src/ui/pages/overview.rs new file mode 100644 index 0000000..8cb38c6 --- /dev/null +++ b/src/ui/pages/overview.rs @@ -0,0 +1,3336 @@ +use crate::ui::pages::{current_config_snapshot, refresh_live_preview_for_key, PageBuildContext}; +use crate::ui::toast::show_toast; +use crate::ui::widgets::color_utils::{hex_to_rgba, rgba_to_hex}; +use crate::ui::widgets::toggle_row::configure_spin_button_for_option_type; +use crate::ui::widgets::tool_page; +use crate::window::{app_settings, recompute_validation, refresh_save_button}; +use gtk4::pango::EllipsizeMode; +use gtk4::prelude::*; +use libadwaita::prelude::{AlertDialogExt, AlertDialogExtManual}; +use mangotune::config::parser::{flag_defaults_to_enabled, Parser}; +use mangotune::config::schema::{get_schema_entry, MANGOHUD_SCHEMA}; +use mangotune::config::types::{ + AnnotatedConfig, Category, ConfigValue, OptionType, ValidationResult, +}; +use mangotune::preview::{ + effective_preview_hud_width, PreviewScene, PreviewStudioOptions, StudioScene, +}; +use mangotune::profiles; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::rc::Rc; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let chips_owned = [ + format!( + "MangoHud {}", + ctx.system_info + .mangohud + .version + .clone() + .unwrap_or_else(|| "unknown".to_string()) + ), + format!("Display {:?}", ctx.system_info.display_server), + format!("GPU {:?}", ctx.system_info.gpu.vendor), + format!("Config {}", active_config_name(ctx)), + ]; + let chips = chips_owned.iter().map(String::as_str).collect::>(); + let (scroll, root) = tool_page::build_start_page( + "Dashboard", + "Start", + "Start with layout, scale, colors, and the metrics most people toggle first. Start pages cover preview launch and starter presets when you need them.", + &chips, + ); + + let top_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + top_row.add_css_class("dashboard-row"); + let position = build_position_card(ctx); + position.add_css_class("dashboard-primary-card"); + let appearance = build_appearance_card(ctx); + appearance.add_css_class("dashboard-secondary-card"); + top_row.append(&position); + top_row.append(&appearance); + root.append(&top_row); + + root.append(&build_metrics_card(ctx)); + let profiles = build_profiles_panel(ctx); + profiles.add_css_class("dashboard-footer-card"); + root.append(&profiles); + + root.append(&build_workspace_strip(ctx)); + + scroll +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DashboardPreset { + Benchmark, + Competitive, + Performance, + Streaming, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PreviewProfile { + Balanced, + ThresholdTest, + GpuStress, + CpuStress, + VramTest, +} + +impl PreviewProfile { + fn title(self) -> &'static str { + match self { + Self::Balanced => "Balanced", + Self::ThresholdTest => "Threshold Test", + Self::GpuStress => "GPU Stress", + Self::CpuStress => "CPU Stress", + Self::VramTest => "VRAM Test", + } + } + + fn tooltip(self) -> &'static str { + match self { + Self::Balanced => { + "General-purpose preview defaults with moderate motion and safe load." + } + Self::ThresholdTest => { + "Steadier FPS and mixed CPU/GPU motion for testing threshold colors." + } + Self::GpuStress => { + "Push GPU-related readouts harder with more coverage and redraw work." + } + Self::CpuStress => { + "Raise CPU-related readouts with more particles and simulation work." + } + Self::VramTest => { + "Raise VRAM usage without turning the rest of the preview into a benchmark." + } + } + } + + fn studio(self) -> PreviewStudioOptions { + match self { + Self::Balanced => PreviewStudioOptions::default(), + Self::ThresholdTest => PreviewStudioOptions { + scene: StudioScene::StaticInspection, + fps_cap: Some(60), + vsync: false, + vram_pressure_mb: 256, + particle_count: 2_000, + particle_size: 0.05, + gpu_passes: 2, + interaction_steps: 12, + paused: false, + }, + Self::GpuStress => PreviewStudioOptions { + scene: StudioScene::MotionStress, + fps_cap: None, + vsync: false, + vram_pressure_mb: 256, + particle_count: 2_000, + particle_size: 0.08, + gpu_passes: 4, + interaction_steps: 4, + paused: false, + }, + Self::CpuStress => PreviewStudioOptions { + scene: StudioScene::NoiseField, + fps_cap: Some(120), + vsync: false, + vram_pressure_mb: 128, + particle_count: 8_000, + particle_size: 0.03, + gpu_passes: 1, + interaction_steps: 32, + paused: false, + }, + Self::VramTest => PreviewStudioOptions { + scene: StudioScene::DarkArena, + fps_cap: Some(120), + vsync: false, + vram_pressure_mb: 1024, + particle_count: 1_000, + particle_size: 0.03, + gpu_passes: 1, + interaction_steps: 0, + paused: false, + }, + } + } + + fn all() -> &'static [Self] { + &[ + Self::Balanced, + Self::ThresholdTest, + Self::GpuStress, + Self::CpuStress, + Self::VramTest, + ] + } +} + +impl DashboardPreset { + fn title(self) -> &'static str { + match self { + Self::Benchmark => "Benchmark", + Self::Competitive => "Competitive", + Self::Performance => "Performance", + Self::Streaming => "Streaming", + } + } + + fn description(self) -> &'static str { + match self { + Self::Benchmark => { + "Fuller telemetry for testing runs, frame pacing checks, and API/session verification." + } + Self::Competitive => { + "Lean top-center readout with FPS thresholds and almost no visual bulk." + } + Self::Performance => { + "Centered horizontal monitoring with FPS, frametime, temps, CPU, RAM, and VRAM." + } + Self::Streaming => { + "Cleaner top-center overlay that stays readable on capture without too much noise." + } + } + } + + fn badge(self) -> &'static str { + match self { + Self::Benchmark => "stress pass", + Self::Competitive => "fast glance", + Self::Performance => "monitoring", + Self::Streaming => "capture ready", + } + } + + fn profile_name(self) -> &'static str { + match self { + Self::Benchmark => "Benchmark", + Self::Competitive => "Competitive", + Self::Performance => "Performance", + Self::Streaming => "Streaming", + } + } +} + +fn studio_scene_title(scene: StudioScene) -> &'static str { + match scene { + StudioScene::DarkArena => "Dark Arena", + StudioScene::BrightWash => "Bright Wash", + StudioScene::MotionStress => "Motion Stress", + StudioScene::StaticInspection => "Static Inspection", + StudioScene::NoiseField => "Noise Field", + } +} + +fn preview_group(title: &str, description: &str) -> (gtk4::Box, gtk4::Box) { + let group = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + group.add_css_class("dashboard-status-panel"); + group.add_css_class("preview-group"); + + let heading = gtk4::Label::new(Some(title)); + heading.add_css_class("dashboard-field-label"); + heading.set_xalign(0.0); + + let subtitle = gtk4::Label::new(Some(description)); + subtitle.add_css_class("dim-label"); + subtitle.set_wrap(true); + subtitle.set_xalign(0.0); + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + body.add_css_class("preview-group-body"); + + group.append(&heading); + group.append(&subtitle); + group.append(&body); + (group, body) +} + +fn configure_preview_spin( + spin: >k4::SpinButton, + option_type: &OptionType, + tooltip: &str, + digits: u32, +) { + spin.add_css_class("control-field"); + spin.add_css_class("preview-fixed-spin"); + spin.set_hexpand(false); + spin.set_halign(gtk4::Align::End); + spin.set_size_request(108, -1); + configure_spin_button_for_option_type(spin, option_type); + spin.set_digits(digits); + spin.set_tooltip_text(Some(tooltip)); + install_scroll_passthrough(spin.upcast_ref()); +} + +fn preview_vram_total_mb(ctx: &PageBuildContext) -> u32 { + ctx.system_info + .gpu + .total_vram_mb + .unwrap_or(4_096) + .clamp(512, 65_536) +} + +fn preview_vram_used_mb(ctx: &PageBuildContext) -> Option { + ctx.system_info + .gpu + .used_vram_mb + .map(|mb| mb.clamp(0, preview_vram_total_mb(ctx))) +} + +fn preview_vram_safe_max_mb(ctx: &PageBuildContext) -> u32 { + let total = preview_vram_total_mb(ctx); + let reserve_mb = ((total as f64) * 0.10).ceil() as u32; + let reserve_mb = reserve_mb.max(1_024).min(total); + + let free_budget_mb = preview_vram_used_mb(ctx) + .map(|used| total.saturating_sub(used).saturating_sub(reserve_mb)) + .unwrap_or_else(|| total.saturating_sub(reserve_mb)); + + // The preview tends to use more VRAM than the raw pressure setting alone, + // so keep Safe max deliberately conservative. + let conservative_target = free_budget_mb / 2; + let rounded = (conservative_target / 256) * 256; + rounded.clamp(0, total) +} + +fn format_mib_as_gib_text(mb: u32) -> String { + format!("{:.1} GiB", mb as f64 / 1024.0) +} + +fn default_preview_window_size() -> (i32, i32) { + (1280, 720) +} + +fn persist_studio_options(studio: &PreviewStudioOptions) { + persist_preview_studio_scene(studio.scene); + persist_studio_fps_cap(studio.fps_cap.unwrap_or(0) as i32); + persist_preview_vsync(studio.vsync); + persist_studio_vram_pressure(studio.vram_pressure_mb as i32); + persist_studio_particle_count(studio.particle_count as i32); + persist_studio_particle_size(studio.particle_size as f64); + persist_studio_gpu_passes(studio.gpu_passes as i32); + persist_studio_interaction_steps(studio.interaction_steps as i32); +} + +pub(crate) fn build_preview_panel(ctx: &PageBuildContext) -> gtk4::Box { + let panel = gtk4::Box::new(gtk4::Orientation::Vertical, 10); + panel.add_css_class("dashboard-stack"); + + let detected_vram_total_mb = preview_vram_total_mb(ctx); + let detected_vram_safe_mb = preview_vram_safe_max_mb(ctx); + let mut initial_studio = preferred_studio_options(); + initial_studio.vram_pressure_mb = initial_studio.vram_pressure_mb.min(detected_vram_total_mb); + let studio_defaults = Rc::new(RefCell::new(initial_studio)); + let control_sync = Rc::new(Cell::new(false)); + + let (session_group, session_body) = preview_group( + "Preview session", + "Start, update, restart, or stop the built-in Studio preview. These changes stay isolated from your saved MangoHud config.", + ); + let status_label = gtk4::Label::new(None); + status_label.set_xalign(0.0); + status_label.set_wrap(true); + status_label.add_css_class("preview-status"); + let status_hint = gtk4::Label::new(Some( + "The preview uses your current unsaved workspace state. Save only when you want to update the real config.", + )); + status_hint.add_css_class("dim-label"); + status_hint.set_wrap(true); + status_hint.set_xalign(0.0); + session_body.append(&status_label); + session_body.append(&status_hint); + + let buttons = gtk4::Grid::new(); + buttons.add_css_class("dashboard-preview-actions"); + buttons.set_column_spacing(8); + buttons.set_column_homogeneous(true); + buttons.set_hexpand(true); + let start_button = gtk4::Button::with_label("Start"); + start_button.add_css_class("suggested-action"); + let reload_button = gtk4::Button::with_label("Apply"); + let restart_button = gtk4::Button::with_label("Restart"); + let stop_button = gtk4::Button::with_label("Stop"); + start_button.set_tooltip_text(Some( + "Launch the Studio preview window with your current unsaved MangoHud workspace and the current Studio runtime controls.", + )); + reload_button.set_tooltip_text(Some( + "Push your current unsaved workspace and current Studio runtime controls into the already running preview without restarting it.", + )); + restart_button.set_tooltip_text(Some( + "Restart the Studio preview window so settings that need a fresh launch, like window size, take effect.", + )); + stop_button.set_tooltip_text(Some("Stop the Studio preview window.")); + start_button.set_hexpand(true); + reload_button.set_hexpand(true); + restart_button.set_hexpand(true); + stop_button.set_hexpand(true); + buttons.attach(&start_button, 0, 0, 1, 1); + buttons.attach(&reload_button, 1, 0, 1, 1); + buttons.attach(&restart_button, 2, 0, 1, 1); + buttons.attach(&stop_button, 3, 0, 1, 1); + session_body.append(&buttons); + + let (window_group, window_body) = preview_group( + "Window", + "Used for the built-in Studio preview window. Size changes still require a restart.", + ); + let (preferred_width, preferred_height) = preferred_preview_window_size(); + let size_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + size_controls.add_css_class("dashboard-inline-row"); + size_controls.add_css_class("preview-size-row"); + size_controls.set_valign(gtk4::Align::Center); + let size_label = gtk4::Label::new(Some("Size")); + size_label.add_css_class("dashboard-field-label"); + size_label.set_xalign(0.0); + size_label.set_hexpand(true); + let width_adjustment = + gtk4::Adjustment::new(preferred_width as f64, 640.0, 3840.0, 20.0, 100.0, 0.0); + let width_spin = gtk4::SpinButton::new(Some(&width_adjustment), 1.0, 0); + configure_preview_spin( + &width_spin, + &OptionType::Int { + min: 640, + max: 3840, + }, + "Preview window width in pixels.", + 0, + ); + let size_separator = gtk4::Label::new(Some("×")); + size_separator.add_css_class("dashboard-value-label"); + let height_adjustment = + gtk4::Adjustment::new(preferred_height as f64, 360.0, 2160.0, 20.0, 100.0, 0.0); + let height_spin = gtk4::SpinButton::new(Some(&height_adjustment), 1.0, 0); + configure_preview_spin( + &height_spin, + &OptionType::Int { + min: 360, + max: 2160, + }, + "Preview window height in pixels.", + 0, + ); + size_controls.append(&size_label); + size_controls.append(&width_spin); + size_controls.append(&size_separator); + size_controls.append(&height_spin); + window_body.append(&size_controls); + + let (backdrop_group, backdrop_body) = preview_group( + "Backdrop and flow", + "Choose the scene feel and whether the preview should stay animated or sync to your display.", + ); + let scene_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + scene_row.add_css_class("dashboard-preview-subsection"); + let scene_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + scene_controls.add_css_class("dashboard-inline-row"); + scene_controls.set_valign(gtk4::Align::Center); + let scene_label = gtk4::Label::new(Some("Scene")); + scene_label.add_css_class("dashboard-field-label"); + scene_label.set_xalign(0.0); + scene_label.set_hexpand(true); + let scene_labels = StudioScene::all() + .iter() + .map(|scene| studio_scene_title(*scene)) + .collect::>(); + let scene_dropdown = gtk4::DropDown::from_strings(&scene_labels); + scene_dropdown.add_css_class("control-field"); + scene_dropdown.add_css_class("preview-scene-control"); + scene_dropdown.set_hexpand(false); + scene_dropdown.set_halign(gtk4::Align::End); + scene_dropdown.set_size_request(180, -1); + let initial_scene_index = StudioScene::all() + .iter() + .position(|scene| *scene == studio_defaults.borrow().scene) + .unwrap_or(0) as u32; + scene_dropdown.set_selected(initial_scene_index); + scene_dropdown.set_tooltip_text(Some( + "Pick the kind of test backdrop you want. Dark Arena is the general default, Bright Wash is for contrast checks, Motion Stress is the busiest, Static Inspection stays calmer, and Noise Field spreads more motion across the screen.", + )); + let scene_hint = gtk4::Label::new(Some( + "Changes the preview backdrop and camera feel without touching your MangoHud config.", + )); + scene_hint.add_css_class("dim-label"); + scene_hint.set_xalign(0.0); + scene_controls.append(&scene_label); + scene_controls.append(&scene_dropdown); + scene_row.append(&scene_controls); + scene_row.append(&scene_hint); + backdrop_body.append(&scene_row); + + let vsync_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + vsync_row.add_css_class("dashboard-inline-row"); + let vsync_label = gtk4::Label::new(Some("VSync")); + vsync_label.add_css_class("dashboard-field-label"); + vsync_label.set_xalign(0.0); + vsync_label.set_hexpand(true); + let vsync_switch = gtk4::Switch::new(); + vsync_switch.set_active(studio_defaults.borrow().vsync); + vsync_switch.set_tooltip_text(Some( + "Match the preview to your display refresh. Turning this on can lower FPS and GPU usage, which is useful when you want steadier readouts.", + )); + vsync_row.append(&vsync_label); + vsync_row.append(&vsync_switch); + backdrop_body.append(&vsync_row); + + let pause_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + pause_row.add_css_class("dashboard-inline-row"); + let pause_label = gtk4::Label::new(Some("Pause motion")); + pause_label.add_css_class("dashboard-field-label"); + pause_label.set_xalign(0.0); + pause_label.set_hexpand(true); + let pause_switch = gtk4::Switch::new(); + pause_switch.set_active(studio_defaults.borrow().paused); + pause_switch.set_tooltip_text(Some( + "Freeze the motion so you can inspect the HUD without the scene moving underneath it.", + )); + pause_row.append(&pause_label); + pause_row.append(&pause_switch); + backdrop_body.append(&pause_row); + + let (gpu_group, gpu_body) = preview_group( + "GPU and frame load", + "These controls mostly affect GPU usage, redraw cost, FPS, and overall screen coverage.", + ); + let fps_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + fps_row.add_css_class("dashboard-preview-subsection"); + let fps_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + fps_controls.add_css_class("dashboard-inline-row"); + fps_controls.set_valign(gtk4::Align::Center); + let fps_label = gtk4::Label::new(Some("FPS cap")); + fps_label.add_css_class("dashboard-field-label"); + fps_label.set_xalign(0.0); + fps_label.set_hexpand(true); + let fps_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().fps_cap.unwrap_or(0) as f64, + 0.0, + 1000.0, + 25.0, + 50.0, + 0.0, + ); + let fps_spin = gtk4::SpinButton::new(Some(&fps_adjustment), 1.0, 0); + configure_preview_spin( + &fps_spin, + &OptionType::Int { min: 0, max: 1000 }, + "Caps how fast the preview runs. Lower values make threshold testing easier. Set 0 only if you want the preview to run flat-out.", + 0, + ); + let fps_hint = gtk4::Label::new(Some("0 = uncapped and will usually max the GPU")); + fps_hint.add_css_class("dim-label"); + fps_hint.set_xalign(0.0); + fps_controls.append(&fps_label); + fps_controls.append(&fps_spin); + fps_row.append(&fps_controls); + fps_row.append(&fps_hint); + gpu_body.append(&fps_row); + + let particle_size_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + particle_size_row.add_css_class("dashboard-preview-subsection"); + let particle_size_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + particle_size_controls.add_css_class("dashboard-inline-row"); + particle_size_controls.set_valign(gtk4::Align::Center); + let particle_size_label = gtk4::Label::new(Some("Particle size")); + particle_size_label.add_css_class("dashboard-field-label"); + particle_size_label.set_xalign(0.0); + particle_size_label.set_hexpand(true); + let particle_size_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().particle_size as f64, + 0.01, + 5.0, + 0.01, + 0.10, + 0.0, + ); + let particle_size_spin = gtk4::SpinButton::new(Some(&particle_size_adjustment), 0.01, 2); + configure_preview_spin( + &particle_size_spin, + &OptionType::Float { min: 0.01, max: 5.0 }, + "How large each particle appears on screen. Bigger particles cover more of the window and usually raise GPU cost the fastest.", + 2, + ); + particle_size_controls.append(&particle_size_label); + particle_size_controls.append(&particle_size_spin); + particle_size_row.append(&particle_size_controls); + gpu_body.append(&particle_size_row); + + let gpu_passes_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + gpu_passes_row.add_css_class("dashboard-preview-subsection"); + let gpu_passes_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + gpu_passes_controls.add_css_class("dashboard-inline-row"); + gpu_passes_controls.set_valign(gtk4::Align::Center); + let gpu_passes_label = gtk4::Label::new(Some("GPU passes")); + gpu_passes_label.add_css_class("dashboard-field-label"); + gpu_passes_label.set_xalign(0.0); + gpu_passes_label.set_hexpand(true); + let gpu_passes_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().gpu_passes as f64, + 1.0, + 64.0, + 1.0, + 4.0, + 0.0, + ); + let gpu_passes_spin = gtk4::SpinButton::new(Some(&gpu_passes_adjustment), 1.0, 0); + configure_preview_spin( + &gpu_passes_spin, + &OptionType::Int { min: 1, max: 64 }, + "How many extra times the preview redraws the scene each frame. This is the most direct way to push GPU load higher.", + 0, + ); + gpu_passes_controls.append(&gpu_passes_label); + gpu_passes_controls.append(&gpu_passes_spin); + gpu_passes_row.append(&gpu_passes_controls); + gpu_body.append(&gpu_passes_row); + + let vram_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + vram_row.add_css_class("dashboard-preview-subsection"); + let vram_controls = gtk4::Grid::new(); + vram_controls.add_css_class("dashboard-inline-grid"); + vram_controls.set_column_spacing(12); + vram_controls.set_row_spacing(6); + let vram_label = gtk4::Label::new(Some("VRAM pressure")); + vram_label.add_css_class("dashboard-field-label"); + vram_label.set_xalign(0.0); + let vram_hint_text = + match (ctx.system_info.gpu.total_vram_mb, ctx.system_info.gpu.used_vram_mb) { + (Some(total), Some(used)) => format!( + "Detected VRAM: {} total, {} already in use. Safe max: {}.", + format_mib_as_gib_text(total), + format_mib_as_gib_text(used.min(total)), + format_mib_as_gib_text(detected_vram_safe_mb) + ), + (Some(total), None) => format!( + "Detected VRAM: {} total. Safe max: {}. Current VRAM use could not be read, so this uses a conservative fallback.", + format_mib_as_gib_text(total), + format_mib_as_gib_text(detected_vram_safe_mb) + ), + (None, _) => format!( + "GPU memory could not be detected. Safe max: {} using a conservative fallback.", + format_mib_as_gib_text(detected_vram_safe_mb) + ), + }; + let vram_hint = gtk4::Label::new(Some(&vram_hint_text)); + vram_hint.add_css_class("dim-label"); + vram_hint.set_xalign(0.0); + let vram_label_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + vram_label_box.set_hexpand(true); + vram_label_box.append(&vram_label); + vram_label_box.append(&vram_hint); + let vram_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().vram_pressure_mb as f64, + 0.0, + detected_vram_total_mb as f64, + 16.0, + 64.0, + 0.0, + ); + let vram_spin = gtk4::SpinButton::new(Some(&vram_adjustment), 1.0, 0); + configure_preview_spin( + &vram_spin, + &OptionType::Int { + min: 0, + max: detected_vram_total_mb as i64, + }, + "Reserves GPU memory so you can see VRAM readouts change. Use Safe max if you want a high value without risking the preview.", + 0, + ); + let safe_vram_button = gtk4::Button::with_label("Safe max"); + safe_vram_button.add_css_class("shell-strip-button"); + safe_vram_button.set_tooltip_text(Some( + "Set VRAM pressure to a conservative high value based on detected total VRAM, current VRAM use, and reserved safety headroom.", + )); + { + let vram_spin = vram_spin.clone(); + safe_vram_button.connect_clicked(move |_| { + vram_spin.set_value(detected_vram_safe_mb as f64); + }); + } + vram_controls.attach(&vram_label_box, 0, 0, 1, 2); + vram_controls.attach(&vram_spin, 1, 0, 1, 1); + vram_controls.attach(&safe_vram_button, 1, 1, 1, 1); + vram_row.append(&vram_controls); + gpu_body.append(&vram_row); + + let (cpu_group, cpu_body) = preview_group( + "CPU and density", + "These controls mostly affect particle count, simulation work, and how busy the preview feels over time.", + ); + let particle_count_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + particle_count_row.add_css_class("dashboard-preview-subsection"); + let particle_count_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + particle_count_controls.add_css_class("dashboard-inline-row"); + particle_count_controls.set_valign(gtk4::Align::Center); + let particle_count_label = gtk4::Label::new(Some("Particle count")); + particle_count_label.add_css_class("dashboard-field-label"); + particle_count_label.set_xalign(0.0); + particle_count_label.set_hexpand(true); + let particle_count_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().particle_count as f64, + 100.0, + 500_000.0, + 100.0, + 5_000.0, + 0.0, + ); + let particle_count_spin = gtk4::SpinButton::new(Some(&particle_count_adjustment), 1.0, 0); + configure_preview_spin( + &particle_count_spin, + &OptionType::Int { + min: 100, + max: 500_000, + }, + "How many particles the preview simulates. More particles usually raise CPU work first, then GPU cost as the scene gets denser.", + 0, + ); + let particle_count_hint = + gtk4::Label::new(Some("Lower this first if the preview is too heavy.")); + particle_count_hint.add_css_class("dim-label"); + particle_count_hint.set_xalign(0.0); + particle_count_controls.append(&particle_count_label); + particle_count_controls.append(&particle_count_spin); + particle_count_row.append(&particle_count_controls); + particle_count_row.append(&particle_count_hint); + cpu_body.append(&particle_count_row); + + let interaction_row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + interaction_row.add_css_class("dashboard-preview-subsection"); + let interaction_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + interaction_controls.add_css_class("dashboard-inline-row"); + interaction_controls.set_valign(gtk4::Align::Center); + let interaction_label = gtk4::Label::new(Some("CPU interaction")); + interaction_label.add_css_class("dashboard-field-label"); + interaction_label.set_xalign(0.0); + interaction_label.set_hexpand(true); + let interaction_adjustment = gtk4::Adjustment::new( + studio_defaults.borrow().interaction_steps as f64, + 0.0, + 256.0, + 1.0, + 8.0, + 0.0, + ); + let interaction_spin = gtk4::SpinButton::new(Some(&interaction_adjustment), 1.0, 0); + configure_preview_spin( + &interaction_spin, + &OptionType::Int { min: 0, max: 256 }, + "Adds extra simulation work on the CPU. Raise this when you want CPU-related MangoHud colors and graphs to move more.", + 0, + ); + interaction_controls.append(&interaction_label); + interaction_controls.append(&interaction_spin); + interaction_row.append(&interaction_controls); + cpu_body.append(&interaction_row); + + let (setup_group, setup_body) = preview_group( + "Preview setups", + "Apply a built-in workload shape quickly, or reset everything back to the Studio defaults.", + ); + let profile_rows = gtk4::Grid::new(); + profile_rows.add_css_class("preview-profile-grid"); + profile_rows.set_column_spacing(6); + profile_rows.set_row_spacing(6); + for (index, profile) in PreviewProfile::all().iter().enumerate() { + let button = gtk4::Button::with_label(profile.title()); + button.add_css_class("dashboard-preset-button"); + button.add_css_class("preview-profile-button"); + button.set_hexpand(true); + button.set_tooltip_text(Some(profile.tooltip())); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let scene_dropdown = scene_dropdown.clone(); + let fps_spin = fps_spin.clone(); + let particle_count_spin = particle_count_spin.clone(); + let particle_size_spin = particle_size_spin.clone(); + let gpu_passes_spin = gpu_passes_spin.clone(); + let interaction_spin = interaction_spin.clone(); + let vram_spin = vram_spin.clone(); + let vsync_switch = vsync_switch.clone(); + let pause_switch = pause_switch.clone(); + button.connect_clicked(move |_| { + let studio = profile.studio(); + studio_defaults.replace(studio.clone()); + persist_studio_options(&studio); + control_sync.set(true); + if let Some(index) = StudioScene::all() + .iter() + .position(|scene| *scene == studio.scene) + { + scene_dropdown.set_selected(index as u32); + } + fps_spin.set_value(studio.fps_cap.unwrap_or(0) as f64); + particle_count_spin.set_value(studio.particle_count as f64); + particle_size_spin.set_value(studio.particle_size as f64); + gpu_passes_spin.set_value(studio.gpu_passes as f64); + interaction_spin.set_value(studio.interaction_steps as f64); + vram_spin.set_value(studio.vram_pressure_mb as f64); + vsync_switch.set_active(studio.vsync); + pause_switch.set_active(studio.paused); + control_sync.set(false); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio, + ); + }); + profile_rows.attach(&button, (index % 2) as i32, (index / 2) as i32, 1, 1); + } + let reset_preview_button = gtk4::Button::with_label("Reset Preview Defaults"); + reset_preview_button.add_css_class("dashboard-preset-button"); + let ctx_reset = ctx.clone(); + let start_button_reset = start_button.clone(); + let status_label_reset = status_label.clone(); + let reload_button_reset = reload_button.clone(); + let restart_button_reset = restart_button.clone(); + let stop_button_reset = stop_button.clone(); + let studio_defaults_reset = studio_defaults.clone(); + let control_sync_reset = control_sync.clone(); + let scene_dropdown_reset = scene_dropdown.clone(); + let fps_spin_reset = fps_spin.clone(); + let particle_count_spin_reset = particle_count_spin.clone(); + let particle_size_spin_reset = particle_size_spin.clone(); + let gpu_passes_spin_reset = gpu_passes_spin.clone(); + let interaction_spin_reset = interaction_spin.clone(); + let vram_spin_reset = vram_spin.clone(); + let vsync_switch_reset = vsync_switch.clone(); + let pause_switch_reset = pause_switch.clone(); + let width_spin_reset = width_spin.clone(); + let height_spin_reset = height_spin.clone(); + reset_preview_button.connect_clicked(move |_| { + let studio = PreviewStudioOptions::default(); + let (default_width, default_height) = default_preview_window_size(); + studio_defaults_reset.replace(studio.clone()); + persist_studio_options(&studio); + persist_preview_window_width(default_width); + persist_preview_window_height(default_height); + control_sync_reset.set(true); + if let Some(index) = StudioScene::all() + .iter() + .position(|scene| *scene == studio.scene) + { + scene_dropdown_reset.set_selected(index as u32); + } + fps_spin_reset.set_value(studio.fps_cap.unwrap_or(0) as f64); + particle_count_spin_reset.set_value(studio.particle_count as f64); + particle_size_spin_reset.set_value(studio.particle_size as f64); + gpu_passes_spin_reset.set_value(studio.gpu_passes as f64); + interaction_spin_reset.set_value(studio.interaction_steps as f64); + vram_spin_reset.set_value(studio.vram_pressure_mb as f64); + vsync_switch_reset.set_active(studio.vsync); + pause_switch_reset.set_active(studio.paused); + width_spin_reset.set_value(default_width as f64); + height_spin_reset.set_value(default_height as f64); + control_sync_reset.set(false); + maybe_restart_active_preview( + &ctx_reset, + &start_button_reset, + &status_label_reset, + &reload_button_reset, + &restart_button_reset, + &stop_button_reset, + studio, + ); + }); + setup_body.append(&profile_rows); + setup_body.append(&reset_preview_button); + + panel.append(&session_group); + panel.append(&window_group); + panel.append(&setup_group); + panel.append(&backdrop_group); + panel.append(&gpu_group); + panel.append(&cpu_group); + + refresh_preview_widgets( + ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + ); + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + scene_dropdown.connect_selected_notify(move |dropdown| { + if control_sync.get() { + return; + } + let Some(scene) = StudioScene::all() + .get(dropdown.selected() as usize) + .copied() + else { + return; + }; + let mut studio = studio_defaults.borrow_mut(); + studio.scene = scene; + persist_preview_studio_scene(scene); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + fps_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let value = spin.value().round().clamp(0.0, 1000.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.fps_cap = if value <= 0 { None } else { Some(value as u32) }; + persist_studio_fps_cap(value); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + particle_count_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let count = spin.value().round().clamp(100.0, 500_000.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.particle_count = count as u32; + persist_studio_particle_count(count); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + particle_size_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let size = spin.value().clamp(0.01, 5.0); + let mut studio = studio_defaults.borrow_mut(); + studio.particle_size = size as f32; + persist_studio_particle_size(size); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + gpu_passes_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let passes = spin.value().round().clamp(1.0, 64.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.gpu_passes = passes as u32; + persist_studio_gpu_passes(passes); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + interaction_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let steps = spin.value().round().clamp(0.0, 256.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.interaction_steps = steps as u32; + persist_studio_interaction_steps(steps); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + vram_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let mb = spin.value().round().clamp(0.0, 4096.0) as i32; + let mut studio = studio_defaults.borrow_mut(); + studio.vram_pressure_mb = mb as u32; + persist_studio_vram_pressure(mb); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + vsync_switch.connect_active_notify(move |switch| { + if control_sync.get() { + return; + } + let mut studio = studio_defaults.borrow_mut(); + studio.vsync = switch.is_active(); + persist_preview_vsync(switch.is_active()); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let studio_defaults = studio_defaults.clone(); + let control_sync = control_sync.clone(); + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + pause_switch.connect_active_notify(move |switch| { + if control_sync.get() { + return; + } + let mut studio = studio_defaults.borrow_mut(); + studio.paused = switch.is_active(); + maybe_apply_studio_preview_runtime( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio.clone(), + ); + }); + } + + { + let ctx = ctx.clone(); + let control_sync = control_sync.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + let studio_defaults = studio_defaults.clone(); + width_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let width = spin.value().round().clamp(640.0, 3840.0) as i32; + persist_preview_window_width(width); + maybe_restart_active_preview( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio_defaults.borrow().clone(), + ); + }); + } + + { + let ctx = ctx.clone(); + let control_sync = control_sync.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + let studio_defaults = studio_defaults.clone(); + height_spin.connect_value_changed(move |spin| { + if control_sync.get() { + return; + } + let height = spin.value().round().clamp(360.0, 2160.0) as i32; + persist_preview_window_height(height); + maybe_restart_active_preview( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + studio_defaults.borrow().clone(), + ); + }); + } + + { + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + let studio_defaults = studio_defaults.clone(); + start_button.clone().connect_clicked(move |_| { + let config = current_config_snapshot(&ctx); + let (width, height) = preview_window_settings(PreviewScene::Studio, &config); + let studio = studio_defaults.borrow().clone(); + + match ctx + .preview + .start(PreviewScene::Studio, &config, width, height, false, studio) + { + Ok(pid) => { + show_toast( + &ctx.toast_overlay, + &format!("Started live preview (pid {pid})"), + ); + refresh_preview_widgets( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + ); + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("Preview failed: {err}")), + } + }); + } + + { + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + reload_button.clone().connect_clicked(move |_| { + let config = current_config_snapshot(&ctx); + match ctx.preview.apply_live_config(&config) { + Ok(pid) => { + show_toast( + &ctx.toast_overlay, + &format!("Applied current config to live preview (pid {pid})"), + ); + refresh_preview_widgets( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + ); + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("Apply failed: {err}")), + } + }); + } + + { + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + restart_button.clone().connect_clicked(move |_| { + let config = current_config_snapshot(&ctx); + match ctx.preview.restart(&config) { + Ok(pid) => { + show_toast( + &ctx.toast_overlay, + &format!("Restarted live preview (pid {pid})"), + ); + refresh_preview_widgets( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + ); + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("Restart failed: {err}")), + } + }); + } + + { + let ctx = ctx.clone(); + let start_button = start_button.clone(); + let status_label = status_label.clone(); + let reload_button = reload_button.clone(); + let restart_button = restart_button.clone(); + let stop_button = stop_button.clone(); + stop_button.clone().connect_clicked(move |_| { + match ctx.preview.stop() { + Ok(true) => show_toast(&ctx.toast_overlay, "Stopped live preview"), + Ok(false) => show_toast(&ctx.toast_overlay, "Preview was not running"), + Err(err) => show_toast(&ctx.toast_overlay, &format!("Stop failed: {err}")), + } + refresh_preview_widgets( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + ); + }); + } + + { + let ctx = ctx.clone(); + let start_button = start_button.downgrade(); + let status_label = status_label.downgrade(); + let reload_button = reload_button.downgrade(); + let restart_button = restart_button.downgrade(); + let stop_button = stop_button.downgrade(); + glib::timeout_add_seconds_local(1, move || { + let ( + Some(start_button), + Some(status_label), + Some(reload_button), + Some(restart_button), + Some(stop_button), + ) = ( + start_button.upgrade(), + status_label.upgrade(), + reload_button.upgrade(), + restart_button.upgrade(), + stop_button.upgrade(), + ) + else { + return glib::ControlFlow::Break; + }; + refresh_preview_widgets( + &ctx, + &start_button, + &status_label, + &reload_button, + &restart_button, + &stop_button, + ); + glib::ControlFlow::Continue + }); + } + + panel +} + +fn build_position_card(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.add_css_class("dashboard-position-card"); + card.append(&card_header( + "Layout & Position", + "Click where the overlay should anchor, then nudge offsets until it sits exactly where you want.", + )); + + let monitor = gtk4::Grid::new(); + monitor.add_css_class("position-grid"); + monitor.add_css_class("position-grid-compact"); + monitor.set_row_spacing(8); + monitor.set_column_spacing(8); + monitor.set_halign(gtk4::Align::Center); + + let current = current_string_value(ctx, "position").unwrap_or_else(|| "top-left".to_string()); + let buttons: Rc>> = Rc::new(RefCell::new(Vec::new())); + for (label, position, column, row) in [ + ("↖", "top-left", 0, 0), + ("↑", "top-center", 1, 0), + ("↗", "top-right", 2, 0), + ("←", "middle-left", 0, 1), + ("→", "middle-right", 2, 1), + ("↙", "bottom-left", 0, 2), + ("↓", "bottom-center", 1, 2), + ("↘", "bottom-right", 2, 2), + ] { + let button = gtk4::Button::with_label(label); + button.add_css_class("position-node"); + button.set_tooltip_text(Some(position)); + if current == position { + button.add_css_class("position-node-active"); + } + monitor.attach(&button, column, row, 1, 1); + buttons + .borrow_mut() + .push((position.to_string(), button.clone())); + + let ctx = ctx.clone(); + let buttons = buttons.clone(); + let selected = position.to_string(); + button.connect_clicked(move |_| { + set_config_value(&ctx, "position", ConfigValue::Value(selected.clone())); + update_position_buttons(&buttons.borrow(), &selected); + maybe_reload_preview_for_key(&ctx, "position"); + }); + } + + let center = gtk4::Label::new(Some("HUD")); + center.add_css_class("position-center"); + center.set_halign(gtk4::Align::Center); + center.set_valign(gtk4::Align::Center); + monitor.attach(¢er, 1, 1, 1, 1); + + card.append(&monitor); + card.append(&build_scale_control( + "Horizontal offset", + "offset_x", + 0.0, + 500.0, + 1.0, + 0, + ctx, + OffsetAxis::Horizontal, + )); + card.append(&build_scale_control( + "Vertical offset", + "offset_y", + 0.0, + 500.0, + 1.0, + 0, + ctx, + OffsetAxis::Vertical, + )); + card.append(&build_width_control(ctx)); + card.append(&build_scale_control( + "Table columns", + "table_columns", + 1.0, + 12.0, + 1.0, + 0, + ctx, + OffsetAxis::Raw, + )); + card +} + +fn build_appearance_card(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.append(&card_header( + "Make It Readable", + "Keep the overlay legible at a glance. The sidebar still exposes every detailed MangoHud option when you need more control.", + )); + + card.append(&build_scale_control( + "Font size", + "font_size", + 8.0, + 36.0, + 1.0, + 0, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Font scale", + "font_scale", + 0.5, + 2.0, + 0.05, + 2, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Background opacity", + "background_alpha", + 0.0, + 1.0, + 0.01, + 2, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Overall alpha", + "alpha", + 0.0, + 1.0, + 0.01, + 2, + ctx, + OffsetAxis::Raw, + )); + card.append(&build_scale_control( + "Corner radius", + "round_corners", + 0.0, + 50.0, + 1.0, + 0, + ctx, + OffsetAxis::Raw, + )); + + let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + toggles.append(&build_flag_toggle("Compact", "hud_compact", ctx)); + toggles.append(&build_flag_toggle("No margin", "hud_no_margin", ctx)); + toggles.append(&build_flag_toggle("Horizontal", "horizontal", ctx)); + toggles.append(&build_flag_toggle("Outline", "text_outline", ctx)); + card.append(&toggles); + + let colors_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + colors_row.append(&build_color_control("Text", "text_color", ctx)); + colors_row.append(&build_color_control("Background", "background_color", ctx)); + colors_row.append(&build_color_control("GPU", "gpu_color", ctx)); + colors_row.append(&build_color_control("CPU", "cpu_color", ctx)); + card.append(&colors_row); + card +} + +pub(crate) fn build_profiles_panel(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.append(&card_header( + "Profiles", + "Save the active config target as a real MangoHud profile, apply one back into the current target, or open the profile folder when you need it.", + )); + + let controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + controls.add_css_class("dashboard-profiles-strip"); + let name_entry = gtk4::Entry::new(); + name_entry.set_placeholder_text(Some("competitive, quality, streaming")); + name_entry.set_hexpand(true); + name_entry.set_width_chars(20); + name_entry.add_css_class("control-field"); + + let (names, paths) = profile_choices(); + let name_refs = names.iter().map(String::as_str).collect::>(); + let restore_dropdown = gtk4::DropDown::from_strings(&name_refs); + restore_dropdown.set_hexpand(true); + restore_dropdown.set_halign(gtk4::Align::Fill); + restore_dropdown.set_size_request(230, -1); + install_profile_dropdown_factory(&restore_dropdown); + install_scroll_passthrough(restore_dropdown.upcast_ref()); + restore_dropdown.add_css_class("control-field"); + + let save_button = icon_action_button( + "document-save-symbolic", + "Save the active config target as a profile", + ); + let restore_button = icon_action_button( + "document-revert-symbolic", + "Apply the selected profile into the active config target", + ); + let delete_button = icon_action_button("user-trash-symbolic", "Delete the selected profile"); + let open_button = icon_action_button("folder-open-symbolic", "Open the profile storage folder"); + + restore_button.set_sensitive(!paths.is_empty()); + delete_button.set_sensitive(!paths.is_empty()); + controls.append(&name_entry); + controls.append(&save_button); + controls.append(&restore_dropdown); + controls.append(&restore_button); + controls.append(&delete_button); + controls.append(&open_button); + + let profile_paths = Rc::new(RefCell::new(paths)); + let initial_selected = refresh_profile_picker( + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + last_selected_profile_name().as_deref(), + ); + if let Some(name) = initial_selected { + name_entry.set_text(&name); + } + + { + let name_entry = name_entry.clone(); + let profile_paths = profile_paths.clone(); + restore_dropdown.connect_selected_notify(move |dropdown| { + if profile_paths.borrow().is_empty() { + name_entry.set_text(""); + persist_last_selected_profile_name(None); + return; + } + let selected = dropdown_selected_profile_name(dropdown); + if let Some(name) = selected.as_deref() { + name_entry.set_text(name); + } + persist_last_selected_profile_name(selected.as_deref()); + }); + } + + { + let ctx = ctx.clone(); + let name_entry = name_entry.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let profile_paths = profile_paths.clone(); + save_button.connect_clicked(move |_| { + let profile_name = name_entry.text().to_string(); + if profile_name.trim().is_empty() { + show_toast(&ctx.toast_overlay, "Enter a profile name first"); + return; + } + let exists = match profiles::profile_exists(&profile_name) { + Ok(exists) => exists, + Err(err) => { + show_toast( + &ctx.toast_overlay, + &format!("Profile save failed: {err}"), + ); + return; + } + }; + + if exists { + let ctx_clone = ctx.clone(); + let name_entry = name_entry.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let profile_paths = profile_paths.clone(); + let profile_name = profile_name.clone(); + glib::MainContext::default().spawn_local(async move { + let confirmed = confirm_profile_action( + &ctx_clone.parent_window, + "Overwrite profile?", + &format!( + "A profile named \"{profile_name}\" already exists. Replace it with the current active config target?" + ), + "Overwrite", + true, + ) + .await; + if confirmed { + save_profile_from_dashboard( + &ctx_clone, + &name_entry, + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + &profile_name, + ); + } + }); + } else { + save_profile_from_dashboard( + &ctx, + &name_entry, + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + &profile_name, + ); + } + }); + } + + { + let ctx = ctx.clone(); + let profile_paths = profile_paths.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + restore_button.clone().connect_clicked(move |_| { + let idx = restore_dropdown.selected() as usize; + let Some(path) = profile_paths.borrow().get(idx).cloned() else { + show_toast(&ctx.toast_overlay, "No profile selected"); + return; + }; + + let target_path = current_config_snapshot(&ctx).path; + match profiles::load_profile_from_path(&path, target_path) { + Ok(loaded) => { + if let Ok(mut state) = ctx.state.lock() { + state.config = loaded; + state.dirty = true; + } + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); + let config = current_config_snapshot(&ctx); + if ctx.preview.running_scene().is_some() { + let _ = ctx + .preview + .apply_live_config(&config) + .or_else(|_| ctx.preview.restart(&config)); + } + let _ = gtk4::prelude::WidgetExt::activate_action( + &ctx.parent_window, + "win.refresh-current-page", + None, + ); + show_toast( + &ctx.toast_overlay, + "Applied profile into the active config target", + ); + let selected_name = dropdown_selected_profile_name(&restore_dropdown); + let selected = refresh_profile_picker( + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + selected_name.as_deref(), + ); + if let Some(selected) = selected { + persist_last_selected_profile_name(Some(&selected)); + } + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("Profile apply failed: {err}")), + } + }); + } + + { + let ctx = ctx.clone(); + let profile_paths = profile_paths.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let name_entry = name_entry.clone(); + delete_button.clone().connect_clicked(move |_| { + let idx = restore_dropdown.selected() as usize; + let Some(path) = profile_paths.borrow().get(idx).cloned() else { + show_toast(&ctx.toast_overlay, "No profile selected"); + return; + }; + let profile_name = path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("profile") + .to_string(); + let ctx_clone = ctx.clone(); + let restore_dropdown = restore_dropdown.clone(); + let restore_button = restore_button.clone(); + let delete_button = delete_button.clone(); + let profile_paths = profile_paths.clone(); + let name_entry = name_entry.clone(); + glib::MainContext::default().spawn_local(async move { + let confirmed = confirm_profile_action( + &ctx_clone.parent_window, + "Delete profile?", + &format!("Delete the profile \"{profile_name}\"? This cannot be undone."), + "Delete", + true, + ) + .await; + if !confirmed { + return; + } + + match profiles::delete_profile_path(&path) { + Ok(()) => { + let selected = refresh_profile_picker( + &restore_dropdown, + &restore_button, + &delete_button, + &profile_paths, + None, + ); + if let Some(selected) = selected { + name_entry.set_text(&selected); + } else { + name_entry.set_text(""); + } + show_toast( + &ctx_clone.toast_overlay, + &format!("Deleted profile {profile_name}"), + ); + } + Err(err) => show_toast( + &ctx_clone.toast_overlay, + &format!("Profile delete failed: {err}"), + ), + } + }); + }); + } + + { + let overlay = ctx.toast_overlay.clone(); + open_button.connect_clicked(move |_| { + let has_graphical_session = std::env::var("DISPLAY") + .ok() + .is_some_and(|value| !value.is_empty()) + || std::env::var("WAYLAND_DISPLAY") + .ok() + .is_some_and(|value| !value.is_empty()); + if !has_graphical_session { + show_toast( + &overlay, + "No graphical session is available to open the profiles folder", + ); + return; + } + + match std::process::Command::new("xdg-open") + .arg(profiles::profiles_dir()) + .spawn() + { + Ok(_) => {} + Err(error) => show_toast( + &overlay, + &format!("Failed to open profiles folder: {error}"), + ), + } + }); + } + + card.append(&controls); + card +} + +fn save_profile_from_dashboard( + ctx: &PageBuildContext, + name_entry: >k4::Entry, + restore_dropdown: >k4::DropDown, + restore_button: >k4::Button, + delete_button: >k4::Button, + profile_paths: &Rc>>, + profile_name: &str, +) { + let config = current_config_snapshot(ctx); + match profiles::save_profile(profile_name, &config) { + Ok(path) => { + let selected = refresh_profile_picker( + restore_dropdown, + restore_button, + delete_button, + profile_paths, + Some(profile_name), + ); + if let Some(selected) = selected { + name_entry.set_text(&selected); + } + show_toast( + &ctx.toast_overlay, + &format!("Saved active config target as profile {}", path.display()), + ); + } + Err(err) => show_toast(&ctx.toast_overlay, &format!("Profile save failed: {err}")), + } +} + +async fn confirm_profile_action( + parent: &libadwaita::ApplicationWindow, + heading: &str, + body: &str, + confirm_label: &str, + destructive: bool, +) -> bool { + let dialog = libadwaita::AlertDialog::builder() + .heading(heading) + .body(body) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("confirm", confirm_label); + dialog.set_default_response(Some("confirm")); + dialog.set_close_response("cancel"); + dialog.set_response_appearance( + "confirm", + if destructive { + libadwaita::ResponseAppearance::Destructive + } else { + libadwaita::ResponseAppearance::Suggested + }, + ); + dialog.choose_future(parent).await.as_str() == "confirm" +} + +pub(crate) fn build_presets_panel(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + let grid = gtk4::Grid::new(); + grid.set_row_spacing(8); + grid.set_column_spacing(8); + grid.attach( + &build_preset_button(DashboardPreset::Benchmark, ctx), + 0, + 0, + 1, + 1, + ); + grid.attach( + &build_preset_button(DashboardPreset::Competitive, ctx), + 1, + 0, + 1, + 1, + ); + grid.attach( + &build_preset_button(DashboardPreset::Performance, ctx), + 0, + 1, + 1, + 1, + ); + grid.attach( + &build_preset_button(DashboardPreset::Streaming, ctx), + 1, + 1, + 1, + 1, + ); + + let note = gtk4::Label::new(Some( + "Presets update the current in-memory config and refresh live preview if it is running. If a matching profile is missing, MangoTune falls back to its built-in mapping.", + )); + note.add_css_class("dim-label"); + note.set_wrap(true); + note.set_xalign(0.0); + + card.append(&grid); + card.append(¬e); + card +} + +fn build_metrics_card(ctx: &PageBuildContext) -> gtk4::Box { + let card = dashboard_card(); + card.add_css_class("dashboard-full-width-card"); + card.append(&card_header( + "Show These Metrics", + "Turn on the HUD items most Linux gamers usually care about first, then go deeper in the sidebar when you need exact formatting and thresholds.", + )); + + card.append(&metric_group( + "Frame & pacing", + &[ + ("FPS", "fps"), + ("Frametime", "frametime"), + ("Frame timing", "frame_timing"), + ], + ctx, + )); + card.append(&metric_group( + "GPU", + &[ + ("GPU stats", "gpu_stats"), + ("GPU temp", "gpu_temp"), + ("GPU clock", "gpu_core_clock"), + ("VRAM", "vram"), + ], + ctx, + )); + card.append(&metric_group( + "CPU & memory", + &[ + ("CPU stats", "cpu_stats"), + ("CPU temp", "cpu_temp"), + ("RAM", "ram"), + ], + ctx, + )); + card.append(&metric_group( + "Extra", + &[ + ("Read I/O", "io_read"), + ("Write I/O", "io_write"), + ("Battery", "battery"), + ("Media", "media_player"), + ], + ctx, + )); + + let hint = gtk4::Label::new(Some( + "Use the GPU, CPU, Memory, Battery, and Performance pages for graphs, labels, thresholds, and the rest of MangoHud’s detailed metric formatting.", + )); + hint.add_css_class("dim-label"); + hint.set_xalign(0.0); + hint.set_wrap(true); + card.append(&hint); + card +} + +fn build_workspace_strip(ctx: &PageBuildContext) -> gtk4::Box { + let strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + strip.add_css_class("dashboard-status-strip"); + + let summary = gtk4::Label::new(None); + summary.set_xalign(0.0); + summary.set_wrap(true); + summary.add_css_class("dashboard-status-line"); + summary.add_css_class("dashboard-status-summary"); + strip.append(&summary); + + refresh_workspace_status(ctx, &summary); + + { + let ctx = ctx.clone(); + let summary = summary.downgrade(); + glib::timeout_add_seconds_local(1, move || { + let Some(summary) = summary.upgrade() else { + return glib::ControlFlow::Break; + }; + refresh_workspace_status(&ctx, &summary); + glib::ControlFlow::Continue + }); + } + + strip +} + +fn dashboard_card() -> gtk4::Box { + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + card.add_css_class("dashboard-card"); + card.add_css_class("dashboard-card-compact"); + card.set_hexpand(true); + card.set_margin_top(0); + card.set_margin_bottom(0); + card.set_margin_start(0); + card.set_margin_end(0); + card +} + +fn card_header(title: &str, subtitle: &str) -> gtk4::Box { + let box_ = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + + let title_label = gtk4::Label::new(Some(title)); + title_label.add_css_class("dashboard-card-title"); + title_label.set_xalign(0.0); + + let subtitle_label = gtk4::Label::new(Some(subtitle)); + subtitle_label.add_css_class("dashboard-card-subtitle"); + subtitle_label.set_wrap(true); + subtitle_label.set_xalign(0.0); + + box_.append(&title_label); + box_.append(&subtitle_label); + box_ +} + +#[allow(clippy::too_many_arguments)] +fn build_scale_control( + label: &str, + key: &str, + min: f64, + max: f64, + step: f64, + digits: u32, + ctx: &PageBuildContext, + axis: OffsetAxis, +) -> gtk4::Box { + let row = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + + let title = gtk4::Label::new(Some(label)); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + title.set_hexpand(true); + + let value_label = gtk4::Label::new(None); + value_label.add_css_class("dashboard-value-label"); + + top.append(&title); + top.append(&value_label); + + let scale = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); + scale.set_hexpand(true); + scale.set_draw_value(false); + scale.add_css_class("dashboard-scale"); + install_scroll_passthrough(scale.upcast_ref()); + + let initial = display_offset_value(ctx, key, axis) + .unwrap_or(min) + .clamp(min, max); + scale.set_value(initial); + update_scale_value_label(&value_label, initial, digits); + + let key_owned = key.to_string(); + let ctx = ctx.clone(); + let value_label_clone = value_label.clone(); + scale.connect_value_changed(move |scale| { + let value = scale.value(); + update_scale_value_label(&value_label_clone, value, digits); + let config_value = offset_config_value(&ctx, axis, value, digits); + set_config_value(&ctx, &key_owned, config_value); + maybe_reload_preview_for_key(&ctx, &key_owned); + }); + + row.append(&top); + row.append(&scale); + row +} + +#[derive(Clone, Copy)] +enum OffsetAxis { + Raw, + Horizontal, + Vertical, +} + +fn build_width_control(ctx: &PageBuildContext) -> gtk4::Box { + let row = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + + let title = gtk4::Label::new(Some("HUD width")); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + title.set_hexpand(true); + + let auto_toggle = gtk4::Switch::new(); + let auto_width = current_numeric_value(ctx, "width").unwrap_or(0.0) <= 0.0; + auto_toggle.set_active(auto_width); + + let auto_label = gtk4::Label::new(Some("Auto")); + auto_label.add_css_class("dashboard-field-label"); + top.append(&title); + top.append(&auto_label); + top.append(&auto_toggle); + + let scale_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + let scale = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 1200.0, 10.0); + scale.set_hexpand(true); + scale.set_draw_value(false); + scale.add_css_class("dashboard-scale"); + install_scroll_passthrough(scale.upcast_ref()); + + let value_label = gtk4::Label::new(None); + value_label.add_css_class("dashboard-value-label"); + + let initial_width = current_numeric_value(ctx, "width") + .unwrap_or(300.0) + .clamp(1.0, 1200.0); + scale.set_value(initial_width); + update_scale_value_label(&value_label, initial_width, 0); + scale.set_sensitive(!auto_width); + if auto_width { + value_label.set_text("auto"); + } + + scale_row.append(&scale); + scale_row.append(&value_label); + + { + let ctx = ctx.clone(); + let value_label = value_label.clone(); + scale.connect_value_changed(move |scale| { + let value = scale.value().round().clamp(1.0, 1200.0); + update_scale_value_label(&value_label, value, 0); + set_config_value( + &ctx, + "width", + ConfigValue::Value((value as i64).to_string()), + ); + maybe_reload_preview_for_key(&ctx, "width"); + }); + } + + { + let ctx = ctx.clone(); + let scale = scale.clone(); + let value_label = value_label.clone(); + auto_toggle.connect_active_notify(move |toggle| { + let enabled = toggle.is_active(); + scale.set_sensitive(!enabled); + if enabled { + value_label.set_text("auto"); + set_config_value(&ctx, "width", ConfigValue::Value("0".to_string())); + } else { + let value = scale.value().round().clamp(1.0, 1200.0); + update_scale_value_label(&value_label, value, 0); + set_config_value( + &ctx, + "width", + ConfigValue::Value((value as i64).to_string()), + ); + } + maybe_reload_preview_for_key(&ctx, "width"); + }); + } + + row.append(&top); + row.append(&scale_row); + row +} + +fn build_flag_toggle(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::ToggleButton { + let button = gtk4::ToggleButton::with_label(label); + button.add_css_class("dashboard-toggle"); + button.set_active(is_flag_enabled(ctx, key)); + + let key_owned = key.to_string(); + let ctx = ctx.clone(); + button.connect_toggled(move |button| { + if button.is_active() { + enable_with_dependencies(&ctx, &key_owned); + } else { + disable_with_dependents(&ctx, &key_owned); + } + maybe_reload_preview_for_key(&ctx, &key_owned); + }); + + button +} + +fn build_color_control(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::Box { + let group = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + group.add_css_class("color-control"); + + let title = gtk4::Label::new(Some(label)); + title.add_css_class("dashboard-field-label"); + title.set_xalign(0.0); + + let dialog = gtk4::ColorDialog::builder() + .title(label) + .modal(true) + .build(); + let swatch = gtk4::ColorDialogButton::new(Some(dialog)); + swatch.add_css_class("color-swatch-button"); + swatch.add_css_class("dashboard-color-button"); + + let entry = gtk4::Entry::new(); + entry.set_width_chars(8); + entry.set_max_length(6); + entry.set_placeholder_text(Some("RRGGBB")); + + if let Some(color) = current_string_value(ctx, key) { + entry.set_text(&color); + if let Some(rgba) = hex_to_rgba(&color) { + swatch.set_rgba(&rgba); + } + } + + { + let ctx = ctx.clone(); + let ctx_for_change = ctx.clone(); + let key_owned = key.to_string(); + let swatch_for_entry = swatch.clone(); + let pending_preview_refresh = Rc::new(Cell::new(false)); + let pending_refresh_for_change = pending_preview_refresh.clone(); + let syncing = Rc::new(Cell::new(false)); + let syncing_for_entry = syncing.clone(); + entry.connect_changed(move |entry| { + if syncing_for_entry.get() { + return; + } + let text = entry.text().to_string().to_ascii_uppercase(); + set_config_value( + &ctx_for_change, + &key_owned, + ConfigValue::Value(text.clone()), + ); + if let Some(rgba) = hex_to_rgba(&text) { + syncing_for_entry.set(true); + swatch_for_entry.set_rgba(&rgba); + syncing_for_entry.set(false); + } + pending_refresh_for_change.set(true); + }); + + connect_dashboard_entry_preview_commit(&entry, &ctx, key, pending_preview_refresh); + let ctx = ctx.clone(); + let key_owned = key.to_string(); + let entry = entry.clone(); + let syncing_for_swatch = syncing.clone(); + swatch.connect_rgba_notify(move |button| { + if syncing_for_swatch.get() { + return; + } + let hex = rgba_to_hex(&button.rgba()); + syncing_for_swatch.set(true); + entry.set_text(&hex); + syncing_for_swatch.set(false); + set_config_value(&ctx, &key_owned, ConfigValue::Value(hex)); + maybe_reload_preview_for_key(&ctx, &key_owned); + }); + } + + group.append(&title); + group.append(&swatch); + group.append(&entry); + group +} + +fn connect_dashboard_entry_preview_commit( + entry: >k4::Entry, + ctx: &PageBuildContext, + key: &str, + pending_refresh: Rc>, +) { + let ctx_clone = ctx.clone(); + entry.connect_activate(move |_| { + gtk4::prelude::GtkWindowExt::set_focus( + &ctx_clone.parent_window, + Option::<>k4::Widget>::None, + ); + }); + + let focus = gtk4::EventControllerFocus::new(); + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let pending_refresh_clone = pending_refresh.clone(); + focus.connect_leave(move |_| { + if pending_refresh_clone.replace(false) { + maybe_reload_preview_for_key(&ctx_clone, &key_owned); + } + }); + entry.add_controller(focus); +} + +fn build_preset_button(preset: DashboardPreset, ctx: &PageBuildContext) -> gtk4::Button { + let button = gtk4::Button::new(); + button.add_css_class("dashboard-preset-button"); + button.set_hexpand(true); + button.set_halign(gtk4::Align::Fill); + + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + content.set_halign(gtk4::Align::Start); + + let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let title = gtk4::Label::new(Some(preset.title())); + title.add_css_class("dashboard-card-title"); + title.set_xalign(0.0); + title.set_hexpand(true); + + let badge = gtk4::Label::new(Some(preset.badge())); + badge.add_css_class("dashboard-preset-badge"); + + top.append(&title); + top.append(&badge); + + let description = gtk4::Label::new(Some(preset.description())); + description.add_css_class("dashboard-card-subtitle"); + description.set_wrap(true); + description.set_xalign(0.0); + + content.append(&top); + content.append(&description); + button.set_child(Some(&content)); + + let ctx = ctx.clone(); + button.connect_clicked(move |_| { + apply_dashboard_preset(&ctx, preset); + }); + + button +} + +fn icon_action_button(icon_name: &str, tooltip: &str) -> gtk4::Button { + let button = gtk4::Button::new(); + button.add_css_class("shell-strip-button"); + button.add_css_class("profile-action-button"); + button.set_tooltip_text(Some(tooltip)); + let icon = gtk4::Image::from_icon_name(icon_name); + icon.set_pixel_size(16); + button.set_child(Some(&icon)); + button +} + +fn metric_group(title: &str, items: &[(&str, &str)], ctx: &PageBuildContext) -> gtk4::Box { + let group = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + group.add_css_class("metric-group"); + group.set_valign(gtk4::Align::Start); + + let heading = gtk4::Label::new(Some(title)); + heading.add_css_class("dashboard-field-label"); + heading.add_css_class("metric-group-title"); + heading.set_xalign(0.0); + heading.set_yalign(0.2); + heading.set_width_chars(12); + heading.set_halign(gtk4::Align::Start); + heading.set_valign(gtk4::Align::Start); + group.append(&heading); + + let wrap = gtk4::FlowBox::new(); + wrap.set_selection_mode(gtk4::SelectionMode::None); + wrap.set_max_children_per_line(4); + wrap.set_row_spacing(4); + wrap.set_column_spacing(6); + wrap.add_css_class("metric-group-flow"); + wrap.set_hexpand(true); + + for (label, key) in items { + let chip = build_metric_toggle(label, key, ctx); + wrap.insert(&chip, -1); + } + + group.append(&wrap); + group +} + +fn build_metric_toggle(label: &str, key: &str, ctx: &PageBuildContext) -> gtk4::ToggleButton { + let button = gtk4::ToggleButton::with_label(label); + button.add_css_class("dashboard-toggle"); + button.add_css_class("metric-toggle"); + button.set_active(is_flag_enabled(ctx, key)); + + let ctx = ctx.clone(); + let key_owned = key.to_string(); + button.connect_toggled(move |button| { + if button.is_active() { + enable_with_dependencies(&ctx, &key_owned); + } else { + disable_with_dependents(&ctx, &key_owned); + } + maybe_reload_preview_for_key(&ctx, &key_owned); + }); + + button +} + +fn refresh_preview_widgets( + ctx: &PageBuildContext, + start_button: >k4::Button, + status_label: >k4::Label, + reload_button: >k4::Button, + restart_button: >k4::Button, + stop_button: >k4::Button, +) { + let snapshot = ctx.preview.snapshot(); + status_label.set_text(&snapshot.status); + status_label.remove_css_class("preview-status-live"); + status_label.remove_css_class("preview-status-idle"); + if snapshot.running { + status_label.add_css_class("preview-status-live"); + } else { + status_label.add_css_class("preview-status-idle"); + } + + start_button.set_sensitive(!snapshot.running); + reload_button.set_sensitive(snapshot.running); + restart_button.set_sensitive(snapshot.can_restart); + stop_button.set_sensitive(snapshot.running); +} + +fn refresh_workspace_status(ctx: &PageBuildContext, summary: >k4::Label) { + let Ok(state) = ctx.state.lock() else { + summary.set_text("State unavailable"); + return; + }; + + let (errors, warnings) = validation_counts(&state.validation); + let validation_text = match (errors, warnings) { + (0, 0) => "Validation clear".to_string(), + (0, warnings) => format!("{warnings} warning(s)"), + (errors, warnings) => format!("{errors} error(s), {warnings} warning(s)"), + }; + let dirty_text = if state.dirty { + "Unsaved changes" + } else { + "Saved" + }; + let config_target = state + .config + .path + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("(unsaved config)"); + + summary.set_text(&format!( + "{dirty_text} • {validation_text} • Target: {config_target} • MangoHud {} • {:?} • GPU {:?}", + ctx.system_info + .mangohud + .version + .clone() + .unwrap_or_else(|| "unknown".to_string()), + ctx.system_info.display_server, + ctx.system_info.gpu.vendor + )); +} + +fn validation_counts(validation: &HashMap) -> (usize, usize) { + let mut errors = 0; + let mut warnings = 0; + for result in validation.values() { + match result { + ValidationResult::Error(_) => errors += 1, + ValidationResult::Warning(_) => warnings += 1, + ValidationResult::Ok => {} + } + } + (errors, warnings) +} + +fn preferred_studio_options() -> PreviewStudioOptions { + let settings = app_settings(); + let scene = settings + .as_ref() + .and_then(|settings| { + mangotune::preview::StudioScene::from_label(&settings.string("preview-studio-scene")) + }) + .unwrap_or(mangotune::preview::StudioScene::DarkArena); + let fps_cap = settings + .as_ref() + .map(|settings| settings.int("preview-fps-cap")) + .unwrap_or(120) + .clamp(0, 1000); + let vsync = settings + .as_ref() + .map(|settings| settings.boolean("preview-vsync")) + .unwrap_or(false); + let vram_pressure_mb = settings + .as_ref() + .map(|settings| settings.int("preview-vram-pressure")) + .unwrap_or(0) + .clamp(0, 65_536); + let particle_count = settings + .as_ref() + .map(|settings| settings.int("preview-particle-count")) + .unwrap_or(1_000) + .clamp(100, 500_000); + let particle_size = settings + .as_ref() + .map(|settings| settings.double("preview-particle-size")) + .unwrap_or(0.03) + .clamp(0.01, 5.0); + let gpu_passes = settings + .as_ref() + .map(|settings| settings.int("preview-gpu-passes")) + .unwrap_or(1) + .clamp(1, 64); + let interaction_steps = settings + .as_ref() + .map(|settings| settings.int("preview-interaction-steps")) + .unwrap_or(0) + .clamp(0, 256); + PreviewStudioOptions { + scene, + fps_cap: if fps_cap <= 0 { + None + } else { + Some(fps_cap as u32) + }, + vsync, + vram_pressure_mb: vram_pressure_mb as u32, + particle_count: particle_count as u32, + particle_size: particle_size as f32, + gpu_passes: gpu_passes as u32, + interaction_steps: interaction_steps as u32, + paused: false, + } +} + +fn preferred_preview_window_size() -> (i32, i32) { + let settings = app_settings(); + let width = settings + .as_ref() + .map(|settings| settings.int("test-window-width")) + .unwrap_or(1280) + .clamp(640, 3840); + let height = settings + .as_ref() + .map(|settings| settings.int("test-window-height")) + .unwrap_or(720) + .clamp(360, 2160); + (width, height) +} + +fn persist_preview_window_width(width: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("test-window-width", width.clamp(640, 3840)); + } +} + +fn persist_preview_window_height(height: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("test-window-height", height.clamp(360, 2160)); + } +} + +fn persist_preview_studio_scene(scene: StudioScene) { + if let Some(settings) = app_settings() { + let _ = settings.set_string("preview-studio-scene", scene.label()); + } +} + +fn persist_studio_fps_cap(fps_cap: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-fps-cap", fps_cap.clamp(0, 1000)); + } +} + +fn persist_studio_vram_pressure(mb: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-vram-pressure", mb.clamp(0, 65_536)); + } +} + +fn persist_studio_particle_count(count: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-particle-count", count.clamp(100, 500_000)); + } +} + +fn persist_studio_particle_size(size: f64) { + if let Some(settings) = app_settings() { + let _ = settings.set_double("preview-particle-size", size.clamp(0.01, 5.0)); + } +} + +fn persist_studio_gpu_passes(passes: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-gpu-passes", passes.clamp(1, 64)); + } +} + +fn persist_studio_interaction_steps(steps: i32) { + if let Some(settings) = app_settings() { + let _ = settings.set_int("preview-interaction-steps", steps.clamp(0, 256)); + } +} + +fn persist_preview_vsync(enabled: bool) { + if let Some(settings) = app_settings() { + let _ = settings.set_boolean("preview-vsync", enabled); + } +} + +fn preview_window_settings(_scene: PreviewScene, config: &AnnotatedConfig) -> (i32, i32) { + let (mut width, height) = preferred_preview_window_size(); + + if let Some(hud_width) = effective_preview_hud_width(config, width) { + width = width + .max(hud_width + extra_preview_margin(config)) + .clamp(720, 3840); + } + + (width, height) +} + +fn extra_preview_margin(config: &AnnotatedConfig) -> i32 { + if is_right_aligned_position(config) { + if is_horizontal_layout(config) { + 980 + } else { + 480 + } + } else { + 220 + } +} + +fn is_horizontal_layout(config: &AnnotatedConfig) -> bool { + match config.options.get("horizontal").map(|(_, value)| value) { + Some(ConfigValue::Flag) => true, + Some(ConfigValue::Value(value)) => { + let normalized = value.trim().to_ascii_lowercase(); + !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") + } + _ => false, + } +} + +fn is_right_aligned_position(config: &AnnotatedConfig) -> bool { + matches!( + current_config_position(config).as_deref(), + Some("top-right" | "middle-right" | "bottom-right") + ) +} + +fn current_config_position(config: &AnnotatedConfig) -> Option { + config + .options + .get("position") + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.clone()), + _ => None, + }) +} + +fn set_config_value(ctx: &PageBuildContext, key: &str, value: ConfigValue) { + let Ok(mut state) = ctx.state.lock() else { + return; + }; + Parser::set_value(&mut state.config, key, value); + state.dirty = state.config.dirty; + drop(state); + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); +} + +fn maybe_reload_preview_for_key(ctx: &PageBuildContext, key: &str) { + refresh_live_preview_for_key(ctx, Some(key)); +} + +fn apply_dashboard_preset(ctx: &PageBuildContext, preset: DashboardPreset) { + let applied_from_profile = apply_dashboard_preset_profile(ctx, preset); + if !applied_from_profile { + let updates = preset_updates(preset); + apply_config_updates(ctx, &updates); + } + if ctx.preview.running_scene().is_some() { + let config = current_config_snapshot(ctx); + let _ = ctx + .preview + .apply_live_config(&config) + .or_else(|_| ctx.preview.restart(&config)); + } + let _ = gtk4::prelude::WidgetExt::activate_action( + &ctx.parent_window, + "win.refresh-current-page", + None, + ); + show_toast( + &ctx.toast_overlay, + &format!( + "Applied {} preset{}", + preset.title(), + if applied_from_profile { + "" + } else { + " (fallback mapping)" + } + ), + ); +} + +fn apply_dashboard_preset_profile(ctx: &PageBuildContext, preset: DashboardPreset) -> bool { + let target_path = current_config_snapshot(ctx).path; + let Ok(loaded) = profiles::load_profile(preset.profile_name(), target_path) else { + return false; + }; + + let Ok(mut state) = ctx.state.lock() else { + return false; + }; + state.config = loaded; + state.dirty = true; + drop(state); + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); + true +} + +fn apply_config_updates(ctx: &PageBuildContext, updates: &[(&str, ConfigValue)]) { + let Ok(mut state) = ctx.state.lock() else { + return; + }; + for (key, value) in updates { + Parser::set_value(&mut state.config, key, value.clone()); + } + state.dirty = state.config.dirty; + drop(state); + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); +} + +fn preset_updates(preset: DashboardPreset) -> Vec<(&'static str, ConfigValue)> { + let mut updates = base_preset_updates(); + match preset { + DashboardPreset::Benchmark => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Flag), + ("frame_timing", ConfigValue::Flag), + ("gpu_stats", ConfigValue::Flag), + ("gpu_temp", ConfigValue::Flag), + ("gpu_core_clock", ConfigValue::Flag), + ("gpu_mem_clock", ConfigValue::Flag), + ("gpu_power", ConfigValue::Flag), + ("cpu_stats", ConfigValue::Flag), + ("ram", ConfigValue::Flag), + ("vram", ConfigValue::Flag), + ("position", ConfigValue::Value("top-left".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("hud_compact", ConfigValue::Flag), + ("font_size", ConfigValue::Value("22".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.75".to_string())), + ("alpha", ConfigValue::Value("0.95".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("fps_sampling_period", ConfigValue::Value("500".to_string())), + ("gamemode", ConfigValue::Flag), + ("vkbasalt", ConfigValue::Flag), + ("display_server", ConfigValue::Flag), + ("dx_api", ConfigValue::Flag), + ("resolution", ConfigValue::Flag), + ("version", ConfigValue::Flag), + ("fsr", ConfigValue::Flag), + ("hdr", ConfigValue::Flag), + ]); + } + DashboardPreset::Competitive => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Flag), + ("frame_timing", ConfigValue::Disabled), + ("gpu_stats", ConfigValue::Disabled), + ("gpu_temp", ConfigValue::Disabled), + ("cpu_stats", ConfigValue::Disabled), + ("cpu_temp", ConfigValue::Disabled), + ("ram", ConfigValue::Disabled), + ("vram", ConfigValue::Disabled), + ("position", ConfigValue::Value("top-center".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("horizontal", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Flag), + ("font_size", ConfigValue::Value("26".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.00".to_string())), + ("alpha", ConfigValue::Value("1.00".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("fps_color_change", ConfigValue::Flag), + ("fps_value", ConfigValue::Value("60,90".to_string())), + ( + "fps_color", + ConfigValue::Value("ff3333,ffaa00,00ff6a".to_string()), + ), + ("width", ConfigValue::Value("0".to_string())), + ]); + } + DashboardPreset::Performance => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Flag), + ("frame_timing", ConfigValue::Flag), + ("gpu_stats", ConfigValue::Flag), + ("gpu_temp", ConfigValue::Flag), + ("cpu_stats", ConfigValue::Flag), + ("cpu_temp", ConfigValue::Flag), + ("ram", ConfigValue::Flag), + ("vram", ConfigValue::Flag), + ("position", ConfigValue::Value("top-center".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("horizontal", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Flag), + ("font_size", ConfigValue::Value("20".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.60".to_string())), + ("alpha", ConfigValue::Value("0.95".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("width", ConfigValue::Value("0".to_string())), + ]); + } + DashboardPreset::Streaming => { + updates.extend([ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Disabled), + ("frame_timing", ConfigValue::Disabled), + ("gpu_stats", ConfigValue::Flag), + ("gpu_temp", ConfigValue::Disabled), + ("cpu_stats", ConfigValue::Flag), + ("cpu_temp", ConfigValue::Disabled), + ("ram", ConfigValue::Flag), + ("vram", ConfigValue::Disabled), + ("position", ConfigValue::Value("top-center".to_string())), + ("table_columns", ConfigValue::Value("6".to_string())), + ("horizontal", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Flag), + ("font_size", ConfigValue::Value("22".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("background_alpha", ConfigValue::Value("0.50".to_string())), + ("alpha", ConfigValue::Value("0.90".to_string())), + ("round_corners", ConfigValue::Value("10".to_string())), + ("text_outline", ConfigValue::Flag), + ( + "text_outline_color", + ConfigValue::Value("000000".to_string()), + ), + ( + "text_outline_thickness", + ConfigValue::Value("2".to_string()), + ), + ("width", ConfigValue::Value("0".to_string())), + ]); + } + } + updates +} + +fn base_preset_updates() -> Vec<(&'static str, ConfigValue)> { + let mut updates = MANGOHUD_SCHEMA + .iter() + .filter(|entry| is_display_category(&entry.category)) + .map(|entry| (entry.key, ConfigValue::Disabled)) + .collect::>(); + updates.extend([ + ("hud_compact", ConfigValue::Flag), + ("hud_no_margin", ConfigValue::Disabled), + ("horizontal", ConfigValue::Disabled), + ("horizontal_stretch", ConfigValue::Disabled), + ("text_outline", ConfigValue::Disabled), + ("position", ConfigValue::Value("top-left".to_string())), + ("offset_x", ConfigValue::Value("0".to_string())), + ("offset_y", ConfigValue::Value("0".to_string())), + ("round_corners", ConfigValue::Value("12".to_string())), + ("background_alpha", ConfigValue::Value("0.28".to_string())), + ("alpha", ConfigValue::Value("1.00".to_string())), + ("font_size", ConfigValue::Value("24".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("width", ConfigValue::Value("300".to_string())), + ]); + updates +} + +fn is_display_category(category: &Category) -> bool { + matches!( + category, + Category::DisplayFps + | Category::DisplayGpu + | Category::DisplayCpu + | Category::DisplayMemory + | Category::DisplayIoNetwork + | Category::DisplayMisc + | Category::DisplayGraphs + | Category::DisplayBattery + | Category::DisplayMediaPlayer + | Category::DisplayGamescope + | Category::DisplaySteamDeck + | Category::DisplayTimeText + ) +} + +#[allow(clippy::too_many_arguments)] +fn maybe_apply_studio_preview_runtime( + ctx: &PageBuildContext, + start_button: >k4::Button, + status_label: >k4::Label, + reload_button: >k4::Button, + restart_button: >k4::Button, + stop_button: >k4::Button, + studio: PreviewStudioOptions, +) { + if ctx.preview.running_scene() != Some(PreviewScene::Studio) { + return; + } + + match ctx.preview.update_studio_runtime(studio) { + Ok(()) => { + refresh_preview_widgets( + ctx, + start_button, + status_label, + reload_button, + restart_button, + stop_button, + ); + } + Err(err) => { + show_toast(&ctx.toast_overlay, &format!("Studio update failed: {err}")); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn maybe_restart_active_preview( + ctx: &PageBuildContext, + start_button: >k4::Button, + status_label: >k4::Label, + reload_button: >k4::Button, + restart_button: >k4::Button, + stop_button: >k4::Button, + studio: PreviewStudioOptions, +) { + let Some(scene) = ctx.preview.running_scene() else { + return; + }; + + let config = current_config_snapshot(ctx); + let (width, height) = preview_window_settings(scene, &config); + if let Ok(pid) = ctx + .preview + .start(scene, &config, width, height, false, studio) + { + show_toast( + &ctx.toast_overlay, + &format!("Updated {} preview (pid {pid})", scene.label()), + ); + refresh_preview_widgets( + ctx, + start_button, + status_label, + reload_button, + restart_button, + stop_button, + ); + } +} + +fn enable_with_dependencies(ctx: &PageBuildContext, key: &str) { + if let Some(schema) = get_schema_entry(key) { + for dependency in schema.dependencies { + enable_with_dependencies(ctx, dependency); + } + } + set_config_value(ctx, key, ConfigValue::Flag); +} + +fn disable_with_dependents(ctx: &PageBuildContext, key: &str) { + disable_with_dependents_inner(ctx, key, &mut Vec::new()); +} + +fn disable_with_dependents_inner(ctx: &PageBuildContext, key: &str, seen: &mut Vec) { + if seen.iter().any(|entry| entry == key) { + return; + } + seen.push(key.to_string()); + + for schema in MANGOHUD_SCHEMA.iter() { + if schema.dependencies.contains(&key) { + disable_with_dependents_inner(ctx, schema.key, seen); + } + } + + set_config_value(ctx, key, ConfigValue::Disabled); +} + +fn current_numeric_value(ctx: &PageBuildContext, key: &str) -> Option { + current_string_value(ctx, key)?.parse::().ok() +} + +fn display_offset_value(ctx: &PageBuildContext, key: &str, _axis: OffsetAxis) -> Option { + let raw = current_numeric_value(ctx, key)?; + Some(raw.abs().max(0.0)) +} + +fn offset_config_value( + _ctx: &PageBuildContext, + _axis: OffsetAxis, + value: f64, + digits: u32, +) -> ConfigValue { + let stored = value.abs(); + if digits == 0 { + ConfigValue::Value((stored.round() as i64).to_string()) + } else { + ConfigValue::Value(format!("{stored:.precision$}", precision = digits as usize)) + } +} + +fn install_scroll_passthrough(widget: >k4::Widget) { + let controller = gtk4::EventControllerScroll::new( + gtk4::EventControllerScrollFlags::VERTICAL | gtk4::EventControllerScrollFlags::DISCRETE, + ); + controller.connect_scroll(move |controller, _dx, dy| { + let Some(widget) = controller.widget() else { + return glib::Propagation::Proceed; + }; + let Some(ancestor) = widget.ancestor(gtk4::ScrolledWindow::static_type()) else { + return glib::Propagation::Proceed; + }; + let Ok(scrolled) = ancestor.downcast::() else { + return glib::Propagation::Proceed; + }; + let adjustment = scrolled.vadjustment(); + let page = adjustment.page_size().max(120.0); + let next = (adjustment.value() + dy * (page * 0.22)).clamp( + adjustment.lower(), + (adjustment.upper() - adjustment.page_size()).max(0.0), + ); + adjustment.set_value(next); + glib::Propagation::Stop + }); + widget.add_controller(controller); +} + +fn current_string_value(ctx: &PageBuildContext, key: &str) -> Option { + let Ok(state) = ctx.state.lock() else { + return None; + }; + state + .config + .options + .get(key) + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.clone()), + ConfigValue::Flag => Some("1".to_string()), + ConfigValue::Absent | ConfigValue::Disabled => None, + }) +} + +fn is_flag_enabled(ctx: &PageBuildContext, key: &str) -> bool { + let Ok(state) = ctx.state.lock() else { + return false; + }; + state + .config + .options + .get(key) + .map(|(_, value)| match value { + ConfigValue::Flag => true, + ConfigValue::Value(raw) => { + let normalized = raw.trim().to_ascii_lowercase(); + !matches!(normalized.as_str(), "" | "0" | "false" | "no" | "off") + } + ConfigValue::Disabled | ConfigValue::Absent => false, + }) + .unwrap_or_else(|| flag_defaults_to_enabled(key)) +} + +fn update_scale_value_label(label: >k4::Label, value: f64, digits: u32) { + if digits == 0 { + label.set_text(&(value.round() as i64).to_string()); + } else { + label.set_text(&format!("{value:.precision$}", precision = digits as usize)); + } +} + +fn update_position_buttons(buttons: &[(String, gtk4::Button)], selected: &str) { + for (position, button) in buttons { + if position == selected { + button.add_css_class("position-node-active"); + } else { + button.remove_css_class("position-node-active"); + } + } +} + +fn profile_choices() -> (Vec, Vec) { + let Ok(profiles) = profiles::list_profiles() else { + return (vec!["No profiles found".to_string()], Vec::new()); + }; + + if profiles.is_empty() { + return (vec!["No profiles found".to_string()], Vec::new()); + } + + let names = profiles + .iter() + .map(|profile| profile.name.clone()) + .collect(); + let paths = profiles + .iter() + .map(|profile| profile.path.clone()) + .collect(); + (names, paths) +} + +fn refresh_profile_picker( + dropdown: >k4::DropDown, + restore_button: >k4::Button, + delete_button: >k4::Button, + profile_paths: &Rc>>, + preferred_name: Option<&str>, +) -> Option { + let (names, paths) = profile_choices(); + *profile_paths.borrow_mut() = paths; + let refs = names.iter().map(String::as_str).collect::>(); + let model = gtk4::StringList::new(&refs); + dropdown.set_model(Some(&model)); + let has_profiles = !profile_paths.borrow().is_empty(); + restore_button.set_sensitive(has_profiles); + delete_button.set_sensitive(has_profiles); + if !has_profiles { + dropdown.set_selected(0); + persist_last_selected_profile_name(None); + return None; + } + + let selected_index = preferred_name + .and_then(|preferred| names.iter().position(|name| name == preferred)) + .unwrap_or(0); + dropdown.set_selected(selected_index as u32); + let selected = names.get(selected_index).cloned(); + persist_last_selected_profile_name(selected.as_deref()); + selected +} + +fn last_selected_profile_name() -> Option { + app_settings() + .map(|settings| settings.string("last-profile-name").to_string()) + .filter(|value| !value.trim().is_empty()) +} + +fn persist_last_selected_profile_name(name: Option<&str>) { + if let Some(settings) = app_settings() { + let _ = settings.set_string("last-profile-name", name.unwrap_or("").trim()); + } +} + +fn dropdown_selected_profile_name(dropdown: >k4::DropDown) -> Option { + dropdown + .selected_item() + .and_then(|item| item.downcast::().ok()) + .map(|item| item.string().to_string()) + .filter(|value| value != "No profiles found") +} + +fn install_profile_dropdown_factory(dropdown: >k4::DropDown) { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let Some(list_item) = item.downcast_ref::() else { + return; + }; + let label = gtk4::Label::new(None); + label.set_xalign(0.0); + label.set_hexpand(true); + label.set_ellipsize(EllipsizeMode::End); + label.set_max_width_chars(28); + list_item.set_child(Some(&label)); + }); + factory.connect_bind(|_, item| { + let Some(list_item) = item.downcast_ref::() else { + return; + }; + let Some(label) = list_item + .child() + .and_then(|child| child.downcast::().ok()) + else { + return; + }; + let text = list_item + .item() + .and_then(|obj| obj.downcast::().ok()) + .map(|obj| obj.string().to_string()) + .unwrap_or_default(); + label.set_label(&text); + label.set_tooltip_text(Some(&text)); + }); + + dropdown.set_factory(Some(&factory)); + dropdown.set_list_factory(Some(&factory)); +} + +fn active_config_name(ctx: &PageBuildContext) -> String { + let Ok(state) = ctx.state.lock() else { + return "current config".to_string(); + }; + + state + .config + .path + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .map(ToString::to_string) + .unwrap_or_else(|| "current config".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + + fn preview_sizing_config(position: &str, width: &str) -> AnnotatedConfig { + let mut options = indexmap::IndexMap::new(); + options.insert( + "position".to_string(), + (0, ConfigValue::Value(position.to_string())), + ); + options.insert( + "width".to_string(), + (1, ConfigValue::Value(width.to_string())), + ); + AnnotatedConfig { + lines: Vec::new(), + options, + path: Some(PathBuf::from("/tmp/MangoHud.conf")), + dirty: false, + } + } + + fn preset_map(preset: DashboardPreset) -> HashMap<&'static str, ConfigValue> { + preset_updates(preset).into_iter().collect() + } + + #[test] + fn benchmark_preset_is_built_for_stress_visibility() { + let preset = preset_map(DashboardPreset::Benchmark); + assert_eq!(preset.get("frame_timing"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("gpu_power"), Some(&ConfigValue::Flag)); + assert_eq!( + preset.get("position"), + Some(&ConfigValue::Value("top-left".to_string())) + ); + } + + #[test] + fn performance_preset_enables_common_metrics() { + let preset = preset_map(DashboardPreset::Performance); + assert_eq!(preset.get("gpu_stats"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("cpu_stats"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("ram"), Some(&ConfigValue::Flag)); + assert_eq!(preset.get("vram"), Some(&ConfigValue::Flag)); + } + + #[test] + fn competitive_preset_prefers_auto_width_for_side_anchors() { + let preset = preset_map(DashboardPreset::Competitive); + assert_eq!(preset.get("horizontal"), Some(&ConfigValue::Flag)); + assert_eq!( + preset.get("width"), + Some(&ConfigValue::Value("0".to_string())) + ); + } + + #[test] + fn streaming_preset_stays_clean_for_capture() { + let preset = preset_map(DashboardPreset::Streaming); + assert_eq!(preset.get("frametime"), Some(&ConfigValue::Disabled)); + assert_eq!(preset.get("text_outline"), Some(&ConfigValue::Flag)); + assert_eq!( + preset.get("position"), + Some(&ConfigValue::Value("top-center".to_string())) + ); + } + + #[test] + fn validation_counts_splits_errors_and_warnings() { + let validation = HashMap::from([ + ("a".to_string(), ValidationResult::Ok), + ( + "b".to_string(), + ValidationResult::Warning("warn".to_string()), + ), + ("c".to_string(), ValidationResult::Error("err".to_string())), + ]); + assert_eq!(validation_counts(&validation), (1, 1)); + } + + #[test] + fn right_aligned_hud_gets_extra_preview_margin() { + let left = preview_sizing_config("top-left", "1400"); + let right = preview_sizing_config("top-right", "1400"); + + let (left_width, _) = preview_window_settings(PreviewScene::Studio, &left); + let (right_width, _) = preview_window_settings(PreviewScene::Studio, &right); + + assert_eq!(right_width - left_width, 260); + assert!(right_width > left_width); + } + + #[test] + fn right_aligned_horizontal_layout_gets_much_larger_margin() { + let mut right = preview_sizing_config("top-right", "1400"); + right + .options + .insert("horizontal".to_string(), (2, ConfigValue::Flag)); + + let (right_width, _) = preview_window_settings(PreviewScene::Studio, &right); + assert_eq!(right_width, 2380); + } +} diff --git a/src/ui/pages/performance.rs b/src/ui/pages/performance.rs new file mode 100644 index 0000000..c69e777 --- /dev/null +++ b/src/ui/pages/performance.rs @@ -0,0 +1,48 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::tool_page; +use mangotune::config::types::Category; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Performance", + "Display", + "Control frame pacing, sync behavior, and how aggressively MangoHud surfaces live FPS and frametime information.", + &["pacing", "frametime", "latency feel"], + ); + + tool_page::append_schema_category_section( + &body, + ctx, + "Renderer controls", + "Filtering, sync, and frame pacing knobs that can materially change how your game feels.", + Some("Performance"), + Category::Performance, + ); + tool_page::append_schema_category_section_filtered( + &body, + ctx, + "FPS and frametime readouts", + "Decide whether the overlay should stay minimal or surface more granular pacing data while you tune. Visual styling lives in Colors and Theme.", + Some("Overlay signal"), + Category::DisplayFps, + |entry| entry.key != "fps_color", + ); + tool_page::append_schema_category_section( + &body, + ctx, + "Runtime and renderer details", + "Show API, refresh, display-server, and other session context when you need to verify how a game is actually running.", + Some("Advanced"), + Category::DisplayMisc, + ); + tool_page::append_schema_category_section( + &body, + ctx, + "Gamescope and upscaling", + "Use these only when you are tuning Gamescope, FSR, HDR, or related presentation behavior. Leave them alone for a normal game-side HUD setup.", + Some("Optional"), + Category::DisplayGamescope, + ); + + page +} diff --git a/src/ui/pages/presets_page.rs b/src/ui/pages/presets_page.rs new file mode 100644 index 0000000..ad0da81 --- /dev/null +++ b/src/ui/pages/presets_page.rs @@ -0,0 +1,15 @@ +use crate::ui::pages::{overview, PageBuildContext}; +use crate::ui::widgets::tool_page; +use gtk4::prelude::*; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_start_page( + "Presets", + "Start", + "Load one of your practical starting shapes, then fine-tune from the dashboard and deeper pages.", + &["starter shapes", "quick launch", "overlay setup"], + ); + + body.append(&overview::build_presets_panel(ctx)); + page +} diff --git a/src/ui/pages/raw_editor.rs b/src/ui/pages/raw_editor.rs new file mode 100644 index 0000000..ced36b6 --- /dev/null +++ b/src/ui/pages/raw_editor.rs @@ -0,0 +1,247 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::toast::show_toast; +use crate::ui::widgets::tool_page; +use crate::window::{recompute_validation, refresh_save_button}; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::parser::Parser; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let (page, body) = tool_page::build_tool_page( + "Raw Editor", + "Advanced", + "Edit the full MangoHud config as plain text when you need absolute control, while still keeping MangoTune validation and save protection in the loop.", + &["full config", "advanced", "validation-aware"], + ); + + tool_page::append_callout( + &body, + "Power tool", + "Raw edits bypass the guided controls", + "Use this when you know exactly what you want to change or when MangoHud adds an option before MangoTune has a polished control for it. Validation still runs before save.", + Some("tool-callout-warning"), + ); + + let session_group = tool_page::append_custom_section( + &body, + "Editor session", + "Open the dedicated raw text window, apply edits back into the in-memory config, then save normally once validation is clean.", + Some("Manual mode"), + ); + + let source_row = libadwaita::ActionRow::builder() + .title("Current config source") + .subtitle(current_config_path(ctx)) + .build(); + source_row.add_css_class("control-row"); + session_group.add(&source_row); + + let stats_row = libadwaita::ActionRow::builder() + .title("Current config snapshot") + .subtitle(current_stats_label(ctx)) + .build(); + stats_row.add_css_class("control-row"); + session_group.add(&stats_row); + + let launch_row = libadwaita::ActionRow::builder() + .title("Open raw text editor") + .subtitle("Launch a dedicated editor window with apply and reload actions") + .build(); + launch_row.add_css_class("control-row"); + + let button_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let open_button = gtk4::Button::with_label("Open Editor"); + open_button.add_css_class("suggested-action"); + let reload_button = gtk4::Button::with_label("Refresh Snapshot"); + button_box.append(&reload_button); + button_box.append(&open_button); + + let ctx_reload = ctx.clone(); + let source_row_reload = source_row.clone(); + let stats_row_reload = stats_row.clone(); + reload_button.connect_clicked(move |_| { + source_row_reload.set_subtitle(¤t_config_path(&ctx_reload)); + stats_row_reload.set_subtitle(¤t_stats_label(&ctx_reload)); + }); + + let ctx_open = ctx.clone(); + let source_row_open = source_row.clone(); + let stats_row_open = stats_row.clone(); + open_button.connect_clicked(move |_| { + source_row_open.set_subtitle(¤t_config_path(&ctx_open)); + stats_row_open.set_subtitle(¤t_stats_label(&ctx_open)); + open_editor_window(&ctx_open); + }); + + launch_row.add_suffix(&button_box); + session_group.add(&launch_row); + + let workflow_group = tool_page::append_custom_section( + &body, + "When raw editing helps", + "Keep this lightweight: the guided controls should still be your default for most changes.", + Some("Quick guidance"), + ); + + let workflow_note = gtk4::Label::new(Some( + "Use the raw editor when you want to paste a known-good MangoHud snippet, touch an uncommon option before MangoTune has a dedicated control for it, or make several text edits at once without jumping across pages.", + )); + workflow_note.set_wrap(true); + workflow_note.set_xalign(0.0); + workflow_note.add_css_class("dim-label"); + workflow_group.add(&workflow_note); + + page +} + +fn open_editor_window(ctx: &PageBuildContext) { + let window = gtk4::Window::builder() + .title("MangoTune Raw Editor") + .default_width(940) + .default_height(680) + .transient_for(&ctx.parent_window) + .modal(false) + .build(); + window.add_css_class("preferences-shell"); + + let (outer, body, footer_row) = tool_page::build_utility_window_shell( + "Manual editing", + "Raw Config Editor", + "Apply updates back into MangoTune when you want the dashboard and page controls to reflect them. Reload discards unsaved raw edits and restores the current in-memory config.", + ); + + let buffer = gtk4::TextBuffer::new(None::<>k4::TextTagTable>); + if let Ok(state) = ctx.state.lock() { + buffer.set_text(&Parser::to_string(&state.config)); + } + + let text_view = gtk4::TextView::builder() + .monospace(true) + .vexpand(true) + .hexpand(true) + .buffer(&buffer) + .top_margin(12) + .bottom_margin(12) + .left_margin(12) + .right_margin(12) + .build(); + + let scrolled = gtk4::ScrolledWindow::builder() + .child(&text_view) + .vexpand(true) + .hexpand(true) + .build(); + scrolled.add_css_class("utility-window-scroller"); + + let footer = gtk4::Label::new(Some("Line count: 0 | Option count: 0")); + footer.add_css_class("dim-label"); + footer.set_xalign(0.0); + + let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + let reload = gtk4::Button::with_label("Reload"); + let apply = gtk4::Button::with_label("Apply to Workspace"); + apply.add_css_class("suggested-action"); + footer_row.append(&spacer); + footer_row.append(&reload); + footer_row.append(&apply); + + let ctx_apply = ctx.clone(); + let buffer_apply = buffer.clone(); + let footer_apply = footer.clone(); + apply.connect_clicked(move |_| { + let start = buffer_apply.start_iter(); + let end = buffer_apply.end_iter(); + let text = buffer_apply.text(&start, &end, false).to_string(); + + if let Ok(mut state) = ctx_apply.state.lock() { + let parsed = Parser::parse_str(&text, state.config.path.clone()); + state.config = parsed; + state.config.dirty = true; + state.dirty = true; + } + + update_footer(&footer_apply, &text); + recompute_validation(&ctx_apply.state); + refresh_save_button(&ctx_apply.state, &ctx_apply.save_button); + show_toast( + &ctx_apply.toast_overlay, + "Applied raw text changes to the workspace", + ); + }); + + let ctx_reload = ctx.clone(); + let buffer_reload = buffer.clone(); + let footer_reload = footer.clone(); + reload.connect_clicked(move |_| { + if let Ok(state) = ctx_reload.state.lock() { + let text = Parser::to_string(&state.config); + buffer_reload.set_text(&text); + update_footer(&footer_reload, &text); + } + }); + + let initial = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false); + update_footer(&footer, &initial); + + body.append(&scrolled); + body.append(&footer); + window.set_child(Some(&outer)); + window.present(); +} + +fn current_config_path(ctx: &PageBuildContext) -> String { + ctx.state + .lock() + .ok() + .and_then(|state| { + state + .config + .path + .as_ref() + .map(|path| path.display().to_string()) + }) + .unwrap_or_else(|| "Unsaved session".to_string()) +} + +fn current_stats_label(ctx: &PageBuildContext) -> String { + ctx.state + .lock() + .ok() + .map(|state| { + let text = Parser::to_string(&state.config); + let (line_count, option_count) = count_text_stats(&text); + format!("{line_count} lines | {option_count} active option rows") + }) + .unwrap_or_else(|| "Unavailable".to_string()) +} + +fn update_footer(footer: >k4::Label, text: &str) { + let (line_count, option_count) = count_text_stats(text); + footer.set_text(&format!( + "Line count: {line_count} | Option count: {option_count}" + )); +} + +fn count_text_stats(text: &str) -> (usize, usize) { + let line_count = text.lines().count(); + let option_count = text + .lines() + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') + }) + .count(); + (line_count, option_count) +} + +#[cfg(test)] +mod tests { + use super::count_text_stats; + + #[test] + fn counts_only_active_option_lines() { + let text = "# comment\nfps\n\nposition=top-left\n"; + assert_eq!(count_text_stats(text), (4, 2)); + } +} diff --git a/src/ui/pages/search_results.rs b/src/ui/pages/search_results.rs new file mode 100644 index 0000000..9877b85 --- /dev/null +++ b/src/ui/pages/search_results.rs @@ -0,0 +1,94 @@ +use crate::ui::pages::{search_results_for_query, PageBuildContext, SearchResultGroup}; +use crate::ui::widgets::tool_page; +use gio::prelude::*; +use gtk4::prelude::*; +use libadwaita::prelude::*; + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let query = ctx.current_search_query.borrow().clone(); + let groups = search_results_for_query(&query); + let chips = if groups.is_empty() { + vec!["no exact page yet"] + } else { + vec!["live results", "grouped by page", "click to jump"] + }; + let (page, body) = tool_page::build_start_page( + "Search Results", + "Search", + "Find exact MangoHud controls by title, description, or raw key. Click a result to open its page and jump to the row.", + &chips, + ); + + if query.trim().is_empty() { + tool_page::append_callout( + &body, + "Search", + "Start typing in the sidebar", + "Results appear here as you type. Click any result to open its page and jump directly to that control.", + None, + ); + return page; + } + + if groups.is_empty() { + tool_page::append_callout( + &body, + "No matches", + "No controls matched that search", + "Try a raw MangoHud key like fps_color_change, or broader terms like font, opacity, battery, or frametime.", + Some("tool-callout-warning"), + ); + return page; + } + + for group in groups { + append_group(&body, ctx, group); + } + + page +} + +fn append_group(body: >k4::Box, ctx: &PageBuildContext, group: SearchResultGroup) { + let title = format!("{} ({})", group.page_title, group.page_section); + let description = format!( + "{} matching control{} on this page.", + group.results.len(), + if group.results.len() == 1 { "" } else { "s" } + ); + let section = + tool_page::append_custom_section(body, &title, &description, Some("Search results")); + + for result in group.results { + let subtitle = if result.summary.trim().is_empty() { + format!("MangoHud key: {}", result.key) + } else { + format!("{} MangoHud key: {}.", result.summary, result.key) + }; + let row = libadwaita::ActionRow::builder() + .title(&result.title) + .subtitle(&subtitle) + .activatable(true) + .build(); + row.add_css_class("control-row"); + + let key_label = gtk4::Label::new(Some(result.key)); + key_label.add_css_class("tool-chip"); + key_label.set_valign(gtk4::Align::Center); + row.add_suffix(&key_label); + + let page_id = group.page_id.to_string(); + let key = result.key.to_string(); + let window = ctx.parent_window.clone(); + let pending = ctx.pending_search_target.clone(); + row.connect_activated(move |_| { + *pending.borrow_mut() = Some(key.clone()); + let _ = gtk4::prelude::WidgetExt::activate_action( + &window, + "win.navigate-page", + Some(&page_id.to_variant()), + ); + }); + + section.add(&row); + } +} diff --git a/src/ui/pages/typography.rs b/src/ui/pages/typography.rs new file mode 100644 index 0000000..14f0e46 --- /dev/null +++ b/src/ui/pages/typography.rs @@ -0,0 +1,272 @@ +use crate::ui::pages::{refresh_live_preview_for_key, register_option_row, PageBuildContext}; +use crate::ui::widgets::{toggle_row, tool_page}; +use crate::window::{recompute_validation, refresh_save_button}; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::parser::Parser; +use mangotune::config::schema::get_schema_entry; +use mangotune::config::types::ConfigValue; +use std::collections::BTreeMap; +use std::process::Command; + +#[derive(Clone)] +struct FontChoice { + family: String, + label: String, + path: String, +} + +pub fn build_page(ctx: &PageBuildContext) -> gtk4::ScrolledWindow { + let fonts = discover_fonts(); + let (page, body) = tool_page::build_tool_page( + "Typography", + "Appearance", + "Make the overlay readable at a glance, whether you want a subtle competitive HUD or a chunkier streaming layout.", + &["font scale", "custom font files", "glyph support"], + ); + + let scaling_group = tool_page::append_custom_section( + &body, + "Scale and density", + "Control the global size, text scale, and compactness of MangoHud typography before you dig into font files.", + Some("Fastest win"), + ); + + for key in [ + "font_size", + "font_size_secondary", + "font_scale", + "font_size_text", + "font_scale_media_player", + "no_small_font", + ] { + if let Some(schema) = get_schema_entry(key) { + toggle_row::add_schema_row(&scaling_group, schema, ctx); + } + } + + let font_file_group = tool_page::append_custom_section( + &body, + "Font files", + "Pick installed system fonts for the main HUD and free-text widgets. Leaving this on system default avoids hard-coding a font path.", + Some("Optional"), + ); + + font_file_group.add(&build_font_file_row( + "Primary Font", + "font_file", + "Font used for the main HUD labels and values", + &fonts, + ctx, + )); + font_file_group.add(&build_font_file_row( + "Text Font", + "font_file_text", + "Font used for free-text widgets and custom text", + &fonts, + ctx, + )); + + let glyph_group = tool_page::append_custom_section( + &body, + "Glyph ranges", + "Turn on extra language/script packs only when you need them so the HUD stays light and predictable.", + Some("Localization"), + ); + if let Some(schema) = get_schema_entry("font_glyph_ranges") { + toggle_row::add_schema_row(&glyph_group, schema, ctx); + } + + page +} + +fn build_font_file_row( + title: &str, + key: &str, + subtitle: &str, + fonts: &[FontChoice], + ctx: &PageBuildContext, +) -> libadwaita::ActionRow { + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + register_option_row(ctx, key, &row); + row.set_title_lines(1); + row.set_subtitle_lines(2); + row.set_tooltip_text(Some(&format!( + "{subtitle}\nMangoHud key: {key}\nUse 'System default' to disable the explicit path" + ))); + + let mut labels = vec!["System default".to_string()]; + let mut values: Vec> = vec![None]; + for font in fonts { + labels.push(font.label.clone()); + values.push(Some(font.path.clone())); + } + + let label_refs = labels.iter().map(String::as_str).collect::>(); + let dropdown = gtk4::DropDown::from_strings(&label_refs); + dropdown.add_css_class("control-field"); + dropdown.set_size_request(320, -1); + dropdown.set_valign(gtk4::Align::Center); + + let current = current_string_value(ctx, key); + if let Some(current_path) = current.as_ref() { + if let Some(idx) = values + .iter() + .position(|candidate| candidate.as_deref() == Some(current_path.as_str())) + { + dropdown.set_selected(idx as u32); + } else { + let fallback = std::path::Path::new(current_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(current_path); + labels.push(format!("Current: {fallback}")); + values.push(Some(current_path.clone())); + let updated = + gtk4::StringList::new(&labels.iter().map(String::as_str).collect::>()); + dropdown.set_model(Some(&updated)); + dropdown.set_selected((values.len() - 1) as u32); + } + } + + let browse_button = gtk4::Button::with_label("Browse"); + browse_button.add_css_class("control-button"); + + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let values_for_change = values.clone(); + dropdown.connect_selected_notify(move |combo| { + let idx = combo.selected() as usize; + let selected = values_for_change.get(idx).cloned().flatten(); + apply_font_value(&ctx_clone, &key_owned, selected); + }); + + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + browse_button.connect_clicked(move |_| { + let dialog = gtk4::FileDialog::builder() + .title("Select font file") + .modal(true) + .build(); + + let filters = gio::ListStore::new::(); + let filter = gtk4::FileFilter::new(); + filter.add_pattern("*.ttf"); + filter.add_pattern("*.otf"); + filter.set_name(Some("Font files (*.ttf, *.otf)")); + filters.append(&filter); + dialog.set_filters(Some(&filters)); + dialog.set_default_filter(Some(&filter)); + + let key_for_resp = key_owned.clone(); + let ctx_for_resp = ctx_clone.clone(); + glib::MainContext::default().spawn_local(async move { + if let Ok(file) = dialog.open_future(Some(&ctx_for_resp.parent_window)).await { + if let Some(path) = file.path() { + apply_font_value( + &ctx_for_resp, + &key_for_resp, + Some(path.display().to_string()), + ); + } + } + }); + }); + + row.add_suffix(&dropdown); + row.add_suffix(&browse_button); + row +} + +fn apply_font_value(ctx: &PageBuildContext, key: &str, selected_path: Option) { + if let Ok(mut state) = ctx.state.lock() { + match selected_path { + Some(path) if !path.trim().is_empty() => { + Parser::set_value(&mut state.config, key, ConfigValue::Value(path)); + } + _ => { + Parser::set_value(&mut state.config, key, ConfigValue::Disabled); + } + } + state.dirty = state.config.dirty; + } + + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); + refresh_live_preview_for_key(ctx, Some(key)); +} + +fn current_string_value(ctx: &PageBuildContext, key: &str) -> Option { + let Ok(state) = ctx.state.lock() else { + return None; + }; + + state + .config + .options + .get(key) + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.clone()), + _ => None, + }) +} + +fn discover_fonts() -> Vec { + let output = Command::new("fc-list") + .arg("--format") + .arg("%{family}\t%{file}\n") + .output(); + + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + + let mut by_path: BTreeMap = BTreeMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some((family, path)) = line.split_once('\t') else { + continue; + }; + let family = family.split(',').next().unwrap_or(family).trim(); + let path = path.trim(); + if family.is_empty() || path.is_empty() { + continue; + } + by_path + .entry(path.to_string()) + .or_insert_with(|| family.to_string()); + } + + let mut family_counts: BTreeMap = BTreeMap::new(); + for family in by_path.values() { + *family_counts.entry(family.clone()).or_insert(0) += 1; + } + + let mut fonts = by_path + .into_iter() + .map(|(path, family)| FontChoice { + label: if family_counts.get(&family).copied().unwrap_or(0) > 1 { + format!( + "{family} ({})", + std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("font") + ) + } else { + family.clone() + }, + family, + path, + }) + .collect::>(); + + fonts.sort_by(|a, b| a.family.cmp(&b.family).then_with(|| a.label.cmp(&b.label))); + fonts +} diff --git a/src/ui/toast.rs b/src/ui/toast.rs new file mode 100644 index 0000000..8625266 --- /dev/null +++ b/src/ui/toast.rs @@ -0,0 +1,31 @@ +use std::cell::RefCell; + +thread_local! { + static LAST_TOAST: RefCell> = const { RefCell::new(None) }; +} + +pub fn show_toast(overlay: &libadwaita::ToastOverlay, message: &str) { + LAST_TOAST.with(|last| { + if let Some(previous) = last.borrow_mut().take() { + previous.dismiss(); + } + }); + + let toast = libadwaita::Toast::new(message); + let lower = message.to_ascii_lowercase(); + let is_problem = lower.contains("failed") + || lower.contains("error") + || lower.contains("warning") + || lower.contains("could not") + || lower.contains("invalid"); + + toast.set_timeout(if is_problem { 4 } else { 2 }); + if is_problem { + toast.set_priority(libadwaita::ToastPriority::High); + } + + overlay.add_toast(toast.clone()); + LAST_TOAST.with(|last| { + last.replace(Some(toast)); + }); +} diff --git a/src/ui/widgets/cascade_view.rs b/src/ui/widgets/cascade_view.rs new file mode 100644 index 0000000..1b8cbe7 --- /dev/null +++ b/src/ui/widgets/cascade_view.rs @@ -0,0 +1,159 @@ +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::resolver::LayerSource; + +pub struct CascadeViewModel { + pub layers: Vec, + pub filter: CascadeFilter, +} + +pub struct LayerViewModel { + pub source: LayerSource, + pub label: String, + pub is_editable: bool, + pub options: Vec, +} + +pub struct OptionViewModel { + pub key: String, + pub value: String, + pub state: OptionState, + pub overridden_by: Option, +} + +pub enum OptionState { + Effective, + Shadowed, + Winning, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CascadeFilter { + All, + ConflictsOnly, + ShadowedOnly, +} + +pub fn build_cascade_view(model: CascadeViewModel) -> gtk4::Widget { + let scrolled = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .vexpand(true) + .hexpand(true) + .min_content_height(420) + .build(); + scrolled.add_css_class("cascade-scroller"); + + let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + outer.set_margin_top(12); + outer.set_margin_bottom(12); + outer.set_margin_start(12); + outer.set_margin_end(12); + + for layer in model.layers { + let filtered_options = filter_options(layer.options, model.filter); + if filtered_options.is_empty() { + continue; + } + + let group = libadwaita::PreferencesGroup::new(); + group.set_title(&layer.label); + let (badge, css_class) = source_badge(&layer.source); + let mode = if layer.is_editable { + "Editable layer" + } else { + "Read-only layer" + }; + group.set_description(Some(&format!("{mode} • {badge}"))); + + for item in filtered_options { + let row = libadwaita::ActionRow::builder() + .title(&item.key) + .subtitle(&item.value) + .build(); + + match item.state { + OptionState::Shadowed => { + row.add_css_class("option-shadowed"); + if let Some(overridden_by) = item.overridden_by { + let label = + gtk4::Label::new(Some(&format!("overridden by {overridden_by}"))); + label.add_css_class("dim-label"); + row.add_suffix(&label); + } + } + OptionState::Winning => { + let icon = gtk4::Image::from_icon_name("emblem-ok-symbolic"); + row.add_suffix(&icon); + } + OptionState::Effective => {} + } + + let badge_label = gtk4::Label::new(Some(badge)); + badge_label.add_css_class(css_class); + row.add_prefix(&badge_label); + + group.add(&row); + } + + outer.append(&group); + } + + scrolled.set_child(Some(&outer)); + scrolled.upcast() +} + +pub fn has_visible_options(model: &CascadeViewModel) -> bool { + model.layers.iter().any(|layer| { + layer.options.iter().any(|item| match model.filter { + CascadeFilter::All => true, + CascadeFilter::ConflictsOnly => item.overridden_by.is_some(), + CascadeFilter::ShadowedOnly => matches!(item.state, OptionState::Shadowed), + }) + }) +} + +fn filter_options(options: Vec, filter: CascadeFilter) -> Vec { + options + .into_iter() + .filter(|item| match filter { + CascadeFilter::All => true, + CascadeFilter::ConflictsOnly => item.overridden_by.is_some(), + CascadeFilter::ShadowedOnly => matches!(item.state, OptionState::Shadowed), + }) + .collect() +} + +fn source_badge(source: &LayerSource) -> (&'static str, &'static str) { + match source { + LayerSource::CompiledDefault => ("DEFAULT", "layer-badge-global"), + LayerSource::GlobalXdg => ("GLOBAL", "layer-badge-global"), + LayerSource::PerAppXdg(_) => ("PER-APP", "layer-badge-perapp"), + LayerSource::AppLocal(_) => ("APP-LOCAL", "layer-badge-perapp"), + LayerSource::EnvFile(_) | LayerSource::EnvInline(_) => ("ENV", "layer-badge-env"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visibility_helper_respects_filter() { + let model = CascadeViewModel { + layers: vec![LayerViewModel { + source: LayerSource::GlobalXdg, + label: "Global".to_string(), + is_editable: true, + options: vec![OptionViewModel { + key: "fps".to_string(), + value: "enabled".to_string(), + state: OptionState::Effective, + overridden_by: None, + }], + }], + filter: CascadeFilter::ConflictsOnly, + }; + + assert!(!has_visible_options(&model)); + } +} diff --git a/src/ui/widgets/color_row.rs b/src/ui/widgets/color_row.rs new file mode 100644 index 0000000..89c0f2c --- /dev/null +++ b/src/ui/widgets/color_row.rs @@ -0,0 +1,456 @@ +use crate::ui::pages::{ + refresh_live_preview_for_key, refresh_registered_validation_rows, register_option_row, + register_validation_row, PageBuildContext, +}; +use crate::ui::widgets::color_utils::{hex_to_rgba, rgba_to_hex}; +use crate::ui::widgets::validation_label; +use crate::window::{recompute_validation, refresh_save_button}; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::parser::Parser; +use mangotune::config::schema::get_schema_entry; +use mangotune::config::types::{ConfigValue, ValidationResult}; +use mangotune::config::validator; +use std::cell::Cell; +use std::rc::Rc; + +const UNSET_SWATCH_CLASS: &str = "color-swatch-unset"; + +pub fn build_color_row( + title: &str, + subtitle: &str, + key: &str, + ctx: &PageBuildContext, +) -> libadwaita::ActionRow { + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + register_option_row(ctx, key, &row); + register_validation_row(ctx, key, &row, subtitle); + + let entry = gtk4::Entry::new(); + entry.add_css_class("control-field"); + entry.add_css_class("stacked-color-entry"); + entry.set_max_length(6); + entry.set_width_chars(6); + entry.set_max_width_chars(6); + entry.set_size_request(50, -1); + entry.set_placeholder_text(Some("RRGGBB")); + + let color_dialog = gtk4::ColorDialog::builder() + .title("Choose Color") + .modal(true) + .build(); + let swatch = gtk4::ColorDialogButton::new(Some(color_dialog)); + swatch.add_css_class("color-swatch-button"); + swatch.add_css_class("control-button"); + swatch.add_css_class("stacked-color-swatch"); + swatch.set_size_request(50, 14); + apply_single_swatch_state(&swatch, None); + + if let Some(text) = current_value(ctx, key) { + entry.set_text(&text); + apply_single_swatch_state(&swatch, Some(&text)); + } + + let editor_box = gtk4::Box::new(gtk4::Orientation::Vertical, 3); + editor_box.add_css_class("stacked-color-editor"); + editor_box.set_halign(gtk4::Align::End); + editor_box.append(&entry); + editor_box.append(&swatch); + row.add_suffix(&editor_box); + let syncing = Rc::new(Cell::new(false)); + + let key_owned = key.to_string(); + let subtitle_owned = subtitle.to_string(); + let ctx_clone = ctx.clone(); + let row_clone = row.clone(); + let swatch_clone = swatch.clone(); + let pending_preview_refresh = Rc::new(Cell::new(false)); + let pending_refresh_for_change = pending_preview_refresh.clone(); + let syncing_for_entry = syncing.clone(); + entry.connect_changed(move |input| { + if syncing_for_entry.get() { + return; + } + let text = input.text().to_string().to_ascii_uppercase(); + apply_value(&ctx_clone, &key_owned, text.clone()); + + let validation = validate_key(&ctx_clone, &key_owned, &text); + validation_label::set_action_row_error( + row_clone.upcast_ref(), + &subtitle_owned, + validation_error_text(&validation), + ); + + if validation_error_text(&validation).is_none() { + syncing_for_entry.set(true); + apply_single_swatch_state(&swatch_clone, Some(&text)); + syncing_for_entry.set(false); + } else { + syncing_for_entry.set(true); + apply_single_swatch_state(&swatch_clone, None); + syncing_for_entry.set(false); + } + + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + pending_refresh_for_change.set(true); + }); + + connect_entry_preview_commit(&entry, ctx, key, pending_preview_refresh); + + let entry_for_swatch = entry.clone(); + let key_for_swatch = key.to_string(); + let ctx_for_swatch = ctx.clone(); + let row_for_swatch = row.clone(); + let subtitle_for_swatch = subtitle.to_string(); + let syncing_for_swatch = syncing.clone(); + swatch.connect_rgba_notify(move |button| { + if syncing_for_swatch.get() { + return; + } + let hex = rgba_to_hex(&button.rgba()); + syncing_for_swatch.set(true); + entry_for_swatch.set_text(&hex); + syncing_for_swatch.set(false); + apply_value(&ctx_for_swatch, &key_for_swatch, hex.clone()); + + let validation = validate_key(&ctx_for_swatch, &key_for_swatch, &hex); + validation_label::set_action_row_error( + row_for_swatch.upcast_ref(), + &subtitle_for_swatch, + validation_error_text(&validation), + ); + + recompute_validation(&ctx_for_swatch.state); + refresh_registered_validation_rows(&ctx_for_swatch); + refresh_save_button(&ctx_for_swatch.state, &ctx_for_swatch.save_button); + refresh_live_preview_for_key(&ctx_for_swatch, Some(&key_for_swatch)); + }); + + row +} + +pub fn build_color_list_row( + title: &str, + subtitle: &str, + key: &str, + ctx: &PageBuildContext, +) -> libadwaita::ActionRow { + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + register_option_row(ctx, key, &row); + register_validation_row(ctx, key, &row, subtitle); + + let editor_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + editor_box.add_css_class("color-list-editor"); + editor_box.set_halign(gtk4::Align::End); + let entries: Rc> = Rc::new( + (0..3) + .map(|_| { + let entry = gtk4::Entry::new(); + entry.add_css_class("control-field"); + entry.add_css_class("color-list-cell-entry"); + entry.set_max_length(6); + entry.set_width_chars(6); + entry.set_max_width_chars(6); + entry.set_size_request(50, -1); + entry.set_placeholder_text(Some("RRGGBB")); + entry + }) + .collect(), + ); + let swatches: Rc> = Rc::new( + (0..3) + .map(|index| { + let dialog = gtk4::ColorDialog::builder() + .title("Choose Color") + .modal(true) + .build(); + let swatch = gtk4::ColorDialogButton::new(Some(dialog)); + swatch.add_css_class("color-swatch-button"); + swatch.add_css_class("control-button"); + swatch.add_css_class("color-list-swatch"); + swatch.set_size_request(50, 14); + swatch.set_tooltip_text(Some(&format!("Palette color {}", index + 1))); + swatch + }) + .collect(), + ); + sync_color_list_editors(&entries, &swatches, ""); + let syncing = Rc::new(Cell::new(false)); + + if let Some(text) = current_value(ctx, key) { + sync_color_list_editors(&entries, &swatches, &text); + } + + for index in 0..3 { + let cell = gtk4::Box::new(gtk4::Orientation::Vertical, 3); + cell.add_css_class("color-list-column"); + cell.append(&entries[index]); + cell.append(&swatches[index]); + editor_box.append(&cell); + } + row.add_suffix(&editor_box); + + let key_owned = key.to_string(); + let subtitle_owned = subtitle.to_string(); + let ctx_clone = ctx.clone(); + let row_clone = row.clone(); + let entries_clone = entries.clone(); + let swatches_clone = swatches.clone(); + let pending_preview_refresh = Rc::new(Cell::new(false)); + for entry in entries.iter() { + let key_owned = key_owned.clone(); + let subtitle_owned = subtitle_owned.clone(); + let ctx_clone = ctx_clone.clone(); + let row_clone = row_clone.clone(); + let entries_clone = entries_clone.clone(); + let swatches_clone = swatches_clone.clone(); + let pending_refresh_for_change = pending_preview_refresh.clone(); + let syncing_for_entry = syncing.clone(); + entry.connect_changed(move |_| { + if syncing_for_entry.get() { + return; + } + let text = compose_color_list_value(&entries_clone); + apply_value(&ctx_clone, &key_owned, text.clone()); + + let validation = validate_key(&ctx_clone, &key_owned, &text); + validation_label::set_action_row_error( + row_clone.upcast_ref(), + &subtitle_owned, + validation_error_text(&validation), + ); + + syncing_for_entry.set(true); + sync_color_list_swatches(&swatches_clone, &text); + syncing_for_entry.set(false); + + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + pending_refresh_for_change.set(true); + }); + } + connect_group_entry_preview_commit(entries.as_ref(), ctx, key, pending_preview_refresh); + + for (index, swatch) in swatches.iter().enumerate() { + let entries_for_swatch = entries.clone(); + let key_for_swatch = key.to_string(); + let ctx_for_swatch = ctx.clone(); + let row_for_swatch = row.clone(); + let subtitle_for_swatch = subtitle.to_string(); + let syncing_for_swatch = syncing.clone(); + swatch.connect_rgba_notify(move |button| { + if syncing_for_swatch.get() { + return; + } + let hex = rgba_to_hex(&button.rgba()); + let mut parts = collect_color_list_parts(&entries_for_swatch); + while parts.len() <= index { + parts.push("FFFFFF".to_string()); + } + parts[index] = hex; + syncing_for_swatch.set(true); + sync_color_list_entries(&entries_for_swatch, &parts); + syncing_for_swatch.set(false); + let value = parts.join(","); + apply_value(&ctx_for_swatch, &key_for_swatch, value.clone()); + + let validation = validate_key(&ctx_for_swatch, &key_for_swatch, &value); + validation_label::set_action_row_error( + row_for_swatch.upcast_ref(), + &subtitle_for_swatch, + validation_error_text(&validation), + ); + + recompute_validation(&ctx_for_swatch.state); + refresh_registered_validation_rows(&ctx_for_swatch); + refresh_save_button(&ctx_for_swatch.state, &ctx_for_swatch.save_button); + refresh_live_preview_for_key(&ctx_for_swatch, Some(&key_for_swatch)); + }); + } + + row +} + +fn current_value(ctx: &PageBuildContext, key: &str) -> Option { + let Ok(state) = ctx.state.lock() else { + return None; + }; + state + .config + .options + .get(key) + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.clone()), + _ => None, + }) +} + +fn apply_value(ctx: &PageBuildContext, key: &str, value: String) { + let Ok(mut state) = ctx.state.lock() else { + return; + }; + Parser::set_value(&mut state.config, key, ConfigValue::Value(value)); + state.dirty = state.config.dirty; +} + +fn validate_key(ctx: &PageBuildContext, key: &str, value: &str) -> ValidationResult { + let Some(schema) = get_schema_entry(key) else { + return ValidationResult::Ok; + }; + let result = validator::validate_value(key, &ConfigValue::Value(value.to_string()), schema); + + if let Ok(mut state) = ctx.state.lock() { + if matches!(result, ValidationResult::Ok) { + state.validation.remove(key); + } else { + state.validation.insert(key.to_string(), result.clone()); + } + } + + result +} + +fn validation_error_text(validation: &ValidationResult) -> Option<&str> { + match validation { + ValidationResult::Ok => None, + ValidationResult::Warning(message) | ValidationResult::Error(message) => { + Some(message.as_str()) + } + } +} + +fn split_color_list(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| part.to_ascii_uppercase()) + .collect() +} + +fn collect_color_list_parts(entries: &[gtk4::Entry]) -> Vec { + entries + .iter() + .map(|entry| entry.text().to_string()) + .map(|value| value.trim().to_ascii_uppercase()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn compose_color_list_value(entries: &[gtk4::Entry]) -> String { + collect_color_list_parts(entries).join(",") +} + +fn sync_color_list_entries(entries: &[gtk4::Entry], parts: &[String]) { + for (index, entry) in entries.iter().enumerate() { + entry.set_text(parts.get(index).map(String::as_str).unwrap_or("")); + } +} + +fn sync_color_list_editors( + entries: &[gtk4::Entry], + buttons: &[gtk4::ColorDialogButton], + value: &str, +) { + let parts = split_color_list(value); + sync_color_list_entries(entries, &parts); + sync_color_list_swatches(buttons, value); +} + +fn sync_color_list_swatches(buttons: &[gtk4::ColorDialogButton], value: &str) { + let parts = split_color_list(value); + for (index, button) in buttons.iter().enumerate() { + let maybe_hex = parts.get(index).map(String::as_str); + apply_single_swatch_state(button, maybe_hex); + } +} + +fn apply_single_swatch_state(button: >k4::ColorDialogButton, value: Option<&str>) { + if let Some(hex) = value.and_then(hex_to_rgba) { + button.remove_css_class(UNSET_SWATCH_CLASS); + button.set_tooltip_text(None); + button.set_rgba(&hex); + } else { + button.add_css_class(UNSET_SWATCH_CLASS); + button.set_tooltip_text(Some("Unset color")); + button.set_rgba(>k4::gdk::RGBA::new(0.45, 0.47, 0.52, 1.0)); + } +} + +fn connect_entry_preview_commit( + entry: >k4::Entry, + ctx: &PageBuildContext, + key: &str, + pending_refresh: Rc>, +) { + let ctx_clone = ctx.clone(); + entry.connect_activate(move |_| { + gtk4::prelude::GtkWindowExt::set_focus( + &ctx_clone.parent_window, + Option::<>k4::Widget>::None, + ); + }); + + let focus = gtk4::EventControllerFocus::new(); + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let pending_refresh_clone = pending_refresh.clone(); + focus.connect_leave(move |_| { + if pending_refresh_clone.replace(false) { + refresh_live_preview_for_key(&ctx_clone, Some(&key_owned)); + } + }); + entry.add_controller(focus); +} + +fn connect_group_entry_preview_commit( + entries: &[gtk4::Entry], + ctx: &PageBuildContext, + key: &str, + pending_refresh: Rc>, +) { + for entry in entries { + let ctx_clone = ctx.clone(); + entry.connect_activate(move |_| { + gtk4::prelude::GtkWindowExt::set_focus( + &ctx_clone.parent_window, + Option::<>k4::Widget>::None, + ); + }); + + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let pending_refresh_clone = pending_refresh.clone(); + let entries_clone = entries.to_vec(); + let focus = gtk4::EventControllerFocus::new(); + focus.connect_leave(move |_| { + let entries_for_check = entries_clone.clone(); + let ctx_for_check = ctx_clone.clone(); + let key_for_check = key_owned.clone(); + let pending_for_check = pending_refresh_clone.clone(); + glib::idle_add_local_once(move || { + if entries_for_check + .iter() + .any(gtk4::prelude::WidgetExt::has_focus) + { + return; + } + if pending_for_check.replace(false) { + refresh_live_preview_for_key(&ctx_for_check, Some(&key_for_check)); + } + }); + }); + entry.add_controller(focus); + } +} diff --git a/src/ui/widgets/color_utils.rs b/src/ui/widgets/color_utils.rs new file mode 100644 index 0000000..f0f4086 --- /dev/null +++ b/src/ui/widgets/color_utils.rs @@ -0,0 +1,36 @@ +use gtk4::gdk; + +pub fn hex_to_rgba(hex: &str) -> Option { + if hex.len() != 6 { + return None; + } + + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + + Some(gdk::RGBA::new( + f32::from(r) / 255.0, + f32::from(g) / 255.0, + f32::from(b) / 255.0, + 1.0, + )) +} + +pub fn rgba_to_hex(rgba: &gdk::RGBA) -> String { + let r = (rgba.red() * 255.0).round().clamp(0.0, 255.0) as u8; + let g = (rgba.green() * 255.0).round().clamp(0.0, 255.0) as u8; + let b = (rgba.blue() * 255.0).round().clamp(0.0, 255.0) as u8; + format!("{r:02X}{g:02X}{b:02X}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_hex_color() { + let rgba = hex_to_rgba("12ABEF").expect("valid color"); + assert_eq!(rgba_to_hex(&rgba), "12ABEF"); + } +} diff --git a/src/ui/widgets/hotkey_row.rs b/src/ui/widgets/hotkey_row.rs new file mode 100644 index 0000000..5f2d16f --- /dev/null +++ b/src/ui/widgets/hotkey_row.rs @@ -0,0 +1,408 @@ +use crate::ui::pages::{refresh_live_preview_for_key, register_option_row, PageBuildContext}; +use crate::ui::toast::show_toast; +use crate::ui::widgets::tool_page; +use crate::window::{recompute_validation, refresh_save_button}; +use gtk4::gdk; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::help::display_summary_for_key; +use mangotune::config::parser::Parser; +use mangotune::config::schema::get_schema_entry; +use mangotune::config::types::ConfigValue; +use mangotune::config::validator; +use std::cell::RefCell; +use std::rc::Rc; + +#[derive(Default, Clone)] +struct CapturedModifiers { + control: Option<&'static str>, + shift: Option<&'static str>, + alt: Option<&'static str>, + super_meta: Option<&'static str>, +} + +pub fn build_hotkey_row(title: &str, key: &str, ctx: &PageBuildContext) -> libadwaita::ActionRow { + let summary = display_summary_for_key(key); + let subtitle = if summary.is_empty() { + format!("MangoHud key: {key}") + } else { + format!("{summary} MangoHud key: {key}.") + }; + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(&subtitle) + .build(); + row.add_css_class("control-row"); + register_option_row(ctx, key, &row); + + let display = gtk4::Label::new(None); + display.add_css_class("control-shortcut"); + display.add_css_class("caption"); + display.set_xalign(1.0); + display.set_selectable(false); + display.set_label(&binding_display_text(current_binding(ctx, key).as_deref())); + + let edit = gtk4::Button::with_label("Capture"); + edit.add_css_class("control-button"); + let clear = gtk4::Button::with_label("✕"); + clear.add_css_class("control-button"); + clear.set_tooltip_text(Some("Clear this keybind")); + + row.add_suffix(&display); + row.add_suffix(&edit); + row.add_suffix(&clear); + + let key_owned = key.to_string(); + let display_clone = display.clone(); + let ctx_clone = ctx.clone(); + edit.connect_clicked(move |_| { + let current = current_binding(&ctx_clone, &key_owned).unwrap_or_default(); + let captured = Rc::new(RefCell::new(current.clone())); + + let dialog = gtk4::Window::builder() + .title("Capture keybind") + .modal(true) + .transient_for(&ctx_clone.parent_window) + .default_width(440) + .build(); + dialog.add_css_class("preferences-shell"); + + let (content, body, actions) = tool_page::build_utility_window_shell( + "Keybinding", + "Capture shortcut", + "Click the capture area, then press the key combo you want. MangoTune will save it in MangoHud format automatically.", + ); + + let capture_box = gtk4::Button::new(); + capture_box.add_css_class("control-field"); + capture_box.add_css_class("flat"); + capture_box.set_hexpand(true); + capture_box.set_focus_on_click(true); + capture_box.set_halign(gtk4::Align::Fill); + + let capture_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + capture_inner.set_margin_top(10); + capture_inner.set_margin_bottom(10); + capture_inner.set_margin_start(12); + capture_inner.set_margin_end(12); + + let capture_title = gtk4::Label::new(Some("Capture area")); + capture_title.set_xalign(0.0); + capture_title.add_css_class("dim-label"); + + let capture_value = gtk4::Label::new(Some(&binding_display_text(Some(¤t)))); + capture_value.set_xalign(0.0); + capture_value.add_css_class("heading"); + + let raw_value = gtk4::Label::new(Some(if current.is_empty() { "Disabled" } else { ¤t })); + raw_value.set_xalign(0.0); + raw_value.add_css_class("caption"); + raw_value.add_css_class("dim-label"); + + capture_inner.append(&capture_title); + capture_inner.append(&capture_value); + capture_inner.append(&raw_value); + capture_box.set_child(Some(&capture_inner)); + + let hint = gtk4::Label::new(Some( + "Supports modifiers like Ctrl, Shift, Alt, and Super. Modifier-only binds are ignored.", + )); + hint.set_xalign(0.0); + hint.set_wrap(true); + hint.add_css_class("dim-label"); + + let error = gtk4::Label::new(None); + error.set_xalign(0.0); + error.set_wrap(true); + error.add_css_class("error"); + error.set_visible(false); + + body.append(&capture_box); + body.append(&hint); + body.append(&error); + + actions.set_halign(gtk4::Align::End); + let clear_capture = gtk4::Button::with_label("Clear"); + clear_capture.add_css_class("shell-strip-button"); + let cancel = gtk4::Button::with_label("Cancel"); + cancel.add_css_class("shell-strip-button"); + let save = gtk4::Button::with_label("Save"); + save.add_css_class("suggested-action"); + save.add_css_class("shell-strip-button"); + actions.append(&clear_capture); + actions.append(&cancel); + actions.append(&save); + + let controller = gtk4::EventControllerKey::new(); + let modifiers = Rc::new(RefCell::new(CapturedModifiers::default())); + { + let captured = captured.clone(); + let capture_value = capture_value.clone(); + let raw_value = raw_value.clone(); + let error = error.clone(); + let modifiers = modifiers.clone(); + controller.connect_key_pressed(move |_, keyval, _, state| { + if let Some((slot, token)) = modifier_slot_and_token(keyval) { + modifiers.borrow_mut().set(slot, token); + return gtk4::glib::Propagation::Stop; + } + if is_modifier_only(keyval) { + return gtk4::glib::Propagation::Stop; + } + let binding = binding_from_event(keyval, state, &modifiers.borrow()); + *captured.borrow_mut() = binding.clone(); + capture_value.set_label(&binding_display_text(Some(&binding))); + raw_value.set_label(&binding); + error.set_visible(false); + gtk4::glib::Propagation::Stop + }); + } + { + let modifiers = modifiers.clone(); + controller.connect_key_released(move |_, keyval, _, _| { + if let Some((slot, _)) = modifier_slot_and_token(keyval) { + modifiers.borrow_mut().clear(slot); + } + }); + } + capture_box.add_controller(controller); + + { + let capture_box = capture_box.clone(); + let focus_target = capture_box.clone(); + capture_box.connect_clicked(move |_| { + let _ = gtk4::prelude::WidgetExt::grab_focus(&focus_target); + }); + } + + { + let captured = captured.clone(); + let capture_value = capture_value.clone(); + let raw_value = raw_value.clone(); + let error = error.clone(); + clear_capture.connect_clicked(move |_| { + *captured.borrow_mut() = String::new(); + capture_value.set_label("Disabled"); + raw_value.set_label("Disabled"); + error.set_visible(false); + }); + } + + let attempt_save = { + let dialog = dialog.clone(); + let key_owned = key_owned.clone(); + let display_clone = display_clone.clone(); + let ctx_clone = ctx_clone.clone(); + let captured = captured.clone(); + let error_clone = error.clone(); + move || { + let binding = captured.borrow().trim().to_string(); + if binding.is_empty() { + apply_binding(&ctx_clone, &key_owned, ""); + display_clone.set_label("Disabled"); + dialog.close(); + return; + } + + let validation_ok = get_schema_entry(&key_owned) + .map(|schema| { + validator::validate_value( + &key_owned, + &ConfigValue::Value(binding.clone()), + schema, + ) + }) + .map(|item| { + !matches!(item, mangotune::config::types::ValidationResult::Error(_)) + }) + .unwrap_or(true); + + if !validation_ok { + error_clone.set_label("Invalid keybind format"); + error_clone.set_visible(true); + return; + } + + apply_binding(&ctx_clone, &key_owned, &binding); + display_clone.set_label(&binding_display_text(Some(&binding))); + dialog.close(); + } + }; + + let dialog_for_cancel = dialog.clone(); + cancel.connect_clicked(move |_| { + dialog_for_cancel.close(); + }); + + let attempt_save_click = attempt_save.clone(); + save.connect_clicked(move |_| { + attempt_save_click(); + }); + + dialog.set_child(Some(&content)); + dialog.present(); + let _ = gtk4::prelude::WidgetExt::grab_focus(&capture_box); + }); + + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let display_clone = display.clone(); + clear.connect_clicked(move |_| { + apply_binding(&ctx_clone, &key_owned, ""); + display_clone.set_label("Disabled"); + }); + + row +} + +fn current_binding(ctx: &PageBuildContext, key: &str) -> Option { + let Ok(state) = ctx.state.lock() else { + return None; + }; + state + .config + .options + .get(key) + .and_then(|(_, value)| match value { + ConfigValue::Value(value) => Some(value.clone()), + _ => None, + }) +} + +fn apply_binding(ctx: &PageBuildContext, key: &str, binding: &str) { + let validation_ok = get_schema_entry(key) + .map(|schema| { + validator::validate_value(key, &ConfigValue::Value(binding.to_string()), schema) + }) + .map(|item| !matches!(item, mangotune::config::types::ValidationResult::Error(_))) + .unwrap_or(true); + + if !validation_ok { + show_toast(&ctx.toast_overlay, "Invalid keybind format"); + return; + } + + if let Ok(mut state) = ctx.state.lock() { + if binding.is_empty() { + Parser::set_value(&mut state.config, key, ConfigValue::Disabled); + } else { + Parser::set_value( + &mut state.config, + key, + ConfigValue::Value(binding.to_string()), + ); + } + state.dirty = state.config.dirty; + } + + recompute_validation(&ctx.state); + refresh_save_button(&ctx.state, &ctx.save_button); + refresh_live_preview_for_key(ctx, Some(key)); +} + +fn is_modifier_only(key: gdk::Key) -> bool { + modifier_slot_and_token(key).is_some() +} + +#[derive(Clone, Copy)] +enum ModifierSlot { + Control, + Shift, + Alt, + SuperMeta, +} + +impl CapturedModifiers { + fn set(&mut self, slot: ModifierSlot, token: &'static str) { + match slot { + ModifierSlot::Control => self.control = Some(token), + ModifierSlot::Shift => self.shift = Some(token), + ModifierSlot::Alt => self.alt = Some(token), + ModifierSlot::SuperMeta => self.super_meta = Some(token), + } + } + + fn clear(&mut self, slot: ModifierSlot) { + match slot { + ModifierSlot::Control => self.control = None, + ModifierSlot::Shift => self.shift = None, + ModifierSlot::Alt => self.alt = None, + ModifierSlot::SuperMeta => self.super_meta = None, + } + } +} + +fn modifier_slot_and_token(key: gdk::Key) -> Option<(ModifierSlot, &'static str)> { + Some(match key { + gdk::Key::Control_L => (ModifierSlot::Control, "Control_L"), + gdk::Key::Control_R => (ModifierSlot::Control, "Control_R"), + gdk::Key::Shift_L => (ModifierSlot::Shift, "Shift_L"), + gdk::Key::Shift_R => (ModifierSlot::Shift, "Shift_R"), + gdk::Key::Alt_L => (ModifierSlot::Alt, "Alt_L"), + gdk::Key::Alt_R => (ModifierSlot::Alt, "Alt_R"), + gdk::Key::Super_L => (ModifierSlot::SuperMeta, "Super_L"), + gdk::Key::Super_R => (ModifierSlot::SuperMeta, "Super_R"), + gdk::Key::Meta_L => (ModifierSlot::SuperMeta, "Meta_L"), + gdk::Key::Meta_R => (ModifierSlot::SuperMeta, "Meta_R"), + gdk::Key::Hyper_L => (ModifierSlot::SuperMeta, "Hyper_L"), + gdk::Key::Hyper_R => (ModifierSlot::SuperMeta, "Hyper_R"), + _ => return None, + }) +} + +fn binding_from_event( + key: gdk::Key, + state: gdk::ModifierType, + modifiers: &CapturedModifiers, +) -> String { + let mut parts = Vec::new(); + if state.contains(gdk::ModifierType::CONTROL_MASK) { + parts.push(modifiers.control.unwrap_or("Control_L").to_string()); + } + if state.contains(gdk::ModifierType::SHIFT_MASK) { + parts.push(modifiers.shift.unwrap_or("Shift_L").to_string()); + } + if state.contains(gdk::ModifierType::ALT_MASK) { + parts.push(modifiers.alt.unwrap_or("Alt_L").to_string()); + } + if state.contains(gdk::ModifierType::SUPER_MASK) || state.contains(gdk::ModifierType::META_MASK) + { + parts.push(modifiers.super_meta.unwrap_or("Super_L").to_string()); + } + parts.push(key_token(key)); + parts.join("+") +} + +fn key_token(key: gdk::Key) -> String { + let Some(name) = key.name() else { + return "Unknown".to_string(); + }; + let name = name.as_str(); + if name.len() == 1 { + return name.to_ascii_uppercase(); + } + match name { + "Return" => "Return".to_string(), + "Escape" => "Escape".to_string(), + "space" => "space".to_string(), + other => other.to_string(), + } +} + +fn binding_display_text(binding: Option<&str>) -> String { + let Some(binding) = binding.filter(|value| !value.trim().is_empty()) else { + return "Disabled".to_string(); + }; + binding + .split('+') + .map(|part| match part { + "Control_L" | "Control_R" => "Ctrl".to_string(), + "Shift_L" | "Shift_R" => "Shift".to_string(), + "Alt_L" | "Alt_R" => "Alt".to_string(), + "Super_L" | "Super_R" | "Meta_L" | "Meta_R" => "Super".to_string(), + other if other.len() == 1 => other.to_ascii_uppercase(), + other => other.replace('_', " "), + }) + .collect::>() + .join(" + ") +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs new file mode 100644 index 0000000..e2f23a0 --- /dev/null +++ b/src/ui/widgets/mod.rs @@ -0,0 +1,7 @@ +pub mod cascade_view; +pub mod color_row; +pub mod color_utils; +pub mod hotkey_row; +pub mod toggle_row; +pub mod tool_page; +pub mod validation_label; diff --git a/src/ui/widgets/toggle_row.rs b/src/ui/widgets/toggle_row.rs new file mode 100644 index 0000000..5d175b8 --- /dev/null +++ b/src/ui/widgets/toggle_row.rs @@ -0,0 +1,1035 @@ +use crate::ui::pages::{ + refresh_live_preview_for_key, refresh_registered_validation_rows, register_option_row, + register_validation_row, PageBuildContext, +}; +use crate::ui::toast::show_toast; +use crate::ui::widgets::color_row; +use crate::ui::widgets::validation_label; +use crate::window::{recompute_validation, refresh_save_button}; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::help::{ + display_summary_for_key, display_title_for_key, option_help_for_key, +}; +use mangotune::config::parser::Parser; +use mangotune::config::schema::get_schema_entry; +use mangotune::config::types::{ConfigValue, OptionType, SchemaEntry, ValidationResult}; +use mangotune::config::validator; +use std::cell::Cell; +use std::collections::BTreeSet; +use std::rc::Rc; + +pub fn build_switch_row( + title: &str, + subtitle: &str, + key: &str, + option_type: &OptionType, + ctx: &PageBuildContext, +) -> libadwaita::SwitchRow { + let row = libadwaita::SwitchRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + row.set_tooltip_text(Some(&tooltip_for_key(key, subtitle))); + register_option_row(ctx, key, &row); + register_validation_row(ctx, key, &row, subtitle); + + row.set_active(is_key_enabled(ctx, key)); + + let key_owned = key.to_string(); + let subtitle_owned = subtitle.to_string(); + let ctx_clone = ctx.clone(); + let option_type = option_type.clone(); + row.connect_active_notify(move |switch| { + let active = switch.is_active(); + let value = match option_type { + OptionType::Bool => ConfigValue::Value(if active { "1" } else { "0" }.to_string()), + _ => { + if active { + ConfigValue::Flag + } else { + ConfigValue::Disabled + } + } + }; + + apply_value(&ctx_clone, &key_owned, value); + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + + let validation = current_validation_for_key(&ctx_clone, &key_owned); + validation_label::set_action_row_error( + switch.upcast_ref(), + &subtitle_owned, + validation_error_text(&validation), + ); + + if active && (key_owned == "gpu_mem_clock" || key_owned == "gpu_mem_temp") { + maybe_prompt_enable_vram(&ctx_clone); + } + + maybe_show_conflict_toast(&ctx_clone, &key_owned); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + refresh_live_preview_for_key(&ctx_clone, Some(&key_owned)); + }); + + row +} + +pub fn build_spin_row( + title: &str, + subtitle: &str, + key: &str, + spec: &SpinSpec, + ctx: &PageBuildContext, +) -> libadwaita::ActionRow { + let (step_increment, page_increment) = match spec.option_type { + OptionType::Float { .. } => { + let step = if spec.digits >= 2 { 0.01 } else { 0.1 }; + let page = if spec.digits >= 2 { 0.05 } else { 0.5 }; + (step, page) + } + _ => (1.0, 5.0), + }; + let adjustment = gtk4::Adjustment::new( + spec.min, + spec.min, + spec.max, + step_increment, + page_increment, + 0.0, + ); + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + row.set_tooltip_text(Some(&tooltip_for_key(key, subtitle))); + register_option_row(ctx, key, &row); + register_validation_row(ctx, key, &row, subtitle); + + let spin = gtk4::SpinButton::new(Some(&adjustment), step_increment, spec.digits); + spin.add_css_class("control-field"); + spin.add_css_class("control-spin"); + spin.set_hexpand(false); + spin.set_numeric(true); + spin.set_update_policy(gtk4::SpinButtonUpdatePolicy::IfValid); + spin.set_snap_to_ticks(false); + spin.set_alignment(1.0); + spin.set_width_chars(match spec.option_type { + OptionType::Float { .. } => 8, + _ => 6, + }); + spin.set_max_width_chars(match spec.option_type { + OptionType::Float { .. } => 10, + _ => 8, + }); + spin.set_size_request( + match spec.option_type { + OptionType::Float { .. } => 112, + _ => 92, + }, + -1, + ); + configure_spin_button_for_option_type(&spin, &spec.option_type); + + let current = current_string_value(ctx, key) + .and_then(|item| item.parse::().ok()) + .unwrap_or(spec.min); + spin.set_value(current.clamp(spec.min, spec.max)); + row.add_suffix(&spin); + row.set_activatable_widget(Some(&spin)); + + let key_owned = key.to_string(); + let subtitle_owned = subtitle.to_string(); + let ctx_clone = ctx.clone(); + let option_type = spec.option_type.clone(); + let row_clone = row.clone(); + spin.connect_value_changed(move |spin| { + let raw = spin.value(); + let value = match option_type { + OptionType::Int { .. } => ConfigValue::Value((raw.round() as i64).to_string()), + _ => ConfigValue::Value(format!("{raw}")), + }; + + apply_value(&ctx_clone, &key_owned, value); + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + + let validation = current_validation_for_key(&ctx_clone, &key_owned); + validation_label::set_action_row_error( + row_clone.upcast_ref(), + &subtitle_owned, + validation_error_text(&validation), + ); + + maybe_show_conflict_toast(&ctx_clone, &key_owned); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + refresh_live_preview_for_key(&ctx_clone, Some(&key_owned)); + }); + + row +} + +pub fn build_combo_row( + title: &str, + subtitle: &str, + key: &str, + variants: &[String], + ctx: &PageBuildContext, +) -> libadwaita::ComboRow { + let row = libadwaita::ComboRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + row.set_tooltip_text(Some(&tooltip_for_key(key, subtitle))); + register_option_row(ctx, key, &row); + register_validation_row(ctx, key, &row, subtitle); + + let model = gtk4::StringList::new(&variants.iter().map(String::as_str).collect::>()); + row.set_model(Some(&model)); + + if let Some(current) = current_string_value(ctx, key) { + if let Some(idx) = variants.iter().position(|value| value == ¤t) { + row.set_selected(idx as u32); + } + } + + let key_owned = key.to_string(); + let subtitle_owned = subtitle.to_string(); + let ctx_clone = ctx.clone(); + let variants_owned = variants.to_vec(); + row.connect_selected_notify(move |combo| { + let idx = combo.selected() as usize; + let selected = variants_owned.get(idx).cloned().unwrap_or_else(String::new); + + apply_value(&ctx_clone, &key_owned, ConfigValue::Value(selected)); + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + + let validation = current_validation_for_key(&ctx_clone, &key_owned); + validation_label::set_action_row_error( + combo.upcast_ref(), + &subtitle_owned, + validation_error_text(&validation), + ); + + maybe_show_conflict_toast(&ctx_clone, &key_owned); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + refresh_live_preview_for_key(&ctx_clone, Some(&key_owned)); + }); + + row +} + +pub fn build_entry_row( + title: &str, + subtitle: &str, + key: &str, + schema: &SchemaEntry, + ctx: &PageBuildContext, +) -> libadwaita::ActionRow { + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + row.set_tooltip_text(Some(&tooltip_for_key(key, subtitle))); + register_option_row(ctx, key, &row); + register_validation_row(ctx, key, &row, subtitle); + + let entry = gtk4::Entry::new(); + entry.add_css_class("control-field"); + entry.set_hexpand(false); + configure_entry_for_option_type(&entry, &schema.option_type); + entry.set_placeholder_text(Some("Enter value")); + if let Some(value) = current_string_value(ctx, key) { + entry.set_text(&value); + } + row.add_suffix(&entry); + + let key_owned = key.to_string(); + let schema_clone = schema.clone(); + let ctx_clone = ctx.clone(); + let subtitle_owned = subtitle.to_string(); + let row_clone = row.clone(); + let pending_preview_refresh = Rc::new(Cell::new(false)); + let pending_refresh_for_change = pending_preview_refresh.clone(); + let syncing = Rc::new(Cell::new(false)); + let syncing_for_change = syncing.clone(); + entry.connect_changed(move |entry| { + if syncing_for_change.get() { + return; + } + let raw = entry.text().to_string(); + let normalized = normalize_entry_text(&schema_clone.option_type, &raw); + if normalized != raw { + let pos = entry.position(); + syncing_for_change.set(true); + entry.set_text(&normalized); + entry.set_position(pos.min(normalized.chars().count() as i32)); + syncing_for_change.set(false); + } + + apply_value( + &ctx_clone, + &key_owned, + ConfigValue::Value(normalized.clone()), + ); + + let validation = + validator::validate_value(&key_owned, &ConfigValue::Value(normalized), &schema_clone); + set_validation_for_key(&ctx_clone, &key_owned, validation.clone()); + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + validation_label::set_action_row_error( + row_clone.upcast_ref(), + &subtitle_owned, + validation_error_text(&validation), + ); + + maybe_show_conflict_toast(&ctx_clone, &key_owned); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + pending_refresh_for_change.set(true); + }); + + connect_entry_preview_commit(&entry, ctx, key, pending_preview_refresh); + + row +} + +pub fn build_int_triplet_row( + title: &str, + subtitle: &str, + key: &str, + schema: &SchemaEntry, + ctx: &PageBuildContext, +) -> libadwaita::ActionRow { + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + row.set_tooltip_text(Some(&tooltip_for_key(key, subtitle))); + register_option_row(ctx, key, &row); + register_validation_row(ctx, key, &row, subtitle); + + let editor_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + editor_box.set_halign(gtk4::Align::End); + let entries: Rc> = Rc::new( + (0..3) + .map(|_| { + let entry = gtk4::Entry::new(); + entry.add_css_class("control-field"); + entry.add_css_class("threshold-triplet-entry"); + entry.set_input_purpose(gtk4::InputPurpose::Digits); + entry.set_max_length(4); + entry.set_width_chars(4); + entry.set_max_width_chars(4); + entry.set_size_request(64, -1); + entry.set_placeholder_text(Some("--")); + entry + }) + .collect(), + ); + + if let Some(value) = current_string_value(ctx, key) { + for (index, part) in value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .take(3) + .enumerate() + { + entries[index].set_text(part); + } + } + + for entry in entries.iter() { + editor_box.append(entry); + } + row.add_suffix(&editor_box); + + let key_owned = key.to_string(); + let schema_clone = schema.clone(); + let ctx_clone = ctx.clone(); + let subtitle_owned = subtitle.to_string(); + let row_clone = row.clone(); + let pending_preview_refresh = Rc::new(Cell::new(false)); + let syncing = Rc::new(Cell::new(false)); + + for entry in entries.iter() { + let key_owned = key_owned.clone(); + let schema_clone = schema_clone.clone(); + let ctx_clone = ctx_clone.clone(); + let subtitle_owned = subtitle_owned.clone(); + let row_clone = row_clone.clone(); + let entries_clone = entries.clone(); + let pending_refresh_for_change = pending_preview_refresh.clone(); + let syncing_for_change = syncing.clone(); + entry.connect_changed(move |entry| { + if syncing_for_change.get() { + return; + } + + let raw = entry.text().to_string(); + let normalized = normalize_threshold_triplet_text(&raw); + if normalized != raw { + let pos = entry.position(); + syncing_for_change.set(true); + entry.set_text(&normalized); + entry.set_position(pos.min(normalized.chars().count() as i32)); + syncing_for_change.set(false); + } + + let joined = entries_clone + .iter() + .map(|item| item.text().to_string()) + .map(|item| normalize_threshold_triplet_text(&item)) + .filter(|item| !item.is_empty()) + .collect::>() + .join(","); + + let config_value = if joined.is_empty() { + ConfigValue::Disabled + } else { + ConfigValue::Value(joined.clone()) + }; + apply_value(&ctx_clone, &key_owned, config_value.clone()); + + let validation = validator::validate_value(&key_owned, &config_value, &schema_clone); + set_validation_for_key(&ctx_clone, &key_owned, validation.clone()); + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + validation_label::set_action_row_error( + row_clone.upcast_ref(), + &subtitle_owned, + validation_error_text(&validation), + ); + + maybe_show_conflict_toast(&ctx_clone, &key_owned); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + pending_refresh_for_change.set(true); + }); + } + connect_group_entry_preview_commit(entries.as_ref(), ctx, key, pending_preview_refresh); + + row +} + +pub fn build_multi_select_row( + title: &str, + subtitle: &str, + key: &str, + valid_values: &[String], + ctx: &PageBuildContext, +) -> libadwaita::ExpanderRow { + let row = libadwaita::ExpanderRow::builder() + .title(title) + .subtitle(subtitle) + .build(); + row.add_css_class("control-row"); + row.set_tooltip_text(Some(&tooltip_for_key(key, subtitle))); + register_option_row(ctx, key, &row); + row.set_show_enable_switch(false); + + let current_values = current_string_value(ctx, key) + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect::>(); + + let checkboxes: Rc> = Rc::new( + valid_values + .iter() + .map(|value| { + let check = gtk4::CheckButton::with_label(value); + check.add_css_class("control-check"); + if current_values.contains(value) { + check.set_active(true); + } + check + }) + .collect(), + ); + + for check in checkboxes.iter() { + let child_row = libadwaita::ActionRow::builder() + .title(check.label().as_deref().unwrap_or_default()) + .build(); + child_row.add_css_class("control-row"); + child_row.add_prefix(check); + row.add_row(&child_row); + } + + let key_owned = key.to_string(); + let subtitle_owned = subtitle.to_string(); + for check in checkboxes.iter() { + let checks_clone = checkboxes.clone(); + let key_clone = key_owned.clone(); + let ctx_clone = ctx.clone(); + let row_clone = row.clone(); + let subtitle_clone = subtitle_owned.clone(); + check.connect_toggled(move |_| { + let mut selected = checks_clone + .iter() + .filter(|btn| btn.is_active()) + .filter_map(|btn| btn.label().map(|text| text.to_string())) + .collect::>(); + selected.sort(); + + if selected.is_empty() { + apply_value(&ctx_clone, &key_clone, ConfigValue::Disabled); + } else { + apply_value( + &ctx_clone, + &key_clone, + ConfigValue::Value(selected.join(",")), + ); + } + + recompute_validation(&ctx_clone.state); + refresh_registered_validation_rows(&ctx_clone); + let validation = current_validation_for_key(&ctx_clone, &key_clone); + if let Some(error) = validation_error_text(&validation) { + row_clone.set_subtitle(&format!("{subtitle_clone} — {error}")); + } else { + row_clone.set_subtitle(&format!("{subtitle_clone} — {} selected", selected.len())); + } + + maybe_show_conflict_toast(&ctx_clone, &key_clone); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + refresh_live_preview_for_key(&ctx_clone, Some(&key_clone)); + }); + } + + row +} + +pub fn add_schema_row( + group: &libadwaita::PreferencesGroup, + schema: &SchemaEntry, + ctx: &PageBuildContext, +) { + let title = display_title_for_key(schema.key); + let subtitle = display_help(schema); + + match &schema.option_type { + OptionType::Flag | OptionType::Bool => { + let row = build_switch_row(&title, &subtitle, schema.key, &schema.option_type, ctx); + group.add(&row); + } + OptionType::Int { min, max } => { + let row = build_spin_row( + &title, + &format!("{subtitle} • range {min} to {max}"), + schema.key, + &SpinSpec { + min: *min as f64, + max: *max as f64, + digits: 0, + option_type: schema.option_type.clone(), + }, + ctx, + ); + group.add(&row); + } + OptionType::Float { min, max } => { + let row = build_spin_row( + &title, + &format!("{subtitle} • range {min:.2} to {max:.2}"), + schema.key, + &SpinSpec { + min: *min, + max: *max, + digits: 2, + option_type: schema.option_type.clone(), + }, + ctx, + ); + group.add(&row); + } + OptionType::Enum { variants } => { + let row = build_combo_row(&title, &subtitle, schema.key, variants, ctx); + group.add(&row); + } + OptionType::CommaSepStrings { + valid_values: Some(values), + } => { + let row = build_multi_select_row(&title, &subtitle, schema.key, values, ctx); + group.add(&row); + } + OptionType::Color => { + let row = color_row::build_color_row(&title, &subtitle, schema.key, ctx); + group.add(&row); + } + OptionType::CommaSepStrings { valid_values: None } if is_color_palette_key(schema.key) => { + let row = color_row::build_color_list_row(&title, &subtitle, schema.key, ctx); + group.add(&row); + } + OptionType::CommaSepInts if is_threshold_value_key(schema.key) => { + let row = build_int_triplet_row(&title, &subtitle, schema.key, schema, ctx); + group.add(&row); + } + _ => { + let row = build_entry_row(&title, &subtitle, schema.key, schema, ctx); + group.add(&row); + } + } +} + +fn is_color_palette_key(key: &str) -> bool { + matches!(key, "fps_color" | "gpu_load_color" | "cpu_load_color") +} + +fn is_threshold_value_key(key: &str) -> bool { + matches!(key, "fps_value" | "gpu_load_value" | "cpu_load_value") +} + +fn configure_entry_for_option_type(entry: >k4::Entry, option_type: &OptionType) { + match option_type { + OptionType::Str { max_len } => { + entry.set_max_length((*max_len).min(i32::MAX as usize) as i32); + entry.set_width_chars(20); + entry.set_max_width_chars(28); + entry.set_size_request(220, -1); + } + OptionType::Path { .. } => { + entry.set_width_chars(24); + entry.set_max_width_chars(36); + entry.set_size_request(260, -1); + } + OptionType::FpsLimitList | OptionType::CommaSepInts => { + entry.set_input_purpose(gtk4::InputPurpose::Digits); + entry.set_width_chars(22); + entry.set_max_width_chars(28); + entry.set_size_request(240, -1); + } + OptionType::CommaSepFloats => { + entry.set_input_purpose(gtk4::InputPurpose::Number); + entry.set_width_chars(22); + entry.set_max_width_chars(28); + entry.set_size_request(240, -1); + } + _ => { + entry.set_width_chars(16); + entry.set_max_width_chars(20); + entry.set_size_request(180, -1); + } + } +} + +pub fn configure_spin_button_for_option_type(row: >k4::SpinButton, option_type: &OptionType) { + let syncing = Rc::new(Cell::new(false)); + let key_filter_type = option_type.clone(); + let key_controller = gtk4::EventControllerKey::new(); + key_controller.connect_key_pressed(move |_, key, _, state| { + if state.intersects( + gtk4::gdk::ModifierType::CONTROL_MASK + | gtk4::gdk::ModifierType::ALT_MASK + | gtk4::gdk::ModifierType::SUPER_MASK + | gtk4::gdk::ModifierType::META_MASK, + ) { + return glib::Propagation::Proceed; + } + + if matches!( + key, + gtk4::gdk::Key::BackSpace + | gtk4::gdk::Key::Delete + | gtk4::gdk::Key::Left + | gtk4::gdk::Key::Right + | gtk4::gdk::Key::Home + | gtk4::gdk::Key::End + | gtk4::gdk::Key::Tab + | gtk4::gdk::Key::ISO_Left_Tab + | gtk4::gdk::Key::Return + | gtk4::gdk::Key::KP_Enter + | gtk4::gdk::Key::Up + | gtk4::gdk::Key::Down + | gtk4::gdk::Key::Page_Up + | gtk4::gdk::Key::Page_Down + ) { + return glib::Propagation::Proceed; + } + + let allowed = key.to_unicode().is_none_or(|ch| match key_filter_type { + OptionType::Int { .. } => ch.is_ascii_digit() || ch == '-', + OptionType::Float { .. } => ch.is_ascii_digit() || ch == '-' || ch == '.', + _ => true, + }); + + if allowed { + glib::Propagation::Proceed + } else { + glib::Propagation::Stop + } + }); + row.add_controller(key_controller); + + let syncing_for_insert = syncing.clone(); + let option_type_for_insert = option_type.clone(); + row.connect_insert_text(move |editable, new_text, position| { + if syncing_for_insert.get() { + return; + } + + let filtered = normalize_spin_text(&option_type_for_insert, new_text); + if filtered == new_text { + return; + } + + editable.stop_signal_emission_by_name("insert-text"); + if filtered.is_empty() { + return; + } + + syncing_for_insert.set(true); + editable.insert_text(&filtered, position); + syncing_for_insert.set(false); + }); + + let syncing_for_change = syncing.clone(); + let option_type_for_change = option_type.clone(); + row.connect_changed(move |editable| { + if syncing_for_change.get() { + return; + } + + let current = editable.text().to_string(); + let normalized = normalize_spin_text(&option_type_for_change, ¤t); + if normalized == current { + return; + } + + let pos = editable.position(); + syncing_for_change.set(true); + editable.set_text(&normalized); + editable.set_position(pos.min(normalized.chars().count() as i32)); + syncing_for_change.set(false); + }); +} + +fn normalize_entry_text(option_type: &OptionType, raw: &str) -> String { + match option_type { + OptionType::Str { max_len } => raw.chars().take(*max_len).collect(), + OptionType::FpsLimitList => raw + .chars() + .filter(|ch| ch.is_ascii_digit() || *ch == ',' || ch.is_ascii_whitespace()) + .collect(), + OptionType::CommaSepInts => raw + .chars() + .filter(|ch| { + ch.is_ascii_digit() || *ch == ',' || *ch == '-' || ch.is_ascii_whitespace() + }) + .collect(), + OptionType::CommaSepFloats => raw + .chars() + .filter(|ch| { + ch.is_ascii_digit() + || *ch == ',' + || *ch == '-' + || *ch == '.' + || ch.is_ascii_whitespace() + }) + .collect(), + _ => raw.to_string(), + } +} + +fn normalize_spin_text(option_type: &OptionType, raw: &str) -> String { + match option_type { + OptionType::Int { .. } => raw + .chars() + .filter(|ch| ch.is_ascii_digit() || *ch == '-') + .collect(), + OptionType::Float { .. } => raw + .chars() + .filter(|ch| ch.is_ascii_digit() || *ch == '-' || *ch == '.') + .collect(), + _ => raw.to_string(), + } +} + +fn normalize_threshold_triplet_text(raw: &str) -> String { + raw.chars().filter(|ch| ch.is_ascii_digit()).collect() +} + +fn maybe_prompt_enable_vram(ctx: &PageBuildContext) { + if is_key_enabled(ctx, "vram") { + return; + } + + let dialog = libadwaita::AlertDialog::new( + Some("Enable dependency?"), + Some("GPU memory clock/temperature requires VRAM display. Enable VRAM now?"), + ); + dialog.add_response("enable", "Enable Both"); + dialog.add_response("cancel", "Cancel"); + dialog.set_default_response(Some("enable")); + + let ctx_clone = ctx.clone(); + glib::MainContext::default().spawn_local(async move { + let response = dialog.choose_future(&ctx_clone.parent_window).await; + if response.as_str() == "enable" { + apply_value(&ctx_clone, "vram", ConfigValue::Flag); + recompute_validation(&ctx_clone.state); + refresh_save_button(&ctx_clone.state, &ctx_clone.save_button); + show_toast( + &ctx_clone.toast_overlay, + "Enabled VRAM to satisfy dependency", + ); + refresh_live_preview_for_key(&ctx_clone, Some("vram")); + } + }); +} + +fn maybe_show_conflict_toast(ctx: &PageBuildContext, key: &str) { + let has_conflict = { + let Ok(state) = ctx.state.lock() else { + return; + }; + validator::check_conflicts(&state.config) + .iter() + .any(|(left, right)| left == key || right == key) + }; + + if has_conflict { + show_toast( + &ctx.toast_overlay, + &format!("'{key}' currently conflicts with another enabled option"), + ); + } +} + +fn connect_entry_preview_commit( + entry: >k4::Entry, + ctx: &PageBuildContext, + key: &str, + pending_refresh: Rc>, +) { + let ctx_clone = ctx.clone(); + entry.connect_activate(move |_| { + gtk4::prelude::GtkWindowExt::set_focus( + &ctx_clone.parent_window, + Option::<>k4::Widget>::None, + ); + }); + + let focus = gtk4::EventControllerFocus::new(); + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let pending_refresh_clone = pending_refresh.clone(); + focus.connect_leave(move |_| { + if pending_refresh_clone.replace(false) { + refresh_live_preview_for_key(&ctx_clone, Some(&key_owned)); + } + }); + entry.add_controller(focus); +} + +fn connect_group_entry_preview_commit( + entries: &[gtk4::Entry], + ctx: &PageBuildContext, + key: &str, + pending_refresh: Rc>, +) { + for entry in entries { + let ctx_clone = ctx.clone(); + entry.connect_activate(move |_| { + gtk4::prelude::GtkWindowExt::set_focus( + &ctx_clone.parent_window, + Option::<>k4::Widget>::None, + ); + }); + + let key_owned = key.to_string(); + let ctx_clone = ctx.clone(); + let pending_refresh_clone = pending_refresh.clone(); + let entries_clone = entries.to_vec(); + let focus = gtk4::EventControllerFocus::new(); + focus.connect_leave(move |_| { + let entries_for_check = entries_clone.clone(); + let ctx_for_check = ctx_clone.clone(); + let key_for_check = key_owned.clone(); + let pending_for_check = pending_refresh_clone.clone(); + glib::idle_add_local_once(move || { + if entries_for_check + .iter() + .any(gtk4::prelude::WidgetExt::has_focus) + { + return; + } + if pending_for_check.replace(false) { + refresh_live_preview_for_key(&ctx_for_check, Some(&key_for_check)); + } + }); + }); + entry.add_controller(focus); + } +} + +fn apply_value(ctx: &PageBuildContext, key: &str, value: ConfigValue) { + let Ok(mut state) = ctx.state.lock() else { + return; + }; + Parser::set_value(&mut state.config, key, value); + state.dirty = state.config.dirty; +} + +fn set_validation_for_key(ctx: &PageBuildContext, key: &str, result: ValidationResult) { + let Ok(mut state) = ctx.state.lock() else { + return; + }; + if matches!(result, ValidationResult::Ok) { + state.validation.remove(key); + } else { + state.validation.insert(key.to_string(), result); + } +} + +fn is_key_enabled(ctx: &PageBuildContext, key: &str) -> bool { + let Ok(state) = ctx.state.lock() else { + return false; + }; + let Some((_, value)) = state.config.options.get(key) else { + return false; + }; + + match value { + ConfigValue::Flag => true, + ConfigValue::Value(v) => v.trim() != "0" && !v.trim().is_empty(), + ConfigValue::Absent | ConfigValue::Disabled => false, + } +} + +fn current_string_value(ctx: &PageBuildContext, key: &str) -> Option { + let Ok(state) = ctx.state.lock() else { + return None; + }; + state + .config + .options + .get(key) + .and_then(|(_, value)| match value { + ConfigValue::Value(v) => Some(v.clone()), + ConfigValue::Flag => Some("1".to_string()), + ConfigValue::Absent | ConfigValue::Disabled => None, + }) +} + +fn current_validation_for_key(ctx: &PageBuildContext, key: &str) -> ValidationResult { + let Ok(state) = ctx.state.lock() else { + return ValidationResult::Error("State lock poisoned".to_string()); + }; + + if let Some(result) = state.validation.get(key) { + return result.clone(); + } + + if let Some((_, value)) = state.config.options.get(key) { + if let Some(schema) = get_schema_entry(key) { + return validator::validate_value(key, value, schema); + } + } + + ValidationResult::Ok +} + +fn validation_error_text(validation: &ValidationResult) -> Option<&str> { + match validation { + ValidationResult::Error(message) | ValidationResult::Warning(message) => { + Some(message.as_str()) + } + ValidationResult::Ok => None, + } +} + +fn display_help(schema: &SchemaEntry) -> String { + let mut parts = vec![summary_for_key(schema.key)]; + + if !schema.dependencies.is_empty() { + parts.push(format!( + "Requires {}", + schema + .dependencies + .iter() + .map(|key| display_title_for_key(key)) + .collect::>() + .join(", ") + )); + } + if !schema.conflicts_with.is_empty() { + parts.push(format!( + "Conflicts with {}", + schema + .conflicts_with + .iter() + .map(|key| display_title_for_key(key)) + .collect::>() + .join(", ") + )); + } + + parts.join(" • ") +} + +fn tooltip_for_key(key: &str, subtitle: &str) -> String { + let mut lines = Vec::new(); + lines.push(subtitle.to_string()); + + if let Some(help) = option_help_for_key(key) { + if !help.option_type.is_empty() { + lines.push(format!("Type: {}", help.option_type)); + } + if !help.default_value.is_empty() { + lines.push(format!("Default: {}", help.default_value)); + } + if !help.notes.is_empty() && help.notes != subtitle { + lines.push(help.notes); + } + } + + if let Some(schema) = get_schema_entry(key) { + if !schema.dependencies.is_empty() { + lines.push(format!( + "Requires: {}", + schema + .dependencies + .iter() + .map(|item| display_title_for_key(item)) + .collect::>() + .join(", ") + )); + } + if !schema.conflicts_with.is_empty() { + lines.push(format!( + "Conflicts with: {}", + schema + .conflicts_with + .iter() + .map(|item| display_title_for_key(item)) + .collect::>() + .join(", ") + )); + } + } + + lines.push(format!("MangoHud key: {key}")); + lines.join("\n") +} + +fn summary_for_key(key: &str) -> String { + display_summary_for_key(key) +} + +pub struct SpinSpec { + pub min: f64, + pub max: f64, + pub digits: u32, + pub option_type: OptionType, +} diff --git a/src/ui/widgets/tool_page.rs b/src/ui/widgets/tool_page.rs new file mode 100644 index 0000000..d53c7e3 --- /dev/null +++ b/src/ui/widgets/tool_page.rs @@ -0,0 +1,301 @@ +use crate::ui::pages::PageBuildContext; +use crate::ui::widgets::toggle_row; +use gtk4::prelude::*; +use mangotune::config::schema::{entries_for_category, get_schema_entry}; +use mangotune::config::types::{Category, SchemaEntry}; + +pub fn build_tool_page( + title: &str, + eyebrow: &str, + subtitle: &str, + chips: &[&str], +) -> (gtk4::ScrolledWindow, gtk4::Box) { + let root = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + root.add_css_class("tool-page"); + root.set_margin_top(24); + root.set_margin_bottom(24); + root.set_margin_start(24); + root.set_margin_end(24); + root.set_hexpand(true); + root.set_halign(gtk4::Align::Fill); + + let header = build_header(title, eyebrow, subtitle, chips); + header.add_css_class("tool-page-hero-flat"); + root.append(&header); + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 16); + body.add_css_class("tool-page-body"); + root.append(&body); + + let clamp = libadwaita::Clamp::new(); + clamp.add_css_class("tool-page-clamp"); + clamp.set_maximum_size(1120); + clamp.set_tightening_threshold(900); + clamp.set_child(Some(&root)); + + let scroll = gtk4::ScrolledWindow::new(); + scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + scroll.set_min_content_width(0); + scroll.set_propagate_natural_width(false); + scroll.set_propagate_natural_height(false); + scroll.set_child(Some(&clamp)); + (scroll, body) +} + +pub fn build_start_page( + title: &str, + eyebrow: &str, + subtitle: &str, + chips: &[&str], +) -> (gtk4::ScrolledWindow, gtk4::Box) { + build_tool_page(title, eyebrow, subtitle, chips) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_single_category_page( + ctx: &PageBuildContext, + title: &str, + eyebrow: &str, + subtitle: &str, + chips: &[&str], + section_title: &str, + section_description: &str, + section_badge: Option<&str>, + category: Category, +) -> gtk4::ScrolledWindow { + let (page, body) = build_tool_page(title, eyebrow, subtitle, chips); + append_schema_category_section( + &body, + ctx, + section_title, + section_description, + section_badge, + category, + ); + page +} + +pub fn append_schema_category_section( + body: >k4::Box, + ctx: &PageBuildContext, + title: &str, + description: &str, + badge: Option<&str>, + category: Category, +) { + let (section, group) = section_shell(title, description, badge); + for entry in entries_for_category(&category) { + toggle_row::add_schema_row(&group, entry, ctx); + } + body.append(§ion); +} + +pub fn append_schema_category_section_filtered( + body: >k4::Box, + ctx: &PageBuildContext, + title: &str, + description: &str, + badge: Option<&str>, + category: Category, + predicate: impl Fn(&SchemaEntry) -> bool, +) { + let (section, group) = section_shell(title, description, badge); + for entry in entries_for_category(&category) + .into_iter() + .filter(|entry| predicate(entry)) + { + toggle_row::add_schema_row(&group, entry, ctx); + } + body.append(§ion); +} + +pub fn append_schema_key_section( + body: >k4::Box, + ctx: &PageBuildContext, + title: &str, + description: &str, + badge: Option<&str>, + keys: &[&str], +) { + let (section, group) = section_shell(title, description, badge); + for key in keys { + if let Some(entry) = get_schema_entry(key) { + toggle_row::add_schema_row(&group, entry, ctx); + } + } + body.append(§ion); +} + +pub fn append_custom_section( + body: >k4::Box, + title: &str, + description: &str, + badge: Option<&str>, +) -> libadwaita::PreferencesGroup { + let (section, group) = section_shell(title, description, badge); + body.append(§ion); + group +} + +pub fn append_callout( + body: >k4::Box, + eyebrow: &str, + title: &str, + description: &str, + tone_css_class: Option<&str>, +) { + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + card.add_css_class("tool-callout"); + if let Some(class_name) = tone_css_class { + card.add_css_class(class_name); + } + + let eyebrow_label = gtk4::Label::new(Some(eyebrow)); + eyebrow_label.add_css_class("tool-page-eyebrow"); + eyebrow_label.set_xalign(0.0); + + let title_label = gtk4::Label::new(Some(title)); + title_label.add_css_class("tool-callout-title"); + title_label.set_xalign(0.0); + + let desc_label = gtk4::Label::new(Some(description)); + desc_label.add_css_class("tool-callout-subtitle"); + desc_label.set_wrap(true); + desc_label.set_xalign(0.0); + + card.append(&eyebrow_label); + card.append(&title_label); + card.append(&desc_label); + body.append(&card); +} + +pub fn build_utility_window_shell( + eyebrow: &str, + title: &str, + description: &str, +) -> (gtk4::Box, gtk4::Box, gtk4::Box) { + let root = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + root.add_css_class("utility-window-shell"); + root.set_margin_top(16); + root.set_margin_bottom(16); + root.set_margin_start(16); + root.set_margin_end(16); + + let header = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + header.add_css_class("utility-window-header"); + + let eyebrow_label = gtk4::Label::new(Some(eyebrow)); + eyebrow_label.add_css_class("tool-page-eyebrow"); + eyebrow_label.set_xalign(0.0); + + let title_label = gtk4::Label::new(Some(title)); + title_label.add_css_class("utility-window-title"); + title_label.set_xalign(0.0); + + let description_label = gtk4::Label::new(Some(description)); + description_label.add_css_class("utility-window-subtitle"); + description_label.set_wrap(true); + description_label.set_xalign(0.0); + + header.append(&eyebrow_label); + header.append(&title_label); + header.append(&description_label); + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + body.add_css_class("utility-window-body"); + body.set_vexpand(true); + + let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + footer.add_css_class("utility-window-footer"); + + root.append(&header); + root.append(&body); + root.append(&footer); + (root, body, footer) +} + +fn build_header(title: &str, eyebrow: &str, subtitle: &str, chips: &[&str]) -> gtk4::Box { + let hero = gtk4::Box::new(gtk4::Orientation::Vertical, 10); + hero.add_css_class("tool-page-hero"); + + let eyebrow_label = gtk4::Label::new(Some(eyebrow)); + eyebrow_label.add_css_class("tool-page-eyebrow"); + eyebrow_label.set_xalign(0.0); + + let title_label = gtk4::Label::new(Some(title)); + title_label.add_css_class("tool-page-title"); + title_label.set_xalign(0.0); + + let subtitle_label = gtk4::Label::new(Some(subtitle)); + subtitle_label.add_css_class("tool-page-subtitle"); + subtitle_label.set_wrap(true); + subtitle_label.set_xalign(0.0); + + let chip_box = gtk4::FlowBox::new(); + chip_box.add_css_class("tool-chip-flow"); + chip_box.set_selection_mode(gtk4::SelectionMode::None); + chip_box.set_activate_on_single_click(false); + chip_box.set_halign(gtk4::Align::Start); + chip_box.set_row_spacing(4); + chip_box.set_column_spacing(8); + for chip in chips { + chip_box.insert(&build_chip(chip), -1); + } + + hero.append(&eyebrow_label); + hero.append(&title_label); + hero.append(&subtitle_label); + if !chips.is_empty() { + hero.append(&chip_box); + } + hero +} + +fn section_shell( + title: &str, + description: &str, + badge: Option<&str>, +) -> (gtk4::Box, libadwaita::PreferencesGroup) { + let section = gtk4::Box::new(gtk4::Orientation::Vertical, 10); + section.add_css_class("tool-section-shell"); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + header.add_css_class("tool-section-header"); + header.set_valign(gtk4::Align::Start); + + let copy = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + copy.set_hexpand(true); + + let title_label = gtk4::Label::new(Some(title)); + title_label.add_css_class("tool-section-title"); + title_label.set_xalign(0.0); + + let desc_label = gtk4::Label::new(Some(description)); + desc_label.add_css_class("tool-section-subtitle"); + desc_label.set_wrap(true); + desc_label.set_xalign(0.0); + + copy.append(&title_label); + copy.append(&desc_label); + header.append(©); + + if let Some(badge_text) = badge { + let badge = build_chip(badge_text); + badge.add_css_class("tool-section-badge"); + badge.set_valign(gtk4::Align::Start); + header.append(&badge); + } + + let group = libadwaita::PreferencesGroup::new(); + group.add_css_class("tool-section-group"); + + section.append(&header); + section.append(&group); + (section, group) +} + +fn build_chip(label: &str) -> gtk4::Label { + let chip = gtk4::Label::new(Some(label)); + chip.add_css_class("tool-chip"); + chip +} diff --git a/src/ui/widgets/validation_label.rs b/src/ui/widgets/validation_label.rs new file mode 100644 index 0000000..34fd3ed --- /dev/null +++ b/src/ui/widgets/validation_label.rs @@ -0,0 +1,12 @@ +use gtk4::prelude::*; +use libadwaita::prelude::*; + +pub fn set_action_row_error(row: &libadwaita::ActionRow, base_subtitle: &str, error: Option<&str>) { + if let Some(message) = error { + row.add_css_class("error"); + row.set_subtitle(message); + } else { + row.remove_css_class("error"); + row.set_subtitle(base_subtitle); + } +} diff --git a/src/window.rs b/src/window.rs new file mode 100644 index 0000000..97eb6be --- /dev/null +++ b/src/window.rs @@ -0,0 +1,3207 @@ +use crate::ui::pages::{self, PageBuildContext}; +use crate::ui::widgets::tool_page; +use gio::prelude::*; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use mangotune::config::normalize::normalize_legacy_option_values; +use mangotune::config::parser::Parser; +use mangotune::config::resolver::{LayerSource, Resolver}; +use mangotune::config::types::{AnnotatedConfig, ConfigLine, ValidationResult}; +use mangotune::config::validator; +use mangotune::integrations::search_game_config_hints; +use mangotune::preview::PreviewController; +use mangotune::system::detect::{self, SystemInfo}; +use mangotune::system::paths::XdgPaths; +use notify::{ + event::EventKind, Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher, +}; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Shared mutable application state, passed via Arc> to editable pages. +pub struct AppState { + pub config: AnnotatedConfig, + pub validation: HashMap, + pub dirty: bool, + pub saved_snapshot: AnnotatedConfig, + pub redo_snapshot: Option, +} + +pub struct MainWindow { + pub window: libadwaita::ApplicationWindow, + _config_watcher: Option, + _preview_controller: PreviewController, +} + +#[derive(Clone)] +struct LayerStackBarWidgets { + bar: gtk4::Box, + summary_label: gtk4::Label, + summary_shell: gtk4::Button, + conflict_badge: gtk4::Label, +} + +impl MainWindow { + pub fn new(app: &libadwaita::Application, system_info: SystemInfo) -> Self { + const WINDOW_WIDTH_DRIFT_WORKAROUND: i32 = 1300; + let settings = app_settings(); + let initial_config = load_initial_config(settings.as_ref()); + let requested_width = settings + .as_ref() + .map(|s| match s.int("window-width") { + 1120 | 1040 | 980 => 860, + width if width > 1040 => 900, + width => width.clamp(680, 920), + }) + .filter(|w| *w >= 640) + .unwrap_or(860) + .max(WINDOW_WIDTH_DRIFT_WORKAROUND); + let requested_height = settings + .as_ref() + .map(|s| s.int("window-height")) + .filter(|h| *h >= 480) + .unwrap_or(780); + let (default_width, default_height) = + clamp_initial_window_size(requested_width, requested_height); + + let window = libadwaita::ApplicationWindow::builder() + .application(app) + .title("MangoTune") + .default_width(default_width) + .default_height(default_height) + .width_request(700) + .height_request(600) + .build(); + window.add_css_class("app-window"); + + let state = Arc::new(Mutex::new(AppState { + config: initial_config.clone(), + validation: HashMap::new(), + dirty: false, + saved_snapshot: initial_config, + redo_snapshot: None, + })); + let preview = PreviewController::new(); + + let save_split = libadwaita::SplitButton::builder() + .label("Save Config") + .sensitive(false) + .build(); + let toast_overlay = libadwaita::ToastOverlay::new(); + toast_overlay.add_css_class("app-content-shell"); + + let reload_banner = + libadwaita::Banner::new("Config file changed externally. Reload to see changes."); + reload_banner.set_button_label(Some("Reload")); + reload_banner.set_revealed(false); + + let page_ctx = PageBuildContext { + state: state.clone(), + preview: preview.clone(), + preview_reload_source: Rc::new(RefCell::new(None)), + validation_rows: Rc::new(RefCell::new(HashMap::new())), + option_rows: Rc::new(RefCell::new(HashMap::new())), + pending_search_target: Rc::new(RefCell::new(None)), + current_search_query: Rc::new(RefCell::new(String::new())), + save_button: save_split.clone(), + toast_overlay: toast_overlay.clone(), + parent_window: window.clone(), + system_info: system_info.clone(), + }; + + let navigation_view = build_navigation_view(&page_ctx); + restore_active_page(&navigation_view, settings.as_ref()); + let split_view = build_split_view(&navigation_view, &page_ctx, settings.as_ref()); + let config_bar = build_config_bar(&navigation_view, &state, &toast_overlay); + let header = build_header_bar(&window, &save_split, settings.as_ref()); + + wire_save_button(&save_split, &state, &toast_overlay, settings.as_ref()); + refresh_save_button(&state, &save_split); + + install_window_actions( + &window, + &state, + &save_split, + &toast_overlay, + &navigation_view, + &reload_banner, + &page_ctx, + &config_bar, + settings.as_ref(), + ); + install_close_guard( + &window, + &state, + &preview, + &save_split, + &toast_overlay, + settings.as_ref(), + ); + + if let (Some(settings), Ok(state)) = (settings.as_ref(), state.lock()) { + if let Some(path) = &state.config.path { + let _ = settings.set_string("last-config-path", &path.display().to_string()); + } + } + + let watcher = install_external_config_watcher( + &state, + &reload_banner, + &toast_overlay, + &save_split, + &page_ctx, + settings.as_ref(), + ); + + toast_overlay.set_child(Some(&split_view)); + + let toolbar_view = libadwaita::ToolbarView::new(); + toolbar_view.add_css_class("app-toolbar-view"); + toolbar_view.add_top_bar(&header); + toolbar_view.add_top_bar(&config_bar.bar); + toolbar_view.add_top_bar(&reload_banner); + toolbar_view.set_content(Some(&toast_overlay)); + + if !system_info.mangohud.installed { + window.set_title(Some("MangoTune (MangoHud not detected)")); + } + + window.set_content(Some(&toolbar_view)); + Self { + window, + _config_watcher: watcher, + _preview_controller: preview, + } + } + + pub fn present(&self) { + self.window.present(); + } +} + +fn clamp_initial_window_size(width: i32, height: i32) -> (i32, i32) { + let min_width = 680; + let min_height = 480; + + let Some(display) = gtk4::gdk::Display::default() else { + return (width.max(min_width), height.max(min_height)); + }; + let monitors = display.monitors(); + let Some(monitor_obj) = monitors.item(0) else { + return (width.max(min_width), height.max(min_height)); + }; + let Ok(monitor) = monitor_obj.downcast::() else { + return (width.max(min_width), height.max(min_height)); + }; + + let geometry = monitor.geometry(); + let area_width = geometry.width().max(0); + let area_height = geometry.height().max(0); + if area_width <= 0 || area_height <= 0 { + return (width.max(min_width), height.max(min_height)); + } + + let width_cap = (area_width - 48).max(min_width); + let height_cap = (area_height - 48).max(min_height); + ( + width.clamp(min_width, width_cap), + height.clamp(min_height, height_cap), + ) +} + +fn debug_log(message: &str) { + mangotune::debug_log::record(message); +} + +pub fn refresh_save_button(state: &Arc>, save_button: &libadwaita::SplitButton) { + let Ok(state) = state.lock() else { + save_button.set_sensitive(false); + return; + }; + let errors = validation_errors(&state.validation); + save_button.set_sensitive(true); + if errors.is_empty() { + if state.dirty { + save_button.set_tooltip_text(Some( + "Save your current MangoHud config, or open the menu for Save a Copy, Discard Changes, or Backup.", + )); + } else { + save_button.set_tooltip_text(Some( + "No unsaved changes right now. The menu still contains Save a Copy, Discard Changes, and Backup actions.", + )); + } + } else { + save_button.set_tooltip_text(Some(&format!( + "Cannot save yet: {} validation error(s). Fix the highlighted issues first, or open the menu for other actions.", + errors.len() + ))); + } +} + +pub fn recompute_validation(state: &Arc>) { + let Ok(mut state) = state.lock() else { + return; + }; + state.validation = validator::validate_all(&state.config); + state.dirty = state.config.dirty; +} + +fn wire_save_button( + save_split: &libadwaita::SplitButton, + state: &Arc>, + toast_overlay: &libadwaita::ToastOverlay, + settings: Option<&gio::Settings>, +) { + let state_clone = state.clone(); + let save_button_clone = save_split.clone(); + let toast_overlay_clone = toast_overlay.clone(); + let settings = settings.cloned(); + + save_split.connect_clicked(move |_| { + let _ = save_current_config( + &state_clone, + &save_button_clone, + &toast_overlay_clone, + settings.as_ref(), + ); + }); +} + +fn save_current_config( + state: &Arc>, + save_button: &libadwaita::SplitButton, + toast_overlay: &libadwaita::ToastOverlay, + settings: Option<&gio::Settings>, +) -> bool { + debug_log("save: begin"); + let config_to_write = { + let Ok(mut state_guard) = state.lock() else { + debug_log("save: failed to lock app state"); + crate::ui::toast::show_toast(toast_overlay, "Could not acquire config state"); + refresh_save_button(state, save_button); + return false; + }; + + let normalized = normalize_legacy_option_values(&mut state_guard.config); + if normalized > 0 { + debug_log(&format!( + "save: normalized {normalized} legacy option value(s) before validation" + )); + state_guard.dirty = state_guard.config.dirty; + } + + debug_log(&format!( + "save: validating {} options", + state_guard.config.options.len() + )); + state_guard.validation = validator::validate_all(&state_guard.config); + let errors = validation_errors(&state_guard.validation); + + if !errors.is_empty() { + debug_log(&format!( + "save: blocked by {} validation errors", + errors.len() + )); + for (key, message) in &errors { + debug_log(&format!("save: validation error {key}: {message}")); + } + + let preview = errors + .iter() + .take(3) + .map(|(key, message)| format!("{key}: {message}")) + .collect::>() + .join(" | "); + + crate::ui::toast::show_toast( + toast_overlay, + &format!( + "Cannot save: {} validation error(s). {}", + errors.len(), + preview + ), + ); + + drop(state_guard); + refresh_save_button(state, save_button); + return false; + } + + state_guard.config.clone() + }; + + if let Some(path) = &config_to_write.path { + debug_log(&format!("save: target path {}", path.display())); + if let Some(parent) = path.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + debug_log(&format!("save: failed to create parent dir: {err}")); + crate::ui::toast::show_toast( + toast_overlay, + &format!("Failed to create config directory: {err}"), + ); + refresh_save_button(state, save_button); + return false; + } + } + + if setting_bool(settings, "auto-backup-on-save", true) && path.exists() { + debug_log("save: creating autosave backup copy"); + if let Err(err) = create_backup_copy_for_path(path, "autosave") { + debug_log(&format!("save: autosave backup warning: {err}")); + crate::ui::toast::show_toast(toast_overlay, &format!("Backup warning: {err}")); + } + } + } + + let write_started = Instant::now(); + debug_log("save: writing config to disk"); + match Parser::write(&config_to_write) { + Ok(()) => { + debug_log(&format!( + "save: write completed in {} ms", + write_started.elapsed().as_millis() + )); + if let Ok(mut state) = state.lock() { + state.config.dirty = false; + state.dirty = false; + state.validation.clear(); + state.saved_snapshot = state.config.clone(); + state.redo_snapshot = None; + + if let (Some(settings), Some(path)) = (settings, state.config.path.as_ref()) { + let _ = settings.set_string("last-config-path", &path.display().to_string()); + } + } + crate::ui::toast::show_toast(toast_overlay, "Config saved"); + refresh_save_button(state, save_button); + debug_log("save: success"); + true + } + Err(err) => { + debug_log(&format!( + "save: write failed after {} ms: {err}", + write_started.elapsed().as_millis() + )); + crate::ui::toast::show_toast(toast_overlay, &format!("Save failed: {err}")); + refresh_save_button(state, save_button); + false + } + } +} + +fn load_initial_config(settings: Option<&gio::Settings>) -> AnnotatedConfig { + let preferred_path = settings + .map(|s| s.string("last-config-path")) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from); + + if let Some(path) = preferred_path { + if path.exists() { + if let Ok(mut parsed) = Parser::read(&path) { + normalize_legacy_option_values(&mut parsed); + return parsed; + } + } + } + + if let Ok(xdg) = XdgPaths::resolve() { + if xdg.global_config.exists() { + if let Ok(mut parsed) = Parser::read(&xdg.global_config) { + normalize_legacy_option_values(&mut parsed); + return parsed; + } + } + + let _ = std::fs::create_dir_all(&xdg.mangohud_dir); + return AnnotatedConfig { + lines: vec![ + ConfigLine::Comment( + "### MangoHud configuration - managed by MangoTune".to_string(), + ), + ConfigLine::Comment("### App: global".to_string()), + ConfigLine::Blank, + ], + options: indexmap::IndexMap::new(), + path: Some(xdg.global_config), + dirty: false, + }; + } + + AnnotatedConfig { + lines: vec![], + options: indexmap::IndexMap::new(), + path: None, + dirty: false, + } +} + +fn build_header_bar( + window: &libadwaita::ApplicationWindow, + save_split: &libadwaita::SplitButton, + settings: Option<&gio::Settings>, +) -> libadwaita::HeaderBar { + let header = libadwaita::HeaderBar::new(); + header.add_css_class("app-headerbar"); + let title = libadwaita::WindowTitle::new("MangoTune", "No config loaded"); + title.add_css_class("shell-window-title"); + header.set_title_widget(Some(&title)); + save_split.add_css_class("shell-save-button"); + save_split.set_popover(Some(&build_save_popover(window, settings))); + header.pack_start(save_split); + + let gear_button = gtk4::MenuButton::new(); + gear_button.set_icon_name("emblem-system-symbolic"); + gear_button.set_popover(Some(&build_gear_popover(window))); + gear_button.add_css_class("shell-menu-button"); + header.pack_end(&gear_button); + + header +} + +fn build_compact_popover() -> (gtk4::Popover, gtk4::Box) { + let popover = gtk4::Popover::new(); + popover.add_css_class("compact-menu-popover"); + popover.set_has_arrow(false); + popover.set_autohide(true); + popover.set_halign(gtk4::Align::Start); + popover.set_valign(gtk4::Align::Start); + + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + content.add_css_class("compact-menu-box"); + content.set_halign(gtk4::Align::Start); + content.set_valign(gtk4::Align::Start); + content.set_hexpand(false); + content.set_vexpand(false); + popover.set_child(Some(&content)); + + (popover, content) +} + +fn build_compact_popover_row( + title: &str, + subtitle: Option<&str>, + trailing: Option<&str>, + leading_mark: bool, +) -> gtk4::Button { + let button = gtk4::Button::new(); + button.add_css_class("flat"); + button.add_css_class("compact-menu-row"); + button.set_halign(gtk4::Align::Start); + button.set_hexpand(false); + button.set_vexpand(false); + + let shell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + shell.set_margin_start(10); + shell.set_margin_end(10); + shell.set_margin_top(2); + shell.set_margin_bottom(2); + shell.set_halign(gtk4::Align::Start); + shell.set_valign(gtk4::Align::Center); + shell.set_hexpand(false); + + let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 1); + text_box.set_hexpand(false); + + let title_label = gtk4::Label::new(Some(title)); + title_label.add_css_class("compact-menu-row-title"); + title_label.set_xalign(0.0); + title_label.set_halign(gtk4::Align::Start); + title_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + title_label.set_max_width_chars(38); + text_box.append(&title_label); + + if let Some(subtitle) = subtitle { + let subtitle_label = gtk4::Label::new(Some(subtitle)); + subtitle_label.add_css_class("compact-menu-row-subtitle"); + subtitle_label.add_css_class("dim-label"); + subtitle_label.set_xalign(0.0); + subtitle_label.set_halign(gtk4::Align::Start); + subtitle_label.set_wrap(false); + subtitle_label.set_max_width_chars(38); + subtitle_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + text_box.append(&subtitle_label); + } + + shell.append(&text_box); + + if trailing.is_none() { + let mark = gtk4::Label::new(Some("✓")); + mark.add_css_class("compact-menu-row-mark"); + mark.add_css_class("dim-label"); + mark.set_xalign(1.0); + mark.set_halign(gtk4::Align::End); + mark.set_visible(leading_mark); + shell.append(&mark); + } else if let Some(trailing) = trailing { + let trailing_label = gtk4::Label::new(Some(trailing)); + trailing_label.add_css_class("compact-menu-row-trailing"); + trailing_label.add_css_class("dim-label"); + trailing_label.set_xalign(1.0); + trailing_label.set_halign(gtk4::Align::End); + trailing_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + shell.append(&trailing_label); + } + + button.set_child(Some(&shell)); + button +} + +fn build_compact_popover_separator() -> gtk4::Separator { + let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal); + separator.add_css_class("compact-menu-separator"); + separator +} + +fn activate_window_action( + window: &libadwaita::ApplicationWindow, + action_name: &str, + target: Option<&glib::Variant>, +) { + let _ = gtk4::prelude::WidgetExt::activate_action(window, action_name, target); +} + +fn window_action_bool_state( + window: &libadwaita::ApplicationWindow, + action_name: &str, + fallback: bool, +) -> bool { + window + .lookup_action(action_name) + .and_then(|action| action.state()) + .and_then(|state| bool::from_variant(&state)) + .unwrap_or(fallback) +} + +fn toggle_window_bool_action(window: &libadwaita::ApplicationWindow, action_name: &str) { + if let Some(action) = window.lookup_action(action_name) { + let current = action + .state() + .and_then(|state| bool::from_variant(&state)) + .unwrap_or(false); + action.change_state(&(!current).to_variant()); + } +} + +fn build_save_popover( + window: &libadwaita::ApplicationWindow, + settings: Option<&gio::Settings>, +) -> gtk4::Popover { + let (popover, content) = build_compact_popover(); + + let save_as = build_compact_popover_row("Save a Copy…", None, None, false); + { + let window = window.clone(); + let popover = popover.clone(); + save_as.connect_clicked(move |_| { + activate_window_action(&window, "win.save-as", None); + popover.popdown(); + }); + } + content.append(&save_as); + + let auto_backup = build_compact_popover_row( + "Auto backup on save", + None, + None, + setting_bool(settings, "auto-backup-on-save", true), + ); + { + let window = window.clone(); + let popover = popover.clone(); + auto_backup.connect_clicked(move |_| { + toggle_window_bool_action(&window, "auto-backup-on-save"); + popover.popdown(); + }); + } + content.append(&auto_backup); + + content.append(&build_compact_popover_separator()); + + for (title, action) in [ + ("Restore Latest Safety Backup", "win.restore-backup"), + ("Reset to Defaults", "win.reset-defaults"), + ("Discard Unsaved Changes", "win.revert"), + ("Create Safety Backup", "win.backup"), + ] { + let row = build_compact_popover_row(title, None, None, false); + let window = window.clone(); + let popover = popover.clone(); + row.connect_clicked(move |_| { + activate_window_action(&window, action, None); + popover.popdown(); + }); + content.append(&row); + } + + popover.connect_show({ + let window = window.clone(); + let settings = settings.cloned(); + move |popover| { + if let Some(container) = popover + .child() + .and_then(|child| child.downcast::().ok()) + { + if let Some(row) = container + .first_child() + .and_then(|child| child.next_sibling()) + .and_then(|child| child.downcast::().ok()) + { + let active = window_action_bool_state( + &window, + "auto-backup-on-save", + setting_bool(settings.as_ref(), "auto-backup-on-save", true), + ); + if let Some(shell) = row + .child() + .and_then(|child| child.downcast::().ok()) + { + if let Some(mark) = shell + .last_child() + .and_then(|child| child.downcast::().ok()) + { + mark.set_visible(active); + } + } + } + } + } + }); + + popover +} + +fn build_gear_popover(window: &libadwaita::ApplicationWindow) -> gtk4::Popover { + let (popover, content) = build_compact_popover(); + for (title, action) in [ + ("Keyboard Shortcuts", "win.shortcuts"), + ("About MangoTune", "win.about"), + ] { + let row = build_compact_popover_row(title, None, None, false); + let window = window.clone(); + let popover = popover.clone(); + row.connect_clicked(move |_| { + activate_window_action(&window, action, None); + popover.popdown(); + }); + content.append(&row); + } + popover +} + +fn build_config_bar( + navigation_view: &libadwaita::NavigationView, + state: &Arc>, + toast_overlay: &libadwaita::ToastOverlay, +) -> LayerStackBarWidgets { + let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + bar.add_css_class("config-bar"); + bar.add_css_class("shell-strip"); + bar.set_margin_start(12); + bar.set_margin_end(12); + bar.set_margin_top(6); + bar.set_margin_bottom(6); + + let icon = gtk4::Image::from_icon_name("globe-symbolic"); + icon.add_css_class("shell-strip-icon"); + bar.append(&icon); + + let editing_label = gtk4::Label::new(Some("Layer stack")); + editing_label.set_xalign(0.0); + editing_label.add_css_class("shell-strip-label"); + bar.append(&editing_label); + + let stack_summary = gtk4::Button::new(); + stack_summary.set_hexpand(true); + stack_summary.set_halign(gtk4::Align::Fill); + stack_summary.add_css_class("flat"); + stack_summary.add_css_class("shell-target-summary"); + stack_summary.set_tooltip_text(Some( + "Click to switch which MangoHud config file MangoTune is actively editing. View Layers still shows the full detected stack.", + )); + + let stack_summary_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + stack_summary_box.set_hexpand(true); + + let summary_label = gtk4::Label::new(Some("Discovering MangoHud layer stack...")); + summary_label.set_xalign(0.0); + summary_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + summary_label.set_width_chars(1); + summary_label.set_max_width_chars(28); + summary_label.set_hexpand(true); + summary_label.add_css_class("shell-target-summary-label"); + stack_summary_box.append(&summary_label); + + let summary_chevron = gtk4::Image::from_icon_name("pan-down-symbolic"); + summary_chevron.add_css_class("dim-label"); + stack_summary_box.append(&summary_chevron); + stack_summary.set_child(Some(&stack_summary_box)); + bar.append(&stack_summary); + + { + let state = state.clone(); + let toast_overlay = toast_overlay.clone(); + let anchor = stack_summary.clone(); + stack_summary.connect_clicked(move |_| { + let toast_overlay = toast_overlay.clone(); + let current_path = state + .lock() + .ok() + .and_then(|guard| guard.config.path.clone()); + let anchor = anchor.clone(); + glib::MainContext::default().spawn_local(async move { + let targets = discover_switchable_config_targets(current_path.clone()).await; + if targets.is_empty() { + crate::ui::toast::show_toast( + &toast_overlay, + "No switchable MangoHud config targets were found", + ); + return; + } + show_switch_config_menu(&anchor, &targets); + }); + }); + } + + let conflict_badge = gtk4::Label::new(Some("No conflicts detected")); + conflict_badge.set_ellipsize(gtk4::pango::EllipsizeMode::End); + conflict_badge.set_width_chars(1); + conflict_badge.set_max_width_chars(20); + conflict_badge.add_css_class("dim-label"); + conflict_badge.add_css_class("shell-status-label"); + conflict_badge.add_css_class("shell-conflict-label"); + bar.append(&conflict_badge); + + let view_layers = gtk4::Button::with_label("View Layers"); + view_layers.add_css_class("shell-strip-button"); + let nav_clone = navigation_view.clone(); + view_layers.connect_clicked(move |_| { + navigate_to_tag(&nav_clone, "conflicts"); + }); + bar.append(&view_layers); + + refresh_layer_stack_widgets(&summary_label, &stack_summary, &conflict_badge, state); + + let summary_label_clone = summary_label.clone(); + let stack_summary_clone = stack_summary.clone(); + let conflict_badge_clone = conflict_badge.clone(); + let state_clone = state.clone(); + glib::timeout_add_seconds_local(5, move || { + refresh_layer_stack_widgets( + &summary_label_clone, + &stack_summary_clone, + &conflict_badge_clone, + &state_clone, + ); + glib::ControlFlow::Continue + }); + + LayerStackBarWidgets { + bar, + summary_label, + summary_shell: stack_summary, + conflict_badge, + } +} + +fn build_split_view( + navigation_view: &libadwaita::NavigationView, + ctx: &PageBuildContext, + settings: Option<&gio::Settings>, +) -> libadwaita::OverlaySplitView { + let split = libadwaita::OverlaySplitView::new(); + split.add_css_class("app-split-view"); + split.set_sidebar_width_fraction(0.18); + split.set_min_sidebar_width(156.0); + split.set_max_sidebar_width(208.0); + split.set_show_sidebar(true); + + let sidebar = build_sidebar(navigation_view, ctx, settings); + split.set_sidebar(Some(&sidebar)); + split.set_content(Some(navigation_view)); + split +} + +fn build_sidebar( + navigation_view: &libadwaita::NavigationView, + ctx: &PageBuildContext, + settings: Option<&gio::Settings>, +) -> gtk4::Box { + let shell = gtk4::Box::new(gtk4::Orientation::Vertical, 10); + shell.add_css_class("navigation-shell"); + + let search = gtk4::SearchEntry::new(); + search.set_placeholder_text(Some("Search pages")); + search.add_css_class("navigation-search"); + shell.append(&search); + + let sidebar = gtk4::ListBox::new(); + sidebar.add_css_class("navigation-sidebar"); + sidebar.set_selection_mode(gtk4::SelectionMode::Single); + sidebar.set_vexpand(true); + shell.append(&sidebar); + + let collapsed_sections = Rc::new(RefCell::new(default_collapsed_sections())); + + let mut last_section: Option<&'static str> = None; + for item in pages::SIDEBAR_ITEMS { + if last_section != Some(item.section) { + last_section = Some(item.section); + let section_row = gtk4::ListBoxRow::new(); + section_row.add_css_class("navigation-section-row"); + section_row.set_activatable(false); + section_row.set_selectable(false); + section_row.set_widget_name(&format!("section:{}", item.section)); + + let button = gtk4::Button::new(); + button.add_css_class("flat"); + button.add_css_class("navigation-section-button"); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.set_margin_start(12); + header.set_margin_end(12); + header.set_margin_top(12); + header.set_margin_bottom(4); + + let arrow = gtk4::Image::from_icon_name( + if collapsed_sections.borrow().contains(item.section) { + "pan-end-symbolic" + } else { + "pan-down-symbolic" + }, + ); + arrow.set_pixel_size(14); + arrow.add_css_class("dim-label"); + header.append(&arrow); + + let label = gtk4::Label::new(Some(item.section)); + label.add_css_class("heading"); + label.add_css_class("dim-label"); + label.set_xalign(0.0); + label.set_hexpand(true); + header.append(&label); + + button.set_child(Some(&header)); + section_row.set_child(Some(&button)); + sidebar.append(§ion_row); + + let list = sidebar.clone(); + let search = search.clone(); + let collapsed_sections = collapsed_sections.clone(); + let section_name = item.section.to_string(); + button.connect_clicked(move |_| { + { + let mut collapsed = collapsed_sections.borrow_mut(); + if collapsed.contains(§ion_name) { + *collapsed = all_sidebar_sections(); + collapsed.remove(§ion_name); + } else { + collapsed.insert(section_name.clone()); + } + } + refresh_sidebar_section_icons(&list, &collapsed_sections.borrow()); + let query = search.text().to_string().to_ascii_lowercase(); + filter_sidebar_rows(&list, &query, &collapsed_sections.borrow()); + }); + } + + let row = gtk4::ListBoxRow::new(); + row.add_css_class("navigation-row"); + row.set_widget_name(item.id); + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + row_box.add_css_class("navigation-row-box"); + row_box.set_margin_start(12); + row_box.set_margin_end(12); + row_box.set_margin_top(6); + row_box.set_margin_bottom(6); + + let icon = gtk4::Image::from_icon_name(item.icon_name); + icon.add_css_class("navigation-row-icon"); + icon.set_pixel_size(16); + row_box.append(&icon); + + let label = gtk4::Label::new(Some(item.title)); + label.add_css_class("navigation-row-label"); + label.set_xalign(0.0); + label.set_hexpand(true); + row_box.append(&label); + + row.set_child(Some(&row_box)); + sidebar.append(&row); + } + + let nav_clone = navigation_view.clone(); + let _settings = settings.cloned(); + sidebar.connect_row_activated(move |_, row| { + if !row.is_activatable() { + return; + } + let raw_id = row.widget_name(); + if raw_id.is_empty() { + return; + } + + let raw_id = raw_id.as_str(); + let Some(item) = pages::SIDEBAR_ITEMS.iter().find(|item| { + item.id.eq_ignore_ascii_case(raw_id) || item.title.eq_ignore_ascii_case(raw_id) + }) else { + return; + }; + let id = item.id; + + if let Some(page) = nav_clone.visible_page() { + if page.tag().as_deref() == Some(id) { + return; + } + } + + debug_log(&format!("nav: row activation raw='{raw_id}' mapped='{id}'")); + navigate_to_tag(&nav_clone, id); + }); + + { + let list = sidebar.clone(); + let collapsed_sections = collapsed_sections.clone(); + let navigation_view = navigation_view.clone(); + let page_ctx = Rc::new(ctx.clone()); + search.connect_search_changed(move |entry| { + let query = entry.text().to_string().to_ascii_lowercase(); + *page_ctx.current_search_query.borrow_mut() = query.clone(); + *page_ctx.pending_search_target.borrow_mut() = None; + filter_sidebar_rows(&list, &query, &collapsed_sections.borrow()); + if query.trim().is_empty() { + if navigation_view + .visible_page() + .and_then(|page| page.tag()) + .is_some_and(|tag| tag.as_str() == "search_results") + { + navigate_to_tag(&navigation_view, "overview"); + } + return; + } + + if navigation_view + .visible_page() + .and_then(|page| page.tag()) + .is_some_and(|tag| tag.as_str() == "search_results") + { + refresh_visible_page(&navigation_view, &page_ctx); + } else { + navigate_to_tag(&navigation_view, "search_results"); + } + }); + } + + filter_sidebar_rows(&sidebar, "", &collapsed_sections.borrow()); + refresh_sidebar_section_icons(&sidebar, &collapsed_sections.borrow()); + + shell +} + +fn build_navigation_view(ctx: &PageBuildContext) -> libadwaita::NavigationView { + let navigation_view = libadwaita::NavigationView::new(); + navigation_view.add_css_class("app-navigation-view"); + navigation_view.set_hexpand(true); + navigation_view.set_halign(gtk4::Align::Fill); + navigation_view.set_vexpand(true); + + for item in pages::SIDEBAR_ITEMS { + if let Some(page) = pages::build_navigation_page(item.id, ctx) { + navigation_view.add(&page); + debug_log(&format!( + "nav: added page id='{}' title='{}'", + item.id, item.title + )); + } + } + + if let Some(page) = pages::build_navigation_page("search_results", ctx) { + navigation_view.add(&page); + } + + if app_settings().is_some() { + let ctx = ctx.clone(); + navigation_view.connect_visible_page_notify(move |view| { + if let Some(page) = view.visible_page() { + if page.tag().is_some() { + refresh_visible_page(view, &ctx); + } + } + }); + } + + navigation_view +} + +fn navigate_to_tag(navigation_view: &libadwaita::NavigationView, tag: &str) { + if navigation_view.find_page(tag).is_none() { + debug_log(&format!("nav: requested missing tag '{tag}'")); + return; + } + navigation_view.replace_with_tags(&[tag]); +} + +fn refresh_visible_page(navigation_view: &libadwaita::NavigationView, ctx: &PageBuildContext) { + let Some(page) = navigation_view.visible_page() else { + return; + }; + let Some(tag) = page.tag() else { + return; + }; + let stable_width = ctx.parent_window.width(); + let stable_height = ctx.parent_window.height(); + let preserve_scroll = ctx.pending_search_target.borrow().is_none(); + let scroll_position = if preserve_scroll { + page.child() + .and_then(|child| child.downcast::().ok()) + .map(|scroll| scroll.vadjustment().value()) + } else { + None + }; + let Some(widget) = pages::build_page_widget(tag.as_str(), ctx) else { + return; + }; + ctx.option_rows.borrow_mut().clear(); + page.set_child(Some(&widget)); + + let window = ctx.parent_window.clone(); + let ctx_clone = ctx.clone(); + glib::idle_add_local_once(move || { + if stable_width > 0 && stable_height > 0 { + let (locked_width, locked_height) = + clamp_initial_window_size(stable_width, stable_height); + window.set_default_size(locked_width, locked_height); + } + if let Some(value) = scroll_position { + if let Some(child) = page.child() { + if let Ok(scroll) = child.downcast::() { + scroll.vadjustment().set_value(value); + } + } + } + pages::focus_pending_search_target(&ctx_clone); + }); +} + +fn filter_sidebar_rows(sidebar: >k4::ListBox, query: &str, collapsed_sections: &HashSet) { + let query = query.trim(); + let mut section_has_visible_items = false; + let mut current_section_row: Option = None; + let mut current_section_name: Option = None; + + let mut child = sidebar.first_child(); + while let Some(widget) = child { + let next = widget.next_sibling(); + let Some(row) = widget.downcast_ref::() else { + child = next; + continue; + }; + + if !row.is_activatable() { + if let Some(previous_section) = current_section_row.replace(row.clone()) { + previous_section.set_visible(section_has_visible_items || query.is_empty()); + } + current_section_name = row + .widget_name() + .strip_prefix("section:") + .map(ToString::to_string); + section_has_visible_items = false; + row.set_visible(query.is_empty()); + child = next; + continue; + } + + let id = row.widget_name(); + let item = pages::SIDEBAR_ITEMS + .iter() + .find(|item| item.id.eq_ignore_ascii_case(id.as_str())); + let matches = query.is_empty() + || item.is_some_and(|item| pages::sidebar_search_text(item).contains(query)); + let collapsed = current_section_name + .as_ref() + .is_some_and(|section| collapsed_sections.contains(section)); + let visible = if query.is_empty() { + matches && !collapsed + } else { + matches + }; + row.set_visible(visible); + section_has_visible_items |= visible; + child = next; + } + + if let Some(section_row) = current_section_row { + section_row.set_visible(section_has_visible_items || query.is_empty()); + } +} + +fn default_collapsed_sections() -> HashSet { + let mut collapsed = all_sidebar_sections(); + collapsed.remove("Start"); + collapsed +} + +fn all_sidebar_sections() -> HashSet { + pages::SIDEBAR_ITEMS + .iter() + .map(|item| item.section.to_string()) + .collect() +} + +fn refresh_sidebar_section_icons(sidebar: >k4::ListBox, collapsed_sections: &HashSet) { + let mut child = sidebar.first_child(); + while let Some(widget) = child { + let next = widget.next_sibling(); + let Some(row) = widget.downcast_ref::() else { + child = next; + continue; + }; + let section_name = row.widget_name(); + let Some(section) = section_name.strip_prefix("section:") else { + child = next; + continue; + }; + let Some(button) = row + .child() + .and_then(|child| child.downcast::().ok()) + else { + child = next; + continue; + }; + let Some(header) = button + .child() + .and_then(|child| child.downcast::().ok()) + else { + child = next; + continue; + }; + let Some(icon) = header + .first_child() + .and_then(|child| child.downcast::().ok()) + else { + child = next; + continue; + }; + icon.set_icon_name(Some(if collapsed_sections.contains(section) { + "pan-end-symbolic" + } else { + "pan-down-symbolic" + })); + child = next; + } +} + +fn restore_active_page( + navigation_view: &libadwaita::NavigationView, + settings: Option<&gio::Settings>, +) { + let _ = (navigation_view, settings); +} + +struct LayerStackSummary { + primary_label: String, + labels: Vec, + conflict_count: usize, +} + +async fn discover_layer_summary(active_path: Option) -> LayerStackSummary { + let mut labels = Vec::new(); + let mut conflict_count = 0; + let mut primary_label = None; + if let Ok(xdg) = XdgPaths::resolve() { + if let Ok(layers) = Resolver::discover(&xdg).await { + conflict_count = Resolver::find_conflicts(&layers).len(); + for layer in layers { + let label = format!( + "[{}] {}", + layer.priority, + Resolver::layer_label(&layer.source_type) + ); + if active_path.as_ref() == layer.path.as_ref() { + primary_label = Some(label.clone()); + } + labels.push(label); + } + } + + if labels.is_empty() && xdg.global_config.exists() { + let fallback = format!("[●] {} (global)", xdg.global_config.display()); + if active_path.as_ref() == Some(&xdg.global_config) { + primary_label = Some(fallback.clone()); + } + labels.push(fallback); + } + } + + if labels.is_empty() { + labels.push("Built-in MangoHud defaults only".to_string()); + } + + let primary_label = + primary_label.unwrap_or_else(|| labels.first().cloned().unwrap_or_default()); + + LayerStackSummary { + primary_label, + labels, + conflict_count, + } +} + +fn refresh_layer_stack_widgets( + summary_label: >k4::Label, + summary_shell: >k4::Button, + conflict_badge: >k4::Label, + state: &Arc>, +) { + let summary_label = summary_label.clone(); + let summary_shell = summary_shell.clone(); + let conflict_badge = conflict_badge.clone(); + let active_path = state + .lock() + .ok() + .and_then(|guard| guard.config.path.clone()); + glib::MainContext::default().spawn_local(async move { + let summary = discover_layer_summary(active_path).await; + summary_label.set_text(&summary.primary_label); + summary_shell.set_tooltip_text(Some(&format!( + "Detected MangoHud layer stack:\\n{}", + summary.labels.join("\n") + ))); + + if summary.conflict_count == 0 { + conflict_badge.set_text("No conflicts detected"); + conflict_badge.remove_css_class("shell-conflict-active"); + } else { + conflict_badge.set_text(&format!( + "{} conflict{} detected", + summary.conflict_count, + if summary.conflict_count == 1 { "" } else { "s" } + )); + conflict_badge.add_css_class("shell-conflict-active"); + } + }); +} + +#[allow(clippy::too_many_arguments)] +fn install_window_actions( + window: &libadwaita::ApplicationWindow, + state: &Arc>, + save_button: &libadwaita::SplitButton, + toast_overlay: &libadwaita::ToastOverlay, + navigation_view: &libadwaita::NavigationView, + reload_banner: &libadwaita::Banner, + page_ctx: &PageBuildContext, + layer_stack: &LayerStackBarWidgets, + settings: Option<&gio::Settings>, +) { + let navigate_action = + gio::SimpleAction::new("navigate-page", Some(&String::static_variant_type())); + { + let navigation_view = navigation_view.clone(); + navigate_action.connect_activate(move |_, parameter| { + let Some(parameter) = parameter else { + return; + }; + let Some(tag) = parameter.get::() else { + return; + }; + navigate_to_tag(&navigation_view, &tag); + }); + } + window.add_action(&navigate_action); + + let refresh_page_action = gio::SimpleAction::new("refresh-current-page", None); + { + let navigation_view = navigation_view.clone(); + let page_ctx = page_ctx.clone(); + refresh_page_action.connect_activate(move |_, _| { + refresh_visible_page(&navigation_view, &page_ctx); + }); + } + window.add_action(&refresh_page_action); + + let refresh_layer_stack_action = gio::SimpleAction::new("refresh-layer-stack", None); + { + let state = state.clone(); + let summary_label = layer_stack.summary_label.clone(); + let summary_shell = layer_stack.summary_shell.clone(); + let conflict_badge = layer_stack.conflict_badge.clone(); + refresh_layer_stack_action.connect_activate(move |_, _| { + refresh_layer_stack_widgets(&summary_label, &summary_shell, &conflict_badge, &state); + }); + } + window.add_action(&refresh_layer_stack_action); + + let save_action = gio::SimpleAction::new("save", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let settings = settings.cloned(); + save_action.connect_activate(move |_, _| { + let _ = save_current_config(&state, &save_button, &toast_overlay, settings.as_ref()); + }); + } + window.add_action(&save_action); + + let revert_action = gio::SimpleAction::new("revert", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + revert_action.connect_activate(move |_, _| { + if restore_saved_snapshot(&state) { + recompute_validation(&state); + refresh_save_button(&state, &save_button); + apply_preview_current_config(&page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); + crate::ui::toast::show_toast( + &toast_overlay, + "Discarded unsaved changes and restored the last saved state", + ); + } else { + crate::ui::toast::show_toast(&toast_overlay, "No unsaved changes to revert"); + } + }); + } + window.add_action(&revert_action); + + let undo_action = gio::SimpleAction::new("undo", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + undo_action.connect_activate(move |_, _| { + if restore_saved_snapshot(&state) { + recompute_validation(&state); + refresh_save_button(&state, &save_button); + apply_preview_current_config(&page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); + crate::ui::toast::show_toast( + &toast_overlay, + "Discarded unsaved changes and restored the last saved state", + ); + } else { + crate::ui::toast::show_toast(&toast_overlay, "Nothing to undo"); + } + }); + } + window.add_action(&undo_action); + + let redo_action = gio::SimpleAction::new("redo", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + redo_action.connect_activate(move |_, _| { + let mut changed = false; + if let Ok(mut state) = state.lock() { + if let Some(snapshot) = state.redo_snapshot.take() { + state.config = snapshot; + state.config.dirty = true; + state.dirty = true; + changed = true; + } + } + + if changed { + recompute_validation(&state); + refresh_save_button(&state, &save_button); + apply_preview_current_config(&page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); + crate::ui::toast::show_toast(&toast_overlay, "Redo applied"); + } else { + crate::ui::toast::show_toast(&toast_overlay, "Nothing to redo"); + } + }); + } + window.add_action(&redo_action); + + let reload_action = gio::SimpleAction::new("reload-config", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let settings = settings.cloned(); + let reload_banner = reload_banner.clone(); + let page_ctx = page_ctx.clone(); + reload_action.connect_activate(move |_, _| { + let _ = reload_config_from_disk( + &state, + &save_button, + &toast_overlay, + &page_ctx, + settings.as_ref(), + ); + reload_banner.set_revealed(false); + }); + } + window.add_action(&reload_action); + + let close_action = gio::SimpleAction::new("close-window", None); + { + let window_weak = window.downgrade(); + close_action.connect_activate(move |_, _| { + if let Some(window) = window_weak.upgrade() { + window.close(); + } + }); + } + window.add_action(&close_action); + + let shortcuts_action = gio::SimpleAction::new("shortcuts", None); + { + let window_weak = window.downgrade(); + shortcuts_action.connect_activate(move |_, _| { + if let Some(window) = window_weak.upgrade() { + show_shortcuts_window(&window); + } + }); + } + window.add_action(&shortcuts_action); + + let auto_backup_action = gio::SimpleAction::new_stateful( + "auto-backup-on-save", + None, + &setting_bool(settings, "auto-backup-on-save", true).to_variant(), + ); + { + let settings = settings.cloned(); + auto_backup_action.connect_change_state(move |action, value| { + let Some(value) = value.and_then(bool::from_variant) else { + return; + }; + action.set_state(&value.to_variant()); + if let Some(settings) = settings.as_ref() { + let _ = settings.set_boolean("auto-backup-on-save", value); + } + }); + } + window.add_action(&auto_backup_action); + + let backup_action = gio::SimpleAction::new("backup", None); + { + let state = state.clone(); + let toast_overlay = toast_overlay.clone(); + backup_action.connect_activate(move |_, _| match backup_current_config(&state) { + Ok(path) => crate::ui::toast::show_toast( + &toast_overlay, + &format!("Backup created: {}", path.display()), + ), + Err(err) => { + crate::ui::toast::show_toast(&toast_overlay, &format!("Backup failed: {err}")) + } + }); + } + window.add_action(&backup_action); + + let restore_backup_action = gio::SimpleAction::new("restore-backup", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + restore_backup_action.connect_activate(move |_, _| match restore_latest_backup_into_state( + &state, + ) { + Ok(path) => { + recompute_validation(&state); + refresh_save_button(&state, &save_button); + apply_preview_current_config(&page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); + crate::ui::toast::show_toast( + &toast_overlay, + &format!("Loaded safety backup from {}", path.display()), + ); + } + Err(err) => crate::ui::toast::show_toast( + &toast_overlay, + &format!("Restore safety backup failed: {err}"), + ), + }); + } + window.add_action(&restore_backup_action); + + let reset_defaults_action = gio::SimpleAction::new("reset-defaults", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let settings = settings.cloned(); + reset_defaults_action.connect_activate(move |_, _| { + if reset_config_to_defaults(&state, settings.as_ref()) { + recompute_validation(&state); + refresh_save_button(&state, &save_button); + apply_preview_current_config(&page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); + crate::ui::toast::show_toast( + &toast_overlay, + "Reset the current config and preview defaults", + ); + } else { + crate::ui::toast::show_toast(&toast_overlay, "Could not reset the current config"); + } + }); + } + window.add_action(&reset_defaults_action); + + let switch_target_action = + gio::SimpleAction::new("switch-config-target", Some(&String::static_variant_type())); + { + let window_weak = window.downgrade(); + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let settings = settings.cloned(); + switch_target_action.connect_activate(move |_, parameter| { + let Some(window) = window_weak.upgrade() else { + return; + }; + let Some(target_path) = parameter.and_then(|value| value.get::()) else { + return; + }; + let current_path = state + .lock() + .ok() + .and_then(|guard| guard.config.path.clone()); + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let settings = settings.clone(); + glib::MainContext::default().spawn_local(async move { + let targets = discover_switchable_config_targets(current_path.clone()).await; + let Some(target) = targets + .into_iter() + .find(|item| item.path.display().to_string() == target_path) + else { + crate::ui::toast::show_toast( + &toast_overlay, + "That config target is no longer available", + ); + return; + }; + switch_active_config_with_guard( + &window, + &state, + &save_button, + &toast_overlay, + &page_ctx, + settings.as_ref(), + target, + ); + }); + }); + } + window.add_action(&switch_target_action); + + let create_per_app_action = gio::SimpleAction::new("create-per-app-config", None); + { + let window_weak = window.downgrade(); + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let settings = settings.cloned(); + create_per_app_action.connect_activate(move |_, _| { + let Some(window) = window_weak.upgrade() else { + return; + }; + let window_for_create = window.clone(); + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let settings = settings.clone(); + show_create_per_app_dialog(&window, move |app_name| { + let Some(xdg) = XdgPaths::resolve().ok() else { + crate::ui::toast::show_toast( + &toast_overlay, + "Could not resolve the MangoHud config directory", + ); + return; + }; + + match Resolver::create_per_app_config(&app_name, &xdg) { + Ok(path) => { + let target = ConfigSwitchTarget { + label: format!("Per-app ({app_name})"), + subtitle: path.display().to_string(), + current: false, + path, + }; + switch_active_config_with_guard( + &window_for_create, + &state, + &save_button, + &toast_overlay, + &page_ctx, + settings.as_ref(), + target, + ); + } + Err(err) => crate::ui::toast::show_toast( + &toast_overlay, + &format!("Could not create per-app config: {err}"), + ), + } + }); + }); + } + window.add_action(&create_per_app_action); + + let delete_current_per_app_action = + gio::SimpleAction::new("delete-current-per-app-config", None); + { + let window_weak = window.downgrade(); + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let settings = settings.cloned(); + delete_current_per_app_action.connect_activate(move |_, _| { + let Some(window) = window_weak.upgrade() else { + return; + }; + delete_current_per_app_config_with_guard( + &window, + &state, + &save_button, + &toast_overlay, + &page_ctx, + settings.as_ref(), + ); + }); + } + window.add_action(&delete_current_per_app_action); + + let save_as_action = gio::SimpleAction::new("save-as", None); + { + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let settings = settings.cloned(); + let window_weak = window.downgrade(); + save_as_action.connect_activate(move |_, _| { + let Some(window) = window_weak.upgrade() else { + return; + }; + + let dialog = gtk4::FileDialog::builder() + .title("Save Config As") + .accept_label("Save") + .modal(true) + .initial_name("MangoHud.conf") + .build(); + + let initial_folder = state + .lock() + .ok() + .and_then(|guard| guard.config.path.clone()) + .and_then(|path| path.parent().map(|parent| parent.to_path_buf())) + .or_else(|| XdgPaths::resolve().ok().map(|xdg| xdg.mangohud_dir)); + if let Some(folder) = initial_folder { + let folder_file = gio::File::for_path(folder); + dialog.set_initial_folder(Some(&folder_file)); + } + + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let settings = settings.clone(); + glib::MainContext::default().spawn_local(async move { + match dialog.save_future(Some(&window)).await { + Ok(file) => { + let Some(path) = file.path() else { + crate::ui::toast::show_toast( + &toast_overlay, + "Selected save location has no local file path", + ); + return; + }; + + if let Ok(mut guard) = state.lock() { + guard.config.path = Some(path); + guard.config.dirty = true; + guard.dirty = true; + } + recompute_validation(&state); + let _ = save_current_config( + &state, + &save_button, + &toast_overlay, + settings.as_ref(), + ); + } + Err(err) => { + if !err.to_string().contains("Dismissed") { + crate::ui::toast::show_toast( + &toast_overlay, + &format!("Save As failed: {err}"), + ); + } + } + } + }); + }); + } + window.add_action(&save_as_action); + + let show_conflicts = gio::SimpleAction::new("show-conflicts", None); + { + let navigation_view = navigation_view.clone(); + show_conflicts.connect_activate(move |_, _| { + navigate_to_tag(&navigation_view, "conflicts"); + }); + } + window.add_action(&show_conflicts); + + let refresh_detection = gio::SimpleAction::new("refresh-detection", None); + { + let window_weak = window.downgrade(); + let toast_overlay = toast_overlay.clone(); + refresh_detection.connect_activate(move |_, _| { + let window_weak = window_weak.clone(); + let toast_overlay = toast_overlay.clone(); + glib::MainContext::default().spawn_local(async move { + match detect::detect_system().await { + Ok(info) => { + if let Some(window) = window_weak.upgrade() { + let title = if info.mangohud.installed { + "MangoTune" + } else { + "MangoTune (MangoHud not detected)" + }; + window.set_title(Some(title)); + } + crate::ui::toast::show_toast( + &toast_overlay, + &format!( + "Detection refreshed: display={:?}, gpu={:?}", + info.display_server, info.gpu.vendor + ), + ); + } + Err(err) => crate::ui::toast::show_toast( + &toast_overlay, + &format!("Refresh failed: {err}"), + ), + } + }); + }); + } + window.add_action(&refresh_detection); + + let about_action = gio::SimpleAction::new("about", None); + { + let window_weak = window.downgrade(); + about_action.connect_activate(move |_, _| { + if let Some(window) = window_weak.upgrade() { + show_about_dialog(&window); + } + }); + } + window.add_action(&about_action); +} + +fn install_close_guard( + window: &libadwaita::ApplicationWindow, + state: &Arc>, + preview: &PreviewController, + save_button: &libadwaita::SplitButton, + toast_overlay: &libadwaita::ToastOverlay, + settings: Option<&gio::Settings>, +) { + let bypass = Rc::new(Cell::new(false)); + let state = state.clone(); + let preview = preview.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let settings = settings.cloned(); + + window.connect_close_request(move |window| { + if bypass.get() { + bypass.set(false); + let _ = preview.stop(); + persist_window_geometry(window, settings.as_ref()); + return glib::Propagation::Proceed; + } + + let dirty = state.lock().map(|s| s.dirty).unwrap_or(false); + if !dirty { + let _ = preview.stop(); + persist_window_geometry(window, settings.as_ref()); + return glib::Propagation::Proceed; + } + + let body = format!( + "You have unsaved changes to {}. What would you like to do?", + active_config_label(&state) + ); + + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let settings = settings.clone(); + let bypass_for_async = bypass.clone(); + let win = window.clone(); + let win_for_discard = win.clone(); + let settings_for_discard = settings.clone(); + let bypass_for_discard = bypass_for_async.clone(); + let win_for_save = win.clone(); + let settings_for_save = settings.clone(); + let bypass_for_save = bypass_for_async.clone(); + let state_for_save = state.clone(); + let save_button_for_save = save_button.clone(); + let toast_overlay_for_save = toast_overlay.clone(); + show_unsaved_changes_dialog( + &win, + &body, + move || { + bypass_for_discard.set(true); + persist_window_geometry(&win_for_discard, settings_for_discard.as_ref()); + win_for_discard.close(); + }, + move || { + if save_current_config( + &state_for_save, + &save_button_for_save, + &toast_overlay_for_save, + settings_for_save.as_ref(), + ) { + bypass_for_save.set(true); + persist_window_geometry(&win_for_save, settings_for_save.as_ref()); + win_for_save.close(); + } + }, + ); + + glib::Propagation::Stop + }); +} + +fn show_unsaved_changes_dialog( + parent: &libadwaita::ApplicationWindow, + body: &str, + on_discard: FDiscard, + on_save: FSave, +) where + FDiscard: Fn() + 'static, + FSave: Fn() + 'static, +{ + let dialog = libadwaita::AlertDialog::builder() + .heading("Unsaved Changes") + .body(body) + .build(); + dialog.add_responses(&[ + ("save", "Save"), + ("cancel", "Cancel"), + ("discard", "Discard Changes"), + ]); + dialog.set_default_response(Some("save")); + dialog.set_close_response("cancel"); + dialog.set_response_appearance("save", libadwaita::ResponseAppearance::Suggested); + dialog.set_response_appearance("discard", libadwaita::ResponseAppearance::Destructive); + dialog.choose( + parent, + None::<&gio::Cancellable>, + move |response| match response.as_str() { + "save" => on_save(), + "discard" => on_discard(), + _ => {} + }, + ); +} + +#[derive(Clone, Debug)] +struct ConfigSwitchTarget { + label: String, + path: PathBuf, + subtitle: String, + current: bool, +} + +async fn discover_switchable_config_targets( + current_path: Option, +) -> Vec { + let Some(xdg) = XdgPaths::resolve().ok() else { + return Vec::new(); + }; + + let Ok(layers) = Resolver::discover(&xdg).await else { + return Vec::new(); + }; + + let current_path_ref = current_path.as_ref(); + let mut targets = Vec::new(); + for layer in layers { + let Some(path) = layer.path.clone() else { + continue; + }; + if !layer.is_editable { + continue; + } + if !matches!( + layer.source_type, + LayerSource::GlobalXdg | LayerSource::PerAppXdg(_) + ) { + continue; + } + targets.push(ConfigSwitchTarget { + label: Resolver::layer_label(&layer.source_type), + subtitle: path.display().to_string(), + current: current_path_ref == Some(&path), + path, + }); + } + + targets.sort_by(|a, b| a.label.cmp(&b.label)); + if let Some(global_idx) = targets + .iter() + .position(|target| target.label == "Saved global config") + { + let global = targets.remove(global_idx); + targets.insert(0, global); + } + targets +} + +fn show_switch_config_menu(anchor: >k4::Button, targets: &[ConfigSwitchTarget]) { + let current_target = targets.iter().find(|target| target.current); + let (popover, content) = build_compact_popover(); + popover.set_position(gtk4::PositionType::Bottom); + popover.set_parent(anchor); + + for target in targets { + let title = if target.label == "Saved global config" { + target.label.clone() + } else { + target + .path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| target.label.clone()) + }; + let row = build_compact_popover_row(&title, None, None, target.current); + row.set_tooltip_text(Some(&target.subtitle)); + let popover_clone = popover.clone(); + let anchor = anchor.clone(); + let target_value = target.path.display().to_string(); + row.connect_clicked(move |_| { + activate_window_action( + &anchor + .root() + .and_then(|root| root.downcast::().ok()) + .expect("window root"), + "win.switch-config-target", + Some(&target_value.to_variant()), + ); + popover_clone.popdown(); + }); + content.append(&row); + } + + content.append(&build_compact_popover_separator()); + + let create_row = build_compact_popover_row("Create New Per-App Config…", None, None, false); + { + let popover = popover.clone(); + let anchor = anchor.clone(); + create_row.connect_clicked(move |_| { + activate_window_action( + &anchor + .root() + .and_then(|root| root.downcast::().ok()) + .expect("window root"), + "win.create-per-app-config", + None, + ); + popover.popdown(); + }); + } + content.append(&create_row); + + if let Some(current) = current_target { + if current.label.starts_with("Per-app (") { + let delete_row = + build_compact_popover_row("Delete Current Per-App Config…", None, None, false); + let popover = popover.clone(); + let anchor = anchor.clone(); + delete_row.connect_clicked(move |_| { + activate_window_action( + &anchor + .root() + .and_then(|root| root.downcast::().ok()) + .expect("window root"), + "win.delete-current-per-app-config", + None, + ); + popover.popdown(); + }); + content.append(&delete_row); + } + } + + popover.connect_closed(|popover| { + popover.unparent(); + }); + popover.popup(); +} + +fn show_create_per_app_dialog( + parent: &libadwaita::ApplicationWindow, + on_create: impl Fn(String) + 'static, +) { + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + content.set_margin_top(8); + content.set_margin_bottom(4); + + let help = gtk4::Label::new(Some( + "Search by game title or type the executable directly. Click a result to fill the config name.", + )); + help.set_wrap(true); + help.set_xalign(0.0); + help.add_css_class("dim-label"); + content.append(&help); + + let entry = gtk4::Entry::new(); + entry.set_placeholder_text(Some("cs2, valheim, vkcube")); + content.append(&entry); + + let results_hint = gtk4::Label::new(Some( + "Start typing a game title or executable name to see matching suggestions.", + )); + results_hint.set_wrap(true); + results_hint.set_xalign(0.0); + results_hint.add_css_class("dim-label"); + content.append(&results_hint); + + let results_scroll = gtk4::ScrolledWindow::new(); + results_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + results_scroll.set_min_content_height(148); + results_scroll.add_css_class("config-hint-results-scroll"); + results_scroll.set_visible(false); + + let results_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + results_box.add_css_class("config-hint-results"); + results_scroll.set_child(Some(&results_box)); + content.append(&results_scroll); + + let error_label = gtk4::Label::new(None); + error_label.set_xalign(0.0); + error_label.add_css_class("error"); + error_label.set_visible(false); + content.append(&error_label); + + let refresh_results = { + let entry = entry.clone(); + let results_box = results_box.clone(); + let results_hint = results_hint.clone(); + let results_scroll = results_scroll.clone(); + Rc::new(move || { + while let Some(child) = results_box.first_child() { + results_box.remove(&child); + } + + let query = entry.text(); + let query = query.trim(); + if query.is_empty() { + results_scroll.set_visible(false); + results_hint.set_label( + "Start typing a game title or executable name to see matching suggestions.", + ); + results_hint.set_visible(true); + return; + } + + let matches = search_game_config_hints(query, 10); + if matches.is_empty() { + results_scroll.set_visible(false); + results_hint.set_label( + "No suggestions found. You can still create the config using the text you typed above.", + ); + results_hint.set_visible(true); + return; + } + + results_hint.set_visible(false); + results_scroll.set_visible(true); + + for hint in matches { + let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + row_box.add_css_class("config-hint-row"); + row_box.set_hexpand(true); + row_box.set_halign(gtk4::Align::Fill); + + let title = gtk4::Label::new(Some(&hint.title)); + title.set_xalign(0.0); + title.add_css_class("config-hint-title"); + + let mut subtitle_text = if hint.verification == "verified" { + "Verified executable hint".to_string() + } else { + "Heuristic executable hint".to_string() + }; + if let Some(appid) = hint.appid { + subtitle_text.push_str(&format!(" • AppID {appid}")); + } + let subtitle = gtk4::Label::new(Some(&subtitle_text)); + subtitle.set_xalign(0.0); + subtitle.set_wrap(true); + subtitle.add_css_class("config-hint-subtitle"); + + let candidate_row = gtk4::FlowBox::new(); + candidate_row.add_css_class("config-hint-candidates"); + candidate_row.set_selection_mode(gtk4::SelectionMode::None); + candidate_row.set_activate_on_single_click(false); + candidate_row.set_halign(gtk4::Align::Start); + candidate_row.set_row_spacing(6); + candidate_row.set_column_spacing(6); + let candidates = std::iter::once(hint.preferred.clone()) + .chain( + hint.candidates + .iter() + .filter(|candidate| candidate.as_str() != hint.preferred) + .cloned(), + ) + .take(4) + .collect::>(); + let unique_candidates = { + let mut seen = std::collections::HashSet::new(); + candidates + .into_iter() + .filter(|candidate| seen.insert(candidate.to_lowercase())) + .collect::>() + }; + for candidate in unique_candidates { + let chip = gtk4::Button::with_label(&candidate); + chip.add_css_class("flat"); + chip.add_css_class("config-hint-candidate"); + if candidate == hint.preferred { + chip.add_css_class("config-hint-candidate-primary"); + } + let entry = entry.clone(); + let candidate_value = candidate.clone(); + chip.connect_clicked(move |_| { + entry.set_text(&candidate_value); + }); + candidate_row.insert(&chip, -1); + } + + let alternatives = hint + .candidates + .iter() + .filter(|candidate| candidate.as_str() != hint.preferred) + .take(2) + .cloned() + .collect::>(); + let detail_text = if alternatives.is_empty() { + format!("Suggested config name: {}", hint.preferred) + } else { + format!("Suggested config name: {}.", hint.preferred) + }; + let details = gtk4::Label::new(Some(&detail_text)); + details.set_xalign(0.0); + details.set_wrap(true); + details.add_css_class("config-hint-subtitle"); + + row_box.append(&title); + row_box.append(&subtitle); + row_box.append(&candidate_row); + row_box.append(&details); + + results_box.append(&row_box); + } + }) + }; + refresh_results(); + { + let refresh_results = refresh_results.clone(); + entry.connect_changed(move |_| { + refresh_results(); + }); + } + + let dialog = libadwaita::AlertDialog::builder() + .heading("Create New Per-App Config") + .body("Create a MangoHud config for one executable or process name.") + .extra_child(&content) + .build(); + dialog.add_responses(&[("create", "Create"), ("cancel", "Cancel")]); + dialog.set_default_response(Some("create")); + dialog.set_close_response("cancel"); + dialog.set_response_appearance("create", libadwaita::ResponseAppearance::Suggested); + + let entry_for_response = entry.clone(); + let error_for_response = error_label.clone(); + dialog.connect_response(Some("create"), move |_dialog, response| { + if response != "create" { + return; + } + + let name = entry_for_response.text().trim().to_string(); + if name.is_empty() { + error_for_response.set_label("Enter an app name first."); + error_for_response.set_visible(true); + return; + } + + on_create(name); + }); + + dialog.present(Some(parent)); + glib::idle_add_local_once(move || { + entry.grab_focus(); + }); +} + +fn show_delete_per_app_dialog( + parent: &libadwaita::ApplicationWindow, + label: &str, + on_delete: impl Fn() + 'static, +) { + let dialog = libadwaita::AlertDialog::builder() + .heading("Delete Per-App Config?") + .body(format!( + "{label} will be removed from ~/.config/MangoHud and MangoTune will switch back to the saved global config." + )) + .build(); + dialog.add_responses(&[("delete", "Delete"), ("cancel", "Cancel")]); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + dialog.set_response_appearance("delete", libadwaita::ResponseAppearance::Destructive); + dialog.choose(parent, None::<&gio::Cancellable>, move |response| { + if response == "delete" { + on_delete(); + } + }); +} + +fn delete_current_per_app_config_with_guard( + parent: &libadwaita::ApplicationWindow, + state: &Arc>, + save_button: &libadwaita::SplitButton, + toast_overlay: &libadwaita::ToastOverlay, + page_ctx: &PageBuildContext, + settings: Option<&gio::Settings>, +) { + let Some(xdg) = XdgPaths::resolve().ok() else { + crate::ui::toast::show_toast( + toast_overlay, + "Could not resolve the MangoHud config directory", + ); + return; + }; + + let Some(current_path) = state + .lock() + .ok() + .and_then(|guard| guard.config.path.clone()) + else { + crate::ui::toast::show_toast(toast_overlay, "No active config target to remove"); + return; + }; + + if current_path == xdg.global_config { + crate::ui::toast::show_toast( + toast_overlay, + "The saved global config cannot be removed here", + ); + return; + } + + let Some(file_name) = current_path.file_name().and_then(|name| name.to_str()) else { + crate::ui::toast::show_toast(toast_overlay, "That config target cannot be removed"); + return; + }; + if file_name == "MangoHud.conf" { + crate::ui::toast::show_toast( + toast_overlay, + "The saved global config cannot be removed here", + ); + return; + } + + let label = format!( + "Per-app ({})", + current_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("unknown") + ); + let dirty = state.lock().map(|guard| guard.dirty).unwrap_or(false); + + let perform_delete: Rc = Rc::new({ + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let global_path = xdg.global_config.clone(); + let current_path = current_path.clone(); + let label = label.clone(); + let settings = settings.cloned(); + move || match std::fs::remove_file(¤t_path) { + Ok(_) => { + let _ = load_config_into_state( + &global_path, + "Saved global config", + &state, + &save_button, + &toast_overlay, + &page_ctx, + settings.as_ref(), + ); + crate::ui::toast::show_toast(&toast_overlay, &format!("Deleted {label}")); + } + Err(err) => crate::ui::toast::show_toast( + &toast_overlay, + &format!("Could not delete {label}: {err}"), + ), + } + }); + + if !dirty { + let parent = parent.clone(); + let perform_delete = perform_delete.clone(); + show_delete_per_app_dialog(&parent, &label, move || perform_delete()); + return; + } + + let body = format!( + "You have unsaved changes to {}. Save before deleting {}?", + active_config_label(state), + label + ); + + let parent_for_discard = parent.clone(); + let parent_for_save = parent.clone(); + let label_for_discard = label.clone(); + let label_for_save = label.clone(); + let state_for_save = state.clone(); + let save_button_for_save = save_button.clone(); + let toast_for_save = toast_overlay.clone(); + let settings_for_save = settings.cloned(); + let perform_delete_after_save = perform_delete.clone(); + let perform_delete_after_discard = perform_delete.clone(); + show_unsaved_changes_dialog( + parent, + &body, + move || { + let perform_delete = perform_delete_after_discard.clone(); + show_delete_per_app_dialog(&parent_for_discard, &label_for_discard, move || { + perform_delete() + }); + }, + move || { + if save_current_config( + &state_for_save, + &save_button_for_save, + &toast_for_save, + settings_for_save.as_ref(), + ) { + let perform_delete = perform_delete_after_save.clone(); + show_delete_per_app_dialog(&parent_for_save, &label_for_save, move || { + perform_delete() + }); + } + }, + ); +} + +fn switch_active_config_with_guard( + parent: &libadwaita::ApplicationWindow, + state: &Arc>, + save_button: &libadwaita::SplitButton, + toast_overlay: &libadwaita::ToastOverlay, + page_ctx: &PageBuildContext, + settings: Option<&gio::Settings>, + target: ConfigSwitchTarget, +) { + let current_path = state + .lock() + .ok() + .and_then(|guard| guard.config.path.clone()); + if current_path.as_ref() == Some(&target.path) { + crate::ui::toast::show_toast(toast_overlay, &format!("Already editing {}", target.label)); + return; + } + + let dirty = state.lock().map(|guard| guard.dirty).unwrap_or(false); + if !dirty { + let _ = load_config_into_state( + &target.path, + &target.label, + state, + save_button, + toast_overlay, + page_ctx, + settings, + ); + return; + } + + let body = format!( + "You have unsaved changes to {}. Save before switching the active config to {}?", + active_config_label(state), + target.label + ); + + let state_for_discard = state.clone(); + let save_button_for_discard = save_button.clone(); + let toast_for_discard = toast_overlay.clone(); + let page_ctx_for_discard = page_ctx.clone(); + let settings_for_discard = settings.cloned(); + let target_for_discard = target.clone(); + + let state_for_save = state.clone(); + let save_button_for_save = save_button.clone(); + let toast_for_save = toast_overlay.clone(); + let page_ctx_for_save = page_ctx.clone(); + let settings_for_save = settings.cloned(); + let target_for_save = target.clone(); + + show_unsaved_changes_dialog( + parent, + &body, + move || { + let _ = load_config_into_state( + &target_for_discard.path, + &target_for_discard.label, + &state_for_discard, + &save_button_for_discard, + &toast_for_discard, + &page_ctx_for_discard, + settings_for_discard.as_ref(), + ); + }, + move || { + if save_current_config( + &state_for_save, + &save_button_for_save, + &toast_for_save, + settings_for_save.as_ref(), + ) { + let _ = load_config_into_state( + &target_for_save.path, + &target_for_save.label, + &state_for_save, + &save_button_for_save, + &toast_for_save, + &page_ctx_for_save, + settings_for_save.as_ref(), + ); + } + }, + ); +} + +fn load_config_into_state( + path: &Path, + label: &str, + state: &Arc>, + save_button: &libadwaita::SplitButton, + toast_overlay: &libadwaita::ToastOverlay, + page_ctx: &PageBuildContext, + settings: Option<&gio::Settings>, +) -> bool { + match Parser::read(path) { + Ok(mut parsed) => { + normalize_legacy_option_values(&mut parsed); + if let Ok(mut guard) = state.lock() { + guard.config = parsed; + guard.saved_snapshot = guard.config.clone(); + guard.redo_snapshot = None; + guard.config.dirty = false; + guard.dirty = false; + guard.validation.clear(); + } + if let Some(settings) = settings { + let _ = settings.set_string("last-config-path", &path.display().to_string()); + } + recompute_validation(state); + refresh_save_button(state, save_button); + apply_preview_current_config(page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-layer-stack", + None, + ); + crate::ui::toast::show_toast( + toast_overlay, + &format!("Switched active config to {label}"), + ); + true + } + Err(err) => { + crate::ui::toast::show_toast(toast_overlay, &format!("Could not load {label}: {err}")); + false + } + } +} + +fn install_external_config_watcher( + state: &Arc>, + banner: &libadwaita::Banner, + toast_overlay: &libadwaita::ToastOverlay, + save_button: &libadwaita::SplitButton, + page_ctx: &PageBuildContext, + settings: Option<&gio::Settings>, +) -> Option { + let config_path = state.lock().ok()?.config.path.clone()?; + let watch_target = config_path.parent()?.to_path_buf(); + + let (sender, receiver) = mpsc::channel::<()>(); + { + let banner = banner.clone(); + glib::timeout_add_local(Duration::from_millis(300), move || { + let mut has_event = false; + while receiver.try_recv().is_ok() { + has_event = true; + } + if has_event { + banner.set_revealed(true); + } + glib::ControlFlow::Continue + }); + } + + let sender_for_watch = sender.clone(); + let state_for_watch = state.clone(); + let mut watcher = + notify::recommended_watcher(move |event_result: notify::Result| { + let Ok(event) = event_result else { + return; + }; + + if !matches!( + event.kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) { + return; + } + + let current_path = state_for_watch + .lock() + .ok() + .and_then(|guard| guard.config.path.clone()); + let Some(current_path) = current_path else { + return; + }; + + if event.paths.is_empty() || event.paths.iter().any(|path| path == ¤t_path) { + let _ = sender_for_watch.send(()); + } + }) + .ok()?; + + if watcher + .configure(NotifyConfig::default()) + .and_then(|_| watcher.watch(&watch_target, RecursiveMode::NonRecursive)) + .is_err() + { + return None; + } + + let state = state.clone(); + let save_button = save_button.clone(); + let toast_overlay = toast_overlay.clone(); + let page_ctx = page_ctx.clone(); + let settings = settings.cloned(); + let banner_clone = banner.clone(); + banner.connect_button_clicked(move |_| { + let _ = reload_config_from_disk( + &state, + &save_button, + &toast_overlay, + &page_ctx, + settings.as_ref(), + ); + banner_clone.set_revealed(false); + }); + + Some(watcher) +} + +fn backup_current_config(state: &Arc>) -> anyhow::Result { + let mut config = state + .lock() + .map_err(|_| anyhow::anyhow!("failed to lock app state"))? + .config + .clone(); + normalize_legacy_option_values(&mut config); + write_backup_snapshot(&config, "manual") +} + +fn restore_latest_backup_into_state(state: &Arc>) -> anyhow::Result { + let source = state + .lock() + .map_err(|_| anyhow::anyhow!("failed to lock app state"))? + .config + .path + .clone() + .ok_or_else(|| anyhow::anyhow!("no backing file path for current config"))?; + + let backup = latest_backup_for_path(&source, &["manual"])?; + let mut parsed = Parser::read(&backup)?; + normalize_legacy_option_values(&mut parsed); + parsed.path = Some(source); + parsed.dirty = true; + + let mut guard = state + .lock() + .map_err(|_| anyhow::anyhow!("failed to lock app state"))?; + guard.config = parsed; + guard.dirty = true; + guard.validation.clear(); + guard.redo_snapshot = None; + + Ok(backup) +} + +fn reset_config_to_defaults( + state: &Arc>, + settings: Option<&gio::Settings>, +) -> bool { + let Ok(mut guard) = state.lock() else { + return false; + }; + let path = guard.config.path.clone(); + guard.config = default_config_for_path(path); + guard.dirty = true; + guard.validation.clear(); + guard.redo_snapshot = None; + drop(guard); + reset_app_preferences_to_defaults(settings); + true +} + +fn default_config_for_path(path: Option) -> AnnotatedConfig { + let resolved_path = path.or_else(|| XdgPaths::resolve().ok().map(|xdg| xdg.global_config)); + let mut config = AnnotatedConfig { + lines: vec![ + ConfigLine::Comment("### MangoHud configuration - managed by MangoTune".to_string()), + ConfigLine::Comment("### Reset to MangoTune defaults".to_string()), + ConfigLine::Blank, + ], + options: indexmap::IndexMap::new(), + path: resolved_path, + dirty: true, + }; + + for (key, value) in mangotune_default_config_updates() { + Parser::set_value(&mut config, key, value); + } + + config +} + +fn mangotune_default_config_updates() -> Vec<(&'static str, mangotune::config::types::ConfigValue)> +{ + use mangotune::config::types::ConfigValue; + + vec![ + ("fps", ConfigValue::Flag), + ("frametime", ConfigValue::Flag), + ("frame_timing", ConfigValue::Disabled), + ("gpu_stats", ConfigValue::Flag), + ("gpu_temp", ConfigValue::Flag), + ("cpu_stats", ConfigValue::Flag), + ("cpu_temp", ConfigValue::Flag), + ("ram", ConfigValue::Flag), + ("vram", ConfigValue::Flag), + ("hud_compact", ConfigValue::Flag), + ("position", ConfigValue::Value("top-left".to_string())), + ("offset_x", ConfigValue::Value("0".to_string())), + ("offset_y", ConfigValue::Value("0".to_string())), + ("horizontal", ConfigValue::Disabled), + ("horizontal_stretch", ConfigValue::Disabled), + ("hud_no_margin", ConfigValue::Disabled), + ("text_outline", ConfigValue::Disabled), + ("background_alpha", ConfigValue::Value("0.28".to_string())), + ("alpha", ConfigValue::Value("1.00".to_string())), + ("round_corners", ConfigValue::Value("12".to_string())), + ("font_size", ConfigValue::Value("24".to_string())), + ("font_scale", ConfigValue::Value("1.00".to_string())), + ("width", ConfigValue::Value("340".to_string())), + ] +} + +fn reset_app_preferences_to_defaults(settings: Option<&gio::Settings>) { + let Some(settings) = settings else { + return; + }; + + for key in [ + "show-raw-editor", + "auto-backup-on-save", + "test-window-width", + "test-window-height", + "dock-test-windows", + "preview-scene", + "preview-studio-scene", + "preview-load", + "preview-fps-cap", + "preview-vsync", + "preview-vram-pressure", + "preview-particle-count", + "preview-particle-size", + "preview-gpu-passes", + "preview-interaction-steps", + ] { + settings.reset(key); + } +} + +fn apply_preview_current_config(page_ctx: &PageBuildContext) { + if page_ctx.preview.running_scene().is_none() { + return; + } + + let config = pages::current_config_snapshot(page_ctx); + let _ = page_ctx + .preview + .apply_live_config(&config) + .or_else(|_| page_ctx.preview.restart(&config)); +} + +fn reload_config_from_disk( + state: &Arc>, + save_button: &libadwaita::SplitButton, + toast_overlay: &libadwaita::ToastOverlay, + page_ctx: &PageBuildContext, + settings: Option<&gio::Settings>, +) -> bool { + let path = { + let Ok(state) = state.lock() else { + crate::ui::toast::show_toast(toast_overlay, "Could not acquire config state"); + return false; + }; + state.config.path.clone() + }; + + let Some(path) = path else { + crate::ui::toast::show_toast(toast_overlay, "No config path to reload"); + return false; + }; + + match Parser::read(&path) { + Ok(mut parsed) => { + normalize_legacy_option_values(&mut parsed); + if let Ok(mut state) = state.lock() { + state.config = parsed; + state.saved_snapshot = state.config.clone(); + state.redo_snapshot = None; + state.config.dirty = false; + state.dirty = false; + state.validation.clear(); + } + if let Some(settings) = settings { + let _ = settings.set_string("last-config-path", &path.display().to_string()); + } + recompute_validation(state); + refresh_save_button(state, save_button); + apply_preview_current_config(page_ctx); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-current-page", + None, + ); + let _ = gtk4::prelude::WidgetExt::activate_action( + &page_ctx.parent_window, + "win.refresh-layer-stack", + None, + ); + crate::ui::toast::show_toast(toast_overlay, "Reloaded config from disk"); + true + } + Err(err) => { + crate::ui::toast::show_toast(toast_overlay, &format!("Reload failed: {err}")); + false + } + } +} + +fn persist_window_geometry( + window: &libadwaita::ApplicationWindow, + settings: Option<&gio::Settings>, +) { + let Some(settings) = settings else { + return; + }; + + let width = window.width(); + let height = window.height(); + if width > 0 { + let _ = settings.set_int("window-width", width); + } + if height > 0 { + let _ = settings.set_int("window-height", height); + } +} + +fn active_config_label(state: &Arc>) -> String { + let Ok(state) = state.lock() else { + return "current config".to_string(); + }; + + state + .config + .path + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .map(ToString::to_string) + .unwrap_or_else(|| "current config".to_string()) +} + +fn restore_saved_snapshot(state: &Arc>) -> bool { + let Ok(mut state) = state.lock() else { + return false; + }; + if !state.dirty { + return false; + } + + state.redo_snapshot = Some(state.config.clone()); + state.config = state.saved_snapshot.clone(); + state.config.dirty = false; + state.dirty = false; + state.validation.clear(); + true +} + +fn show_shortcuts_window(parent: &libadwaita::ApplicationWindow) { + let window = gtk4::Window::builder() + .title("Keyboard Shortcuts") + .default_width(520) + .default_height(420) + .transient_for(parent) + .modal(true) + .build(); + window.add_css_class("preferences-shell"); + let (root, body, footer) = tool_page::build_utility_window_shell( + "Shortcuts", + "Keyboard Shortcuts", + "Global shortcuts available while MangoTune is focused.", + ); + + let group = tool_page::append_custom_section( + &body, + "Global shortcuts", + "These apply to the main MangoTune window while it is focused.", + None, + ); + + for (title, accelerator) in [ + ("Save", "Ctrl+S"), + ("Discard unsaved changes", "Ctrl+Z"), + ("Redo", "Ctrl+Shift+Z"), + ("Reload config", "Ctrl+R"), + ("Close window", "Ctrl+W"), + ("Refresh detection", "F5"), + ] { + let row = libadwaita::ActionRow::builder() + .title(title) + .subtitle(accelerator) + .build(); + row.add_css_class("preferences-row"); + group.add(&row); + } + + let close = gtk4::Button::with_label("Close"); + close.add_css_class("shell-strip-button"); + { + let window = window.clone(); + close.connect_clicked(move |_| { + window.close(); + }); + } + let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + footer.append(&spacer); + footer.append(&close); + + window.set_child(Some(&root)); + window.present(); +} + +fn show_about_dialog(window: &libadwaita::ApplicationWindow) { + let about = libadwaita::AboutDialog::builder() + .application_name("MangoTune") + .application_icon("com.mangotune.MangoTune") + .version(env!("CARGO_PKG_VERSION")) + .comments("A modern, accurate MangoHud configurator for Linux") + .license_type(gtk4::License::MitX11) + .website("https://github.com/your-org/mangotune") + .issue_url("https://github.com/your-org/mangotune/issues") + .developer_name("MangoTune Contributors") + .build(); + about.add_legal_section( + "MangoTune", + Some("Copyright (c) MangoTune Contributors"), + gtk4::License::MitX11, + None, + ); + about.add_legal_section( + "Third-Party Dependencies", + Some("Dependencies are licensed by their respective authors"), + gtk4::License::Unknown, + Some("See THIRD_PARTY_LICENSES.md in the project root for dependency license details."), + ); + about.present(Some(window)); +} + +pub(crate) fn app_settings() -> Option { + let schema_id = "com.mangotune.MangoTune"; + + if let Some(default_source) = gio::SettingsSchemaSource::default() { + if let Some(schema) = default_source.lookup(schema_id, true) { + return Some(gio::Settings::new_full( + &schema, + None::<&gio::SettingsBackend>, + None::<&str>, + )); + } + } + + let parent_source = gio::SettingsSchemaSource::default(); + let local_schema_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"); + if let Ok(source) = + gio::SettingsSchemaSource::from_directory(&local_schema_dir, parent_source.as_ref(), false) + { + if let Some(schema) = source.lookup(schema_id, true) { + return Some(gio::Settings::new_full( + &schema, + None::<&gio::SettingsBackend>, + None::<&str>, + )); + } + } + + None +} + +fn validation_errors(validation: &HashMap) -> Vec<(String, String)> { + validation + .iter() + .filter_map(|(key, result)| match result { + ValidationResult::Error(message) => Some((key.clone(), message.clone())), + ValidationResult::Warning(_) | ValidationResult::Ok => None, + }) + .collect() +} + +fn setting_bool(settings: Option<&gio::Settings>, key: &str, default: bool) -> bool { + settings.map(|s| s.boolean(key)).unwrap_or(default) +} + +fn create_backup_copy_for_path(source: &std::path::Path, label: &str) -> anyhow::Result { + let backup = backup_path_for(source, label); + std::fs::copy(source, &backup)?; + Ok(backup) +} + +fn write_backup_snapshot(config: &AnnotatedConfig, label: &str) -> anyhow::Result { + let source = config + .path + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no backing file path for current config"))?; + let backup = backup_path_for(source, label); + let mut backup_config = config.clone(); + backup_config.path = Some(backup.clone()); + backup_config.dirty = false; + Parser::write(&backup_config)?; + Ok(backup) +} + +fn backup_path_for(source: &std::path::Path, label: &str) -> PathBuf { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + PathBuf::from(format!("{}.{}-backup-{ts}.bak", source.display(), label)) +} + +fn latest_backup_for_path(source: &std::path::Path, labels: &[&str]) -> anyhow::Result { + let parent = source + .parent() + .ok_or_else(|| anyhow::anyhow!("backup search requires a parent directory"))?; + let file_name = source + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("source config has no valid file name"))?; + let prefix = format!("{file_name}."); + + let mut newest: Option<(std::time::SystemTime, PathBuf)> = None; + for entry in std::fs::read_dir(parent)? { + let entry = entry?; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with(&prefix) || !name.ends_with(".bak") { + continue; + } + if !labels.is_empty() + && !labels + .iter() + .any(|label| name.contains(&format!(".{label}-backup-"))) + { + continue; + } + let modified = entry + .metadata() + .and_then(|meta| meta.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + match &newest { + Some((current, _)) if modified <= *current => {} + _ => newest = Some((modified, path)), + } + } + + newest + .map(|(_, path)| path) + .ok_or_else(|| anyhow::anyhow!("no backup files found for {}", source.display())) +} + +#[cfg(test)] +mod tests { + use super::{ + default_collapsed_sections, default_config_for_path, latest_backup_for_path, + restore_saved_snapshot, AppState, + }; + use mangotune::config::normalize::normalize_legacy_option_values; + use mangotune::config::parser::Parser; + use mangotune::config::types::ConfigValue; + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; + + #[test] + fn normalizes_flag_zero_to_disabled() { + let mut config = Parser::parse_str("horizontal_stretch=0\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 1); + assert!(matches!( + config.options.get("horizontal_stretch").map(|item| &item.1), + Some(ConfigValue::Disabled) + )); + } + + #[test] + fn normalizes_flag_one_to_flag() { + let mut config = Parser::parse_str("horizontal_stretch=1\n", None); + let changed = normalize_legacy_option_values(&mut config); + assert_eq!(changed, 1); + assert!(matches!( + config.options.get("horizontal_stretch").map(|item| &item.1), + Some(ConfigValue::Flag) + )); + } + + #[test] + fn default_sidebar_collapses_deep_sections() { + let collapsed = default_collapsed_sections(); + assert!(collapsed.contains("Display")); + assert!(collapsed.contains("Appearance")); + assert!(collapsed.contains("Behavior")); + assert!(collapsed.contains("Advanced")); + assert!(collapsed.contains("Tools")); + assert!(!collapsed.contains("Start")); + } + + #[test] + fn restore_saved_snapshot_restores_and_tracks_redo() { + let saved = Parser::parse_str("fps\n", None); + let mut current = Parser::parse_str("frametime\n", None); + current.dirty = true; + + let state = Arc::new(Mutex::new(AppState { + config: current.clone(), + validation: HashMap::new(), + dirty: true, + saved_snapshot: saved.clone(), + redo_snapshot: None, + })); + + assert!(restore_saved_snapshot(&state)); + + let guard = state.lock().expect("state"); + assert_eq!(guard.config.options, saved.options); + assert!(!guard.dirty); + assert!(guard.redo_snapshot.is_some()); + assert_eq!( + guard.redo_snapshot.as_ref().map(|cfg| &cfg.options), + Some(¤t.options) + ); + } + + #[test] + fn default_config_uses_sane_dashboard_baseline() { + let config = default_config_for_path(Some(PathBuf::from("/tmp/MangoHud.conf"))); + assert!(matches!( + config.options.get("fps").map(|entry| &entry.1), + Some(ConfigValue::Flag) + )); + assert!(matches!( + config.options.get("gpu_stats").map(|entry| &entry.1), + Some(ConfigValue::Flag) + )); + assert!(matches!( + config.options.get("position").map(|entry| &entry.1), + Some(ConfigValue::Value(value)) if value == "top-left" + )); + } + + #[test] + fn latest_backup_prefers_manual_safety_backups() { + let dir = tempdir().expect("tempdir"); + let source = dir.path().join("MangoHud.conf"); + fs::write(&source, "fps\n").expect("source"); + + let manual = dir.path().join("MangoHud.conf.manual-backup-111.bak"); + let autosave = dir.path().join("MangoHud.conf.autosave-backup-999.bak"); + fs::write(&manual, "manual\n").expect("manual"); + fs::write(&autosave, "autosave\n").expect("autosave"); + + let picked = latest_backup_for_path(&source, &["manual"]).expect("manual backup"); + assert_eq!(picked, manual); + } +}