Input mapping tabs UI, emulated touchpad, and rumble for non-PS5 controllers (#1541)

* Revamp input mapping dialog with tabs, gamepad remapping and gyro emulation toggle

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Add emulated touchpad (mouse/gamepad) with particle skins; widen input dialog

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Disable mouse touchpad mode when the cursor leaves the window

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Add touchpad hand-cursor toggle; share touchpad options across both tabs

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Hide touchpad trail and particles when the visual toggle is off

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Rename input mapping tabs

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Rumble non-DualSense gamepads from haptic audio

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix input mapping, touchpad transitions, and rumble fallback

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: nmzik <Nmzik@mail.ru>
This commit is contained in:
Felipe
2026-10-15 23:04:37 -03:00
committed by GitHub
parent 6a0a666a41
commit 3ff6606fcb
18 changed files with 1909 additions and 210 deletions
+6 -6
View File
@@ -113,7 +113,7 @@ jobs:
shell: cmd
run: |
cmake --build _Build/windows --target launcher ^
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
audio_out2_port_tests pad_haptics_tests controller_settings_tests host_input_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
shader_recompiler_compute_tests ^
resource_tracking_tests scalar_provenance_tests ^
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests --parallel
@@ -121,7 +121,7 @@ jobs:
- name: Test
shell: cmd
run: |
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$"
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|host_input|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$"
- name: Install
shell: cmd
@@ -202,7 +202,7 @@ jobs:
shell: bash
run: |
cmake --build _Build/macos \
--target launcher audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
--target launcher audio_out2_port_tests pad_haptics_tests controller_settings_tests host_input_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
memory_tracker_tests page_manager_tests shader_recompiler_compute_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
@@ -212,7 +212,7 @@ jobs:
shell: bash
run: |
ctest --test-dir _Build/macos --output-on-failure \
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|host_input|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
- name: Install
shell: bash
@@ -388,7 +388,7 @@ jobs:
run: |
cmake --build _Build/linux \
--target launcher page_manager_tests memory_tracker_tests \
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
audio_out2_port_tests pad_haptics_tests controller_settings_tests host_input_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
--parallel
@@ -397,7 +397,7 @@ jobs:
shell: bash
run: |
ctest --test-dir _Build/linux --output-on-failure \
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|host_input|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
- name: Install
shell: bash
+14
View File
@@ -526,6 +526,10 @@ add_executable(controller_settings_tests EXCLUDE_FROM_ALL
target_link_libraries(controller_settings_tests common fmt::fmt SDL3::SDL3-static)
target_include_directories(controller_settings_tests PRIVATE ${inc_headers})
add_executable(host_input_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/HostInputTests.cpp")
target_link_libraries(host_input_tests common fmt::fmt SDL3::SDL3-static)
target_include_directories(host_input_tests PRIVATE ${inc_headers})
add_executable(save_data_memory_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/SaveDataMemoryTests.cpp"
)
@@ -644,6 +648,7 @@ if(BUILD_TESTING)
add_test(NAME ngs2_sampler COMMAND $<TARGET_FILE:ngs2_sampler_tests>)
add_test(NAME pad_haptics COMMAND $<TARGET_FILE:pad_haptics_tests>)
add_test(NAME controller_settings COMMAND $<TARGET_FILE:controller_settings_tests>)
add_test(NAME host_input COMMAND $<TARGET_FILE:host_input_tests>)
add_test(NAME save_data_memory COMMAND $<TARGET_FILE:save_data_memory_tests>)
add_test(NAME png_enc COMMAND $<TARGET_FILE:png_enc_tests>)
add_test(NAME ces COMMAND $<TARGET_FILE:ces_tests>)
@@ -723,6 +728,7 @@ if(BUILD_TESTING)
ngs2_sampler_tests
pad_haptics_tests
controller_settings_tests
host_input_tests
save_data_memory_tests
png_enc_tests
ces_tests
@@ -760,6 +766,14 @@ add_custom_target(kyty_trophy_sound
add_dependencies(kyty_emulator kyty_trophy_sound)
install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION assets/sounds)
add_custom_target(kyty_touch_cursors
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/assets/cursors"
"$<TARGET_FILE_DIR:kyty_emulator>/assets/cursors"
VERBATIM)
add_dependencies(kyty_emulator kyty_touch_cursors)
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/assets/cursors" DESTINATION assets)
target_link_libraries(kyty_emulator ${kyty_emulator_link_libraries})
if (WIN32)
target_link_libraries(kyty_emulator opengl32)
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+374 -3
View File
@@ -10,6 +10,7 @@
#include "gpu_blit_shaders/gpu_imgui_hdr_spv.h"
#include "graphics/host_gpu/graphicContext.h"
#include "graphics/presentation/ddsImage.h"
#include "graphics/presentation/window/hostInput.h"
#include "imgui.h"
#include "imgui_internal.h"
#include "imgui_impl_vulkan.h"
@@ -149,6 +150,47 @@ bool GetOverlaySnapshot(OverlaySnapshot* snapshot) {
}
constexpr size_t INPUT_QUEUE_CAPACITY = 128;
constexpr int MAX_TOUCH_CURSOR_IMAGE_DIMENSION = 2048;
constexpr float TOUCH_CURSOR_IMAGE_SIZE = 48.0f;
enum class TouchParticleSkin : uint8_t { Classic, BlueNeon, Monochrome, Crimson, RedBlue };
TouchParticleSkin GetTouchParticleSkin() {
static const auto skin = [] {
for (const auto& value: Config::GetKeymap()) {
constexpr std::string_view key = "TouchParticleSkin=";
if (std::string_view(value).starts_with(key)) {
const auto name = std::string_view(value).substr(key.size());
if (name == "BlueNeon") {
return TouchParticleSkin::BlueNeon;
}
if (name == "Monochrome") {
return TouchParticleSkin::Monochrome;
}
if (name == "Crimson") {
return TouchParticleSkin::Crimson;
}
if (name == "RedBlue") {
return TouchParticleSkin::RedBlue;
}
}
}
return TouchParticleSkin::Classic;
}();
return skin;
}
bool TouchCursorImageEnabled() {
static const bool enabled = [] {
for (const auto& value: Config::GetKeymap()) {
if (std::string_view(value) == "TouchCursorImage=Off") {
return false;
}
}
return true;
}();
return enabled;
}
enum class InputKind : uint8_t {
Button,
@@ -552,9 +594,11 @@ SystemOverlayVisualState GetSystemOverlayVisualState() noexcept {
const auto system = SystemDialog::GetVisualState();
const bool trophy_active = g_trophy_notification_active.load(std::memory_order_acquire);
const bool splash = g_splash_active.load(std::memory_order_acquire);
return {core.active || dialog.active || system.active || trophy_active || splash,
const auto cursor = GetHostInputTouchPadVisualState();
return {core.active || dialog.active || system.active || trophy_active || splash ||
(cursor.enabled && cursor.cursor_visible),
core.revision + dialog.revision + system.revision + (trophy_active ? 1u : 0u) +
(splash ? 2u : 0u)};
(splash ? 2u : 0u) + static_cast<decltype(core.revision)>(cursor.revision)};
}
bool ProcessSystemOverlayInput(const SDL_Event& event) {
@@ -694,6 +738,20 @@ void NotifyTrophyUnlocked(std::string_view name, int32_t grade,
}
struct SystemOverlay::Impl {
enum class TouchParticleIcon : uint8_t { Square, Triangle, Cross, Circle };
struct TouchParticle {
ImVec2 position;
Clock::time_point born;
TouchParticleIcon icon;
};
struct TouchTrailSegment {
ImVec2 from;
ImVec2 to;
Clock::time_point born;
};
explicit Impl(GraphicContext& context): graphics(context) {}
~Impl() {
@@ -705,6 +763,12 @@ struct SystemOverlay::Impl {
ReleaseVulkan();
if (imgui_context != nullptr) {
ImGui::SetCurrentContext(imgui_context);
if (hand_cursor_image) {
ImGui::UnregisterUserTexture(hand_cursor_image.get());
}
if (drawing_cursor_image) {
ImGui::UnregisterUserTexture(drawing_cursor_image.get());
}
if (trophy_notification_image) {
ImGui::UnregisterUserTexture(trophy_notification_image.get());
}
@@ -735,6 +799,8 @@ struct SystemOverlay::Impl {
style.WindowRounding = 10.0f;
style.FrameRounding = 6.0f;
style.ItemSpacing = {8.0f, 8.0f};
hand_cursor_image = LoadTouchCursorImage("hand.png");
drawing_cursor_image = LoadTouchCursorImage("drawing.png");
}
void PlayTrophySound() {
@@ -775,6 +841,55 @@ struct SystemOverlay::Impl {
}
}
std::unique_ptr<ImTextureData> LoadTouchCursorImage(std::string_view filename) {
const char* base_path = SDL_GetBasePath();
if (base_path == nullptr) {
LOGF("Could not locate the emulator folder for touchpad cursor images: %s\n",
SDL_GetError());
return nullptr;
}
const std::filesystem::path path =
std::filesystem::path(base_path) / "assets" / "cursors" / std::string(filename);
Common::File file;
if (!file.Open(path, Common::File::Mode::Read)) {
LOGF("Touchpad cursor image not found: %s\n", Common::PathToString(path).c_str());
return nullptr;
}
const auto encoded = file.ReadWholeBuffer();
if (encoded.empty() ||
encoded.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
LOGF("Touchpad cursor image is empty or too large: %s\n",
Common::PathToString(path).c_str());
return nullptr;
}
int width = 0;
int height = 0;
auto* pixels = stbi_load_from_memory(
reinterpret_cast<const stbi_uc*>(encoded.data()), static_cast<int>(encoded.size()),
&width, &height, nullptr, 4);
if (pixels == nullptr || width <= 0 || height <= 0 ||
width > MAX_TOUCH_CURSOR_IMAGE_DIMENSION ||
height > MAX_TOUCH_CURSOR_IMAGE_DIMENSION) {
LOGF("Could not load touchpad cursor image %s: %s\n",
Common::PathToString(path).c_str(),
pixels == nullptr ? stbi_failure_reason() : "image dimensions exceed 2048x2048");
stbi_image_free(pixels);
return nullptr;
}
auto texture = std::make_unique<ImTextureData>();
texture->Create(ImTextureFormat_RGBA32, width, height);
std::memcpy(texture->Pixels, pixels,
static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4);
texture->UseColors = true;
stbi_image_free(pixels);
ImGui::RegisterUserTexture(texture.get());
LOGF("Loaded touchpad cursor image: %s\n", Common::PathToString(path).c_str());
return texture;
}
std::unique_ptr<ImTextureData> LoadTrophyIcon(std::span<const std::byte> encoded) {
if (encoded.empty() ||
encoded.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
@@ -1191,6 +1306,248 @@ struct SystemOverlay::Impl {
}
}
void EmitTouchParticles(ImVec2 from, ImVec2 to, Clock::time_point now) {
const float dx = to.x - from.x;
const float dy = to.y - from.y;
const float distance = std::hypot(dx, dy);
if (distance < 0.01f) {
return;
}
if (distance >= 1.0f) {
trail_segments.push_back({from, to, now});
if (trail_segments.size() > MAX_TOUCH_TRAIL_SEGMENTS) {
trail_segments.erase(trail_segments.begin());
}
}
constexpr float PARTICLE_SPACING = 24.0f;
float traveled = 0.0f;
while (particle_distance + distance - traveled >= PARTICLE_SPACING) {
traveled += PARTICLE_SPACING - particle_distance;
const float fraction = std::clamp(traveled / distance, 0.0f, 1.0f);
particles.push_back(
{{from.x + dx * fraction, from.y + dy * fraction}, now, next_particle_icon});
next_particle_icon = static_cast<TouchParticleIcon>(
(static_cast<uint8_t>(next_particle_icon) + 1) % 4);
if (particles.size() > MAX_TOUCH_PARTICLES) {
particles.erase(particles.begin());
}
particle_distance = 0.0f;
}
particle_distance += distance - traveled;
}
void DrawTouchParticles(ImDrawList* draw, Clock::time_point now) {
const auto skin = GetTouchParticleSkin();
constexpr float PARTICLE_LIFETIME = 1.0f;
constexpr float TRAIL_LIFETIME = 0.35f;
std::erase_if(trail_segments, [now](const auto& segment) {
return std::chrono::duration<float>(now - segment.born).count() >= TRAIL_LIFETIME;
});
std::erase_if(particles, [now](const auto& particle) {
return std::chrono::duration<float>(now - particle.born).count() >=
PARTICLE_LIFETIME;
});
for (const auto& segment: trail_segments) {
const float age =
std::chrono::duration<float>(now - segment.born).count() / TRAIL_LIFETIME;
const float fade = 1.0f - age;
if (skin == TouchParticleSkin::RedBlue) {
draw->AddLine(segment.from, segment.to,
IM_COL32(20, 75, 220, static_cast<int>(105.0f * fade)),
9.0f * (1.0f - age * 0.5f));
draw->AddLine(segment.from, segment.to,
IM_COL32(255, 45, 65, static_cast<int>(230.0f * fade)),
4.0f * (1.0f - age * 0.5f));
} else if (skin == TouchParticleSkin::Crimson) {
draw->AddLine(segment.from, segment.to,
IM_COL32(110, 0, 8, static_cast<int>(130.0f * fade)),
9.0f * (1.0f - age * 0.5f));
draw->AddLine(segment.from, segment.to,
IM_COL32(255, 35, 20, static_cast<int>(235.0f * fade)),
4.0f * (1.0f - age * 0.5f));
} else {
draw->AddLine(segment.from, segment.to,
IM_COL32(35, 145, 255, static_cast<int>(180.0f * fade)),
5.0f * (1.0f - age * 0.5f));
}
}
for (const auto& particle: particles) {
const float age =
std::chrono::duration<float>(now - particle.born).count() / PARTICLE_LIFETIME;
const float alpha = 1.0f - age;
const float size = 9.0f * (1.0f - age * 0.35f);
const float drift = (static_cast<uint8_t>(particle.icon) % 2 == 0 ? -1.0f : 1.0f) *
8.0f * age;
const ImVec2 center {particle.position.x + drift, particle.position.y - 18.0f * age};
const auto faded = [alpha, skin, icon = particle.icon](int r, int g, int b) {
if (skin == TouchParticleSkin::BlueNeon) {
r = 75;
g = 220;
b = 255;
} else if (skin == TouchParticleSkin::RedBlue) {
const bool red = icon == TouchParticleIcon::Square ||
icon == TouchParticleIcon::Cross;
r = red ? 255 : 75;
g = red ? 65 : 175;
b = red ? 85 : 255;
} else if (skin == TouchParticleSkin::Monochrome) {
r = 235;
g = 245;
b = 255;
} else if (skin == TouchParticleSkin::Crimson) {
const bool ember = icon == TouchParticleIcon::Triangle ||
icon == TouchParticleIcon::Circle;
r = 255;
g = ember ? 205 : 115;
b = ember ? 70 : 15;
}
return IM_COL32(r, g, b, static_cast<int>(255.0f * alpha));
};
if (skin != TouchParticleSkin::Classic) {
const ImU32 glow =
skin == TouchParticleSkin::BlueNeon
? IM_COL32(0, 115, 255, static_cast<int>(38.0f * alpha))
: (skin == TouchParticleSkin::RedBlue
? IM_COL32(75, 100, 255, static_cast<int>(55.0f * alpha))
: (skin == TouchParticleSkin::Crimson
? IM_COL32(255, 70, 0, static_cast<int>(70.0f * alpha))
: IM_COL32(115, 165, 255, static_cast<int>(30.0f * alpha))));
draw->AddCircleFilled(center, size + 6.0f, glow);
}
const ImU32 particle_glow =
skin == TouchParticleSkin::Crimson
? (particle.icon == TouchParticleIcon::Triangle ||
particle.icon == TouchParticleIcon::Circle
? IM_COL32(255, 195, 30, static_cast<int>(135.0f * alpha))
: IM_COL32(255, 95, 0, static_cast<int>(135.0f * alpha)))
: (skin == TouchParticleSkin::RedBlue
? (particle.icon == TouchParticleIcon::Square ||
particle.icon == TouchParticleIcon::Cross
? IM_COL32(255, 40, 65, static_cast<int>(105.0f * alpha))
: IM_COL32(35, 125, 255, static_cast<int>(105.0f * alpha)))
: IM_COL32(35, 145, 255,
static_cast<int>((skin == TouchParticleSkin::BlueNeon
? 100.0f
: 65.0f) *
alpha)));
draw->AddCircleFilled(center, size + 2.0f, particle_glow);
switch (particle.icon) {
case TouchParticleIcon::Square:
draw->AddRect({center.x - size * 0.65f, center.y - size * 0.65f},
{center.x + size * 0.65f, center.y + size * 0.65f},
faded(255, 105, 180), 2.0f, 0, 2.2f);
break;
case TouchParticleIcon::Triangle:
draw->AddLine({center.x, center.y - size}, {center.x - size, center.y + size},
faded(80, 230, 130), 2.2f);
draw->AddLine({center.x - size, center.y + size},
{center.x + size, center.y + size}, faded(80, 230, 130), 2.2f);
draw->AddLine({center.x + size, center.y + size},
{center.x, center.y - size}, faded(80, 230, 130), 2.2f);
break;
case TouchParticleIcon::Cross:
draw->AddLine({center.x - size * 0.75f, center.y - size * 0.75f},
{center.x + size * 0.75f, center.y + size * 0.75f},
faded(90, 170, 255), 2.5f);
draw->AddLine({center.x + size * 0.75f, center.y - size * 0.75f},
{center.x - size * 0.75f, center.y + size * 0.75f},
faded(90, 170, 255), 2.5f);
break;
case TouchParticleIcon::Circle:
draw->AddCircle(center, size * 0.8f, faded(255, 105, 105), 0, 2.2f);
break;
}
}
}
void DrawTouchPadCursor(const HostInputTouchPadVisualState& state) {
if (!TouchCursorImageEnabled()) {
particles.clear();
trail_segments.clear();
return;
}
const auto now = Clock::now();
if (state.enabled && state.position_valid) {
const ImVec2 target {state.x * extent.width, state.y * extent.height};
if (!cursor_initialized || state.gesture != cursor_gesture) {
cursor_position = target;
cursor_initialized = true;
cursor_gesture = state.gesture;
particle_distance = 0.0f;
} else {
const float dt = last_cursor_update == Clock::time_point {}
? 1.0f / 60.0f
: std::clamp(std::chrono::duration<float>(now -
last_cursor_update)
.count(),
1.0f / 1000.0f, 0.05f);
const ImVec2 previous = cursor_position;
const float follow = 1.0f - std::exp(-18.0f * dt);
cursor_position.x += (target.x - cursor_position.x) * follow;
cursor_position.y += (target.y - cursor_position.y) * follow;
if (state.drawing) {
EmitTouchParticles(previous, cursor_position, now);
}
}
last_cursor_update = now;
}
auto* draw = ImGui::GetForegroundDrawList();
DrawTouchParticles(draw, now);
if (!state.enabled || !state.cursor_visible ||
!state.position_valid || !cursor_initialized) {
return;
}
const ImVec2 position = cursor_position;
const auto& cursor_image =
state.drawing ? drawing_cursor_image : hand_cursor_image;
if (cursor_image != nullptr) {
const float scale = TOUCH_CURSOR_IMAGE_SIZE /
static_cast<float>(std::max(cursor_image->Height, 1));
const ImVec2 size {static_cast<float>(cursor_image->Width) * scale,
static_cast<float>(cursor_image->Height) * scale};
draw->AddImage(cursor_image->GetTexRef(), position,
{position.x + size.x, position.y + size.y});
return;
}
const ImU32 blue = IM_COL32(35, 145, 255, 220);
const ImVec2 hand[] = {
{position.x, position.y}, {position.x, position.y + 20.0f},
{position.x + 4.0f, position.y + 16.0f},
{position.x + 9.0f, position.y + 27.0f},
{position.x + 13.0f, position.y + 25.0f},
{position.x + 10.0f, position.y + 17.0f},
{position.x + 18.0f, position.y + 18.0f},
{position.x + 20.0f, position.y + 15.0f},
{position.x + 19.0f, position.y + 12.0f},
{position.x + 12.0f, position.y + 10.0f},
{position.x + 12.0f, position.y + 4.0f},
{position.x + 10.0f, position.y + 2.0f},
{position.x + 8.0f, position.y + 3.0f},
{position.x + 8.0f, position.y + 10.0f},
{position.x + 6.0f, position.y + 2.0f},
{position.x + 4.0f, position.y + 2.0f},
{position.x + 3.0f, position.y + 10.0f},
{position.x + 2.0f, position.y + 1.0f},
};
draw->AddConvexPolyFilled(hand, IM_ARRAYSIZE(hand), IM_COL32(250, 250, 250, 255));
for (std::size_t i = 0; i < IM_ARRAYSIZE(hand); i++) {
draw->AddLine(hand[i], hand[(i + 1) % IM_ARRAYSIZE(hand)],
IM_COL32(20, 25, 35, 255), 1.5f);
}
if (state.drawing) {
const ImVec2 pen_start {position.x + 12.0f, position.y + 8.0f};
const ImVec2 pen_end {position.x + 23.0f, position.y - 3.0f};
draw->AddLine(pen_start, pen_end, IM_COL32(10, 45, 95, 255), 5.0f);
draw->AddLine(pen_start, pen_end, blue, 2.5f);
}
}
void DrawTrophyNotification(vk::Extent2D frame_extent, Clock::time_point now) {
std::erase_if(retired_trophy_images, [](const auto& texture) {
if (texture->Status != ImTextureStatus_Destroyed) {
@@ -1316,7 +1673,8 @@ struct SystemOverlay::Impl {
const bool has_overlay = GetOverlaySnapshot(&snapshot);
const auto now = Clock::now();
if (!has_overlay && !g_trophy_notification_active.load(std::memory_order_acquire) &&
!g_splash_active.load(std::memory_order_acquire)) {
!g_splash_active.load(std::memory_order_acquire) &&
!GetHostInputTouchPadVisualState().enabled) {
return false;
}
const auto prepared_session = snapshot.session;
@@ -1356,6 +1714,7 @@ struct SystemOverlay::Impl {
}
}
DrawTrophyNotification(frame_extent, now);
DrawTouchPadCursor(GetHostInputTouchPadVisualState());
ImGui::Render();
extent = frame_extent;
return true;
@@ -1410,6 +1769,18 @@ struct SystemOverlay::Impl {
ImVec2 right_stick {};
OverlaySession session;
std::unique_ptr<ImTextureData> trophy_notification_image;
std::unique_ptr<ImTextureData> hand_cursor_image;
std::unique_ptr<ImTextureData> drawing_cursor_image;
bool cursor_initialized = false;
ImVec2 cursor_position {};
float particle_distance = 0.0f;
TouchParticleIcon next_particle_icon = TouchParticleIcon::Square;
Clock::time_point last_cursor_update;
uint64_t cursor_gesture = 0;
std::vector<TouchParticle> particles;
std::vector<TouchTrailSegment> trail_segments;
static constexpr std::size_t MAX_TOUCH_PARTICLES = 64;
static constexpr std::size_t MAX_TOUCH_TRAIL_SEGMENTS = 128;
std::optional<TrophyNotification> trophy_notification;
Clock::time_point trophy_notification_born;
std::vector<std::unique_ptr<ImTextureData>> retired_trophy_images;
+567 -3
View File
@@ -12,8 +12,10 @@
#include <cmath>
#include <cstddef>
#include <cstdlib>
#include <mutex>
#include <string>
#include <string_view>
#include <unordered_map>
namespace Libs::Graphics {
@@ -27,6 +29,8 @@ struct ControlInfo {
float touch_x = 0.0f;
Controller::Setting setting {};
bool gyro = false;
bool touchpad_mode = false;
};
static constexpr std::array CONTROL_INFO = {
@@ -61,10 +65,66 @@ static constexpr std::array CONTROL_INFO = {
ControlInfo {.name = "VibrationIntensity", .setting = Controller::Setting::VibrationIntensity},
ControlInfo {.name = "TriggerEffectIntensity",
.setting = Controller::Setting::TriggerEffectIntensity},
ControlInfo {.name = "GyroToggle", .gyro = true},
ControlInfo {.name = "TouchPadMode", .touchpad_mode = true},
};
constexpr std::size_t INVALID_CONTROL = CONTROL_INFO.size();
struct PadSource {
std::string_view name;
int button; // SDL_GamepadButton, or -1 for an analog trigger
};
constexpr std::array PAD_SOURCES = {
PadSource {"South", SDL_GAMEPAD_BUTTON_SOUTH},
PadSource {"East", SDL_GAMEPAD_BUTTON_EAST},
PadSource {"West", SDL_GAMEPAD_BUTTON_WEST},
PadSource {"North", SDL_GAMEPAD_BUTTON_NORTH},
PadSource {"Back", SDL_GAMEPAD_BUTTON_BACK},
PadSource {"Guide", SDL_GAMEPAD_BUTTON_GUIDE},
PadSource {"Start", SDL_GAMEPAD_BUTTON_START},
PadSource {"LeftStick", SDL_GAMEPAD_BUTTON_LEFT_STICK},
PadSource {"RightStick", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
PadSource {"LeftShoulder", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
PadSource {"RightShoulder", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
PadSource {"DpadUp", SDL_GAMEPAD_BUTTON_DPAD_UP},
PadSource {"DpadDown", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
PadSource {"DpadLeft", SDL_GAMEPAD_BUTTON_DPAD_LEFT},
PadSource {"DpadRight", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
PadSource {"Misc1", SDL_GAMEPAD_BUTTON_MISC1},
PadSource {"RightPaddle1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1},
PadSource {"LeftPaddle1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1},
PadSource {"RightPaddle2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2},
PadSource {"LeftPaddle2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2},
PadSource {"Touchpad", SDL_GAMEPAD_BUTTON_TOUCHPAD},
PadSource {"LeftTrigger", -1},
PadSource {"RightTrigger", -1},
};
constexpr std::size_t LEFT_TRIGGER_SOURCE = PAD_SOURCES.size() - 2;
constexpr std::size_t RIGHT_TRIGGER_SOURCE = PAD_SOURCES.size() - 1;
constexpr std::size_t INVALID_SOURCE = PAD_SOURCES.size();
struct PadDefault {
std::string_view control;
std::string_view source;
};
constexpr std::array PAD_DEFAULTS = {
PadDefault {"Cross", "South"}, PadDefault {"Circle", "East"},
PadDefault {"Square", "West"}, PadDefault {"Triangle", "North"},
PadDefault {"Options", "Start"}, PadDefault {"L3", "LeftStick"},
PadDefault {"R3", "RightStick"}, PadDefault {"L1", "LeftShoulder"},
PadDefault {"R1", "RightShoulder"}, PadDefault {"L2", "LeftTrigger"},
PadDefault {"R2", "RightTrigger"}, PadDefault {"Up", "DpadUp"},
PadDefault {"Down", "DpadDown"}, PadDefault {"Left", "DpadLeft"},
PadDefault {"Right", "DpadRight"}, PadDefault {"TouchPad", "Touchpad"},
PadDefault {"GyroToggle", "Back"},
};
constexpr std::string_view PAD_PREFIX = "Pad:";
struct Binding {
SDL_Keycode key = SDLK_UNKNOWN;
uint8_t mouse_button = 0;
@@ -74,6 +134,7 @@ struct Binding {
constexpr int MOUSE_POLL_INTERVAL_MS = 33;
constexpr uint64_t CURSOR_IDLE_HIDE_MS = 2000;
constexpr std::string_view MOUSE_SENSITIVITY = "MouseSensitivity=";
constexpr std::string_view TOUCH_PARTICLE_SKIN = "TouchParticleSkin=";
struct MouseJoystickState {
bool enabled = false;
@@ -85,6 +146,38 @@ MouseJoystickState g_mouse;
SDL_Window* g_mouse_window = nullptr;
uint64_t g_cursor_hide_at = 0;
struct MouseTouchPadState {
bool enabled = false;
bool cursor_visible = true;
bool drawing = false;
bool position_valid = false;
float x = 0.5f;
float y = 0.5f;
uint64_t revision = 0;
uint64_t gesture = 0;
};
struct GamepadTouchPadState {
bool enabled = false;
bool drawing = false;
int id = -1;
int x_axis = 128;
int y_axis = 128;
float x = 0.5f;
float y = 0.5f;
uint64_t next_poll = 0;
uint64_t last_poll = 0;
};
constexpr int GAMEPAD_TOUCHPAD_POLL_MS = 8;
constexpr int GAMEPAD_TOUCHPAD_DEADZONE = 18;
constexpr float GAMEPAD_TOUCHPAD_SPEED = 0.8f;
MouseTouchPadState g_mouse_touchpad;
GamepadTouchPadState g_gamepad_touchpad;
std::mutex g_touchpad_mutex;
std::unordered_map<int, std::array<bool, 2>> g_pad_trigger_pressed;
std::size_t ControlFromName(std::string_view name) {
const auto info = std::find_if(CONTROL_INFO.begin(), CONTROL_INFO.end(),
[name](const auto& item) { return item.name == name; });
@@ -126,6 +219,14 @@ public:
InputMap() {
for (const auto& value: Config::GetKeymap()) {
const std::string_view entry = value;
if (entry == "Keyboard=Disabled") {
m_custom = true;
continue;
}
if (entry == "Pad=Disabled") {
m_custom_pad = true;
continue;
}
if (entry.starts_with(MOUSE_SENSITIVITY)) {
const float sensitivity =
std::strtof(value.c_str() + MOUSE_SENSITIVITY.size(), nullptr);
@@ -133,6 +234,13 @@ public:
std::clamp(std::isfinite(sensitivity) ? sensitivity : 1.0f, 0.1f, 5.0f);
continue;
}
if (entry.starts_with(TOUCH_PARTICLE_SKIN) || entry.starts_with("TouchCursorImage=")) {
continue;
}
if (entry.starts_with(PAD_PREFIX)) {
AddPadEntry(entry.substr(PAD_PREFIX.size()), value);
continue;
}
const auto split = entry.find('=');
Binding binding;
@@ -153,10 +261,25 @@ public:
}
Add(binding);
}
if (!m_custom_pad) {
for (const auto& item: PAD_DEFAULTS) {
AssignPad(ControlFromName(item.control), SourceFromName(item.source));
}
}
}
[[nodiscard]] bool Custom() const { return m_size != 0; }
[[nodiscard]] bool Custom() const { return m_custom; }
[[nodiscard]] float MouseSensitivity() const { return m_mouse_sensitivity; }
[[nodiscard]] std::size_t PadControl(std::size_t source) const {
return source < m_pad_controls.size() ? m_pad_controls[source] : INVALID_CONTROL;
}
static std::size_t SourceFromButton(int button) {
const auto item = std::find_if(PAD_SOURCES.begin(), PAD_SOURCES.end(),
[button](const auto& s) { return s.button == button; });
return static_cast<std::size_t>(std::distance(PAD_SOURCES.begin(), item));
}
[[nodiscard]] std::size_t FindKey(int key_code) const {
key_code = NormalizeKey(static_cast<SDL_Keycode>(key_code));
const auto binding =
@@ -173,7 +296,39 @@ public:
}
private:
static std::size_t SourceFromName(std::string_view name) {
const auto item = std::find_if(PAD_SOURCES.begin(), PAD_SOURCES.end(),
[name](const auto& s) { return s.name == name; });
return static_cast<std::size_t>(std::distance(PAD_SOURCES.begin(), item));
}
void AssignPad(std::size_t control, std::size_t source) {
for (auto& existing: m_pad_controls) {
if (existing == control) {
existing = INVALID_CONTROL;
}
}
m_pad_controls[source] = control;
}
void AddPadEntry(std::string_view entry, const std::string& original) {
const auto split = entry.find('=');
const auto control = split != std::string_view::npos ? ControlFromName(entry.substr(0, split))
: INVALID_CONTROL;
const auto source = split != std::string_view::npos ? SourceFromName(entry.substr(split + 1))
: INVALID_SOURCE;
if (control == INVALID_CONTROL || source == INVALID_SOURCE) {
EXIT("Invalid input mapping: %s\n", original.c_str());
}
const auto& info = CONTROL_INFO[control];
if (info.button == 0 && !info.gyro && info.axis != Controller::Axis::AxisMax) {
EXIT("Invalid input mapping: %s\n", original.c_str());
}
m_custom_pad = true;
AssignPad(control, source);
}
void Add(const Binding& binding) {
m_custom = true;
for (std::size_t index = 0; index < m_size;) {
if (Conflicts(m_bindings[index], binding)) {
m_bindings[index] = m_bindings[--m_size];
@@ -186,6 +341,13 @@ private:
}
std::array<Binding, CONTROL_INFO.size()> m_bindings {};
std::array<std::size_t, PAD_SOURCES.size()> m_pad_controls = [] {
std::array<std::size_t, PAD_SOURCES.size()> a {};
a.fill(INVALID_CONTROL);
return a;
}();
bool m_custom = false;
bool m_custom_pad = false;
std::size_t m_size = 0;
float m_mouse_sensitivity = 1.0f;
};
@@ -211,6 +373,39 @@ void SetTouchPad(float x, bool down) {
Controller::SetTouchPad(Controller::HOST_INPUT_CONTROLLER_ID, 0, down, x, 0.5f);
}
void SetSwipeTouchPad(bool down, float x, float y) {
Controller::SetTouchPad(Controller::HOST_INPUT_CONTROLLER_ID, 1, down, x, y);
}
void RecordMouseTouchPadPosition(float x, float y) {
int width = 0;
int height = 0;
if (g_mouse_window != nullptr) {
SDL_GetWindowSize(g_mouse_window, &width, &height);
}
if (width > 0 && height > 0) {
x = std::clamp(x / static_cast<float>(width), 0.0f, 1.0f);
y = std::clamp(y / static_cast<float>(height), 0.0f, 1.0f);
} else {
x = 0.5f;
y = 0.5f;
}
std::scoped_lock lock(g_touchpad_mutex);
if (!g_mouse_touchpad.enabled) {
return;
}
g_mouse_touchpad.x = x;
g_mouse_touchpad.y = y;
g_mouse_touchpad.position_valid = true;
g_mouse_touchpad.revision++;
}
bool TouchPadModeActive() {
std::scoped_lock lock(g_touchpad_mutex);
return g_mouse_touchpad.enabled || g_gamepad_touchpad.enabled;
}
uint32_t DefaultKeyboardButton(int key_code) {
switch (NormalizeKey(static_cast<SDL_Keycode>(key_code))) {
case SDLK_UP: return Controller::PAD_BUTTON_UP;
@@ -270,6 +465,18 @@ void SetControl(std::size_t control, bool down) {
SetButton(info.button, down);
return;
}
if (info.gyro) {
if (down) {
Controller::ToggleGyroEmulation(Controller::HOST_INPUT_CONTROLLER_ID);
}
return;
}
if (info.touchpad_mode) {
if (down) {
HostInputToggleTouchPadMode();
}
return;
}
if (info.axis == Controller::Axis::AxisMax) {
CycleSettingOnPress(info.setting, down);
return;
@@ -297,6 +504,11 @@ void DefaultKeyboardInput(int key_code, bool down) {
switch (NormalizeKey(static_cast<SDL_Keycode>(key_code))) {
case SDLK_BACKSPACE: SetTouchPad(0.25f, down); return;
case SDLK_F6:
if (down) {
HostInputToggleTouchPadMode();
}
return;
case SDLK_TAB: SetTouchPad(0.75f, down); return;
case SDLK_1: CycleSettingOnPress(Controller::Setting::SpeakerVolume, down); return;
case SDLK_2: CycleSettingOnPress(Controller::Setting::VibrationIntensity, down); return;
@@ -399,8 +611,134 @@ bool IsCursorActivity(const SDL_Event& event) {
return which != SDL_TOUCH_MOUSEID && which != SDL_PEN_MOUSEID;
}
std::array<int, 2> GamepadLeftStick(int id) {
auto* pad = SDL_GetGamepadFromID(id);
if (pad == nullptr) {
return {128, 128};
}
const auto value = [pad](SDL_GamepadAxis axis) {
return Controller::controller_get_axis(SDL_JOYSTICK_AXIS_MIN, SDL_JOYSTICK_AXIS_MAX,
SDL_GetGamepadAxis(pad, axis));
};
return {value(SDL_GAMEPAD_AXIS_LEFTX), value(SDL_GAMEPAD_AXIS_LEFTY)};
}
void ApplyPadControl(int id, std::size_t control, std::size_t source, bool down) {
if (control == INVALID_CONTROL) {
return;
}
const auto& info = CONTROL_INFO[control];
if (info.gyro) {
if (down) {
Controller::ToggleGyroEmulation(id);
}
} else if (info.touchpad_mode) {
if (down) {
HostInputToggleGamepadTouchPad(id);
}
} else if (info.button == Controller::PAD_BUTTON_CROSS && HostInputGamepadDrawButton(id, down)) {
return;
} else if (info.button == Controller::PAD_BUTTON_TOUCH_PAD) {
// Real touchpads report their own finger position; other inputs emulate a click.
if (source != PAD_SOURCES.size() && PAD_SOURCES[source].button != SDL_GAMEPAD_BUTTON_TOUCHPAD) {
Controller::SetTouchPad(id, 0, down, info.touch_x != 0.0f ? info.touch_x : 0.5f, 0.5f);
}
Controller::SetButton(id, Controller::PAD_BUTTON_TOUCH_PAD, down);
} else if (info.button == Controller::PAD_BUTTON_L2) {
Controller::SetAxis(id, Controller::Axis::TriggerLeft, down ? 255 : 0);
} else if (info.button == Controller::PAD_BUTTON_R2) {
Controller::SetAxis(id, Controller::Axis::TriggerRight, down ? 255 : 0);
} else if (info.button != 0) {
Controller::SetButton(id, info.button, down);
} else if (info.axis == Controller::Axis::AxisMax) {
CycleSettingOnPress(info.setting, down);
}
}
int PollGamepadTouchPad(uint64_t now_ms) {
float x = 0;
float y = 0;
int id = -1;
bool drawing = false;
{
std::scoped_lock lock(g_touchpad_mutex);
if (!g_gamepad_touchpad.enabled) {
return -1;
}
if (now_ms < g_gamepad_touchpad.next_poll) {
return static_cast<int>(g_gamepad_touchpad.next_poll - now_ms);
}
const float dt = g_gamepad_touchpad.last_poll == 0 || now_ms <= g_gamepad_touchpad.last_poll
? 0.008f
: std::min(static_cast<float>(now_ms - g_gamepad_touchpad.last_poll) *
0.001f,
0.05f);
g_gamepad_touchpad.last_poll = now_ms;
g_gamepad_touchpad.next_poll = now_ms + GAMEPAD_TOUCHPAD_POLL_MS;
const auto velocity = [](int axis) {
const int offset = axis - 128;
if (std::abs(offset) <= GAMEPAD_TOUCHPAD_DEADZONE) {
return 0.0f;
}
const int adjusted =
offset > 0 ? offset - GAMEPAD_TOUCHPAD_DEADZONE : offset + GAMEPAD_TOUCHPAD_DEADZONE;
return static_cast<float>(adjusted) / static_cast<float>(127 - GAMEPAD_TOUCHPAD_DEADZONE);
};
const float old_x = g_gamepad_touchpad.x;
const float old_y = g_gamepad_touchpad.y;
g_gamepad_touchpad.x = std::clamp(
old_x + velocity(g_gamepad_touchpad.x_axis) * GAMEPAD_TOUCHPAD_SPEED * dt, 0.0f, 1.0f);
g_gamepad_touchpad.y = std::clamp(
old_y + velocity(g_gamepad_touchpad.y_axis) * GAMEPAD_TOUCHPAD_SPEED * dt, 0.0f, 1.0f);
if (old_x != g_gamepad_touchpad.x || old_y != g_gamepad_touchpad.y) {
g_mouse_touchpad.revision++;
}
id = g_gamepad_touchpad.id;
x = g_gamepad_touchpad.x;
y = g_gamepad_touchpad.y;
drawing = g_gamepad_touchpad.drawing;
}
if (drawing) {
Controller::SetTouchPad(id, 1, true, x, y);
}
return GAMEPAD_TOUCHPAD_POLL_MS;
}
} // namespace
void HostInputPadButton(int id, int button, bool down) {
const auto source = InputMap::SourceFromButton(button);
const auto& map = GetInputMap();
ApplyPadControl(id, map.PadControl(source), source, down);
}
void HostInputPadTrigger(int id, bool left, int value) {
const auto source = left ? LEFT_TRIGGER_SOURCE : RIGHT_TRIGGER_SOURCE;
const auto control = GetInputMap().PadControl(source);
if (control == INVALID_CONTROL) {
return;
}
const auto& info = CONTROL_INFO[control];
if (info.button == Controller::PAD_BUTTON_L2) {
Controller::SetAxis(id, Controller::Axis::TriggerLeft, value);
} else if (info.button == Controller::PAD_BUTTON_R2) {
Controller::SetAxis(id, Controller::Axis::TriggerRight, value);
} else {
auto& state = g_pad_trigger_pressed[id][left ? 0 : 1];
const bool down = state ? value > 32 : value > 96;
if (down != state) {
state = down;
ApplyPadControl(id, control, source, down);
}
}
}
void HostInputDisconnectGamepad(int id) {
g_pad_trigger_pressed.erase(id);
HostInputDisconnectGamepadTouchPad(id);
}
void HostInputInit(SDL_Window* window) {
GetInputMap();
g_mouse_window = window;
@@ -410,6 +748,16 @@ void HostInputInit(SDL_Window* window) {
}
void HostInputShutdown() {
g_pad_trigger_pressed.clear();
HostInputSetTouchPadMode(false);
int gamepad_touch_id = -1;
{
std::scoped_lock lock(g_touchpad_mutex);
gamepad_touch_id = g_gamepad_touchpad.enabled ? g_gamepad_touchpad.id : -1;
}
if (gamepad_touch_id >= 0) {
HostInputDisconnectGamepadTouchPad(gamepad_touch_id);
}
if (g_mouse.enabled) {
SetRelativeMouseMode(false);
CenterMouseStick();
@@ -438,6 +786,219 @@ void HostInputMouseButton(uint8_t mouse_button, bool down) {
}
}
void HostInputMouseTouchPad(bool down, float x, float y) {
RecordMouseTouchPadPosition(x, y);
{
std::scoped_lock lock(g_touchpad_mutex);
if (!g_mouse_touchpad.enabled) {
return;
}
if (down && !g_mouse_touchpad.drawing) {
g_mouse_touchpad.gesture++;
}
g_mouse_touchpad.drawing = down;
g_mouse_touchpad.revision++;
x = g_mouse_touchpad.x;
y = g_mouse_touchpad.y;
}
SetSwipeTouchPad(down, x, y);
}
void HostInputMouseMotion(float x, float y) {
RecordMouseTouchPadPosition(x, y);
std::scoped_lock lock(g_touchpad_mutex);
if (g_mouse_touchpad.enabled && g_mouse_touchpad.drawing) {
SetSwipeTouchPad(true, g_mouse_touchpad.x, g_mouse_touchpad.y);
}
}
void HostInputSetTouchPadMode(bool enabled) {
if (enabled) {
int gamepad_id = -1;
{
std::scoped_lock lock(g_touchpad_mutex);
if (g_gamepad_touchpad.enabled) {
gamepad_id = g_gamepad_touchpad.id;
}
}
if (gamepad_id >= 0) {
HostInputDisconnectGamepadTouchPad(gamepad_id);
}
}
{
std::scoped_lock lock(g_touchpad_mutex);
if (g_mouse_touchpad.enabled == enabled) {
return;
}
}
if (!enabled) {
float x = 0;
float y = 0;
(void) SDL_GetMouseState(&x, &y);
HostInputMouseTouchPad(false, x, y);
}
bool show_system_cursor = false;
{
std::scoped_lock lock(g_touchpad_mutex);
g_mouse_touchpad.enabled = enabled;
g_mouse_touchpad.cursor_visible = true;
g_mouse_touchpad.drawing = false;
g_mouse_touchpad.position_valid = false;
g_mouse_touchpad.gesture++;
g_mouse_touchpad.revision++;
show_system_cursor = !enabled && !g_gamepad_touchpad.enabled;
}
if (enabled) {
if (!SDL_HideCursor()) {
LOGF("Failed to hide mouse cursor for touchpad mode: %s\n", SDL_GetError());
}
} else if (show_system_cursor && !Config::HideCursorEnabled() && !SDL_ShowCursor()) {
LOGF("Failed to restore mouse cursor after touchpad mode: %s\n", SDL_GetError());
}
LOGF("Mouse touchpad mode: %s\n", enabled ? "enabled" : "disabled");
}
void HostInputToggleTouchPadMode() {
bool enabled = false;
{
std::scoped_lock lock(g_touchpad_mutex);
enabled = !g_mouse_touchpad.enabled;
}
HostInputSetTouchPadMode(enabled);
}
void HostInputToggleGamepadTouchPad(int id) {
bool mouse_mode = false;
{
std::scoped_lock lock(g_touchpad_mutex);
mouse_mode = g_mouse_touchpad.enabled;
}
if (mouse_mode) {
HostInputSetTouchPadMode(false);
}
const auto stick = GamepadLeftStick(id);
bool enabled = false;
float x = 0.5f;
float y = 0.5f;
{
std::scoped_lock lock(g_touchpad_mutex);
if (g_gamepad_touchpad.enabled && g_gamepad_touchpad.id != id) {
return;
}
enabled = !g_gamepad_touchpad.enabled;
if (enabled) {
g_gamepad_touchpad = {};
g_gamepad_touchpad.enabled = true;
g_gamepad_touchpad.id = id;
g_gamepad_touchpad.x_axis = stick[0];
g_gamepad_touchpad.y_axis = stick[1];
g_gamepad_touchpad.last_poll = SDL_GetTicks();
g_gamepad_touchpad.next_poll = g_gamepad_touchpad.last_poll + GAMEPAD_TOUCHPAD_POLL_MS;
g_mouse_touchpad.gesture++;
} else {
x = g_gamepad_touchpad.x;
y = g_gamepad_touchpad.y;
g_gamepad_touchpad = {};
}
g_mouse_touchpad.revision++;
}
Controller::SetAxis(id, Controller::Axis::LeftX, enabled ? 128 : stick[0]);
Controller::SetAxis(id, Controller::Axis::LeftY, enabled ? 128 : stick[1]);
if (enabled) {
if (!SDL_HideCursor()) {
LOGF("Failed to hide mouse cursor for gamepad touchpad mode: %s\n", SDL_GetError());
}
} else {
Controller::SetTouchPad(id, 1, false, x, y);
if (!Config::HideCursorEnabled() && !SDL_ShowCursor()) {
LOGF("Failed to restore mouse cursor after gamepad touchpad mode: %s\n",
SDL_GetError());
}
}
LOGF("Gamepad touchpad mode: %s\n", enabled ? "enabled" : "disabled");
}
bool HostInputGamepadDrawButton(int id, bool down) {
float x = 0;
float y = 0;
{
std::scoped_lock lock(g_touchpad_mutex);
if (!g_gamepad_touchpad.enabled || g_gamepad_touchpad.id != id) {
return false;
}
if (!down && !g_gamepad_touchpad.drawing) {
return false;
}
if (down && !g_gamepad_touchpad.drawing) {
g_mouse_touchpad.gesture++;
}
g_gamepad_touchpad.drawing = down;
x = g_gamepad_touchpad.x;
y = g_gamepad_touchpad.y;
g_mouse_touchpad.revision++;
}
Controller::SetTouchPad(id, 1, down, x, y);
return true;
}
bool HostInputGamepadAxis(int id, bool horizontal, int value) {
std::scoped_lock lock(g_touchpad_mutex);
if (!g_gamepad_touchpad.enabled || g_gamepad_touchpad.id != id) {
return false;
}
(horizontal ? g_gamepad_touchpad.x_axis : g_gamepad_touchpad.y_axis) = std::clamp(value, 0, 255);
g_gamepad_touchpad.next_poll = SDL_GetTicks();
return true;
}
void HostInputDisconnectGamepadTouchPad(int id) {
float x = 0;
float y = 0;
{
std::scoped_lock lock(g_touchpad_mutex);
if (!g_gamepad_touchpad.enabled || g_gamepad_touchpad.id != id) {
return;
}
x = g_gamepad_touchpad.x;
y = g_gamepad_touchpad.y;
g_gamepad_touchpad = {};
g_mouse_touchpad.revision++;
}
Controller::SetTouchPad(id, 1, false, x, y);
const auto stick = GamepadLeftStick(id);
Controller::SetAxis(id, Controller::Axis::LeftX, stick[0]);
Controller::SetAxis(id, Controller::Axis::LeftY, stick[1]);
if (!TouchPadModeActive() && !Config::HideCursorEnabled() && !SDL_ShowCursor()) {
LOGF("Failed to restore mouse cursor after gamepad disconnect: %s\n", SDL_GetError());
}
}
HostInputTouchPadVisualState GetHostInputTouchPadVisualState() {
std::scoped_lock lock(g_touchpad_mutex);
HostInputTouchPadVisualState state {};
if (g_gamepad_touchpad.enabled) {
state.enabled = true;
state.cursor_visible = true;
state.drawing = g_gamepad_touchpad.drawing;
state.x = g_gamepad_touchpad.x;
state.y = g_gamepad_touchpad.y;
state.revision = g_mouse_touchpad.revision;
state.gesture = g_mouse_touchpad.gesture;
state.position_valid = true;
return state;
}
state.enabled = g_mouse_touchpad.enabled;
state.cursor_visible = g_mouse_touchpad.cursor_visible;
state.drawing = g_mouse_touchpad.drawing;
state.x = g_mouse_touchpad.x;
state.y = g_mouse_touchpad.y;
state.revision = g_mouse_touchpad.revision;
state.gesture = g_mouse_touchpad.gesture;
state.position_valid = g_mouse_touchpad.position_valid;
return state;
}
void HostInputToggleMouseToJoystick() {
if (g_mouse.enabled) {
SetRelativeMouseMode(false);
@@ -467,16 +1028,19 @@ bool HostInputWaitEvent(SDL_Event* event, int max_wait_ms) {
timeout = PollMouse(SDL_GetTicks());
}
if (g_cursor_hide_at != 0) {
if (g_cursor_hide_at != 0 && !TouchPadModeActive()) {
const auto now_ms = SDL_GetTicks();
const int cursor_timeout =
now_ms < g_cursor_hide_at ? static_cast<int>(g_cursor_hide_at - now_ms) : 0;
timeout = timeout < 0 ? cursor_timeout : std::min(timeout, cursor_timeout);
}
if (const int touch_timeout = PollGamepadTouchPad(SDL_GetTicks()); touch_timeout >= 0) {
timeout = timeout < 0 ? touch_timeout : std::min(timeout, touch_timeout);
}
timeout = timeout < 0 ? max_wait_ms : std::min(timeout, max_wait_ms);
const bool has_event = SDL_WaitEventTimeout(event, timeout);
if (Config::HideCursorEnabled()) {
if (Config::HideCursorEnabled() && !TouchPadModeActive()) {
const auto now_ms = SDL_GetTicks();
if (has_event && !g_mouse.enabled && IsCursorActivity(*event) &&
SDL_GetWindowFromEvent(event) == g_mouse_window) {
@@ -8,10 +8,33 @@ union SDL_Event;
namespace Libs::Graphics {
struct HostInputTouchPadVisualState {
bool enabled = false;
bool cursor_visible = true;
bool drawing = false;
bool position_valid = false;
float x = 0.5f;
float y = 0.5f;
uint64_t revision = 0;
uint64_t gesture = 0;
};
void HostInputInit(SDL_Window* window);
void HostInputShutdown();
void HostInputKey(int key_code, bool down);
void HostInputMouseButton(uint8_t mouse_button, bool down);
void HostInputMouseTouchPad(bool down, float x, float y);
void HostInputMouseMotion(float x, float y);
void HostInputPadButton(int id, int sdl_button, bool down);
void HostInputPadTrigger(int id, bool left, int value);
void HostInputDisconnectGamepad(int id);
void HostInputSetTouchPadMode(bool enabled);
void HostInputToggleTouchPadMode();
void HostInputToggleGamepadTouchPad(int id);
[[nodiscard]] bool HostInputGamepadDrawButton(int id, bool down);
[[nodiscard]] bool HostInputGamepadAxis(int id, bool horizontal, int value);
void HostInputDisconnectGamepadTouchPad(int id);
[[nodiscard]] HostInputTouchPadVisualState GetHostInputTouchPadVisualState();
void HostInputToggleMouseToJoystick();
[[nodiscard]] bool HostInputWaitEvent(SDL_Event* event, int max_wait_ms);
+24 -40
View File
@@ -1,4 +1,4 @@
#include "graphics/presentation/window.h"
#include "graphics/presentation/window.h"
#include <SDL3/SDL.h>
@@ -54,25 +54,6 @@ struct EventKeyboard {
double timestamp_seconds;
};
static uint32_t ControllerButtonToPadButton(int button) {
switch (button) {
case SDL_GAMEPAD_BUTTON_SOUTH: return Controller::PAD_BUTTON_CROSS;
case SDL_GAMEPAD_BUTTON_EAST: return Controller::PAD_BUTTON_CIRCLE;
case SDL_GAMEPAD_BUTTON_WEST: return Controller::PAD_BUTTON_SQUARE;
case SDL_GAMEPAD_BUTTON_NORTH: return Controller::PAD_BUTTON_TRIANGLE;
case SDL_GAMEPAD_BUTTON_START: return Controller::PAD_BUTTON_OPTIONS;
case SDL_GAMEPAD_BUTTON_LEFT_STICK: return Controller::PAD_BUTTON_L3;
case SDL_GAMEPAD_BUTTON_RIGHT_STICK: return Controller::PAD_BUTTON_R3;
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: return Controller::PAD_BUTTON_L1;
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: return Controller::PAD_BUTTON_R1;
case SDL_GAMEPAD_BUTTON_DPAD_UP: return Controller::PAD_BUTTON_UP;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return Controller::PAD_BUTTON_DOWN;
case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return Controller::PAD_BUTTON_LEFT;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return Controller::PAD_BUTTON_RIGHT;
case SDL_GAMEPAD_BUTTON_TOUCHPAD: return Controller::PAD_BUTTON_TOUCH_PAD;
default: return 0;
}
}
static Controller::Axis ControllerAxisFromSdl(int axis_id) {
switch (axis_id) {
@@ -268,6 +249,15 @@ static void GameEventMouse([[maybe_unused]] const EventMouse& mb) {
}
HostInputMouseButton(mouse_button, mb.down);
if (!mb.touch && mb.left) {
HostInputMouseTouchPad(mb.down, static_cast<float>(mb.x), static_cast<float>(mb.y));
}
} else if (mb.motion && !mb.touch) {
if (mb.left) {
HostInputMouseTouchPad(true, static_cast<float>(mb.x), static_cast<float>(mb.y));
} else {
HostInputMouseMotion(static_cast<float>(mb.x), static_cast<float>(mb.y));
}
}
}
@@ -316,6 +306,7 @@ static void GameEventController([[maybe_unused]] const EventController& f) {
}
if (f.removed) {
HostInputDisconnectGamepad(f.id);
if (auto* pad = SDL_GetGamepadFromID(f.id); pad != nullptr) {
Controller::Disconnect(f.id);
SDL_CloseGamepad(pad);
@@ -323,30 +314,22 @@ static void GameEventController([[maybe_unused]] const EventController& f) {
}
if (f.down || f.up) {
const auto button = ControllerButtonToPadButton(f.button);
if (button != 0) {
Controller::SetButton(f.id, button, f.down);
} else if (f.button == SDL_GAMEPAD_BUTTON_BACK) {
// Pads without a touchpad (Switch, Xbox, ...) have no way to click it, which
// blocks games that require it. Use their Back/Select button as a centered click.
auto* pad = SDL_GetGamepadFromID(f.id);
if (pad != nullptr && SDL_GetNumGamepadTouchpads(pad) == 0) {
if (f.down) {
Controller::SetTouchPad(f.id, 0, true, 0.5f, 0.5f);
}
Controller::SetButton(f.id, Controller::PAD_BUTTON_TOUCH_PAD, f.down);
if (f.up) {
Controller::SetTouchPad(f.id, 0, false, 0.5f, 0.5f);
}
}
}
HostInputPadButton(f.id, f.button, f.down);
}
if (f.axis) {
const auto axis = ControllerAxisFromSdl(f.axis_id);
if (axis != Controller::Axis::AxisMax) {
Controller::SetAxis(f.id, axis,
if (f.axis_id == SDL_GAMEPAD_AXIS_LEFT_TRIGGER ||
f.axis_id == SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) {
HostInputPadTrigger(f.id, f.axis_id == SDL_GAMEPAD_AXIS_LEFT_TRIGGER,
ControllerAxisValueFromSdl(f.axis_id, f.axis_value));
} else if (const auto axis = ControllerAxisFromSdl(f.axis_id);
axis != Controller::Axis::AxisMax) {
const int value = ControllerAxisValueFromSdl(f.axis_id, f.axis_value);
if ((f.axis_id == SDL_GAMEPAD_AXIS_LEFTX || f.axis_id == SDL_GAMEPAD_AXIS_LEFTY) &&
HostInputGamepadAxis(f.id, f.axis_id == SDL_GAMEPAD_AXIS_LEFTX, value)) {
return;
}
Controller::SetAxis(f.id, axis, value);
}
}
}
@@ -431,6 +414,7 @@ void WindowContext::ProcessWindowEvent(const SDL_WindowEvent& event) {
break;
case SDL_EVENT_WINDOW_MOUSE_LEAVE:
LOGF("Mouse left window %" PRIu32 "\n", window_event.windowID);
HostInputSetTouchPadMode(false);
break;
case SDL_EVENT_WINDOW_FOCUS_GAINED:
LOGF("Window %" PRIu32 " gained keyboard focus\n", window_event.windowID);
+1
View File
@@ -105,6 +105,7 @@ if(APPLE)
install(TARGETS kyty_emulator DESTINATION "KytyPS5.app/Contents/MacOS")
install(TARGETS kyty_updater DESTINATION "KytyPS5.app/Contents/MacOS")
install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION "KytyPS5.app/Contents/Resources/assets/sounds")
install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/cursors" DESTINATION "KytyPS5.app/Contents/Resources/assets")
# Deploy and ad-hoc sign the Qt frameworks and plugins in the bundle.
find_program(KYTY_MACDEPLOYQT NAMES macdeployqt PATHS "${Qt6_DIR}/../../../bin" NO_DEFAULT_PATH)
if(NOT KYTY_MACDEPLOYQT)
+2
View File
@@ -24,6 +24,7 @@ public:
void SetSuspended(bool suspended);
[[nodiscard]] const QSet<QString>& Pressed() const { return m_pressed; }
[[nodiscard]] const QSet<QString>& RawPressed() const { return m_raw_pressed; }
[[nodiscard]] QPointF LeftStick() const { return m_left_stick; }
[[nodiscard]] QPointF RightStick() const { return m_right_stick; }
[[nodiscard]] const QString& DeviceName() const { return m_device_name; }
@@ -44,6 +45,7 @@ private:
bool m_preview_enabled = false;
bool m_suspended = false;
QSet<QString> m_pressed;
QSet<QString> m_raw_pressed;
QPointF m_left_stick;
QPointF m_right_stick;
QString m_device_name;
+21 -6
View File
@@ -1,15 +1,20 @@
#ifndef LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_
#ifndef LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_
#define LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_
#include <QDialog>
#include <QHash>
#include <QSet>
#include <QStringList>
class ControllerPreview;
class DualSenseWidget;
class QLabel;
class QPushButton;
class QCheckBox;
class QComboBox;
class QDoubleSpinBox;
class QTabWidget;
class QWidget;
class InputMappingDialog final: public QDialog {
public:
@@ -20,20 +25,30 @@ public:
[[nodiscard]] QStringList Mapping() const;
private:
void ChangeBinding(const QString& id);
void RestoreDefaults();
void UpdateButtons();
void UpdatePreview();
void UpdateHighlights();
QWidget* BuildTab(bool pad);
void ChangeKeyboardBinding(const QString& id);
void ChangePadBinding(const QString& id);
void ClearBinding(bool pad, const QString& id);
void RestoreDefaults();
void UpdateButtons();
void UpdatePreview();
void UpdateHighlights();
ControllerPreview* m_preview;
DualSenseWidget* m_controller = nullptr;
QTabWidget* m_tabs = nullptr;
QHash<QString, QString> m_bindings;
QHash<QString, QString> m_pad_bindings;
QHash<QString, QPushButton*> m_buttons;
QHash<QString, QPushButton*> m_pad_buttons;
QSet<QString> m_pad_pressed;
QString m_selected;
QLabel* m_status = nullptr;
QDoubleSpinBox* m_sensitivity = nullptr;
QComboBox* m_particle_skin = nullptr;
QCheckBox* m_touch_cursor = nullptr;
bool m_custom_bindings = false;
bool m_custom_pad = false;
};
#endif /* LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_ */
+45 -2
View File
@@ -20,6 +20,34 @@ constexpr struct {
{SDL_GAMEPAD_BUTTON_START, "Options"},
};
// Host-side names; they must match PAD_SOURCES in hostInput.cpp.
constexpr struct {
SDL_GamepadButton button;
const char* name;
} RAW_BUTTONS[] = {
{SDL_GAMEPAD_BUTTON_SOUTH, "South"},
{SDL_GAMEPAD_BUTTON_EAST, "East"},
{SDL_GAMEPAD_BUTTON_WEST, "West"},
{SDL_GAMEPAD_BUTTON_NORTH, "North"},
{SDL_GAMEPAD_BUTTON_BACK, "Back"},
{SDL_GAMEPAD_BUTTON_GUIDE, "Guide"},
{SDL_GAMEPAD_BUTTON_START, "Start"},
{SDL_GAMEPAD_BUTTON_LEFT_STICK, "LeftStick"},
{SDL_GAMEPAD_BUTTON_RIGHT_STICK, "RightStick"},
{SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, "LeftShoulder"},
{SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, "RightShoulder"},
{SDL_GAMEPAD_BUTTON_DPAD_UP, "DpadUp"},
{SDL_GAMEPAD_BUTTON_DPAD_DOWN, "DpadDown"},
{SDL_GAMEPAD_BUTTON_DPAD_LEFT, "DpadLeft"},
{SDL_GAMEPAD_BUTTON_DPAD_RIGHT, "DpadRight"},
{SDL_GAMEPAD_BUTTON_MISC1, "Misc1"},
{SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, "RightPaddle1"},
{SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, "LeftPaddle1"},
{SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, "RightPaddle2"},
{SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, "LeftPaddle2"},
{SDL_GAMEPAD_BUTTON_TOUCHPAD, "Touchpad"},
};
double Axis(SDL_Gamepad* gamepad, SDL_GamepadAxis axis) {
const int value = SDL_GetGamepadAxis(gamepad, axis);
return value / (value < 0 ? 32768.0 : 32767.0);
@@ -100,6 +128,7 @@ void ControllerPreview::UpdateRunning() {
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
}
m_pressed.clear();
m_raw_pressed.clear();
m_left_stick = {};
m_right_stick = {};
m_device_name.clear();
@@ -131,12 +160,25 @@ void ControllerPreview::Refresh() {
}
QSet<QString> pressed;
QSet<QString> raw;
QPointF left;
QPointF right;
QString name;
if (!m_gamepads.isEmpty()) {
auto* gamepad = m_gamepads.first();
name = QString::fromUtf8(SDL_GetGamepadName(gamepad));
for (const auto& binding: RAW_BUTTONS) {
if (SDL_GamepadHasButton(gamepad, binding.button) &&
SDL_GetGamepadButton(gamepad, binding.button)) {
raw.insert(QString::fromLatin1(binding.name));
}
}
if (Axis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > 0.5) {
raw.insert(QStringLiteral("LeftTrigger"));
}
if (Axis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > 0.5) {
raw.insert(QStringLiteral("RightTrigger"));
}
for (const auto& binding: BUTTONS) {
if (SDL_GetGamepadButton(gamepad, binding.button)) {
pressed.insert(QString::fromLatin1(binding.control));
@@ -180,9 +222,10 @@ void ControllerPreview::Refresh() {
pressed.insert(QStringLiteral("TouchPadRight"));
}
}
if (pressed != m_pressed || left != m_left_stick || right != m_right_stick ||
name != m_device_name) {
if (pressed != m_pressed || raw != m_raw_pressed || left != m_left_stick ||
right != m_right_stick || name != m_device_name) {
m_pressed = pressed;
m_raw_pressed = raw;
m_left_stick = left;
m_right_stick = right;
m_device_name = name;
+452 -146
View File
@@ -1,8 +1,10 @@
#include "inputMappingDialog.h"
#include "inputMappingDialog.h"
#include "controllerPreview.h"
#include "dualsenseWidget.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QGridLayout>
@@ -16,56 +18,113 @@
#include <QPushButton>
#include <QScreen>
#include <QScrollArea>
#include <QSet>
#include <QStringList>
#include <QTabWidget>
#include <QVBoxLayout>
namespace {
constexpr auto DEFAULT_MOUSE_SENSITIVITY = 1.0;
constexpr char MOUSE_SENSITIVITY[] = "MouseSensitivity=";
constexpr char TOUCH_PARTICLE_SKIN[] = "TouchParticleSkin=";
constexpr char TOUCH_CURSOR_OFF[] = "TouchCursorImage=Off";
constexpr char KEYBOARD_DISABLED[] = "Keyboard=Disabled";
constexpr char PAD_DISABLED[] = "Pad=Disabled";
enum Group { Dpad, LeftStick, RightStick, Face, Shoulders, Center, Shortcuts, GroupCount };
constexpr char PAD_PREFIX[] = "Pad:";
enum Section { Face, Dpad, Shoulders, Sticks, System, LeftStick, RightStick, Shortcuts, SectionCount };
const char* const SECTION_TITLES[SectionCount] = {
QT_TR_NOOP("Face buttons"), QT_TR_NOOP("D-pad"),
QT_TR_NOOP("Shoulders && triggers"), QT_TR_NOOP("Stick clicks"),
QT_TR_NOOP("System"), QT_TR_NOOP("Left stick"),
QT_TR_NOOP("Right stick"), QT_TR_NOOP("In-game shortcuts (cycle)"),
};
// pad_default is null for controls that have no gamepad equivalent.
struct PadControl {
const char* id;
const char* label;
const char* default_binding;
Group group;
int row;
int column;
const char* keyboard_default;
const char* pad_default;
Section section;
};
constexpr PadControl PAD_CONTROLS[] = {
{"Up", "Up", "Up", Dpad, 0, 1},
{"Down", "Down", "Down", Dpad, 2, 1},
{"Left", "Left", "Left", Dpad, 1, 0},
{"Right", "Right", "Right", Dpad, 1, 2},
{"LeftStickUp", "Up", "W", LeftStick, 0, 1},
{"LeftStickDown", "Down", "S", LeftStick, 2, 1},
{"LeftStickLeft", "Left", "A", LeftStick, 1, 0},
{"LeftStickRight", "Right", "D", LeftStick, 1, 2},
{"RightStickUp", "Up", "T", RightStick, 0, 1},
{"RightStickDown", "Down", "G", RightStick, 2, 1},
{"RightStickLeft", "Left", "F", RightStick, 1, 0},
{"RightStickRight", "Right", "H", RightStick, 1, 2},
{"Triangle", "Triangle", "I", Face, 0, 1},
{"Circle", "Circle", "L", Face, 1, 2},
{"Cross", "Cross", "J", Face, 2, 1},
{"Square", "Square", "K", Face, 1, 0},
{"L1", "L1", "Q", Shoulders, 0, 1},
{"R1", "R1", "E", Shoulders, 0, 2},
{"L2", "L2", "Z", Shoulders, 0, 0},
{"R2", "R2", "C", Shoulders, 0, 3},
{"L3", "Press / L3", "Left Shift", LeftStick, 1, 1},
{"R3", "Press / R3", "Left Ctrl", RightStick, 1, 1},
{"Options", "Options", "Return", Center, 0, 1},
{"TouchPad", "Touch pad left", "Backspace", Center, 0, 0},
{"TouchPadRight", "Touch pad right", "Tab", Center, 0, 2},
{"SpeakerVolume", "Speaker volume", "1", Shortcuts, 0, 0},
{"VibrationIntensity", "Vibration intensity", "2", Shortcuts, 0, 1},
{"TriggerEffectIntensity", "Trigger intensity", "3", Shortcuts, 0, 2},
{"Cross", "Cross", "J", "South", Face},
{"Circle", "Circle", "L", "East", Face},
{"Square", "Square", "K", "West", Face},
{"Triangle", "Triangle", "I", "North", Face},
{"Up", "Up", "Up", "DpadUp", Dpad},
{"Down", "Down", "Down", "DpadDown", Dpad},
{"Left", "Left", "Left", "DpadLeft", Dpad},
{"Right", "Right", "Right", "DpadRight", Dpad},
{"L1", "L1", "Q", "LeftShoulder", Shoulders},
{"R1", "R1", "E", "RightShoulder", Shoulders},
{"L2", "L2", "Z", "LeftTrigger", Shoulders},
{"R2", "R2", "C", "RightTrigger", Shoulders},
{"L3", "L3 (left stick click)", "Left Shift", "LeftStick", Sticks},
{"R3", "R3 (right stick click)", "Left Ctrl", "RightStick", Sticks},
{"Options", "Options", "Return", "Start", System},
{"TouchPad", "Touch pad click", "Backspace", "Touchpad", System},
{"TouchPadRight", "Touch pad right", "Tab", nullptr, System},
{"GyroToggle", "Gyro emulation toggle", "", "Back", System},
{"TouchPadMode", "Emulated touch pad mode", "F6", "", System},
{"LeftStickUp", "Up", "W", nullptr, LeftStick},
{"LeftStickDown", "Down", "S", nullptr, LeftStick},
{"LeftStickLeft", "Left", "A", nullptr, LeftStick},
{"LeftStickRight", "Right", "D", nullptr, LeftStick},
{"RightStickUp", "Up", "T", nullptr, RightStick},
{"RightStickDown", "Down", "G", nullptr, RightStick},
{"RightStickLeft", "Left", "F", nullptr, RightStick},
{"RightStickRight", "Right", "H", nullptr, RightStick},
{"SpeakerVolume", "Speaker volume", "1", nullptr, Shortcuts},
{"VibrationIntensity", "Vibration intensity", "2", nullptr, Shortcuts},
{"TriggerEffectIntensity", "Trigger intensity", "3", nullptr, Shortcuts},
};
struct PadSourceName {
const char* id;
const char* label;
};
constexpr PadSourceName PAD_SOURCES[] = {
{"South", "A / Cross"},
{"East", "B / Circle"},
{"West", "X / Square"},
{"North", "Y / Triangle"},
{"Back", "View / Back / Share"},
{"Guide", "Guide / Home"},
{"Start", "Menu / Start"},
{"LeftStick", "Left stick click"},
{"RightStick", "Right stick click"},
{"LeftShoulder", "LB / L1"},
{"RightShoulder", "RB / R1"},
{"DpadUp", "D-pad Up"},
{"DpadDown", "D-pad Down"},
{"DpadLeft", "D-pad Left"},
{"DpadRight", "D-pad Right"},
{"Misc1", "Share / Misc"},
{"RightPaddle1", "Right paddle 1"},
{"LeftPaddle1", "Left paddle 1"},
{"RightPaddle2", "Right paddle 2"},
{"LeftPaddle2", "Left paddle 2"},
{"Touchpad", "Touch pad click"},
{"LeftTrigger", "LT / L2"},
{"RightTrigger", "RT / R2"},
};
QString PadSourceLabel(const QString& id) {
for (const auto& source: PAD_SOURCES) {
if (id == QLatin1String(source.id)) {
return QObject::tr(source.label);
}
}
return id;
}
QString KeypadName(int key) {
if (key >= Qt::Key_0 && key <= Qt::Key_9) {
return QStringLiteral("Keypad %1").arg(key - Qt::Key_0);
@@ -191,6 +250,54 @@ private:
QString m_binding;
};
class PadCaptureDialog final: public QDialog {
public:
PadCaptureDialog(ControllerPreview* preview, QWidget* parent): QDialog(parent) {
setWindowTitle(tr("Set Binding"));
setModal(true);
setMinimumWidth(360);
auto* layout = new QVBoxLayout(this);
m_label = new QLabel(this);
m_label->setAlignment(Qt::AlignCenter);
m_label->setWordWrap(true);
layout->addWidget(m_label);
if (preview == nullptr) {
return;
}
// Inputs already held when the dialog opens must be released before they count.
m_held = preview->RawPressed();
connect(preview, &ControllerPreview::StateChanged, this, [this, preview] {
const auto& pressed = preview->RawPressed();
m_held.intersect(pressed);
for (const auto& name: pressed) {
if (!m_held.contains(name)) {
m_binding = name;
accept();
return;
}
}
UpdateText(preview);
});
UpdateText(preview);
}
[[nodiscard]] const QString& Binding() const { return m_binding; }
private:
void UpdateText(const ControllerPreview* preview) {
m_label->setText(preview->DeviceName().isEmpty()
? tr("No controller connected.\nConnect one, or press Esc to cancel.")
: tr("Press a button or trigger on %1.\nEsc cancels.")
.arg(preview->DeviceName()));
}
QLabel* m_label = nullptr;
QString m_binding;
QSet<QString> m_held;
};
void AssignBinding(QHash<QString, QString>& bindings, const QString& id, const QString& binding) {
for (auto item = bindings.begin(); item != bindings.end();) {
if (item.value().compare(binding, Qt::CaseInsensitive) == 0) {
@@ -205,7 +312,11 @@ void AssignBinding(QHash<QString, QString>& bindings, const QString& id, const Q
QHash<QString, QString> ParseMapping(const QStringList& mapping) {
QHash<QString, QString> result;
for (const auto& entry: mapping) {
if (entry.startsWith(QLatin1String(MOUSE_SENSITIVITY))) {
if (entry == QLatin1String(KEYBOARD_DISABLED) || entry == QLatin1String(PAD_DISABLED) ||
entry.startsWith(QLatin1String(MOUSE_SENSITIVITY)) ||
entry.startsWith(QLatin1String(TOUCH_PARTICLE_SKIN)) ||
entry.startsWith(QLatin1String("TouchCursorImage=")) ||
entry.startsWith(QLatin1String(PAD_PREFIX))) {
continue;
}
const auto separator = entry.indexOf(QLatin1Char('='));
@@ -216,6 +327,15 @@ QHash<QString, QString> ParseMapping(const QStringList& mapping) {
return result;
}
QString ParseTouchParticleSkin(const QStringList& mapping) {
for (const auto& entry: mapping) {
if (entry.startsWith(QLatin1String(TOUCH_PARTICLE_SKIN))) {
return entry.mid(sizeof(TOUCH_PARTICLE_SKIN) - 1);
}
}
return QStringLiteral("Classic");
}
double ParseMouseSensitivity(const QStringList& mapping) {
for (const auto& entry: mapping) {
if (entry.startsWith(QLatin1String(MOUSE_SENSITIVITY))) {
@@ -231,106 +351,67 @@ InputMappingDialog::InputMappingDialog(const QStringList& mapping, ControllerPre
QWidget* parent)
: QDialog(parent), m_preview(preview) {
setWindowTitle(tr("Controls"));
resize(QSize(1100, 820).boundedTo(screen()->availableGeometry().size()));
resize(QSize(1360, 780).boundedTo(screen()->availableGeometry().size()));
m_bindings = ParseMapping(mapping);
m_custom_bindings = !m_bindings.isEmpty() || mapping.contains(QLatin1String(KEYBOARD_DISABLED));
m_custom_pad = mapping.contains(QLatin1String(PAD_DISABLED));
for (const auto& entry: mapping) {
if (!entry.startsWith(QLatin1String(PAD_PREFIX))) {
continue;
}
const auto body = entry.mid(static_cast<int>(sizeof(PAD_PREFIX)) - 1);
const auto separator = body.indexOf(QLatin1Char('='));
if (separator > 0 && separator + 1 < body.size()) {
AssignBinding(m_pad_bindings, body.left(separator), body.mid(separator + 1));
m_custom_pad = true;
}
}
for (const auto& control: PAD_CONTROLS) {
const auto id = QString::fromLatin1(control.id);
if (!m_custom_bindings && control.keyboard_default[0] != '\0') {
m_bindings.insert(id, QString::fromLatin1(control.keyboard_default));
}
if (!m_custom_pad && control.pad_default != nullptr) {
m_pad_bindings.insert(id, QString::fromLatin1(control.pad_default));
}
}
auto* layout = new QVBoxLayout(this);
layout->setSpacing(10);
auto* title = new QLabel(tr("Controller setup"), this);
auto font = title->font();
font.setPointSize(font.pointSize() + 7);
font.setBold(true);
title->setFont(font);
layout->addWidget(title);
auto* hint = new QLabel(
tr("Select a button to remap. Press controller buttons to test."), this);
hint->setWordWrap(true);
layout->addWidget(hint);
m_status = new QLabel(this);
m_status->setObjectName(QStringLiteral("controllerStatus"));
m_status->setWordWrap(true);
layout->addWidget(m_status);
auto* scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto* content = new QWidget(scroll);
auto* grid = new QGridLayout(content);
grid->setContentsMargins(0, 0, 0, 0);
grid->setSpacing(12);
QGroupBox* groups[GroupCount];
QGridLayout* cells[GroupCount];
const char* titles[] = {"D-Pad",
"Left stick",
"Right stick",
"Face buttons",
"Shoulders and triggers",
"Touch pad and options",
"In-game shortcuts (cycle)"};
for (int index = 0; index < GroupCount; ++index) {
groups[index] = new QGroupBox(tr(titles[index]), content);
cells[index] = new QGridLayout(groups[index]);
cells[index]->setSpacing(5);
}
m_bindings = ParseMapping(mapping);
m_custom_bindings = !m_bindings.isEmpty();
for (const auto& control: PAD_CONTROLS) {
const auto id = QString::fromLatin1(control.id);
auto* cell = new QGroupBox(tr(control.label), groups[control.group]);
auto* cell_layout = new QVBoxLayout(cell);
cell_layout->setContentsMargins(3, 5, 3, 3);
auto* button = new QPushButton(cell);
button->setObjectName(QStringLiteral("binding_") + id);
button->setAutoDefault(false);
button->setMinimumWidth(64);
button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
button->setAccessibleName(tr(titles[control.group]) + QStringLiteral(" / ") +
tr(control.label));
cell_layout->addWidget(button);
cells[control.group]->addWidget(cell, control.row, control.column);
m_buttons.insert(id, button);
if (!m_custom_bindings) {
m_bindings.insert(id, QString::fromLatin1(control.default_binding));
}
connect(button, &QPushButton::clicked, this, [this, id] { ChangeBinding(id); });
}
auto* left = new QVBoxLayout;
left->addWidget(groups[Dpad]);
left->addWidget(groups[LeftStick]);
left->addStretch();
grid->addLayout(left, 0, 0);
auto* center = new QVBoxLayout;
center->addWidget(groups[Shoulders]);
m_controller = new DualSenseWidget(content);
m_controller->setObjectName(QStringLiteral("controllerDiagram"));
center->addWidget(m_controller, 1);
center->addWidget(groups[Center]);
grid->addLayout(center, 0, 1);
auto* right = new QVBoxLayout;
right->addWidget(groups[Face]);
right->addWidget(groups[RightStick]);
right->addStretch();
grid->addLayout(right, 0, 2);
grid->setColumnStretch(1, 1);
grid->addWidget(groups[Shortcuts], 1, 0, 1, 3);
scroll->setWidget(content);
layout->addWidget(scroll, 1);
auto* sensitivity_layout = new QHBoxLayout;
sensitivity_layout->addWidget(new QLabel(tr("Mouse sensitivity"), this));
m_sensitivity = new QDoubleSpinBox(this);
m_sensitivity->setObjectName(QStringLiteral("mouseSensitivity"));
m_sensitivity->setRange(0.1, 5.0);
m_sensitivity->setSingleStep(0.1);
m_sensitivity->setDecimals(1);
m_sensitivity->setSuffix(QStringLiteral("×"));
m_tabs = new QTabWidget(this);
m_tabs->setObjectName(QStringLiteral("inputTabs"));
m_tabs->setDocumentMode(true);
m_tabs->addTab(BuildTab(true), tr("Gamepad"));
m_tabs->addTab(BuildTab(false), tr("Keyboard && Mouse"));
layout->addWidget(m_tabs, 1);
m_sensitivity->setValue(ParseMouseSensitivity(mapping));
sensitivity_layout->addWidget(m_sensitivity);
sensitivity_layout->addWidget(
new QLabel(tr("F7 in-game toggles mouse movement on the right stick."), this));
sensitivity_layout->addStretch();
layout->addLayout(sensitivity_layout);
auto* touch_group = new QGroupBox(tr("Emulated touch pad (shared by both tabs)"), this);
auto* touch_layout = new QHBoxLayout(touch_group);
touch_layout->addWidget(new QLabel(tr("Particle skin"), touch_group));
m_particle_skin = new QComboBox(touch_group);
m_particle_skin->setObjectName(QStringLiteral("touchParticleSkin"));
m_particle_skin->addItem(tr("Classic"), QStringLiteral("Classic"));
m_particle_skin->addItem(tr("Blue neon"), QStringLiteral("BlueNeon"));
m_particle_skin->addItem(tr("Monochrome"), QStringLiteral("Monochrome"));
m_particle_skin->addItem(tr("Crimson"), QStringLiteral("Crimson"));
m_particle_skin->addItem(tr("Red/Blue"), QStringLiteral("RedBlue"));
touch_layout->addWidget(m_particle_skin);
m_touch_cursor = new QCheckBox(tr("Show hand cursor and trail"), touch_group);
m_touch_cursor->setObjectName(QStringLiteral("touchCursorImage"));
m_touch_cursor->setToolTip(tr("When off, the emulated touch pad still works but the hand "
"image and drawing trail are not shown."));
m_touch_cursor->setChecked(!mapping.contains(QLatin1String(TOUCH_CURSOR_OFF)));
touch_layout->addWidget(m_touch_cursor);
touch_layout->addStretch();
touch_layout->addWidget(new QLabel(
tr("Toggle with the touch pad mode key (F6 by default); draw with left mouse / Cross."),
touch_group));
layout->addWidget(touch_group);
const auto skin_index = m_particle_skin->findData(ParseTouchParticleSkin(mapping));
m_particle_skin->setCurrentIndex(skin_index >= 0 ? skin_index : 0);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::RestoreDefaults |
QDialogButtonBox::Save | QDialogButtonBox::Cancel,
@@ -338,9 +419,14 @@ InputMappingDialog::InputMappingDialog(const QStringList& mapping, ControllerPre
auto* defaults = buttons->button(QDialogButtonBox::RestoreDefaults);
defaults->setObjectName(QStringLiteral("defaults"));
defaults->setAutoDefault(false);
layout->addWidget(buttons);
connect(m_controller, &DualSenseWidget::ControlClicked, this,
&InputMappingDialog::ChangeBinding);
defaults->setToolTip(tr("Restore the defaults of the current tab."));
auto* tip = new QLabel(tr("Tip: right-click a binding to unmap it."), this);
auto* footer = new QHBoxLayout;
footer->addWidget(tip);
footer->addStretch();
footer->addWidget(buttons);
layout->addLayout(footer);
connect(defaults, &QPushButton::clicked, this, &InputMappingDialog::RestoreDefaults);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
@@ -359,6 +445,128 @@ InputMappingDialog::~InputMappingDialog() {
}
}
QWidget* InputMappingDialog::BuildTab(bool pad) {
auto* page = new QWidget(this);
auto* layout = new QVBoxLayout(page);
layout->setSpacing(10);
auto* hint = new QLabel(
pad ? tr("Choose which button on your gamepad triggers each PS5 control. Click a "
"binding, then press the button or trigger you want to use.")
: tr("Choose which keyboard key or mouse button triggers each PS5 control. Click a "
"binding, then press the key or mouse button."),
page);
hint->setWordWrap(true);
layout->addWidget(hint);
if (pad) {
m_status = new QLabel(page);
m_status->setObjectName(QStringLiteral("controllerStatus"));
m_status->setWordWrap(true);
layout->addWidget(m_status);
}
auto* body = new QHBoxLayout;
body->setSpacing(14);
layout->addLayout(body, 1);
auto* scroll = new QScrollArea(page);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto* content = new QWidget(scroll);
auto* columns = new QGridLayout(content);
columns->setContentsMargins(0, 0, 8, 0);
columns->setSpacing(12);
columns->setAlignment(Qt::AlignTop);
// Section order and the column each one lives in.
static constexpr Section ORDER[] = {Face, Dpad, Shoulders, Sticks, System, LeftStick,
RightStick, Shortcuts};
int column_rows[2] = {0, 0};
for (const auto section: ORDER) {
QGroupBox* group = nullptr;
QGridLayout* rows = nullptr;
int row = 0;
for (const auto& control: PAD_CONTROLS) {
if (control.section != section || (pad && control.pad_default == nullptr)) {
continue;
}
if (group == nullptr) {
group = new QGroupBox(tr(SECTION_TITLES[section]), content);
rows = new QGridLayout(group);
rows->setHorizontalSpacing(12);
rows->setVerticalSpacing(6);
rows->setColumnStretch(0, 1);
rows->setColumnMinimumWidth(1, 190);
}
const auto id = QString::fromLatin1(control.id);
const auto label = !pad && id == QLatin1String("TouchPad") ? tr("Touch pad left")
: tr(control.label);
auto* name = new QLabel(label, group);
auto* button = new QPushButton(group);
button->setObjectName((pad ? QStringLiteral("padBinding_") : QStringLiteral("binding_")) +
id);
button->setAutoDefault(false);
button->setMinimumWidth(190);
button->setContextMenuPolicy(Qt::CustomContextMenu);
button->setAccessibleName(tr(SECTION_TITLES[section]) + QStringLiteral(" / ") + label);
rows->addWidget(name, row, 0);
rows->addWidget(button, row, 1);
++row;
(pad ? m_pad_buttons : m_buttons).insert(id, button);
connect(button, &QPushButton::clicked, this, [this, pad, id] {
pad ? ChangePadBinding(id) : ChangeKeyboardBinding(id);
});
connect(button, &QPushButton::customContextMenuRequested, this,
[this, pad, id] { ClearBinding(pad, id); });
}
if (group != nullptr) {
const int target = column_rows[0] <= column_rows[1] ? 0 : 1;
columns->addWidget(group, column_rows[target]++, target);
}
}
columns->setColumnStretch(0, 1);
columns->setColumnStretch(1, 1);
scroll->setWidget(content);
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scroll->setMinimumWidth(pad ? 640 : 700);
body->addWidget(scroll, 3);
if (pad) {
m_controller = new DualSenseWidget(page);
m_controller->setObjectName(QStringLiteral("controllerDiagram"));
m_controller->setMinimumWidth(320);
body->addWidget(m_controller, 2);
connect(m_controller, &DualSenseWidget::ControlClicked, this,
[this](const QString& id) {
ChangePadBinding(id == QLatin1String("TouchPadRight") ? QStringLiteral("TouchPad")
: id);
});
auto* note = new QLabel(tr("The gyro emulation toggle activates tilt mode on gamepads "
"without a gyroscope: the right stick then controls pitch and "
"roll. It defaults to the View/Back button."),
page);
note->setWordWrap(true);
layout->addWidget(note);
} else {
auto* sensitivity_layout = new QHBoxLayout;
sensitivity_layout->addWidget(new QLabel(tr("Mouse sensitivity"), page));
m_sensitivity = new QDoubleSpinBox(page);
m_sensitivity->setObjectName(QStringLiteral("mouseSensitivity"));
m_sensitivity->setRange(0.1, 5.0);
m_sensitivity->setSingleStep(0.1);
m_sensitivity->setDecimals(1);
m_sensitivity->setSuffix(QStringLiteral("×"));
m_sensitivity->setValue(DEFAULT_MOUSE_SENSITIVITY);
sensitivity_layout->addWidget(m_sensitivity);
sensitivity_layout->addWidget(
new QLabel(tr("F7 in-game toggles mouse movement on the right stick."), page));
sensitivity_layout->addStretch();
layout->addLayout(sensitivity_layout);
}
return page;
}
QStringList InputMappingDialog::Mapping() const {
QStringList result;
if (m_custom_bindings) {
@@ -369,15 +577,38 @@ QStringList InputMappingDialog::Mapping() const {
result.append(id + QLatin1Char('=') + binding);
}
}
if (result.isEmpty()) {
result.append(QLatin1String(KEYBOARD_DISABLED));
}
}
if (m_custom_pad) {
const auto start = result.size();
for (const auto& control: PAD_CONTROLS) {
const auto id = QString::fromLatin1(control.id);
const auto source = m_pad_bindings.value(id);
if (!source.isEmpty()) {
result.append(QLatin1String(PAD_PREFIX) + id + QLatin1Char('=') + source);
}
}
if (result.size() == start) {
result.append(QLatin1String(PAD_DISABLED));
}
}
if (m_sensitivity->value() != DEFAULT_MOUSE_SENSITIVITY) {
result.append(QLatin1String(MOUSE_SENSITIVITY) +
QString::number(m_sensitivity->value(), 'f', 1));
}
const auto skin = m_particle_skin->currentData().toString();
if (skin != QLatin1String("Classic")) {
result.append(QLatin1String(TOUCH_PARTICLE_SKIN) + skin);
}
if (!m_touch_cursor->isChecked()) {
result.append(QLatin1String(TOUCH_CURSOR_OFF));
}
return result;
}
void InputMappingDialog::ChangeBinding(const QString& id) {
void InputMappingDialog::ChangeKeyboardBinding(const QString& id) {
if (!m_buttons.contains(id)) {
return;
}
@@ -393,14 +624,54 @@ void InputMappingDialog::ChangeBinding(const QString& id) {
UpdateButtons();
}
void InputMappingDialog::RestoreDefaults() {
m_bindings.clear();
for (const auto& control: PAD_CONTROLS) {
m_bindings.insert(QString::fromLatin1(control.id),
QString::fromLatin1(control.default_binding));
void InputMappingDialog::ChangePadBinding(const QString& id) {
if (!m_pad_buttons.contains(id)) {
return;
}
m_selected = id;
UpdateHighlights();
PadCaptureDialog dialog(m_preview, this);
dialog.setWindowTitle(tr("Set binding — %1").arg(m_pad_buttons.value(id)->accessibleName()));
if (dialog.exec() == QDialog::Accepted) {
AssignBinding(m_pad_bindings, id, dialog.Binding());
m_custom_pad = true;
}
m_selected.clear();
UpdateButtons();
}
void InputMappingDialog::ClearBinding(bool pad, const QString& id) {
if (pad) {
m_pad_bindings.remove(id);
m_custom_pad = true;
} else {
m_bindings.remove(id);
m_custom_bindings = true;
}
UpdateButtons();
}
void InputMappingDialog::RestoreDefaults() {
if (m_tabs->currentIndex() == 0) {
m_pad_bindings.clear();
for (const auto& control: PAD_CONTROLS) {
if (control.pad_default != nullptr) {
m_pad_bindings.insert(QString::fromLatin1(control.id),
QString::fromLatin1(control.pad_default));
}
}
m_custom_pad = false;
} else {
m_bindings.clear();
for (const auto& control: PAD_CONTROLS) {
if (control.keyboard_default[0] != '\0') {
m_bindings.insert(QString::fromLatin1(control.id),
QString::fromLatin1(control.keyboard_default));
}
}
m_sensitivity->setValue(DEFAULT_MOUSE_SENSITIVITY);
m_custom_bindings = false;
}
m_sensitivity->setValue(DEFAULT_MOUSE_SENSITIVITY);
m_custom_bindings = false;
UpdateButtons();
}
@@ -410,14 +681,42 @@ void InputMappingDialog::UpdateButtons() {
const auto binding = m_bindings.value(item.key());
const auto label = binding.isEmpty() ? tr("None") : binding;
button->setText(QString(label).replace(QLatin1Char('&'), QLatin1String("&&")));
button->setToolTip(tr("%1: %2. Click to change.").arg(button->accessibleName(), label));
button->setToolTip(
tr("%1: %2. Click to change, right-click to clear.").arg(button->accessibleName(), label));
}
for (auto item = m_pad_buttons.begin(); item != m_pad_buttons.end(); ++item) {
auto* button = item.value();
const auto source = m_pad_bindings.value(item.key());
const auto label = source.isEmpty() ? tr("None") : PadSourceLabel(source);
button->setText(QString(label).replace(QLatin1Char('&'), QLatin1String("&&")));
button->setToolTip(
tr("%1: %2. Click to change, right-click to clear.").arg(button->accessibleName(), label));
}
UpdateHighlights();
}
void InputMappingDialog::UpdatePreview() {
const auto pressed = m_preview != nullptr ? m_preview->Pressed() : QSet<QString>();
m_controller->SetPressed(pressed);
m_pad_pressed.clear();
if (m_preview != nullptr) {
const auto& raw = m_preview->RawPressed();
for (auto item = m_pad_bindings.begin(); item != m_pad_bindings.end(); ++item) {
if (raw.contains(item.value())) {
m_pad_pressed.insert(item.key());
}
}
if (m_pad_pressed.contains(QStringLiteral("TouchPad"))) {
m_pad_pressed.insert(QStringLiteral("TouchPadRight"));
}
for (const auto& name: m_preview->Pressed()) {
// Stick directions and touch pad fingers are shown as the pad reports them.
if (name.startsWith(QLatin1String("LeftStick")) ||
name.startsWith(QLatin1String("RightStick")) ||
name.startsWith(QLatin1String("TouchPad"))) {
m_pad_pressed.insert(name);
}
}
}
m_controller->SetPressed(m_pad_pressed);
m_controller->SetSticks(m_preview != nullptr ? m_preview->LeftStick() : QPointF(),
m_preview != nullptr ? m_preview->RightStick() : QPointF());
UpdateHighlights();
@@ -432,8 +731,8 @@ void InputMappingDialog::UpdatePreview() {
void InputMappingDialog::UpdateHighlights() {
m_controller->SetSelected(m_selected);
for (auto item = m_buttons.begin(); item != m_buttons.end(); ++item) {
const bool down = m_preview != nullptr && m_preview->Pressed().contains(item.key());
for (auto item = m_pad_buttons.begin(); item != m_pad_buttons.end(); ++item) {
const bool down = m_pad_pressed.contains(item.key());
auto colors = palette();
if (down) {
colors.setColor(QPalette::Button, colors.color(QPalette::Highlight));
@@ -446,4 +745,11 @@ void InputMappingDialog::UpdateHighlights() {
item.value()->setPalette(colors);
item.value()->setFont(label_font);
}
for (auto item = m_buttons.begin(); item != m_buttons.end(); ++item) {
auto colors = palette();
if (item.key() == m_selected) {
colors.setColor(QPalette::ButtonText, colors.color(QPalette::Highlight));
}
item.value()->setPalette(colors);
}
}
+101 -1
View File
@@ -126,6 +126,7 @@ public:
void Motion(int id, Sensor sensor, const float* data, uint64_t time_us);
void SetMotionSensorState(bool enable);
void ResetOrientation();
void ToggleGyroEmulation(int id);
void ResetInputState();
void ReleaseHostPads();
void GetConnectionInfo(bool* flag, int* count);
@@ -147,6 +148,8 @@ private:
void CheckActive();
void AddState();
// Emulated tilt: the right stick drives pitch and roll instead of the stick axes.
void UpdateEmulatedTilt();
// Apply cached output without changing its game-requested lifetime; caller holds m_mutex.
void ApplyVibration();
bool SendTriggerEffect(const PadTriggerEffectParam& param);
@@ -160,6 +163,9 @@ private:
int m_connected_count = 0;
bool m_motion_enabled = true;
uint64_t m_gyro_time = 0;
bool m_gyro_emulation = false;
uint64_t m_emulated_time = 0;
std::array<int, 2> m_emulated_stick {128, 128};
// Accelerometer gravity direction in the reset frame.
std::array<float, 3> m_up_reference {0.0f, 1.0f, 0.0f};
bool m_up_reference_valid = false;
@@ -479,6 +485,9 @@ void GameController::CheckActive() {
m_connected = new_connected;
m_state = {};
m_gyro_time = 0;
m_gyro_emulation = false;
m_emulated_time = 0;
m_emulated_stick = {128, 128};
m_up_reference_valid = false;
m_states_num = 0;
m_first_state = 0;
@@ -524,6 +533,12 @@ void GameController::Axis(int id, Controller::Axis axis, int value) {
EXIT_IF(axis_id < 0 || axis_id >= static_cast<int>(Controller::Axis::AxisMax));
if (m_gyro_emulation && (axis == Controller::Axis::RightX || axis == Controller::Axis::RightY)) {
m_emulated_stick[axis == Controller::Axis::RightY ? 1 : 0] = value;
UpdateEmulatedTilt();
return;
}
m_state.axes[axis_id] = value;
uint32_t trigger = 0;
@@ -545,7 +560,12 @@ void GameController::RightStick(int id, int x, int y) {
Common::LockGuard lock(m_mutex);
if (m_active_id == id || id == HOST_INPUT_CONTROLLER_ID) {
m_state.time = LibKernel::KernelGetProcessTime();
m_state.time = LibKernel::KernelGetProcessTime();
if (m_gyro_emulation) {
m_emulated_stick = {x, y};
UpdateEmulatedTilt();
return;
}
m_state.axes[static_cast<int>(Axis::RightX)] = x;
m_state.axes[static_cast<int>(Axis::RightY)] = y;
AddState();
@@ -677,6 +697,82 @@ void GameController::SetMotionSensorState(bool enable) {
}
}
static Quat QuatMultiply(const Quat& a, const Quat& b) {
return {a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]};
}
void GameController::UpdateEmulatedTilt() {
constexpr float max_angle = 0.7853982f; // 45 degrees at full stick deflection
const auto angle = [](int value) {
return std::clamp((value - 128) / 127.0f, -1.0f, 1.0f) * max_angle;
};
const float roll = angle(m_emulated_stick[0]);
const float pitch = angle(m_emulated_stick[1]);
const Quat q_pitch {std::sin(pitch * 0.5f), 0.0f, 0.0f, std::cos(pitch * 0.5f)};
const Quat q_roll {0.0f, 0.0f, std::sin(-roll * 0.5f), std::cos(-roll * 0.5f)};
Quat q = QuatMultiply(q_roll, q_pitch);
const uint64_t now = LibKernel::KernelGetProcessTime();
Vec3 rate {};
if (m_emulated_time != 0 && now > m_emulated_time) {
const float dt = std::clamp(static_cast<float>(now - m_emulated_time) * 0.000001f,
0.001f, 0.1f);
auto delta = QuatMultiply(QuatConjugate(m_state.orientation), q);
if (delta[3] < 0.0f) {
for (auto& v: delta) {
v = -v;
}
}
rate = {2.0f * delta[0] / dt, 2.0f * delta[1] / dt, 2.0f * delta[2] / dt};
}
m_emulated_time = now;
m_state.time = now;
m_state.orientation = q;
m_state.accel = QuatRotate(QuatConjugate(q), {0.0f, 1.0f, 0.0f});
m_state.gyro = rate;
AddState();
// Angular velocity is only nonzero while the tilt changes.
m_state.gyro = {};
}
void GameController::ToggleGyroEmulation(int id) {
Common::LockGuard lock(m_mutex);
if (id != HOST_INPUT_CONTROLLER_ID && id != m_active_id) {
return;
}
// Pads with a real gyroscope report it directly.
if (m_active_id != HOST_INPUT_CONTROLLER_ID) {
auto* pad = SDL_GetGamepadFromID(static_cast<SDL_JoystickID>(m_active_id));
if (pad != nullptr && SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO)) {
return;
}
}
m_gyro_emulation = !m_gyro_emulation;
m_emulated_time = 0;
m_state.time = LibKernel::KernelGetProcessTime();
if (m_gyro_emulation) {
m_emulated_stick = {m_state.axes[static_cast<int>(Axis::RightX)],
m_state.axes[static_cast<int>(Axis::RightY)]};
m_state.axes[static_cast<int>(Axis::RightX)] = 128;
m_state.axes[static_cast<int>(Axis::RightY)] = 128;
UpdateEmulatedTilt();
} else {
m_state.orientation = {0.0f, 0.0f, 0.0f, 1.0f};
m_state.accel = {0.0f, 1.0f, 0.0f};
m_state.gyro = {};
m_state.axes[static_cast<int>(Axis::RightX)] = m_emulated_stick[0];
m_state.axes[static_cast<int>(Axis::RightY)] = m_emulated_stick[1];
AddState();
}
LOGF("Emulated gyro tilt: %s\n", m_gyro_emulation ? "enabled" : "disabled");
}
void GameController::ResetOrientation() {
Common::LockGuard lock(m_mutex);
m_state.orientation = {0.0f, 0.0f, 0.0f, 1.0f};
@@ -931,6 +1027,10 @@ void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us) {
g_controller->Motion(id, sensor, data, time_us);
}
void ToggleGyroEmulation(int id) {
g_controller->ToggleGyroEmulation(id);
}
void ResetInputState() {
g_controller->ResetInputState();
}
+1
View File
@@ -73,6 +73,7 @@ void SetAxis(int id, Axis axis, int value);
void SetRightStick(int id, int x, int y);
void SetTouchPad(int id, int finger, bool down, float x, float y);
void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us);
void ToggleGyroEmulation(int id);
void ResetInputState();
int GetActiveControllerId();
+27
View File
@@ -326,6 +326,33 @@ uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames
if (frames == 0) {
return 0;
}
if (!stream->speaker &&
SDL_GetGamepadTypeForID(static_cast<SDL_JoystickID>(controller)) != SDL_GAMEPAD_TYPE_PS5) {
CloseDevice(stream);
stream->next_check = 0;
// Non-DualSense pads have no haptic audio path: approximate it with the rumble motors.
float peak[2] = {0.0f, 0.0f};
for (uint32_t frame = 0; frame < frames; frame++) {
for (uint32_t ch = 0; ch < 2; ch++) {
const auto src_ch = channels == 1 ? 0 : ch;
const auto index = (static_cast<size_t>(first_frame) + frame) * channels + src_ch;
float value = is_float ? static_cast<const float*>(data)[index]
: static_cast<const int16_t*>(data)[index] / 32768.0f;
value *= volume[src_ch] / 32768.0f * gain * (gains != nullptr ? gains[src_ch] : 1.0f);
peak[ch] = std::max(peak[ch], std::fabs(value));
}
}
const auto to_motor = [](float v) {
return static_cast<uint16_t>(std::clamp(v * 2.0f, 0.0f, 1.0f) * 65535.0f);
};
SDL_LockJoysticks();
if (auto* pad = SDL_GetGamepadFromID(static_cast<SDL_JoystickID>(controller));
pad != nullptr) {
(void) SDL_RumbleGamepad(pad, to_motor(peak[0]), to_motor(peak[1]), 100);
}
SDL_UnlockJoysticks();
return static_cast<uint64_t>(frames) * 1000000 / stream->freq;
}
const bool wireless = IsWireless(controller);
if (!wireless && !CanUseDevice(controller)) {
CloseDevice(stream);
+205
View File
@@ -0,0 +1,205 @@
// Exercise production host input with deterministic gamepads and guest output.
#include <SDL3/SDL.h>
#include <array>
#include <cstdio>
#include <cstdlib>
namespace {
void Check(bool condition, const char* message) {
if (!condition) {
std::fprintf(stderr, "HostInputTests: %s\n", message);
std::abort();
}
}
std::array<Sint16, 2> physical_stick {};
std::array<int, 2> guest_stick {128, 128};
uint32_t guest_buttons = 0;
bool guest_touch = false;
} // namespace
namespace Fake {
SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) {
return id == 1 || id == 2 ? reinterpret_cast<SDL_Gamepad*>(static_cast<uintptr_t>(id))
: nullptr;
}
Sint16 GetGamepadAxis(SDL_Gamepad*, SDL_GamepadAxis axis) {
return physical_stick[axis == SDL_GAMEPAD_AXIS_LEFTY ? 1 : 0];
}
Uint64 GetTicks() {
return 1000;
}
bool HideCursor() {
return true;
}
bool ShowCursor() {
return true;
}
SDL_MouseButtonFlags GetMouseState(float* x, float* y) {
*x = *y = 0.0f;
return 0;
}
} // namespace Fake
#define SDL_GetGamepadFromID Fake::GetGamepadFromID
#define SDL_GetGamepadAxis Fake::GetGamepadAxis
#define SDL_GetTicks Fake::GetTicks
#define SDL_HideCursor Fake::HideCursor
#define SDL_ShowCursor Fake::ShowCursor
#define SDL_GetMouseState Fake::GetMouseState
#include "graphics/presentation/window/hostInput.cpp"
#undef SDL_GetGamepadFromID
#undef SDL_GetGamepadAxis
#undef SDL_GetTicks
#undef SDL_HideCursor
#undef SDL_ShowCursor
#undef SDL_GetMouseState
namespace Libs::Controller {
void SetButton(int id, uint32_t button, bool down) {
if (id == 1) {
guest_buttons = down ? guest_buttons | button : guest_buttons & ~button;
}
}
void SetAxis(int id, Axis axis, int value) {
if (id == 1 && (axis == Axis::LeftX || axis == Axis::LeftY)) {
guest_stick[axis == Axis::LeftY ? 1 : 0] = value;
}
}
void SetTouchPad(int id, int finger, bool down, float, float) {
if (id == 1 && finger == 1) {
guest_touch = down;
}
}
void SetRightStick(int, int, int) {}
void ToggleGyroEmulation(int) {}
void CycleSetting(Setting) {}
} // namespace Libs::Controller
namespace {
using namespace Libs::Graphics;
using namespace Libs::Controller;
struct Inputs {
Inputs() {
physical_stick = {};
guest_stick = {128, 128};
guest_buttons = 0;
guest_touch = false;
HostInputInit(nullptr);
}
~Inputs() { HostInputShutdown(); }
};
void TestTouchPadStickHandoff() {
Inputs inputs;
physical_stick = {SDL_JOYSTICK_AXIS_MAX, SDL_JOYSTICK_AXIS_MIN};
guest_stick = {255, 0};
HostInputToggleGamepadTouchPad(1);
Check(guest_stick == std::array {128, 128}, "entering touchpad mode retained guest movement");
PollGamepadTouchPad(1008);
const auto cursor = GetHostInputTouchPadVisualState();
Check(cursor.x > 0.5f && cursor.y < 0.5f,
"entering touchpad mode did not sample an already-held stick");
Check(HostInputGamepadAxis(1, true, 127) && HostInputGamepadAxis(1, false, 127),
"touchpad mode did not capture stick releases");
Check(guest_stick == std::array {128, 128}, "touchpad movement leaked to guest axes");
// Sample current physical axes on exit even if an event was consumed elsewhere.
physical_stick = {SDL_JOYSTICK_AXIS_MIN, SDL_JOYSTICK_AXIS_MAX};
HostInputToggleGamepadTouchPad(1);
Check(guest_stick == std::array {0, 255}, "leaving touchpad mode did not restore current axes");
Check(!HostInputGamepadAxis(1, true, 127), "leaving touchpad mode retained stick capture");
}
void TestKeyboardSwitchesFromGamepadTouchPad() {
Inputs inputs;
physical_stick = {SDL_JOYSTICK_AXIS_MIN, SDL_JOYSTICK_AXIS_MAX};
HostInputToggleGamepadTouchPad(1);
Check(HostInputGamepadDrawButton(1, true) && guest_touch, "gamepad drawing did not start");
HostInputKey(SDLK_F6, true);
Check(GetHostInputTouchPadVisualState().enabled && !HostInputGamepadAxis(1, true, 127),
"F6 failed to switch from gamepad to mouse touchpad mode");
Check(!guest_touch && guest_stick == std::array {0, 255},
"switching to mouse touchpad mode retained gamepad input capture");
HostInputKey(SDLK_F6, false);
HostInputKey(SDLK_F6, true);
Check(!GetHostInputTouchPadVisualState().enabled, "F6 failed to disable mouse touchpad mode");
}
void TestIndependentTriggerEdges() {
Inputs inputs;
HostInputPadTrigger(2, true, 255);
HostInputPadTrigger(1, true, 255);
Check(guest_buttons == PAD_BUTTON_CROSS, "an inactive pad suppressed the active trigger press");
HostInputPadTrigger(2, true, 0);
HostInputPadTrigger(1, true, 0);
Check(guest_buttons == 0, "an inactive pad suppressed the active trigger release");
HostInputPadTrigger(1, false, 255);
HostInputPadTrigger(1, true, 255);
HostInputPadTrigger(1, true, 0);
Check(guest_buttons == PAD_BUTTON_CIRCLE, "left and right trigger edges were mixed");
HostInputPadTrigger(1, false, 0);
Check(guest_buttons == 0, "right trigger release was lost");
}
void TestTriggerLifecycleReset() {
Inputs inputs;
HostInputPadTrigger(1, true, 255);
HostInputDisconnectGamepad(1);
guest_buttons = 0;
HostInputPadTrigger(1, true, 255);
Check(guest_buttons == PAD_BUTTON_CROSS, "disconnect retained a previous trigger press");
HostInputShutdown();
guest_buttons = 0;
HostInputInit(nullptr);
HostInputPadTrigger(1, true, 255);
Check(guest_buttons == PAD_BUTTON_CROSS, "shutdown retained a previous trigger press");
}
void TestDisabledMappingTabs() {
Config::ConfigOptions options;
const auto south = InputMap::SourceFromButton(SDL_GAMEPAD_BUTTON_SOUTH);
Config::Load(options);
const InputMap defaults;
Check(!defaults.Custom() && defaults.PadControl(south) == ControlFromName("Cross"),
"empty configuration lost its default mappings");
options.keymap = {"Keyboard=Disabled"};
Config::Load(options);
const InputMap keyboard_disabled;
Check(keyboard_disabled.Custom() && keyboard_disabled.FindKey(SDLK_J) == INVALID_CONTROL &&
keyboard_disabled.PadControl(south) == ControlFromName("Cross"),
"disabling keyboard mappings changed gamepad defaults or restored keyboard input");
for (const auto& keymap: {Config::Keymap {"Pad=Disabled"},
Config::Keymap {"Keyboard=Disabled", "Pad=Disabled"}}) {
options.keymap = keymap;
Config::Load(options);
const InputMap disabled;
Check(disabled.Custom() == (keymap.size() == 2),
"disabling gamepad mappings changed keyboard defaults");
for (std::size_t source = 0; source < PAD_SOURCES.size(); source++) {
Check(disabled.PadControl(source) == INVALID_CONTROL,
"a disabled gamepad mapping reverted to its default");
}
}
options.keymap = {"Cross=J", "Pad:Circle=South"};
Config::Load(options);
const InputMap mapped;
Check(mapped.Custom() && mapped.FindKey(SDLK_J) == ControlFromName("Cross") &&
mapped.PadControl(south) == ControlFromName("Circle"),
"explicit mappings no longer override defaults");
}
} // namespace
int main() {
Config::Initialize();
Config::ConfigOptions options;
options.keymap = {"Pad:Cross=LeftTrigger", "Pad:Circle=RightTrigger"};
Config::Load(options);
TestTouchPadStickHandoff();
TestKeyboardSwitchesFromGamepadTouchPad();
TestIndependentTriggerEdges();
TestTriggerLifecycleReset();
TestDisabledMappingTabs();
Config::Shutdown();
return 0;
}
+46 -3
View File
@@ -143,13 +143,13 @@ void DestroyAudioStream(SDL_AudioStream* stream) {
SDL_AudioDeviceID GetAudioStreamDevice(SDL_AudioStream*) {
return 999;
}
// Pads 1 and 3 are DualSenses.
// Pads 1 and 3 are DualSenses; pad 2 only supports rumble.
SDL_GamepadType GetGamepadTypeForID(SDL_JoystickID id) {
return id == 1 || id == 3 ? SDL_GAMEPAD_TYPE_PS5 : SDL_GAMEPAD_TYPE_UNKNOWN;
}
SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) {
return id == 1 || id == 3 ? reinterpret_cast<SDL_Gamepad*>(static_cast<uintptr_t>(id))
: nullptr;
return id >= 1 && id <= 3 ? reinterpret_cast<SDL_Gamepad*>(static_cast<uintptr_t>(id))
: nullptr;
}
SDL_GamepadType GetGamepadType(SDL_Gamepad* pad) {
return GetGamepadTypeForID(static_cast<SDL_JoystickID>(reinterpret_cast<uintptr_t>(pad)));
@@ -458,6 +458,47 @@ void TestDiscoveryAndHotplug() {
Check(streams.empty(), "disconnected endpoint retained its stream");
}
void TestRumbleFallbackGains() {
Fixture f;
auto port = Open();
const auto queue = [&](std::array<float, 2> gains) {
Check(Haptics::Queue(port.get(), 2, pcm.data(), 2, 2, true, unity.data(), 1.0f,
gains.data()) == 2 * 1000000 / 48000,
"rumble fallback playback time is wrong");
};
queue({0.0f, 0.25f});
Check(pad_rumble[2].large == 0 && pad_rumble[2].small == 16383,
"rumble fallback ignored per-channel gains");
queue({0.5f, 0.0f});
Check(pad_rumble[2].large == 32767 && pad_rumble[2].small == 0,
"rumble fallback swapped per-channel gains");
queue({0.0f, 0.0f});
Check(pad_rumble[2].large == 0 && pad_rumble[2].small == 0,
"muted fallback channels still rumbled");
Check(streams.empty() && hid_opens == 0, "rumble fallback opened an audio device");
}
void TestRumbleFallbackSwitch() {
for (bool bluetooth: {false, true}) {
Fixture f;
wireless = hid_available = bluetooth;
auto port = Open();
Queue(port, pcm.data());
Check(bluetooth ? Haptics::UsesBluetooth(port.get()) : streams.size() == 1,
"DualSense transport did not open before fallback");
Haptics::Queue(port.get(), 2, pcm.data(), 2, 2, true, unity.data());
Check(streams.empty() && !Haptics::UsesBluetooth(port.get()) &&
(!bluetooth || hid_closes == 1),
"switching to rumble retained the DualSense transport");
Check(pad_rumble[2].large == 65535 && pad_rumble[2].small == 65535,
"switching from DualSense did not start rumble fallback");
Queue(port, pcm.data());
Check(bluetooth ? Haptics::UsesBluetooth(port.get()) && hid_opens == 2
: streams.size() == 1 && opens == 2,
"switching back delayed reopening the DualSense transport");
}
}
void TestSpeaker() {
Fixture f;
devices = {{10, "DualSense Wireless Controller", 2}};
@@ -1279,6 +1320,8 @@ int main() {
TestAudioDefaultResumeFailure();
TestFormatsAndVolume();
TestDiscoveryAndHotplug();
TestRumbleFallbackGains();
TestRumbleFallbackSwitch();
TestSpeaker();
TestSpeakerUnplug();
TestSpeakerSwitchAndFailures();