commit b56dd2a34e48d2a847464c943acc596358ead6bc Author: Abel Hoogeveen Date: Fri Jun 26 15:35:13 2026 +0200 Initial commit diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fa2a7b4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,686 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "bitflags" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dbus" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd9e78c210146a1860f897db03412fd5091fd73100778e43ee255cca252cf32" +dependencies = [ + "libc", + "libdbus-sys", +] + +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "evdev-rs" +version = "0.4.0" +source = "git+https://github.com/ndesh26/evdev-rs.git?rev=8e995b8bf#8e995b8bfc1f8ed844ae47edaa4286c99e8c88b5" +dependencies = [ + "bitflags 1.3.2", + "evdev-sys", + "libc", + "log", +] + +[[package]] +name = "evdev-sys" +version = "0.2.1" +source = "git+https://github.com/ndesh26/evdev-rs.git?rev=8e995b8bf#8e995b8bfc1f8ed844ae47edaa4286c99e8c88b5" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hidapi" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "798154e4b6570af74899d71155fb0072d5b17e6aa12f39c8ef22c60fb8ec99e7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "winapi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mac-notification-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb6b71a9a89cd38b395d994214297447e8e63b1ba5708a9a2b0b1048ceda76" +dependencies = [ + "cc", + "chrono", + "dirs", + "objc-foundation", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "notify-rust" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144acee6a0543dc74893e4b8a33936b5b0a94cc2d4ab024afd0c6daff7afc3c0" +dependencies = [ + "dbus", + "mac-notification-sys", + "winrt-notification", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6e920b65c65f10b2ae65c831a81a073a89edd28c7cce89475bff467ab4167a" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom", + "redox_syscall", + "rust-argon2", +] + +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "strum" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca6e4730f517e041e547ffe23d29daab8de6b73af4b6ae2a002108169f5e7da" + +[[package]] +name = "strum_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3384590878eb0cab3b128e844412e2d010821e7e091211b9d87324173ada7db8" +dependencies = [ + "quote 0.3.15", + "syn 0.11.11", +] + +[[package]] +name = "surface-dial-renewed" +version = "0.1.0" +dependencies = [ + "evdev-rs", + "hidapi", + "notify-rust", + "udev", +] + +[[package]] +name = "syn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b891b9015c88c576343b9b3e41c2c11a51c219ef067b264bd9c8aa9b441dad" +dependencies = [ + "quote 0.3.15", + "synom", + "unicode-xid", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote 1.0.46", + "unicode-ident", +] + +[[package]] +name = "synom" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a393066ed9010ebaed60b9eafa373d4b1baac186dd7e008555b0f702b51945b6" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "udev" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048df778e99eea028c08cca7853b9b521df6948b59bb29ab8bb737c057f58e6d" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f860d7d29cf02cb2f3f359fd35991af3d30bac52c57d265a3c461074cb4dc" + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote 1.0.46", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote 1.0.46", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[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-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote 1.0.46", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote 1.0.46", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "winrt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30cba82e22b083dc5a422c2ee77e20dc7927271a0dc981360c57c1453cb48d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winrt-notification" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57790eb281688a4682dab44df2a1ba8b78373233bd71cb291c3e75fecb1a01c4" +dependencies = [ + "strum", + "strum_macros", + "winapi", + "winrt", + "xml-rs", +] + +[[package]] +name = "xml-rs" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1945e12e16b951721d7976520b0832496ef79c31602c7a29d950de79ba74621" +dependencies = [ + "bitflags 0.9.1", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e1c4ec9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "surface-dial-renewed" +version = "0.1.0" +edition = "2021" + +[dependencies] +evdev-rs = { git = "https://github.com/ndesh26/evdev-rs.git", rev = "8e995b8bf" } +hidapi = { version = "1.2.3", default-features = false, features = ["linux-shared-hidraw"] } +notify-rust = "=4.0.0" +udev = "0.5" + +[profile.release.package.evdev-rs] +opt-level = 0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..63f1d9c --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Surface Dial Renewed Driver + +This is a concept rewrite of the Surface Dial userspace driver. It is kept +separate from the existing daemon so the new event model can be tested without +touching the current code. + +## Goals + +- Multitouch scrolling first. +- Regular wheel scrolling as a fallback mode. +- Dial sensitivity adjustment by holding the button and rotating. +- Desktop notification feedback for sensitivity changes. +- Small, explicit state machines instead of mode/thread sprawl. + +## Run + +```bash +cargo run --manifest-path renewed_driver/Cargo.toml -- --mode multitouch +``` + +Regular wheel mode: + +```bash +cargo run --manifest-path renewed_driver/Cargo.toml -- --mode wheel +``` + +The process still needs access to the Surface Dial event device and +`/dev/uinput`, just like the original daemon. + +## Controls + +- Rotate: scroll. +- Hold and rotate: change sensitivity. +- Release after changing sensitivity: resume scroll mode. + +Sensitivity levels are 1 through 32. KDE displays the current value through the +native OSD text path, with desktop notifications used only as a fallback. + +## Documentation + +Detailed architecture, behavior, protocol, install, and troubleshooting notes +live under [`doc/`](doc/README.md). diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..4ee14c1 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,13 @@ +# Renewed Driver Documentation + +This folder documents the current concept driver implementation. + +- [Architecture](architecture.md): process layout, module responsibilities, and data flow. +- [Behavior](behavior.md): user-visible controls, scrolling model, sensitivity, haptics, and OSD. +- [Input and Output Protocol](input-output.md): Surface Dial event decoding and virtual uinput devices. +- [Installation and Service](installation-service.md): install files, udev rules, systemd user service, and logs. +- [Troubleshooting](troubleshooting.md): raw event inspection and common failure modes. + +The driver intentionally stays small. The main design choice is to keep the +event loop explicit and avoid the configurable mode framework from the older +daemon until there is a concrete need for it. diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..7fdbf09 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,86 @@ +# Architecture + +`surface-dial-renewed` is a userspace Linux driver. It reads the Microsoft +Surface Dial through evdev, emits virtual input devices through uinput, uses HID +for haptic feedback, and uses KDE's OSD service for sensitivity feedback. + +## Runtime Threads + +The process has one main event loop and one input worker thread. + +The input worker is created by `DialReader::spawn` in `src/dial.rs`. It finds +the `Surface Dial System Multi Axis` event node through udev, blocks on evdev +events, and sends simplified raw events through a channel. + +The main thread owns all output state: + +- `DriverState` in `src/main.rs` +- `VirtualInput` in `src/virtual_input.rs` +- `Haptics` in `src/haptics.rs` +- `SensitivityNotifier` in `src/notify.rs` + +Keeping output ownership on one thread avoids locking around uinput devices and +keeps gesture lifecycle decisions local to the main loop. + +## Module Responsibilities + +`src/main.rs` + +The state machine. It decides whether a Dial rotation means scroll or +sensitivity adjustment. It starts and ends multitouch gestures, updates +sensitivity, and calls haptics/OSD hooks. + +`src/dial.rs` + +Device discovery and event decoding. It converts evdev events into +`DialEventKind` values: connect, disconnect, button press/release, long press, +rotation, and ignored noise. + +`src/virtual_input.rs` + +Creates two uinput devices: + +- `Surface Dial Renewed Touchpad` for multitouch scrolling. +- `Surface Dial Renewed Wheel` for regular wheel mode. + +The touchpad device is intentionally not advertised as a buttonpad and does not +expose `BTN_LEFT`, reducing the chance that very short gestures become tap or +right-click behavior. + +`src/sensitivity.rs` + +Defines the 1-32 sensitivity scale and maps each level to scroll scaling. + +`src/haptics.rs` + +Opens the Dial's HID device and sends manual haptic buzz reports. Haptics are +best-effort; failure disables haptics but does not stop scrolling. + +`src/notify.rs` + +Shows the sensitivity value. KDE uses `qdbus6` with +`org.kde.osdService.showText`. If that fails, the code falls back to +FreeDesktop notifications. + +`src/error.rs` + +Small shared error enum for the concept driver. + +## Data Flow + +1. `DialReader` finds `Surface Dial System Multi Axis`. +2. Raw evdev events are sent to the main loop. +3. `main.rs` decodes intent: + - button held + rotate: sensitivity adjustment + - rotate alone: scrolling + - connect/disconnect: haptics and gesture cleanup +4. `VirtualInput` emits either multitouch or wheel events. +5. `Haptics` buzzes on selector entry and sensitivity level changes. +6. `SensitivityNotifier` displays `Surface Dial sensitivity N/32`. + +## Reconnect Model + +The input worker scans for an existing Dial at startup. If the device is removed +or an input read fails, it sends a disconnect event, sleeps briefly, and retries +discovery/monitoring. The main loop ends any active multitouch gesture and drops +the HID haptics handle on disconnect. On reconnect it reopens haptics. diff --git a/doc/behavior.md b/doc/behavior.md new file mode 100644 index 0000000..13d3220 --- /dev/null +++ b/doc/behavior.md @@ -0,0 +1,85 @@ +# Driver Behavior + +## Default Mode + +The default mode is multitouch scrolling. Run explicitly with: + +```bash +surface-dial-renewed --mode multitouch +``` + +Regular wheel mode exists for fallback/testing: + +```bash +surface-dial-renewed --mode wheel +``` + +## Normal Rotation + +Normal Dial rotation emits a synthetic two-finger touchpad scroll through +`Surface Dial Renewed Touchpad`. + +The driver starts a virtual two-finger contact on the first rotation event, +moves both virtual fingers vertically, and ends the touch gesture after no +rotation has arrived for 2 seconds. + +The 2 second hold is intentional. Very slow physical Dial movement can emit +sparse `REL_DIAL` events. If the virtual fingers are lifted too quickly, libinput +can treat those sparse movements as many tiny gestures and ignore them. Holding +the contact makes slow and precise movement accumulate into one gesture. + +## Sensitivity Selector + +Hold the Dial down and rotate to change sensitivity. + +- Range: `1/32` through `32/32` +- Default: `7/32` +- Step threshold: `48` raw Dial units per sensitivity level +- Display: KDE OSD text, falling back to notification +- Haptics: one buzz when entering the selector, one buzz per accepted level + +While the button is held, rotation never scrolls. If a multitouch scroll gesture +is active when the button is pressed, it is ended before sensitivity adjustment. + +## Sensitivity Scaling + +The sensitivity level maps to milli-pixels per raw Dial unit: + +```text +500 + (level - 1) * 250 +``` + +This means level 1 is `0.5` virtual coordinate units per raw Dial unit, level 7 +is `2.0`, and level 32 is `8.25`. + +The driver stores fractional remainders while a gesture is active. A nonzero raw +Dial event is rounded away from zero if it would otherwise produce no integer +coordinate movement. + +## Haptics + +Haptics are deliberately limited: + +- Enter sensitivity selector: buzz once. +- Change sensitivity level: buzz once. + +Normal scrolling does not buzz. It was tested and removed because it was +distracting and hard to align with physical rotation consistently. + +## OSD + +On KDE, the driver calls: + +```text +org.kde.plasmashell /org/kde/osdService org.kde.osdService.showText +``` + +The displayed text is: + +```text +Surface Dial sensitivity N/32 +``` + +KDE exposes polished volume/brightness OSD methods, but those are semantically +volume/brightness-specific. The generic custom progress API is not callable as a +normal method on this system, so the driver uses text OSD instead. diff --git a/doc/input-output.md b/doc/input-output.md new file mode 100644 index 0000000..a517ec0 --- /dev/null +++ b/doc/input-output.md @@ -0,0 +1,100 @@ +# Input and Output Protocol + +## Physical Dial Input + +The driver reads the `Surface Dial System Multi Axis` evdev device. + +Find it with: + +```bash +rg -n -C 8 "Surface Dial System Multi Axis" /proc/bus/input/devices +``` + +Then inspect raw data: + +```bash +sudo evtest /dev/input/eventXX +``` + +Expected raw events: + +```text +EV_REL / REL_DIAL: rotation +EV_KEY / BTN_0: button press and release +EV_SYN and EV_MSC: ignored framing/scancode events +``` + +The `Surface Dial System Control` node is not used for scrolling. It exposes +sleep/wakeup-style keys, not rotation. + +## Virtual Touchpad + +The multitouch output device is named: + +```text +Surface Dial Renewed Touchpad +``` + +It exposes a type-B multitouch touchpad with slots. On gesture start it creates +two active tracking IDs. On movement it moves both slots along the Y axis and +updates the single-touch compatibility `ABS_Y` value. + +Important implementation details: + +- Two fingers are used because libinput interprets that as scroll. +- `INPUT_PROP_POINTER` is enabled. +- `INPUT_PROP_BUTTONPAD` is intentionally not enabled. +- `BTN_LEFT` is intentionally not exposed. +- Pressure/touch-major/tool-width axes are exposed to look more like a real + touchpad. + +The lack of buttonpad/left-button capability reduces accidental tap/click +interpretation when a scroll gesture is short. + +## Gesture Lifecycle + +`multitouch_start` + +Creates slot 0 and slot 1 with tracking IDs, positions, pressure, touch major, +and two-finger tool state. + +`multitouch_move` + +Moves both slots to the same new Y position. The driver keeps an accumulated +`mt_position` relative to `MT_BASELINE`. + +`multitouch_settle` + +Emits one final stationary frame before ending. This reduces the chance that the +final movement is interpreted as a flick. + +`multitouch_end` + +Sets both tracking IDs to `-1`, releases `BTN_TOUCH` and tool keys, and reports +zero pressure. + +## Coordinate Model + +`MT_BASELINE` is `10_000`, with coordinates from `0` to `20_000`. + +The driver recenters the synthetic gesture if `mt_position` exceeds +`MT_RECENTER_THRESHOLD`. This avoids running into coordinate limits during long +scrolls. + +## Virtual Wheel + +The fallback wheel device is named: + +```text +Surface Dial Renewed Wheel +``` + +It emits both: + +```text +REL_WHEEL +REL_WHEEL_HI_RES +``` + +Wheel mode is implemented but has not received the same tuning as multitouch +mode. diff --git a/doc/installation-service.md b/doc/installation-service.md new file mode 100644 index 0000000..7794413 --- /dev/null +++ b/doc/installation-service.md @@ -0,0 +1,87 @@ +# Installation and Systemd Service + +## Files + +Install helpers: + +```text +renewed_driver/install.sh +renewed_driver/uninstall.sh +``` + +Systemd user service: + +```text +renewed_driver/install/surface-dial-renewed.service +``` + +udev rules: + +```text +renewed_driver/install/70-surface-dial-renewed-uinput.rules +renewed_driver/install/70-surface-dial-renewed-input.rules +renewed_driver/install/70-surface-dial-renewed-hidraw.rules +``` + +## Install + +From the repository root: + +```bash +./renewed_driver/install.sh +``` + +This performs: + +1. `cargo install --path renewed_driver` +2. udev rule installation under `/etc/udev/rules.d` +3. udev reload and trigger +4. systemd user service installation +5. `systemctl --user enable --now surface-dial-renewed.service` + +The service runs as the desktop user, not root. That is required for access to +the user session D-Bus used by KDE OSD. + +## Service + +The service runs: + +```text +%h/.cargo/bin/surface-dial-renewed --mode multitouch +``` + +It restarts on failure with a 2 second delay. + +Useful commands: + +```bash +systemctl --user status surface-dial-renewed.service +systemctl --user restart surface-dial-renewed.service +journalctl --user -u surface-dial-renewed.service -f +``` + +## Permissions + +The udev rules grant user-session access to: + +- `/dev/uinput` +- Surface Dial input event nodes +- Surface Dial HID raw node for haptics + +If input permissions still fail, add the user to the group that owns +`/dev/input/event*`, then log out and back in: + +```bash +sudo gpasswd -a "$(whoami)" "$(stat -c '%G' /dev/input/event0)" +``` + +## Uninstall + +From the repository root: + +```bash +./renewed_driver/uninstall.sh +``` + +This disables the user service, removes service/rule files, reloads udev, and +uninstalls the Cargo binary. diff --git a/doc/troubleshooting.md b/doc/troubleshooting.md new file mode 100644 index 0000000..217f1b1 --- /dev/null +++ b/doc/troubleshooting.md @@ -0,0 +1,82 @@ +# Troubleshooting + +## Check the Service + +```bash +systemctl --user status surface-dial-renewed.service +journalctl --user -u surface-dial-renewed.service -n 100 --no-pager +``` + +Expected startup lines: + +```text +surface-dial-renewed: running in Multitouch mode +surface-dial-renewed: dial connected +``` + +## Check Raw Dial Rotation + +Find the current event node: + +```bash +rg -n -C 8 "Surface Dial System Multi Axis" /proc/bus/input/devices +``` + +Then run: + +```bash +sudo evtest /dev/input/eventXX +``` + +Rotation should produce: + +```text +type 2 (EV_REL), code 7 (REL_DIAL), value ... +``` + +If `REL_DIAL` does not appear, the driver cannot scroll. Reconnect the Dial over +Bluetooth and check again. + +## Wrong Event Node + +The Control node is not the rotation node. It looks like: + +```text +Surface Dial System Control +``` + +and exposes `KEY_SLEEP`/`KEY_WAKEUP`. Use `Surface Dial System Multi Axis` for +rotation. + +## Haptics Work But Scroll Does Not + +Haptics use HID raw access; scrolling uses evdev input plus uinput output. +Haptics working only proves the HID path works. + +Check: + +1. Raw `REL_DIAL` events with `evtest`. +2. Service logs for `dial connected`. +3. `/dev/uinput` permissions. +4. Whether `Surface Dial Renewed Touchpad` appears in `/proc/bus/input/devices`. + +## Sensitivity OSD Warnings + +The service may log messages from `qdbus6` about locale fallback, for example +`Detected locale "C"`. The OSD still works. This is caused by the systemd user +service environment lacking a UTF-8 locale variable. + +If it becomes noisy, set locale environment for the user service or replace the +shell-out OSD backend with a direct D-Bus implementation. + +## Reconnect Oddities + +After Bluetooth reconnects, event numbers can change. Always rediscover with: + +```bash +rg -n -C 8 "Surface Dial" /proc/bus/input/devices +``` + +If button events appear but rotation does not, disconnect and reconnect the Dial +again. The driver cannot synthesize rotation if the kernel is not emitting +`REL_DIAL`. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f809a6f --- /dev/null +++ b/install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +driver_dir="${repo_dir}/renewed_driver" + +cargo install --path "${driver_dir}" + +sudo install -Dm644 "${driver_dir}/install/70-surface-dial-renewed-uinput.rules" \ + /etc/udev/rules.d/70-surface-dial-renewed-uinput.rules +sudo install -Dm644 "${driver_dir}/install/70-surface-dial-renewed-input.rules" \ + /etc/udev/rules.d/70-surface-dial-renewed-input.rules +sudo install -Dm644 "${driver_dir}/install/70-surface-dial-renewed-hidraw.rules" \ + /etc/udev/rules.d/70-surface-dial-renewed-hidraw.rules +sudo udevadm control --reload-rules +sudo udevadm trigger + +install -Dm644 "${driver_dir}/install/surface-dial-renewed.service" \ + "${HOME}/.config/systemd/user/surface-dial-renewed.service" + +systemctl --user daemon-reload +systemctl --user enable --now surface-dial-renewed.service + +systemctl --user --no-pager status surface-dial-renewed.service diff --git a/install/70-surface-dial-renewed-hidraw.rules b/install/70-surface-dial-renewed-hidraw.rules new file mode 100644 index 0000000..61561b1 --- /dev/null +++ b/install/70-surface-dial-renewed-hidraw.rules @@ -0,0 +1 @@ +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="045e", ATTRS{idProduct}=="091b", TAG+="uaccess", TAG+="udev-acl" diff --git a/install/70-surface-dial-renewed-input.rules b/install/70-surface-dial-renewed-input.rules new file mode 100644 index 0000000..7952310 --- /dev/null +++ b/install/70-surface-dial-renewed-input.rules @@ -0,0 +1,2 @@ +KERNEL=="event*", SUBSYSTEM=="input", ATTRS{name}=="Surface Dial System Multi Axis", TAG+="uaccess", TAG+="udev-acl" +KERNEL=="event*", SUBSYSTEM=="input", ATTRS{name}=="Surface Dial System Control", TAG+="uaccess", TAG+="udev-acl" diff --git a/install/70-surface-dial-renewed-uinput.rules b/install/70-surface-dial-renewed-uinput.rules new file mode 100644 index 0000000..d2a6315 --- /dev/null +++ b/install/70-surface-dial-renewed-uinput.rules @@ -0,0 +1 @@ +KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", TAG+="udev-acl" diff --git a/install/README.md b/install/README.md new file mode 100644 index 0000000..0e547e9 --- /dev/null +++ b/install/README.md @@ -0,0 +1,77 @@ +# Installing `surface-dial-renewed` + +These files install the renewed driver as a systemd user service. The service +runs as your desktop user so it can access your session D-Bus for KDE OSD. + +## Build and Install the Binary + +Automatic install from the repository root: + +```bash +./renewed_driver/install.sh +``` + +Manual install: + +```bash +cargo install --path renewed_driver +``` + +This installs: + +```text +~/.cargo/bin/surface-dial-renewed +``` + +## Install Device Permission Rules + +```bash +sudo cp renewed_driver/install/70-surface-dial-renewed-uinput.rules /etc/udev/rules.d/ +sudo cp renewed_driver/install/70-surface-dial-renewed-input.rules /etc/udev/rules.d/ +sudo cp renewed_driver/install/70-surface-dial-renewed-hidraw.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +If the service still cannot access input devices, add your user to the group +that owns `/dev/input/event*` on your system, then log out and back in: + +```bash +sudo gpasswd -a "$(whoami)" "$(stat -c '%G' /dev/input/event0)" +``` + +## Install the User Service + +```bash +mkdir -p ~/.config/systemd/user +cp renewed_driver/install/surface-dial-renewed.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now surface-dial-renewed.service +``` + +## Check Status and Logs + +```bash +systemctl --user status surface-dial-renewed.service +journalctl --user -u surface-dial-renewed.service -f +``` + +## Uninstall + +Automatic uninstall from the repository root: + +```bash +./renewed_driver/uninstall.sh +``` + +Manual uninstall: + +```bash +systemctl --user disable --now surface-dial-renewed.service +rm ~/.config/systemd/user/surface-dial-renewed.service +sudo rm /etc/udev/rules.d/70-surface-dial-renewed-uinput.rules +sudo rm /etc/udev/rules.d/70-surface-dial-renewed-input.rules +sudo rm /etc/udev/rules.d/70-surface-dial-renewed-hidraw.rules +systemctl --user daemon-reload +sudo udevadm control --reload-rules +``` diff --git a/install/surface-dial-renewed.service b/install/surface-dial-renewed.service new file mode 100644 index 0000000..b489a3c --- /dev/null +++ b/install/surface-dial-renewed.service @@ -0,0 +1,15 @@ +[Unit] +Description=Surface Dial Renewed Driver +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.cargo/bin/surface-dial-renewed --mode multitouch +Restart=on-failure +RestartSec=2 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target diff --git a/src/dial.rs b/src/dial.rs new file mode 100644 index 0000000..9e51f48 --- /dev/null +++ b/src/dial.rs @@ -0,0 +1,261 @@ +use std::fs; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use evdev_rs::enums::{EventCode, EventType, EV_KEY, EV_REL}; +use evdev_rs::{InputEvent, ReadFlag}; + +use crate::error::{Error, Result}; + +const MULTI_AXIS_NAME: &str = r#""Surface Dial System Multi Axis""#; + +#[derive(Debug)] +pub struct DialEvent { + pub kind: DialEventKind, +} + +#[derive(Debug)] +pub enum DialEventKind { + Connected, + Disconnected, + Ignored, + ButtonPress, + ButtonRelease, + ButtonLongPress, + Rotate(i32), +} + +enum RawDialEvent { + Connected, + Disconnected, + Input(InputEvent), +} + +pub struct DialReader { + rx: mpsc::Receiver, + long_press_timeout: Duration, + // Long-press is not used by the current UX, but keeping the detection here + // leaves room for a future mode/menu without complicating the main loop. + long_press_deadline: Option, +} + +impl DialReader { + pub fn spawn(long_press_timeout: Duration) -> Result { + let (tx, rx) = mpsc::channel(); + + std::thread::Builder::new() + .name("dial-input".to_string()) + .spawn(move || { + if let Err(err) = input_worker(tx) { + eprintln!("surface-dial-renewed: input worker stopped: {err}"); + } + })?; + + Ok(Self { + rx, + long_press_timeout, + long_press_deadline: None, + }) + } + + pub fn next_event(&mut self, idle_timeout: Option) -> Result> { + let timeout = self.next_timeout(idle_timeout); + + let raw = match timeout { + Some(timeout) => match self.rx.recv_timeout(timeout) { + Ok(raw) => raw, + Err(mpsc::RecvTimeoutError::Timeout) => { + if self.long_press_due() { + self.long_press_deadline = None; + return Ok(Some(DialEvent { + kind: DialEventKind::ButtonLongPress, + })); + } + + return Ok(None); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(Error::UnexpectedEvent( + "input worker disconnected".to_string(), + )); + } + }, + None => self + .rx + .recv() + .map_err(|_| Error::UnexpectedEvent("input worker disconnected".to_string()))?, + }; + + let event = match raw { + RawDialEvent::Connected => DialEvent { + kind: DialEventKind::Connected, + }, + RawDialEvent::Disconnected => DialEvent { + kind: DialEventKind::Disconnected, + }, + RawDialEvent::Input(input) => decode_input(input)?, + }; + + match event.kind { + DialEventKind::ButtonPress => { + self.long_press_deadline = Some(Instant::now() + self.long_press_timeout); + } + DialEventKind::ButtonRelease | DialEventKind::Rotate(_) => { + self.long_press_deadline = None; + } + _ => {} + } + + Ok(Some(event)) + } + + fn next_timeout(&self, idle_timeout: Option) -> Option { + let long_press_timeout = self + .long_press_deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now())); + + match (idle_timeout, long_press_timeout) { + (Some(idle), Some(long_press)) => Some(idle.min(long_press)), + (Some(idle), None) => Some(idle), + (None, Some(long_press)) => Some(long_press), + (None, None) => None, + } + } + + fn long_press_due(&self) -> bool { + self.long_press_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + } +} + +fn input_worker(tx: mpsc::Sender) -> std::io::Result<()> { + loop { + // First handle already-connected Dials. Bluetooth reconnects can create + // new event node numbers, so each outer loop does a fresh udev scan. + match find_existing_device() { + Ok(Some(device)) => { + if let Err(err) = read_device(device, &tx) { + eprintln!("surface-dial-renewed: input read failed: {err}"); + let _ = tx.send(RawDialEvent::Disconnected); + std::thread::sleep(Duration::from_millis(250)); + } + continue; + } + Ok(None) => {} + Err(err) => { + eprintln!("surface-dial-renewed: input scan failed: {err}"); + std::thread::sleep(Duration::from_millis(250)); + } + } + + // If there is no current Dial, block on udev add events and immediately + // read the first matching device. Errors are logged and retried so a + // transient Bluetooth/udev issue does not kill the daemon. + if let Err(err) = wait_for_added_device(&tx) { + eprintln!("surface-dial-renewed: input monitor failed: {err}"); + std::thread::sleep(Duration::from_millis(250)); + } + } +} + +fn find_existing_device() -> std::io::Result> { + let mut enumerator = udev::Enumerator::new()?; + enumerator.match_subsystem("input")?; + + for device in enumerator.scan_devices()? { + if let Some(device) = udev_to_evdev(&device)? { + return Ok(Some(device)); + } + } + + Ok(None) +} + +fn wait_for_added_device(tx: &mpsc::Sender) -> std::io::Result<()> { + let mut socket = udev::MonitorBuilder::new()? + .match_subsystem("input")? + .listen()?; + + loop { + let Some(event) = socket.next() else { + std::thread::sleep(Duration::from_millis(25)); + continue; + }; + + if !matches!(event.event_type(), udev::EventType::Add) { + continue; + } + + let Some(device) = udev_to_evdev(&event.device())? else { + continue; + }; + + read_device(device, tx)?; + return Ok(()); + } +} + +fn udev_to_evdev(device: &udev::Device) -> std::io::Result> { + let Some(devnode) = device.devnode() else { + return Ok(None); + }; + + let Some(parent) = device.parent() else { + return Ok(None); + }; + + // The /dev/input/eventXX node itself is a child. The readable device name + // lives on its parent input device. + let name = parent + .property_value("NAME") + .unwrap_or_else(|| std::ffi::OsStr::new("")) + .to_string_lossy(); + + if name != MULTI_AXIS_NAME { + return Ok(None); + } + + let file = fs::File::open(devnode)?; + evdev_rs::Device::new_from_fd(file).map(Some) +} + +fn read_device(device: evdev_rs::Device, tx: &mpsc::Sender) -> std::io::Result<()> { + let _ = tx.send(RawDialEvent::Connected); + + loop { + match device.next_event(ReadFlag::BLOCKING) { + Ok((_status, event)) => { + let _ = tx.send(RawDialEvent::Input(event)); + } + // ENODEV means the Bluetooth input device disappeared. + Err(err) if err.raw_os_error() == Some(19) => break, + Err(err) => return Err(err), + } + } + + let _ = tx.send(RawDialEvent::Disconnected); + Ok(()) +} + +fn decode_input(input: InputEvent) -> Result { + // Keep the public event surface deliberately small: BTN_0 is the button, + // REL_DIAL is rotation, SYN/MSC framing is noise for this driver. + let kind = match input.event_type { + EventType::EV_SYN | EventType::EV_MSC => DialEventKind::Ignored, + EventType::EV_KEY => match input.event_code { + EventCode::EV_KEY(EV_KEY::BTN_0) => match input.value { + 0 => DialEventKind::ButtonRelease, + 1 => DialEventKind::ButtonPress, + _ => return Err(Error::UnexpectedEvent(format!("{input:?}"))), + }, + _ => DialEventKind::Ignored, + }, + EventType::EV_REL => match input.event_code { + EventCode::EV_REL(EV_REL::REL_DIAL) => DialEventKind::Rotate(input.value), + _ => DialEventKind::Ignored, + }, + _ => DialEventKind::Ignored, + }; + + Ok(DialEvent { kind }) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..bd9c32b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,42 @@ +use std::fmt; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + Io(std::io::Error), + Hid(hidapi::HidError), + Notify(notify_rust::error::Error), + UnexpectedEvent(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(err) => write!(f, "{err}"), + Error::Hid(err) => write!(f, "{err}"), + Error::Notify(err) => write!(f, "{err}"), + Error::UnexpectedEvent(event) => write!(f, "unexpected input event: {event}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::Io(err) + } +} + +impl From for Error { + fn from(err: notify_rust::error::Error) -> Self { + Error::Notify(err) + } +} + +impl From for Error { + fn from(err: hidapi::HidError) -> Self { + Error::Hid(err) + } +} diff --git a/src/haptics.rs b/src/haptics.rs new file mode 100644 index 0000000..b0a174f --- /dev/null +++ b/src/haptics.rs @@ -0,0 +1,76 @@ +use hidapi::{HidApi, HidDevice}; + +const SURFACE_DIAL_VENDOR_ID: u16 = 0x045e; +const SURFACE_DIAL_PRODUCT_ID: u16 = 0x091b; + +pub struct Haptics { + device: Option, +} + +impl Haptics { + pub fn new() -> Self { + Self { device: None } + } + + pub fn reconnect(&mut self) { + // HID raw is separate from evdev. Treat it as optional: lack of hidraw + // permission should not break scrolling. + if self.device.is_some() { + return; + } + + self.device = match DialHid::open() { + Ok(device) => Some(device), + Err(err) => { + eprintln!("surface-dial-renewed: haptics unavailable: {err}"); + None + } + }; + } + + pub fn disconnect(&mut self) { + self.device = None; + } + + pub fn buzz(&mut self, repeat: u8) { + let Some(device) = &self.device else { + return; + }; + + // If the Bluetooth HID handle went stale, drop it. The next Dial + // reconnect event will try to reopen haptics. + if let Err(err) = device.buzz(repeat) { + eprintln!("surface-dial-renewed: haptics failed: {err}"); + self.device = None; + } + } +} + +struct DialHid { + // Keep HidApi alive for at least as long as HidDevice. Some hidapi backends + // tie device lifetime to the owning API context. + device: HidDevice, + _api: HidApi, +} + +impl DialHid { + fn open() -> Result { + let api = HidApi::new()?; + let device = api.open(SURFACE_DIAL_VENDOR_ID, SURFACE_DIAL_PRODUCT_ID)?; + Ok(Self { device, _api: api }) + } + + fn buzz(&self, repeat: u8) -> Result<(), hidapi::HidError> { + // Manual haptic trigger report, ported from the old driver. We only use + // this for selector entry and level changes. + let buf = [ + 0x01, // Report ID + repeat, // RepeatCount + 0x03, // ManualTrigger + 0x00, // RetriggerPeriod low byte + 0x00, // RetriggerPeriod high byte + ]; + self.device.write(&buf)?; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..56d8444 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,275 @@ +mod dial; +mod error; +mod haptics; +mod notify; +mod sensitivity; +mod virtual_input; + +use std::time::Duration; + +use dial::{DialEvent, DialEventKind, DialReader}; +use error::Result; +use haptics::Haptics; +use notify::SensitivityNotifier; +use sensitivity::Sensitivity; +use virtual_input::{VirtualInput, WheelDirection}; + +const MULTITOUCH_IDLE_TIMEOUT: Duration = Duration::from_millis(2_000); +const MULTITOUCH_SETTLE_TIMEOUT: Duration = Duration::from_millis(80); +const SENSITIVITY_STEP_UNITS: i32 = 48; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum OutputMode { + Multitouch, + Wheel, +} + +#[derive(Debug)] +struct DriverState { + mode: OutputMode, + sensitivity: Sensitivity, + button_down: bool, + adjusted_while_pressed: bool, + sensitivity_accumulator: i32, + // Active synthetic touchpad gesture state. These are kept in the main loop + // so all uinput writes happen from one place and gesture cleanup is explicit. + mt_active: bool, + mt_position: i32, + mt_remainder_milli: i32, +} + +impl DriverState { + fn new(mode: OutputMode) -> Self { + Self { + mode, + sensitivity: Sensitivity::default(), + button_down: false, + adjusted_while_pressed: false, + sensitivity_accumulator: 0, + mt_active: false, + mt_position: 0, + mt_remainder_milli: 0, + } + } +} + +fn main() { + if let Err(err) = run() { + eprintln!("surface-dial-renewed: {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let mode = parse_mode(std::env::args().skip(1)); + let mut state = DriverState::new(mode); + // Input reading runs on a worker thread; output devices stay owned by the + // main thread to avoid sharing uinput handles across threads. + let mut dial = DialReader::spawn(Duration::from_millis(750))?; + let mut output = VirtualInput::new()?; + let mut notifier = SensitivityNotifier::new(); + let mut haptics = Haptics::new(); + + eprintln!("surface-dial-renewed: running in {mode:?} mode"); + + loop { + // While a synthetic two-finger gesture is active, recv with a timeout. + // Timeout means "the user stopped rotating", so the virtual fingers can + // be lifted. The 2s delay is deliberate: very slow Dial movement emits + // sparse REL_DIAL events, and libinput needs one continuous gesture to + // recognize those as precise scroll movement. + let timeout = if state.mt_active { + Some(MULTITOUCH_IDLE_TIMEOUT) + } else { + None + }; + + match dial.next_event(timeout)? { + Some(event) => { + handle_event(event, &mut state, &mut output, &mut notifier, &mut haptics)? + } + None => end_multitouch_if_active(&mut state, &mut output)?, + } + } +} + +fn handle_event( + event: DialEvent, + state: &mut DriverState, + output: &mut VirtualInput, + notifier: &mut SensitivityNotifier, + haptics: &mut Haptics, +) -> Result<()> { + match event.kind { + DialEventKind::Connected => { + eprintln!("surface-dial-renewed: dial connected"); + // Haptics are tied to the HID raw device, not the evdev input node, + // so reconnect them when the input side confirms the Dial is back. + haptics.reconnect(); + } + DialEventKind::Disconnected => { + end_multitouch_if_active(state, output)?; + haptics.disconnect(); + eprintln!("surface-dial-renewed: dial disconnected"); + } + DialEventKind::ButtonPress => { + // Holding the button turns rotation into sensitivity adjustment. The + // buzz is the tactile "selector entered" cue. + state.button_down = true; + state.adjusted_while_pressed = false; + state.sensitivity_accumulator = 0; + haptics.buzz(1); + } + DialEventKind::ButtonRelease => { + state.button_down = false; + state.adjusted_while_pressed = false; + state.sensitivity_accumulator = 0; + } + DialEventKind::ButtonLongPress => { + // The concept driver reserves long-press for future mode switching. + } + DialEventKind::Rotate(delta) => { + if state.button_down { + // Never scroll while the sensitivity selector is active. End a + // pending touchpad gesture before showing the selector so the + // desktop does not see overlapping scroll and adjustment input. + end_multitouch_if_active(state, output)?; + adjust_sensitivity(delta, state, notifier, haptics)?; + return Ok(()); + } + + match state.mode { + OutputMode::Multitouch => multitouch_scroll(delta, state, output)?, + OutputMode::Wheel => wheel_scroll(delta, state, output)?, + } + } + DialEventKind::Ignored => {} + } + + Ok(()) +} + +fn adjust_sensitivity( + delta: i32, + state: &mut DriverState, + notifier: &mut SensitivityNotifier, + haptics: &mut Haptics, +) -> Result<()> { + // The Dial emits many REL_DIAL events per visible detent. Accumulating raw + // units makes the 1-32 selector controllable instead of changing a level for + // every tiny event. + state.sensitivity_accumulator = state.sensitivity_accumulator.saturating_add(delta); + + while state.sensitivity_accumulator.abs() >= SENSITIVITY_STEP_UNITS { + let direction = state.sensitivity_accumulator.signum(); + state.sensitivity_accumulator -= direction * SENSITIVITY_STEP_UNITS; + + if state.sensitivity.adjust(direction) { + notifier.show(state.sensitivity)?; + haptics.buzz(1); + eprintln!( + "surface-dial-renewed: sensitivity level {}", + state.sensitivity.level() + ); + state.adjusted_while_pressed = true; + } + } + + Ok(()) +} + +fn multitouch_scroll(delta: i32, state: &mut DriverState, output: &mut VirtualInput) -> Result<()> { + if !state.mt_active { + state.mt_position = 0; + output.multitouch_start()?; + state.mt_active = true; + } + + // Sensitivity is expressed in milli-pixels so low sensitivity can preserve + // fractional motion. A nonzero raw delta rounds away from zero; otherwise a + // tiny slow rotation could be observed by evtest but never move the virtual + // fingers enough to give libinput a chance to scroll. + let scaled_delta = delta + .saturating_mul(state.sensitivity.multitouch_milli_pixels()) + .saturating_add(state.mt_remainder_milli); + let mut whole_delta = scaled_delta / 1_000; + state.mt_remainder_milli = scaled_delta % 1_000; + + if whole_delta == 0 && scaled_delta != 0 { + whole_delta = scaled_delta.signum(); + state.mt_remainder_milli = 0; + } + + state.mt_position = state.mt_position.saturating_add(whole_delta); + + if state.mt_position.abs() > virtual_input::MT_RECENTER_THRESHOLD { + // Long scrolls would eventually hit the virtual touchpad coordinate + // limits. Recreate the two-finger contact near the baseline and keep + // only the latest movement. + output.multitouch_end()?; + output.multitouch_start()?; + state.mt_position = whole_delta; + state.mt_remainder_milli = 0; + } + + output.multitouch_move(state.mt_position)?; + Ok(()) +} + +fn wheel_scroll(delta: i32, state: &DriverState, output: &mut VirtualInput) -> Result<()> { + let ticks = delta.abs().saturating_mul(state.sensitivity.wheel_ticks()); + let direction = if delta > 0 { + WheelDirection::Up + } else { + WheelDirection::Down + }; + + for _ in 0..ticks.max(1) { + output.wheel_step(direction)?; + } + + Ok(()) +} + +fn end_multitouch_if_active(state: &mut DriverState, output: &mut VirtualInput) -> Result<()> { + if state.mt_active { + // Emit one stationary frame and wait briefly before lifting fingers. + // This made libinput less likely to interpret the final delta as a + // touchpad flick/coast. + output.multitouch_settle(state.mt_position)?; + std::thread::sleep(MULTITOUCH_SETTLE_TIMEOUT); + output.multitouch_end()?; + state.mt_active = false; + state.mt_position = 0; + state.mt_remainder_milli = 0; + } + + Ok(()) +} + +fn parse_mode(args: impl Iterator) -> OutputMode { + let mut mode = OutputMode::Multitouch; + let mut previous = String::new(); + + for arg in args { + if previous == "--mode" { + mode = parse_mode_value(&arg); + } else if let Some(value) = arg.strip_prefix("--mode=") { + mode = parse_mode_value(value); + } + previous = arg; + } + + mode +} + +fn parse_mode_value(value: &str) -> OutputMode { + match value { + "wheel" | "scroll" | "regular" => OutputMode::Wheel, + "multitouch" | "mt" | "touchpad" => OutputMode::Multitouch, + other => { + eprintln!("surface-dial-renewed: unknown mode '{other}', using multitouch"); + OutputMode::Multitouch + } + } +} diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..6600367 --- /dev/null +++ b/src/notify.rs @@ -0,0 +1,75 @@ +use std::process::Command; + +use notify_rust::{Hint, Notification, NotificationHandle, Timeout}; + +use crate::error::Result; +use crate::sensitivity::Sensitivity; + +pub struct SensitivityNotifier { + handle: Option, +} + +impl SensitivityNotifier { + pub fn new() -> Self { + Self { handle: None } + } + + pub fn show(&mut self, sensitivity: Sensitivity) -> Result<()> { + let text = sensitivity_text(sensitivity); + // KDE notifications can collapse/suppress rapid replacement updates. + // Plasma's OSD text path is more reliable for a repeatedly-updated + // sensitivity readout, so try it first. + if show_kde_osd(&text) { + return Ok(()); + } + + // Non-KDE fallback. Replacement by notification id prevents stacking + // many sensitivity notifications. + let mut notification = Notification::new(); + notification + .appname("surface-dial-renewed") + .summary(&format!( + "Surface Dial sensitivity: {}", + sensitivity.level() + )) + .body(&text) + .icon("input-mouse") + .hint(Hint::Transient(true)) + .hint(Hint::Category("device".to_string())) + .hint(Hint::CustomInt( + "value".to_string(), + sensitivity.percent().clamp(0, 100), + )) + .timeout(Timeout::Milliseconds(1_800)); + + if let Some(handle) = &self.handle { + notification.id(handle.id()); + } + + self.handle = Some(notification.show()?); + Ok(()) + } +} + +fn show_kde_osd(text: &str) -> bool { + // Shelling out keeps the dependency set simple. A future direct D-Bus + // backend would avoid qdbus locale warnings in journal output. + Command::new("qdbus6") + .args([ + "org.kde.plasmashell", + "/org/kde/osdService", + "org.kde.osdService.showText", + "input-mouse", + text, + ]) + .status() + .is_ok_and(|status| status.success()) +} + +fn sensitivity_text(sensitivity: Sensitivity) -> String { + format!( + "Surface Dial sensitivity {}/{}", + sensitivity.level(), + Sensitivity::MAX + ) +} diff --git a/src/sensitivity.rs b/src/sensitivity.rs new file mode 100644 index 0000000..444b70d --- /dev/null +++ b/src/sensitivity.rs @@ -0,0 +1,43 @@ +#[derive(Debug, Clone, Copy)] +pub struct Sensitivity { + level: i32, +} + +impl Default for Sensitivity { + fn default() -> Self { + Self { level: 7 } + } +} + +impl Sensitivity { + pub const MIN: i32 = 1; + pub const MAX: i32 = 32; + + pub fn level(self) -> i32 { + self.level + } + + pub fn percent(self) -> i32 { + ((self.level - Self::MIN) * 100) / (Self::MAX - Self::MIN) + } + + pub fn adjust(&mut self, direction: i32) -> bool { + let old = self.level; + self.level = (self.level + direction).clamp(Self::MIN, Self::MAX); + old != self.level + } + + pub fn multitouch_milli_pixels(self) -> i32 { + // Milli-pixels allow fractional scaling at low levels while the main + // gesture code still writes integer evdev coordinates. + 500 + (self.level - Self::MIN) * 250 + } + + pub fn wheel_ticks(self) -> i32 { + if self.level >= 8 { + 2 + } else { + 1 + } + } +} diff --git a/src/virtual_input.rs b/src/virtual_input.rs new file mode 100644 index 0000000..a251d08 --- /dev/null +++ b/src/virtual_input.rs @@ -0,0 +1,308 @@ +use std::io; + +use evdev_rs::enums::*; +use evdev_rs::{AbsInfo, Device, InputEvent, TimeVal, UInputDevice}; + +use crate::error::Result; + +pub const MT_BASELINE: i32 = 10_000; +pub const MT_RECENTER_THRESHOLD: i32 = MT_BASELINE / 2; + +// The touchpad coordinate space is synthetic. Large enough coordinates make +// long scrolls possible; recentering in main.rs prevents limit exhaustion. +const TOUCHPAD_MAX: i32 = MT_BASELINE * 2; +const SLOT_0_TRACKING_ID: i32 = 10; +const SLOT_1_TRACKING_ID: i32 = 11; +const FINGER_GAP: i32 = MT_BASELINE / 4; +const TOUCH_PRESSURE: i32 = 30; +const TOUCH_MAJOR: i32 = 700; +const TOOL_WIDTH: i32 = 4; +const HI_RES_WHEEL_STEP: i32 = 120; + +pub struct VirtualInput { + wheel: UInputDevice, + touchpad: UInputDevice, +} + +#[derive(Debug, Clone, Copy)] +pub enum WheelDirection { + Up, + Down, +} + +impl VirtualInput { + pub fn new() -> Result { + let wheel = create_wheel_device()?; + let touchpad = create_touchpad_device()?; + + // Give udev/libinput a brief chance to notice the new virtual devices + // before the first synthetic event is written. + std::thread::sleep(std::time::Duration::from_millis(250)); + + Ok(Self { wheel, touchpad }) + } + + pub fn wheel_step(&mut self, direction: WheelDirection) -> Result<()> { + let value = match direction { + WheelDirection::Up => 1, + WheelDirection::Down => -1, + }; + + self.wheel + .write_event(&event_rel(EV_REL::REL_WHEEL, value))?; + self.wheel.write_event(&event_rel( + EV_REL::REL_WHEEL_HI_RES, + value * HI_RES_WHEEL_STEP, + ))?; + self.wheel_syn()?; + Ok(()) + } + + pub fn multitouch_start(&mut self) -> Result<()> { + // Type-B multitouch start: create slot 0, report one-finger state, then + // create slot 1 and switch to two-finger state. This shape mirrors a + // real touchpad closely enough for libinput to classify it as scrolling. + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_SLOT, 0))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_TRACKING_ID, SLOT_0_TRACKING_ID))?; + self.touchpad.write_event(&event_abs( + EV_ABS::ABS_MT_POSITION_X, + MT_BASELINE - FINGER_GAP, + ))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_POSITION_Y, MT_BASELINE))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_PRESSURE, TOUCH_PRESSURE))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_TOUCH_MAJOR, TOUCH_MAJOR))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOUCH, 1))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_FINGER, 1))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_DOUBLETAP, 1))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_X, MT_BASELINE - FINGER_GAP))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_Y, MT_BASELINE))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_PRESSURE, TOUCH_PRESSURE))?; + self.touch_syn()?; + + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_SLOT, 1))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_TRACKING_ID, SLOT_1_TRACKING_ID))?; + self.touchpad.write_event(&event_abs( + EV_ABS::ABS_MT_POSITION_X, + MT_BASELINE + FINGER_GAP, + ))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_POSITION_Y, MT_BASELINE))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_PRESSURE, TOUCH_PRESSURE))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_TOUCH_MAJOR, TOUCH_MAJOR))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_TOOL_WIDTH, TOOL_WIDTH))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_FINGER, 0))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_DOUBLETAP, 1))?; + self.touch_syn()?; + + Ok(()) + } + + pub fn multitouch_move(&mut self, y_delta: i32) -> Result<()> { + let y = (MT_BASELINE + y_delta).clamp(1, TOUCHPAD_MAX - 1); + + self.write_multitouch_y(y)?; + self.touch_syn()?; + + Ok(()) + } + + pub fn multitouch_settle(&mut self, y_delta: i32) -> Result<()> { + // A final stationary frame before lift-off reduces accidental flicking + // or coasting at the end of a synthetic scroll gesture. + let y = (MT_BASELINE + y_delta).clamp(1, TOUCHPAD_MAX - 1); + + self.write_multitouch_y(y)?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_PRESSURE, TOUCH_PRESSURE))?; + self.touch_syn()?; + + Ok(()) + } + + fn write_multitouch_y(&mut self, y: i32) -> Result<()> { + // Move both virtual fingers together. For two-finger scrolling, libinput + // cares about the gesture movement, not about preserving a changing + // distance between fingers. + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_SLOT, 0))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_POSITION_Y, y))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_SLOT, 1))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_POSITION_Y, y))?; + self.touchpad.write_event(&event_abs(EV_ABS::ABS_Y, y))?; + Ok(()) + } + + pub fn multitouch_end(&mut self) -> Result<()> { + // End both tracking IDs and clear touch/tool state. If this is skipped + // because the process is killed, the uinput device disappearing should + // still clear the state in the compositor. + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_SLOT, 0))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_TRACKING_ID, -1))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_SLOT, 1))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_MT_TRACKING_ID, -1))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOUCH, 0))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_FINGER, 0))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_DOUBLETAP, 0))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_TRIPLETAP, 0))?; + self.touchpad + .write_event(&event_key(EV_KEY::BTN_TOOL_QUADTAP, 0))?; + self.touchpad + .write_event(&event_abs(EV_ABS::ABS_PRESSURE, 0))?; + self.touch_syn()?; + + Ok(()) + } + + fn wheel_syn(&mut self) -> io::Result<()> { + self.wheel.write_event(&event_syn()) + } + + fn touch_syn(&mut self) -> io::Result<()> { + self.touchpad.write_event(&event_syn()) + } +} + +fn create_wheel_device() -> io::Result { + let device = Device::new().expect("evdev device allocation failed"); + device.set_name("Surface Dial Renewed Wheel"); + + device.enable(&EventType::EV_SYN)?; + device.enable(&EventCode::EV_SYN(EV_SYN::SYN_REPORT))?; + device.enable(&EventType::EV_REL)?; + device.enable(&EventCode::EV_REL(EV_REL::REL_WHEEL))?; + device.enable(&EventCode::EV_REL(EV_REL::REL_WHEEL_HI_RES))?; + + UInputDevice::create_from_device(&device) +} + +fn create_touchpad_device() -> io::Result { + let device = Device::new().expect("evdev device allocation failed"); + device.set_name("Surface Dial Renewed Touchpad"); + + device.enable(&InputProp::INPUT_PROP_POINTER)?; + // Do not advertise BUTTONPAD or BTN_LEFT. Those made very short gestures + // easier for the desktop stack to treat as tap/click behavior. + + device.enable(&EventType::EV_SYN)?; + device.enable(&EventCode::EV_SYN(EV_SYN::SYN_REPORT))?; + + device.enable(&EventType::EV_KEY)?; + device.enable(&EventCode::EV_KEY(EV_KEY::BTN_TOUCH))?; + device.enable(&EventCode::EV_KEY(EV_KEY::BTN_TOOL_FINGER))?; + device.enable(&EventCode::EV_KEY(EV_KEY::BTN_TOOL_DOUBLETAP))?; + device.enable(&EventCode::EV_KEY(EV_KEY::BTN_TOOL_TRIPLETAP))?; + device.enable(&EventCode::EV_KEY(EV_KEY::BTN_TOOL_QUADTAP))?; + + device.enable(&EventType::EV_ABS)?; + enable_abs(&device, EV_ABS::ABS_X, 0, TOUCHPAD_MAX, MT_BASELINE)?; + enable_abs(&device, EV_ABS::ABS_Y, 0, TOUCHPAD_MAX, MT_BASELINE)?; + enable_abs(&device, EV_ABS::ABS_PRESSURE, 0, 255, 0)?; + enable_abs(&device, EV_ABS::ABS_TOOL_WIDTH, 0, 15, 0)?; + enable_abs(&device, EV_ABS::ABS_MT_SLOT, 0, 4, 0)?; + enable_abs(&device, EV_ABS::ABS_MT_TOUCH_MAJOR, 0, 2_500, 0)?; + enable_abs(&device, EV_ABS::ABS_MT_TRACKING_ID, 0, 65535, 0)?; + enable_abs( + &device, + EV_ABS::ABS_MT_POSITION_X, + 0, + TOUCHPAD_MAX, + MT_BASELINE, + )?; + enable_abs( + &device, + EV_ABS::ABS_MT_POSITION_Y, + 0, + TOUCHPAD_MAX, + MT_BASELINE, + )?; + enable_abs(&device, EV_ABS::ABS_MT_PRESSURE, 0, 255, 0)?; + + UInputDevice::create_from_device(&device) +} + +fn enable_abs( + device: &Device, + code: EV_ABS, + minimum: i32, + maximum: i32, + value: i32, +) -> io::Result<()> { + // Resolution affects how libinput interprets physical movement. Keep this + // consistent across axes unless the gesture scaling is retuned deliberately. + device.enable_event_code( + &EventCode::EV_ABS(code), + Some(&AbsInfo { + value, + minimum, + maximum, + fuzz: 0, + flat: 0, + resolution: 32, + }), + ) +} + +fn event_syn() -> InputEvent { + InputEvent { + time: TimeVal::new(0, 0), + event_code: EventCode::EV_SYN(EV_SYN::SYN_REPORT), + event_type: EventType::EV_SYN, + value: 0, + } +} + +fn event_key(code: EV_KEY, value: i32) -> InputEvent { + InputEvent { + time: TimeVal::new(0, 0), + event_code: EventCode::EV_KEY(code), + event_type: EventType::EV_KEY, + value, + } +} + +fn event_rel(code: EV_REL, value: i32) -> InputEvent { + InputEvent { + time: TimeVal::new(0, 0), + event_code: EventCode::EV_REL(code), + event_type: EventType::EV_REL, + value, + } +} + +fn event_abs(code: EV_ABS, value: i32) -> InputEvent { + InputEvent { + time: TimeVal::new(0, 0), + event_code: EventCode::EV_ABS(code), + event_type: EventType::EV_ABS, + value, + } +} diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..84ed9a6 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +systemctl --user disable --now surface-dial-renewed.service || true +rm -f "${HOME}/.config/systemd/user/surface-dial-renewed.service" +systemctl --user daemon-reload + +sudo rm -f /etc/udev/rules.d/70-surface-dial-renewed-uinput.rules +sudo rm -f /etc/udev/rules.d/70-surface-dial-renewed-input.rules +sudo rm -f /etc/udev/rules.d/70-surface-dial-renewed-hidraw.rules +sudo udevadm control --reload-rules + +cargo uninstall surface-dial-renewed || true