diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61b221222..c820aed70 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index ba2d71a7c..21a5e35bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 $) add_test(NAME pad_haptics COMMAND $) add_test(NAME controller_settings COMMAND $) + add_test(NAME host_input COMMAND $) add_test(NAME save_data_memory COMMAND $) add_test(NAME png_enc COMMAND $) add_test(NAME ces COMMAND $) @@ -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" + "$/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) diff --git a/assets/cursors/drawing.png b/assets/cursors/drawing.png new file mode 100644 index 000000000..31bc6e9dd Binary files /dev/null and b/assets/cursors/drawing.png differ diff --git a/assets/cursors/hand.png b/assets/cursors/hand.png new file mode 100644 index 000000000..08f84010d Binary files /dev/null and b/assets/cursors/hand.png differ diff --git a/src/graphics/presentation/systemOverlay.cpp b/src/graphics/presentation/systemOverlay.cpp index 9197644d1..058f64b06 100644 --- a/src/graphics/presentation/systemOverlay.cpp +++ b/src/graphics/presentation/systemOverlay.cpp @@ -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(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 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::numeric_limits::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(encoded.data()), static_cast(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(); + texture->Create(ImTextureFormat_RGBA32, width, height); + std::memcpy(texture->Pixels, pixels, + static_cast(width) * static_cast(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 LoadTrophyIcon(std::span encoded) { if (encoded.empty() || encoded.size() > static_cast(std::numeric_limits::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( + (static_cast(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(now - segment.born).count() >= TRAIL_LIFETIME; + }); + std::erase_if(particles, [now](const auto& particle) { + return std::chrono::duration(now - particle.born).count() >= + PARTICLE_LIFETIME; + }); + for (const auto& segment: trail_segments) { + const float age = + std::chrono::duration(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(105.0f * fade)), + 9.0f * (1.0f - age * 0.5f)); + draw->AddLine(segment.from, segment.to, + IM_COL32(255, 45, 65, static_cast(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(130.0f * fade)), + 9.0f * (1.0f - age * 0.5f)); + draw->AddLine(segment.from, segment.to, + IM_COL32(255, 35, 20, static_cast(235.0f * fade)), + 4.0f * (1.0f - age * 0.5f)); + } else { + draw->AddLine(segment.from, segment.to, + IM_COL32(35, 145, 255, static_cast(180.0f * fade)), + 5.0f * (1.0f - age * 0.5f)); + } + } + + for (const auto& particle: particles) { + const float age = + std::chrono::duration(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(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(255.0f * alpha)); + }; + + if (skin != TouchParticleSkin::Classic) { + const ImU32 glow = + skin == TouchParticleSkin::BlueNeon + ? IM_COL32(0, 115, 255, static_cast(38.0f * alpha)) + : (skin == TouchParticleSkin::RedBlue + ? IM_COL32(75, 100, 255, static_cast(55.0f * alpha)) + : (skin == TouchParticleSkin::Crimson + ? IM_COL32(255, 70, 0, static_cast(70.0f * alpha)) + : IM_COL32(115, 165, 255, static_cast(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(135.0f * alpha)) + : IM_COL32(255, 95, 0, static_cast(135.0f * alpha))) + : (skin == TouchParticleSkin::RedBlue + ? (particle.icon == TouchParticleIcon::Square || + particle.icon == TouchParticleIcon::Cross + ? IM_COL32(255, 40, 65, static_cast(105.0f * alpha)) + : IM_COL32(35, 125, 255, static_cast(105.0f * alpha))) + : IM_COL32(35, 145, 255, + static_cast((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(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(std::max(cursor_image->Height, 1)); + const ImVec2 size {static_cast(cursor_image->Width) * scale, + static_cast(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 trophy_notification_image; + std::unique_ptr hand_cursor_image; + std::unique_ptr 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 particles; + std::vector trail_segments; + static constexpr std::size_t MAX_TOUCH_PARTICLES = 64; + static constexpr std::size_t MAX_TOUCH_TRAIL_SEGMENTS = 128; std::optional trophy_notification; Clock::time_point trophy_notification_born; std::vector> retired_trophy_images; diff --git a/src/graphics/presentation/window/hostInput.cpp b/src/graphics/presentation/window/hostInput.cpp index 980673a2d..fdda4afca 100644 --- a/src/graphics/presentation/window/hostInput.cpp +++ b/src/graphics/presentation/window/hostInput.cpp @@ -12,8 +12,10 @@ #include #include #include +#include #include #include +#include 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> 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::distance(PAD_SOURCES.begin(), item)); + } + [[nodiscard]] std::size_t FindKey(int key_code) const { key_code = NormalizeKey(static_cast(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::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 m_bindings {}; + std::array m_pad_controls = [] { + std::array 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(width), 0.0f, 1.0f); + y = std::clamp(y / static_cast(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(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(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 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(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(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(adjusted) / static_cast(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(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) { diff --git a/src/graphics/presentation/window/hostInput.h b/src/graphics/presentation/window/hostInput.h index 824484650..53e94640b 100644 --- a/src/graphics/presentation/window/hostInput.h +++ b/src/graphics/presentation/window/hostInput.h @@ -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); diff --git a/src/graphics/presentation/window/window.cpp b/src/graphics/presentation/window/window.cpp index a68e22342..8b636720e 100644 --- a/src/graphics/presentation/window/window.cpp +++ b/src/graphics/presentation/window/window.cpp @@ -1,4 +1,4 @@ -#include "graphics/presentation/window.h" +#include "graphics/presentation/window.h" #include @@ -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(mb.x), static_cast(mb.y)); + } + } else if (mb.motion && !mb.touch) { + if (mb.left) { + HostInputMouseTouchPad(true, static_cast(mb.x), static_cast(mb.y)); + } else { + HostInputMouseMotion(static_cast(mb.x), static_cast(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); diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 988194aa4..78b482b21 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -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) diff --git a/src/launcher/include/controllerPreview.h b/src/launcher/include/controllerPreview.h index cf9917fe8..a6a890ee5 100644 --- a/src/launcher/include/controllerPreview.h +++ b/src/launcher/include/controllerPreview.h @@ -24,6 +24,7 @@ public: void SetSuspended(bool suspended); [[nodiscard]] const QSet& Pressed() const { return m_pressed; } + [[nodiscard]] const QSet& 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 m_pressed; + QSet m_raw_pressed; QPointF m_left_stick; QPointF m_right_stick; QString m_device_name; diff --git a/src/launcher/include/inputMappingDialog.h b/src/launcher/include/inputMappingDialog.h index 7b3b22c16..a7728363c 100644 --- a/src/launcher/include/inputMappingDialog.h +++ b/src/launcher/include/inputMappingDialog.h @@ -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 #include +#include #include 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 m_bindings; + QHash m_pad_bindings; QHash m_buttons; + QHash m_pad_buttons; + QSet 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_ */ diff --git a/src/launcher/src/controllerPreview.cpp b/src/launcher/src/controllerPreview.cpp index cabe2894b..1dc3aea66 100644 --- a/src/launcher/src/controllerPreview.cpp +++ b/src/launcher/src/controllerPreview.cpp @@ -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 pressed; + QSet 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; diff --git a/src/launcher/src/inputMappingDialog.cpp b/src/launcher/src/inputMappingDialog.cpp index 4e2a7acc5..d880349cf 100644 --- a/src/launcher/src/inputMappingDialog.cpp +++ b/src/launcher/src/inputMappingDialog.cpp @@ -1,8 +1,10 @@ -#include "inputMappingDialog.h" +#include "inputMappingDialog.h" #include "controllerPreview.h" #include "dualsenseWidget.h" +#include +#include #include #include #include @@ -16,56 +18,113 @@ #include #include #include +#include #include +#include #include 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 m_held; +}; + void AssignBinding(QHash& 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& bindings, const QString& id, const Q QHash ParseMapping(const QStringList& mapping) { QHash 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 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(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(); - 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); + } } diff --git a/src/libs/controller.cpp b/src/libs/controller.cpp index 4c34601a0..5c6c853ab 100644 --- a/src/libs/controller.cpp +++ b/src/libs/controller.cpp @@ -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 m_emulated_stick {128, 128}; // Accelerometer gravity direction in the reset frame. std::array 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(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(Axis::RightX)] = x; m_state.axes[static_cast(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(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(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(Axis::RightX)], + m_state.axes[static_cast(Axis::RightY)]}; + m_state.axes[static_cast(Axis::RightX)] = 128; + m_state.axes[static_cast(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(Axis::RightX)] = m_emulated_stick[0]; + m_state.axes[static_cast(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(); } diff --git a/src/libs/controller.h b/src/libs/controller.h index b84246a18..c53641261 100644 --- a/src/libs/controller.h +++ b/src/libs/controller.h @@ -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(); diff --git a/src/libs/dualSenseHaptics.cpp b/src/libs/dualSenseHaptics.cpp index 9fe4af61c..b884f528e 100644 --- a/src/libs/dualSenseHaptics.cpp +++ b/src/libs/dualSenseHaptics.cpp @@ -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(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(first_frame) + frame) * channels + src_ch; + float value = is_float ? static_cast(data)[index] + : static_cast(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(std::clamp(v * 2.0f, 0.0f, 1.0f) * 65535.0f); + }; + SDL_LockJoysticks(); + if (auto* pad = SDL_GetGamepadFromID(static_cast(controller)); + pad != nullptr) { + (void) SDL_RumbleGamepad(pad, to_motor(peak[0]), to_motor(peak[1]), 100); + } + SDL_UnlockJoysticks(); + return static_cast(frames) * 1000000 / stream->freq; + } const bool wireless = IsWireless(controller); if (!wireless && !CanUseDevice(controller)) { CloseDevice(stream); diff --git a/tests/HostInputTests.cpp b/tests/HostInputTests.cpp new file mode 100644 index 000000000..44b3c7b27 --- /dev/null +++ b/tests/HostInputTests.cpp @@ -0,0 +1,205 @@ +// Exercise production host input with deterministic gamepads and guest output. +#include +#include +#include +#include + +namespace { +void Check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "HostInputTests: %s\n", message); + std::abort(); + } +} + +std::array physical_stick {}; +std::array 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(static_cast(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; +} diff --git a/tests/PadHapticsTests.cpp b/tests/PadHapticsTests.cpp index 47234959c..dfa381802 100644 --- a/tests/PadHapticsTests.cpp +++ b/tests/PadHapticsTests.cpp @@ -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(static_cast(id)) - : nullptr; + return id >= 1 && id <= 3 ? reinterpret_cast(static_cast(id)) + : nullptr; } SDL_GamepadType GetGamepadType(SDL_Gamepad* pad) { return GetGamepadTypeForID(static_cast(reinterpret_cast(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 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();