mirror of
https://github.com/LizardByte/libvirtualhid.git
synced 2026-11-02 01:56:43 +09:00
1298 lines
41 KiB
C++
1298 lines
41 KiB
C++
/**
|
|
* @file tools/virtualhid_control.cpp
|
|
* @brief SDL3 and Dear ImGui UI for creating and testing libvirtualhid gamepads.
|
|
*/
|
|
|
|
// standard includes
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cfloat>
|
|
#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"
|
|
|
|
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::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_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;
|
|
|
|
struct ControlledGamepad: OutputState {
|
|
std::string profile_label;
|
|
std::unique_ptr<lvh::GamepadStateAdapter> adapter;
|
|
};
|
|
|
|
struct DeviceListItem {
|
|
lvh::DeviceId id = 0;
|
|
std::string label;
|
|
};
|
|
|
|
struct SelectedSnapshot {
|
|
bool has_device = false;
|
|
lvh::DeviceId id = 0;
|
|
std::string profile_label;
|
|
lvh::DeviceProfile profile;
|
|
lvh::GamepadState state;
|
|
lvh::GamepadProfileSupport support;
|
|
std::uint64_t submit_count = 0;
|
|
std::vector<lvh::DeviceNode> nodes;
|
|
std::vector<OutputLogEntry> outputs;
|
|
std::string state_text;
|
|
std::string output_text;
|
|
};
|
|
|
|
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_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);
|
|
}
|
|
|
|
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)
|
|
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_gamepad) const {
|
|
#if defined(_WIN32)
|
|
ScopedDisabled disabled {!snapshot_.broker_available || !snapshot_.licensed};
|
|
#endif
|
|
if (ImGui::Button("Create", {-FLT_MIN, 0.0F})) {
|
|
create_gamepad();
|
|
}
|
|
}
|
|
|
|
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)
|
|
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)
|
|
std::array<char, 128> license_key_input_ {};
|
|
#endif
|
|
std::string buy_url_;
|
|
std::string manage_account_url_;
|
|
};
|
|
|
|
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 gamepads: %s", ex.what());
|
|
} catch (...) {
|
|
SDL_Log("Failed to close virtual gamepads.");
|
|
}
|
|
}
|
|
|
|
ControlApp(const ControlApp &) = delete;
|
|
ControlApp &operator=(const ControlApp &) = delete;
|
|
|
|
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();
|
|
|
|
if (ImGui::GetContentRegionAvail().x < 760.0F) {
|
|
render_device_panel(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_device_panel(devices);
|
|
|
|
ImGui::TableSetColumnIndex(1);
|
|
render_control_panel(selected);
|
|
|
|
ImGui::EndTable();
|
|
}
|
|
|
|
render_error_popup();
|
|
ImGui::End();
|
|
}
|
|
|
|
private:
|
|
std::vector<DeviceListItem> snapshot_devices() const {
|
|
std::vector<DeviceListItem> result;
|
|
std::lock_guard lock {mutex_};
|
|
result.reserve(devices_.size());
|
|
for (const auto &[id, device] : devices_) {
|
|
result.push_back({.id = id, .label = std::format("#{} {}", id, device.profile_label)});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
SelectedSnapshot snapshot_selected_device() const {
|
|
std::lock_guard lock {mutex_};
|
|
SelectedSnapshot snapshot;
|
|
const auto *device = selected_device_locked();
|
|
if (device == nullptr || device->adapter == nullptr || device->adapter->gamepad() == nullptr) {
|
|
return snapshot;
|
|
}
|
|
|
|
const auto *gamepad = device->adapter->gamepad();
|
|
snapshot.has_device = true;
|
|
snapshot.id = gamepad->device_id();
|
|
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 lvh::tools::virtualhid_control::ProfileChoice *current_profile_choice() const {
|
|
if (profile_index_ >= profile_choices.size()) {
|
|
return nullptr;
|
|
}
|
|
return &profile_choices[profile_index_];
|
|
}
|
|
|
|
std::optional<lvh::DeviceProfile> current_profile() const {
|
|
const auto *choice = current_profile_choice();
|
|
if (choice == nullptr) {
|
|
return std::nullopt;
|
|
}
|
|
return profile_for_choice(*choice);
|
|
}
|
|
|
|
std::optional<lvh::DeviceProfile> control_profile(const SelectedSnapshot &selected) const {
|
|
if (selected.has_device) {
|
|
return selected.profile;
|
|
}
|
|
return current_profile();
|
|
}
|
|
|
|
void render_device_panel(const std::vector<DeviceListItem> &devices) {
|
|
license_panel_.render([this](std::string_view message) {
|
|
show_error(message);
|
|
});
|
|
ImGui::Separator();
|
|
|
|
ImGui::TextUnformatted("Profile");
|
|
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())) {
|
|
for (std::size_t index = 0; index < profile_choices.size(); ++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();
|
|
}
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
|
|
license_panel_.render_create_button([this] {
|
|
create_gamepad();
|
|
});
|
|
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted("Devices");
|
|
if (ImGui::BeginListBox("##devices", {-FLT_MIN, 150.0F})) {
|
|
if (devices.empty()) {
|
|
ImGui::TextDisabled("No devices");
|
|
}
|
|
for (const auto &device : devices) {
|
|
const auto selected = selected_id_ == device.id;
|
|
if (ImGui::Selectable(device.label.c_str(), selected)) {
|
|
selected_id_ = device.id;
|
|
}
|
|
}
|
|
ImGui::EndListBox();
|
|
}
|
|
|
|
{
|
|
ScopedDisabled disabled {selected_id_ == 0};
|
|
if (ImGui::Button("Reset", {-FLT_MIN, 0.0F})) {
|
|
reset_selected_device();
|
|
}
|
|
if (ImGui::Button("Remove selected", {-FLT_MIN, 0.0F})) {
|
|
remove_selected_device();
|
|
}
|
|
}
|
|
{
|
|
ScopedDisabled disabled {devices.empty()};
|
|
if (ImGui::Button("Remove all", {-FLT_MIN, 0.0F})) {
|
|
remove_all_devices();
|
|
}
|
|
}
|
|
}
|
|
|
|
void render_control_panel(const SelectedSnapshot &selected) {
|
|
const auto profile = 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(profile_feature_summary(*profile));
|
|
ImGui::TextWrapped("%s", feature_text.c_str());
|
|
} else {
|
|
ImGui::TextDisabled("No profile selected.");
|
|
}
|
|
|
|
ImGui::Separator();
|
|
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 render_error_popup() {
|
|
if (open_error_popup_) {
|
|
ImGui::OpenPopup("Error");
|
|
open_error_popup_ = false;
|
|
}
|
|
|
|
if (ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
|
ImGui::TextWrapped("%s", error_message_.c_str());
|
|
if (ImGui::Button("OK", {120.0F, 0.0F})) {
|
|
error_message_.clear();
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
}
|
|
|
|
void create_gamepad() {
|
|
const auto *choice = current_profile_choice();
|
|
if (choice == nullptr) {
|
|
show_error("Select a profile first.");
|
|
return;
|
|
}
|
|
|
|
const auto profile = profile_for_choice(*choice);
|
|
if (!profile) {
|
|
show_error("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) {
|
|
show_error(created.status.message());
|
|
return;
|
|
}
|
|
|
|
const auto *gamepad = created.adapter->gamepad();
|
|
if (gamepad == nullptr) {
|
|
show_error("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;
|
|
license_panel_.refresh();
|
|
}
|
|
|
|
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({});
|
|
}
|
|
button_active_.fill(false);
|
|
if (!status.ok()) {
|
|
show_error(status.message());
|
|
}
|
|
}
|
|
|
|
void remove_selected_device() {
|
|
std::unique_ptr<lvh::GamepadStateAdapter> 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;
|
|
}
|
|
button_active_.fill(false);
|
|
if (adapter) {
|
|
if (const auto status = adapter->close(); !status.ok()) {
|
|
show_error(status.message());
|
|
}
|
|
}
|
|
license_panel_.refresh();
|
|
}
|
|
|
|
void remove_all_devices() {
|
|
const auto adapters = take_all_adapters();
|
|
button_active_.fill(false);
|
|
close_adapters(adapters, true);
|
|
license_panel_.refresh();
|
|
}
|
|
|
|
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()) {
|
|
show_error(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()) {
|
|
show_error(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()) {
|
|
show_error(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()) {
|
|
show_error(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()) {
|
|
show_error(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 adapters = take_all_adapters();
|
|
close_adapters(adapters, false);
|
|
}
|
|
|
|
std::vector<std::unique_ptr<lvh::GamepadStateAdapter>> take_all_adapters() {
|
|
std::vector<std::unique_ptr<lvh::GamepadStateAdapter>> adapters;
|
|
std::lock_guard lock {mutex_};
|
|
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<std::unique_ptr<lvh::GamepadStateAdapter>> &adapters,
|
|
bool report_errors
|
|
) {
|
|
std::optional<std::string> first_error;
|
|
for (const auto &adapter : adapters) {
|
|
if (adapter) {
|
|
if (const auto status = adapter->close(); !status.ok() && report_errors && !first_error) {
|
|
first_error = std::string {status.message()};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (first_error) {
|
|
show_error(*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;
|
|
}
|
|
|
|
void show_error(std::string_view message) {
|
|
error_message_ = message.empty() ? "Operation failed." : std::string {message};
|
|
open_error_popup_ = true;
|
|
}
|
|
|
|
std::unique_ptr<lvh::Runtime> runtime_;
|
|
mutable std::mutex mutex_;
|
|
std::map<lvh::DeviceId, ControlledGamepad> devices_;
|
|
lvh::DeviceId selected_id_ = 0;
|
|
std::size_t profile_index_ = 3;
|
|
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::string error_message_;
|
|
bool open_error_popup_ = false;
|
|
std::uint64_t next_metadata_index_ = 0;
|
|
std::uint64_t next_output_sequence_ = 1;
|
|
LicensePanel license_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;
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
/**
|
|
* @brief Run the libvirtualhid diagnostic control UI.
|
|
* @return Process exit code.
|
|
*/
|
|
int main(int, char **) {
|
|
return run_control_ui();
|
|
}
|