Files
libvirtualhid/tools/virtualhid_control.cpp

1885 lines
61 KiB
C++

/**
* @file tools/virtualhid_control.cpp
* @brief SDL3 and Dear ImGui UI for creating and testing libvirtualhid devices.
*/
// standard includes
#include <algorithm>
#include <array>
#include <cfloat>
#include <chrono>
#include <cstdint>
#include <exception>
#include <format>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
// third-party includes
#include <imgui.h>
#include <imgui_impl_sdl3.h>
#include <imgui_impl_sdlrenderer3.h>
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
// local includes
#include "virtualhid_control_model.hpp"
/**
* @brief Implementation of the diagnostic control application's UI.
*/
namespace lvh::tools::virtualhid_control::ui {
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_feature_summary;
using lvh::tools::virtualhid_control::device_type_choices;
using lvh::tools::virtualhid_control::device_type_name;
using lvh::tools::virtualhid_control::mouse_button_choices;
using lvh::tools::virtualhid_control::mouse_button_event;
using lvh::tools::virtualhid_control::mouse_event_for_action;
using lvh::tools::virtualhid_control::MouseControlAction;
using lvh::tools::virtualhid_control::node_kind_name;
using lvh::tools::virtualhid_control::normalized_license_key;
using lvh::tools::virtualhid_control::output_kind_name;
using lvh::tools::virtualhid_control::output_summary;
using lvh::tools::virtualhid_control::OutputLogEntry;
using lvh::tools::virtualhid_control::OutputState;
using lvh::tools::virtualhid_control::profile_choices;
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;
struct ControlledGamepad: OutputState {
std::string profile_label;
std::unique_ptr<lvh::GamepadStateAdapter> adapter;
};
struct ControlledMouse {
std::string profile_label;
std::unique_ptr<lvh::Mouse> mouse;
std::array<bool, mouse_button_choices.size()> buttons {};
};
struct DeviceHandles {
std::vector<std::unique_ptr<lvh::GamepadStateAdapter>> gamepads;
std::vector<std::unique_ptr<lvh::Mouse>> mice;
};
struct ScheduledMouseEvent {
lvh::DeviceId device_id = 0;
lvh::MouseEvent event;
std::chrono::steady_clock::time_point due_at;
};
struct DeviceListItem {
lvh::DeviceId id = 0;
std::string label;
};
struct SelectedSnapshot {
bool has_device = false;
lvh::DeviceId id = 0;
lvh::DeviceType device_type = lvh::DeviceType::gamepad;
std::string profile_label;
lvh::DeviceProfile profile;
lvh::GamepadState state;
lvh::GamepadProfileSupport support;
std::array<bool, mouse_button_choices.size()> mouse_buttons {};
std::uint64_t submit_count = 0;
std::vector<lvh::DeviceNode> nodes;
std::vector<OutputLogEntry> outputs;
std::string state_text;
std::string output_text;
};
lvh::DeviceId first_device_id(
const std::map<lvh::DeviceId, ControlledGamepad> &gamepads,
const std::map<lvh::DeviceId, ControlledMouse> &mice
) {
if (gamepads.empty()) {
return mice.empty() ? 0 : mice.begin()->first;
}
if (mice.empty()) {
return gamepads.begin()->first;
}
return std::min(gamepads.begin()->first, mice.begin()->first);
}
class ScopedDisabled {
public:
explicit ScopedDisabled(bool disabled):
disabled_ {disabled} {
if (disabled_) {
ImGui::BeginDisabled();
}
}
ScopedDisabled(const ScopedDisabled &) = delete;
ScopedDisabled &operator=(const ScopedDisabled &) = delete;
~ScopedDisabled() {
if (disabled_) {
ImGui::EndDisabled();
}
}
private:
bool disabled_ = false;
};
void append_utf8(std::string &target, char32_t codepoint) {
if (codepoint <= 0x7F) {
target.push_back(static_cast<char>(codepoint));
} else if (codepoint <= 0x7FF) {
target.push_back(static_cast<char>(0xC0 | (codepoint >> 6U)));
target.push_back(static_cast<char>(0x80 | (codepoint & 0x3FU)));
} else if (codepoint <= 0xFFFF) {
target.push_back(static_cast<char>(0xE0 | (codepoint >> 12U)));
target.push_back(static_cast<char>(0x80 | ((codepoint >> 6U) & 0x3FU)));
target.push_back(static_cast<char>(0x80 | (codepoint & 0x3FU)));
} else {
target.push_back(static_cast<char>(0xF0 | (codepoint >> 18U)));
target.push_back(static_cast<char>(0x80 | ((codepoint >> 12U) & 0x3FU)));
target.push_back(static_cast<char>(0x80 | ((codepoint >> 6U) & 0x3FU)));
target.push_back(static_cast<char>(0x80 | (codepoint & 0x3FU)));
}
}
bool is_high_surrogate(char32_t codepoint) {
return codepoint >= 0xD800 && codepoint <= 0xDBFF;
}
bool is_low_surrogate(char32_t codepoint) {
return codepoint >= 0xDC00 && codepoint <= 0xDFFF;
}
char32_t next_wide_codepoint(std::wstring_view value, std::size_t &index) {
auto codepoint = static_cast<char32_t>(value[index]);
++index;
if constexpr (sizeof(wchar_t) == 2) {
if (is_high_surrogate(codepoint) && index < value.size()) {
const auto low = static_cast<char32_t>(value[index]);
if (is_low_surrogate(low)) {
++index;
return 0x10000 + ((codepoint - 0xD800) << 10U) + (low - 0xDC00);
}
}
}
return codepoint;
}
std::string to_utf8(std::wstring_view value) {
std::string result;
result.reserve(value.size());
std::size_t index = 0;
while (index < value.size()) {
append_utf8(result, next_wide_codepoint(value, index));
}
return result;
}
std::string output_line(const OutputLogEntry &entry) {
const auto &output = entry.output;
std::ostringstream line;
line << "#" << entry.sequence << " " << to_utf8(output_kind_name(output.kind))
<< " low=" << output.low_frequency_rumble
<< " high=" << output.high_frequency_rumble
<< " rgb=" << static_cast<unsigned>(output.red) << "," << static_cast<unsigned>(output.green) << ","
<< static_cast<unsigned>(output.blue);
if (!output.raw_report.empty()) {
line << " raw=" << to_utf8(raw_hex(output.raw_report));
}
return line.str();
}
void render_device_nodes(const SelectedSnapshot &selected) {
if (!selected.has_device) {
ImGui::TextDisabled("No selected device.");
return;
}
if (selected.nodes.empty()) {
ImGui::TextDisabled("No device nodes reported yet.");
return;
}
for (const auto &node : selected.nodes) {
const auto line = to_utf8(node_kind_name(node.kind)) + ": " + node.path;
ImGui::TextWrapped("%s", line.c_str());
}
}
void render_output_reports(const SelectedSnapshot &selected) {
if (!selected.has_device) {
ImGui::TextDisabled("No selected device.");
return;
}
if (selected.outputs.empty()) {
ImGui::TextDisabled("No output reports received.");
return;
}
for (const auto &entry : selected.outputs) {
const auto line = output_line(entry);
ImGui::TextWrapped("%s", line.c_str());
}
}
int scaled_window_dimension(int value, float scale) {
return std::max(value, static_cast<int>(static_cast<float>(value) * scale));
}
bool selected_battery_state_is_full(int state_index) {
return state_index >= 0 &&
static_cast<std::size_t>(state_index) < battery_choices.size() &&
battery_choices[static_cast<std::size_t>(state_index)].state == lvh::GamepadBatteryState::full;
}
std::string backend_text(const lvh::Runtime &runtime) {
const auto &caps = runtime.capabilities();
auto text = std::string {"Backend: "} + caps.backend_name;
text += caps.supports_gamepad ? " | gamepad available" : " | gamepad unavailable";
text += caps.supports_mouse ? " | mouse available" : " | mouse unavailable";
text += caps.supports_output_reports ? " | output reports available" : " | output reports unavailable";
return text;
}
struct LicenseSnapshot {
bool broker_supported = false;
bool broker_available = false;
bool licensed = false;
std::string plan_name = "Unavailable";
std::string customer_email;
std::string state_text = "License broker unavailable.";
std::string message;
std::string purchase_url;
std::string manage_account_url;
std::uint32_t active_devices = 0;
std::uint32_t free_limit = 0;
std::uint32_t activation_limit = 0;
std::uint32_t activation_usage = 0;
};
bool open_url(std::string_view url, std::string &error) {
if (url.empty()) {
error = "URL is not configured.";
return false;
}
if (SDL_OpenURL(std::string {url}.c_str())) {
return true;
}
error = SDL_GetError();
if (error.empty()) {
error = "Unable to open URL.";
}
return false;
}
std::string license_state_name(lvh::LicenseState state) {
using enum lvh::LicenseState;
switch (state) {
case unlicensed:
return "Unlicensed";
case licensed:
return "Licensed";
case expired:
return "Expired";
case disabled:
return "Disabled";
case invalid:
return "Invalid";
case unavailable:
default:
return "Unavailable";
}
}
LicenseSnapshot license_snapshot_from(const lvh::LicenseResult &result) {
const auto &status = result.license;
LicenseSnapshot snapshot;
snapshot.broker_supported = status.state != lvh::LicenseState::unavailable;
snapshot.broker_available = status.service_available;
snapshot.licensed = status.licensed();
snapshot.plan_name = status.plan_name;
snapshot.customer_email = status.customer_email;
snapshot.message = status.message.empty() ? result.status.message() : status.message;
snapshot.purchase_url = status.purchase_url;
snapshot.manage_account_url = status.manage_account_url;
snapshot.active_devices = status.active_devices;
snapshot.activation_limit = status.activation_limit;
snapshot.activation_usage = status.activation_usage;
const auto state = license_state_name(status.state);
snapshot.state_text = state;
if (!snapshot.plan_name.empty() && snapshot.plan_name != state) {
snapshot.state_text += " | " + snapshot.plan_name;
}
snapshot.state_text += std::format(" | active devices {}", status.active_devices);
if (status.licensed()) {
snapshot.state_text += " / unlimited";
} else {
snapshot.state_text += " | license required";
}
if (status.activation_limit > 0U) {
snapshot.state_text += std::format(" | machine limit {}", status.activation_limit);
}
return snapshot;
}
LicenseSnapshot license_snapshot_from(const lvh::LicenseResult &result, std::string &error) {
error = result.status.ok() ? std::string {} : result.status.message();
return license_snapshot_from(result);
}
/**
* @brief Display license status and manage machine activation on Windows and macOS.
*/
class LicensePanel {
public:
LicensePanel() {
refresh();
}
void refresh() {
std::string ignored;
apply_result(lvh::get_license_status(), ignored);
}
template<typename ErrorHandler>
void render(ErrorHandler show_error) {
ImGui::TextUnformatted("License");
ImGui::TextWrapped("%s", snapshot_.state_text.c_str());
if (!snapshot_.customer_email.empty()) {
ImGui::TextWrapped("%s", snapshot_.customer_email.c_str());
}
if (!snapshot_.message.empty()) {
ImGui::TextWrapped("%s", snapshot_.message.c_str());
}
#if defined(_WIN32) || defined(__APPLE__)
ImGui::TextUnformatted("License key");
ImGui::InputText("##license-key", license_key_input_.data(), license_key_input_.size());
{
const auto license_key = normalized_license_key(license_key_input_.data());
ScopedDisabled disabled {license_key.empty()};
if (ImGui::Button("Activate license", {-FLT_MIN, 0.0F})) {
activate(license_key, show_error);
}
}
{
ScopedDisabled disabled {!snapshot_.broker_available};
if (ImGui::Button("Refresh", {-FLT_MIN, 0.0F})) {
validate(show_error);
}
if (ImGui::Button("Deactivate this machine", {-FLT_MIN, 0.0F})) {
deactivate(show_error);
}
}
#else
if (ImGui::Button("Refresh", {-FLT_MIN, 0.0F})) {
refresh_and_report(show_error);
}
#endif
{
ScopedDisabled disabled {buy_url_.empty()};
if (ImGui::Button("Buy license", {-FLT_MIN, 0.0F})) {
open_configured_url(buy_url_, show_error);
}
}
{
ScopedDisabled disabled {manage_account_url_.empty()};
if (ImGui::Button("Manage account", {-FLT_MIN, 0.0F})) {
open_configured_url(manage_account_url_, show_error);
}
}
}
template<typename CreateHandler>
void render_create_button(CreateHandler create_device) const {
#if defined(_WIN32)
ScopedDisabled disabled {!snapshot_.broker_available || !snapshot_.licensed};
#endif
if (ImGui::Button("Create", {-FLT_MIN, 0.0F})) {
create_device();
}
}
private:
void apply_result(const lvh::LicenseResult &result, std::string &error) {
snapshot_ = license_snapshot_from(result, error);
buy_url_ = snapshot_.purchase_url;
manage_account_url_ = snapshot_.manage_account_url;
}
template<typename ErrorHandler>
void refresh_and_report(ErrorHandler &show_error) {
std::string error;
apply_result(lvh::get_license_status(), error);
if (!error.empty()) {
show_error(error);
}
}
template<typename ErrorHandler>
void open_configured_url(const std::string &url, ErrorHandler &show_error) const {
std::string error;
if (!open_url(url, error)) {
show_error(error);
}
}
#if defined(_WIN32) || defined(__APPLE__)
template<typename ErrorHandler>
void activate(std::string_view license_key, ErrorHandler &show_error) {
std::string error;
apply_result(lvh::activate_license(license_key), error);
if (!error.empty()) {
show_error(error);
} else {
license_key_input_.fill('\0');
}
}
template<typename ErrorHandler>
void validate(ErrorHandler &show_error) {
std::string error;
apply_result(lvh::validate_license(), error);
if (!error.empty()) {
show_error(error);
}
}
template<typename ErrorHandler>
void deactivate(ErrorHandler &show_error) {
std::string error;
apply_result(lvh::deactivate_license(), error);
if (!error.empty()) {
show_error(error);
}
}
#endif
LicenseSnapshot snapshot_;
#if defined(_WIN32) || defined(__APPLE__)
std::array<char, 128> license_key_input_ {};
#endif
std::string buy_url_;
std::string manage_account_url_;
};
class ErrorPanel {
public:
void show(std::string_view message) {
message_ = message.empty() ? "Operation failed." : std::string {message};
open_popup_ = true;
}
void render() {
if (open_popup_) {
ImGui::OpenPopup("Error");
open_popup_ = false;
}
if (ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::TextWrapped("%s", message_.c_str());
if (ImGui::Button("OK", {120.0F, 0.0F})) {
message_.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
}
private:
std::string message_;
bool open_popup_ = false;
};
class DevicePanel {
public:
void refresh_license() {
license_panel_.refresh();
}
lvh::DeviceType current_device_type() const {
const auto *choice = current_device_type_choice();
return choice == nullptr ? lvh::DeviceType::gamepad : choice->type;
}
std::optional<lvh::DeviceProfile> current_profile() const {
const auto *choice = current_profile_choice();
if (choice == nullptr) {
return std::nullopt;
}
return profile_for_choice(*choice);
}
const lvh::tools::virtualhid_control::ProfileChoice *selected_profile_choice() const {
return current_profile_choice();
}
std::optional<lvh::DeviceProfile> control_profile(const SelectedSnapshot &selected) const {
if (selected.has_device) {
return selected.profile;
}
if (current_device_type() == lvh::DeviceType::mouse) {
return lvh::profiles::mouse();
}
return current_profile();
}
template<typename CreateHandler, typename ResetHandler, typename RemoveHandler, typename RemoveAllHandler, typename ErrorHandler>
void render(
const std::vector<DeviceListItem> &devices,
lvh::DeviceId &selected_id,
CreateHandler create_device,
ResetHandler reset_device,
RemoveHandler remove_device,
RemoveAllHandler remove_all_devices,
ErrorHandler show_error
) {
license_panel_.render(show_error);
ImGui::Separator();
render_device_type_selector();
render_profile_selector();
license_panel_.render_create_button(create_device);
render_device_list(devices, selected_id);
render_device_actions(devices.empty(), selected_id, reset_device, remove_device, remove_all_devices);
}
private:
const lvh::tools::virtualhid_control::DeviceTypeChoice *current_device_type_choice() const {
if (device_type_index_ >= device_type_choices.size()) {
return nullptr;
}
return &device_type_choices[device_type_index_];
}
const lvh::tools::virtualhid_control::ProfileChoice *current_profile_choice() const {
if (profile_index_ >= profile_choices.size()) {
return nullptr;
}
return &profile_choices[profile_index_];
}
void render_device_type_selector() {
ImGui::TextUnformatted("Device type");
const auto *choice = current_device_type_choice();
if (const auto preview = choice == nullptr ? std::string {"Select device type"} : to_utf8(choice->label); !ImGui::BeginCombo("##device-type", preview.c_str())) {
return;
}
for (std::size_t index = 0; index < device_type_choices.size(); ++index) {
render_device_type_option(index);
}
ImGui::EndCombo();
}
void render_device_type_option(std::size_t index) {
const auto label = to_utf8(device_type_choices[index].label);
const auto selected = index == device_type_index_;
if (ImGui::Selectable(label.c_str(), selected)) {
device_type_index_ = index;
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
}
void render_profile_selector() {
ImGui::TextUnformatted("Profile");
if (current_device_type() != lvh::DeviceType::gamepad) {
ImGui::TextUnformatted("Generic five-button mouse");
return;
}
const auto *choice = current_profile_choice();
if (const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); !ImGui::BeginCombo("##profile", preview.c_str())) {
return;
}
for (std::size_t index = 0; index < profile_choices.size(); ++index) {
render_profile_option(index);
}
ImGui::EndCombo();
}
void render_profile_option(std::size_t index) {
const auto label = to_utf8(profile_choices[index].label);
const auto selected = index == profile_index_;
if (ImGui::Selectable(label.c_str(), selected)) {
profile_index_ = index;
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
}
void render_device_list(const std::vector<DeviceListItem> &devices, lvh::DeviceId &selected_id) const {
ImGui::Spacing();
ImGui::TextUnformatted("Devices");
if (!ImGui::BeginListBox("##devices", {-FLT_MIN, 150.0F})) {
return;
}
if (devices.empty()) {
ImGui::TextDisabled("No devices");
}
for (const auto &device : devices) {
if (ImGui::Selectable(device.label.c_str(), selected_id == device.id)) {
selected_id = device.id;
}
}
ImGui::EndListBox();
}
template<typename ResetHandler, typename RemoveHandler, typename RemoveAllHandler>
void render_device_actions(
bool devices_empty,
lvh::DeviceId selected_id,
ResetHandler reset_device,
RemoveHandler remove_device,
RemoveAllHandler remove_all_devices
) const {
{
ScopedDisabled disabled {selected_id == 0};
if (ImGui::Button("Reset", {-FLT_MIN, 0.0F})) {
reset_device();
}
if (ImGui::Button("Remove selected", {-FLT_MIN, 0.0F})) {
remove_device();
}
}
{
ScopedDisabled disabled {devices_empty};
if (ImGui::Button("Remove all", {-FLT_MIN, 0.0F})) {
remove_all_devices();
}
}
}
LicensePanel license_panel_;
std::size_t device_type_index_ = 0;
std::size_t profile_index_ = 3;
};
class MouseControlPanel {
public:
template<typename SubmitHandler>
void tick(SubmitHandler submit_event) {
dispatch_due_events(submit_event);
}
template<typename SubmitHandler>
void render(const SelectedSnapshot &selected, SubmitHandler submit_event) {
using enum MouseControlAction;
ImGui::TextWrapped(
"Keyboard control: use Tab or arrow keys to highlight a control, then Space or Enter to activate it. "
"Mouse buttons remain pressed only while the activation key is held."
);
ImGui::SliderInt("Movement step", &motion_step_, 1, 500);
ImGui::SliderInt("Scroll step", &scroll_step_, 1, 1200);
ImGui::Checkbox("Delayed browser test", &delayed_test_);
if (ImGui::IsItemDeactivatedAfterEdit()) {
release_active_buttons(submit_event);
cancel_all_scheduled_events(submit_event);
}
if (delayed_test_) {
render_delayed_test_controls(submit_event);
}
ImGui::TextUnformatted("Relative movement");
if (ImGui::BeginTable("mouse-motion", 4, ImGuiTableFlags_SizingStretchSame)) {
render_action_button(selected, "Move left", move_left, submit_event);
render_action_button(selected, "Move up", move_up, submit_event);
render_action_button(selected, "Move down", move_down, submit_event);
render_action_button(selected, "Move right", move_right, submit_event);
ImGui::EndTable();
}
ImGui::TextUnformatted("Buttons (momentary)");
if (ImGui::BeginTable("mouse-buttons", 3, ImGuiTableFlags_SizingStretchSame)) {
for (std::size_t index = 0; index < mouse_button_choices.size(); ++index) {
render_button_control(selected, index, submit_event);
}
ImGui::EndTable();
}
ImGui::TextUnformatted("Wheel");
if (ImGui::BeginTable("mouse-wheel", 4, ImGuiTableFlags_SizingStretchSame)) {
render_action_button(selected, "Pan left", pan_left, submit_event);
render_action_button(selected, "Wheel up", wheel_up, submit_event);
render_action_button(selected, "Wheel down", wheel_down, submit_event);
render_action_button(selected, "Pan right", pan_right, submit_event);
ImGui::EndTable();
}
}
void forget_device(lvh::DeviceId id) {
std::erase_if(scheduled_events_, [id](const ScheduledMouseEvent &event) {
return event.device_id == id;
});
for (std::size_t index = 0; index < active_device_ids_.size(); ++index) {
if (active_device_ids_[index] == id) {
button_active_[index] = false;
active_device_ids_[index] = 0;
}
}
}
void reset() {
button_active_.fill(false);
active_device_ids_.fill(0);
scheduled_events_.clear();
}
private:
template<typename SubmitHandler>
void render_delayed_test_controls(SubmitHandler submit_event) {
ImGui::SliderInt("Delay (seconds)", &delay_seconds_, 1, 10);
ImGui::TextWrapped(
"Activate an action, then switch to the browser before the delay expires. Button actions send one "
"press-and-release click. Keep the pointer over the browser test target."
);
render_scheduled_status(submit_event);
}
template<typename SubmitHandler>
void render_scheduled_status(SubmitHandler submit_event) {
if (scheduled_events_.empty()) {
ImGui::TextDisabled("No browser-test action queued.");
return;
}
const auto next = std::ranges::min_element(scheduled_events_, {}, &ScheduledMouseEvent::due_at);
const auto remaining = std::max(
std::chrono::duration<float> {next->due_at - std::chrono::steady_clock::now()}.count(),
0.0F
);
ImGui::TextColored(
{1.0F, 0.8F, 0.2F, 1.0F},
"Browser-test input queued: switch windows now (%.1f s).",
remaining
);
if (ImGui::Button("Cancel queued input")) {
cancel_all_scheduled_events(submit_event);
}
}
template<typename SubmitHandler>
void render_action_button(
const SelectedSnapshot &selected,
const char *label,
MouseControlAction action,
SubmitHandler submit_event
) {
ImGui::TableNextColumn();
ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse};
if (!ImGui::Button(label, {-FLT_MIN, 30.0F})) {
return;
}
const auto event = mouse_event_for_action(action, motion_step_, scroll_step_);
if (delayed_test_) {
schedule_event(selected.id, event);
} else {
submit_event(selected.id, event);
}
}
template<typename SubmitHandler>
void render_button_control(const SelectedSnapshot &selected, std::size_t index, SubmitHandler submit_event) {
ImGui::TableNextColumn();
ImGui::PushID(static_cast<int>(index));
const auto pressed = selected.has_device && selected.device_type == lvh::DeviceType::mouse &&
selected.mouse_buttons[index];
if (pressed) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive));
}
auto clicked = false;
auto active = false;
{
ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse};
const auto label = to_utf8(mouse_button_choices[index].label);
clicked = ImGui::Button(label.c_str(), {-FLT_MIN, 30.0F});
active = ImGui::IsItemActive();
}
if (delayed_test_ && clicked) {
schedule_click(selected.id, mouse_button_choices[index].button);
} else if (!delayed_test_) {
update_active_button(selected.id, index, active, submit_event);
}
if (pressed) {
ImGui::PopStyleColor();
}
ImGui::PopID();
}
template<typename SubmitHandler>
void update_active_button(lvh::DeviceId selected_id, std::size_t index, bool active, SubmitHandler submit_event) {
if (button_active_[index] == active) {
return;
}
button_active_[index] = active;
if (active) {
active_device_ids_[index] = selected_id;
submit_event(selected_id, mouse_button_event(mouse_button_choices[index].button, true));
return;
}
const auto device_id = active_device_ids_[index];
active_device_ids_[index] = 0;
submit_event(device_id, mouse_button_event(mouse_button_choices[index].button, false));
}
void schedule_event(lvh::DeviceId id, const lvh::MouseEvent &event) {
if (id == 0) {
return;
}
scheduled_events_.push_back({
.device_id = id,
.event = event,
.due_at = std::chrono::steady_clock::now() + std::chrono::seconds {delay_seconds_},
});
}
void schedule_click(lvh::DeviceId id, lvh::MouseButton button) {
if (id == 0) {
return;
}
const auto press_at = std::chrono::steady_clock::now() + std::chrono::seconds {delay_seconds_};
scheduled_events_.push_back({
.device_id = id,
.event = mouse_button_event(button, true),
.due_at = press_at,
});
scheduled_events_.push_back({
.device_id = id,
.event = mouse_button_event(button, false),
.due_at = press_at + std::chrono::milliseconds {100},
});
}
template<typename SubmitHandler>
void dispatch_due_events(SubmitHandler submit_event) {
const auto now = std::chrono::steady_clock::now();
auto iter = scheduled_events_.begin();
while (iter != scheduled_events_.end()) {
if (iter->due_at > now) {
++iter;
continue;
}
const auto event = *iter;
iter = scheduled_events_.erase(iter);
submit_event(event.device_id, event.event);
}
}
template<typename SubmitHandler>
void release_active_buttons(SubmitHandler submit_event) {
for (std::size_t index = 0; index < button_active_.size(); ++index) {
if (!button_active_[index]) {
continue;
}
submit_event(
active_device_ids_[index],
mouse_button_event(mouse_button_choices[index].button, false)
);
button_active_[index] = false;
active_device_ids_[index] = 0;
}
}
template<typename SubmitHandler>
void cancel_all_scheduled_events(SubmitHandler submit_event) {
std::vector<ScheduledMouseEvent> releases;
for (const auto &event : scheduled_events_) {
if (event.event.kind == lvh::MouseEventKind::button && !event.event.pressed) {
releases.push_back(event);
}
}
scheduled_events_.clear();
for (const auto &release : releases) {
submit_event(release.device_id, release.event);
}
}
std::array<bool, mouse_button_choices.size()> button_active_ {};
std::array<lvh::DeviceId, mouse_button_choices.size()> active_device_ids_ {};
int motion_step_ = 25;
int scroll_step_ = 120;
bool delayed_test_ = false;
int delay_seconds_ = 3;
std::vector<ScheduledMouseEvent> scheduled_events_;
};
int axis_position(const SelectedSnapshot &selected, std::size_t index) {
if (!selected.has_device) {
return 0;
}
switch (index) {
case 0:
return axis_to_slider(selected.state.left_stick.x);
case 1:
return axis_to_slider(selected.state.left_stick.y);
case 2:
return axis_to_slider(selected.state.right_stick.x);
case 3:
return axis_to_slider(selected.state.right_stick.y);
case 4:
return trigger_to_slider(selected.state.left_trigger);
case 5:
return trigger_to_slider(selected.state.right_trigger);
default:
return 0;
}
}
class ControlApp {
public:
ControlApp() {
lvh::RuntimeOptions options;
options.backend = lvh::BackendKind::platform_default;
runtime_ = lvh::Runtime::create(options);
}
~ControlApp() noexcept {
try {
close_all_devices();
} catch (const std::exception &ex) {
SDL_Log("Failed to close virtual devices: %s", ex.what());
} catch (...) {
SDL_Log("Failed to close virtual devices.");
}
}
ControlApp(const ControlApp &) = delete;
ControlApp &operator=(const ControlApp &) = delete;
void tick() {
mouse_control_panel_.tick([this](lvh::DeviceId id, const lvh::MouseEvent &event) {
submit_mouse_event(id, event);
});
}
void render() {
const auto devices = snapshot_devices();
const auto selected = snapshot_selected_device();
const auto &io = ImGui::GetIO();
ImGui::SetNextWindowPos({0.0F, 0.0F});
ImGui::SetNextWindowSize(io.DisplaySize);
constexpr auto window_flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus;
ImGui::Begin("libvirtualhid control", nullptr, window_flags);
const auto backend = backend_text(*runtime_);
ImGui::TextWrapped("%s", backend.c_str());
ImGui::Separator();
const auto create_device = [this] {
create_selected_device();
};
const auto reset_device = [this] {
reset_selected_device();
};
const auto remove_device = [this] {
remove_selected_device();
};
const auto remove_all = [this] {
remove_all_devices();
};
const auto show_error = [this](std::string_view message) {
error_panel_.show(message);
};
const auto render_devices = [this, &devices, &create_device, &reset_device, &remove_device, &remove_all, &show_error] {
device_panel_.render(
devices,
selected_id_,
create_device,
reset_device,
remove_device,
remove_all,
show_error
);
};
if (ImGui::GetContentRegionAvail().x < 760.0F) {
render_devices();
ImGui::Separator();
render_control_panel(selected);
} else if (ImGui::BeginTable("control-layout", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV)) {
ImGui::TableSetupColumn("Devices", ImGuiTableColumnFlags_WidthFixed, 220.0F);
ImGui::TableSetupColumn("Controls", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
render_devices();
ImGui::TableSetColumnIndex(1);
render_control_panel(selected);
ImGui::EndTable();
}
error_panel_.render();
ImGui::End();
}
private:
std::vector<DeviceListItem> snapshot_devices() const {
std::vector<DeviceListItem> result;
std::lock_guard lock {mutex_};
result.reserve(devices_.size() + mice_.size());
for (const auto &[id, device] : devices_) {
result.push_back({.id = id, .label = std::format("#{} {}", id, device.profile_label)});
}
for (const auto &[id, device] : mice_) {
result.push_back({.id = id, .label = std::format("#{} {}", id, device.profile_label)});
}
std::ranges::sort(result, {}, &DeviceListItem::id);
return result;
}
SelectedSnapshot snapshot_selected_device() const {
std::lock_guard lock {mutex_};
SelectedSnapshot snapshot;
if (const auto *device = selected_device_locked(); device != nullptr && device->adapter != nullptr && device->adapter->gamepad() != nullptr) {
const auto *gamepad = device->adapter->gamepad();
snapshot.has_device = true;
snapshot.id = gamepad->device_id();
snapshot.device_type = lvh::DeviceType::gamepad;
snapshot.profile_label = device->profile_label;
snapshot.profile = gamepad->profile();
snapshot.state = device->adapter->state();
snapshot.support = device->adapter->support();
snapshot.submit_count = gamepad->submit_count();
snapshot.nodes = gamepad->device_nodes();
snapshot.outputs = device->outputs;
snapshot.output_text = to_utf8(output_summary(*device, snapshot.profile));
std::ostringstream state;
state << snapshot.profile_label << " #" << snapshot.id << "\n"
<< to_utf8(device_type_name(snapshot.profile.device_type)) << " | " << snapshot.profile.name << "\n"
<< "L(" << snapshot.state.left_stick.x << ", " << snapshot.state.left_stick.y << ") "
<< "R(" << snapshot.state.right_stick.x << ", " << snapshot.state.right_stick.y << ") "
<< "LT " << snapshot.state.left_trigger << " RT " << snapshot.state.right_trigger << "\n";
if (snapshot.state.battery) {
state << "Battery " << to_utf8(battery_state_name(snapshot.state.battery->state)) << " "
<< static_cast<unsigned>(snapshot.state.battery->percentage) << "% | ";
} else {
state << "Battery unset | ";
}
state << snapshot.submit_count << " submits";
snapshot.state_text = state.str();
return snapshot;
}
const auto *device = selected_mouse_locked();
if (device == nullptr || device->mouse == nullptr) {
return snapshot;
}
snapshot.has_device = true;
snapshot.id = device->mouse->device_id();
snapshot.device_type = lvh::DeviceType::mouse;
snapshot.profile_label = device->profile_label;
snapshot.profile = device->mouse->profile();
snapshot.mouse_buttons = device->buttons;
snapshot.submit_count = device->mouse->submit_count();
snapshot.nodes = device->mouse->device_nodes();
snapshot.output_text = "Output: mouse is input-only.";
std::ostringstream state;
state << snapshot.profile_label << " #" << snapshot.id << "\n"
<< to_utf8(device_type_name(snapshot.profile.device_type)) << " | " << snapshot.profile.name << "\n"
<< snapshot.submit_count << " submits";
#if defined(_WIN32)
state << "\n"
<< (snapshot.nodes.empty() ? "SendInput fallback (no HID device node)" : "Driver-backed HID mouse");
#endif
snapshot.state_text = state.str();
return snapshot;
}
void render_control_panel(const SelectedSnapshot &selected) {
const auto profile = device_panel_.control_profile(selected);
if (selected.has_device) {
ImGui::TextWrapped("%s", selected.state_text.c_str());
} else {
ImGui::TextUnformatted("No device selected.");
}
if (profile) {
const auto feature_text = to_utf8(device_feature_summary(*profile));
ImGui::TextWrapped("%s", feature_text.c_str());
} else {
ImGui::TextDisabled("No profile selected.");
}
ImGui::Separator();
if (const auto device_type = selected.has_device ? selected.device_type : device_panel_.current_device_type(); device_type == lvh::DeviceType::mouse) {
mouse_control_panel_.render(selected, [this](lvh::DeviceId id, const lvh::MouseEvent &event) {
submit_mouse_event(id, event);
});
} else {
render_button_controls(selected, profile);
ImGui::Separator();
render_axis_controls(selected);
ImGui::Separator();
render_battery_controls(selected, profile);
}
ImGui::Separator();
render_output_controls(selected);
}
void render_button_controls(
const SelectedSnapshot &selected,
const std::optional<lvh::DeviceProfile> &profile
) {
ImGui::Checkbox("Lock buttons", &lock_buttons_);
if (ImGui::IsItemDeactivatedAfterEdit()) {
handle_button_lock_changed();
}
std::array<bool, button_choices.size()> visible_buttons {};
auto battery_visible = false;
if (profile) {
update_visible_controls_for_profile(*profile, visible_buttons, battery_visible);
}
if (!selected.has_device || lock_buttons_) {
button_active_.fill(false);
}
const auto column_count = ImGui::GetContentRegionAvail().x < 480.0F ? 3 : 4;
if (ImGui::BeginTable("buttons", column_count, ImGuiTableFlags_SizingStretchSame)) {
for (std::size_t index = 0; index < button_choices.size(); ++index) {
if (!visible_buttons[index]) {
continue;
}
render_button_control(selected, index);
}
ImGui::EndTable();
}
}
void render_button_control(const SelectedSnapshot &selected, std::size_t index) {
ImGui::TableNextColumn();
ImGui::PushID(static_cast<int>(index));
const auto pressed = selected.has_device && selected.state.buttons.test(button_choices[index].button);
if (pressed) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive));
}
{
ScopedDisabled disabled {!selected.has_device};
const auto label = to_utf8(button_choices[index].label);
if (const auto clicked = ImGui::Button(label.c_str(), {-FLT_MIN, 30.0F}); lock_buttons_ && clicked) {
toggle_selected_button(index);
}
if (!lock_buttons_) {
const auto active = ImGui::IsItemActive() && ImGui::IsMouseDown(ImGuiMouseButton_Left);
if (button_active_[index] != active) {
button_active_[index] = active;
set_selected_button(index, active);
}
}
}
if (pressed) {
ImGui::PopStyleColor();
}
ImGui::PopID();
}
void render_axis_controls(const SelectedSnapshot &selected) {
ImGui::TextUnformatted("Axes");
if (ImGui::BeginTable("axes", 2, ImGuiTableFlags_SizingStretchSame)) {
for (std::size_t index = 0; index < axis_choices.size(); ++index) {
ImGui::TableNextColumn();
ImGui::PushID(static_cast<int>(index));
const auto label = to_utf8(axis_choices[index].label);
ImGui::TextUnformatted(label.c_str());
auto position = axis_position(selected, index);
{
ScopedDisabled disabled {!selected.has_device};
if (ImGui::SliderInt("##axis", &position, axis_choices[index].minimum, axis_choices[index].maximum)) {
set_selected_axis(index, position);
}
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
void render_battery_controls(
const SelectedSnapshot &selected,
const std::optional<lvh::DeviceProfile> &profile
) {
auto visible_buttons = std::array<bool, button_choices.size()> {};
auto battery_visible = false;
if (profile) {
update_visible_controls_for_profile(*profile, visible_buttons, battery_visible);
}
if (!battery_visible) {
return;
}
ImGui::TextUnformatted("Battery");
const auto enabled = selected.has_device && selected.support.supports_battery;
if (selected.state.battery) {
battery_state_index_ = battery_choice_index(selected.state.battery->state);
battery_percentage_ = selected.state.battery->percentage;
}
if (selected_battery_state_is_full(battery_state_index_)) {
battery_percentage_ = 100;
}
{
ScopedDisabled disabled {!enabled};
if (const auto state_label = to_utf8(battery_choices[static_cast<std::size_t>(battery_state_index_)].label); ImGui::BeginCombo("State", state_label.c_str())) {
for (std::size_t index = 0; index < battery_choices.size(); ++index) {
const auto label = to_utf8(battery_choices[index].label);
const auto selected_state = static_cast<int>(index) == battery_state_index_;
if (ImGui::Selectable(label.c_str(), selected_state)) {
battery_state_index_ = static_cast<int>(index);
battery_percentage_ = selected_battery_state_is_full(battery_state_index_) ? 100 : battery_percentage_;
set_selected_battery_from_controls();
}
if (selected_state) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndCombo();
}
{
ScopedDisabled percentage_disabled {selected_battery_state_is_full(battery_state_index_)};
if (ImGui::SliderInt("Percentage", &battery_percentage_, 0, 100)) {
set_selected_battery_from_controls();
}
}
}
{
ScopedDisabled disabled {!enabled || !selected.state.battery.has_value()};
if (ImGui::Button("Clear battery")) {
clear_selected_battery();
}
}
}
void render_output_controls(const SelectedSnapshot &selected) const {
ImGui::TextWrapped("%s", selected.has_device ? selected.output_text.c_str() : "Output: no selected device.");
if (ImGui::BeginTable("diagnostics", 2, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchSame)) {
ImGui::TableSetupColumn("Device nodes");
ImGui::TableSetupColumn("Output reports");
ImGui::TableHeadersRow();
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
if (ImGui::BeginChild("device-nodes", {0.0F, 170.0F}, ImGuiChildFlags_Borders)) {
render_device_nodes(selected);
}
ImGui::EndChild();
ImGui::TableSetColumnIndex(1);
if (ImGui::BeginChild("output-reports", {0.0F, 170.0F}, ImGuiChildFlags_Borders)) {
render_output_reports(selected);
}
ImGui::EndChild();
ImGui::EndTable();
}
}
void create_selected_device() {
if (device_panel_.current_device_type() == lvh::DeviceType::mouse) {
create_mouse();
} else {
create_gamepad();
}
}
void create_gamepad() {
const auto *choice = device_panel_.selected_profile_choice();
if (choice == nullptr) {
error_panel_.show("Select a profile first.");
return;
}
const auto profile = profile_for_choice(*choice);
if (!profile) {
error_panel_.show("Could not create the selected profile.");
return;
}
lvh::CreateGamepadOptions options;
options.profile = *profile;
options.metadata.global_index = static_cast<int>(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) {
error_panel_.show(created.status.message());
return;
}
const auto *gamepad = created.adapter->gamepad();
if (gamepad == nullptr) {
error_panel_.show("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 = to_utf8(choice->label);
device.adapter = std::move(created.adapter);
{
std::lock_guard lock {mutex_};
devices_[id] = std::move(device);
}
selected_id_ = id;
device_panel_.refresh_license();
}
void create_mouse() {
lvh::CreateMouseOptions options;
options.profile = lvh::profiles::mouse();
options.stable_id = std::format("libvirtualhid-control-mouse-{}", next_metadata_index_++);
auto created = runtime_->create_mouse(options);
if (!created) {
error_panel_.show(created.status.message());
return;
}
const auto id = created.mouse->device_id();
ControlledMouse device;
device.profile_label = "Mouse";
device.mouse = std::move(created.mouse);
{
std::lock_guard lock {mutex_};
mice_[id] = std::move(device);
}
selected_id_ = id;
device_panel_.refresh_license();
}
void reset_selected_device() {
auto status = lvh::OperationStatus::success();
auto reset_id = lvh::DeviceId {0};
{
std::lock_guard lock {mutex_};
reset_id = selected_id_;
if (auto *device = selected_device_locked(); device != nullptr) {
status = device->adapter->set_state({});
} else if (auto *mouse = selected_mouse_locked(); mouse != nullptr) {
for (std::size_t index = 0; index < mouse->buttons.size(); ++index) {
if (!mouse->buttons[index]) {
continue;
}
const auto release_status = mouse->mouse->button(mouse_button_choices[index].button, false);
if (release_status.ok()) {
mouse->buttons[index] = false;
} else if (status.ok()) {
status = release_status;
}
}
} else {
return;
}
}
button_active_.fill(false);
mouse_control_panel_.forget_device(reset_id);
if (!status.ok()) {
error_panel_.show(status.message());
}
}
void remove_selected_device() {
std::unique_ptr<lvh::GamepadStateAdapter> adapter;
std::unique_ptr<lvh::Mouse> mouse;
auto removed_id = lvh::DeviceId {0};
{
std::lock_guard lock {mutex_};
if (selected_id_ == 0) {
return;
}
removed_id = selected_id_;
if (const auto gamepad_iter = devices_.find(selected_id_); gamepad_iter != devices_.end()) {
adapter = std::move(gamepad_iter->second.adapter);
devices_.erase(gamepad_iter);
} else if (const auto mouse_iter = mice_.find(selected_id_); mouse_iter != mice_.end()) {
mouse = std::move(mouse_iter->second.mouse);
mice_.erase(mouse_iter);
} else {
return;
}
selected_id_ = first_device_id(devices_, mice_);
}
button_active_.fill(false);
mouse_control_panel_.forget_device(removed_id);
auto status = lvh::OperationStatus::success();
if (adapter != nullptr) {
status = adapter->close();
} else if (mouse != nullptr) {
status = mouse->close();
}
if (!status.ok()) {
error_panel_.show(status.message());
}
device_panel_.refresh_license();
}
void remove_all_devices() {
const auto handles = take_all_devices();
button_active_.fill(false);
mouse_control_panel_.reset();
close_devices(handles, true);
device_panel_.refresh_license();
}
void submit_mouse_event(lvh::DeviceId id, const lvh::MouseEvent &event) {
auto status = lvh::OperationStatus::success();
{
std::lock_guard lock {mutex_};
const auto iter = mice_.find(id);
if (iter == mice_.end() || iter->second.mouse == nullptr) {
return;
}
auto &device = iter->second;
status = device.mouse->submit(event);
if (status.ok() && event.kind == lvh::MouseEventKind::button) {
for (std::size_t index = 0; index < mouse_button_choices.size(); ++index) {
if (mouse_button_choices[index].button == event.button) {
device.buttons[index] = event.pressed;
break;
}
}
}
}
if (!status.ok()) {
error_panel_.show(status.message());
}
}
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 || index >= button_choices.size()) {
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 || index >= button_choices.size()) {
return;
}
status = device->adapter->set_button(button_choices[index].button, pressed);
}
if (!status.ok()) {
error_panel_.show(status.message());
}
}
void handle_button_lock_changed() {
button_active_.fill(false);
if (lock_buttons_) {
return;
}
auto status = lvh::OperationStatus::success();
{
std::lock_guard lock {mutex_};
auto *device = selected_device_locked();
if (device == nullptr) {
return;
}
auto state = device->adapter->state();
state.buttons.clear();
status = device->adapter->set_state(state);
}
if (!status.ok()) {
error_panel_.show(status.message());
}
}
void set_selected_axis(std::size_t index, int position) {
auto status = lvh::OperationStatus::success();
{
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()) {
error_panel_.show(status.message());
}
}
void set_selected_battery_from_controls() {
if (battery_state_index_ < 0 || static_cast<std::size_t>(battery_state_index_) >= battery_choices.size()) {
return;
}
lvh::GamepadBattery battery;
battery.state = battery_choices[static_cast<std::size_t>(battery_state_index_)].state;
if (battery.state == lvh::GamepadBatteryState::full) {
battery_percentage_ = 100;
}
battery.percentage = static_cast<std::uint8_t>(std::clamp(battery_percentage_, 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()) {
error_panel_.show(status.message());
}
}
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()) {
error_panel_.show(status.message());
}
}
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_
);
}
void close_all_devices() {
const auto handles = take_all_devices();
close_devices(handles, false);
}
DeviceHandles take_all_devices() {
DeviceHandles handles;
std::lock_guard lock {mutex_};
for (auto &[id, device] : devices_) {
handles.gamepads.push_back(std::move(device.adapter));
}
for (auto &[id, device] : mice_) {
handles.mice.push_back(std::move(device.mouse));
}
devices_.clear();
mice_.clear();
selected_id_ = 0;
return handles;
}
void close_devices(const DeviceHandles &handles, bool report_errors) {
std::optional<std::string> first_error;
for (const auto &adapter : handles.gamepads) {
if (adapter) {
if (const auto status = adapter->close(); !status.ok() && report_errors && !first_error) {
first_error = std::string {status.message()};
}
}
}
for (const auto &mouse : handles.mice) {
if (mouse) {
if (const auto status = mouse->close(); !status.ok() && report_errors && !first_error) {
first_error = std::string {status.message()};
}
}
}
if (first_error) {
error_panel_.show(*first_error);
}
}
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;
}
const ControlledGamepad *selected_device_locked() const {
if (selected_id_ == 0) {
return nullptr;
}
const auto iter = devices_.find(selected_id_);
if (iter == devices_.end()) {
return nullptr;
}
return &iter->second;
}
ControlledMouse *selected_mouse_locked() {
if (selected_id_ == 0) {
return nullptr;
}
const auto iter = mice_.find(selected_id_);
if (iter == mice_.end()) {
return nullptr;
}
return &iter->second;
}
const ControlledMouse *selected_mouse_locked() const {
if (selected_id_ == 0) {
return nullptr;
}
const auto iter = mice_.find(selected_id_);
if (iter == mice_.end()) {
return nullptr;
}
return &iter->second;
}
std::unique_ptr<lvh::Runtime> runtime_;
mutable std::mutex mutex_;
std::map<lvh::DeviceId, ControlledGamepad> devices_;
std::map<lvh::DeviceId, ControlledMouse> mice_;
lvh::DeviceId selected_id_ = 0;
bool lock_buttons_ = false;
std::array<bool, button_choices.size()> button_active_ {};
int battery_state_index_ = battery_choice_index(lvh::GamepadBatteryState::full);
int battery_percentage_ = 100;
std::uint64_t next_metadata_index_ = 0;
std::uint64_t next_output_sequence_ = 1;
DevicePanel device_panel_;
MouseControlPanel mouse_control_panel_;
ErrorPanel error_panel_;
static constexpr std::size_t max_output_events_ = 50;
};
int run_control_ui() {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("SDL_Init failed: %s", SDL_GetError());
return 1;
}
auto main_scale = SDL_GetDisplayContentScale(SDL_GetPrimaryDisplay());
if (main_scale <= 0.0F) {
main_scale = 1.0F;
}
constexpr auto base_width = 860;
constexpr auto base_height = 760;
constexpr auto minimum_width = 600;
constexpr auto minimum_height = 700;
const auto window_flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN | SDL_WINDOW_HIGH_PIXEL_DENSITY;
auto *window = SDL_CreateWindow(
"libvirtualhid control",
scaled_window_dimension(base_width, main_scale),
scaled_window_dimension(base_height, main_scale),
window_flags
);
if (window == nullptr) {
SDL_Log("SDL_CreateWindow failed: %s", SDL_GetError());
SDL_Quit();
return 1;
}
if (!SDL_SetWindowMinimumSize(
window,
scaled_window_dimension(minimum_width, main_scale),
scaled_window_dimension(minimum_height, main_scale)
)) {
SDL_Log("SDL_SetWindowMinimumSize failed: %s", SDL_GetError());
}
auto *renderer = SDL_CreateRenderer(window, nullptr);
if (renderer == nullptr) {
SDL_Log("SDL_CreateRenderer failed: %s", SDL_GetError());
SDL_DestroyWindow(window);
SDL_Quit();
return 1;
}
SDL_SetRenderVSync(renderer, 1);
SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
SDL_ShowWindow(window);
IMGUI_CHECKVERSION();
ImGui::CreateContext();
auto &io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
// The tool creates virtual gamepads that SDL can see, so gamepad navigation would feed back into the UI.
io.IniFilename = nullptr;
ImGui::StyleColorsDark();
auto &style = ImGui::GetStyle();
style.ScaleAllSizes(main_scale);
style.FontScaleDpi = main_scale;
ImGui_ImplSDL3_InitForSDLRenderer(window, renderer);
ImGui_ImplSDLRenderer3_Init(renderer);
ControlApp app;
auto done = false;
while (!done) {
SDL_Event event;
while (SDL_PollEvent(&event)) {
ImGui_ImplSDL3_ProcessEvent(&event);
if (event.type == SDL_EVENT_QUIT) {
done = true;
}
if (event.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED && event.window.windowID == SDL_GetWindowID(window)) {
done = true;
}
}
app.tick();
if ((SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) != 0U) {
SDL_Delay(10);
continue;
}
ImGui_ImplSDLRenderer3_NewFrame();
ImGui_ImplSDL3_NewFrame();
ImGui::NewFrame();
app.render();
ImGui::Render();
SDL_SetRenderScale(renderer, io.DisplayFramebufferScale.x, io.DisplayFramebufferScale.y);
SDL_SetRenderDrawColorFloat(renderer, 0.08F, 0.08F, 0.09F, 1.0F);
SDL_RenderClear(renderer);
ImGui_ImplSDLRenderer3_RenderDrawData(ImGui::GetDrawData(), renderer);
SDL_RenderPresent(renderer);
}
ImGui_ImplSDLRenderer3_Shutdown();
ImGui_ImplSDL3_Shutdown();
ImGui::DestroyContext();
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
} // namespace lvh::tools::virtualhid_control::ui
/**
* @brief Run the libvirtualhid diagnostic control UI.
* @return Process exit code.
*/
int main(int, char **) {
return lvh::tools::virtualhid_control::ui::run_control_ui();
}