diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e19605..df89196 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,7 @@ jobs: -DBUILD_EXAMPLES=ON \ -DBUILD_TESTS=ON \ -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_CONFIG} \ + -DLIBVIRTUALHID_BUILD_TOOLS=ON \ -B cmake-build-ci \ -G Ninja \ -S . @@ -226,6 +227,7 @@ jobs: -DBUILD_DOCS=OFF ` -DBUILD_EXAMPLES=ON ` -DBUILD_TESTS=ON ` + -DLIBVIRTUALHID_BUILD_TOOLS=ON ` -A x64 ` -B cmake-build-ci ` -G "Visual Studio 17 2022" ` @@ -339,7 +341,9 @@ jobs: } & $openCppCoverage ` + --sources "$env:GITHUB_WORKSPACE\examples" ` --sources "$env:GITHUB_WORKSPACE\src" ` + --sources "$env:GITHUB_WORKSPACE\tools" ` "--export_type=cobertura:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\coverage.xml" ` --working_dir "$env:GITHUB_WORKSPACE\cmake-build-ci\tests" ` -- ` @@ -375,6 +379,9 @@ jobs: $coverage.Save($coveragePath) + - name: Run gamepad adapter example + run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --target run_gamepad_adapter_example + - name: Generate gcov report id: test_report if: >- @@ -386,8 +393,13 @@ jobs: GCOV_EXECUTABLE: ${{ matrix.gcov_executable }} MSYS2_PATH_TYPE: inherit run: | - uv run --project ../third-party/lizardbyte-common --locked --no-sync gcovr . -r ../src \ + uv run --project ../third-party/lizardbyte-common --locked --no-sync gcovr . -r .. \ + --filter ../examples/ \ + --filter ../src/ \ + --filter ../tools/ \ --gcov-executable "${GCOV_EXECUTABLE}" \ + --exclude ../tests/ \ + --exclude ../third-party/ \ --exclude-noncode-lines \ --exclude-throw-branches \ --exclude-unreachable-branches \ @@ -395,22 +407,6 @@ jobs: --xml-pretty \ -o reports/coverage.xml - - name: Run gamepad adapter example - if: runner.os == 'Linux' - run: | - ./cmake-build-ci/examples/gamepad_adapter - - - name: Run gamepad adapter example Windows - if: runner.os == 'Windows' - shell: pwsh - run: | - if ("${{ matrix.kind }}" -eq "msys2") { - $env:PATH = "C:\msys64\${{ matrix.msystem }}\bin;C:\msys64\usr\bin;$env:PATH" - & .\cmake-build-ci\examples\gamepad_adapter.exe - } else { - & ".\cmake-build-ci\examples\$env:CMAKE_BUILD_CONFIG\gamepad_adapter.exe" - } - - name: Uninstall Windows driver installer if: >- always() && @@ -505,7 +501,7 @@ jobs: run: >- cmake --build cmake-build-driver --config ${{ env.DRIVER_BUILD_CONFIG }} - --target libvirtualhid_windows_catalog gamepad_adapter + --target libvirtualhid_windows_catalog gamepad_adapter virtualhid_control --parallel 2 - name: Validate Azure signing configuration diff --git a/.run/docs.run.xml b/.run/docs.run.xml index 3ce29f4..0af20da 100644 --- a/.run/docs.run.xml +++ b/.run/docs.run.xml @@ -1,5 +1,5 @@ - + diff --git a/CMakeLists.txt b/CMakeLists.txt index 1af5407..ccc9ad1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,9 @@ include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_version.cmake") option(BUILD_DOCS "Build documentation" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_TESTS "Build tests" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_EXAMPLES "Build examples" ${LIBVIRTUALHID_IS_TOP_LEVEL}) +option(LIBVIRTUALHID_BUILD_TOOLS "Build libvirtualhid diagnostic tools" ${LIBVIRTUALHID_IS_TOP_LEVEL}) +option(LIBVIRTUALHID_TOOLS_STATIC_RUNTIME "Link libvirtualhid tools to static compiler runtimes where supported" ON) +option(LIBVIRTUALHID_TOOLS_FULLY_STATIC "Attempt to link libvirtualhid tools as fully static binaries" OFF) option(LIBVIRTUALHID_ENABLE_XTEST "Enable X11/XTest keyboard and mouse fallback on Linux" ON) option(LIBVIRTUALHID_BUILD_WINDOWS_DRIVER "Build the Windows UMDF2 driver package with the WDK/MSVC toolchain" OFF) option(LIBVIRTUALHID_INSTALL @@ -107,6 +110,10 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) add_subdirectory(examples) endif() + if(LIBVIRTUALHID_BUILD_TOOLS) + add_subdirectory(tools) + endif() + if(BUILD_TESTS) enable_testing() add_subdirectory(tests) diff --git a/README.md b/README.md index 30081ea..b08354c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ behind backend implementations. HID Framework, with keyboard and mouse support through normal Win32 APIs. - Output callbacks for profile-specific feedback such as rumble, LEDs, adaptive triggers, and raw HID output reports when available. +- An optional `virtualhid_control` native UI tool for creating, removing, + controlling, and inspecting test gamepads through the public C++ API. - CMake consumption through installed packages, vendored source, `add_subdirectory`, or `FetchContent`. diff --git a/cmake/packaging/windows.cmake b/cmake/packaging/windows.cmake index 5e539dd..ced7e13 100644 --- a/cmake/packaging/windows.cmake +++ b/cmake/packaging/windows.cmake @@ -18,8 +18,6 @@ set(LIBVIRTUALHID_DRIVER_LICENSE_FILE install(FILES "${PROJECT_SOURCE_DIR}/scripts/windows/libvirtualhid-driver-common.ps1" "${PROJECT_SOURCE_DIR}/scripts/windows/install-driver.ps1" - "${PROJECT_SOURCE_DIR}/scripts/windows/test-browser-gamepad.ps1" - "${PROJECT_SOURCE_DIR}/scripts/windows/test-installed-driver.ps1" "${PROJECT_SOURCE_DIR}/scripts/windows/uninstall-driver.ps1" DESTINATION "scripts/windows" COMPONENT driver) @@ -30,10 +28,20 @@ if(NOT TARGET gamepad_adapter) "gamepad_adapter validation tool can be packaged.") endif() +if(NOT TARGET virtualhid_control) + message(FATAL_ERROR + "The Windows driver installer requires LIBVIRTUALHID_BUILD_TOOLS=ON " + "so the virtualhid_control UI tool can be packaged.") +endif() + install(TARGETS gamepad_adapter RUNTIME DESTINATION "tools/windows" COMPONENT driver) +install(TARGETS virtualhid_control + RUNTIME DESTINATION "tools/windows" + COMPONENT driver) + if(LIBVIRTUALHID_DRIVER_TEST_CERTIFICATE) install(FILES "${LIBVIRTUALHID_DRIVER_TEST_CERTIFICATE}" DESTINATION "certificates" diff --git a/docs/development.md b/docs/development.md index 4fb3a65..72e5034 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,6 +66,8 @@ code and tests provide a better source of truth. - `test_libvirtualhid`: shared GoogleTest binary under the build directory's `tests` directory. - `gamepad_adapter`: example executable for profile and lifecycle diagnostics. +- `run_gamepad_adapter_example`: CMake target that runs `gamepad_adapter` from + its generator-specific output path when a platform backend is available. - Windows driver helper scripts under `scripts/windows`. - Linux consumer tests through SDL2, libinput, `uhid`, and `uinput` where the host environment supports real virtual devices. diff --git a/docs/platform-support.md b/docs/platform-support.md index ca8e7d4..73c7346 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -56,6 +56,15 @@ reports, and output reports matter for controller compatibility. Keyboard and pointer devices prefer `uinput` because those devices map naturally to Linux input devices. +The optional `virtualhid_control` diagnostic UI is currently implemented with +native Windows controls. A future Linux UI should use a native toolkit that can +be packaged as a release asset. Static Linux linking is possible only when the +target distribution provides static archives for all selected backend and UI +dependencies, including `libevdev` and any enabled X11/XTest libraries. Many +distro toolchains intentionally omit some static archives, so release packaging +should keep full static linking as a packaging-mode choice rather than an +unconditional default. + ### Permissions Linux deployment is usually a permissions problem, not a kernel-module problem. diff --git a/docs/store-review-validation.md b/docs/store-review-validation.md index 14a9a04..0594645 100644 --- a/docs/store-review-validation.md +++ b/docs/store-review-validation.md @@ -14,50 +14,55 @@ Paste this into the Partner Center certification notes field: ```text This package installs the libvirtualhid Windows user-mode UMDF/VHF virtual HID -driver. It has no standalone end-user UI; applications consume it through the -libvirtualhid client API. +driver. Applications consume it through the libvirtualhid client API, and the +MSI includes a native diagnostic UI for local validation. -A prebuilt validation tool and PowerShell validation scripts are installed by -the MSI so no programming, SDK setup, or driver build environment is required. -Please install the released, production-signed MSI, then run the validation -commands below from PowerShell. +A prebuilt native validation tool is installed by the MSI so no programming, +SDK setup, or driver build environment is required. Please install the released, +production-signed MSI, then launch the validation tool below. Default install root: C:\Program Files\libvirtualhid Installed validation files: -C:\Program Files\libvirtualhid\scripts\windows\test-installed-driver.ps1 -C:\Program Files\libvirtualhid\scripts\windows\test-browser-gamepad.ps1 +C:\Program Files\libvirtualhid\tools\windows\virtualhid_control.exe C:\Program Files\libvirtualhid\tools\windows\gamepad_adapter.exe Required validation: $installRoot = Join-Path $env:ProgramFiles "libvirtualhid" -powershell -ExecutionPolicy Bypass -File "$installRoot\scripts\windows\test-installed-driver.ps1" ` - -GamepadAdapterPath "$installRoot\tools\windows\gamepad_adapter.exe" ` - -GamepadProfile xseries ` - -Verbose +& "$installRoot\tools\windows\virtualhid_control.exe" + +In the libvirtualhid control window, leave the default Xbox Series profile +selected and click Create. Use the button and axis controls in the UI to submit +input to the virtual controller. Expected result: -- The command exits successfully. -- The ROOT\LIBVIRTUALHID control device reports Status: Started. -- The \\.\LibVirtualHid control device opens successfully. +- The UI launches without requiring SDK or WDK tools. +- The backend status reports windows-umdf with gamepad support available. +- A virtual HID gamepad is created and appears in the device list. - A virtual HID gamepad child device starts, matching either HID\VID_045E&PID_0B12&IG_00 or HID\VID_045E&PID_02FF&IG_00. +- Button and axis values in the UI can be pressed or moved without errors. Optional browser validation: $installRoot = Join-Path $env:ProgramFiles "libvirtualhid" -powershell -ExecutionPolicy Bypass -File "$installRoot\scripts\windows\test-browser-gamepad.ps1" ` - -GamepadAdapterPath "$installRoot\tools\windows\gamepad_adapter.exe" ` - -GamepadProfile xseries ` - -KeepBrowserOpen +& "$installRoot\tools\windows\virtualhid_control.exe" + +Create the default Xbox Series gamepad, then open: +https://hardwaretester.com/gamepad + +Use the libvirtualhid control window to press buttons or move axes while the +browser page is open. Expected result: -- Microsoft Edge or Google Chrome opens a gamepad test page. +- Microsoft Edge, Google Chrome, or another desktop browser opens a gamepad + test page. - The browser Gamepad API sees an Xbox-compatible controller. -- Button and axis values change while the validation adapter is running. +- Button and axis values change while controls are used in the validation UI. -The installed gamepad_adapter.exe is built with the static MSVC runtime, so it -does not require the Visual C++ Redistributable to be installed separately. +The installed virtualhid_control.exe and gamepad_adapter.exe binaries are built +with the static MSVC runtime, so they do not require the Visual C++ +Redistributable to be installed separately. ``` ## Manual Review Steps @@ -66,8 +71,8 @@ does not require the Visual C++ Redistributable to be installed separately. `libvirtualhid-Windows-Driver-installer.msi`. 2. Reboot only if Windows reports that a reboot is required. 3. Open PowerShell. -4. Run the required validation command from the submission notes. -5. Optionally run the browser validation command. +4. Run the required validation tool from the submission notes. +5. Optionally run the browser validation steps. If the default install location was changed during MSI installation, replace `$env:ProgramFiles\libvirtualhid` with the selected install directory. @@ -85,4 +90,4 @@ HID-only and intentionally does not emulate the Xbox 360 XUSB stack. The reviewer-visible success signal is the installed `ROOT\LIBVIRTUALHID` control device, the `\\.\LibVirtualHid` control path, and a started HID gamepad -child device while `gamepad_adapter.exe` is running. +child device while `virtualhid_control.exe` has a gamepad created. diff --git a/docs/usage.md b/docs/usage.md index b0b5635..c8b4d8e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -56,6 +56,17 @@ unless they explicitly enable additional options. level project. - `BUILD_DOCS`: build Doxygen documentation when this repository is the top level project. +- `LIBVIRTUALHID_BUILD_TOOLS`: build diagnostic tool binaries, including + `virtualhid_control`, when this repository is the top level project. +- `LIBVIRTUALHID_TOOLS_STATIC_RUNTIME`: link diagnostic tools against static + compiler runtimes where supported. This is enabled by default so the Windows + MinGW/UCRT64 `virtualhid_control.exe` does not need adjacent MinGW runtime + DLLs. MSVC uses the static runtime for tools in the Windows driver package + build, where the packaged library, examples, and tools are all built with the + same runtime setting. +- `LIBVIRTUALHID_TOOLS_FULLY_STATIC`: pass full static link flags for + diagnostic tools. On Linux this also requires static archives for backend + dependencies such as `libevdev`, and may not be supported by every distro. - `LIBVIRTUALHID_INSTALL`: install targets, headers, and CMake package files. This defaults to on for direct builds and off when consumed by another CMake project. @@ -71,6 +82,28 @@ Linux consumers need the backend development packages used by the build, such as with MSVC or MinGW/UCRT64; the UMDF driver package is a separate WDK/MSVC build artifact. +## Diagnostic UI + +`virtualhid_control` is an optional native diagnostic UI binary: + +```bash +virtualhid_control +``` + +The current Windows UI can create and remove gamepads from the built-in +profiles, submit buttons, sticks, triggers, and battery state, show backend and +profile capabilities, list device nodes reported for UI-created devices, and +display normalized gamepad output such as rumble, RGB LED, adaptive trigger, +trigger rumble, and raw report events delivered through the normal callback +path. Button controls are momentary by default so they behave like physical +gamepad buttons; enable `Lock buttons` to keep the old click-to-toggle behavior +for held inputs. + +External devices created by another process, such as Sunshine, are not +enumerated yet. That requires backend protocol support so the Windows driver or +Linux backend can expose cross-process device snapshots without letting two +processes race to control the same virtual device. + ## Public API Shape The API centers on portable device concepts: diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 3836445..504bf05 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -22,9 +22,10 @@ User-mode virtual HID driver package that enables compatible apps to create virt Virtual HID Driver installs the user-mode driver component used by compatible applications to create virtual HID gamepads on Windows. -The package has no standalone user interface. After installation, compatible -applications can request virtual HID gamepads, and Windows applications that -understand standard HID gamepads can discover those devices. +The package includes a local diagnostic UI for creating and testing virtual +gamepads. Compatible applications can also request virtual HID gamepads, and +Windows applications that understand standard HID gamepads can discover those +devices. ``` ## Architecture @@ -51,9 +52,9 @@ Build the UMDF package with a Visual Studio generator and the WDK installed: ```powershell cmake -S . -B cmake-build-windows-driver -G "Visual Studio 17 2022" -A x64 ` -DLIBVIRTUALHID_BUILD_WINDOWS_DRIVER=ON -DLIBVIRTUALHID_ENABLE_PACKAGING=ON ` - -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=ON + -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=ON -DLIBVIRTUALHID_BUILD_TOOLS=ON cmake --build cmake-build-windows-driver --config Release ` - --target libvirtualhid_windows_catalog gamepad_adapter + --target libvirtualhid_windows_catalog gamepad_adapter virtualhid_control cpack -G WIX -C Release --config .\cmake-build-windows-driver\CPackConfig.cmake ``` @@ -83,9 +84,13 @@ powershell -ExecutionPolicy Bypass -File .\scripts\windows\uninstall-driver.ps1 The WiX installer also places validation files under the default install root, `C:\Program Files\libvirtualhid`: -- `scripts\windows\test-installed-driver.ps1` -- `scripts\windows\test-browser-gamepad.ps1` - `tools\windows\gamepad_adapter.exe` +- `tools\windows\virtualhid_control.exe` + +The source-tree validation scripts remain developer and CI helpers. They are not +packaged as reviewer-facing MSI validation scripts because the native +`virtualhid_control.exe` tool can create, exercise, and inspect virtual +gamepads interactively. The install helper stages the INF with `pnputil`, updates an existing `ROOT\LIBVIRTUALHID` device when present, and creates that root-enumerated @@ -99,7 +104,7 @@ desktop browser at `https://hardwaretester.com/gamepad` and validates that the browser Gamepad API observes the held virtual controller. For manual browser validation, run the browser helper with `-KeepBrowserOpen`, -or run: +run the interactive UI, or run: ```powershell tools\windows\gamepad_adapter.exe xseries --hold-seconds 60 @@ -109,6 +114,20 @@ Then open `https://hardwaretester.com/gamepad` in a normal desktop browser and press one of the held virtual buttons if the browser requires a gamepad activation event. +For interactive local validation, run: + +```powershell +tools\windows\virtualhid_control.exe +``` + +The native UI can create, remove, control, and monitor gamepads that it owns. +Buttons are momentary by default, with an explicit lock mode for held inputs. +The UI also shows supported profile features, battery input state, device nodes, +and normalized feedback reports such as rumble, RGB LED, adaptive trigger, and +raw output events. Devices created by another process are not listed yet; that +requires a future Windows control-protocol extension for cross-process +diagnostics. + ## Installation Notes The driver binary is a user-mode UMDF DLL installed through the Windows Driver diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index a17e05b..23a2986 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -19,3 +19,20 @@ endif() libvirtualhid_copy_mingw_runtime(gamepad_adapter) libvirtualhid_copy_mingw_runtime(keyboard_mouse_adapter) + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR WIN32) + set(RUN_GAMEPAD_EXAMPLE_COMMAND "$") + set(RUN_GAMEPAD_EXAMPLE_COMMENT "Running gamepad_adapter example") +else() + set(RUN_GAMEPAD_EXAMPLE_COMMAND + "${CMAKE_COMMAND}" -E echo + "Skipping gamepad_adapter example: no ${CMAKE_SYSTEM_NAME} backend") + set(RUN_GAMEPAD_EXAMPLE_COMMENT "Skipping gamepad_adapter example") +endif() + +add_custom_target(run_gamepad_adapter_example + COMMAND ${RUN_GAMEPAD_EXAMPLE_COMMAND} + COMMENT "${RUN_GAMEPAD_EXAMPLE_COMMENT}" + DEPENDS gamepad_adapter + VERBATIM + USES_TERMINAL) diff --git a/libvirtualhid.ico b/libvirtualhid.ico new file mode 100644 index 0000000..e7b9f5c Binary files /dev/null and b/libvirtualhid.ico differ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4802151..eb642bf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,11 @@ set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_windows_protocol.cpp") +if(TARGET virtualhid_control_model) + list(APPEND LIBVIRTUALHID_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_virtualhid_control_model.cpp") +endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") find_package(PkgConfig REQUIRED) pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev) @@ -64,6 +69,12 @@ target_link_libraries(${TEST_BINARY} libvirtualhid::libvirtualhid lizardbyte::test_support) +if(TARGET virtualhid_control_model) + target_link_libraries(${TEST_BINARY} + PRIVATE + virtualhid_control_model) +endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") target_link_libraries(${TEST_BINARY} PRIVATE diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp new file mode 100644 index 0000000..9f47387 --- /dev/null +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -0,0 +1,245 @@ +/** + * @file tests/unit/test_virtualhid_control_model.cpp + * @brief Unit tests for virtualhid_control model helpers. + */ + +// standard includes +#include +#include +#include +#include + +// third-party includes +#include + +// local includes +#include + +namespace { + namespace control = lvh::tools::virtualhid_control; + + bool visible_button( + const std::array &visible_buttons, + lvh::GamepadButton button + ) { + for (std::size_t index = 0; index < control::button_choices.size(); ++index) { + if (control::button_choices[index].button == button) { + return visible_buttons[index]; + } + } + return false; + } + + lvh::GamepadOutput output(lvh::GamepadOutputKind kind) { + lvh::GamepadOutput value; + value.kind = kind; + return value; + } +} // namespace + +TEST(VirtualHidControlModelTest, NamesKnownAndFallbackEnumValues) { + EXPECT_EQ(control::device_type_name(lvh::DeviceType::gamepad), L"gamepad"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::keyboard), L"keyboard"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::mouse), L"mouse"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::touchscreen), L"touchscreen"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::trackpad), L"trackpad"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::pen_tablet), L"pen tablet"); + EXPECT_EQ(control::device_type_name(static_cast(255)), L"unknown"); + + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::input_event), L"input"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::joystick), L"joystick"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::hidraw), L"hidraw"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::sysfs), L"sysfs"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::other), L"other"); + EXPECT_EQ(control::node_kind_name(static_cast(255)), L"other"); + + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::rumble), L"rumble"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::rgb_led), L"rgb led"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::adaptive_triggers), L"adaptive triggers"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::raw_report), L"raw report"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::trigger_rumble), L"trigger rumble"); + EXPECT_EQ(control::output_kind_name(static_cast(255)), L"raw report"); + + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::unknown), L"unknown"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::discharging), L"discharging"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::charging), L"charging"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::full), L"full"); + EXPECT_EQ( + control::battery_state_name(lvh::GamepadBatteryState::voltage_or_temperature_error), + L"voltage/temperature error" + ); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::temperature_error), L"temperature error"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::charging_error), L"charging error"); + EXPECT_EQ(control::battery_state_name(static_cast(255)), L"unknown"); + + EXPECT_EQ(control::yes_no(true), L"yes"); + EXPECT_EQ(control::yes_no(false), L"no"); +} + +TEST(VirtualHidControlModelTest, MapsProfileChoicesToProfiles) { + for (const auto &choice : control::profile_choices) { + const auto profile = control::profile_for_choice(choice); + ASSERT_TRUE(profile.has_value()) << std::string(choice.id.begin(), choice.id.end()); + EXPECT_EQ(profile->device_type, lvh::DeviceType::gamepad); + EXPECT_EQ(profile->gamepad_kind, choice.kind); + EXPECT_FALSE(profile->name.empty()); + } + + const control::ProfileChoice invalid { + L"invalid", + L"Invalid", + static_cast(255), + lvh::ClientControllerType::unknown, + }; + EXPECT_FALSE(control::profile_for_choice(invalid).has_value()); +} + +TEST(VirtualHidControlModelTest, ConvertsSliderValues) { + EXPECT_EQ(control::axis_to_slider(-2.0F), -control::slider_scale); + EXPECT_EQ(control::axis_to_slider(-0.5F), -50); + EXPECT_EQ(control::axis_to_slider(0.0F), 0); + EXPECT_EQ(control::axis_to_slider(0.5F), 50); + EXPECT_EQ(control::axis_to_slider(2.0F), control::slider_scale); + + EXPECT_EQ(control::trigger_to_slider(-1.0F), 0); + EXPECT_EQ(control::trigger_to_slider(0.25F), 25); + EXPECT_EQ(control::trigger_to_slider(1.0F), control::slider_scale); + EXPECT_EQ(control::trigger_to_slider(2.0F), control::slider_scale); + + EXPECT_FLOAT_EQ(control::slider_to_float(-25), -0.25F); + EXPECT_FLOAT_EQ(control::slider_to_float(0), 0.0F); + EXPECT_FLOAT_EQ(control::slider_to_float(75), 0.75F); +} + +TEST(VirtualHidControlModelTest, MapsBatteryComboChoices) { + for (std::size_t index = 0; index < control::battery_choices.size(); ++index) { + EXPECT_EQ(control::battery_choice_index(control::battery_choices[index].state), static_cast(index)); + } + EXPECT_EQ(control::battery_choice_index(static_cast(255)), 0); +} + +TEST(VirtualHidControlModelTest, FormatsRawHex) { + EXPECT_EQ(control::raw_hex({}), L""); + EXPECT_EQ(control::raw_hex({0x00, 0x0F, 0xA5, 0xFF}), L"000fa5ff"); +} + +TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) { + const auto generic = lvh::profiles::generic_gamepad(); + const auto dualsense = lvh::profiles::dualsense(); + + EXPECT_FALSE(control::supports_normalized_feedback(generic)); + EXPECT_TRUE(control::supports_normalized_feedback(dualsense)); + + EXPECT_EQ( + control::profile_feature_summary(generic), + L"Features: battery no | rumble no | trigger rumble no | RGB LED no | adaptive triggers no | raw output no" + ); + EXPECT_EQ( + control::profile_feature_summary(dualsense), + L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | adaptive triggers yes | raw output yes" + ); +} + +TEST(VirtualHidControlModelTest, UpdatesVisibleControlsForProfiles) { + std::array visible_buttons {}; + auto battery_visible = false; + + const auto dualshock4 = lvh::profiles::dualshock4(); + EXPECT_TRUE(control::update_visible_controls_for_profile(dualshock4, visible_buttons, battery_visible)); + EXPECT_TRUE(visible_button(visible_buttons, lvh::GamepadButton::a)); + EXPECT_TRUE(visible_button(visible_buttons, lvh::GamepadButton::touchpad)); + EXPECT_FALSE(visible_button(visible_buttons, lvh::GamepadButton::misc1)); + EXPECT_FALSE(visible_button(visible_buttons, lvh::GamepadButton::paddle1)); + EXPECT_TRUE(battery_visible); + + EXPECT_FALSE(control::update_visible_controls_for_profile(dualshock4, visible_buttons, battery_visible)); + + const auto generic = lvh::profiles::generic_gamepad(); + EXPECT_TRUE(control::update_visible_controls_for_profile(generic, visible_buttons, battery_visible)); + EXPECT_TRUE(visible_button(visible_buttons, lvh::GamepadButton::misc1)); + EXPECT_FALSE(visible_button(visible_buttons, lvh::GamepadButton::touchpad)); + EXPECT_FALSE(battery_visible); +} + +TEST(VirtualHidControlModelTest, SummarizesOutputState) { + using enum lvh::GamepadOutputKind; + + const auto generic = lvh::profiles::generic_gamepad(); + const auto dualsense = lvh::profiles::dualsense(); + + control::OutputState state; + EXPECT_EQ( + control::output_summary(state, generic), + L"Output: no reports received | profile has no normalized feedback categories" + ); + EXPECT_EQ(control::output_summary(state, dualsense), L"Output: no reports received"); + + state.outputs.push_back({.sequence = 1, .output = output(raw_report)}); + EXPECT_EQ(control::output_summary(state, dualsense), L"Output: reports received"); + + state.latest_raw_report = output(raw_report); + EXPECT_EQ(control::output_summary(state, dualsense), L"Output: raw report"); + + state.latest_rumble = output(rumble); + state.latest_rumble->low_frequency_rumble = 10; + state.latest_rumble->high_frequency_rumble = 20; + state.latest_trigger_rumble = output(trigger_rumble); + state.latest_trigger_rumble->left_trigger_rumble = 30; + state.latest_trigger_rumble->right_trigger_rumble = 40; + state.latest_rgb_led = output(rgb_led); + state.latest_rgb_led->red = 1; + state.latest_rgb_led->green = 2; + state.latest_rgb_led->blue = 3; + state.latest_adaptive_triggers = output(adaptive_triggers); + state.latest_adaptive_triggers->adaptive_trigger_flags = 4; + + EXPECT_EQ( + control::output_summary(state, dualsense), + L"Output: rumble low=10 high=20 | trigger rumble L=30 R=40 | RGB 1,2,3 | adaptive flags=4" + ); +} + +TEST(VirtualHidControlModelTest, RecordsOutputsAndMaintainsLatestSummaryFields) { + control::OutputState state; + auto next_sequence = std::uint64_t {7}; + + auto rumble = output(lvh::GamepadOutputKind::rumble); + rumble.low_frequency_rumble = 100; + rumble.high_frequency_rumble = 200; + control::record_output(state, rumble, next_sequence, 3); + + auto trigger_rumble = output(lvh::GamepadOutputKind::trigger_rumble); + trigger_rumble.left_trigger_rumble = 300; + trigger_rumble.right_trigger_rumble = 400; + control::record_output(state, trigger_rumble, next_sequence, 3); + + auto rgb = output(lvh::GamepadOutputKind::rgb_led); + rgb.red = 5; + rgb.green = 6; + rgb.blue = 7; + control::record_output(state, rgb, next_sequence, 3); + + auto adaptive = output(lvh::GamepadOutputKind::adaptive_triggers); + adaptive.adaptive_trigger_flags = 8; + control::record_output(state, adaptive, next_sequence, 3); + + auto raw = output(lvh::GamepadOutputKind::raw_report); + raw.raw_report = {0x12, 0x34}; + control::record_output(state, raw, next_sequence, 3); + + ASSERT_EQ(state.outputs.size(), 3U); + EXPECT_EQ(state.outputs.front().sequence, 9U); + EXPECT_EQ(state.outputs.back().sequence, 11U); + EXPECT_EQ(next_sequence, 12U); + + ASSERT_TRUE(state.latest_rumble.has_value()); + EXPECT_EQ(state.latest_rumble->low_frequency_rumble, 100); + ASSERT_TRUE(state.latest_trigger_rumble.has_value()); + EXPECT_EQ(state.latest_trigger_rumble->right_trigger_rumble, 400); + ASSERT_TRUE(state.latest_rgb_led.has_value()); + EXPECT_EQ(state.latest_rgb_led->blue, 7); + ASSERT_TRUE(state.latest_adaptive_triggers.has_value()); + EXPECT_EQ(state.latest_adaptive_triggers->adaptive_trigger_flags, 8); + ASSERT_TRUE(state.latest_raw_report.has_value()); + EXPECT_EQ(state.latest_raw_report->raw_report, (std::vector {0x12, 0x34})); +} diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..38bbda3 --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,66 @@ +add_library(virtualhid_control_model STATIC + "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control_model.cpp") + +target_include_directories(virtualhid_control_model + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}") + +target_link_libraries(virtualhid_control_model + PUBLIC + libvirtualhid::libvirtualhid) + +add_executable(virtualhid_control + "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control.cpp") + +target_link_libraries(virtualhid_control + PRIVATE + virtualhid_control_model) + +if(WIN32) + set_target_properties(virtualhid_control PROPERTIES + WIN32_EXECUTABLE TRUE) + target_sources(virtualhid_control + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/windows/virtualhid_control.rc") + target_include_directories(virtualhid_control + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/windows" + "${PROJECT_SOURCE_DIR}") + target_compile_definitions(virtualhid_control + PRIVATE + NOMINMAX + WIN32_LEAN_AND_MEAN + _WIN32_WINNT=0x0600) + target_link_libraries(virtualhid_control + PRIVATE + comctl32 + gdi32 + user32) +endif() + +if(MSVC AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) + set_property(TARGET virtualhid_control virtualhid_control_model PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +endif() + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME) + target_link_options(virtualhid_control + PRIVATE + -static-libgcc + -static-libstdc++) + + if(WIN32) + target_link_options(virtualhid_control + PRIVATE + -static) + endif() +endif() + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND LIBVIRTUALHID_TOOLS_FULLY_STATIC) + target_link_options(virtualhid_control + PRIVATE + -static) +endif() + +install(TARGETS virtualhid_control + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp new file mode 100644 index 0000000..924d088 --- /dev/null +++ b/tools/virtualhid_control.cpp @@ -0,0 +1,1215 @@ +/** + * @file tools/virtualhid_control.cpp + * @brief Native UI for creating and testing libvirtualhid gamepads. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "virtualhid_control_model.hpp" + +#if defined(_WIN32) + + #ifndef NOMINMAX + #define NOMINMAX + #endif + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef UNICODE + #define UNICODE + #endif + #ifndef _UNICODE + #define _UNICODE + #endif + + #include + #include + +// clang-format off + #include + #include +// clang-format on + +namespace { + using lvh::tools::virtualhid_control::axis_choices; + using lvh::tools::virtualhid_control::axis_to_slider; + using lvh::tools::virtualhid_control::battery_choice_index; + using lvh::tools::virtualhid_control::battery_choices; + using lvh::tools::virtualhid_control::battery_state_name; + using lvh::tools::virtualhid_control::button_choices; + using lvh::tools::virtualhid_control::device_type_name; + using lvh::tools::virtualhid_control::node_kind_name; + using lvh::tools::virtualhid_control::output_kind_name; + using lvh::tools::virtualhid_control::output_summary; + using lvh::tools::virtualhid_control::OutputState; + using lvh::tools::virtualhid_control::profile_choices; + using lvh::tools::virtualhid_control::profile_feature_summary; + using lvh::tools::virtualhid_control::profile_for_choice; + using lvh::tools::virtualhid_control::raw_hex; + using lvh::tools::virtualhid_control::slider_to_float; + using lvh::tools::virtualhid_control::trigger_to_slider; + using lvh::tools::virtualhid_control::update_visible_controls_for_profile; + + constexpr auto output_changed_message = WM_APP + 1U; + constexpr auto button_subclass_id = 1U; + constexpr auto icon_resource_id = 101; + + enum class ControlId : int { + profile_combo = 1000, + create_button, + device_list, + reset_button, + remove_selected_button, + remove_all_button, + lock_buttons_check, + state_text, + feature_text, + battery_state_combo, + battery_slider, + clear_battery_button, + output_summary_text, + node_list, + output_list, + button_base = 2000, + axis_base = 3000, + }; + + constexpr auto profile_combo_id = std::to_underlying(ControlId::profile_combo); + constexpr auto create_button_id = std::to_underlying(ControlId::create_button); + constexpr auto device_list_id = std::to_underlying(ControlId::device_list); + constexpr auto reset_button_id = std::to_underlying(ControlId::reset_button); + constexpr auto remove_selected_button_id = std::to_underlying(ControlId::remove_selected_button); + constexpr auto remove_all_button_id = std::to_underlying(ControlId::remove_all_button); + constexpr auto lock_buttons_check_id = std::to_underlying(ControlId::lock_buttons_check); + constexpr auto state_text_id = std::to_underlying(ControlId::state_text); + constexpr auto feature_text_id = std::to_underlying(ControlId::feature_text); + constexpr auto battery_state_combo_id = std::to_underlying(ControlId::battery_state_combo); + constexpr auto battery_slider_id = std::to_underlying(ControlId::battery_slider); + constexpr auto clear_battery_button_id = std::to_underlying(ControlId::clear_battery_button); + constexpr auto output_summary_text_id = std::to_underlying(ControlId::output_summary_text); + constexpr auto node_list_id = std::to_underlying(ControlId::node_list); + constexpr auto output_list_id = std::to_underlying(ControlId::output_list); + constexpr auto button_base_id = std::to_underlying(ControlId::button_base); + constexpr auto axis_base_id = std::to_underlying(ControlId::axis_base); + + struct ControlledGamepad: OutputState { + std::wstring profile_label; + std::unique_ptr adapter; + }; + + struct MainControls { + HWND backend_text = nullptr; + HWND profile_combo = nullptr; + HWND create_button = nullptr; + HWND state_text = nullptr; + HWND feature_text = nullptr; + }; + + struct DeviceListControls { + HWND list = nullptr; + HWND reset_button = nullptr; + HWND remove_selected_button = nullptr; + HWND remove_all_button = nullptr; + }; + + struct ButtonControls { + HWND lock_check = nullptr; + std::array controls {}; + std::array visible {}; + std::array momentary_pointer_pressed {}; + std::array momentary_key_pressed {}; + }; + + struct AxisControls { + std::array labels {}; + std::array sliders {}; + }; + + struct BatteryControls { + HWND label = nullptr; + HWND state_combo = nullptr; + HWND slider = nullptr; + HWND clear_button = nullptr; + bool visible = false; + }; + + struct OutputControls { + HWND summary_text = nullptr; + HWND nodes_label = nullptr; + HWND output_label = nullptr; + HWND nodes_list = nullptr; + HWND output_list = nullptr; + }; + + template + Target win32_cast(Source value) { + static_assert(sizeof(Target) == sizeof(Source)); + return std::bit_cast(value); + } + + std::wstring widen(std::string_view value) { + if (value.empty()) { + return {}; + } + + const auto required = ::MultiByteToWideChar( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + nullptr, + 0 + ); + if (required <= 0) { + return std::wstring {value.begin(), value.end()}; + } + + std::wstring result(static_cast(required), L'\0'); + if (const auto copied = ::MultiByteToWideChar( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + result.data(), + required + ); + copied <= 0) { + return std::wstring {value.begin(), value.end()}; + } + return result; + } + + std::optional current_combo_profile(HWND profile_combo) { + const auto selection = ::SendMessageW(profile_combo, CB_GETCURSEL, 0, 0); + if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { + return std::nullopt; + } + + return profile_for_choice(profile_choices[static_cast(selection)]); + } + + void move_control(HWND control, int x, int y, int width, int height) { + if (control != nullptr) { + ::SetWindowPos(control, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS); + } + } + + void handle_min_max_info(MINMAXINFO *info) { + if (info == nullptr) { + return; + } + + info->ptMinTrackSize.x = 980; + info->ptMinTrackSize.y = 740; + } + + bool buttons_locked(HWND window) { + return ::IsDlgButtonChecked(window, lock_buttons_check_id) == BST_CHECKED; + } + + void reset_sliders(const AxisControls &axes) { + for (auto *slider : axes.sliders) { + ::SendMessageW(slider, TBM_SETPOS, TRUE, 0); + } + } + + void set_button_visual(const ButtonControls &buttons, std::size_t index, bool pressed) { + if (index >= buttons.controls.size()) { + return; + } + ::SendMessageW(buttons.controls[index], BM_SETSTATE, pressed ? TRUE : FALSE, 0); + } + + void refresh_nodes(HWND nodes_list, const lvh::Gamepad &gamepad) { + ::SendMessageW(nodes_list, LB_RESETCONTENT, 0, 0); + const auto nodes = gamepad.device_nodes(); + if (nodes.empty()) { + ::SendMessageW(nodes_list, LB_ADDSTRING, 0, win32_cast(L"No device nodes reported yet.")); + return; + } + + for (const auto &node : nodes) { + const auto line = node_kind_name(node.kind) + L": " + widen(node.path); + ::SendMessageW(nodes_list, LB_ADDSTRING, 0, win32_cast(line.c_str())); + } + } + + void refresh_outputs(HWND output_list, const ControlledGamepad &device) { + ::SendMessageW(output_list, LB_RESETCONTENT, 0, 0); + if (device.outputs.empty()) { + ::SendMessageW(output_list, LB_ADDSTRING, 0, win32_cast(L"No output reports received.")); + return; + } + + for (const auto &entry : device.outputs) { + const auto &output = entry.output; + std::wostringstream line; + line << L"#" << entry.sequence << L" " << output_kind_name(output.kind) + << L" low=" << output.low_frequency_rumble + << L" high=" << output.high_frequency_rumble + << L" rgb=" << static_cast(output.red) << L"," << static_cast(output.green) << L"," + << static_cast(output.blue); + if (!output.raw_report.empty()) { + line << L" raw=" << raw_hex(output.raw_report); + } + ::SendMessageW(output_list, LB_ADDSTRING, 0, win32_cast(line.str().c_str())); + } + } + + class ControlWindow { + public: + explicit ControlWindow(HINSTANCE instance): + instance_ {instance} { + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + runtime_ = lvh::Runtime::create(options); + } + + int run() { + INITCOMMONCONTROLSEX controls {}; + controls.dwSize = sizeof(controls); + controls.dwICC = ICC_BAR_CLASSES | ICC_STANDARD_CLASSES; + static_cast(::InitCommonControlsEx(&controls)); + + WNDCLASSEXW window_class {}; + window_class.cbSize = sizeof(window_class); + window_class.hInstance = instance_; + window_class.lpfnWndProc = &ControlWindow::window_proc; + window_class.lpszClassName = L"LibVirtualHidControlWindow"; + window_class.hCursor = ::LoadCursorW(nullptr, IDC_ARROW); + window_class.hIcon = ::LoadIconW(instance_, MAKEINTRESOURCEW(icon_resource_id)); + if (window_class.hIcon == nullptr) { + window_class.hIcon = ::LoadIconW(nullptr, IDI_APPLICATION); + } + window_class.hIconSm = win32_cast(::LoadImageW( + instance_, + MAKEINTRESOURCEW(icon_resource_id), + IMAGE_ICON, + ::GetSystemMetrics(SM_CXSMICON), + ::GetSystemMetrics(SM_CYSMICON), + LR_DEFAULTCOLOR + )); + if (window_class.hIconSm == nullptr) { + window_class.hIconSm = window_class.hIcon; + } + window_class.hbrBackground = ::GetSysColorBrush(COLOR_WINDOW); + + if (::RegisterClassExW(&window_class) == 0U) { + return 1; + } + + window_ = ::CreateWindowExW( + 0, + window_class.lpszClassName, + L"libvirtualhid control", + WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, + CW_USEDEFAULT, + CW_USEDEFAULT, + 980, + 700, + nullptr, + nullptr, + instance_, + this + ); + if (window_ == nullptr) { + return 1; + } + + ::ShowWindow(window_, SW_SHOW); + ::UpdateWindow(window_); + + MSG message {}; + while (::GetMessageW(&message, nullptr, 0, 0) > 0) { + ::TranslateMessage(&message); + ::DispatchMessageW(&message); + } + return static_cast(message.wParam); + } + + private: + static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + auto *self = win32_cast(::GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + const auto *create = win32_cast(lparam); + self = static_cast(create->lpCreateParams); + self->window_ = window; + ::SetWindowLongPtrW(window, GWLP_USERDATA, win32_cast(self)); + } + + if (self != nullptr) { + return self->handle_message(message, wparam, lparam); + } + return ::DefWindowProcW(window, message, wparam, lparam); + } + + static LRESULT CALLBACK button_subclass_proc(HWND control, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR, DWORD_PTR ref_data) { + auto *self = win32_cast(ref_data); + if (self == nullptr) { + return ::DefSubclassProc(control, message, wparam, lparam); + } + + return self->handle_button_message(control, message, wparam, lparam); + } + + LRESULT handle_button_message(HWND control, UINT message, WPARAM wparam, LPARAM lparam) { + const auto id = ::GetDlgCtrlID(control); + if (id < button_base_id || id >= button_base_id + static_cast(button_choices.size())) { + return ::DefSubclassProc(control, message, wparam, lparam); + } + + const auto index = static_cast(id - button_base_id); + if (!buttons_locked(window_)) { + switch (message) { + case WM_LBUTTONDOWN: + case WM_LBUTTONDBLCLK: + buttons_.momentary_pointer_pressed[index] = true; + set_selected_button(index, true); + break; + case WM_LBUTTONUP: + if (buttons_.momentary_pointer_pressed[index]) { + buttons_.momentary_pointer_pressed[index] = false; + set_selected_button(index, false); + } + break; + case WM_CAPTURECHANGED: + if (buttons_.momentary_pointer_pressed[index]) { + buttons_.momentary_pointer_pressed[index] = false; + set_selected_button(index, false); + } + break; + case WM_KEYDOWN: + if (wparam == VK_SPACE && !buttons_.momentary_key_pressed[index]) { + buttons_.momentary_key_pressed[index] = true; + set_selected_button(index, true); + } + break; + case WM_KEYUP: + if (wparam == VK_SPACE && buttons_.momentary_key_pressed[index]) { + buttons_.momentary_key_pressed[index] = false; + set_selected_button(index, false); + } + break; + case WM_KILLFOCUS: + if (buttons_.momentary_key_pressed[index]) { + buttons_.momentary_key_pressed[index] = false; + set_selected_button(index, false); + } + break; + default: + break; + } + } + + return ::DefSubclassProc(control, message, wparam, lparam); + } + + LRESULT handle_message(UINT message, WPARAM wparam, LPARAM lparam) { + switch (message) { + case WM_CREATE: + create_controls(); + refresh_all(); + return 0; + case WM_SIZE: + layout_controls(LOWORD(lparam), HIWORD(lparam)); + redraw_window(); + return 0; + case WM_GETMINMAXINFO: + handle_min_max_info(win32_cast(lparam)); + return 0; + case WM_COMMAND: + handle_command(LOWORD(wparam), HIWORD(wparam)); + return 0; + case WM_HSCROLL: + handle_slider(lparam); + return 0; + case output_changed_message: + refresh_selected_device(); + return 0; + case WM_DESTROY: + close_all_devices(); + ::PostQuitMessage(0); + return 0; + default: + break; + } + return ::DefWindowProcW(window_, message, wparam, lparam); + } + + HWND create_child( + const wchar_t *class_name, + const wchar_t *text, + DWORD style, + int id + ) { + auto *font = win32_cast(::GetStockObject(DEFAULT_GUI_FONT)); + auto *control = ::CreateWindowExW( + 0, + class_name, + text, + WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | style, + 0, + 0, + 10, + 10, + window_, + win32_cast(static_cast(id)), + instance_, + nullptr + ); + if (control != nullptr) { + ::SendMessageW(control, WM_SETFONT, win32_cast(font), TRUE); + } + return control; + } + + void create_controls() { + main_controls_.backend_text = create_child(L"STATIC", L"", SS_LEFT, 0); + main_controls_.profile_combo = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, profile_combo_id); + main_controls_.create_button = create_child(L"BUTTON", L"Create", BS_PUSHBUTTON, create_button_id); + device_controls_.list = create_child(L"LISTBOX", L"", LBS_NOTIFY | WS_BORDER | WS_VSCROLL, device_list_id); + device_controls_.reset_button = create_child(L"BUTTON", L"Reset", BS_PUSHBUTTON, reset_button_id); + device_controls_.remove_selected_button = create_child(L"BUTTON", L"Remove selected", BS_PUSHBUTTON, remove_selected_button_id); + device_controls_.remove_all_button = create_child(L"BUTTON", L"Remove all", BS_PUSHBUTTON, remove_all_button_id); + buttons_.lock_check = create_child(L"BUTTON", L"Lock buttons", BS_AUTOCHECKBOX, lock_buttons_check_id); + main_controls_.state_text = create_child(L"STATIC", L"No device selected.", SS_LEFT, state_text_id); + main_controls_.feature_text = create_child(L"STATIC", L"", SS_LEFT, feature_text_id); + battery_.label = create_child(L"STATIC", L"Battery", SS_LEFT, 0); + battery_.state_combo = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, battery_state_combo_id); + battery_.slider = create_child(TRACKBAR_CLASSW, L"", TBS_NOTICKS, battery_slider_id); + battery_.clear_button = create_child(L"BUTTON", L"Clear", BS_PUSHBUTTON, clear_battery_button_id); + output_controls_.summary_text = create_child(L"STATIC", L"", SS_LEFT, output_summary_text_id); + output_controls_.nodes_label = create_child(L"STATIC", L"Device nodes", SS_LEFT, 0); + output_controls_.output_label = create_child(L"STATIC", L"Output reports", SS_LEFT, 0); + output_controls_.nodes_list = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, node_list_id); + output_controls_.output_list = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, output_list_id); + + for (const auto &choice : profile_choices) { + ::SendMessageW(main_controls_.profile_combo, CB_ADDSTRING, 0, win32_cast(choice.label.data())); + } + ::SendMessageW(main_controls_.profile_combo, CB_SETCURSEL, 3, 0); + + for (const auto &choice : battery_choices) { + ::SendMessageW(battery_.state_combo, CB_ADDSTRING, 0, win32_cast(choice.label.data())); + } + ::SendMessageW(battery_.state_combo, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); + ::SendMessageW(battery_.slider, TBM_SETRANGE, TRUE, MAKELPARAM(0, 100)); + ::SendMessageW(battery_.slider, TBM_SETPOS, TRUE, 100); + + for (std::size_t index = 0; index < button_choices.size(); ++index) { + buttons_.controls[index] = create_child( + L"BUTTON", + button_choices[index].label.data(), + BS_PUSHBUTTON | BS_NOTIFY, + button_base_id + static_cast(index) + ); + ::SetWindowSubclass( + buttons_.controls[index], + &ControlWindow::button_subclass_proc, + button_subclass_id, + win32_cast(this) + ); + } + + for (std::size_t index = 0; index < axis_choices.size(); ++index) { + axes_.labels[index] = create_child(L"STATIC", axis_choices[index].label.data(), SS_LEFT, 0); + axes_.sliders[index] = create_child( + TRACKBAR_CLASSW, + L"", + TBS_NOTICKS, + axis_base_id + static_cast(index) + ); + ::SendMessageW( + axes_.sliders[index], + TBM_SETRANGE, + TRUE, + MAKELPARAM(axis_choices[index].minimum, axis_choices[index].maximum) + ); + ::SendMessageW(axes_.sliders[index], TBM_SETPOS, TRUE, 0); + } + } + + void layout_controls(int width, int height) { + constexpr auto margin = 12; + constexpr auto gap = 8; + constexpr auto row = 28; + constexpr auto label_height = 18; + constexpr auto axis_label_height = 22; + constexpr auto axis_slider_height = 34; + constexpr auto axis_row_height = 64; + constexpr auto button_width = 108; + constexpr auto button_height = 28; + constexpr auto state_height = 72; + constexpr auto feature_height = 34; + constexpr auto output_summary_height = 34; + + const auto left_width = std::clamp(width / 3, 250, 320); + const auto right_x = margin + left_width + gap; + const auto right_width = std::max(360, width - right_x - margin); + const auto profile_width = std::max(120, left_width - 98); + const auto left_actions_top = std::max(margin + 140, height - margin - (row * 2) - gap); + const auto list_top = margin + row + 48; + const auto left_list_height = std::max(120, left_actions_top - list_top - gap); + + move_control(main_controls_.profile_combo, margin, margin, profile_width, 200); + move_control(main_controls_.create_button, margin + profile_width + gap, margin, left_width - profile_width - gap, row); + move_control(main_controls_.backend_text, margin, margin + row + gap, left_width, 40); + move_control(device_controls_.list, margin, list_top, left_width, left_list_height); + move_control(device_controls_.reset_button, margin, left_actions_top, 80, row); + move_control(device_controls_.remove_selected_button, margin + 88, left_actions_top, left_width - 88, row); + move_control(device_controls_.remove_all_button, margin, left_actions_top + row + gap, left_width, row); + + move_control(main_controls_.state_text, right_x, margin, right_width, state_height); + move_control(main_controls_.feature_text, right_x, margin + state_height, right_width, feature_height); + + const auto buttons_header_top = margin + state_height + feature_height + gap; + move_control(buttons_.lock_check, right_x, buttons_header_top, 130, row); + + const auto button_columns = std::max(1, std::min(5, (right_width + gap) / (button_width + gap))); + const auto actual_button_width = std::max(button_width, (right_width - ((button_columns - 1) * gap)) / button_columns); + auto visible_button_count = 0; + const auto buttons_top = buttons_header_top + row + gap; + for (std::size_t index = 0; index < buttons_.controls.size(); ++index) { + if (!buttons_.visible[index]) { + ::ShowWindow(buttons_.controls[index], SW_HIDE); + continue; + } + + ::ShowWindow(buttons_.controls[index], SW_SHOW); + const auto column = visible_button_count % button_columns; + const auto row_index = visible_button_count / button_columns; + move_control( + buttons_.controls[index], + right_x + column * (actual_button_width + gap), + buttons_top + row_index * (button_height + gap), + actual_button_width, + button_height + ); + ++visible_button_count; + } + + const auto button_rows = std::max(1, (visible_button_count + button_columns - 1) / button_columns); + const auto slider_top = buttons_top + button_rows * (button_height + gap) + gap; + const auto slider_columns = right_width >= 520 ? 2 : 1; + const auto slider_width = std::max(180, (right_width - ((slider_columns - 1) * gap)) / slider_columns); + for (std::size_t index = 0; index < axes_.sliders.size(); ++index) { + const auto column = static_cast(index % static_cast(slider_columns)); + const auto row_index = static_cast(index / static_cast(slider_columns)); + const auto x = right_x + column * (slider_width + gap); + const auto y = slider_top + row_index * axis_row_height; + move_control(axes_.labels[index], x, y, slider_width, axis_label_height); + move_control(axes_.sliders[index], x, y + axis_label_height, slider_width, axis_slider_height); + } + + const auto slider_rows = static_cast((axes_.sliders.size() + static_cast(slider_columns) - 1U) / static_cast(slider_columns)); + auto next_top = slider_top + slider_rows * axis_row_height + gap; + const auto show_battery = battery_.visible; + ::ShowWindow(battery_.label, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_.state_combo, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_.slider, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_.clear_button, show_battery ? SW_SHOW : SW_HIDE); + if (show_battery) { + const auto clear_width = 70; + const auto battery_label_width = 84; + const auto combo_width = std::min(220, std::max(150, right_width / 3)); + move_control(battery_.label, right_x, next_top + 5, battery_label_width, label_height); + move_control(battery_.state_combo, right_x + battery_label_width + gap, next_top, combo_width, 180); + move_control(battery_.clear_button, right_x + right_width - clear_width, next_top, clear_width, row); + move_control( + battery_.slider, + right_x + battery_label_width + gap + combo_width + gap, + next_top, + std::max(120, right_width - battery_label_width - combo_width - clear_width - (gap * 3)), + row + ); + next_top += row + gap; + } + + move_control(output_controls_.summary_text, right_x, next_top, right_width, output_summary_height); + next_top += output_summary_height + gap; + + const auto list_width = (right_width - gap) / 2; + const auto bottom_height = std::max(90, height - next_top - label_height - margin); + move_control(output_controls_.nodes_label, right_x, next_top, list_width, label_height); + move_control(output_controls_.output_label, right_x + list_width + gap, next_top, list_width, label_height); + move_control(output_controls_.nodes_list, right_x, next_top + label_height, list_width, bottom_height); + move_control(output_controls_.output_list, right_x + list_width + gap, next_top + label_height, list_width, bottom_height); + } + + void layout_current_client() { + RECT rect {}; + if (::GetClientRect(window_, &rect) == FALSE) { + return; + } + layout_controls(rect.right - rect.left, rect.bottom - rect.top); + } + + void relayout_and_redraw() { + layout_current_client(); + redraw_window(); + } + + void redraw_window() { + ::RedrawWindow(window_, nullptr, nullptr, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_ERASE); + } + + void handle_command(int id, int notification) { + if (id == create_button_id && notification == BN_CLICKED) { + create_gamepad(); + return; + } + if (id == profile_combo_id && notification == CBN_SELCHANGE) { + refresh_selected_device(); + relayout_and_redraw(); + return; + } + if (id == reset_button_id && notification == BN_CLICKED) { + reset_selected_device(); + return; + } + if (id == remove_selected_button_id && notification == BN_CLICKED) { + remove_selected_device(); + return; + } + if (id == remove_all_button_id && notification == BN_CLICKED) { + remove_all_devices(); + return; + } + if (id == lock_buttons_check_id && notification == BN_CLICKED) { + handle_button_lock_changed(); + return; + } + if (id == battery_state_combo_id && notification == CBN_SELCHANGE) { + set_selected_battery_from_controls(); + return; + } + if (id == clear_battery_button_id && notification == BN_CLICKED) { + clear_selected_battery(); + return; + } + if (id == device_list_id && notification == LBN_SELCHANGE) { + if (const auto selection = ::SendMessageW(device_controls_.list, LB_GETCURSEL, 0, 0); + selection != LB_ERR) { + selected_id_ = static_cast(::SendMessageW(device_controls_.list, LB_GETITEMDATA, static_cast(selection), 0)); + refresh_selected_device(); + } + return; + } + if (id >= button_base_id && id < button_base_id + static_cast(button_choices.size()) && notification == BN_CLICKED && buttons_locked(window_)) { + toggle_selected_button(static_cast(id - button_base_id)); + } + } + + void handle_slider(LPARAM slider_param) { + const auto slider = win32_cast(slider_param); + if (slider == battery_.slider) { + set_selected_battery_from_controls(); + return; + } + + for (std::size_t index = 0; index < axes_.sliders.size(); ++index) { + if (axes_.sliders[index] == slider) { + set_selected_axis(index); + return; + } + } + } + + void create_gamepad() { + const auto selection = ::SendMessageW(main_controls_.profile_combo, CB_GETCURSEL, 0, 0); + if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { + show_error(L"Select a profile first."); + return; + } + + const auto &choice = profile_choices[static_cast(selection)]; + const auto profile = profile_for_choice(choice); + if (!profile) { + show_error(L"Could not create the selected profile."); + return; + } + + lvh::CreateGamepadOptions options; + options.profile = *profile; + options.metadata.global_index = static_cast(next_metadata_index_++); + options.metadata.client_relative_index = 0; + options.metadata.client_type = choice.client_type; + options.metadata.has_motion_sensors = profile->capabilities.supports_motion; + options.metadata.has_touchpad = profile->capabilities.supports_touchpad; + options.metadata.has_rgb_led = profile->capabilities.supports_rgb_led; + options.metadata.has_battery = profile->capabilities.supports_battery; + options.metadata.stable_id = std::format("libvirtualhid-control-{}", options.metadata.global_index); + + auto created = lvh::GamepadStateAdapter::create(*runtime_, options); + if (!created) { + show_error(widen(created.status.message())); + return; + } + + const auto *gamepad = created.adapter->gamepad(); + if (gamepad == nullptr) { + show_error(L"Created gamepad handle is missing."); + return; + } + + const auto id = gamepad->device_id(); + created.adapter->set_output_callback([this, id](const lvh::GamepadOutput &output) { + record_output(id, output); + }); + + ControlledGamepad device; + device.profile_label = std::wstring {choice.label}; + device.adapter = std::move(created.adapter); + + { + std::lock_guard lock {mutex_}; + devices_[id] = std::move(device); + } + selected_id_ = id; + refresh_all(); + } + + void reset_selected_device() { + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->set_state({}); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void remove_selected_device() { + std::unique_ptr adapter; + { + std::lock_guard lock {mutex_}; + if (selected_id_ == 0) { + return; + } + const auto iter = devices_.find(selected_id_); + if (iter == devices_.end()) { + return; + } + adapter = std::move(iter->second.adapter); + devices_.erase(iter); + selected_id_ = devices_.empty() ? 0 : devices_.begin()->first; + } + if (adapter) { + if (const auto status = adapter->close(); !status.ok()) { + show_error(widen(status.message())); + } + } + refresh_all(); + } + + void remove_all_devices() { + std::vector> adapters; + { + std::lock_guard lock {mutex_}; + adapters = take_all_adapters_locked(); + } + + close_adapters(adapters, true); + refresh_all(); + } + + void toggle_selected_button(std::size_t index) { + auto pressed = false; + { + std::lock_guard lock {mutex_}; + const auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + pressed = !device->adapter->state().buttons.test(button_choices[index].button); + } + set_selected_button(index, pressed); + } + + void set_selected_button(std::size_t index, bool pressed) { + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->set_button(button_choices[index].button, pressed); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void handle_button_lock_changed() { + if (buttons_locked(window_)) { + refresh_selected_device(); + return; + } + + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + refresh_selected_device(); + return; + } + + auto state = device->adapter->state(); + state.buttons.clear(); + status = device->adapter->set_state(state); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void set_selected_axis(std::size_t index) { + auto status = lvh::OperationStatus::success(); + const auto position = ::SendMessageW(axes_.sliders[index], TBM_GETPOS, 0, 0); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + auto state = device->adapter->state(); + const auto value = slider_to_float(position); + switch (index) { + case 0: + state.left_stick.x = value; + status = device->adapter->set_left_stick(state.left_stick); + break; + case 1: + state.left_stick.y = value; + status = device->adapter->set_left_stick(state.left_stick); + break; + case 2: + state.right_stick.x = value; + status = device->adapter->set_right_stick(state.right_stick); + break; + case 3: + state.right_stick.y = value; + status = device->adapter->set_right_stick(state.right_stick); + break; + case 4: + status = device->adapter->set_left_trigger(value); + break; + case 5: + status = device->adapter->set_right_trigger(value); + break; + default: + break; + } + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void set_selected_battery_from_controls() { + const auto state_selection = ::SendMessageW(battery_.state_combo, CB_GETCURSEL, 0, 0); + if (state_selection == CB_ERR || state_selection < 0 || static_cast(state_selection) >= battery_choices.size()) { + return; + } + + lvh::GamepadBattery battery; + battery.state = battery_choices[static_cast(state_selection)].state; + battery.percentage = static_cast(std::clamp(::SendMessageW(battery_.slider, TBM_GETPOS, 0, 0), 0, 100)); + + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->set_battery(battery); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void clear_selected_battery() { + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->clear_battery(); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void refresh_all() { + refresh_backend_text(); + refresh_device_list(); + refresh_selected_device(); + } + + void refresh_backend_text() { + const auto &caps = runtime_->capabilities(); + std::wstring text = L"Backend: " + widen(caps.backend_name); + text += caps.supports_gamepad ? L" | gamepad available" : L" | gamepad unavailable"; + text += caps.supports_output_reports ? L" | output reports available" : L" | output reports unavailable"; + ::SetWindowTextW(main_controls_.backend_text, text.c_str()); + } + + void refresh_device_list() { + ::SendMessageW(device_controls_.list, LB_RESETCONTENT, 0, 0); + std::lock_guard lock {mutex_}; + for (const auto &[id, device] : devices_) { + std::wostringstream label; + label << L"#" << id << L" " << device.profile_label; + const auto index = ::SendMessageW(device_controls_.list, LB_ADDSTRING, 0, win32_cast(label.str().c_str())); + if (index != LB_ERR) { + ::SendMessageW(device_controls_.list, LB_SETITEMDATA, static_cast(index), static_cast(id)); + if (id == selected_id_) { + ::SendMessageW(device_controls_.list, LB_SETCURSEL, static_cast(index), 0); + } + } + } + } + + void refresh_selected_device() { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + auto relayout_needed = false; + const auto enabled = device != nullptr; + ::EnableWindow(device_controls_.reset_button, enabled); + ::EnableWindow(device_controls_.remove_selected_button, enabled); + ::EnableWindow(device_controls_.remove_all_button, !devices_.empty()); + for (auto *slider : axes_.sliders) { + ::EnableWindow(slider, enabled); + } + + if (device == nullptr) { + if (const auto profile = current_combo_profile(main_controls_.profile_combo)) { + relayout_needed = update_visible_controls_for_profile(*profile, buttons_.visible, battery_.visible); + ::SetWindowTextW(main_controls_.feature_text, profile_feature_summary(*profile).c_str()); + } else { + relayout_needed = std::ranges::any_of(buttons_.visible, [](bool visible) { + return visible; + }) || + battery_.visible; + buttons_.visible.fill(false); + battery_.visible = false; + ::SetWindowTextW(main_controls_.feature_text, L""); + } + + for (std::size_t index = 0; index < buttons_.controls.size(); ++index) { + ::EnableWindow(buttons_.controls[index], FALSE); + set_button_visual(buttons_, index, false); + } + ::EnableWindow(battery_.state_combo, FALSE); + ::EnableWindow(battery_.slider, FALSE); + ::EnableWindow(battery_.clear_button, FALSE); + ::SetWindowTextW(main_controls_.state_text, L"No device selected. Create a gamepad to begin testing."); + ::SetWindowTextW(output_controls_.summary_text, L"Output: no selected device."); + ::SendMessageW(output_controls_.nodes_list, LB_RESETCONTENT, 0, 0); + ::SendMessageW(output_controls_.output_list, LB_RESETCONTENT, 0, 0); + reset_sliders(axes_); + refresh_battery_controls({}, false); + if (relayout_needed) { + relayout_and_redraw(); + } + return; + } + + const auto *gamepad = device->adapter->gamepad(); + const auto &profile = gamepad->profile(); + const auto state = device->adapter->state(); + relayout_needed = update_visible_controls_for_profile(profile, buttons_.visible, battery_.visible); + + std::wostringstream state_text; + state_text << device->profile_label << L" #" << gamepad->device_id() << L"\r\n" + << device_type_name(profile.device_type) << L" | " << widen(profile.name) << L"\r\n" + << L"L(" << state.left_stick.x << L", " << state.left_stick.y << L") " + << L"R(" << state.right_stick.x << L", " << state.right_stick.y << L") " + << L"LT " << state.left_trigger << L" RT " << state.right_trigger << L"\r\n"; + if (state.battery) { + state_text << L"Battery " << battery_state_name(state.battery->state) << L" " << static_cast(state.battery->percentage) << L"% | "; + } else { + state_text << L"Battery unset | "; + } + state_text + << gamepad->submit_count() << L" submits"; + ::SetWindowTextW(main_controls_.state_text, state_text.str().c_str()); + ::SetWindowTextW(main_controls_.feature_text, profile_feature_summary(profile).c_str()); + ::SetWindowTextW(output_controls_.summary_text, output_summary(*device, profile).c_str()); + + for (std::size_t index = 0; index < button_choices.size(); ++index) { + ::EnableWindow(buttons_.controls[index], buttons_.visible[index]); + set_button_visual(buttons_, index, state.buttons.test(button_choices[index].button)); + } + + ::SendMessageW(axes_.sliders[0], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.x)); + ::SendMessageW(axes_.sliders[1], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.y)); + ::SendMessageW(axes_.sliders[2], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.x)); + ::SendMessageW(axes_.sliders[3], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.y)); + ::SendMessageW(axes_.sliders[4], TBM_SETPOS, TRUE, trigger_to_slider(state.left_trigger)); + ::SendMessageW(axes_.sliders[5], TBM_SETPOS, TRUE, trigger_to_slider(state.right_trigger)); + refresh_battery_controls(state, device->adapter->support().supports_battery); + + refresh_nodes(output_controls_.nodes_list, *gamepad); + refresh_outputs(output_controls_.output_list, *device); + if (relayout_needed) { + relayout_and_redraw(); + } + } + + void refresh_battery_controls(const lvh::GamepadState &state, bool supported) { + const auto enabled = selected_device_locked() != nullptr && supported; + ::EnableWindow(battery_.state_combo, enabled); + ::EnableWindow(battery_.slider, enabled); + ::EnableWindow(battery_.clear_button, enabled && state.battery.has_value()); + + if (state.battery) { + ::SendMessageW(battery_.state_combo, CB_SETCURSEL, battery_choice_index(state.battery->state), 0); + ::SendMessageW(battery_.slider, TBM_SETPOS, TRUE, state.battery->percentage); + return; + } + + ::SendMessageW(battery_.state_combo, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); + ::SendMessageW(battery_.slider, TBM_SETPOS, TRUE, 100); + } + + ControlledGamepad *selected_device_locked() { + if (selected_id_ == 0) { + return nullptr; + } + const auto iter = devices_.find(selected_id_); + if (iter == devices_.end()) { + return nullptr; + } + return &iter->second; + } + + void record_output(lvh::DeviceId id, const lvh::GamepadOutput &output) { + { + std::lock_guard lock {mutex_}; + const auto iter = devices_.find(id); + if (iter == devices_.end()) { + return; + } + + lvh::tools::virtualhid_control::record_output(iter->second, output, next_output_sequence_, max_output_events_); + } + if (window_ != nullptr) { + ::PostMessageW(window_, output_changed_message, 0, 0); + } + } + + void close_all_devices() { + std::vector> adapters; + { + std::lock_guard lock {mutex_}; + adapters = take_all_adapters_locked(); + } + + close_adapters(adapters, false); + } + + std::vector> take_all_adapters_locked() { + std::vector> adapters; + for (auto &[id, device] : devices_) { + adapters.push_back(std::move(device.adapter)); + } + devices_.clear(); + selected_id_ = 0; + return adapters; + } + + void close_adapters(const std::vector> &adapters, bool report_errors) const { + std::optional first_error; + for (const auto &adapter : adapters) { + if (adapter) { + if (const auto status = adapter->close(); !status.ok() && report_errors && !first_error) { + first_error = widen(status.message()); + } + } + } + + if (first_error) { + show_error(*first_error); + } + } + + void show_error(const std::wstring &message) const { + ::MessageBoxW(window_, message.c_str(), L"libvirtualhid control", MB_ICONERROR | MB_OK); + } + + HINSTANCE instance_ = nullptr; + HWND window_ = nullptr; + MainControls main_controls_; + DeviceListControls device_controls_; + ButtonControls buttons_; + AxisControls axes_; + BatteryControls battery_; + OutputControls output_controls_; + std::unique_ptr runtime_; + std::mutex mutex_; + std::map devices_; + lvh::DeviceId selected_id_ = 0; + std::uint64_t next_metadata_index_ = 0; + std::uint64_t next_output_sequence_ = 1; + static constexpr std::size_t max_output_events_ = 50; + }; + +} // namespace + +/** + * @brief Run the native Windows libvirtualhid control UI. + * @param instance Module instance. + * @return Process exit code. + */ +int WINAPI WinMain(HINSTANCE instance, HINSTANCE, LPSTR, int) { + ControlWindow window {instance}; + return window.run(); +} + +#else + + // standard includes + #include + +/** + * @brief Report that the native UI is not implemented on this platform yet. + * @return Nonzero because no UI was shown. + */ +int main() { + std::cerr << "virtualhid_control native UI is currently implemented for Windows only.\n"; + return 1; +} + +#endif diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp new file mode 100644 index 0000000..bdcd832 --- /dev/null +++ b/tools/virtualhid_control_model.cpp @@ -0,0 +1,281 @@ +/** + * @file tools/virtualhid_control_model.cpp + * @brief Testable model helper definitions for the native libvirtualhid control UI. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include "virtualhid_control_model.hpp" + +namespace lvh::tools::virtualhid_control { + namespace { + + void append_summary_separator(std::wostringstream &stream, bool wrote) { + if (wrote) { + stream << L" | "; + } + } + + } // namespace + + std::wstring device_type_name(DeviceType type) { + switch (type) { + using enum DeviceType; + + case gamepad: + return L"gamepad"; + case keyboard: + return L"keyboard"; + case mouse: + return L"mouse"; + case touchscreen: + return L"touchscreen"; + case trackpad: + return L"trackpad"; + case pen_tablet: + return L"pen tablet"; + } + return L"unknown"; + } + + std::wstring node_kind_name(DeviceNodeKind kind) { + switch (kind) { + using enum DeviceNodeKind; + + case input_event: + return L"input"; + case joystick: + return L"joystick"; + case hidraw: + return L"hidraw"; + case sysfs: + return L"sysfs"; + case other: + return L"other"; + } + return L"other"; + } + + std::wstring output_kind_name(GamepadOutputKind kind) { + switch (kind) { + using enum GamepadOutputKind; + + case rumble: + return L"rumble"; + case rgb_led: + return L"rgb led"; + case adaptive_triggers: + return L"adaptive triggers"; + case raw_report: + return L"raw report"; + case trigger_rumble: + return L"trigger rumble"; + } + return L"raw report"; + } + + std::wstring battery_state_name(GamepadBatteryState state) { + switch (state) { + using enum GamepadBatteryState; + + case unknown: + return L"unknown"; + case discharging: + return L"discharging"; + case charging: + return L"charging"; + case full: + return L"full"; + case voltage_or_temperature_error: + return L"voltage/temperature error"; + case temperature_error: + return L"temperature error"; + case charging_error: + return L"charging error"; + } + return L"unknown"; + } + + int battery_choice_index(GamepadBatteryState state) { + for (std::size_t index = 0; index < battery_choices.size(); ++index) { + if (battery_choices[index].state == state) { + return static_cast(index); + } + } + return 0; + } + + std::wstring raw_hex(const std::vector &bytes) { + std::wostringstream stream; + stream << std::hex << std::setfill(L'0'); + for (const auto value : bytes) { + stream << std::setw(2) << static_cast(value); + } + return stream.str(); + } + + std::optional profile_for_choice(const ProfileChoice &choice) { + switch (choice.kind) { + using enum GamepadProfileKind; + + case generic: + return profiles::generic_gamepad(); + case xbox_360: + return profiles::xbox_360(); + case xbox_one: + return profiles::xbox_one(); + case xbox_series: + return profiles::xbox_series(); + case dualshock4: + return profiles::dualshock4(); + case dualsense: + return profiles::dualsense(); + case switch_pro: + return profiles::switch_pro(); + } + return std::nullopt; + } + + int axis_to_slider(float value) { + return static_cast(std::lround(std::clamp(value, -1.0F, 1.0F) * static_cast(slider_scale))); + } + + int trigger_to_slider(float value) { + return static_cast(std::lround(std::clamp(value, 0.0F, 1.0F) * static_cast(slider_scale))); + } + + float slider_to_float(long value) { + return static_cast(value) / static_cast(slider_scale); + } + + std::wstring yes_no(bool value) { + return value ? L"yes" : L"no"; + } + + bool supports_normalized_feedback(const DeviceProfile &profile) { + using enum GamepadOutputKind; + + return supports_gamepad_output(profile, rumble) || + supports_gamepad_output(profile, rgb_led) || + supports_gamepad_output(profile, adaptive_triggers) || + supports_gamepad_output(profile, trigger_rumble); + } + + std::wstring profile_feature_summary(const DeviceProfile &profile) { + using enum GamepadOutputKind; + + const auto support = gamepad_profile_support(profile); + std::wostringstream stream; + stream << L"Features: battery " << yes_no(support.supports_battery); + stream << L" | rumble " << yes_no(supports_gamepad_output(profile, rumble)); + stream << L" | trigger rumble " << yes_no(supports_gamepad_output(profile, trigger_rumble)); + stream << L" | RGB LED " << yes_no(supports_gamepad_output(profile, rgb_led)); + stream << L" | adaptive triggers " << yes_no(supports_gamepad_output(profile, adaptive_triggers)); + stream << L" | raw output " << yes_no(supports_gamepad_output(profile, raw_report)); + return stream.str(); + } + + bool append_latest_output_summary(std::wostringstream &stream, const OutputState &state) { + auto wrote = false; + if (state.latest_rumble) { + stream << L"rumble low=" << state.latest_rumble->low_frequency_rumble + << L" high=" << state.latest_rumble->high_frequency_rumble; + wrote = true; + } + if (state.latest_trigger_rumble) { + append_summary_separator(stream, wrote); + stream << L"trigger rumble L=" << state.latest_trigger_rumble->left_trigger_rumble + << L" R=" << state.latest_trigger_rumble->right_trigger_rumble; + wrote = true; + } + if (state.latest_rgb_led) { + append_summary_separator(stream, wrote); + stream << L"RGB " << static_cast(state.latest_rgb_led->red) << L"," + << static_cast(state.latest_rgb_led->green) << L"," + << static_cast(state.latest_rgb_led->blue); + wrote = true; + } + if (state.latest_adaptive_triggers) { + append_summary_separator(stream, wrote); + stream << L"adaptive flags=" << static_cast(state.latest_adaptive_triggers->adaptive_trigger_flags); + wrote = true; + } + if (!wrote && state.latest_raw_report) { + stream << L"raw report"; + wrote = true; + } + return wrote; + } + + std::wstring output_summary(const OutputState &state, const DeviceProfile &profile) { + std::wostringstream stream; + stream << L"Output: "; + if (state.outputs.empty()) { + stream << L"no reports received"; + } else if (!append_latest_output_summary(stream, state)) { + stream << L"reports received"; + } + + if (!supports_normalized_feedback(profile)) { + stream << L" | profile has no normalized feedback categories"; + } + return stream.str(); + } + + bool update_visible_controls_for_profile( + const DeviceProfile &profile, + std::array &visible_buttons, + bool &battery_controls_visible + ) { + auto changed = false; + for (std::size_t index = 0; index < button_choices.size(); ++index) { + const auto visible = supports_gamepad_button(profile, button_choices[index].button); + changed = changed || visible_buttons[index] != visible; + visible_buttons[index] = visible; + } + changed = changed || battery_controls_visible != profile.capabilities.supports_battery; + battery_controls_visible = profile.capabilities.supports_battery; + return changed; + } + + void record_output( + OutputState &state, + const GamepadOutput &output, + std::uint64_t &next_sequence, + std::size_t max_output_events + ) { + state.outputs.push_back({.sequence = next_sequence++, .output = output}); + if (state.outputs.size() > max_output_events) { + state.outputs.erase( + state.outputs.begin(), + state.outputs.begin() + static_cast(state.outputs.size() - max_output_events) + ); + } + + switch (output.kind) { + using enum GamepadOutputKind; + + case rumble: + state.latest_rumble = output; + break; + case trigger_rumble: + state.latest_trigger_rumble = output; + break; + case rgb_led: + state.latest_rgb_led = output; + break; + case adaptive_triggers: + state.latest_adaptive_triggers = output; + break; + case raw_report: + state.latest_raw_report = output; + break; + } + } + +} // namespace lvh::tools::virtualhid_control diff --git a/tools/virtualhid_control_model.hpp b/tools/virtualhid_control_model.hpp new file mode 100644 index 0000000..ca37476 --- /dev/null +++ b/tools/virtualhid_control_model.hpp @@ -0,0 +1,142 @@ +/** + * @file tools/virtualhid_control_model.hpp + * @brief Testable model helpers for the native libvirtualhid control UI. + */ + +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include + +namespace lvh::tools::virtualhid_control { + + inline constexpr auto slider_scale = 100; + + struct ProfileChoice { + std::wstring_view id; + std::wstring_view label; + GamepadProfileKind kind; + ClientControllerType client_type; + }; + + struct ButtonChoice { + std::wstring_view label; + GamepadButton button; + }; + + struct AxisChoice { + std::wstring_view label; + int minimum; + int maximum; + }; + + struct BatteryChoice { + std::wstring_view label; + GamepadBatteryState state; + }; + + struct OutputLogEntry { + std::uint64_t sequence = 0; + GamepadOutput output; + }; + + struct OutputState { + std::vector outputs; + std::optional latest_rumble; + std::optional latest_trigger_rumble; + std::optional latest_rgb_led; + std::optional latest_adaptive_triggers; + std::optional latest_raw_report; + }; + + inline constexpr std::array profile_choices { + ProfileChoice {L"generic", L"Generic HID", GamepadProfileKind::generic, ClientControllerType::unknown}, + ProfileChoice {L"x360", L"Xbox 360", GamepadProfileKind::xbox_360, ClientControllerType::xbox}, + ProfileChoice {L"xone", L"Xbox One", GamepadProfileKind::xbox_one, ClientControllerType::xbox}, + ProfileChoice {L"xseries", L"Xbox Series", GamepadProfileKind::xbox_series, ClientControllerType::xbox}, + ProfileChoice {L"ds4", L"DualShock 4", GamepadProfileKind::dualshock4, ClientControllerType::playstation}, + ProfileChoice {L"ds5", L"DualSense", GamepadProfileKind::dualsense, ClientControllerType::playstation}, + ProfileChoice {L"switch", L"Switch Pro", GamepadProfileKind::switch_pro, ClientControllerType::nintendo}, + }; + + inline constexpr std::array button_choices { + ButtonChoice {L"A", GamepadButton::a}, + ButtonChoice {L"B", GamepadButton::b}, + ButtonChoice {L"X", GamepadButton::x}, + ButtonChoice {L"Y", GamepadButton::y}, + ButtonChoice {L"Back", GamepadButton::back}, + ButtonChoice {L"Start", GamepadButton::start}, + ButtonChoice {L"Guide", GamepadButton::guide}, + ButtonChoice {L"L3", GamepadButton::left_stick}, + ButtonChoice {L"R3", GamepadButton::right_stick}, + ButtonChoice {L"LB", GamepadButton::left_shoulder}, + ButtonChoice {L"RB", GamepadButton::right_shoulder}, + ButtonChoice {L"D-pad Up", GamepadButton::dpad_up}, + ButtonChoice {L"D-pad Down", GamepadButton::dpad_down}, + ButtonChoice {L"D-pad Left", GamepadButton::dpad_left}, + ButtonChoice {L"D-pad Right", GamepadButton::dpad_right}, + ButtonChoice {L"Misc", GamepadButton::misc1}, + ButtonChoice {L"Touchpad", GamepadButton::touchpad}, + ButtonChoice {L"Paddle 1", GamepadButton::paddle1}, + ButtonChoice {L"Paddle 2", GamepadButton::paddle2}, + ButtonChoice {L"Paddle 3", GamepadButton::paddle3}, + ButtonChoice {L"Paddle 4", GamepadButton::paddle4}, + }; + + inline constexpr std::array axis_choices { + AxisChoice {L"Left X", -slider_scale, slider_scale}, + AxisChoice {L"Left Y", -slider_scale, slider_scale}, + AxisChoice {L"Right X", -slider_scale, slider_scale}, + AxisChoice {L"Right Y", -slider_scale, slider_scale}, + AxisChoice {L"Left Trigger", 0, slider_scale}, + AxisChoice {L"Right Trigger", 0, slider_scale}, + }; + + inline constexpr std::array battery_choices { + BatteryChoice {L"Unknown", GamepadBatteryState::unknown}, + BatteryChoice {L"Discharging", GamepadBatteryState::discharging}, + BatteryChoice {L"Charging", GamepadBatteryState::charging}, + BatteryChoice {L"Full", GamepadBatteryState::full}, + BatteryChoice {L"Voltage/temperature error", GamepadBatteryState::voltage_or_temperature_error}, + BatteryChoice {L"Temperature error", GamepadBatteryState::temperature_error}, + BatteryChoice {L"Charging error", GamepadBatteryState::charging_error}, + }; + + std::wstring device_type_name(DeviceType type); + std::wstring node_kind_name(DeviceNodeKind kind); + std::wstring output_kind_name(GamepadOutputKind kind); + std::wstring battery_state_name(GamepadBatteryState state); + int battery_choice_index(GamepadBatteryState state); + std::wstring raw_hex(const std::vector &bytes); + std::optional profile_for_choice(const ProfileChoice &choice); + int axis_to_slider(float value); + int trigger_to_slider(float value); + float slider_to_float(long value); + std::wstring yes_no(bool value); + bool supports_normalized_feedback(const DeviceProfile &profile); + std::wstring profile_feature_summary(const DeviceProfile &profile); + bool append_latest_output_summary(std::wostringstream &stream, const OutputState &state); + std::wstring output_summary(const OutputState &state, const DeviceProfile &profile); + bool update_visible_controls_for_profile( + const DeviceProfile &profile, + std::array &visible_buttons, + bool &battery_controls_visible + ); + void record_output( + OutputState &state, + const GamepadOutput &output, + std::uint64_t &next_sequence, + std::size_t max_output_events + ); + +} // namespace lvh::tools::virtualhid_control diff --git a/tools/windows/virtualhid_control.rc b/tools/windows/virtualhid_control.rc new file mode 100644 index 0000000..7346a4a --- /dev/null +++ b/tools/windows/virtualhid_control.rc @@ -0,0 +1 @@ +101 ICON "libvirtualhid.ico"