From c5136331b4b532b5754327810317f4d5ad92d4ef Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 2 Jul 2026 20:59:16 +0800 Subject: [PATCH] Initial commit --- .gitignore | 31 ++ Cargo.lock | 1025 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 26 + LICENSE | 21 + README.md | 103 ++++ bakery.toml | 12 + src/layout.rs | 248 ++++++++++ src/main.rs | 208 ++++++++ src/mirror.rs | 299 ++++++++++++ src/monitor.rs | 461 ++++++++++++++++++ src/profile.rs | 179 +++++++ src/ui/config_view.rs | 488 +++++++++++++++++++ src/ui/layout_view.rs | 332 +++++++++++++ src/ui/mirror_view.rs | 420 ++++++++++++++++ src/ui/mod.rs | 322 ++++++++++++ src/ui/profiles_view.rs | 381 +++++++++++++++ 16 files changed, 4556 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bakery.toml create mode 100644 src/layout.rs create mode 100644 src/main.rs create mode 100644 src/mirror.rs create mode 100644 src/monitor.rs create mode 100644 src/profile.rs create mode 100644 src/ui/config_view.rs create mode 100644 src/ui/layout_view.rs create mode 100644 src/ui/mirror_view.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/profiles_view.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36f7f5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Rust build artifacts +target/ + +# Editor and IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini + +# Environment and secrets +.env +.env.* +*.env +*.pem +*.key +*.p12 +secrets/ + +# Log files +*.log +logs/ + +# Runtime files +*.sock +*.pid diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0d7e384 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1025 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "breadmon" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "dirs", + "futures", + "ratatui", + "serde", + "serde_json", + "tokio", + "toml", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[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-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[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_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[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..c914e08 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "breadmon" +version = "0.1.0" +edition = "2021" +description = "TUI monitor manager for Hyprland" +license = "MIT" + +[[bin]] +name = "breadmon" +path = "src/main.rs" + +[dependencies] +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "signal", "sync", "time", "net", "io-util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +anyhow = "1" +dirs = "5" +futures = "0.3" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc02fc7 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# breadmon + +A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply changes live via `hyprctl`. + +## Requirements + +- Hyprland compositor (the `hyprctl` binary must be on `PATH`) +- Rust toolchain (to build from source) + +## Build + +``` +cargo build --release +``` + +The binary is written to `target/release/breadmon`. + +If you use the bread ecosystem, `bakery` can install it instead: + +``` +bread modules install /path/to/breadmon +``` + +## Usage + +``` +breadmon +``` + +The TUI opens with four tabs, switchable by number key or mouse click: + +| Key | Tab | +|-----|-----| +| `1` / F1 | Layout | +| `2` / F2 | Config | +| `3` / F3 | Mirror | +| `4` / F4 | Profiles | + +### Layout + +A scaled canvas showing all connected monitors. The selected monitor is highlighted. + +| Key | Action | +|-----|--------| +| `hjkl` / arrow keys | Move selected monitor 1 px | +| `Shift+hjkl` | Move selected monitor 10 px | +| `Tab` / `n` | Cycle to next monitor | +| `Shift+Tab` / `p` | Cycle to previous monitor | +| `0` | Auto-arrange monitors left-to-right with no gaps | +| `[` / `]` | Zoom canvas out / in | +| Mouse drag | Drag monitor to reposition | + +Monitors snap to each other's edges when dragged or nudged within 10 px. + +### Config + +Per-monitor settings for the currently selected display. + +| Key | Action | +|-----|--------| +| `jk` / `Tab` | Move between fields | +| `hl` / scroll | Cycle field value | +| `,` / `.` | Scale −0.1 / +0.1 | +| `[` / `]` | Switch to previous / next monitor | +| `Enter` | Commit scale edit and apply | +| `Esc` | Discard pending edits | + +Fields: Resolution, Refresh rate, Scale, Transform (rotation/flip), VRR, DPMS, Mirror of. + +The header shows the monitor's PPI and a suggested fractional scale when physical dimensions are available. + +### Mirror + +Finds the best common mode between two monitors and sets one to mirror the other. Use `Tab` to move focus between source and target, `hl` to cycle the selection, `Enter` to confirm. + +### Profiles + +Named snapshots of the current monitor configuration, stored as TOML files. + +| Key | Action | +|-----|--------| +| `jk` | Navigate profile list | +| `Enter` | Load selected profile | +| `d d` | Delete selected profile (double-press within 3 s) | +| `Tab` | Move focus to name input / save button | + +Profiles are saved to `~/.config/breadmon/profiles/`. + +### Global keys + +| Key | Action | +|-----|--------| +| `a` | Apply current configuration via `hyprctl` | +| `s` | Save current configuration as a profile | +| `r` | Refresh monitor list from Hyprland | +| `Ctrl+Z` | Undo last change (up to 20 steps) | +| `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) | + +breadmon also listens on Hyprland's event socket and reloads the monitor list automatically when a display is connected or disconnected. + +## Config + +Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file. diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..69a1c1a --- /dev/null +++ b/bakery.toml @@ -0,0 +1,12 @@ +name = "breadmon" +description = "TUI monitor manager for Hyprland" +binaries = ["breadmon"] +system_deps = [] +optional_system_deps = ["hyprland"] +bread_deps = [] + +[config] +dir = "~/.config/breadmon" + +[install] +post_install = [] diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..c19923a --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,248 @@ +use crate::monitor::Monitor; + +#[derive(Debug, Clone)] +pub struct LayoutState { + pub selected: usize, + pub snap_threshold: i32, + pub zoom: f32, +} + +impl Default for LayoutState { + fn default() -> Self { + Self { + selected: 0, + snap_threshold: 10, + zoom: 1.0, + } + } +} + +impl LayoutState { + pub fn clamp_selected(&mut self, count: usize) { + if count == 0 { + self.selected = 0; + } else if self.selected >= count { + self.selected = count - 1; + } + } + + pub fn next(&mut self, count: usize) { + if count > 0 { + self.selected = (self.selected + 1) % count; + } + } + + pub fn prev(&mut self, count: usize) { + if count > 0 { + self.selected = self.selected.checked_sub(1).unwrap_or(count - 1); + } + } +} + +/// Snap a monitor being moved to the edges of its neighbours. +/// Returns the adjusted (x, y) after snapping. +pub fn snap_position( + moving_idx: usize, + new_x: i32, + new_y: i32, + monitors: &[Monitor], + threshold: i32, +) -> (i32, i32) { + let mw = monitors[moving_idx].world_width() as i32; + let mh = monitors[moving_idx].world_height() as i32; + + // Candidate snaps: (snapped_x, snapped_y, priority) + // We collect the closest snap in each axis independently + let mut best_x_dist = threshold + 1; + let mut best_y_dist = threshold + 1; + let mut snap_x = new_x; + let mut snap_y = new_y; + + for (i, other) in monitors.iter().enumerate() { + if i == moving_idx { + continue; + } + let ox = other.x; + let oy = other.y; + let ow = other.world_width() as i32; + let oh = other.world_height() as i32; + + // X-axis edge pairs: + // moving left vs other left + let pairs_x = [ + (new_x, ox), // left aligned + (new_x, ox + ow), // moving left snaps to other right + (new_x + mw, ox), // moving right snaps to other left + (new_x + mw, ox + ow), // right aligned + ]; + for (ma, oa) in pairs_x { + let dist = (ma - oa).abs(); + if dist < best_x_dist { + best_x_dist = dist; + snap_x = new_x + (oa - ma); + } + } + + // Y-axis edge pairs + let pairs_y = [ + (new_y, oy), + (new_y, oy + oh), + (new_y + mh, oy), + (new_y + mh, oy + oh), + ]; + for (ma, oa) in pairs_y { + let dist = (ma - oa).abs(); + if dist < best_y_dist { + best_y_dist = dist; + snap_y = new_y + (oa - ma); + } + } + } + + (snap_x, snap_y) +} + +/// Move the selected monitor by (dx, dy) pixels, then snap. +pub fn move_selected(state: &LayoutState, monitors: &mut Vec, dx: i32, dy: i32) { + let idx = state.selected; + if idx >= monitors.len() { + return; + } + let new_x = monitors[idx].x + dx; + let new_y = monitors[idx].y + dy; + let (sx, sy) = snap_position(idx, new_x, new_y, monitors, state.snap_threshold); + monitors[idx].x = sx; + monitors[idx].y = sy; +} + +/// Place monitors in a left-to-right row with no gaps. +pub fn auto_arrange(monitors: &mut Vec) { + let mut cursor = 0i32; + for m in monitors.iter_mut() { + m.x = cursor; + m.y = 0; + cursor += m.world_width() as i32; + } +} + +/// Compute canvas scale: returns pixels-per-cell such that all monitors fit in (area_w, area_h) cells. +/// The 0.5 factor corrects for terminal cells being ~2:1 tall. +pub fn canvas_scale(monitors: &[Monitor], area_w: u16, area_h: u16, zoom: f32) -> f32 { + if monitors.is_empty() || area_w == 0 || area_h == 0 { + return 1.0; + } + let (min_x, min_y, max_x, max_y) = bounding_box(monitors); + let total_w = (max_x - min_x).max(1) as f32; + let total_h = (max_y - min_y).max(1) as f32; + + let sx = area_w as f32 / total_w; + let sy = area_h as f32 / total_h * 0.5; // cell-aspect correction + sx.min(sy) * zoom +} + +/// (min_x, min_y, max_x, max_y) in world coordinates +pub fn bounding_box(monitors: &[Monitor]) -> (i32, i32, i32, i32) { + let min_x = monitors.iter().map(|m| m.x).min().unwrap_or(0); + let min_y = monitors.iter().map(|m| m.y).min().unwrap_or(0); + let max_x = monitors.iter().map(|m| m.right_edge()).max().unwrap_or(1); + let max_y = monitors.iter().map(|m| m.bottom_edge()).max().unwrap_or(1); + (min_x, min_y, max_x, max_y) +} + +/// Map a canvas cell position back to world coordinates (inverse of world_to_canvas) +pub fn canvas_to_world( + col: u16, + row: u16, + scale: f32, + origin_x: i32, + origin_y: i32, + pad_x: u16, + pad_y: u16, +) -> (i32, i32) { + let wx = if col >= pad_x { + ((col - pad_x) as f32 / scale) as i32 + origin_x + } else { + origin_x - (((pad_x - col) as f32 / scale) as i32) + }; + let wy = if row >= pad_y { + ((row - pad_y) as f32 / (scale * 0.5)) as i32 + origin_y + } else { + origin_y - (((pad_y - row) as f32 / (scale * 0.5)) as i32) + }; + (wx, wy) +} + +/// Map a world coordinate to a canvas cell position +pub fn world_to_canvas( + wx: i32, + wy: i32, + scale: f32, + origin_x: i32, + origin_y: i32, + pad_x: u16, + pad_y: u16, +) -> (u16, u16) { + let cx = ((wx - origin_x) as f32 * scale) as u16 + pad_x; + let cy = ((wy - origin_y) as f32 * scale * 0.5) as u16 + pad_y; + (cx, cy) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::monitor::{Mode, Transform}; + + fn mon(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor { + Monitor { + name: name.into(), + description: String::new(), + active_mode: Mode { width: w, height: h, refresh: 60.0 }, + x, + y, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: None, + available_modes: vec![], + physical_width_mm: 0, + physical_height_mm: 0, + } + } + + #[test] + fn snap_to_adjacent_right_edge() { + let monitors = vec![ + mon("A", 1920, 1200, 0, 0), + mon("B", 1920, 1080, 1915, 0), // almost touching, 5px gap + ]; + // Moving B (index 1) at x=1915, A's right edge is 1920 + // snap should pull B to x=1920 + let (sx, _sy) = snap_position(1, 1915, 0, &monitors, 10); + assert_eq!(sx, 1920); + } + + #[test] + fn no_snap_beyond_threshold() { + let monitors = vec![ + mon("A", 1920, 1200, 0, 0), + mon("B", 1920, 1080, 1950, 0), // 30px gap, beyond threshold + ]; + let (sx, _sy) = snap_position(1, 1950, 0, &monitors, 10); + assert_eq!(sx, 1950); + } + + #[test] + fn auto_arrange_no_gaps() { + let mut monitors = vec![ + mon("A", 1920, 1200, 100, 50), + mon("B", 1280, 1024, 200, 100), + ]; + auto_arrange(&mut monitors); + assert_eq!(monitors[0].x, 0); + assert_eq!(monitors[0].y, 0); + assert_eq!(monitors[1].x, 1920); + assert_eq!(monitors[1].y, 0); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..57e3ba6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,208 @@ +mod layout; +mod mirror; +mod monitor; +mod profile; +mod ui; + +use std::io; + +use anyhow::Result; +use crossterm::{ + event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, + MouseEventKind, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::StreamExt; +use ratatui::{backend::CrosstermBackend, Terminal}; +use tokio::{ + sync::mpsc, + time::{interval, Duration}, +}; + +use ui::{AppState, StatusLevel}; + +fn hyprland_socket2_path() -> Option { + let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into()); + Some(format!("{}/hypr/{}/.socket2.sock", runtime, instance)) +} + +#[derive(Debug)] +enum AppEvent { + Key(crossterm::event::KeyEvent), + Mouse(crossterm::event::MouseEvent), + Resize(u16, u16), + Tick, + MonitorChange, +} + +#[tokio::main] +async fn main() -> Result<()> { + let monitors = monitor::load_monitors().await.unwrap_or_else(|e| { + eprintln!("Warning: could not load monitors: {}", e); + vec![] + }); + + // Terminal setup + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = run(&mut terminal, monitors).await; + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + terminal.show_cursor()?; + + result +} + +async fn run( + terminal: &mut Terminal>, + monitors: Vec, +) -> Result<()> { + let size = terminal.size()?; + let mut state = AppState::new(monitors, (size.width, size.height)); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Event reader task + let tx_events = tx.clone(); + tokio::spawn(async move { + let mut stream = EventStream::new(); + let mut tick = interval(Duration::from_secs(5)); + loop { + tokio::select! { + _ = tick.tick() => { + let _ = tx_events.send(AppEvent::Tick); + } + maybe_event = stream.next() => { + match maybe_event { + Some(Ok(Event::Key(key))) => { + let _ = tx_events.send(AppEvent::Key(key)); + } + Some(Ok(Event::Mouse(mouse))) => { + let _ = tx_events.send(AppEvent::Mouse(mouse)); + } + Some(Ok(Event::Resize(w, h))) => { + let _ = tx_events.send(AppEvent::Resize(w, h)); + } + None => break, + _ => {} + } + } + } + } + }); + + // Hyprland socket hotplug listener + let tx_hotplug = tx.clone(); + tokio::spawn(async move { + if let Some(sig) = hyprland_socket2_path() { + if let Ok(mut stream) = tokio::net::UnixStream::connect(&sig).await { + use tokio::io::AsyncBufReadExt; + let reader = tokio::io::BufReader::new(&mut stream); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.starts_with("monitoradded") + || line.starts_with("monitorremoved") + || line.starts_with("monitorscale") + { + let _ = tx_hotplug.send(AppEvent::MonitorChange); + } + } + } + } + }); + + loop { + state.tick_status(); + terminal.draw(|f| ui::render(f, &state))?; + + let event = rx.recv().await; + + match event { + None => break, + Some(AppEvent::Tick) => { + if let Ok(monitors) = monitor::load_monitors().await { + if !state.dirty { + state.monitors = monitors; + state.layout.clamp_selected(state.monitors.len()); + } + } + } + Some(AppEvent::MonitorChange) => { + if let Ok(monitors) = monitor::load_monitors().await { + state.monitors = monitors; + state.layout.clamp_selected(state.monitors.len()); + state.set_status("Monitor configuration changed.", StatusLevel::Info); + } + } + Some(AppEvent::Resize(w, h)) => { + state.terminal_size = (w, h); + } + Some(AppEvent::Mouse(mouse)) => { + if mouse.kind == MouseEventKind::Moved { + continue; + } + ui::handle_mouse(mouse, &mut state); + } + Some(AppEvent::Key(key)) => { + if key.kind != KeyEventKind::Press { + continue; + } + + match key.code { + crossterm::event::KeyCode::Char('a') => { + state.pending_apply = true; + state.set_status("Applying...", StatusLevel::Info); + } + crossterm::event::KeyCode::Char('s') => { + ui::layout_view::trigger_save(&mut state); + } + crossterm::event::KeyCode::Char('r') => { + match monitor::load_monitors().await { + Ok(monitors) => { + state.monitors = monitors; + state.layout.clamp_selected(state.monitors.len()); + state.dirty = false; + state.set_status("Monitors refreshed.", StatusLevel::Success); + } + Err(e) => { + state.set_status( + format!("Refresh failed: {}", e), + StatusLevel::Error, + ); + } + } + } + _ => { + if !ui::handle_key(key, &mut state) { + break; + } + } + } + } + } + + if state.pending_apply { + state.pending_apply = false; + match monitor::apply_monitors(&state.monitors).await { + Ok(()) => { + state.set_status("Applied.", StatusLevel::Success); + } + Err(e) => { + state.set_status(format!("Apply failed: {}", e), StatusLevel::Error); + } + } + } + } + + Ok(()) +} diff --git a/src/mirror.rs b/src/mirror.rs new file mode 100644 index 0000000..6485a58 --- /dev/null +++ b/src/mirror.rs @@ -0,0 +1,299 @@ +use std::collections::HashMap; + +use crate::monitor::{Mode, Monitor}; + +#[derive(Debug, Clone)] +pub struct MirrorResult { + pub source_mode: Mode, + pub mirror_mode: Mode, + pub refresh: f64, + pub ar_exact: bool, + pub ar_ratio: (u32, u32), +} + +fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { a } else { gcd(b, a % b) } +} + +fn reduced_ar(w: u32, h: u32) -> (u32, u32) { + let g = gcd(w, h); + (w / g, h / g) +} + +fn ratio_f64(ar: (u32, u32)) -> f64 { + ar.0 as f64 / ar.1 as f64 +} + +pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option { + let src_modes = &source.available_modes; + let tgt_modes = &target.available_modes; + + if src_modes.is_empty() || tgt_modes.is_empty() { + return None; + } + + // Group source modes by reduced AR + let mut src_by_ar: HashMap<(u32, u32), Vec<&Mode>> = HashMap::new(); + for m in src_modes { + src_by_ar.entry(reduced_ar(m.width, m.height)).or_default().push(m); + } + + #[derive(Debug)] + struct Candidate { + src_ar: (u32, u32), + tgt_ar: (u32, u32), + is_exact: bool, + } + + let mut candidates: Vec = Vec::new(); + + for t in tgt_modes { + let tgt_ar = reduced_ar(t.width, t.height); + let tgt_ratio = ratio_f64(tgt_ar); + + if src_by_ar.contains_key(&tgt_ar) { + // Check if we already have this exact pair + if !candidates.iter().any(|c| c.src_ar == tgt_ar && c.tgt_ar == tgt_ar && c.is_exact) { + candidates.push(Candidate { src_ar: tgt_ar, tgt_ar, is_exact: true }); + } + continue; + } + + // Approximate: check all source ARs within 5% + for &s_ar in src_by_ar.keys() { + let s_ratio = ratio_f64(s_ar); + if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05 { + if !candidates.iter().any(|c| c.src_ar == s_ar && c.tgt_ar == tgt_ar) { + candidates.push(Candidate { src_ar: s_ar, tgt_ar, is_exact: false }); + } + } + } + } + + if candidates.is_empty() { + return None; + } + + // Score each candidate: sum of best pixel counts on each side + // Exact beats approximate regardless of score + let mut best_score: u64 = 0; + let mut best_exact = false; + let mut best_src_ar = (0u32, 0u32); + let mut best_tgt_ar = (0u32, 0u32); + + for c in &candidates { + let src_max_px = src_by_ar[&c.src_ar] + .iter() + .map(|m| m.pixels()) + .max() + .unwrap_or(0); + let tgt_max_px = tgt_modes + .iter() + .filter(|m| reduced_ar(m.width, m.height) == c.tgt_ar) + .map(|m| m.pixels()) + .max() + .unwrap_or(0); + let score = src_max_px + tgt_max_px; + + let better = if c.is_exact && !best_exact { + true + } else if !c.is_exact && best_exact { + false + } else { + score > best_score + }; + + if better { + best_score = score; + best_exact = c.is_exact; + best_src_ar = c.src_ar; + best_tgt_ar = c.tgt_ar; + } + } + + if best_src_ar == (0, 0) { + return None; + } + + // Best source mode (highest pixels at winning AR) + let best_src_mode = src_by_ar[&best_src_ar] + .iter() + .max_by_key(|m| m.pixels()) + .copied()?; + + // Best target mode + let best_tgt_mode = tgt_modes + .iter() + .filter(|m| reduced_ar(m.width, m.height) == best_tgt_ar) + .max_by_key(|m| m.pixels())?; + + // Refresh matching + let src_refreshes: Vec = src_by_ar[&best_src_ar] + .iter() + .filter(|m| m.width == best_src_mode.width && m.height == best_src_mode.height) + .map(|m| m.refresh) + .collect(); + + let tgt_refreshes: Vec = tgt_modes + .iter() + .filter(|m| m.width == best_tgt_mode.width && m.height == best_tgt_mode.height) + .map(|m| m.refresh) + .collect(); + + // Exact common (within 0.01 Hz) + let exact_common: Vec = src_refreshes + .iter() + .filter(|&&sr| tgt_refreshes.iter().any(|&tr| (sr - tr).abs() < 0.01)) + .copied() + .collect(); + + let chosen_refresh = if !exact_common.is_empty() { + exact_common.iter().copied().fold(f64::NEG_INFINITY, f64::max) + } else { + // Near-match within 1 Hz + let near: Vec = src_refreshes + .iter() + .flat_map(|&sr| { + tgt_refreshes + .iter() + .filter(move |&&tr| (sr - tr).abs() <= 1.0) + .map(move |&tr| sr.min(tr)) + }) + .collect(); + + if !near.is_empty() { + near.iter().copied().fold(f64::NEG_INFINITY, f64::max) + } else { + // Fallback: max source refresh + src_refreshes.iter().copied().fold(f64::NEG_INFINITY, f64::max) + } + }; + + // Find actual mode structs within 0.1 Hz of chosen_refresh + let final_src = src_by_ar[&best_src_ar] + .iter() + .filter(|m| m.width == best_src_mode.width && m.height == best_src_mode.height) + .filter(|m| (m.refresh - chosen_refresh).abs() < 0.1) + .max_by_key(|m| m.pixels()) + .copied() + .or(Some(best_src_mode))?; + + let final_tgt = tgt_modes + .iter() + .filter(|m| m.width == best_tgt_mode.width && m.height == best_tgt_mode.height) + .filter(|m| (m.refresh - chosen_refresh).abs() < 0.1) + .max_by_key(|m| m.pixels()) + .or_else(|| { + // fallback: nearest refresh on target + tgt_modes + .iter() + .filter(|m| m.width == best_tgt_mode.width && m.height == best_tgt_mode.height) + .min_by(|a, b| { + let da = (a.refresh - chosen_refresh).abs(); + let db = (b.refresh - chosen_refresh).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + })?; + + Some(MirrorResult { + source_mode: final_src.clone(), + mirror_mode: final_tgt.clone(), + refresh: chosen_refresh, + ar_exact: best_exact, + ar_ratio: best_src_ar, + }) +} + +pub fn refresh_match_label(result: &MirrorResult) -> &'static str { + let diff = (result.source_mode.refresh - result.mirror_mode.refresh).abs(); + if diff < 0.01 { + "exact match" + } else if diff <= 1.0 { + "near match (≤1 Hz)" + } else { + "mismatch" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::monitor::{Transform}; + + fn make_monitor_with_modes(name: &str, modes: Vec) -> Monitor { + let active = modes[0].clone(); + Monitor { + name: name.into(), + description: String::new(), + active_mode: active, + x: 0, + y: 0, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: None, + available_modes: modes, + physical_width_mm: 0, + physical_height_mm: 0, + } + } + + fn m(w: u32, h: u32, r: f64) -> Mode { + Mode { width: w, height: h, refresh: r } + } + + #[test] + fn identical_mode_lists_exact_match() { + let modes = vec![m(1920, 1200, 60.0), m(1280, 800, 60.0)]; + let src = make_monitor_with_modes("eDP-1", modes.clone()); + let tgt = make_monitor_with_modes("HDMI-A-1", modes); + let result = find_mirror_modes(&src, &tgt).unwrap(); + assert!(result.ar_exact); + assert_eq!(result.source_mode.width, 1920); + assert_eq!(result.mirror_mode.width, 1920); + assert!((result.refresh - 60.0).abs() < 0.01); + } + + #[test] + fn different_ar_approx_match() { + // 16:9 source vs 16:10 target — AR ratio ~11% difference, exceeds 5% + // so this should fail to find a match (incompatible) + // Actually 16:10 ratio is 1.6, 16:9 is 1.777, diff/1.777 = 10% > 5% + let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]); + let tgt = make_monitor_with_modes("tgt", vec![m(1920, 1200, 60.0)]); + // These are >5% apart so should be None + let result = find_mirror_modes(&src, &tgt); + assert!(result.is_none()); + } + + #[test] + fn near_common_ar_within_5pct() { + // 16:9 = 1.7778, 17:9 = 1.8889, diff/1.7778 = ~6%, just over + // Let's use a case within 5%: 1920x1080 (16:9 = 1.7778) vs 2560x1440 (16:9 = 1.7778) + let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]); + let tgt = make_monitor_with_modes("tgt", vec![m(2560, 1440, 60.0)]); + let result = find_mirror_modes(&src, &tgt).unwrap(); + assert!(result.ar_exact); + } + + #[test] + fn near_refresh_match() { + let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]); + let tgt = make_monitor_with_modes("tgt", vec![m(1920, 1080, 59.94)]); + let result = find_mirror_modes(&src, &tgt).unwrap(); + assert!(result.ar_exact); + // chosen_refresh should be the min of the near pair = 59.94 + assert!((result.refresh - 59.94).abs() < 0.1); + } + + #[test] + fn incompatible_modes_returns_none() { + let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]); + let tgt = make_monitor_with_modes("tgt", vec![m(1024, 768, 60.0)]); + // 16:9 vs 4:3, difference >> 5% + let result = find_mirror_modes(&src, &tgt); + assert!(result.is_none()); + } +} diff --git a/src/monitor.rs b/src/monitor.rs new file mode 100644 index 0000000..ca047aa --- /dev/null +++ b/src/monitor.rs @@ -0,0 +1,461 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use tokio::process::Command; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawMonitor { + pub id: u32, + pub name: String, + pub description: String, + pub width: u32, + pub height: u32, + pub x: i32, + pub y: i32, + pub scale: f64, + pub refresh_rate: f64, + pub transform: u8, + pub available_modes: Vec, + pub physical_width: u32, + pub physical_height: u32, + pub dpms_status: bool, + pub vrr: bool, + pub mirror_of: String, + #[serde(default)] + pub disabled: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Mode { + pub width: u32, + pub height: u32, + pub refresh: f64, +} + +impl Mode { + pub fn parse(s: &str) -> Option { + // Format: "1920x1200@60.00Hz" + let s = s.trim_end_matches("Hz"); + let (res, refresh_str) = s.split_once('@')?; + let (w_str, h_str) = res.split_once('x')?; + Some(Mode { + width: w_str.parse().ok()?, + height: h_str.parse().ok()?, + refresh: refresh_str.parse().ok()?, + }) + } + + pub fn compact(&self) -> String { + format!("{}x{}@{:.2}", self.width, self.height, self.refresh) + } + + pub fn pixels(&self) -> u64 { + self.width as u64 * self.height as u64 + } +} + +impl std::fmt::Display for Mode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}x{}@{:.2}Hz", self.width, self.height, self.refresh) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transform { + Normal = 0, + R90 = 1, + R180 = 2, + R270 = 3, + Flipped = 4, + FlippedR90 = 5, + FlippedR180 = 6, + FlippedR270 = 7, +} + +impl Transform { + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Self::R90, + 2 => Self::R180, + 3 => Self::R270, + 4 => Self::Flipped, + 5 => Self::FlippedR90, + 6 => Self::FlippedR180, + 7 => Self::FlippedR270, + _ => Self::Normal, + } + } + + pub fn as_u8(self) -> u8 { + self as u8 + } + + pub fn label(self) -> &'static str { + match self { + Self::Normal => "Normal", + Self::R90 => "90°", + Self::R180 => "180°", + Self::R270 => "270°", + Self::Flipped => "Flipped", + Self::FlippedR90 => "Flipped 90°", + Self::FlippedR180 => "Flipped 180°", + Self::FlippedR270 => "Flipped 270°", + } + } + + pub fn all() -> &'static [Transform] { + &[ + Self::Normal, + Self::R90, + Self::R180, + Self::R270, + Self::Flipped, + Self::FlippedR90, + Self::FlippedR180, + Self::FlippedR270, + ] + } +} + +#[derive(Debug, Clone)] +pub struct Monitor { + pub name: String, + pub description: String, + pub active_mode: Mode, + pub x: i32, + pub y: i32, + pub scale: f64, + pub transform: Transform, + pub vrr: bool, + pub dpms: bool, + pub disabled: bool, + pub mirror_of: Option, + pub available_modes: Vec, + /// Physical dimensions in millimetres (0 if unknown) + pub physical_width_mm: u32, + pub physical_height_mm: u32, +} + +impl Monitor { + fn from_raw(raw: RawMonitor) -> Self { + let mut modes: Vec = raw + .available_modes + .iter() + .filter_map(|s| Mode::parse(s)) + .collect(); + // Sort descending by pixels then refresh for consistent ordering + modes.sort_by(|a, b| { + b.pixels() + .cmp(&a.pixels()) + .then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal)) + }); + modes.dedup_by(|a, b| a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01); + + let active_mode = Mode { + width: raw.width, + height: raw.height, + refresh: raw.refresh_rate, + }; + + let mirror_of = if raw.mirror_of.is_empty() || raw.mirror_of == "none" { + None + } else { + Some(raw.mirror_of) + }; + + Monitor { + name: raw.name, + description: raw.description, + active_mode, + x: raw.x, + y: raw.y, + scale: raw.scale, + transform: Transform::from_u8(raw.transform), + vrr: raw.vrr, + dpms: raw.dpms_status, + disabled: raw.disabled, + mirror_of, + available_modes: modes, + physical_width_mm: raw.physical_width, + physical_height_mm: raw.physical_height, + } + } + + /// Width in world coordinates (swapped for 90/270 transforms) + pub fn world_width(&self) -> u32 { + match self.transform { + Transform::R90 | Transform::R270 | Transform::FlippedR90 | Transform::FlippedR270 => { + self.active_mode.height + } + _ => self.active_mode.width, + } + } + + /// Height in world coordinates + pub fn world_height(&self) -> u32 { + match self.transform { + Transform::R90 | Transform::R270 | Transform::FlippedR90 | Transform::FlippedR270 => { + self.active_mode.width + } + _ => self.active_mode.height, + } + } + + pub fn right_edge(&self) -> i32 { + self.x + self.world_width() as i32 + } + + pub fn bottom_edge(&self) -> i32 { + self.y + self.world_height() as i32 + } + + /// Pixels-per-inch, or None if physical dimensions are unknown. + pub fn ppi(&self) -> Option { + if self.physical_width_mm == 0 || self.physical_height_mm == 0 { + return None; + } + let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt(); + let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt(); + Some(diag_px / (diag_mm / 25.4)) + } + + /// Suggested fractional scale based on PPI. + pub fn suggested_scale(&self) -> Option { + let ppi = self.ppi()?; + Some(if ppi < 110.0 { + 1.0 + } else if ppi < 150.0 { + 1.25 + } else if ppi < 200.0 { + 1.5 + } else { + 2.0 + }) + } + + /// Unique resolutions available (deduped WxH pairs) + pub fn unique_resolutions(&self) -> Vec<(u32, u32)> { + let mut seen = Vec::new(); + for m in &self.available_modes { + let pair = (m.width, m.height); + if !seen.contains(&pair) { + seen.push(pair); + } + } + seen + } + + /// Refresh rates available for a given WxH + pub fn refreshes_for(&self, width: u32, height: u32) -> Vec { + self.available_modes + .iter() + .filter(|m| m.width == width && m.height == height) + .map(|m| m.refresh) + .collect() + } +} + +pub async fn load_monitors() -> Result> { + let output = Command::new("hyprctl") + .args(["monitors", "all", "-j"]) + .output() + .await + .context("failed to run hyprctl monitors all -j")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let raw: Vec = + serde_json::from_str(&stdout).context("failed to parse hyprctl monitors JSON")?; + + // Hyprland reports mirrorOf as a numeric ID string when using `monitors all`. + // Resolve to monitor name so format_hypr_line emits the correct `mirror,`. + let id_to_name: std::collections::HashMap = + raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect(); + + Ok(raw + .into_iter() + .map(|mut r| { + if r.mirror_of != "none" && !r.mirror_of.is_empty() { + if let Some(name) = id_to_name.get(&r.mirror_of) { + r.mirror_of = name.clone(); + } + } + Monitor::from_raw(r) + }) + .collect()) +} + +pub fn format_hypr_line(m: &Monitor) -> String { + if let Some(src) = &m.mirror_of { + format!( + "monitor={},{},auto,{:.2},mirror,{}", + m.name, + m.active_mode.compact(), + m.scale, + src + ) + } else { + format!( + "monitor={},{},{}x{},{:.2},transform,{},vrr,{}", + m.name, + m.active_mode.compact(), + m.x, + m.y, + m.scale, + m.transform.as_u8(), + if m.vrr { 1 } else { 0 } + ) + } +} + +pub async fn apply_monitors(monitors: &[Monitor]) -> Result<()> { + // breadmon stores mirror_of on the slave; hl.monitor() wants mirror= on the source. + let mut mirror_slaves: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for m in monitors { + if let Some(src) = &m.mirror_of { + mirror_slaves.insert(src.as_str(), m.name.as_str()); + } + } + + let mut eval_stmts: Vec = Vec::new(); + let mut dpms_off: Vec = Vec::new(); + + for m in monitors { + if m.disabled { + continue; + } + if m.mirror_of.is_some() { + // Mirror slaves get no independent hl.monitor() call; only track DPMS. + if !m.dpms { + dpms_off.push(m.name.clone()); + } + continue; + } + + let mode = format!( + "{}x{}@{}", + m.active_mode.width, + m.active_mode.height, + (m.active_mode.refresh + 0.5) as u32 + ); + let position = format!("{}x{}", m.x, m.y); + let scale = format!("{:.2}", m.scale); + + let mut stmt = format!( + "hl.monitor({{ output = \"{}\", mode = \"{}\", position = \"{}\", scale = \"{}\", transform = {}, vrr = {}", + m.name, mode, position, scale, m.transform.as_u8(), m.vrr + ); + + if let Some(&slave) = mirror_slaves.get(m.name.as_str()) { + stmt.push_str(&format!(", mirror = \"{}\"", slave)); + } + + stmt.push_str(" })"); + eval_stmts.push(stmt); + + if !m.dpms { + dpms_off.push(m.name.clone()); + } + } + + if !eval_stmts.is_empty() { + let lua = eval_stmts.join("; "); + let output = Command::new("hyprctl") + .args(["eval", &lua]) + .output() + .await + .context("hyprctl eval failed")?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if stdout != "ok" { + return Err(anyhow::anyhow!("hyprctl: {}", stdout)); + } + } + + for name in dpms_off { + Command::new("hyprctl") + .args(["dispatch", "dpms", "off", &name]) + .output() + .await + .context("hyprctl dispatch dpms off failed")?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mode_parse_normal() { + let m = Mode::parse("1920x1200@60.00Hz").unwrap(); + assert_eq!(m.width, 1920); + assert_eq!(m.height, 1200); + assert!((m.refresh - 60.0).abs() < 0.01); + } + + #[test] + fn mode_parse_fractional() { + let m = Mode::parse("3840x2160@59.94Hz").unwrap(); + assert_eq!(m.width, 3840); + assert_eq!(m.height, 2160); + assert!((m.refresh - 59.94).abs() < 0.01); + } + + #[test] + fn mode_compact_roundtrip() { + let m = Mode { width: 1920, height: 1080, refresh: 60.0 }; + let s = m.compact(); + let m2 = Mode::parse(&format!("{}Hz", s)).unwrap(); + assert_eq!(m.width, m2.width); + assert_eq!(m.height, m2.height); + } + + #[test] + fn format_hypr_line_normal() { + let m = Monitor { + name: "eDP-1".into(), + description: String::new(), + active_mode: Mode { width: 1920, height: 1200, refresh: 60.0 }, + x: 0, + y: 0, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: None, + available_modes: vec![], + physical_width_mm: 0, + physical_height_mm: 0, + }; + assert_eq!( + format_hypr_line(&m), + "monitor=eDP-1,1920x1200@60.00,0x0,1.00,transform,0,vrr,0" + ); + } + + #[test] + fn format_hypr_line_mirror() { + let m = Monitor { + name: "HDMI-A-1".into(), + description: String::new(), + active_mode: Mode { width: 1920, height: 1080, refresh: 60.0 }, + x: 1920, + y: 0, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: Some("eDP-1".into()), + available_modes: vec![], + physical_width_mm: 0, + physical_height_mm: 0, + }; + assert_eq!( + format_hypr_line(&m), + "monitor=HDMI-A-1,1920x1080@60.00,auto,1.00,mirror,eDP-1" + ); + } +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..233aa50 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,179 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::monitor::{Mode, Monitor, Transform}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileMonitor { + pub name: String, + pub mode: String, + pub x: i32, + pub y: i32, + pub scale: f64, + pub transform: u8, + pub vrr: bool, + pub dpms: bool, + pub mirror_of: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileMeta { + pub name: String, + pub created: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub profile: ProfileMeta, + pub monitors: Vec, +} + +pub fn profiles_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("breadmon/profiles") +} + +pub fn save(profile: &Profile) -> Result<()> { + let dir = profiles_dir(); + std::fs::create_dir_all(&dir).context("failed to create profiles dir")?; + let path = dir.join(format!("{}.toml", profile.profile.name)); + let content = toml::to_string_pretty(profile).context("failed to serialize profile")?; + std::fs::write(&path, content).context("failed to write profile")?; + Ok(()) +} + +pub fn load(name: &str) -> Result { + let path = profiles_dir().join(format!("{}.toml", name)); + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read profile '{}'", name))?; + toml::from_str(&content).context("failed to parse profile TOML") +} + +pub fn list() -> Result> { + let dir = profiles_dir(); + if !dir.exists() { + return Ok(vec![]); + } + let mut names = Vec::new(); + for entry in std::fs::read_dir(&dir).context("failed to read profiles dir")? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + names.push(stem.to_owned()); + } + } + } + names.sort(); + Ok(names) +} + +pub fn delete(name: &str) -> Result<()> { + let path = profiles_dir().join(format!("{}.toml", name)); + std::fs::remove_file(&path) + .with_context(|| format!("failed to delete profile '{}'", name)) +} + +pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile { + let profile_monitors = monitors + .iter() + .map(|m| ProfileMonitor { + name: m.name.clone(), + mode: m.active_mode.compact(), + x: m.x, + y: m.y, + scale: m.scale, + transform: m.transform.as_u8(), + vrr: m.vrr, + dpms: m.dpms, + mirror_of: m.mirror_of.clone().unwrap_or_default(), + }) + .collect(); + + Profile { + profile: ProfileMeta { + name: name.to_owned(), + created: chrono_now(), + }, + monitors: profile_monitors, + } +} + +/// Apply a profile's settings onto a list of live monitors (matched by name). +/// Monitors not in the profile are left unchanged. +pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec) { + for pm in &profile.monitors { + if let Some(m) = monitors.iter_mut().find(|m| m.name == pm.name) { + if let Some(mode) = Mode::parse(&format!("{}Hz", pm.mode)) { + m.active_mode = mode; + } + m.x = pm.x; + m.y = pm.y; + m.scale = pm.scale; + m.transform = Transform::from_u8(pm.transform); + m.vrr = pm.vrr; + m.dpms = pm.dpms; + m.mirror_of = if pm.mirror_of.is_empty() { + None + } else { + Some(pm.mirror_of.clone()) + }; + } + } +} + +fn chrono_now() -> String { + // Simple ISO 8601 timestamp without pulling in chrono + // Uses date command; falls back to a placeholder if unavailable + std::process::Command::new("date") + .arg("+%Y-%m-%dT%H:%M:%SZ") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_owned()) + .unwrap_or_else(|| "unknown".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::monitor::{Mode, Transform}; + + fn make_monitor(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor { + Monitor { + name: name.into(), + description: String::new(), + active_mode: Mode { width: w, height: h, refresh: 60.0 }, + x, + y, + scale: 1.0, + transform: Transform::Normal, + vrr: false, + dpms: true, + disabled: false, + mirror_of: None, + available_modes: vec![], + physical_width_mm: 0, + physical_height_mm: 0, + } + } + + #[test] + fn profile_roundtrip() { + let monitors = vec![ + make_monitor("eDP-1", 1920, 1200, 0, 0), + make_monitor("HDMI-A-1", 1920, 1080, 1920, 0), + ]; + let profile = from_monitors("test", &monitors); + let serialized = toml::to_string_pretty(&profile).unwrap(); + let deserialized: Profile = toml::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.profile.name, "test"); + assert_eq!(deserialized.monitors.len(), 2); + assert_eq!(deserialized.monitors[0].name, "eDP-1"); + assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00"); + assert_eq!(deserialized.monitors[1].x, 1920); + } +} diff --git a/src/ui/config_view.rs b/src/ui/config_view.rs new file mode 100644 index 0000000..4ec4026 --- /dev/null +++ b/src/ui/config_view.rs @@ -0,0 +1,488 @@ +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::{ + monitor::{Monitor, Transform}, + ui::{AppState, StatusLevel}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigField { + Resolution, + Refresh, + Scale, + Transform, + Vrr, + Dpms, + MirrorOf, +} + +impl ConfigField { + const ALL: &'static [ConfigField] = &[ + Self::Resolution, + Self::Refresh, + Self::Scale, + Self::Transform, + Self::Vrr, + Self::Dpms, + Self::MirrorOf, + ]; + + fn label(self) -> &'static str { + match self { + Self::Resolution => "Resolution", + Self::Refresh => "Refresh", + Self::Scale => "Scale", + Self::Transform => "Transform", + Self::Vrr => "VRR", + Self::Dpms => "DPMS", + Self::MirrorOf => "Mirror of", + } + } +} + +#[derive(Debug, Default)] +pub struct ConfigState { + pub monitor_idx: usize, + pub focused: usize, // index into ConfigField::ALL + // Dropdown indices + pub res_idx: usize, + pub refresh_idx: usize, + pub transform_idx: usize, + pub mirror_idx: usize, // 0 = none, 1.. = monitor names + // Scale editing + pub scale_str: String, + pub scale_editing: bool, + // Cached lists + pub resolutions: Vec<(u32, u32)>, + pub refreshes: Vec, + pub mirror_options: Vec, // "(none)", then monitor names +} + +impl ConfigState { + pub fn sync_from_monitor(&mut self, idx: usize, monitors: &[Monitor]) { + if monitors.is_empty() { + return; + } + let idx = idx.min(monitors.len() - 1); + self.monitor_idx = idx; + let m = &monitors[idx]; + + self.resolutions = m.unique_resolutions(); + // Find current resolution index + self.res_idx = self + .resolutions + .iter() + .position(|&(w, h)| w == m.active_mode.width && h == m.active_mode.height) + .unwrap_or(0); + + self.update_refreshes(m); + + self.transform_idx = m.transform.as_u8() as usize; + self.scale_str = format!("{:.2}", m.scale); + self.scale_editing = false; + + // Mirror options + self.mirror_options = std::iter::once("(none)".to_owned()) + .chain( + monitors + .iter() + .filter(|other| other.name != m.name) + .map(|other| other.name.clone()), + ) + .collect(); + + self.mirror_idx = m + .mirror_of + .as_deref() + .and_then(|src| self.mirror_options.iter().position(|o| o == src)) + .unwrap_or(0); + } + + fn update_refreshes(&mut self, m: &Monitor) { + if let Some(&(w, h)) = self.resolutions.get(self.res_idx) { + self.refreshes = m.refreshes_for(w, h); + self.refresh_idx = self + .refreshes + .iter() + .position(|&r| (r - m.active_mode.refresh).abs() < 0.01) + .unwrap_or(0); + } + } + + fn current_field(&self) -> ConfigField { + ConfigField::ALL[self.focused.min(ConfigField::ALL.len() - 1)] + } + + fn next_field(&mut self) { + self.focused = (self.focused + 1) % ConfigField::ALL.len(); + } + + fn prev_field(&mut self) { + self.focused = self.focused.checked_sub(1).unwrap_or(ConfigField::ALL.len() - 1); + } +} + +pub fn handle_key(event: KeyEvent, state: &mut AppState) { + let cfg = &mut state.config; + + match event.code { + KeyCode::Char('j') | KeyCode::Down => { + cfg.scale_editing = false; + cfg.next_field(); + } + KeyCode::Char('k') | KeyCode::Up => { + cfg.scale_editing = false; + cfg.prev_field(); + } + KeyCode::Tab => { + cfg.scale_editing = false; + cfg.next_field(); + } + KeyCode::BackTab => { + cfg.scale_editing = false; + cfg.prev_field(); + } + // Navigate between monitors + KeyCode::Char('[') => { + let count = state.monitors.len(); + if count > 0 { + let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1); + state.layout.selected = new_idx; + state.config.sync_from_monitor(new_idx, &state.monitors); + } + } + KeyCode::Char(']') => { + let count = state.monitors.len(); + if count > 0 { + let new_idx = (state.config.monitor_idx + 1) % count; + state.layout.selected = new_idx; + state.config.sync_from_monitor(new_idx, &state.monitors); + } + } + KeyCode::Char('a') => { + apply_current(state); + } + KeyCode::Char('s') => { + crate::ui::layout_view::trigger_save(state); + } + KeyCode::Esc => { + state.config.scale_editing = false; + // Re-sync from live monitor to discard pending edits + let idx = state.config.monitor_idx; + state.config.sync_from_monitor(idx, &state.monitors); + } + KeyCode::Enter => { + if state.config.current_field() == ConfigField::Scale { + commit_scale(state); + } + apply_current(state); + } + _ => { + state.push_undo(); + handle_field_key(event, state); + } + } +} + +pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { + if state.monitors.is_empty() { + return; + } + let row = event.row; + // Content area starts at y=2 (after tab bar). + // Config view: header (Length(2)) at y=2-3, list block starts at y=4. + // List border top at y=4, items at y=5+. + let items_start_row = 5u16; + + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + if row >= items_start_row { + let field_idx = (row - items_start_row) as usize; + if field_idx < ConfigField::ALL.len() { + state.config.focused = field_idx; + } + } + } + MouseEventKind::ScrollUp => { + state.push_undo(); + let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE); + handle_field_key(fake_right, state); + } + MouseEventKind::ScrollDown => { + state.push_undo(); + let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE); + handle_field_key(fake_left, state); + } + _ => {} + } +} + +fn handle_field_key(event: KeyEvent, state: &mut AppState) { + let monitors_len = state.monitors.len(); + if monitors_len == 0 { + return; + } + let idx = state.config.monitor_idx.min(monitors_len - 1); + + match state.config.current_field() { + ConfigField::Resolution => match event.code { + KeyCode::Char('h') | KeyCode::Left => { + if state.config.res_idx > 0 { + state.config.res_idx -= 1; + let m = &state.monitors[idx]; + state.config.update_refreshes(m); + sync_mode_to_monitor(state, idx); + state.dirty = true; + } + } + KeyCode::Char('l') | KeyCode::Right => { + if state.config.res_idx + 1 < state.config.resolutions.len() { + state.config.res_idx += 1; + let m = &state.monitors[idx]; + state.config.update_refreshes(m); + sync_mode_to_monitor(state, idx); + state.dirty = true; + } + } + _ => {} + }, + ConfigField::Refresh => match event.code { + KeyCode::Char('h') | KeyCode::Left => { + if state.config.refresh_idx > 0 { + state.config.refresh_idx -= 1; + sync_mode_to_monitor(state, idx); + state.dirty = true; + } + } + KeyCode::Char('l') | KeyCode::Right => { + if state.config.refresh_idx + 1 < state.config.refreshes.len() { + state.config.refresh_idx += 1; + sync_mode_to_monitor(state, idx); + state.dirty = true; + } + } + _ => {} + }, + ConfigField::Scale => match event.code { + KeyCode::Char(',') => { + let s = state.monitors[idx].scale - 0.1; + state.monitors[idx].scale = (s * 100.0).round() / 100.0; + state.monitors[idx].scale = state.monitors[idx].scale.max(0.1); + state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); + state.dirty = true; + } + KeyCode::Char('.') => { + let s = state.monitors[idx].scale + 0.1; + state.monitors[idx].scale = (s * 100.0).round() / 100.0; + state.monitors[idx].scale = state.monitors[idx].scale.min(10.0); + state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); + state.dirty = true; + } + KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { + state.config.scale_editing = true; + state.config.scale_str.push(c); + } + KeyCode::Backspace => { + state.config.scale_str.pop(); + } + _ => {} + }, + ConfigField::Transform => match event.code { + KeyCode::Char('h') | KeyCode::Left => { + let all = Transform::all(); + state.config.transform_idx = state + .config + .transform_idx + .checked_sub(1) + .unwrap_or(all.len() - 1); + state.monitors[idx].transform = all[state.config.transform_idx]; + state.dirty = true; + } + KeyCode::Char('l') | KeyCode::Right => { + let all = Transform::all(); + state.config.transform_idx = (state.config.transform_idx + 1) % all.len(); + state.monitors[idx].transform = all[state.config.transform_idx]; + state.dirty = true; + } + _ => {} + }, + ConfigField::Vrr => match event.code { + KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { + state.monitors[idx].vrr = !state.monitors[idx].vrr; + state.dirty = true; + } + _ => {} + }, + ConfigField::Dpms => match event.code { + KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { + state.monitors[idx].dpms = !state.monitors[idx].dpms; + state.dirty = true; + } + _ => {} + }, + ConfigField::MirrorOf => match event.code { + KeyCode::Char('h') | KeyCode::Left => { + if state.config.mirror_idx > 0 { + state.config.mirror_idx -= 1; + sync_mirror_to_monitor(state, idx); + state.dirty = true; + } + } + KeyCode::Char('l') | KeyCode::Right => { + if state.config.mirror_idx + 1 < state.config.mirror_options.len() { + state.config.mirror_idx += 1; + sync_mirror_to_monitor(state, idx); + state.dirty = true; + } + } + _ => {} + }, + } +} + +fn sync_mode_to_monitor(state: &mut AppState, idx: usize) { + if let Some(&(w, h)) = state.config.resolutions.get(state.config.res_idx) { + if let Some(&r) = state.config.refreshes.get(state.config.refresh_idx) { + state.monitors[idx].active_mode.width = w; + state.monitors[idx].active_mode.height = h; + state.monitors[idx].active_mode.refresh = r; + } + } +} + +fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) { + let chosen = state.config.mirror_options.get(state.config.mirror_idx).cloned(); + state.monitors[idx].mirror_of = match chosen.as_deref() { + Some("(none)") | None => None, + Some(s) => Some(s.to_owned()), + }; +} + +fn commit_scale(state: &mut AppState) { + let idx = state.config.monitor_idx.min(state.monitors.len().saturating_sub(1)); + if let Ok(v) = state.config.scale_str.parse::() { + state.monitors[idx].scale = v.clamp(0.1, 10.0); + state.config.scale_str = format!("{:.2}", state.monitors[idx].scale); + state.dirty = true; + } + state.config.scale_editing = false; +} + +fn apply_current(state: &mut AppState) { + state.pending_apply = true; + state.set_status("Applying...", StatusLevel::Info); + state.dirty = false; +} + +pub fn render(f: &mut Frame, area: Rect, state: &AppState) { + if state.monitors.is_empty() { + f.render_widget( + Paragraph::new("No monitors detected.").style(Style::default().fg(Color::DarkGray)), + area, + ); + return; + } + + let idx = state.config.monitor_idx.min(state.monitors.len() - 1); + let m = &state.monitors[idx]; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(0)]) + .split(area); + + // Monitor header with PPI hint + let ppi_hint = match (m.ppi(), m.suggested_scale()) { + (Some(ppi), Some(scale)) => format!(" │ {:.0} PPI (suggested scale: {:.2})", ppi, scale), + _ => String::new(), + }; + let header = format!(" {} — {}{}", m.name, m.description, ppi_hint); + f.render_widget( + Paragraph::new(header).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + chunks[0], + ); + + let form_area = chunks[1]; + let row_height = 1u16; + let fields = ConfigField::ALL; + + let items: Vec = fields + .iter() + .enumerate() + .map(|(i, &field)| { + let is_focused = i == state.config.focused; + let value = field_value(field, state, m); + let label = format!(" {:12} {}", field.label(), value); + let style = if is_focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + .bg(Color::DarkGray) + } else { + Style::default().fg(Color::White) + }; + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect(); + + let _ = row_height; // used implicitly via ListItem heights + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Config "), + ); + + let mut list_state = ListState::default(); + list_state.select(Some(state.config.focused)); + f.render_stateful_widget(list, form_area, &mut list_state); +} + +fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String { + match field { + ConfigField::Resolution => { + if let Some(&(w, h)) = state.config.resolutions.get(state.config.res_idx) { + format!("{}x{} (h/l to change)", w, h) + } else { + format!("{}x{}", m.active_mode.width, m.active_mode.height) + } + } + ConfigField::Refresh => { + if let Some(&r) = state.config.refreshes.get(state.config.refresh_idx) { + format!("{:.2} Hz (h/l to change)", r) + } else { + format!("{:.2} Hz", m.active_mode.refresh) + } + } + ConfigField::Scale => { + if state.config.scale_editing { + format!("{}| (Enter to commit, ,/. for ±0.1)", state.config.scale_str) + } else { + format!("{} (,/. for ±0.1)", state.config.scale_str) + } + } + ConfigField::Transform => Transform::all() + [state.config.transform_idx.min(Transform::all().len() - 1)] + .label() + .to_owned(), + ConfigField::Vrr => { + if m.vrr { "ON".to_owned() } else { "OFF".to_owned() } + } + ConfigField::Dpms => { + if m.dpms { "ON".to_owned() } else { "OFF".to_owned() } + } + ConfigField::MirrorOf => state + .config + .mirror_options + .get(state.config.mirror_idx) + .cloned() + .unwrap_or_else(|| "(none)".to_owned()), + } +} diff --git a/src/ui/layout_view.rs b/src/ui/layout_view.rs new file mode 100644 index 0000000..ffa7131 --- /dev/null +++ b/src/ui/layout_view.rs @@ -0,0 +1,332 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, BorderType, Borders, Paragraph}, + Frame, +}; + +use crate::{ + layout::{auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, world_to_canvas}, + monitor::Monitor, + ui::{AppState, DragState, StatusLevel, Tab}, +}; + +pub fn handle_key(event: KeyEvent, state: &mut AppState) { + let shift = event.modifiers.contains(KeyModifiers::SHIFT); + let step = if shift { 10 } else { 1 }; + let count = state.monitors.len(); + + match event.code { + KeyCode::Char('h') | KeyCode::Left => { + state.push_undo(); + move_selected(&state.layout, &mut state.monitors, -step, 0); + state.dirty = true; + } + KeyCode::Char('l') | KeyCode::Right => { + state.push_undo(); + move_selected(&state.layout, &mut state.monitors, step, 0); + state.dirty = true; + } + KeyCode::Char('k') | KeyCode::Up => { + state.push_undo(); + move_selected(&state.layout, &mut state.monitors, 0, -step); + state.dirty = true; + } + KeyCode::Char('j') | KeyCode::Down => { + state.push_undo(); + move_selected(&state.layout, &mut state.monitors, 0, step); + state.dirty = true; + } + KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count), + KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count), + KeyCode::Char('[') => state.layout.zoom = (state.layout.zoom - 0.1).max(0.1), + KeyCode::Char(']') => state.layout.zoom = (state.layout.zoom + 0.1).min(5.0), + KeyCode::Char('0') => { + state.push_undo(); + auto_arrange(&mut state.monitors); + state.dirty = true; + } + KeyCode::Enter => { + state.config.sync_from_monitor(state.layout.selected, &state.monitors); + state.tab = Tab::Config; + } + _ => {} + } +} + +pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { + let col = event.column; + let row = event.row; + + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + let canvas = canvas_area(state.terminal_size); + if let Some(idx) = monitor_at(col, row, canvas, state) { + let (min_x, min_y, _, _) = bounding_box(&state.monitors); + let scale = canvas_scale_for(canvas, state); + let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + + // Push undo at drag start, not on every move + state.push_undo(); + state.layout.selected = idx; + state.drag_state = Some(DragState { + monitor_idx: idx, + origin_x: state.monitors[idx].x, + origin_y: state.monitors[idx].y, + click_world_x: wx, + click_world_y: wy, + }); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(ref drag) = state.drag_state { + let canvas = canvas_area(state.terminal_size); + let (min_x, min_y, _, _) = bounding_box(&state.monitors); + let scale = canvas_scale_for(canvas, state); + let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1); + + let idx = drag.monitor_idx; + let new_x = drag.origin_x + (wx - drag.click_world_x); + let new_y = drag.origin_y + (wy - drag.click_world_y); + let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold); + state.monitors[idx].x = sx; + state.monitors[idx].y = sy; + state.dirty = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + state.drag_state = None; + } + MouseEventKind::ScrollUp => { + let canvas = canvas_area(state.terminal_size); + if in_canvas(col, row, canvas) { + state.layout.zoom = (state.layout.zoom + 0.1).min(5.0); + } else { + state.layout.prev(state.monitors.len()); + } + } + MouseEventKind::ScrollDown => { + let canvas = canvas_area(state.terminal_size); + if in_canvas(col, row, canvas) { + state.layout.zoom = (state.layout.zoom - 0.1).max(0.1); + } else { + state.layout.next(state.monitors.len()); + } + } + _ => {} + } +} + +pub fn render(f: &mut Frame, area: Rect, state: &AppState) { + if state.monitors.is_empty() { + let msg = Paragraph::new("No monitors detected. Is Hyprland running?") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(msg, area); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area); + + render_canvas(f, chunks[0], state); + render_readout(f, chunks[1], state); +} + +fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) { + let monitors = &state.monitors; + let (min_x, min_y, _, _) = bounding_box(monitors); + let scale = canvas_scale_for(area, state); + + // Pre-compute which monitors are overlapping (shown with red borders) + let overlapping = overlapping_monitors(monitors); + + let selected = state.layout.selected; + let draw_order: Vec = (0..monitors.len()) + .filter(|&i| i != selected) + .chain(std::iter::once(selected)) + .collect(); + + for i in draw_order { + let m = &monitors[i]; + let (cx, cy) = world_to_canvas(m.x, m.y, scale, min_x, min_y, area.x + 1, area.y + 1); + let cw = ((m.world_width() as f32 * scale) as u16).max(4); + let ch = ((m.world_height() as f32 * scale * 0.5) as u16).max(2); + + let cw = cw.min(area.width.saturating_sub(cx.saturating_sub(area.x))); + let ch = ch.min(area.height.saturating_sub(cy.saturating_sub(area.y))); + if cw == 0 || ch == 0 { + continue; + } + + let rect = Rect { x: cx, y: cy, width: cw, height: ch }; + + let is_selected = i == selected; + let is_dragging = state.drag_state.as_ref().map(|d| d.monitor_idx == i).unwrap_or(false); + let is_overlapping = overlapping[i]; + + let border_style = if is_dragging { + Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD) + } else if is_overlapping { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else if is_selected { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Blue) + }; + + let label = format!(" {} ", m.name); + let mode_str = format!(" {}@{:.0}Hz ", m.active_mode.width, m.active_mode.refresh); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(if is_selected || is_dragging || is_overlapping { + BorderType::Thick + } else { + BorderType::Rounded + }) + .border_style(border_style) + .title(Span::styled(&label, border_style)) + .title_bottom(Span::styled(&mode_str, Style::default().fg(Color::DarkGray))); + + f.render_widget(block, rect); + } +} + +fn render_readout(f: &mut Frame, area: Rect, state: &AppState) { + if state.monitors.is_empty() { + return; + } + let idx = state.layout.selected.min(state.monitors.len() - 1); + let m = &state.monitors[idx]; + + let mirror_info = m.mirror_of.as_ref() + .map(|src| format!(" mirror:{}", src)) + .unwrap_or_default(); + + let overlap_warn = if overlapping_monitors(&state.monitors)[idx] { + " ⚠ OVERLAP" + } else { + "" + }; + + let drag_hint = if state.drag_state.is_some() { " [dragging]" } else { "" }; + + let text = format!( + " {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}", + m.name, m.x, m.y, + m.active_mode.width, m.active_mode.height, m.active_mode.refresh, + m.scale, + mirror_info, overlap_warn, drag_hint, + ); + f.render_widget( + Paragraph::new(text).style(Style::default().fg(Color::Cyan)), + area, + ); +} + +/// Returns a bool per monitor: true if it overlaps any other monitor. +fn overlapping_monitors(monitors: &[Monitor]) -> Vec { + let mut flags = vec![false; monitors.len()]; + for i in 0..monitors.len() { + for j in (i + 1)..monitors.len() { + let a = &monitors[i]; + let b = &monitors[j]; + if a.x < b.right_edge() && a.right_edge() > b.x + && a.y < b.bottom_edge() && a.bottom_edge() > b.y + { + flags[i] = true; + flags[j] = true; + } + } + } + flags +} + +/// Canvas rect from terminal size. +/// Tab bar = y 0-1 (height 2), bottom bar = last row (height 1), readout = 1 row inside content. +pub fn canvas_area(terminal_size: (u16, u16)) -> Rect { + let (tw, th) = terminal_size; + Rect { + x: 0, + y: 2, + width: tw, + height: th.saturating_sub(4), + } +} + +fn in_canvas(col: u16, row: u16, canvas: Rect) -> bool { + col >= canvas.x + 1 + && col < canvas.x + canvas.width.saturating_sub(1) + && row >= canvas.y + 1 + && row < canvas.y + canvas.height.saturating_sub(1) +} + +fn canvas_scale_for(area: Rect, state: &AppState) -> f32 { + let w = area.width.saturating_sub(2); + let h = area.height.saturating_sub(2); + canvas_scale(&state.monitors, w, h, state.layout.zoom) +} + +/// Hit-test: which monitor (if any) is at canvas position (col, row)? +fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option { + if state.monitors.is_empty() { + return None; + } + let monitors = &state.monitors; + let (min_x, min_y, _, _) = bounding_box(monitors); + let scale = canvas_scale_for(canvas, state); + let pad_x = canvas.x + 1; + let pad_y = canvas.y + 1; + + // Check selected first (renders on top) + let selected = state.layout.selected; + let order: Vec = std::iter::once(selected) + .chain((0..monitors.len()).filter(|&i| i != selected)) + .collect(); + + for i in order { + let m = &monitors[i]; + let (cx, cy) = world_to_canvas(m.x, m.y, scale, min_x, min_y, pad_x, pad_y); + let cw = ((m.world_width() as f32 * scale) as u16).max(4); + let ch = ((m.world_height() as f32 * scale * 0.5) as u16).max(2); + if col >= cx && col < cx + cw && row >= cy && row < cy + ch { + return Some(i); + } + } + None +} + +pub fn trigger_save(state: &mut AppState) { + use crate::monitor::format_hypr_line; + use std::path::PathBuf; + + let path: PathBuf = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default())) + .join("hypr/monitors.conf"); + + let is_new = !path.exists(); + let mut lines = vec!["# Generated by breadmon — do not edit by hand".to_owned()]; + for m in &state.monitors { + lines.push(format_hypr_line(m)); + } + let content = lines.join("\n") + "\n"; + + match std::fs::write(&path, &content) { + Ok(()) => { + if is_new { + state.set_status( + format!("Saved. Add: source = {} to hyprland.conf", path.display()), + StatusLevel::Success, + ); + } else { + state.set_status(format!("Saved to {}", path.display()), StatusLevel::Success); + } + state.dirty = false; + } + Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error), + } +} diff --git a/src/ui/mirror_view.rs b/src/ui/mirror_view.rs new file mode 100644 index 0000000..b593dd1 --- /dev/null +++ b/src/ui/mirror_view.rs @@ -0,0 +1,420 @@ +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::{ + mirror::{find_mirror_modes, refresh_match_label, MirrorResult}, + ui::{AppState, StatusLevel}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MirrorField { + Source, + Target, + Compute, + Apply, + Cancel, +} + +#[derive(Debug, Default)] +pub struct MirrorState { + pub source_idx: usize, + pub target_idx: usize, + pub result: Option, + pub focused: usize, // index into FIELDS +} + +const FIELDS: &[MirrorField] = &[ + MirrorField::Source, + MirrorField::Target, + MirrorField::Compute, + MirrorField::Apply, + MirrorField::Cancel, +]; + +impl MirrorState { + fn current_field(&self) -> MirrorField { + FIELDS[self.focused.min(FIELDS.len() - 1)] + } + + fn next_field(&mut self) { + // Skip Apply/Cancel if no result yet + let mut next = (self.focused + 1) % FIELDS.len(); + if self.result.is_none() && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) { + next = 0; + } + self.focused = next; + } + + fn prev_field(&mut self) { + let len = FIELDS.len(); + let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1); + if self.result.is_none() && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) { + prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + } + self.focused = prev; + } + + /// Ensure source and target are different + pub fn fix_indices(&mut self, count: usize) { + if count < 2 { + return; + } + if self.source_idx == self.target_idx { + self.target_idx = (self.source_idx + 1) % count; + } + } +} + +pub fn handle_key(event: KeyEvent, state: &mut AppState) { + let count = state.monitors.len(); + if count < 2 { + return; + } + + match event.code { + KeyCode::Tab | KeyCode::Char('j') | KeyCode::Down => { + state.mirror.next_field(); + } + KeyCode::BackTab | KeyCode::Char('k') | KeyCode::Up => { + state.mirror.prev_field(); + } + KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() { + MirrorField::Source => { + state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.fix_indices(count); + state.mirror.result = None; + } + MirrorField::Target => { + state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.fix_indices(count); + state.mirror.result = None; + } + _ => {} + }, + KeyCode::Char('l') | KeyCode::Right => match state.mirror.current_field() { + MirrorField::Source => { + state.mirror.source_idx = (state.mirror.source_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; + } + MirrorField::Target => { + state.mirror.target_idx = (state.mirror.target_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; + } + _ => {} + }, + KeyCode::Enter => match state.mirror.current_field() { + MirrorField::Compute => { + let src = &state.monitors[state.mirror.source_idx]; + let tgt = &state.monitors[state.mirror.target_idx]; + match find_mirror_modes(src, tgt) { + Some(result) => { + state.mirror.result = Some(result); + // Move focus to Apply + state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + } + None => { + state.set_status( + "No compatible modes found between these monitors.", + StatusLevel::Error, + ); + } + } + } + MirrorField::Apply => { + if let Some(result) = state.mirror.result.clone() { + state.push_undo(); + let src_name = state.monitors[state.mirror.source_idx].name.clone(); + let tgt_idx = state.mirror.target_idx; + + state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); + state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); + + state.dirty = true; + state.mirror.result = None; + state.mirror.focused = 0; + state.set_status( + format!( + "Mirror set: {} → {} at {}", + src_name, + state.monitors[tgt_idx].name, + result.mirror_mode + ), + StatusLevel::Success, + ); + } + } + MirrorField::Cancel => { + state.mirror.result = None; + state.mirror.focused = 0; + } + _ => {} + }, + KeyCode::Esc => { + state.mirror.result = None; + state.mirror.focused = 0; + } + _ => {} + } +} + +pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { + let count = state.monitors.len(); + if count < 2 { + return; + } + + let row = event.row; + // Content area starts at y=2. + // Pickers chunk: y=2..5 (height 4). Source line at y=2, target at y=4. + // Compute button: y=6. + // Result panel: y=7+ (border at y=7, buttons at ~y=13 for a typical terminal). + + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + match row { + 2 | 3 => { + // Source picker area + let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Source).unwrap_or(0); + state.mirror.focused = f_idx; + } + 4 | 5 => { + // Target picker area + let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Target).unwrap_or(1); + state.mirror.focused = f_idx; + } + 6 => { + // Compute button + let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2); + state.mirror.focused = f_idx; + // Also activate it + let src = &state.monitors[state.mirror.source_idx]; + let tgt = &state.monitors[state.mirror.target_idx]; + match crate::mirror::find_mirror_modes(src, tgt) { + Some(result) => { + state.mirror.result = Some(result); + state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + } + None => { + state.set_status( + "No compatible modes found between these monitors.", + crate::ui::StatusLevel::Error, + ); + } + } + } + r if r >= 7 => { + // Result panel: Apply is on the line with buttons. + // Rough column check: col < 20 = Apply, col >= 20 = Cancel + if state.mirror.result.is_some() { + let col = event.column; + if col < 20 { + // Activate Apply + state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3); + if let Some(result) = state.mirror.result.clone() { + state.push_undo(); + let src_name = state.monitors[state.mirror.source_idx].name.clone(); + let tgt_idx = state.mirror.target_idx; + state.monitors[tgt_idx].active_mode = result.mirror_mode.clone(); + state.monitors[tgt_idx].mirror_of = Some(src_name.clone()); + state.dirty = true; + state.mirror.result = None; + state.mirror.focused = 0; + state.set_status( + format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode), + crate::ui::StatusLevel::Success, + ); + } + } else { + // Cancel + state.mirror.result = None; + state.mirror.focused = 0; + } + } + } + _ => {} + } + } + MouseEventKind::ScrollUp => { + // Scroll in source/target pickers to cycle monitors + match state.mirror.current_field() { + MirrorField::Source => { + state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.fix_indices(count); + state.mirror.result = None; + } + MirrorField::Target => { + state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1); + state.mirror.fix_indices(count); + state.mirror.result = None; + } + _ => {} + } + } + MouseEventKind::ScrollDown => { + match state.mirror.current_field() { + MirrorField::Source => { + state.mirror.source_idx = (state.mirror.source_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; + } + MirrorField::Target => { + state.mirror.target_idx = (state.mirror.target_idx + 1) % count; + state.mirror.fix_indices(count); + state.mirror.result = None; + } + _ => {} + } + } + _ => {} + } +} + +pub fn render(f: &mut Frame, area: Rect, state: &AppState) { + let count = state.monitors.len(); + + if count < 2 { + f.render_widget( + Paragraph::new("Need at least 2 monitors for mirror setup.") + .style(Style::default().fg(Color::DarkGray)), + area, + ); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // pickers + Constraint::Length(1), // compute button + Constraint::Min(0), // result / hint + ]) + .split(area); + + render_pickers(f, chunks[0], state, count); + render_compute_btn(f, chunks[1], state); + if let Some(ref result) = state.mirror.result { + render_result(f, chunks[2], state, result); + } else { + let hint = Paragraph::new(" Press Enter on [Compute] to find the best matching modes.") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(hint, chunks[2]); + } +} + +fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) { + let src_name = state + .monitors + .get(state.mirror.source_idx.min(count - 1)) + .map(|m| m.name.as_str()) + .unwrap_or("?"); + let tgt_name = state + .monitors + .get(state.mirror.target_idx.min(count - 1)) + .map(|m| m.name.as_str()) + .unwrap_or("?"); + + let focused = state.mirror.current_field(); + + let src_style = if focused == MirrorField::Source { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let tgt_style = if focused == MirrorField::Target { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let lines = vec![ + Line::from(vec![ + Span::raw(" Source "), + Span::styled(format!("[ {} ]", src_name), src_style), + Span::raw(" (mirror this output)"), + ]), + Line::raw(""), + Line::from(vec![ + Span::raw(" Target "), + Span::styled(format!("[ {} ]", tgt_name), tgt_style), + Span::raw(" (the mirroring output)"), + ]), + ]; + + f.render_widget(Paragraph::new(lines), area); +} + +fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) { + let focused = state.mirror.current_field() == MirrorField::Compute; + let style = if focused { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + f.render_widget( + Paragraph::new(" [ Compute Best Match ]").style(style), + area, + ); +} + +fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorResult) { + let ar = result.ar_ratio; + let ar_label = if result.ar_exact { + format!("{}:{} (exact)", ar.0, ar.1) + } else { + format!("{}:{} (approx)", ar.0, ar.1) + }; + + let refresh_label = refresh_match_label(result); + + let focused = state.mirror.current_field(); + let apply_style = if focused == MirrorField::Apply { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let cancel_style = if focused == MirrorField::Cancel { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let lines = vec![ + Line::raw(""), + Line::from(Span::styled( + format!(" Source mode: {} (AR {})", result.source_mode, ar_label), + Style::default().fg(Color::White), + )), + Line::from(Span::styled( + format!(" Mirror mode: {}", result.mirror_mode), + Style::default().fg(Color::White), + )), + Line::from(Span::styled( + format!(" Refresh: {:.2} Hz ({})", result.refresh, refresh_label), + Style::default().fg(Color::White), + )), + Line::raw(""), + Line::from(vec![ + Span::raw(" "), + Span::styled("[ Apply Mirror ]", apply_style), + Span::raw(" "), + Span::styled("[ Cancel ]", cancel_style), + ]), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Result "); + + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget(Paragraph::new(lines), inner); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..e4891c4 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,322 @@ +pub mod config_view; +pub mod layout_view; +pub mod mirror_view; +pub mod profiles_view; + +use std::time::Instant; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph, Tabs}, + Frame, +}; + +use crate::{ + layout::LayoutState, + monitor::Monitor, +}; + +use config_view::ConfigState; +use mirror_view::MirrorState; +use profiles_view::ProfilesState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Layout, + Config, + Mirror, + Profiles, +} + +impl Tab { + pub fn index(self) -> usize { + match self { + Self::Layout => 0, + Self::Config => 1, + Self::Mirror => 2, + Self::Profiles => 3, + } + } + + fn from_col(col: u16) -> Option { + // Tab titles: " 1 Layout "(10), " 2 Config "(10), " 3 Mirror "(10), " 4 Profiles "(12) + // Each separated by a 1-char divider │ + const WIDTHS: [u16; 4] = [10, 10, 10, 12]; + let mut x = 0u16; + for (i, &w) in WIDTHS.iter().enumerate() { + if col >= x && col < x + w { + return Some(match i { + 0 => Self::Layout, + 1 => Self::Config, + 2 => Self::Mirror, + 3 => Self::Profiles, + _ => unreachable!(), + }); + } + x += w + 1; + } + None + } + + fn hints(self) -> &'static str { + match self { + Self::Layout => + " [hjkl/drag]move [Tab]next [Shift+hjkl]×10 [0]arrange [Ctrl+Z]undo [a]apply [s]save", + Self::Config => + " [jk]field [hl]value [,.]scale [Enter]apply [Esc]cancel [\\[\\]]monitor [Ctrl+Z]undo", + Self::Mirror => + " [Tab]focus [hl]cycle [Enter]confirm [Ctrl+Z]undo", + Self::Profiles => + " [jk]nav [Enter]load [d×2]delete [Tab]name [Ctrl+Z]undo", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusLevel { + Info, + Success, + Error, +} + +pub struct StatusMsg { + pub text: String, + pub level: StatusLevel, + pub born: Instant, +} + +/// Drag state for moving monitors in the layout canvas. +pub struct DragState { + pub monitor_idx: usize, + pub origin_x: i32, + pub origin_y: i32, + pub click_world_x: i32, + pub click_world_y: i32, +} + +pub struct AppState { + pub monitors: Vec, + pub tab: Tab, + pub layout: LayoutState, + pub config: ConfigState, + pub mirror: MirrorState, + pub profiles: ProfilesState, + pub status: Option, + pub dirty: bool, + pub quit_confirm: bool, + pub drag_state: Option, + pub terminal_size: (u16, u16), + /// Set to true by any handler that wants `main.rs` to run `apply_monitors`. + pub pending_apply: bool, + /// Snapshots for Ctrl+Z undo (up to 20 deep). + pub undo_stack: Vec>, +} + +impl AppState { + pub fn new(monitors: Vec, terminal_size: (u16, u16)) -> Self { + let profiles_list = crate::profile::list().unwrap_or_default(); + Self { + monitors, + tab: Tab::Layout, + layout: LayoutState::default(), + config: ConfigState::default(), + mirror: MirrorState::default(), + profiles: ProfilesState::new(profiles_list), + status: None, + dirty: false, + quit_confirm: false, + drag_state: None, + terminal_size, + pending_apply: false, + undo_stack: Vec::new(), + } + } + + pub fn set_status(&mut self, text: impl Into, level: StatusLevel) { + self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() }); + } + + pub fn tick_status(&mut self) { + if let Some(s) = &self.status { + if s.born.elapsed().as_secs() >= 3 { + self.status = None; + } + } + } + + pub fn switch_tab(&mut self, tab: Tab) { + self.tab = tab; + if tab == Tab::Config { + self.config.sync_from_monitor(self.layout.selected, &self.monitors); + } + } + + /// Save a monitor snapshot for undo (max 20 entries). + pub fn push_undo(&mut self) { + self.undo_stack.push(self.monitors.clone()); + if self.undo_stack.len() > 20 { + self.undo_stack.remove(0); + } + } + + /// Restore the most recent undo snapshot. + pub fn undo(&mut self) { + if let Some(snapshot) = self.undo_stack.pop() { + self.monitors = snapshot; + self.dirty = true; + self.layout.clamp_selected(self.monitors.len()); + // Re-sync config view to the restored state + let idx = self.layout.selected; + self.config.sync_from_monitor(idx, &self.monitors); + self.set_status("Undone.", StatusLevel::Info); + } else { + self.set_status("Nothing to undo.", StatusLevel::Info); + } + } +} + +/// Returns true if the app should continue running, false to quit. +pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool { + // Quit + if event.code == KeyCode::Char('q') + || (event.code == KeyCode::Char('c') && event.modifiers.contains(KeyModifiers::CONTROL)) + { + if state.dirty && !state.quit_confirm { + state.set_status("Unsaved changes. Press q again to quit.", StatusLevel::Info); + state.quit_confirm = true; + return true; + } + return false; + } + state.quit_confirm = false; + + // Global: Ctrl+Z undo + if event.code == KeyCode::Char('z') && event.modifiers.contains(KeyModifiers::CONTROL) { + state.undo(); + return true; + } + + // Global tab switching + match event.code { + KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; } + KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; } + KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; } + KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); return true; } + _ => {} + } + + match state.tab { + Tab::Layout => layout_view::handle_key(event, state), + Tab::Config => config_view::handle_key(event, state), + Tab::Mirror => mirror_view::handle_key(event, state), + Tab::Profiles => profiles_view::handle_key(event, state), + } + + true +} + +pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { + let col = event.column; + let row = event.row; + let (_, th) = state.terminal_size; + + // Tab bar: rows 0-1 + if row < 2 { + if event.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some(tab) = Tab::from_col(col) { + state.switch_tab(tab); + } + } + return; + } + + // Bottom bar: last row + if row >= th.saturating_sub(1) { + return; + } + + match state.tab { + Tab::Layout => layout_view::handle_mouse(event, state), + Tab::Config => config_view::handle_mouse(event, state), + Tab::Mirror => mirror_view::handle_mouse(event, state), + Tab::Profiles => profiles_view::handle_mouse(event, state), + } +} + +pub fn render(f: &mut Frame, state: &AppState) { + let area = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + render_tabs(f, chunks[0], state); + + match state.tab { + Tab::Layout => layout_view::render(f, chunks[1], state), + Tab::Config => config_view::render(f, chunks[1], state), + Tab::Mirror => mirror_view::render(f, chunks[1], state), + Tab::Profiles => profiles_view::render(f, chunks[1], state), + } + + render_bottom_bar(f, chunks[2], state); +} + +fn render_tabs(f: &mut Frame, area: Rect, state: &AppState) { + let titles: Vec = vec![ + Line::from(Span::raw(" 1 Layout ")), + Line::from(Span::raw(" 2 Config ")), + Line::from(Span::raw(" 3 Mirror ")), + Line::from(Span::raw(" 4 Profiles ")), + ]; + let tabs = Tabs::new(titles) + .block(Block::default()) + .select(state.tab.index()) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .divider(Span::raw("│")); + + f.render_widget(tabs, area); +} + +fn render_bottom_bar(f: &mut Frame, area: Rect, state: &AppState) { + let dirty_marker = if state.dirty { " [modified]" } else { "" }; + let hints = format!("{}{} [q]uit", state.tab.hints(), dirty_marker); + + let (status_text, status_style) = if let Some(msg) = &state.status { + let color = match msg.level { + StatusLevel::Info => Color::Cyan, + StatusLevel::Success => Color::Green, + StatusLevel::Error => Color::Red, + }; + (format!(" {} ", msg.text), Style::default().fg(color)) + } else { + (String::new(), Style::default()) + }; + + let status_width = status_text.len() as u16; + let bar_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(status_width)]) + .split(area); + + f.render_widget( + Paragraph::new(hints).style(Style::default().fg(Color::DarkGray)), + bar_chunks[0], + ); + f.render_widget( + Paragraph::new(status_text).style(status_style), + bar_chunks[1], + ); +} diff --git a/src/ui/profiles_view.rs b/src/ui/profiles_view.rs new file mode 100644 index 0000000..7e23bfd --- /dev/null +++ b/src/ui/profiles_view.rs @@ -0,0 +1,381 @@ +use std::time::Instant; + +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::{ + profile, + ui::{AppState, StatusLevel}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileField { + List, + NameInput, + SaveBtn, +} + +pub struct ProfilesState { + pub profiles: Vec, + pub selected_idx: usize, + pub new_name: String, + pub focused: ProfileField, + pub delete_confirm: Option<(String, Instant)>, +} + +impl ProfilesState { + pub fn new(profiles: Vec) -> Self { + Self { + profiles, + selected_idx: 0, + new_name: String::new(), + focused: ProfileField::List, + delete_confirm: None, + } + } + + pub fn refresh(&mut self) { + self.profiles = profile::list().unwrap_or_default(); + if self.selected_idx >= self.profiles.len() && !self.profiles.is_empty() { + self.selected_idx = self.profiles.len() - 1; + } + } + + fn tick_delete_confirm(&mut self) { + if let Some((_, born)) = &self.delete_confirm { + if born.elapsed().as_secs() >= 3 { + self.delete_confirm = None; + } + } + } +} + +pub fn handle_key(event: KeyEvent, state: &mut AppState) { + state.profiles.tick_delete_confirm(); + + match event.code { + KeyCode::Tab => { + state.profiles.focused = match state.profiles.focused { + ProfileField::List => ProfileField::NameInput, + ProfileField::NameInput => ProfileField::SaveBtn, + ProfileField::SaveBtn => ProfileField::List, + }; + } + KeyCode::BackTab => { + state.profiles.focused = match state.profiles.focused { + ProfileField::List => ProfileField::SaveBtn, + ProfileField::NameInput => ProfileField::List, + ProfileField::SaveBtn => ProfileField::NameInput, + }; + } + KeyCode::Esc => { + state.profiles.new_name.clear(); + state.profiles.delete_confirm = None; + state.profiles.focused = ProfileField::List; + } + _ => match state.profiles.focused { + ProfileField::List => handle_list_key(event, state), + ProfileField::NameInput => handle_name_key(event, state), + ProfileField::SaveBtn => { + if event.code == KeyCode::Enter { + do_save(state); + } + } + }, + } +} + +fn handle_list_key(event: KeyEvent, state: &mut AppState) { + let count = state.profiles.profiles.len(); + + match event.code { + KeyCode::Char('j') | KeyCode::Down => { + if count > 0 { + state.profiles.selected_idx = (state.profiles.selected_idx + 1) % count; + } + } + KeyCode::Char('k') | KeyCode::Up => { + if count > 0 { + state.profiles.selected_idx = state + .profiles + .selected_idx + .checked_sub(1) + .unwrap_or(count - 1); + } + } + KeyCode::Enter => { + if count > 0 { + state.push_undo(); + do_load(state); + } + } + KeyCode::Char('d') => { + if count == 0 { + return; + } + let name = state.profiles.profiles[state.profiles.selected_idx].clone(); + if let Some((pending, _)) = &state.profiles.delete_confirm { + if *pending == name { + // Second press: confirm delete + match profile::delete(&name) { + Ok(()) => { + state.profiles.refresh(); + state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success); + } + Err(e) => { + state.set_status(format!("Delete failed: {}", e), StatusLevel::Error); + } + } + state.profiles.delete_confirm = None; + return; + } + } + state.profiles.delete_confirm = Some((name.clone(), Instant::now())); + state.set_status( + format!("Press d again to delete '{}' (3s to cancel)", name), + StatusLevel::Info, + ); + } + _ => {} + } +} + +pub fn handle_mouse(event: MouseEvent, state: &mut AppState) { + let row = event.row; + let col = event.column; + let (tw, th) = state.terminal_size; + + // Layout in profiles view: + // Content area: y=2..th-2 + // List: y=2, height = content_h - 3 (save row is Length(3)) + // Save row: y = th - 4, height 3 + let content_h = th.saturating_sub(3); // th - tab(2) - bottom(1) + let save_row_y = 2 + content_h.saturating_sub(3); + // List block: border at y=2, items start at y=3 + let list_items_start = 3u16; + + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + if row >= save_row_y { + // Clicked in save row + let save_btn_x = tw.saturating_sub(16); + if col >= save_btn_x { + // Save button + state.profiles.focused = ProfileField::SaveBtn; + do_save(state); + } else { + // Name input + state.profiles.focused = ProfileField::NameInput; + } + } else if row >= list_items_start { + let item_idx = (row - list_items_start) as usize; + if item_idx < state.profiles.profiles.len() { + state.profiles.focused = ProfileField::List; + state.profiles.selected_idx = item_idx; + } + } + } + MouseEventKind::Down(MouseButton::Right) => { + // Right-click on a profile item = load it + if row >= list_items_start && row < save_row_y { + let item_idx = (row - list_items_start) as usize; + if item_idx < state.profiles.profiles.len() { + state.profiles.selected_idx = item_idx; + state.push_undo(); + do_load(state); + } + } + } + MouseEventKind::ScrollUp => { + let count = state.profiles.profiles.len(); + if count > 0 { + state.profiles.selected_idx = + state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1); + state.profiles.focused = ProfileField::List; + } + } + MouseEventKind::ScrollDown => { + let count = state.profiles.profiles.len(); + if count > 0 { + state.profiles.selected_idx = (state.profiles.selected_idx + 1) % count; + state.profiles.focused = ProfileField::List; + } + } + _ => {} + } +} + +fn handle_name_key(event: KeyEvent, state: &mut AppState) { + match event.code { + KeyCode::Char(c) => { + // Only allow alphanumeric, dash, underscore + if c.is_alphanumeric() || c == '-' || c == '_' { + state.profiles.new_name.push(c); + } + } + KeyCode::Backspace => { + state.profiles.new_name.pop(); + } + KeyCode::Enter => { + do_save(state); + } + _ => {} + } +} + +fn do_save(state: &mut AppState) { + let name = state.profiles.new_name.trim().to_owned(); + if name.is_empty() { + state.set_status("Enter a profile name first.", StatusLevel::Info); + return; + } + let profile = profile::from_monitors(&name, &state.monitors); + match profile::save(&profile) { + Ok(()) => { + state.profiles.new_name.clear(); + state.profiles.refresh(); + state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success); + } + Err(e) => { + state.set_status(format!("Save failed: {}", e), StatusLevel::Error); + } + } +} + +fn do_load(state: &mut AppState) { + let name = state.profiles.profiles[state.profiles.selected_idx].clone(); + match profile::load(&name) { + Ok(p) => { + profile::apply_to_monitors(&p, &mut state.monitors); + state.dirty = true; + state.set_status( + format!("Loaded profile '{}'. Press [a] to apply.", name), + StatusLevel::Success, + ); + } + Err(e) => { + state.set_status(format!("Load failed: {}", e), StatusLevel::Error); + } + } +} + +pub fn render(f: &mut Frame, area: Rect, state: &AppState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // profile list + Constraint::Length(3), // name input + save button + ]) + .split(area); + + render_list(f, chunks[0], state); + render_save_row(f, chunks[1], state); +} + +fn render_list(f: &mut Frame, area: Rect, state: &AppState) { + let profiles = &state.profiles.profiles; + let is_focused = state.profiles.focused == ProfileField::List; + + let items: Vec = if profiles.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No profiles saved yet.", + Style::default().fg(Color::DarkGray), + )))] + } else { + profiles + .iter() + .enumerate() + .map(|(i, name)| { + let is_selected = i == state.profiles.selected_idx; + let pending_delete = state + .profiles + .delete_confirm + .as_ref() + .map(|(n, _)| n == name) + .unwrap_or(false); + + let style = if is_selected && is_focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + .bg(Color::DarkGray) + } else if is_selected { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let label = if pending_delete { + format!(" {} [d again to confirm delete]", name) + } else { + format!(" {}", name) + }; + + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect() + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(if is_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }) + .title(" Profiles (Enter=load d=delete) "); + + let list = List::new(items).block(block); + let mut list_state = ListState::default(); + if !profiles.is_empty() { + list_state.select(Some(state.profiles.selected_idx)); + } + f.render_stateful_widget(list, area, &mut list_state); +} + +fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) { + let name_focused = state.profiles.focused == ProfileField::NameInput; + let save_focused = state.profiles.focused == ProfileField::SaveBtn; + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(16)]) + .split(area); + + // Name input + let input_display = if name_focused { + format!(" Profile name: {}|", state.profiles.new_name) + } else { + format!(" Profile name: {}", state.profiles.new_name) + }; + let input_style = if name_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + f.render_widget( + Paragraph::new(input_display) + .style(input_style) + .block(Block::default().borders(Borders::ALL).border_style(input_style)), + chunks[0], + ); + + // Save button + let save_style = if save_focused { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + f.render_widget( + Paragraph::new(" [ Save ] ") + .style(save_style) + .block(Block::default().borders(Borders::ALL).border_style(save_style)), + chunks[1], + ); +}