diff --git a/dist/qt_themes/qdarkstyle_midnight_blue/style.qss b/dist/qt_themes/qdarkstyle_midnight_blue/style.qss index f968ad18b7..17d5dfef1c 100644 --- a/dist/qt_themes/qdarkstyle_midnight_blue/style.qss +++ b/dist/qt_themes/qdarkstyle_midnight_blue/style.qss @@ -375,7 +375,9 @@ QMenuBar::item { } QMenuBar::item:selected { - background: transparent; + /* A hovered menu stays readable */ + background-color: #1464A0; + color: #F0F0F0; border: 0px solid #32414B; } diff --git a/src/common/controller_console_state.h b/src/common/controller_console_state.h new file mode 100644 index 0000000000..5a01cd122b --- /dev/null +++ b/src/common/controller_console_state.h @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/settings.h" + +namespace Common { + +/// State shared between the console-mode engine (in the main window) and the Controllers tab: +/// the UI reads and requests, the engine applies on its next tick. +struct ControllerConsoleState { + std::mutex mutex; + /// Live pads as (display name, identity): possible handheld controls first + std::vector> pads; + /// How many of `pads` could be a handheld's controls (the first ones) + std::size_t builtin_pads = 0; + /// Set by the Identify button: capture the next pad that has a button pressed + bool identify_armed = false; + std::chrono::steady_clock::time_point identify_armed_at{}; + + /// One assigned player, as the Controllers tab lists it + struct PlayerRow { + int player; ///< 0-based player index + std::string name; + bool active; ///< the pad has an input held (or was just tapped) + bool leaving; ///< the controller left and nothing took its place: shown red, then gone + int flash; ///< 0 none, 1 green (a controller took the player), 2 red (one left) + long long flash_since; ///< ms since epoch; the flash blinks for 1.5 seconds from here + std::string profile; ///< the input profile the player's controller carries ("" none) + /// Turn Off: 0 not Bluetooth, 1 not yet (identical ones not told apart), 2 yes + int turn_off = 0; + /// The player its controller started as (-1: this one); a reconnect's guessed player + int origin = -1; + bool guessed = false; + /// The name given to its controller ("" none): `name` is then "Name (Xbox)" + std::string custom_name; + /// Battery percent (-1 unknown), charging, wired + int battery = -1; + bool charging = false; + bool wired = false; + }; + /// True while an automatic assignment mode runs (the engine owns the players) + bool engine_active = false; + /// The players the engine assigned, in player order + std::vector players; + /// The handheld's own pad: its name ("" when not identified) and whether it holds a player + std::string handheld_name; + bool handheld_has_player = false; + /// The Controllers tab asked to remove the handheld's controller (its ✕) + bool builtin_remove_request = false; + /// The removed controller auto-detect leaves alone, by name ("" when not connected) + std::string handheld_blocked_name; + /// Swaps requested from the settings and the overlay, for this session only + std::vector> swap_requests; + /// Players whose Bluetooth controller is to be turned off; the player is freed + std::vector remove_requests; + /// Names given to players' controllers from the Controllers tab ("" takes it off) + std::vector> rename_requests; + /// Players whose controller the Controllers tab asked to rumble (a click on its row) + std::vector rumble_requests; + /// An input profile picked for a player, put on its controller's set. No name takes it off + /// (`restore`: the set's mappings from before this session's first profile come back). + struct ProfileRequest { + int player; + std::string name; + bool restore = false; + }; + std::vector profile_requests; + /// Bypass Steam Input took effect at startup: SDL opens the real controllers + std::atomic steam_bypassed{false}; + /// Whether Steam's virtual pads are around, and the models Steam hid that Eden opened again + /// for their motion (vendor << 16 | product) + std::atomic steam_pads_present{false}; + std::set steam_hidden_models; + /// Bumped when the engine reloads the emulated controllers: the player tabs refresh + unsigned long long generation = 0; + /// Use Any Controller as Joy-Cons: each player playing as a single Joy-Con (0 no, 1 held + /// sideways, 2 upright). Its mapping is the engine's then: the player tab can't edit it. + std::array joycon_held{}; +}; + +[[nodiscard]] inline ControllerConsoleState& GetControllerConsoleState() { + static ControllerConsoleState instance; + return instance; +} + +/// True when Eden was launched through Steam: Steam Input then wraps the controllers in +/// virtual ones +[[nodiscard]] inline bool RunningUnderSteam() { + static const bool launched = + std::getenv("SteamGameId") != nullptr || std::getenv("SteamAppId") != nullptr; + return launched; +} + +/// True when Steam Input's virtual pads are the players: under Steam, not bypassed, with +/// Steam's pads around. Otherwise the real controllers are, as outside Steam. +[[nodiscard]] inline bool SteamPadsInUse() { + const auto& state = GetControllerConsoleState(); + return RunningUnderSteam() && !state.steam_bypassed && state.steam_pads_present; +} + +/// True for a controller model Steam hid that Eden opened again for its motion (never a player) +[[nodiscard]] inline bool SteamHidModel(unsigned vendor, unsigned product) { + auto& state = GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + return state.steam_hidden_models.contains(vendor << 16 | product); +} + +/// Debug Probes: detailed controller traces in the log, for bug reports +[[nodiscard]] inline bool ControllerProbes() { + return Settings::values.console_debug_probes.GetValue(); +} + +} // namespace Common diff --git a/src/common/overlay_panels.h b/src/common/overlay_panels.h new file mode 100644 index 0000000000..7cd13eb3e6 --- /dev/null +++ b/src/common/overlay_panels.h @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" + +namespace Common { + +/// The screen corner a panel is placed against +enum class OverlayAnchor { + TopLeft, + BottomLeft, + TopRight, +}; + +/// An image drawn over the game inside the presented frame, so it also shows under gamescope, +/// where no Qt widget can composite over the game. The frontend fills it (RGBA8); the presenter +/// blends it in last. +struct OverlayPanel { + std::mutex mutex; + std::vector pixels; ///< RGBA8, width * height * 4 + u32 width = 0; + u32 height = 0; + bool enabled = false; + /// Incremented on every push so presenters can cache by version + u64 version = 0; + OverlayAnchor anchor = OverlayAnchor::TopLeft; + /// A top-anchored panel sits this share of the screen's height lower (clear of other + /// overlays in the corner) + float drop = 0.0f; +}; + +/// The panels, in drawing order +enum class OverlayPanelId : std::size_t { + Players, ///< The controller players (top left) + Hints, ///< The controller hints (bottom left) + Cheats, ///< The cheats (top right) + Count, +}; + +inline constexpr std::size_t OverlayPanelCount = static_cast(OverlayPanelId::Count); + +[[nodiscard]] inline std::array& GetOverlayPanels() { + static std::array panels; + static const bool placed = [] { + panels[static_cast(OverlayPanelId::Hints)].anchor = + OverlayAnchor::BottomLeft; + panels[static_cast(OverlayPanelId::Cheats)].anchor = + OverlayAnchor::TopRight; + return true; + }(); + (void)placed; + return panels; +} + +[[nodiscard]] inline OverlayPanel& GetOverlayPanel(OverlayPanelId id) { + return GetOverlayPanels()[static_cast(id)]; +} + +/// A panel's place, in the pixels of the screen area the game is drawn in +struct OverlayRect { + float x; + float y; + float width; + float height; +}; + +/// Where a panel goes on a screen area (left, top, right, bottom), for both the presenter and +/// the mouse handling. Whole pixels, or the image's edge blends into a dark line. Call with the +/// panel's mutex held. +[[nodiscard]] inline OverlayRect PlaceOverlayPanel(const OverlayPanel& panel, float left, + float top, float right, float bottom) { + constexpr float margin = 4.0f; + const float width = static_cast(panel.width); + const float height = static_cast(panel.height); + const float x = panel.anchor == OverlayAnchor::TopRight ? right - width - margin + : left + margin; + const float y = panel.anchor == OverlayAnchor::BottomLeft + ? bottom - height - margin + : top + margin + (bottom - top) * panel.drop; + return {std::floor(x), std::floor(y), width, height}; +} + +/// The width a top-left panel has before it reaches the panels shown at the top right. Takes +/// the other panels' mutexes: never call it with one held. +[[nodiscard]] inline float OverlayRoomTopLeft(float screen_width) { + constexpr float margin = 4.0f; + constexpr float apart = 8.0f; + float right_taken = 0.0f; + for (auto& panel : GetOverlayPanels()) { + std::scoped_lock lock{panel.mutex}; + if (panel.enabled && panel.anchor == OverlayAnchor::TopRight) { + right_taken = (std::max)(right_taken, static_cast(panel.width) + margin + apart); + } + } + return screen_width - margin - right_taken; +} + +} // namespace Common diff --git a/src/common/settings.h b/src/common/settings.h index e611759429..427850c3d2 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -202,6 +202,66 @@ struct Values { // Core SwitchableSetting use_multi_core{linkage, true, "use_multi_core", Category::Core}; + + /// Show the controller debug overlay (the real pad state plus the logic's actions) + SwitchableSetting console_debug_overlay{linkage, true, "console_debug_overlay", + Category::LibraryApplet, + Specialization::Default, true, true}; + /// Modern Console: each player's starting input profile, comma-separated in player order + Setting modern_seat_profiles{linkage, std::string{}, "modern_seat_profiles", + Category::LibraryApplet}; + /// Name a controller on screen for a moment when it takes a player or leaves one + SwitchableSetting console_hints{linkage, true, "console_hints", Category::LibraryApplet, + Specialization::Default, true, true}; + /// The overlay's button history: shown, how many inputs it keeps, how long each lasts (in + /// seconds), and the time between inputs + SwitchableSetting console_history{linkage, true, "console_history", + Category::LibraryApplet}; + SwitchableSetting console_history_size{ + linkage, 10, 5, 20, "console_history_size", Category::LibraryApplet}; + SwitchableSetting console_history_seconds{ + linkage, 5, 1, 30, "console_history_seconds", Category::LibraryApplet}; + SwitchableSetting console_history_gaps{linkage, false, "console_history_gaps", + Category::LibraryApplet}; + /// Under Steam: Eden reads the real controllers instead of Steam's virtual ones. At startup. + SwitchableSetting console_bypass_steam{linkage, false, "console_bypass_steam", + Category::LibraryApplet}; + /// Modern Console: when a game only takes Joy-Cons, controllers act as Joy-Cons + SwitchableSetting console_joycon_auto{linkage, true, "console_joycon_auto", + Category::LibraryApplet}; + /// Modern Console: pause when a player's controller is turned off or gone for 3 seconds, + /// resume when a controller takes the player again + SwitchableSetting console_pause_disconnect{linkage, true, "console_pause_disconnect", + Category::LibraryApplet}; + /// Modern Console: controllers' player lights and light bars show their player + SwitchableSetting console_player_lights{linkage, true, "console_player_lights", + Category::LibraryApplet}; + /// Modern Console: Xbox and PlayStation style buttons by label instead of position + SwitchableSetting console_button_labels{linkage, false, "console_button_labels", + Category::LibraryApplet}; + /// Detailed controller traces in the log, for bug reports + SwitchableSetting console_debug_probes{linkage, false, "console_debug_probes", + Category::LibraryApplet}; + + /// The user picked the handheld's own pad (picker or Identify): auto-detect keeps it + SwitchableSetting builtin_pad_manual{linkage, false, "builtin_pad_manual", + Category::Core}; + + /// The handheld's own pad identity, latched when Handheld Console Mode engages + SwitchableSetting builtin_pad_identity{linkage, std::string{}, + "builtin_pad_identity", Category::Core}; + + /// A controller the user removed as the handheld's own (Steam handle, else identity): + /// auto-detect skips it until it is allowed again + SwitchableSetting builtin_pad_blocked{linkage, std::string{}, + "builtin_pad_blocked", Category::Core}; + /// Under Steam, the handheld's own pad's Steam handle: stable across sessions, unlike the + /// virtual pads' shared GUID + SwitchableSetting builtin_pad_steam_handle{ + linkage, std::string{}, "builtin_pad_steam_handle", Category::Core}; + /// The user picked the Steam handle (picker or Identify): kept whatever the pad reports + SwitchableSetting builtin_pad_steam_manual{linkage, false, "builtin_pad_steam_manual", + Category::Core}; SwitchableSetting memory_layout_mode{linkage, MemoryLayout::Memory_4Gb, "memory_layout_mode", diff --git a/src/common/settings_enums.h b/src/common/settings_enums.h index eaf3ec541d..aba53e918b 100644 --- a/src/common/settings_enums.h +++ b/src/common/settings_enums.h @@ -150,7 +150,7 @@ ENUM(ScalingFilter, NearestNeighbor, Bilinear, Bicubic, Gaussian, Lanczos, Scale ENUM(AntiAliasing, None, Fxaa, Smaa); ENUM(AspectRatio, R16_9, R4_3, R21_9, R16_10, Stretch); ENUM(ConsoleMode, Handheld, Docked); -ENUM(AppletMode, HLE, LLE, Disabled); +ENUM(AppletMode, HLE, LLE, Disabled, ConsoleMode, HandheldConsoleMode); ENUM(SpirvOptimizeMode, Never, OnLoad, Always); ENUM(GpuClock, Normal, Boost, Overclock) ENUM(GpuUnswizzleSize, VerySmall, Small, Normal, Large, VeryLarge) diff --git a/src/hid_core/hid_core.cpp b/src/hid_core/hid_core.cpp index cbc193b741..fcf87d6d63 100644 --- a/src/hid_core/hid_core.cpp +++ b/src/hid_core/hid_core.cpp @@ -130,6 +130,14 @@ NpadStyleTag HIDCore::GetSupportedStyleTag() const { return supported_style_tag; } +void HIDCore::SetJoyHoldHorizontal(bool horizontal) { + joy_hold_horizontal = horizontal; +} + +bool HIDCore::IsJoyHoldHorizontal() const { + return joy_hold_horizontal; +} + s8 HIDCore::GetPlayerCount() const { s8 active_players = 0; for (std::size_t player_index = 0; player_index < available_controllers - 2; ++player_index) { @@ -196,6 +204,7 @@ void HIDCore::DisableAllControllerConfiguration() { } void HIDCore::ReloadInputDevices() { + std::scoped_lock lock{reload_mutex}; player_1->ReloadFromSettings(); player_2->ReloadFromSettings(); player_3->ReloadFromSettings(); diff --git a/src/hid_core/hid_core.h b/src/hid_core/hid_core.h index 7da11897ca..1cd3427724 100644 --- a/src/hid_core/hid_core.h +++ b/src/hid_core/hid_core.h @@ -6,7 +6,9 @@ #pragma once +#include #include +#include #include "common/common_funcs.h" #include "hid_core/hid_types.h" @@ -46,6 +48,10 @@ public: void SetSupportedStyleTag(NpadStyleTag style_tag); NpadStyleTag GetSupportedStyleTag() const; + /// How the running game holds a single Joy-Con: sideways (horizontal) or upright (the default) + void SetJoyHoldHorizontal(bool horizontal); + bool IsJoyHoldHorizontal() const; + /// Counts the connected players from P1-P8 s8 GetPlayerCount() const; @@ -76,6 +82,9 @@ public: /// Number of emulated controllers static constexpr std::size_t available_controllers{10}; + /// Reloads come from the HID service (starting a game) and the frontend (settings, the + /// console modes): one at a time, or two threads rebuild the same controllers + std::mutex reload_mutex; std::unique_ptr player_1; std::unique_ptr player_2; std::unique_ptr player_3; @@ -90,6 +99,7 @@ public: std::unique_ptr devices; Kernel::KernelCore& kernel; NpadStyleTag supported_style_tag{NpadStyleSet::All}; + std::atomic joy_hold_horizontal{false}; NpadIdType last_active_controller{NpadIdType::Handheld}; }; diff --git a/src/hid_core/resources/npad/npad.cpp b/src/hid_core/resources/npad/npad.cpp index 2b04e2c670..485f4a8f80 100644 --- a/src/hid_core/resources/npad/npad.cpp +++ b/src/hid_core/resources/npad/npad.cpp @@ -45,6 +45,8 @@ NPad::NPad(Core::HID::HIDCore& hid_core_, KernelHelpers::ServiceContext& service AbstractPad{hid_core_.kernel}, }} { + // (A new game holds its Joy-Cons upright until it says otherwise) + hid_core.SetJoyHoldHorizontal(false); for (std::size_t aruid_index = 0; aruid_index < AruidIndexMax; ++aruid_index) { for (std::size_t i = 0; i < controller_data[aruid_index].size(); ++i) { auto& controller = controller_data[aruid_index][i]; @@ -149,6 +151,18 @@ void NPad::ControllerUpdate(Kernel::KernelCore& kernel, Core::HID::ControllerTri ControllerUpdate(kernel, Core::HID::ControllerTriggerType::Battery, controller_idx); return; } + // Only connection and battery changes concern the npad here. Motion arrives hundreds of + // times a second from its own thread (a DSU server's socket), and the callbacks are live + // from the constructor on, before SetNpadExternals provides the applet resource: an update + // in that window during boot dereferenced a null applet resource (SIGSEGV). + if (type != Core::HID::ControllerTriggerType::Connected && + type != Core::HID::ControllerTriggerType::Disconnected && + type != Core::HID::ControllerTriggerType::Battery) { + return; + } + if (!applet_resource_holder.applet_resource) { + return; + } for (std::size_t aruid_index = 0; aruid_index < AruidIndexMax; aruid_index++) { if (controller_idx >= controller_data[aruid_index].size()) { @@ -703,7 +717,11 @@ Result NPad::SetSupportedNpadIdType(Kernel::KernelCore& kernel, u64 aruid, std:: Result NPad::SetNpadJoyHoldType(u64 aruid, NpadJoyHoldType hold_type) { std::scoped_lock lock{mutex}; - return npad_resource.SetNpadJoyHoldType(aruid, hold_type); + const Result result = npad_resource.SetNpadJoyHoldType(aruid, hold_type); + if (result.IsSuccess()) { + hid_core.SetJoyHoldHorizontal(hold_type == NpadJoyHoldType::Horizontal); + } + return result; } Result NPad::GetNpadJoyHoldType(u64 aruid, NpadJoyHoldType& out_hold_type) const { diff --git a/src/input_common/drivers/sdl_driver.cpp b/src/input_common/drivers/sdl_driver.cpp index c3e9a066b3..588d7fa8cd 100644 --- a/src/input_common/drivers/sdl_driver.cpp +++ b/src/input_common/drivers/sdl_driver.cpp @@ -3,8 +3,19 @@ // SPDX-FileCopyrightText: 2018 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include "common/controller_console_state.h" #include "common/logging.h" #include "common/math_util.h" #include "common/param_package.h" @@ -450,6 +461,29 @@ std::shared_ptr SDLDriver::GetSDLJoystickBySDLID(SDL_JoystickID sdl return *vec_it; } +std::optional> SDLDriver::GetBindingForSDLID(SDL_JoystickID sdl_id) { + const auto joystick = GetSDLJoystickBySDLID(sdl_id); + if (!joystick) { + return std::nullopt; + } + return std::pair{joystick->GetGUID().RawString(), joystick->GetPort()}; +} + +void SDLDriver::MarkActivity(SDL_JoystickID sdl_id) { + std::scoped_lock lock{activity_mutex}; + last_activity[sdl_id] = std::chrono::steady_clock::now(); +} + +std::optional SDLDriver::GetLastActivity( + SDL_JoystickID sdl_id) const { + std::scoped_lock lock{activity_mutex}; + const auto it = last_activity.find(sdl_id); + if (it == last_activity.end()) { + return std::nullopt; + } + return it->second; +} + std::shared_ptr SDLDriver::GetSDLJoystickByGamepadID(SDL_JoystickID sdl_id) { auto* const sdl_gamepad = SDL_GetGamepadFromID(sdl_id); if (sdl_gamepad == nullptr) { @@ -535,6 +569,11 @@ void SDLDriver::InitJoystick(SDL_JoystickID joystick_id) { [](const auto& joystick) { return !joystick->GetSDLJoystick(); }); if (joystick_it != joystick_guid_list.end()) { + const int port = static_cast(joystick_it - joystick_guid_list.begin()); + if (Common::ControllerProbes()) { + LOG_INFO(Input, "input: attach guid={} port={} name={} (reusing a freed slot)", + guid.RawString(), port, SDL_GetJoystickName(sdl_joystick)); + } (*joystick_it)->SetSDLJoystick(sdl_joystick, sdl_gamecontroller); (*joystick_it)->EnableMotion(); SetBattery((*joystick_it)->GetPadIdentifier(), @@ -563,6 +602,10 @@ void SDLDriver::CloseJoystick(SDL_Joystick* sdl_joystick) { }); if (joystick_it != joystick_guid_list.end()) { + if (Common::ControllerProbes()) { + LOG_INFO(Input, "input: detach guid={} port={}", guid.RawString(), + static_cast(joystick_it - joystick_guid_list.begin())); + } (*joystick_it)->SetSDLJoystick(nullptr, nullptr); } } @@ -583,6 +626,7 @@ void SDLDriver::HandleGameControllerEvent(const SDL_Event& event) { break; } case SDL_EVENT_JOYSTICK_BUTTON_DOWN: { + MarkActivity(event.jbutton.which); if (const auto joystick = GetSDLJoystickBySDLID(event.jbutton.which)) { const PadIdentifier identifier = joystick->GetPadIdentifier(); SetButton(identifier, event.jbutton.button, true); @@ -590,6 +634,9 @@ void SDLDriver::HandleGameControllerEvent(const SDL_Event& event) { break; } case SDL_EVENT_JOYSTICK_HAT_MOTION: { + if (event.jhat.value != SDL_HAT_CENTERED) { + MarkActivity(event.jhat.which); + } if (const auto joystick = GetSDLJoystickBySDLID(event.jhat.which)) { const PadIdentifier identifier = joystick->GetPadIdentifier(); SetHatButton(identifier, event.jhat.hat, event.jhat.value); @@ -597,8 +644,20 @@ void SDLDriver::HandleGameControllerEvent(const SDL_Event& event) { break; } case SDL_EVENT_JOYSTICK_AXIS_MOTION: { - if (const auto joystick = GetSDLJoystickBySDLID(event.jaxis.which)) { - const PadIdentifier identifier = joystick->GetPadIdentifier(); + // Half travel, so resting drift never counts. A trigger rests at one end of its range + // (-32768 here), so for it half travel is past the middle. + Sint16 rest = 0; + const auto moved = GetSDLJoystickBySDLID(event.jaxis.which); + SDL_Joystick* const moved_js = moved ? moved->GetSDLJoystick() : nullptr; + const bool trigger = + moved_js && SDL_GetJoystickAxisInitialState(moved_js, event.jaxis.axis, &rest) && + rest < -16384; + if (trigger ? event.jaxis.value > 0 + : event.jaxis.value > 16384 || event.jaxis.value < -16384) { + MarkActivity(event.jaxis.which); + } + if (moved) { + const PadIdentifier identifier = moved->GetPadIdentifier(); SetAxis(identifier, event.jaxis.axis, event.jaxis.value / 32767.0f); } break; @@ -640,6 +699,152 @@ void SDLDriver::CloseJoysticks() { joystick_map.clear(); } +namespace { + +/// Controllers with motion sensors that Steam Input hides behind its sensorless virtual pads +bool IsMotionController(unsigned vendor, unsigned product) { + switch (vendor) { + case 0x28DE: + // The Steam Deck only: SDL's driver would talk to a Steam Controller alongside Steam + // (Bypass Steam Input gives SDL Steam Controllers, motion included) + return product == 0x1205; + case 0x054C: // Sony + case 0x057E: // Nintendo + return true; + default: + return false; + } +} + +/// Controllers Steam Input hid that Eden opened again for their motion (vendor << 16 | product). +/// Motion sources only: never offered as input devices. +std::set& UnhiddenControllers() { + static std::set unhidden; + return unhidden; +} + +/// Logs the system's motion sensors and HID devices once (Debug Probes), to identify handhelds +/// from users' logs: a built-in gyro is often a system sensor (IIO) that SDL never sees. +void LogMotionHardware() { +#ifdef __linux__ + if (!Common::ControllerProbes()) { + return; + } + namespace fs = std::filesystem; + const auto read_line = [](const fs::path& path) { + std::ifstream file{path}; + std::string line; + std::getline(file, line); + return line; + }; + std::error_code error; + std::string sensors; + // (No exceptions: a device can disappear while it is read) + for (fs::directory_iterator it{"/sys/bus/iio/devices", error}, end; !error && it != end; + it.increment(error)) { + const fs::path dir = it->path(); + std::error_code ignored; + const bool gyro = fs::exists(dir / "in_anglvel_x_raw", ignored); + const bool accel = fs::exists(dir / "in_accel_x_raw", ignored); + if (!gyro && !accel) { + continue; + } + std::string mount = read_line(dir / "in_anglvel_mount_matrix"); + if (mount.empty()) { + mount = read_line(dir / "in_accel_mount_matrix"); + } + const fs::path device = fs::canonical(dir / "device", ignored); + sensors += fmt::format("\n {} name={} gyro={} accel={} mount=[{}] at={}", + dir.filename().string(), read_line(dir / "name"), gyro, accel, mount, + ignored ? std::string{"?"} : device.string()); + } + LOG_INFO(Input, "Motion sensors of the system (IIO):{}", + sensors.empty() ? std::string{" none"} : sensors); + + std::string hid; + error.clear(); + for (fs::directory_iterator it{"/sys/class/hidraw", error}, end; !error && it != end; + it.increment(error)) { + const auto& entry = *it; + std::ifstream uevent{entry.path() / "device" / "uevent"}; + std::string line; + std::string id; + std::string name; + while (std::getline(uevent, line)) { + if (line.starts_with("HID_ID=")) { + // bus:vendor:product, each zero-padded to 8 hex digits + unsigned bus = 0, vendor = 0, product = 0; + if (std::sscanf(line.c_str() + 7, "%x:%x:%x", &bus, &vendor, &product) == 3) { + id = fmt::format("{:04x}:{:04x} bus={}", vendor, product, bus); + } + } else if (line.starts_with("HID_NAME=")) { + name = line.substr(9); + } + } + hid += fmt::format("\n {} {} name={}", entry.path().filename().string(), id, name); + } + LOG_INFO(Input, "HID devices:{}", hid.empty() ? std::string{" none"} : hid); +#endif +} + +/// A controller Eden opened again under Steam Input for its motion alone +bool IsUnhiddenController(SDL_Joystick* joystick) { + const u32 id = + static_cast(SDL_GetJoystickVendor(joystick)) << 16 | SDL_GetJoystickProduct(joystick); + return UnhiddenControllers().contains(id); +} + +/// Under Steam, takes the motion controllers off Steam's hide list, so SDL also opens the real +/// devices next to Steam's sensorless virtual pads +void UnhideMotionControllers() { + if (!Common::RunningUnderSteam()) { + return; + } + const char* env = std::getenv("SDL_GAMECONTROLLER_IGNORE_DEVICES"); + std::string list = env ? env : ""; + if (list.starts_with('@')) { + std::ifstream file{list.substr(1)}; + list.assign(std::istreambuf_iterator{file}, std::istreambuf_iterator{}); + } + std::string kept; + std::string dropped; + std::stringstream entries{list}; + for (std::string entry; std::getline(entries, entry, ',');) { + entry.erase(std::remove_if(entry.begin(), entry.end(), + [](unsigned char c) { return std::isspace(c); }), + entry.end()); + unsigned vendor = 0; + unsigned product = 0; + if (entry.empty()) { + continue; + } + if (std::sscanf(entry.c_str(), "%x/%x", &vendor, &product) == 2 && + IsMotionController(vendor, product)) { + dropped += entry + ','; + UnhiddenControllers().insert(vendor << 16 | product); + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.steam_hidden_models.insert(vendor << 16 | product); + } + continue; + } + kept += entry + ','; + } + if (!kept.empty()) { + kept.pop_back(); + } + if (Common::ControllerProbes()) { + LOG_INFO(Input, "Steam Input: opening motion controllers for their sensors: {}", + dropped.empty() ? std::string{"none hidden"} : dropped); + } + SDL_SetHintWithPriority(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, kept.c_str(), + SDL_HINT_OVERRIDE); + SDL_SetHintWithPriority(SDL_HINT_JOYSTICK_HIDAPI_STEAMDECK, "1", SDL_HINT_OVERRIDE); +} + +} // Anonymous namespace + SDLDriver::SDLDriver(std::string input_engine_) : InputEngine(std::move(input_engine_)) { // Set our application name. Currently passed to DBus by SDL and visible to the user through // their desktop environment. @@ -657,6 +862,24 @@ SDLDriver::SDLDriver(std::string input_engine_) : InputEngine(std::move(input_en // SDL3 defaults Steam Controller Bluetooth HIDAPI support to off, which can disable gyro. SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1"); + // Bypass Steam Input (Modern Console, under Steam): SDL takes every controller Steam hides, + // and the engine uses those instead of Steam's virtual pads + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + if (Common::RunningUnderSteam() && automatic && + Settings::values.console_bypass_steam.GetValue()) { + SDL_SetHintWithPriority(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, "", SDL_HINT_OVERRIDE); + SDL_SetHintWithPriority(SDL_HINT_JOYSTICK_HIDAPI_STEAMDECK, "1", SDL_HINT_OVERRIDE); + Common::GetControllerConsoleState().steam_bypassed = true; + if (Common::ControllerProbes()) { + LOG_INFO(Input, "Steam Input bypassed: SDL opens every controller"); + } + } else if (automatic) { + // (Only the automatic modes read their motion) + UnhideMotionControllers(); + } + LogMotionHardware(); SDL_SetHint(SDL_HINT_GAMECONTROLLER_SENSOR_FUSION, "1"); SDL_SetHint(SDL_HINT_AUTO_UPDATE_SENSORS, "1"); @@ -727,8 +950,17 @@ std::vector SDLDriver::GetInputDevices() const { if (!joystick->GetSDLJoystick()) { continue; } + // Steam's virtual pads carry the real controller's name: mark them. Their GUID + // keeps Steam's ids (Valve 0x28DE, virtual gamepad 0x11FF, at bytes 4 and 8). + const SDL_GUID sdl_guid = SDL_GetJoystickGUID(joystick->GetSDLJoystick()); + const bool steam_virtual = sdl_guid.data[4] == 0xDE && sdl_guid.data[5] == 0x28 && + sdl_guid.data[8] == 0xFF && sdl_guid.data[9] == 0x11; + if (!steam_virtual && IsUnhiddenController(joystick->GetSDLJoystick())) { + continue; + } const std::string name = - fmt::format("{} {}", joystick->GetControllerName(), joystick->GetPort()); + fmt::format("{} {}{}", joystick->GetControllerName(), joystick->GetPort(), + steam_virtual ? " (Steam)" : ""); devices.emplace_back(Common::ParamPackage{ {"engine", GetEngineName()}, {"display", std::move(name)}, @@ -744,7 +976,8 @@ std::vector SDLDriver::GetInputDevices() const { // Add dual controllers for (const auto& [key, value] : joystick_map) { for (const auto& joystick : value) { - if (joystick->IsJoyconRight()) { + if (joystick->IsJoyconRight() && joystick->GetSDLJoystick() && + !IsUnhiddenController(joystick->GetSDLJoystick())) { if (!joycon_pairs.contains(joystick->GetPort())) { continue; } diff --git a/src/input_common/drivers/sdl_driver.h b/src/input_common/drivers/sdl_driver.h index e6751b6688..d97811f9fc 100644 --- a/src/input_common/drivers/sdl_driver.h +++ b/src/input_common/drivers/sdl_driver.h @@ -7,8 +7,12 @@ #pragma once #include +#include #include +#include +#include #include +#include #include "common/container/unordered_map.h" #include @@ -45,6 +49,15 @@ public: std::shared_ptr GetSDLJoystickBySDLID(SDL_JoystickID sdl_id); std::shared_ptr GetSDLJoystickByGamepadID(SDL_JoystickID sdl_id); + /// The guid and port that bindings use to address this SDL instance, if the driver has it + /// open. Must not be called while holding SDL_LockJoysticks (the driver locks its map). + std::optional> GetBindingForSDLID(SDL_JoystickID sdl_id); + + /// When this SDL instance last had a deliberate input: a button or d-pad press, or a + /// stick or trigger past half travel (not drift, not motion) + std::optional GetLastActivity( + SDL_JoystickID sdl_id) const; + /** * Check how many identical joysticks (by guid) were connected before the one with sdl_id and so * tie it to a SDLJoystick with the same guid and that port @@ -117,6 +130,10 @@ private: ::Common::unordered_map>> joystick_map; std::mutex joystick_map_mutex; + void MarkActivity(SDL_JoystickID sdl_id); + std::unordered_map last_activity; + mutable std::mutex activity_mutex; + std::atomic initialized = false; bool start_thread; }; diff --git a/src/input_common/helpers/stick_from_buttons.cpp b/src/input_common/helpers/stick_from_buttons.cpp index 576fd0244c..6c5ad4e2c1 100644 --- a/src/input_common/helpers/stick_from_buttons.cpp +++ b/src/input_common/helpers/stick_from_buttons.cpp @@ -25,10 +25,25 @@ public: using Button = std::unique_ptr; Stick(Button up_, Button down_, Button left_, Button right_, Button modifier_, Button updater_, - float modifier_scale_, float modifier_angle_) + Button stick_, float modifier_scale_, float modifier_angle_) : up(std::move(up_)), down(std::move(down_)), left(std::move(left_)), right(std::move(right_)), modifier(std::move(modifier_)), updater(std::move(updater_)), - modifier_scale(modifier_scale_), modifier_angle(modifier_angle_) { + stick(std::move(stick_)), modifier_scale(modifier_scale_), + modifier_angle(modifier_angle_) { + // A real stick alongside the buttons: it moves the stick while no button is held + stick_status.x.properties = properties; + stick_status.y.properties = properties; + if (stick) { + stick->SetCallback({ + .on_change = + [this](const Common::Input::CallbackStatus& callback_) { + stick_status = callback_.stick_status; + if (amplitude == 0) { + SoftUpdate(); + } + }, + }); + } up->SetCallback({ .on_change = [this](const Common::Input::CallbackStatus& callback_) { @@ -220,6 +235,9 @@ public: left->ForceUpdate(); right->ForceUpdate(); modifier->ForceUpdate(); + if (stick) { + stick->ForceUpdate(); + } } void SoftUpdate() { @@ -237,6 +255,9 @@ public: } Common::Input::StickStatus GetStatus() const { + if (stick && amplitude == 0) { + return stick_status; + } Common::Input::StickStatus status{}; status.x.properties = properties; status.y.properties = properties; @@ -270,6 +291,8 @@ private: Button right; Button modifier; Button updater; + Button stick; + Common::Input::StickStatus stick_status{}; float modifier_scale{}; float modifier_angle{}; float angle{}; @@ -296,9 +319,13 @@ std::unique_ptr StickFromButton::Create( auto updater = Common::Input::CreateInputDeviceFromString("engine:updater,button:0"); auto modifier_scale = params.Get("modifier_scale", 0.5f); auto modifier_angle = params.Get("modifier_angle", 5.5f); + // (Optional: a real stick the buttons move alongside) + Stick::Button stick = params.Has("stick") + ? Common::Input::CreateInputDeviceFromString(params.Get("stick", "")) + : nullptr; return std::make_unique(std::move(up), std::move(down), std::move(left), std::move(right), std::move(modifier), std::move(updater), - modifier_scale, modifier_angle); + std::move(stick), modifier_scale, modifier_angle); } } // namespace InputCommon diff --git a/src/input_common/input_poller.cpp b/src/input_common/input_poller.cpp index 188e862d7f..bbabbb9d70 100644 --- a/src/input_common/input_poller.cpp +++ b/src/input_common/input_poller.cpp @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -557,9 +559,9 @@ private: class InputFromMotion final : public Common::Input::InputDevice { public: explicit InputFromMotion(PadIdentifier identifier_, int motion_sensor_, float gyro_threshold_, - InputEngine* input_engine_) + int turn_, InputEngine* input_engine_) : identifier(identifier_), motion_sensor(motion_sensor_), gyro_threshold(gyro_threshold_), - input_engine(input_engine_) { + turn(turn_), input_engine(input_engine_) { UpdateCallback engine_callback{[this]() { OnChange(); }}; const InputIdentifier input_identifier{ .identifier = identifier, @@ -589,6 +591,17 @@ public: status.gyro.x = {.raw_value = basic_motion.gyro_x, .properties = properties}; status.gyro.y = {.raw_value = basic_motion.gyro_y, .properties = properties}; status.gyro.z = {.raw_value = basic_motion.gyro_z, .properties = properties}; + // Turned a quarter about the face for a controller standing in for a sideways Joy-Con + // (1: clockwise, as a right one; -1: the other way): the Joy-Con's x is the + // controller's -y, its y the controller's x (the left one: y, and -x) + if (turn != 0) { + for (auto* axes : {&status.accel, &status.gyro}) { + const float x = axes->x.raw_value; + const float y = axes->y.raw_value; + axes->x.raw_value = turn > 0 ? -y : y; + axes->y.raw_value = turn > 0 ? x : -x; + } + } status.delta_timestamp = basic_motion.delta_timestamp; return status; } @@ -606,6 +619,7 @@ private: const PadIdentifier identifier; const int motion_sensor; const float gyro_threshold; + const int turn; int callback_key; InputEngine* input_engine; }; @@ -1069,9 +1083,10 @@ std::unique_ptr InputFactory::CreateMotionDevice( if (params.Has("motion")) { const auto motion_sensor = params.Get("motion", 0); const auto gyro_threshold = params.Get("threshold", 0.007f); + const auto turn = std::clamp(params.Get("turn", 0), -1, 1); input_engine->PreSetController(identifier); input_engine->PreSetMotion(identifier, motion_sensor); - return std::make_unique(identifier, motion_sensor, gyro_threshold, + return std::make_unique(identifier, motion_sensor, gyro_threshold, turn, input_engine.get()); } diff --git a/src/input_common/main.cpp b/src/input_common/main.cpp index 6c8443ff62..765d44fdd7 100644 --- a/src/input_common/main.cpp +++ b/src/input_common/main.cpp @@ -442,6 +442,23 @@ std::vector InputSubsystem::GetInputDevices() const { return impl->GetInputDevices(); } +std::optional> InputSubsystem::GetSDLBinding(u32 sdl_instance) const { +#ifdef HAVE_SDL3 + return impl->sdl->GetBindingForSDLID(static_cast(sdl_instance)); +#else + return std::nullopt; +#endif +} + +std::optional InputSubsystem::GetSDLLastActivity( + u32 sdl_instance) const { +#ifdef HAVE_SDL3 + return impl->sdl->GetLastActivity(static_cast(sdl_instance)); +#else + return std::nullopt; +#endif +} + AnalogMapping InputSubsystem::GetAnalogMappingForDevice(const Common::ParamPackage& device) const { return impl->GetAnalogMappingForDevice(device); } diff --git a/src/input_common/main.h b/src/input_common/main.h index 3b33ef30eb..cf9bffb414 100644 --- a/src/input_common/main.h +++ b/src/input_common/main.h @@ -6,10 +6,14 @@ #pragma once +#include #include +#include #include -#include "common/container/unordered_map.h" +#include #include +#include "common/common_types.h" +#include "common/container/unordered_map.h" namespace Common { class ParamPackage; @@ -152,6 +156,13 @@ public: /// Returns true if axis of a stick aren't mapped in the correct direction [[nodiscard]] bool IsStickInverted(const Common::ParamPackage& device) const; + /// The SDL guid and port that bindings use for an SDL joystick instance, if SDL has it open. + [[nodiscard]] std::optional> GetSDLBinding(u32 sdl_instance) const; + + /// When an SDL joystick last had a deliberate input (a stick or trigger past half travel) + [[nodiscard]] std::optional GetSDLLastActivity( + u32 sdl_instance) const; + /// Reloads the input devices. void ReloadInputDevices(); diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index f7561686dc..737ea2a90d 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -30,9 +30,41 @@ std::unique_ptr InitializeTranslations(QObject* parent) { // A setting can be ignored by giving it a blank name + // Toggled from View > Show Controller Overlay + INSERT(Settings, console_debug_overlay, tr("Controller overlay"), + tr("The players, their controllers and their last buttons in the top-left corner.")); + INSERT(Settings, console_hints, tr("Controller hints"), + tr("Names a controller in the bottom-left corner when it joins or leaves.")); + INSERT(Settings, console_history, QString(), QString()); + INSERT(Settings, console_history_size, QString(), QString()); + INSERT(Settings, console_history_seconds, QString(), QString()); + INSERT(Settings, console_history_gaps, QString(), QString()); + INSERT(Settings, console_debug_probes, QString(), QString()); + INSERT(Settings, console_bypass_steam, QString(), QString()); + INSERT(Settings, console_joycon_auto, tr("Use any controller as Joy-Cons"), + tr("When the game only takes Joy-Cons, controllers act as Joy-Cons.")); + INSERT(Settings, console_pause_disconnect, tr("Pause when a controller disconnects"), + tr("Pauses when a player's controller is turned off, or has been gone for 3 seconds. " + "+ on another controller goes on without that player.")); + INSERT(Settings, console_player_lights, tr("Player lights"), + tr("Controllers' player lights show their player.")); + INSERT(Settings, console_button_labels, tr("Button layout by label"), + tr("Xbox and PlayStation style controllers' buttons by their labels instead of their " + "positions.")); + INSERT(Settings, modern_seat_profiles, QString(), QString()); + INSERT(Settings, builtin_pad_identity, QString(), QString()); + INSERT(Settings, builtin_pad_steam_handle, QString(), QString()); + INSERT(Settings, builtin_pad_blocked, QString(), QString()); + INSERT(Settings, builtin_pad_manual, QString(), QString()); + INSERT(Settings, builtin_pad_steam_manual, QString(), QString()); + // Applets INSERT(Settings, cabinet_applet_mode, tr("Amiibo editor"), QString()); - INSERT(Settings, controller_applet_mode, tr("Controller configuration"), QString()); + // Chosen in Controls > Players ("Assign players") + INSERT(Settings, controller_applet_mode, tr("Controller configuration"), + tr("How games get their players: Modern Console and Handheld Console assign them " + "automatically; Custom Frontend and Real Hardware show a controller applet; " + "Disabled uses the players set in each player's tab.")); INSERT(Settings, data_erase_applet_mode, tr("Data erase"), QString()); INSERT(Settings, error_applet_mode, tr("Error"), QString()); INSERT(Settings, net_connect_applet_mode, tr("Net connect"), QString()); @@ -369,9 +401,11 @@ std::unique_ptr ComboboxEnumeration(QObject* parent) { // Intentionally skipping VSyncMode to let the UI fill that one out translations->insert({Settings::EnumMetadata::Index(), { - PAIR(AppletMode, HLE, tr("Custom frontend")), - PAIR(AppletMode, LLE, tr("Real applet")), + PAIR(AppletMode, HLE, tr("Custom Frontend")), + PAIR(AppletMode, LLE, tr("Real Hardware")), PAIR(AppletMode, Disabled, tr("Disabled")), + PAIR(AppletMode, ConsoleMode, tr("Modern Console")), + PAIR(AppletMode, HandheldConsoleMode, tr("Handheld Console")), }}); translations->insert({Settings::EnumMetadata::Index(), diff --git a/src/qt_common/config/uisettings.cpp b/src/qt_common/config/uisettings.cpp index f4f5688781..00e7095c0c 100644 --- a/src/qt_common/config/uisettings.cpp +++ b/src/qt_common/config/uisettings.cpp @@ -30,7 +30,7 @@ namespace UISettings { // This must be in alphabetical order according to action name as it must have the same order as // UISetting::values.shortcuts, which is alphabetically ordered. // clang-format off -const std::array default_hotkeys{{ +const std::array default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Mute/Unmute")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+M"), std::string("Home+Dpad_Right"), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Down")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("-"), std::string("Home+Dpad_Down"), Qt::ApplicationShortcut, true}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Up")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("="), std::string("Home+Dpad_Up"), Qt::ApplicationShortcut, true}}, @@ -64,6 +64,7 @@ const std::array default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Renderdoc Capture")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string(""), std::string(""), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Status Bar")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+S"), std::string(""), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Performance Overlay")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+V"), std::string(""), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Controller Overlay")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+Shift+V"), std::string(""), Qt::WindowShortcut, false}}, }}; // clang-format on diff --git a/src/qt_common/config/uisettings.h b/src/qt_common/config/uisettings.h index 6b4ed43e76..b027b5f8eb 100644 --- a/src/qt_common/config/uisettings.h +++ b/src/qt_common/config/uisettings.h @@ -239,7 +239,7 @@ void SaveWindowState(); void RestoreWindowState(std::unique_ptr& qtConfig); // sync with uisettings.cpp -extern const std::array default_hotkeys; +extern const std::array default_hotkeys; } // namespace UISettings diff --git a/src/video_core/renderer_vulkan/present/filters.cpp b/src/video_core/renderer_vulkan/present/filters.cpp index 6203b41218..d8dc2291f6 100644 --- a/src/video_core/renderer_vulkan/present/filters.cpp +++ b/src/video_core/renderer_vulkan/present/filters.cpp @@ -30,29 +30,29 @@ namespace Vulkan { -std::unique_ptr MakeNearestNeighbor(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateNearestNeighborSampler(device), +std::unique_ptr MakeNearestNeighbor(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateNearestNeighborSampler(device), BuildShader(device, VULKAN_PRESENT_FRAG_SPV)); } -std::unique_ptr MakeBilinear(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeBilinear(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, VULKAN_PRESENT_FRAG_SPV)); } -std::unique_ptr MakeSpline1(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeSpline1(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_SPLINE1_FRAG_SPV)); } -std::unique_ptr MakeBicubic(const Device& device, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights) { +std::unique_ptr MakeBicubic(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights) { // No need for handrolled shader -- if the VK impl can do it for us ;) // Catmull-Rom is default bicubic for all implementations... if (device.IsExtFilterCubicSupported() && (device.IsQcomFilterCubicWeightsSupported() || qcom_weights == VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM)) { - return std::make_unique(device, frame_format, CreateCubicSampler(device, + return std::make_unique(device, memory_allocator, frame_format, CreateCubicSampler(device, qcom_weights), BuildShader(device, VULKAN_PRESENT_FRAG_SPV)); } else { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), [&](){ + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), [&](){ switch (qcom_weights) { case VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM: return BuildShader(device, PRESENT_BICUBIC_FRAG_SPV); @@ -69,32 +69,32 @@ std::unique_ptr MakeBicubic(const Device& device, VkFormat fram } } -std::unique_ptr MakeGaussian(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeGaussian(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_GAUSSIAN_FRAG_SPV)); } -std::unique_ptr MakeLanczos(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeLanczos(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_LANCZOS_FRAG_SPV)); } -std::unique_ptr MakeScaleForce(const Device& device, VkFormat frame_format) { +std::unique_ptr MakeScaleForce(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { auto const select_fn = [&]() { return device.IsFloat16Supported() ? BuildShader(device, VULKAN_PRESENT_SCALEFORCE_FP16_FRAG_SPV) : BuildShader(device, VULKAN_PRESENT_SCALEFORCE_FP32_FRAG_SPV); }; - return std::make_unique(device, frame_format, CreateBilinearSampler(device), select_fn()); + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), select_fn()); } -std::unique_ptr MakeArea(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeArea(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_AREA_FRAG_SPV)); } -std::unique_ptr MakeMmpx(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateNearestNeighborSampler(device), +std::unique_ptr MakeMmpx(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateNearestNeighborSampler(device), BuildShader(device, PRESENT_MMPX_FRAG_SPV)); } diff --git a/src/video_core/renderer_vulkan/present/filters.h b/src/video_core/renderer_vulkan/present/filters.h index afc3ba29a0..84e3c4068e 100644 --- a/src/video_core/renderer_vulkan/present/filters.h +++ b/src/video_core/renderer_vulkan/present/filters.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2024 Torzu Emulator Project @@ -15,14 +15,14 @@ namespace Vulkan { class MemoryAllocator; -std::unique_ptr MakeNearestNeighbor(const Device& device, VkFormat frame_format); -std::unique_ptr MakeBilinear(const Device& device, VkFormat frame_format); -std::unique_ptr MakeBicubic(const Device& device, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights); -std::unique_ptr MakeSpline1(const Device& device, VkFormat frame_format); -std::unique_ptr MakeGaussian(const Device& device, VkFormat frame_format); -std::unique_ptr MakeLanczos(const Device& device, VkFormat frame_format); -std::unique_ptr MakeScaleForce(const Device& device, VkFormat frame_format); -std::unique_ptr MakeArea(const Device& device, VkFormat frame_format); -std::unique_ptr MakeMmpx(const Device& device, VkFormat frame_format); +std::unique_ptr MakeNearestNeighbor(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeBilinear(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeBicubic(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights); +std::unique_ptr MakeSpline1(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeGaussian(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeLanczos(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeScaleForce(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeArea(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeMmpx(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); } // namespace Vulkan diff --git a/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp b/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp index 549b0660c1..024d31adc7 100644 --- a/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp +++ b/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp @@ -6,10 +6,17 @@ #include "core/frontend/framebuffer_layout.h" #include "video_core/framebuffer_config.h" +#include "video_core/host_shaders/vulkan_present_frag_spv.h" #include "video_core/host_shaders/vulkan_present_vert_spv.h" #include "video_core/renderer_vulkan/present/layer.h" #include "video_core/renderer_vulkan/present/present_push_constants.h" #include "video_core/renderer_vulkan/present/util.h" +#include + +#include "common/overlay_panels.h" +#include "common/logging.h" +#include "video_core/renderer_vulkan/present/present_push_constants.h" +#include "video_core/renderer_vulkan/present/util.h" #include "video_core/renderer_vulkan/present/window_adapt_pass.h" #include "video_core/renderer_vulkan/vk_present_manager.h" #include "video_core/renderer_vulkan/vk_shader_util.h" @@ -18,8 +25,11 @@ namespace Vulkan { -WindowAdaptPass::WindowAdaptPass(const Device& device, VkFormat frame_format, vk::Sampler&& sampler_, vk::ShaderModule&& fragment_shader_) - : sampler(std::move(sampler_)) +WindowAdaptPass::WindowAdaptPass(const Device& device, MemoryAllocator& memory_allocator_, + VkFormat frame_format, vk::Sampler&& sampler_, + vk::ShaderModule&& fragment_shader_) + : memory_allocator{memory_allocator_} + , sampler(std::move(sampler_)) , fragment_shader(std::move(fragment_shader_)) { CreateDescriptorSetLayout(device); @@ -27,6 +37,10 @@ WindowAdaptPass::WindowAdaptPass(const Device& device, VkFormat frame_format, vk CreateVertexShader(device); CreateRenderPass(device, frame_format); CreatePipelines(device); + overlay_shader = BuildShader(device, VULKAN_PRESENT_FRAG_SPV); + overlay_sampler = CreateNearestNeighborSampler(device); + overlay_pipeline = CreateWrappedCoverageBlendingPipeline( + device, render_pass, pipeline_layout, std::tie(vertex_shader, overlay_shader)); } WindowAdaptPass::~WindowAdaptPass() = default; @@ -34,7 +48,8 @@ WindowAdaptPass::~WindowAdaptPass() = default; void WindowAdaptPass::Draw(const Device& device, RasterizerVulkan& rasterizer, Scheduler& scheduler, size_t image_index, std::list& layers, std::span configs, - const Layout::FramebufferLayout& layout, Frame* dst) { + const Layout::FramebufferLayout& layout, Frame* dst, + bool overlay_panels) { const VkFramebuffer host_framebuffer{*dst->framebuffer}; const VkRenderPass renderpass{*render_pass}; @@ -69,6 +84,129 @@ void WindowAdaptPass::Draw(const Device& device, RasterizerVulkan& rasterizer, S layer_it++; } + // The overlay panels (see common/overlay_panels.h): one more quad each, straight-alpha, + // drawn last. + std::array overlay_draw{}; + std::array overlay_push{}; + std::array overlay_set{}; + auto& panels = Common::GetOverlayPanels(); + for (size_t o = 0; overlay_panels && o < panels.size(); ++o) { + auto& overlay_frame = panels[o]; + auto& quad = overlays[o]; + std::scoped_lock lock{overlay_frame.mutex}; + if (!overlay_frame.enabled || overlay_frame.pixels.empty() || overlay_frame.width == 0 || + overlay_frame.height == 0) { + continue; + } + constexpr VkFormat overlay_format = VK_FORMAT_R8G8B8A8_UNORM; + const VkExtent2D extent{overlay_frame.width, overlay_frame.height}; + if (!quad.ready || quad.extent.width != extent.width || + quad.extent.height != extent.height) { + // (A frame still on the GPU may be drawing the old image: replaced once it's done) + scheduler.Wait(quad.used_tick); + quad.image = CreateWrappedImage(memory_allocator, extent, overlay_format); + quad.view = CreateWrappedImageView(device, quad.image, overlay_format); + quad.pool = CreateWrappedDescriptorPool(device, 1, 1); + const std::vector layouts(1u, *descriptor_set_layout); + quad.sets = CreateWrappedDescriptorSets(quad.pool, layouts); + quad.extent = extent; + quad.ready = true; + quad.version = 0; + } + if (quad.version != overlay_frame.version) { + // Copied through one of the panel's two staging buffers: only that buffer's last + // copy is waited for, never the whole GPU + const std::size_t k = quad.next_staging; + quad.next_staging ^= 1; + scheduler.Wait(quad.staging_tick[k]); + auto& staging = quad.staging[k]; + const std::size_t bytes = overlay_frame.pixels.size(); + if (!staging || quad.staging_size[k] < bytes) { + const VkBufferCreateInfo staging_ci{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .size = bytes, + .usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .queueFamilyIndexCount = 0, + .pQueueFamilyIndices = nullptr, + }; + staging = memory_allocator.CreateBuffer(staging_ci, MemoryUsage::Upload); + quad.staging_size[k] = bytes; + } + std::ranges::copy(overlay_frame.pixels, staging.Mapped().begin()); + staging.Flush(); + const VkBuffer source = *staging; + const VkImage target = *quad.image; + const VkBufferImageCopy region{ + .bufferOffset = 0, + .bufferRowLength = extent.width, + .bufferImageHeight = extent.height, + .imageSubresource{.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .mipLevel = 0, + .baseArrayLayer = 0, + .layerCount = 1}, + .imageOffset{}, + .imageExtent{.width = extent.width, .height = extent.height, .depth = 1}, + }; + scheduler.RequestOutsideRenderPassOperationContext(); + scheduler.Record([source, target, region](vk::CommandBuffer cmdbuf) { + TransitionImageLayout(cmdbuf, target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_UNDEFINED); + cmdbuf.CopyBufferToImage(source, target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + std::array{region}); + TransitionImageLayout(cmdbuf, target, VK_IMAGE_LAYOUT_GENERAL, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + }); + quad.staging_tick[k] = scheduler.CurrentTick(); + quad.version = overlay_frame.version; + const VkDescriptorImageInfo image_info{ + .sampler = *overlay_sampler, + .imageView = *quad.view, + .imageLayout = VK_IMAGE_LAYOUT_GENERAL, + }; + const VkWriteDescriptorSet write{ + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = quad.sets[0], + .dstBinding = 0, + .dstArrayElement = 0, + .descriptorCount = 1, + .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + .pImageInfo = &image_info, + .pBufferInfo = nullptr, + .pTexelBufferView = nullptr, + }; + device.GetLogical().UpdateDescriptorSets(std::array{write}, {}); + } + overlay_set[o] = quad.sets[0]; + + auto& push = overlay_push[o]; + push.modelview_matrix = + MakeOrthographicMatrix(f32(layout.width), static_cast(layout.height)); + const auto& screen = layout.screen; + const auto place = Common::PlaceOverlayPanel(overlay_frame, f32(screen.left), + f32(screen.top), f32(screen.right), + f32(screen.bottom)); + const f32 w = place.width; + const f32 h = place.height; + const f32 x = place.x; + const f32 y = place.y; + push.vertices[0] = ScreenRectVertex(x, y, 0.f, 0.f); + push.vertices[1] = ScreenRectVertex(x + w, y, 1.f, 0.f); + push.vertices[2] = ScreenRectVertex(x, y + h, 0.f, 1.f); + push.vertices[3] = ScreenRectVertex(x + w, y + h, 1.f, 1.f); + overlay_draw[o] = true; + quad.used_tick = scheduler.CurrentTick(); + + static u32 logged_quads = 0; + if (logged_quads < 3) { + ++logged_quads; + LOG_INFO(Render_Vulkan, "overlay: quad drawn {}x{} at {},{}", extent.width, + extent.height, x, y); + } + } + scheduler.Record([=](vk::CommandBuffer cmdbuf) { const f32 bg_red = Settings::values.bg_red.GetValue() / 255.0f; const f32 bg_green = Settings::values.bg_green.GetValue() / 255.0f; @@ -103,6 +241,18 @@ void WindowAdaptPass::Draw(const Device& device, RasterizerVulkan& rasterizer, S cmdbuf.Draw(4, 1, 0, 0); } + for (size_t o = 0; o < overlay_draw.size(); ++o) { + if (!overlay_draw[o]) { + continue; + } + cmdbuf.BindPipeline(VK_PIPELINE_BIND_POINT_GRAPHICS, *overlay_pipeline); + cmdbuf.PushConstants(graphics_pipeline_layout, VK_SHADER_STAGE_VERTEX_BIT, + overlay_push[o]); + cmdbuf.BindDescriptorSets(VK_PIPELINE_BIND_POINT_GRAPHICS, graphics_pipeline_layout, + 0, overlay_set[o], {}); + cmdbuf.Draw(4, 1, 0, 0); + } + cmdbuf.EndRenderPass(); }); } diff --git a/src/video_core/renderer_vulkan/present/window_adapt_pass.h b/src/video_core/renderer_vulkan/present/window_adapt_pass.h index 58d6fc01c0..ec9004dc38 100644 --- a/src/video_core/renderer_vulkan/present/window_adapt_pass.h +++ b/src/video_core/renderer_vulkan/present/window_adapt_pass.h @@ -6,9 +6,12 @@ #pragma once +#include +#include #include #include "common/math_util.h" +#include "common/overlay_panels.h" #include "video_core/vulkan_common/vulkan_wrapper.h" namespace Layout { @@ -22,6 +25,7 @@ struct FramebufferConfig; namespace Vulkan { class Device; +class MemoryAllocator; struct Frame; class Layer; class Scheduler; @@ -29,13 +33,15 @@ class RasterizerVulkan; class WindowAdaptPass final { public: - explicit WindowAdaptPass(const Device& device, VkFormat frame_format, vk::Sampler&& sampler, + explicit WindowAdaptPass(const Device& device, MemoryAllocator& memory_allocator, + VkFormat frame_format, vk::Sampler&& sampler, vk::ShaderModule&& fragment_shader); ~WindowAdaptPass(); + /// (With the overlay panels on top only where asked: the screen's, not a capture) void Draw(const Device& device, RasterizerVulkan& rasterizer, Scheduler& scheduler, size_t image_index, std::list& layers, std::span configs, - const Layout::FramebufferLayout& layout, Frame* dst); + const Layout::FramebufferLayout& layout, Frame* dst, bool overlay_panels = false); VkDescriptorSetLayout GetDescriptorSetLayout(); VkRenderPass GetRenderPass(); @@ -47,6 +53,8 @@ private: void CreateRenderPass(const Device& device, VkFormat frame_format); void CreatePipelines(const Device& device); + MemoryAllocator& memory_allocator; + vk::DescriptorSetLayout descriptor_set_layout; vk::PipelineLayout pipeline_layout; vk::Sampler sampler; @@ -56,6 +64,33 @@ private: vk::Pipeline opaque_pipeline; vk::Pipeline premultiplied_pipeline; vk::Pipeline coverage_pipeline; + + // The overlay panels' own pipeline: the plain present shader, pixel for pixel (the scaling + // filter's shader would blur their text) + vk::ShaderModule overlay_shader; + vk::Sampler overlay_sampler; + vk::Pipeline overlay_pipeline; + + // The overlay panels (common/overlay_panels.h): a straight-alpha quad each, composited on + // top of the layers, for both the accelerated and the unaccelerated present paths. + struct OverlayQuad { + vk::Image image; + vk::ImageView view; + vk::DescriptorPool pool; + vk::DescriptorSets sets; + VkExtent2D extent{}; + std::uint64_t version = 0; + bool ready = false; + /// Staging buffers, used in turn: each is written again only once the GPU is past its + /// last copy (normally no wait) + std::array staging; + std::array staging_size{}; + std::array staging_tick{}; + std::size_t next_staging = 0; + /// The GPU tick that last drew the image: replaced only once the GPU is past it + std::uint64_t used_tick = 0; + }; + std::array overlays; }; } // namespace Vulkan diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp index 27ca531186..a1c6c4d907 100644 --- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp @@ -180,6 +180,8 @@ try , frame_gen(memory_allocator, scheduler) #endif { + // The overlay panels go on the screen only: not in screenshots or the applets' capture + blit_swapchain.DrawOverlayPanels(true); if (Settings::values.renderer_force_max_clock.GetValue() && device.ShouldBoostClocks()) { turbo_mode.emplace(instance, dld); diff --git a/src/video_core/renderer_vulkan/vk_blit_screen.cpp b/src/video_core/renderer_vulkan/vk_blit_screen.cpp index 205382f81a..4228440423 100644 --- a/src/video_core/renderer_vulkan/vk_blit_screen.cpp +++ b/src/video_core/renderer_vulkan/vk_blit_screen.cpp @@ -44,44 +44,44 @@ void BlitScreen::SetWindowAdaptPass(const Device& device) { switch (scaling_filter) { case Settings::ScalingFilter::NearestNeighbor: - window_adapt = MakeNearestNeighbor(device, swapchain_view_format); + window_adapt = MakeNearestNeighbor(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Bicubic: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM); break; case Settings::ScalingFilter::ZeroTangent: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_ZERO_TANGENT_CARDINAL_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_ZERO_TANGENT_CARDINAL_QCOM); break; case Settings::ScalingFilter::BSpline: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_B_SPLINE_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_B_SPLINE_QCOM); break; case Settings::ScalingFilter::Mitchell: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_MITCHELL_NETRAVALI_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_MITCHELL_NETRAVALI_QCOM); break; case Settings::ScalingFilter::Spline1: - window_adapt = MakeSpline1(device, swapchain_view_format); + window_adapt = MakeSpline1(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Gaussian: - window_adapt = MakeGaussian(device, swapchain_view_format); + window_adapt = MakeGaussian(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Lanczos: - window_adapt = MakeLanczos(device, swapchain_view_format); + window_adapt = MakeLanczos(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::ScaleForce: - window_adapt = MakeScaleForce(device, swapchain_view_format); + window_adapt = MakeScaleForce(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Area: - window_adapt = MakeArea(device, swapchain_view_format); + window_adapt = MakeArea(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Mmpx: - window_adapt = MakeMmpx(device, swapchain_view_format); + window_adapt = MakeMmpx(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Fsr: case Settings::ScalingFilter::Sgsr: case Settings::ScalingFilter::SgsrEdge: case Settings::ScalingFilter::Bilinear: default: - window_adapt = MakeBilinear(device, swapchain_view_format); + window_adapt = MakeBilinear(device, memory_allocator, swapchain_view_format); break; } } @@ -155,7 +155,8 @@ void BlitScreen::DrawToFrame(const Device& device, RasterizerVulkan& rasterizer, } } - window_adapt->Draw(device, rasterizer, scheduler, image_index, layers, framebuffers, layout, frame); + window_adapt->Draw(device, rasterizer, scheduler, image_index, layers, framebuffers, layout, frame, + overlay_panels); if (++image_index >= image_count) { image_index = 0; diff --git a/src/video_core/renderer_vulkan/vk_blit_screen.h b/src/video_core/renderer_vulkan/vk_blit_screen.h index 56b429ae53..719e348d43 100644 --- a/src/video_core/renderer_vulkan/vk_blit_screen.h +++ b/src/video_core/renderer_vulkan/vk_blit_screen.h @@ -62,6 +62,12 @@ public: void PrepareFrame(const Device& device, Frame* frame, const Layout::FramebufferLayout& layout); + /// Draws the overlay panels on top (the screen's pass only: never in a screenshot or the + /// applets' capture) + void DrawOverlayPanels(bool draw) { + overlay_panels = draw; + } + [[nodiscard]] vk::Framebuffer CreateFramebuffer(const Device& device, const Layout::FramebufferLayout& layout, VkImageView image_view, VkFormat current_view_format); @@ -83,6 +89,7 @@ private: Settings::ScalingFilter scaling_filter{}; std::unique_ptr window_adapt{}; + bool overlay_panels = false; std::list layers{}; }; diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index b93080f2a1..6d7c25e15b 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -43,6 +43,8 @@ add_executable(yuzu configuration/configure_applets.cpp configuration/configure_applets.h configuration/configure_applets.ui + configuration/configure_modern_per_game.cpp + configuration/configure_modern_per_game.h configuration/configure_audio.cpp configuration/configure_audio.h configuration/configure_audio.ui @@ -90,6 +92,8 @@ add_executable(yuzu configuration/configure_input_advanced.cpp configuration/configure_input_advanced.h configuration/configure_input_advanced.ui + configuration/configure_player_assignment.cpp + configuration/configure_player_assignment.h configuration/configure_input_per_game.cpp configuration/configure_input_per_game.h configuration/configure_input_per_game.ui @@ -194,6 +198,8 @@ add_executable(yuzu multiplayer/state.cpp multiplayer/state.h multiplayer/validation.h + overlay_panel.cpp + overlay_panel.h startup_checks.cpp startup_checks.h set_play_time_dialog.cpp @@ -204,6 +210,8 @@ add_executable(yuzu util/controller_navigation.h util/limitable_input_dialog.cpp util/limitable_input_dialog.h + util/gamescope.cpp + util/gamescope.h util/overlay_dialog.cpp util/overlay_dialog.h util/overlay_dialog.ui diff --git a/src/yuzu/applets/qt_software_keyboard.cpp b/src/yuzu/applets/qt_software_keyboard.cpp index aa77e082bd..583565fdbf 100644 --- a/src/yuzu/applets/qt_software_keyboard.cpp +++ b/src/yuzu/applets/qt_software_keyboard.cpp @@ -19,6 +19,7 @@ #include "yuzu/applets/qt_software_keyboard.h" #include "yuzu/main_window.h" #include "yuzu/util/overlay_dialog.h" +#include "yuzu/util/gamescope.h" namespace { @@ -42,8 +43,12 @@ QtSoftwareKeyboardDialog::QtSoftwareKeyboardDialog( is_inline{is_inline_}, initialize_parameters{std::move(initialize_parameters_)} { ui->setupUi(this); + // Under gamescope only one normal window shows at a time: a window bypassing the window + // manager is drawn over the game instead of in its place + const bool gamescope = Gamescope::IsRunningUnder(); setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowTitleHint | - Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint); + Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint | + (gamescope ? Qt::X11BypassWindowManagerHint : Qt::WindowFlags{})); setWindowModality(Qt::WindowModal); setAttribute(Qt::WA_DeleteOnClose); setAttribute(Qt::WA_TranslucentBackground); diff --git a/src/yuzu/configuration/configure_applets.cpp b/src/yuzu/configuration/configure_applets.cpp index 139bfa9da3..2ed88f1f77 100644 --- a/src/yuzu/configuration/configure_applets.cpp +++ b/src/yuzu/configuration/configure_applets.cpp @@ -1,6 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2024 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "common/controller_console_state.h" #include "common/settings.h" #include "core/core.h" #include "ui_configure_applets.h" @@ -47,6 +62,16 @@ void ConfigureApplets::Setup(const ConfigurationShared::Builder& builder) { push(Settings::values.linkage.by_category[Settings::Category::LibraryApplet]); for (auto setting : settings) { + // The Modern Console options are the Controllers tab's (and a game's Modern Console + // tab). Skipped before they are built: building registers how to apply them. + if (setting->Id() == Settings::values.console_debug_overlay.Id() || + setting->Id() == Settings::values.console_hints.Id() || + setting->Id() == Settings::values.console_joycon_auto.Id() || + setting->Id() == Settings::values.console_pause_disconnect.Id() || + setting->Id() == Settings::values.console_player_lights.Id() || + setting->Id() == Settings::values.console_button_labels.Id()) { + continue; + } ConfigurationShared::Widget* widget = builder.BuildWidget(setting, apply_funcs); if (widget == nullptr) { diff --git a/src/yuzu/configuration/configure_applets.h b/src/yuzu/configuration/configure_applets.h index b794b49e0b..acd91dff8d 100644 --- a/src/yuzu/configuration/configure_applets.h +++ b/src/yuzu/configuration/configure_applets.h @@ -6,12 +6,17 @@ #pragma once +#include #include #include "yuzu/configuration/configuration_shared.h" class QCheckBox; -class QLineEdit; +class QComboBox; class QDateTimeEdit; +class QLabel; +class QLineEdit; +class QPushButton; +class QTimer; namespace Core { class System; } diff --git a/src/yuzu/configuration/configure_dialog.cpp b/src/yuzu/configuration/configure_dialog.cpp index 547008262e..f4d94a47b3 100644 --- a/src/yuzu/configuration/configure_dialog.cpp +++ b/src/yuzu/configuration/configure_dialog.cpp @@ -121,6 +121,23 @@ ConfigureDialog::~ConfigureDialog() = default; void ConfigureDialog::SetConfiguration() {} +void ConfigureDialog::OpenPlayerTab(int player) { + QWidget* tab = input_tab->PlayerTab(player); + if (!tab) { + return; + } + // Controls is the page whose tabs are the input tab's + for (int row = 0; row < ui->selectorList->count(); ++row) { + const auto tabs = + qvariant_cast>(ui->selectorList->item(row)->data(Qt::UserRole)); + if (tabs.contains(tab)) { + ui->selectorList->setCurrentRow(row); + ui->tabWidget->setCurrentWidget(tab); + return; + } + } +} + void ConfigureDialog::ApplyConfiguration() { general_tab->ApplyConfiguration(); ui_tab->ApplyConfiguration(); diff --git a/src/yuzu/configuration/configure_dialog.h b/src/yuzu/configuration/configure_dialog.h index 73db11238e..65a086e5c9 100644 --- a/src/yuzu/configuration/configure_dialog.h +++ b/src/yuzu/configuration/configure_dialog.h @@ -55,6 +55,9 @@ public: Core::System& system_, bool enable_web_config = true); ~ConfigureDialog() override; + /// Opens on a player's tab of the Controls page (0-based player) + void OpenPlayerTab(int player); + void ApplyConfiguration(); private slots: diff --git a/src/yuzu/configuration/configure_graphics.cpp b/src/yuzu/configuration/configure_graphics.cpp index 641f65d82a..c0d6b90d8a 100644 --- a/src/yuzu/configuration/configure_graphics.cpp +++ b/src/yuzu/configuration/configure_graphics.cpp @@ -4,6 +4,7 @@ // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +38,7 @@ #include "yuzu/configuration/configuration_shared.h" #include "yuzu/configuration/configure_graphics.h" #include "yuzu/configuration/shared_widget.h" +#include "yuzu/util/gamescope.h" #ifdef HAS_RESHADE #include "yuzu/configuration/configure_post_processing.h" @@ -79,7 +82,10 @@ ConfigureGraphics::ConfigureGraphics( } index++; } - if (static_cast(index) < vsync_mode_combobox_enum_map.size()) { + // (A mode greyed out for Game Mode is never selected) + const auto* vsync_model = qobject_cast(vsync_mode_combobox->model()); + if (static_cast(index) < vsync_mode_combobox_enum_map.size() && + (!vsync_model || vsync_model->item(index)->isEnabled())) { vsync_mode_combobox->setCurrentIndex(index); } @@ -169,6 +175,35 @@ void ConfigureGraphics::PopulateVSyncModeSelection(bool use_setting) { index++; } + // In Steam's Game Mode, gamescope offers only some present modes: the others are listed + // greyed out, saying how to get them, rather than missing without a word + const bool gamescope = Gamescope::IsRunningUnder(); + if (gamescope && backend == Settings::RendererBackend::Vulkan) { + auto* model = qobject_cast(vsync_mode_combobox->model()); + for (const auto missing : {VK_PRESENT_MODE_IMMEDIATE_KHR, VK_PRESENT_MODE_FIFO_RELAXED_KHR, + VK_PRESENT_MODE_MAILBOX_KHR}) { + if (std::find(present_modes.begin(), present_modes.end(), missing) != + present_modes.end()) { + continue; + } + const auto mode_name = TranslateVSyncMode(missing, backend); + if (mode_name.isEmpty() || !model) { + continue; + } + vsync_mode_combobox->insertItem(index, + tr("%1 - unavailable in Game Mode").arg(mode_name)); + vsync_mode_combobox->setItemData( + index, + tr("Steam's Game Mode does not offer this mode by default. In Steam's Performance " + "settings, turn off Disable Frame Limit and turn on Allow Tearing, then open " + "these settings again."), + Qt::ToolTipRole); + model->item(index)->setEnabled(false); + vsync_mode_combobox_enum_map.push_back(missing); + index++; + } + } + if (!Settings::IsConfiguringGlobal()) { vsync_restore_global_button->setVisible(!Settings::values.vsync_mode.UsingGlobal()); diff --git a/src/yuzu/configuration/configure_input.cpp b/src/yuzu/configuration/configure_input.cpp index 4473df901e..24dcbb665b 100644 --- a/src/yuzu/configuration/configure_input.cpp +++ b/src/yuzu/configuration/configure_input.cpp @@ -4,9 +4,34 @@ // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/controller_console_state.h" #include "common/settings.h" #include "common/settings_enums.h" #include "core/core.h" @@ -15,6 +40,7 @@ #include "core/hle/service/sm/sm.h" #include "hid_core/frontend/emulated_controller.h" #include "hid_core/hid_core.h" +#include "qt_common/config/uisettings.h" #include "qt_common/qt_compat.h" #include "ui_configure_input.h" #include "ui_configure_input_advanced.h" @@ -25,6 +51,7 @@ #include "yuzu/configuration/configure_input_advanced.h" #include "yuzu/configuration/configure_input_player.h" #include "yuzu/configuration/configure_motion_touch.h" +#include "yuzu/configuration/configure_player_assignment.h" #include "yuzu/configuration/configure_ringcon.h" #include "yuzu/configuration/configure_touchscreen_advanced.h" #include "yuzu/configuration/configure_vibration.h" @@ -142,6 +169,334 @@ void ConfigureInput::Initialize(InputCommon::InputSubsystem* input_subsystem, connect(player_controllers[0], &ConfigureInputPlayer::HandheldStateChanged, [this](bool is_handheld) { UpdateDockedState(is_handheld); }); + // The Controllers tab comes first: how players are assigned, and who is which player now + assignment = new ConfigurePlayerAssignment(this); + assignment->setAccessibleName(tr("Controllers")); + assignment->SetOpenPlayerTab([this](int player) { + for (QWidget* parent = player_tabs[player]->parentWidget(); parent; + parent = parent->parentWidget()) { + if (auto* tabs = qobject_cast(parent)) { + tabs->setCurrentWidget(player_tabs[player]); + return; + } + } + }); + + // Console Mode first in the Controllers tab: a mirror of the player tabs' Docked/Handheld + // choice, which stays the one applied + { + auto* console_box = new QGroupBox(tr("Console Mode"), assignment); + auto* console_layout = new QHBoxLayout(console_box); + auto* console_group = new QButtonGroup(console_box); + console_layout->addWidget(ConfigurePlayerAssignment::MakeSegmented( + console_box, {tr("Docked"), tr("Handheld")}, {0, 1}, console_group)); + console_layout->addStretch(1); + auto* docked = console_group->button(0); + auto* handheld = console_group->button(1); + assignment->AddTopWidget(console_box); + const auto mirror = [this, docked, handheld] { + docked->setChecked(ui->radioDocked->isChecked()); + handheld->setChecked(ui->radioUndocked->isChecked()); + docked->setEnabled(ui->radioDocked->isEnabled()); + handheld->setEnabled(ui->radioUndocked->isEnabled()); + }; + connect(ui->radioDocked, &QRadioButton::toggled, this, mirror); + connect(ui->radioUndocked, &QRadioButton::toggled, this, mirror); + connect(docked, &QAbstractButton::clicked, this, + [this] { ui->radioDocked->setChecked(true); }); + connect(handheld, &QAbstractButton::clicked, this, + [this] { ui->radioUndocked->setChecked(true); }); + mirror_console_mode = mirror; + mirror(); + } + + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + followed_generation = state.generation; + } + follow_timer = new QTimer(this); + connect(follow_timer, &QTimer::timeout, this, [this] { + unsigned long long generation = 0; + std::vector rows; + bool engine_active = false; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + generation = state.generation; + engine_active = state.engine_active; + rows = state.players; + } + last_rows = rows; + // In the automatic modes only the players' tabs show, and connection can't be toggled + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + QTabWidget* tabs = nullptr; + for (QWidget* parent = player_tabs[0]->parentWidget(); parent && !tabs; + parent = parent->parentWidget()) { + tabs = qobject_cast(parent); + } + // The tabs follow the engine's rows and flashes, like the overlay + constexpr long long flash_ms = 1500; + const long long now = QDateTime::currentMSecsSinceEpoch(); + // The player tabs' choice can be locked (player 1 set to Handheld) without toggling + if (mirror_console_mode) { + mirror_console_mode(); + } + // The tabs show the battery percent only while they all fit with it (else the bar + // scrolls, or squeezes the labels on macOS). Judged with "100%" so it doesn't flicker. + bool tab_percents = true; + if (tabs) { + QFont bold = tabs->tabBar()->font(); + bold.setBold(true); + const QFontMetrics fm{bold}; + int shown_extra = 0; + int most_extra = 0; + for (int t = 0; t < tabs->count(); ++t) { + if (!tabs->isTabVisible(t) || tabs->tabIcon(t).isNull()) { + continue; + } + const QString text = tabs->tabText(t); + const int at = text.lastIndexOf(QLatin1Char(' ')); + if (text.endsWith(QLatin1Char('%')) && at > 0) { + shown_extra += fm.horizontalAdvance(text.mid(at)); + } + most_extra += fm.horizontalAdvance(QStringLiteral(" 100%")); + } + const int need = tabs->tabBar()->sizeHint().width() - shown_extra + most_extra; + tab_percents = need <= tabs->width() - 8; + } + // Several players: the tabs drag and carry arrows; one alone has nothing to reorder + const bool several = + std::count_if(rows.begin(), rows.end(), [](const auto& r) { return !r.leaving; }) > 1; + for (std::size_t i = 0; i < player_tabs.size(); ++i) { + const auto row = std::find_if(rows.begin(), rows.end(), [i](const auto& r) { + return r.player == static_cast(i); + }); + bool shown = true; + const QColor label; // the tab's own text colour: the bubbles colour themselves + tab_lit[i] = 0; + if (automatic) { + shown = engine_active && row != rows.end(); + // Lit as the menu bar's chips are + const long long elapsed = shown ? now - row->flash_since : 0; + const bool blink = + shown && row->flash != 0 && elapsed < flash_ms && (elapsed / 125) % 2 == 0; + if (blink && row->flash == 2) { + tab_lit[i] = 3; + } else if (shown && row->active && !row->leaving) { + tab_lit[i] = 1; + } else if (blink) { + tab_lit[i] = 2; + } + } + if (tabs && !tab_dragging) { + if (const int index = tabs->indexOf(player_tabs[i]); index >= 0) { + if (player_tab_titles[i].isEmpty()) { + player_tab_titles[i] = tabs->tabText(index); + } + // Only what changed: each of these lays the tab bar out again + const auto set_text = [tabs, index](const QString& text) { + if (tabs->tabText(index) != text) { + tabs->setTabText(index, text); + } + }; + const auto set_tip = [tabs, index](const QString& tip) { + if (tabs->tabToolTip(index) != tip) { + tabs->setTabToolTip(index, tip); + } + }; + if (tabs->isTabVisible(index) != shown) { + tabs->setTabVisible(index, shown); + } + if (tabs->tabBar()->tabTextColor(index) != label) { + tabs->tabBar()->setTabTextColor(index, label); + } + // The controller's battery as the tab's icon, when it tells its charge + const int battery = automatic && shown && row->battery >= 0 && !row->leaving + ? row->battery * 2 + (row->charging ? 1 : 0) + : -1; + // (Also when the tab lost it: the dialog re-adds the tabs on page changes) + if (tab_battery[i] != battery || + tabs->tabIcon(index).isNull() != (battery < 0)) { + tab_battery[i] = battery; + tabs->setTabIcon(index, + battery < 0 ? QIcon{} + : QIcon(ConfigurePlayerAssignment::Battery( + row->battery, row->charging))); + } + if (!automatic) { + set_text(player_tab_titles[i]); + set_tip({}); + } else if (shown) { + // The grip shows the tab drags; the percent is in the label so the tab + // has room for it (the icon is drawn after it) + const QString percent = battery < 0 || !tab_percents + ? QString{} + : QStringLiteral(" %1%").arg(row->battery); + set_text((several ? QStringLiteral("\u283F P%1").arg(i + 1) + : QStringLiteral("P%1").arg(i + 1)) + + percent); + const QString battery_tip = + battery < 0 ? QString{} + : row->charging + ? tr("\nBattery %1% (charging)").arg(row->battery) + : tr("\nBattery %1%").arg(row->battery); + set_tip(tr("P%1: %2\nDrag left or right to change the player order") + .arg(i + 1) + .arg(QString::fromStdString(row->name)) + + battery_tip); + } + } + } + player_controllers[i]->SetConnectionLocked(automatic); + } + // In the automatic modes the player tabs drag to change the player order + if (tabs) { + const bool controls_page = tabs->indexOf(player_tabs[0]) >= 0; + tabs_editable = automatic && engine_active && controls_page && several; + // Under the dark themes Qt's drag would show the theme's tab: Eden drags it instead + tabs->setMovable(tabs_editable && !UISettings::IsDarkTheme()); + // Pill tabs in the players' colours (the tab bar is shared with the dialog's other + // pages: only on this page) + QTabBar* bar = tabs->tabBar(); + const bool pills = automatic && controls_page; + // (The bar is remembered: under a style sheet its style() is a wrapper, not ours) + if (pills && pill_bar != bar) { + auto* style = new PlayerTabStyle(QApplication::style()->name()); + style->setParent(bar); + bar->setStyle(style); + // (The battery icons' own shape: a square icon would shrink them) + bar->setIconSize(QSize(20, 12)); + pill_bar = bar; + } else if (!pills && pill_bar == bar) { + bar->setIconSize(QSize{}); + bar->setStyle(nullptr); + pill_bar = nullptr; + } + // The Midnight Blue themes resize a hovered tab (a 1-pixel border, less padding), so + // the page shook on hover: the hovered tab keeps its size + const bool midnight = QString::fromStdString(UISettings::values.theme) + .contains(QStringLiteral("midnight")); + const QString steady = + midnight ? QStringLiteral("QTabBar::tab:top:!selected:hover { padding-left: 7px; " + "padding-right: 7px; padding-top: 1px; " + "padding-bottom: 1px; }") + : QString{}; + if (bar->styleSheet() != steady) { + bar->setStyleSheet(steady); + } + // The dark themes' style sheet draws the tabs itself: the bubbles are painted over it + const bool painted = pills && UISettings::IsDarkTheme(); + if (painted) { + if (!tab_overlay || tab_overlay->parentWidget() != bar) { + delete tab_overlay; + tab_overlay = + new PlayerTabOverlay(bar, QString::fromStdString(UISettings::values.theme) + .contains(QStringLiteral("midnight")) + ? QColor(0x19, 0x23, 0x2D) + : QColor(0x31, 0x36, 0x3B)); + } + if (tab_overlay->geometry() != bar->rect()) { + tab_overlay->setGeometry(bar->rect()); + } + if (!tab_overlay->isVisible()) { + tab_overlay->lower(); + tab_overlay->show(); + } + tab_overlay->update(); + } else if (tab_overlay) { + tab_overlay->hide(); + } + // A player tab's colour is what makes it a bubble: none outside these modes + for (std::size_t i = 0; i < player_tabs.size(); ++i) { + if (const int index = tabs->indexOf(player_tabs[i]); index >= 0) { + const QVariant tab_data = + pills ? QVariant(QVariantList{ + ConfigurePlayerAssignment::PlayerColor(static_cast(i)), + tab_lit[i]}) + : QVariant{}; + if (bar->tabData(index) != tab_data) { + bar->setTabData(index, tab_data); + bar->update(); // (lit or not: the bubble repaints) + } + } + } + if (tabs != drag_tabs) { + drag_tabs = tabs; + tabs->tabBar()->installEventFilter(this); + connect(tabs, &QTabWidget::currentChanged, this, &ConfigureInput::UpdateTabArrows); + } + if (pending_select >= 0) { + const int index = tabs->indexOf(player_tabs[pending_select]); + if (index >= 0 && tabs->isTabVisible(index)) { + tabs->setCurrentIndex(index); + pending_select = -1; + } else if (now > pending_select_until) { + pending_select = -1; + } + } + if (!tab_dragging) { + UpdateTabArrows(); + } + } + + // Clicking a player's tab rumbles its controller, to tell which one it is + if (tabs && tabs != rumble_tabs) { + rumble_tabs = tabs; + connect(tabs->tabBar(), &QTabBar::tabBarClicked, this, [this, tabs](int index) { + const auto applet_mode = Settings::values.controller_applet_mode.GetValue(); + if (applet_mode != Settings::AppletMode::ConsoleMode && + applet_mode != Settings::AppletMode::HandheldConsoleMode) { + return; + } + const auto it = + std::find(player_tabs.begin(), player_tabs.end(), tabs->widget(index)); + if (it == player_tabs.end()) { + return; + } + const auto player = static_cast(it - player_tabs.begin()); + if (!Settings::values.vibration_enabled.GetValue() || + !Settings::values.players.GetValue()[player].vibration_enabled) { + return; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(static_cast(player)); + }); + } + + // The Controllers tab carries Console Mode in the automatic modes + ui->handheldGroup->setVisible(!automatic); + ui->connectedControllers->setVisible(!automatic); + // A player playing as a single Joy-Con: its mapping is the engine's (no Defaults/Clear) + if (auto* tab = qobject_cast(ui->consoleInputSettings->parent())) { + const bool locked = tab->JoyConLocked(); + ui->buttonClearAll->setEnabled(!locked); + ui->buttonRestoreDefaults->setEnabled(!locked); + } + + if (generation == followed_generation) { + return; + } + followed_generation = generation; + for (auto* controller : player_controllers) { + controller->RefreshFromController(); + } + // The boxes mirror the emulated controllers without acting on them + for (auto* checkbox : connected_controller_checkboxes) { + checkbox->blockSignals(true); + } + LoadPlayerControllerIndices(); + for (auto* checkbox : connected_controller_checkboxes) { + checkbox->blockSignals(false); + } + }); + follow_timer->start(50); + ui->tabWidget->insertTab(0, assignment, tr("Controllers")); + ui->tabWidget->setCurrentIndex(0); + advanced = new ConfigureInputAdvanced(hid_core, this); QHBoxLayout* advancedLayout = new QHBoxLayout(ui->tabAdvanced); @@ -201,10 +556,489 @@ void ConfigureInput::PropagatePlayerNumberChanged(size_t player_index, bool chec } } +namespace { + +/// A player tab's colour and how it is lit, from its data; nothing for other tabs +struct PlayerTabLook { + QColor accent; + int lit = 0; +}; + +std::optional TabLook(const QVariant& data) { + if (data.metaType() != QMetaType::fromType()) { + return std::nullopt; + } + const QVariantList list = data.toList(); + if (list.size() != 2) { + return std::nullopt; + } + return PlayerTabLook{list[0].value(), list[1].toInt()}; +} + +/// A player tab's label with its battery after it, the two centred together in the area +void PaintTabLabel(QPainter& painter, const QRect& area, const QString& text, const QIcon& icon, + const QSize& icon_size) { + if (icon.isNull()) { + painter.drawText(area, Qt::AlignCenter, text); + return; + } + const int text_w = QFontMetrics(painter.font()).horizontalAdvance(text); + const int left = area.center().x() - (text_w + 4 + icon_size.width()) / 2; + painter.drawText(QRect(left, area.top(), text_w + 2, area.height()), + Qt::AlignVCenter | Qt::AlignLeft, text); + icon.paint(&painter, QRect(left + text_w + 4, area.center().y() - icon_size.height() / 2, + icon_size.width(), icon_size.height())); +} + +/// Like the menu bar's chips: outlined, filled while an input is held; the selected tab the +/// other way round +struct TabPaint { + bool filled; + QColor fill; + QColor ink; +}; + +/// A chevron drawn as an icon, so it sits centred in its button; dimmed when disabled +QIcon ChevronIcon(const QColor& ink, bool left, qreal ratio) { + const auto draw = [&](int alpha) { + QPixmap pixmap(QSize(14, 14) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + QColor color = ink; + color.setAlpha(alpha); + painter.setPen(QPen(color, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + const qreal tip = left ? 4.5 : 9.5; + const qreal back = left ? 9.0 : 5.0; + const QPointF chevron[3] = {QPointF(back, 2.5), QPointF(tip, 7), QPointF(back, 11.5)}; + painter.drawPolyline(chevron, 3); + return pixmap; + }; + QIcon icon; + icon.addPixmap(draw(255), QIcon::Normal); + icon.addPixmap(draw(80), QIcon::Disabled); + return icon; +} + +TabPaint PaintFor(const PlayerTabLook& look, bool selected) { + if (look.lit == 3) { + return {true, QColor(200, 50, 50), QColor(Qt::white)}; + } + if (look.lit == 2) { + return {true, QColor(45, 160, 80), QColor(Qt::white)}; + } + const bool filled = selected != (look.lit == 1); + return {filled, look.accent, filled ? QColor(Qt::white) : look.accent}; +} + +} // Anonymous namespace + +PlayerTabStyle::PlayerTabStyle(const QString& base) : QProxyStyle(base) {} + +void PlayerTabStyle::drawControl(ControlElement element, const QStyleOption* option, + QPainter* painter, const QWidget* widget) const { + const auto* tab = qstyleoption_cast(option); + const auto* bar = qobject_cast(widget); + if (!tab || !bar || (element != CE_TabBarTabShape && element != CE_TabBarTabLabel)) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + // Found by its text: a dragged tab is painted into an image of its own + int index = -1; + for (int i = 0; i < bar->count() && index < 0; ++i) { + if (bar->tabText(i) == tab->text) { + index = i; + } + } + // Only a player tab is a bubble, in its player's colour; the others stay as they were + const auto look = index >= 0 ? TabLook(bar->tabData(index)) : std::nullopt; + if (!look) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + const bool selected = tab->state & State_Selected; + const TabPaint paint = PaintFor(*look, selected); + if (element == CE_TabBarTabShape) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + const QRectF pill = QRectF(tab->rect).adjusted(2, 3, -2, -3); + if (paint.filled) { + painter->setPen(Qt::NoPen); + painter->setBrush(paint.fill); + } else { + painter->setPen(QPen(look->accent, 1.5)); + QColor hover = look->accent; + hover.setAlpha(tab->state & State_MouseOver ? 45 : 0); + painter->setBrush(hover); + } + painter->drawRoundedRect(pill, pill.height() / 2, pill.height() / 2); + painter->restore(); + return; + } + // The label: bold, white on a filled tab, else in the player's colour, with its battery + // after it (between the tab's arrows) + painter->save(); + QFont font = painter->font(); + font.setBold(true); + painter->setFont(font); + painter->setPen(paint.ink); + const QRect area = tab->rect.adjusted(tab->leftButtonSize.width(), 0, + -tab->rightButtonSize.width(), 0); + PaintTabLabel(*painter, area, tab->text, tab->icon, tab->iconSize); + painter->restore(); +} + +QSize PlayerTabStyle::sizeFromContents(ContentsType type, const QStyleOption* option, + const QSize& size, const QWidget* widget) const { + QSize result = QProxyStyle::sizeFromContents(type, option, size, widget); + // Room for a player tab's rounded ends and bold label + const auto* tab = qstyleoption_cast(option); + if (type == CT_TabBarTab && tab && + (tab->text.startsWith(QChar(0x283F)) || !tab->icon.isNull())) { + result += QSize(14, 4); + if (widget) { + QFont bold = widget->font(); + bold.setBold(true); + result.rwidth() += (std::max)(0, QFontMetrics(bold).horizontalAdvance(tab->text) - + widget->fontMetrics().horizontalAdvance(tab->text)); + } + } + return result; +} + +PlayerTabOverlay::PlayerTabOverlay(QTabBar* bar_, QColor background_) + : QWidget(bar_), bar{bar_}, background{background_} { + setAttribute(Qt::WA_TransparentForMouseEvents); + setAttribute(Qt::WA_NoSystemBackground); + // The dark themes give a plain widget a solid background, which would cover the tab bar + setStyleSheet(QStringLiteral("background: transparent;")); +} + +void PlayerTabOverlay::SetDrag(int index, int offset) { + drag_index = index; + drag_offset = offset; + update(); +} + +void PlayerTabOverlay::paintEvent(QPaintEvent*) { + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + const int hovered = bar->underMouse() ? bar->tabAt(bar->mapFromGlobal(QCursor::pos())) : -1; + const auto bubble = [&](int i, QRect tab, bool around_arrows) { + const auto look = TabLook(bar->tabData(i)); + const TabPaint paint = PaintFor(*look, i == bar->currentIndex()); + const QRectF pill = QRectF(tab).adjusted(2, 3, -2, -3); + if (paint.filled) { + painter.setPen(Qt::NoPen); + painter.setBrush(paint.fill); + } else { + painter.setPen(QPen(look->accent, 1.5)); + QColor hover = look->accent; + hover.setAlpha(i == hovered ? 45 : 0); + painter.setBrush(hover); + } + painter.drawRoundedRect(pill, pill.height() / 2, pill.height() / 2); + // The label, between the tab's arrows + QRect text = tab; + if (around_arrows) { + if (const QWidget* left = bar->tabButton(i, QTabBar::LeftSide)) { + text.setLeft(left->geometry().right() + 1); + } + if (const QWidget* right = bar->tabButton(i, QTabBar::RightSide)) { + text.setRight(right->geometry().left() - 1); + } + } + QFont font = bar->font(); + font.setBold(true); + painter.setFont(font); + painter.setPen(paint.ink); + // The battery after the label + PaintTabLabel(painter, text, bar->tabText(i), bar->tabIcon(i), bar->iconSize()); + }; + for (int i = 0; i < bar->count(); ++i) { + const auto look = TabLook(bar->tabData(i)); + if (!look || !bar->isTabVisible(i)) { + continue; + } + const QRect tab = bar->tabRect(i); + // The theme's tab goes under the theme's background, and the bubble goes on top + painter.fillRect(tab, background); + if (i == drag_index) { + // Where the dragged tab came from: an empty slot + QColor edge = look->accent; + edge.setAlpha(90); + painter.setPen(QPen(edge, 1, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + const QRectF slot = QRectF(tab).adjusted(3, 4, -3, -4); + painter.drawRoundedRect(slot, slot.height() / 2, slot.height() / 2); + continue; + } + bubble(i, tab, true); + } + // The dragged tab, last, over the others + if (drag_index >= 0 && drag_index < bar->count()) { + bubble(drag_index, bar->tabRect(drag_index).translated(drag_offset, 0), false); + } +} + +void ConfigureInput::SwapPlayers(int a, int b) { + if (a == b || a < 0 || b < 0 || a >= 8 || b >= 8) { + return; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(a, b); +} + +bool ConfigureInput::eventFilter(QObject* object, QEvent* event) { + if (drag_tabs && object == drag_tabs->tabBar() && tab_overlay && tab_overlay->isVisible() && + DragPlayerTab(event)) { + return true; + } + if (drag_tabs && object == drag_tabs->tabBar() && drag_tabs->isMovable()) { + if (event->type() == QEvent::MouseButtonPress && + static_cast(event)->button() == Qt::LeftButton) { + tab_order.clear(); + for (int k = 0; k < drag_tabs->count(); ++k) { + tab_order.push_back(drag_tabs->widget(k)); + } + const QPoint at = static_cast(event)->position().toPoint(); + dragged_tab = drag_tabs->widget(drag_tabs->tabBar()->tabAt(at)); + tab_dragging = true; + } else if (event->type() == QEvent::MouseButtonRelease && tab_dragging) { + tab_dragging = false; + // After the tab bar has finished its own drop + QTimer::singleShot(0, this, &ConfigureInput::FinishTabDrag); + } + } + return QWidget::eventFilter(object, event); +} + +void ConfigureInput::FinishTabDrag() { + QTabWidget* tabs = drag_tabs; + if (!tabs || tab_order.isEmpty()) { + return; + } + // The players in the order their tabs were dropped in + std::vector dropped; + for (int k = 0; k < tabs->count(); ++k) { + const auto it = std::find(player_tabs.begin(), player_tabs.end(), tabs->widget(k)); + if (it != player_tabs.end() && tabs->isTabVisible(k)) { + dropped.push_back(static_cast(it - player_tabs.begin())); + } + } + // The tabs go back in player order: the controllers move instead + for (int k = 0; k < static_cast(tab_order.size()); ++k) { + const int from = tabs->indexOf(tab_order[k]); + if (from >= 0 && from != k) { + tabs->tabBar()->moveTab(from, k); + } + } + tab_order.clear(); + const auto dragged = std::find(player_tabs.begin(), player_tabs.end(), dragged_tab); + ApplyDroppedOrder(dropped, dragged != player_tabs.end() + ? static_cast(dragged - player_tabs.begin()) + : -1); +} + +void ConfigureInput::ApplyDroppedOrder(const std::vector& dropped, int dragged_player) { + std::vector places = dropped; + std::sort(places.begin(), places.end()); + if (dropped == places) { + return; + } + + // Place k takes the controller player dropped[k] has now, one swap at a time + std::map holder; + for (const int place : places) { + holder[place] = place; + } + for (std::size_t k = 0; k < places.size(); ++k) { + int from = places[k]; + for (const auto& [place, original] : holder) { + if (original == dropped[k]) { + from = place; + } + } + if (from != places[k]) { + SwapPlayers(places[k], from); + std::swap(holder[places[k]], holder[from]); + } + } + + // The selected tab follows the controller that was dragged + const auto at = std::find(dropped.begin(), dropped.end(), dragged_player); + if (at != dropped.end()) { + pending_select = places[at - dropped.begin()]; + pending_select_until = QDateTime::currentMSecsSinceEpoch() + 2000; + } +} + +bool ConfigureInput::DragPlayerTab(QEvent* event) { + QTabBar* bar = drag_tabs->tabBar(); + const auto type = event->type(); + if (type != QEvent::MouseButtonPress && type != QEvent::MouseMove && + type != QEvent::MouseButtonRelease) { + return false; + } + const auto* mouse = static_cast(event); + const QPoint at = mouse->position().toPoint(); + const auto set_arrows = [bar](int index, bool shown) { + for (const auto side : {QTabBar::LeftSide, QTabBar::RightSide}) { + if (QWidget* button = bar->tabButton(index, side)) { + button->setVisible(shown); + } + } + }; + if (type == QEvent::MouseButtonPress) { + const int index = bar->tabAt(at); + // (Player tabs carry their look: colour and lit state) + own_drag_index = + mouse->button() == Qt::LeftButton && index >= 0 && TabLook(bar->tabData(index)) ? index + : -1; + own_drag_press_x = at.x(); + own_dragging = false; + return false; // the tab bar still selects the tab + } + if (own_drag_index < 0) { + return false; + } + const int offset = at.x() - own_drag_press_x; + if (type == QEvent::MouseMove) { + if (!own_dragging && std::abs(offset) < QApplication::startDragDistance()) { + return false; + } + if (!own_dragging) { + own_dragging = true; + set_arrows(own_drag_index, false); + } + tab_overlay->SetDrag(own_drag_index, offset); + return true; + } + // Released: the players take the order of the tabs with the dragged one where it was let go + const int index = own_drag_index; + const bool dragged = own_dragging; + own_drag_index = -1; + own_dragging = false; + if (!dragged) { + return false; + } + set_arrows(index, true); + tab_overlay->SetDrag(-1, 0); + std::vector> centres; // (x, player) + int dragged_player = -1; + for (int k = 0; k < bar->count(); ++k) { + const auto it = std::find(player_tabs.begin(), player_tabs.end(), drag_tabs->widget(k)); + if (it == player_tabs.end() || !bar->isTabVisible(k)) { + continue; + } + const int player = static_cast(it - player_tabs.begin()); + const int x = bar->tabRect(k).center().x() + (k == index ? offset : 0); + if (k == index) { + dragged_player = player; + } + centres.emplace_back(x, player); + } + std::stable_sort(centres.begin(), centres.end()); + std::vector dropped; + for (const auto& [x, player] : centres) { + dropped.push_back(player); + } + ApplyDroppedOrder(dropped, dragged_player); + return true; +} + +void ConfigureInput::UpdateTabArrows() { + QTabWidget* tabs = drag_tabs; + if (!tabs) { + return; + } + const bool movable = tabs_editable; + int last_player = -1; + for (const auto& row : last_rows) { + if (!row.leaving) { + last_player = (std::max)(last_player, row.player); + } + } + for (int player = 0; player < static_cast(player_tabs.size()); ++player) { + const int index = tabs->indexOf(player_tabs[player]); + if (index < 0) { + continue; + } + // (A tab's buttons go when the tab is removed, as on leaving the Controls page) + auto& left = tab_lefts[player]; + auto& right = tab_rights[player]; + // Only a shown tab carries arrows (a hidden tab's would float over the bar) + if (!movable || !tabs->isTabVisible(index)) { + if (left || right) { + tabs->tabBar()->setTabButton(index, QTabBar::LeftSide, nullptr); + tabs->tabBar()->setTabButton(index, QTabBar::RightSide, nullptr); + delete left; + delete right; + } + continue; + } + if (!left || !right) { + const auto make = [this, player](Qt::ArrowType arrow, int step) { + auto* button = new QToolButton; + // A chevron in the tab's ink (the dark themes' arrows are large triangles) + button->setProperty("left", arrow == Qt::LeftArrow); + button->setIconSize(QSize(14, 14)); + button->setAutoRaise(true); + button->setFixedSize(20, 20); + // The tab makes room for the size a style sheet reports (12 pixels under the dark + // themes, too small) + button->setStyleSheet(QStringLiteral("QToolButton { width: 20px; height: 20px; " + "padding: 0px; border: none; " + "background: transparent; }")); + connect(button, &QToolButton::clicked, this, [this, player, step] { + SwapPlayers(player, player + step); + pending_select = player + step; + pending_select_until = QDateTime::currentMSecsSinceEpoch() + 2000; + }); + return button; + }; + left = make(Qt::LeftArrow, -1); + right = make(Qt::RightArrow, 1); + tabs->tabBar()->setTabButton(index, QTabBar::LeftSide, left); + tabs->tabBar()->setTabButton(index, QTabBar::RightSide, right); + } + // Above the dark themes' bubble layer, which would hide them (and shown, but mid-drag) + if (!own_dragging) { + left->show(); + right->show(); + } + left->raise(); + right->raise(); + // In the tab's ink, like the chips' arrows: white on a filled tab, else its colour + if (const auto look = TabLook(tabs->tabBar()->tabData(index))) { + const TabPaint paint = PaintFor(*look, index == tabs->currentIndex()); + for (QToolButton* button : {left.data(), right.data()}) { + if (button->property("ink").value() != paint.ink) { + button->setProperty("ink", paint.ink); + button->setIcon(ChevronIcon(paint.ink, button->property("left").toBool(), + button->devicePixelRatioF())); + } + } + } + left->setEnabled(player > 0); + left->setToolTip(tr("Swap with P%1").arg(player)); + right->setEnabled(player < last_player); + right->setToolTip(tr("Swap with P%1").arg(player + 2)); + } +} + +QWidget* ConfigureInput::PlayerTab(int player) const { + return player >= 0 && player < static_cast(player_tabs.size()) ? player_tabs[player] + : nullptr; +} + QList ConfigureInput::GetSubTabs() const { + // The settings dialog shows these, not this widget's own tab bar return { - ui->tabPlayer1, ui->tabPlayer2, ui->tabPlayer3, ui->tabPlayer4, ui->tabPlayer5, - ui->tabPlayer6, ui->tabPlayer7, ui->tabPlayer8, ui->tabAdvanced, + assignment, ui->tabAdvanced, ui->tabPlayer1, ui->tabPlayer2, ui->tabPlayer3, + ui->tabPlayer4, ui->tabPlayer5, ui->tabPlayer6, ui->tabPlayer7, ui->tabPlayer8, }; } @@ -216,6 +1050,7 @@ void ConfigureInput::ApplyConfiguration() { } advanced->ApplyConfiguration(); + assignment->ApplyConfiguration(); const bool pre_docked_mode = Settings::IsDockedMode(); const bool docked_mode_selected = ui->radioDocked->isChecked(); diff --git a/src/yuzu/configuration/configure_input.h b/src/yuzu/configuration/configure_input.h index 9bdd0d68ca..6b1d46a4bf 100644 --- a/src/yuzu/configuration/configure_input.h +++ b/src/yuzu/configuration/configure_input.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2016 Citra Emulator Project @@ -7,12 +7,20 @@ #pragma once #include +#include #include +#include +#include #include #include +#include +#include +#include #include +#include "common/controller_console_state.h" + namespace Core { class System; } @@ -20,9 +28,12 @@ class System; class QCheckBox; class QString; class QTimer; +class QTabBar; +class QTabWidget; class ConfigureInputAdvanced; class ConfigureInputPlayer; +class ConfigurePlayerAssignment; class InputProfiles; @@ -36,6 +47,35 @@ class ConfigureInput; void OnDockedModeChanged(bool last_state, bool new_state, Core::System& system); +/// The settings tab bar in the automatic modes: player tabs drawn as bubbles in their colour +class PlayerTabStyle : public QProxyStyle { +public: + explicit PlayerTabStyle(const QString& base); + void drawControl(ControlElement element, const QStyleOption* option, QPainter* painter, + const QWidget* widget) const override; + QSize sizeFromContents(ContentsType type, const QStyleOption* option, const QSize& size, + const QWidget* widget) const override; +}; + +/// Under Eden's dark themes the style sheet draws the tabs: this layer paints the player tabs' +/// bubbles instead +class PlayerTabOverlay : public QWidget { +public: + PlayerTabOverlay(QTabBar* bar, QColor background); + /// A player tab being dragged (-1: none), drawn this far from its place + void SetDrag(int index, int offset); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + QTabBar* bar; + /// The theme's own background, which covers its tab under the bubble + QColor background; + int drag_index = -1; + int drag_offset = 0; +}; + class ConfigureInput : public QWidget { Q_OBJECT @@ -51,8 +91,22 @@ public: QList GetSubTabs() const; + /// A player's tab (0-based player) + QWidget* PlayerTab(int player) const; + private: void changeEvent(QEvent* event) override; + bool eventFilter(QObject* object, QEvent* event) override; + + /// Player tabs dropped in a new order: the controllers take that order, the tabs go back + void FinishTabDrag(); + /// The players' new order, as their tabs were dropped: the controllers move to match + void ApplyDroppedOrder(const std::vector& dropped, int dragged_player); + /// Eden's own drag of a player tab, under the dark themes (Qt's would be the theme's tab) + bool DragPlayerTab(QEvent* event); + /// The arrows on each player tab, swapping with the player before or after it + void UpdateTabArrows(); + void SwapPlayers(int a, int b); void RetranslateUI(); void ClearAll(); @@ -75,7 +129,39 @@ private: std::unique_ptr profiles; std::array player_controllers{}; + ConfigurePlayerAssignment* assignment = nullptr; + /// Redraws the player tabs when the engine reloads the controllers + QTimer* follow_timer = nullptr; + unsigned long long followed_generation = 0; + std::function mirror_console_mode; + /// The settings dialog's tab widget whose tab clicks rumble the players' controllers + QTabWidget* rumble_tabs = nullptr; std::array player_tabs{}; + /// Dragging the player tabs: the tab bar, and the order when the drag began + QTabWidget* drag_tabs = nullptr; + /// The tab bar drawn with PlayerTabStyle (its player tabs as bubbles) + QPointer pill_bar; + QPointer tab_overlay; + /// The players can be reordered from their tabs (the automatic modes, on this page) + bool tabs_editable = false; + /// Eden's own tab drag: the tab pressed, where, and whether it is being dragged + int own_drag_index = -1; + int own_drag_press_x = 0; + bool own_dragging = false; + QList tab_order; + QWidget* dragged_tab = nullptr; + bool tab_dragging = false; + std::array player_tab_titles{}; + std::vector last_rows; + /// How each player tab is lit (0 idle, 1 input held, 2 green blink, 3 red blink) + std::array tab_lit{}; + /// The battery each player tab shows (percent * 2 + charging, -1 none) + std::array tab_battery{-1, -1, -1, -1, -1, -1, -1, -1}; + std::array, 8> tab_lefts; + std::array, 8> tab_rights; + /// The tab to select once its player shows (the controller a drag or an arrow moved) + int pending_select = -1; + long long pending_select_until = 0; // Checkboxes representing the "Connected Controllers". std::array connected_controller_checkboxes{}; ConfigureInputAdvanced* advanced = nullptr; diff --git a/src/yuzu/configuration/configure_input_player.cpp b/src/yuzu/configuration/configure_input_player.cpp index 2750cd4a13..6178dadd53 100644 --- a/src/yuzu/configuration/configure_input_player.cpp +++ b/src/yuzu/configuration/configure_input_player.cpp @@ -6,13 +6,21 @@ #include #include +#include +#include #include +#include +#include +#include #include #include #include #include #include +#include +#include #include "common/assert.h" +#include "common/controller_console_state.h" #include "common/param_package.h" #include "hid_core/frontend/emulated_controller.h" #include "hid_core/hid_core.h" @@ -26,6 +34,7 @@ #include "yuzu/configuration/configure_input_player.h" #include "yuzu/configuration/configure_input_player_widget.h" #include "yuzu/configuration/configure_mouse_panning.h" +#include "yuzu/configuration/configure_player_assignment.h" #include "yuzu/configuration/input_profiles.h" #include "yuzu/util/limitable_input_dialog.h" @@ -289,6 +298,42 @@ QString ConfigureInputPlayer::AnalogToText(const Common::ParamPackage& param, return QObject::tr("[unknown]"); } +namespace { + +/// The SDL controller (guid, port) a player's bindings point at +std::optional> SdlDevice(const Settings::PlayerInput& player) { + for (const std::string& raw : player.buttons) { + const Common::ParamPackage param{raw}; + if (!raw.empty() && param.Get("engine", "") == "sdl") { + return std::pair{param.Get("guid", ""), param.Get("port", 0)}; + } + } + return std::nullopt; +} + +/// Points a player's SDL bindings at one controller, leaving other engines alone +void KeepSdlDevice(Settings::PlayerInput& player, const std::pair& device) { + const auto keep = [&device](auto& params) { + for (std::string& raw : params) { + Common::ParamPackage param{raw}; + if (raw.empty() || param.Get("engine", "") != "sdl") { + continue; + } + if (param.Has("guid2") && param.Get("guid2", "") == param.Get("guid", "")) { + param.Set("guid2", device.first); + } + param.Set("guid", device.first); + param.Set("port", device.second); + raw = param.Serialize(); + } + }; + keep(player.buttons); + keep(player.analogs); + keep(player.motions); +} + +} // Anonymous namespace + ConfigureInputPlayer::ConfigureInputPlayer(QWidget* parent, std::size_t player_index_, QWidget* bottom_row_, InputCommon::InputSubsystem* input_subsystem_, @@ -901,6 +946,552 @@ void ConfigureInputPlayer::ConnectPlayer(bool connected) { } } +void ConfigureInputPlayer::RefreshFromController() { + UpdateUI(); + UpdateInputDeviceCombobox(); + ApplyJoyConLock(); +} + +void ConfigureInputPlayer::ApplyJoyConLock() { + int held = 0; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + if (state.engine_active && player_index < state.joycon_held.size()) { + held = state.joycon_held[player_index]; + } + } + const bool locked = held != 0; + const bool changed = locked != joycon_locked; + joycon_locked = locked; + for (auto* button : button_map) { + if (button) { + button->setEnabled(!locked); + } + } + for (std::size_t i = 0; i < analog_map_buttons.size(); ++i) { + for (auto* button : analog_map_buttons[i]) { + if (button) { + button->setEnabled(!locked); + } + } + for (QWidget* widget : std::initializer_list{ + analog_map_modifier_button[i], analog_map_deadzone_slider[i], + analog_map_modifier_slider[i], analog_map_range_spinbox[i]}) { + if (widget) { + widget->setEnabled(!locked); + } + } + } + // (Saving would keep the Joy-Con's arrangement as the player's own mapping) + if (card_save) { + card_save->setEnabled(!locked); + card_save_new->setEnabled(!locked); + } + if (joycon_note) { + joycon_note->setVisible(locked); + if (locked) { + joycon_note->setText( + (held == 1 ? tr("P%1 plays as a sideways Joy-Con for this game") + : tr("P%1 plays as an upright Joy-Con for this game")) + .arg(player_index + 1) + + tr(": its controls are set automatically. Pick a controller type to set them " + "yourself.")); + } + } + if (card_status) { + card_status->setVisible(!locked); + } + if (changed && !locked && profile_card && profile_card->isVisible()) { + UpdateProfileCard(); + } +} + +void ConfigureInputPlayer::SetConnectionLocked(bool locked) { + const bool changed = connection_locked != locked || !style_applied; + connection_locked = locked; + style_applied = true; + // The automatic modes draw the tab like the Controllers tab, keeping the spacing (the page + // fills the Deck's screen exactly) + if (changed) { + setStyleSheet( + locked ? ConfigurePlayerAssignment::Themed(QStringLiteral( + "QLabel { background: transparent; }" + "QGroupBox { border: 1px solid palette(mid); border-radius: 8px; " + "margin-top: 1.1em; padding-bottom: 5px; " + "background: palette(alternate-base); }" + "QGroupBox::title { subcontrol-origin: margin; " + "subcontrol-position: top center; padding: 0 3px; }" + "QGroupBox QGroupBox { border: none; background: transparent; " + "padding-bottom: 0px; }" + "QGroupBox QGroupBox::title { padding: 0px; }" + // The single-button boxes carry their label inside, above the button + "QGroupBox#buttonShoulderButtonsButtonZLGroup, " + "QGroupBox#buttonShoulderButtonsButtonLGroup, " + "QGroupBox#buttonMiscButtonsMinusGroup, " + "QGroupBox#buttonMiscButtonsPlusGroup, " + "QGroupBox#buttonShoulderButtonsZRGroup, " + "QGroupBox#buttonShoulderButtonsRGroup, " + "QGroupBox#buttonMiscButtonsHomeGroup, " + "QGroupBox#buttonMiscButtonsScreenshotGroup { margin-top: 0px; " + "padding-top: 1.3em; }" + // The top row starts level with the section cards beside it + "QGroupBox#buttonShoulderButtonsButtonZLGroup, " + "QGroupBox#buttonShoulderButtonsButtonLGroup, " + "QGroupBox#buttonMiscButtonsMinusGroup, " + "QGroupBox#buttonMiscButtonsPlusGroup, " + "QGroupBox#buttonShoulderButtonsZRGroup, " + "QGroupBox#buttonShoulderButtonsRGroup { margin-top: 1.1em; }" + "QGroupBox#buttonShoulderButtonsButtonZLGroup::title, " + "QGroupBox#buttonShoulderButtonsButtonLGroup::title, " + "QGroupBox#buttonMiscButtonsMinusGroup::title, " + "QGroupBox#buttonMiscButtonsPlusGroup::title, " + "QGroupBox#buttonShoulderButtonsZRGroup::title, " + "QGroupBox#buttonShoulderButtonsRGroup::title, " + "QGroupBox#buttonMiscButtonsHomeGroup::title, " + "QGroupBox#buttonMiscButtonsScreenshotGroup::title { " + "subcontrol-origin: padding; subcontrol-position: top center; " + "top: 3px; padding: 0px; }" + "QPushButton { border: 1px solid palette(mid); border-radius: 6px; " + "padding: 2px 6px; background: palette(base); }" + "QPushButton:hover { border-color: palette(highlight); }" + "QPushButton:pressed, QPushButton:checked { " + "background: palette(highlight); color: palette(highlighted-text); }")) + + ConfigurePlayerAssignment::ModernComboStyle() + : QString{}); + if (!locked) { + // The manual modes get the tab exactly as it was + if (home_centred) { + home_centred = false; + ui->miscButtons->setStretch(0, 0); + delete ui->miscButtons->takeAt(ui->miscButtons->count() - 1); + } + if (shoulders_raised) { + shoulders_raised = false; + for (QPushButton* button : + {ui->buttonL, ui->buttonR, ui->buttonMinus, ui->buttonPlus}) { + if (auto* box = qobject_cast( + button->parentWidget() ? button->parentWidget()->layout() : nullptr)) { + delete box->takeAt(box->count() - 1); + } + } + } + if (QLayout* connect = ui->groupConnectedController->layout()) { + connect->setAlignment(ui->comboControllerType, Qt::Alignment{}); + } + // Back to the inherited fonts + for (QGroupBox* box : bolded_boxes) { + if (box) { + box->setFont(QFont()); + for (QWidget* child : box->findChildren(Qt::FindDirectChildrenOnly)) { + child->setFont(QFont()); + } + } + } + bolded_boxes.clear(); + } + if (locked) { + ConfigurePlayerAssignment::ModernizeCombos(this); + // Bold section titles (a style sheet can't bold only a title) + QFont normal = font(); + normal.setBold(false); // explicit, or the box's bold is inherited after all + QFont bold = normal; + bold.setBold(true); + for (QGroupBox* box : findChildren()) { + // No box around a section + bool inside_box = false; + for (QWidget* up = box->parentWidget(); up && up != this; up = up->parentWidget()) { + inside_box = inside_box || qobject_cast(up) != nullptr; + } + const bool section = + !inside_box && !box->objectName().startsWith(QStringLiteral("buttonMisc")) && + !box->objectName().startsWith(QStringLiteral("buttonShoulderButtons")); + if (!section) { + continue; + } + box->setFont(bold); + for (QWidget* child : box->findChildren(Qt::FindDirectChildrenOnly)) { + child->setFont(normal); + } + bolded_boxes.push_back(box); + } + // Home centred under the controller + if (!home_centred) { + home_centred = true; + ui->miscButtons->setStretch(0, 1); + ui->miscButtons->addStretch(1); + } + // Connect Controller's drop-down level with the profile card's + if (QLayout* connect = ui->groupConnectedController->layout()) { + connect->setAlignment(ui->comboControllerType, Qt::AlignTop); + } + // L, R, Minus and Plus line up with ZL and ZR: a fixed space the size of ZL's + // trigger slider under each + if (!shoulders_raised) { + shoulders_raised = true; + const int slider = ui->sliderZLThreshold->sizeHint().height(); + for (QPushButton* button : + {ui->buttonL, ui->buttonR, ui->buttonMinus, ui->buttonPlus}) { + if (auto* box = qobject_cast( + button->parentWidget() ? button->parentWidget()->layout() : nullptr)) { + box->addSpacerItem(new QSpacerItem( + 0, slider + box->spacing(), QSizePolicy::Minimum, QSizePolicy::Fixed)); + } + } + } + } + } + if (!profile_card && locked) { + BuildProfileCard(); + } + ui->profilesGroup->setVisible(!locked); + if (profile_card) { + profile_card->setVisible(locked); + if (locked) { + card_timer->start(); + UpdateProfileCard(); + } else { + card_timer->stop(); + } + } + ApplyJoyConLock(); + ui->devicesGroup->setVisible(!locked); + // The automatic modes bind motion themselves (the controller's own, or its real device's) + ui->buttonMotionLeftGroup->setVisible(!locked); + ui->buttonMotionRightGroup->setVisible(!locked); + if (ui->groupConnectedController->isCheckable() == !locked) { + return; + } + const bool blocked = ui->groupConnectedController->blockSignals(true); + ui->groupConnectedController->setCheckable(!locked); + if (!locked) { + ui->groupConnectedController->setChecked(emulated_controller->IsConnected(true)); + } + ui->groupConnectedController->blockSignals(blocked); +} + +namespace { + +/// A binding without its controller (guid, port), its fields in a fixed order +std::string MappingKey(const std::string& raw) { + if (raw.empty()) { + return {}; + } + Common::ParamPackage param{raw}; + if (param.Get("engine", "") == "sdl") { + param.Erase("guid"); + param.Erase("guid2"); + param.Erase("port"); + } + std::vector fields; + std::string field; + for (const char c : param.Serialize() + ",") { + if (c != ',') { + field += c; + continue; + } + fields.push_back(field); + field.clear(); + } + std::sort(fields.begin(), fields.end()); + std::string out; + for (const auto& f : fields) { + out += f + ';'; + } + return out; +} + +/// Asks the engine to put a profile on a player's controller ("" drops it): the engine owns the +/// mappings in the automatic modes +void RequestProfile(std::size_t player, const QString& name, bool restore = false) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.profile_requests.push_back({static_cast(player), name.toStdString(), restore}); +} + +/// The box layout that holds a widget, searched from a top layout down +QBoxLayout* LayoutHolding(QLayout* layout, QWidget* widget) { + if (!layout) { + return nullptr; + } + if (layout->indexOf(widget) >= 0) { + return qobject_cast(layout); + } + for (int i = 0; i < layout->count(); ++i) { + if (auto* found = LayoutHolding(layout->itemAt(i)->layout(), widget)) { + return found; + } + } + return nullptr; +} + +} // Anonymous namespace + +void ConfigureInputPlayer::BuildProfileCard() { + profile_card = new QGroupBox(tr("Mapping Profile"), this); + auto* column = new QVBoxLayout(profile_card); + // Connect Controller's margins, so the two drop-downs are level + if (const QLayout* connect = ui->groupConnectedController->layout()) { + column->setContentsMargins(connect->contentsMargins()); + } + auto* row = new QHBoxLayout; + card_profiles = new QComboBox(profile_card); + card_profiles->setPlaceholderText(tr("No profile")); + ConfigurePlayerAssignment::ModernizeCombos(profile_card); + // Fills the row up to its buttons, which end at the card's edge + card_profiles->setMinimumWidth(160); + card_profiles->setToolTip(tr("Picking a profile applies it to this player at once.")); + card_save = new QPushButton(tr("Save"), profile_card); + card_save->setDefault(false); + card_save->setAutoDefault(false); + // The profile's actions, all in view beside Save (Defaults, below the drawing, resets) + const auto action = [this](const QString& text, const QString& tip) { + auto* button = new QPushButton(text, profile_card); + button->setAutoDefault(false); + button->setToolTip(tip); + return button; + }; + card_save_new = action(tr("Save as New"), tr("Keep this mapping as a new profile")); + card_rename = action(tr("Rename"), tr("Rename the selected profile")); + card_duplicate = action(tr("Duplicate"), tr("Copy the selected profile under a new name")); + card_delete = action(tr("Delete"), tr("Delete the selected profile; this player keeps its " + "current mapping")); + // (Inside the drop-down) takes the profile off: the mappings from before it come back + card_remove = ConfigurePlayerAssignment::AddClearButton(card_profiles); + card_remove->setToolTip(tr("Remove the profile from this player")); + row->addWidget(card_profiles, 1); + row->addWidget(card_save); + row->addWidget(card_save_new); + row->addWidget(card_rename); + row->addWidget(card_duplicate); + row->addWidget(card_delete); + column->addLayout(row); + card_status = new QLabel(profile_card); + card_status->setWordWrap(true); + column->addWidget(card_status); + joycon_note = new QLabel(profile_card); + joycon_note->setWordWrap(true); + joycon_note->setVisible(false); + column->addWidget(joycon_note); + // No taller than its rows: the drawing below gets the height + profile_card->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); + + connect(card_save_new, &QPushButton::clicked, this, [this] { + const int count = ui->comboProfiles->count(); + CreateProfile(); + if (ui->comboProfiles->count() > count) { + RequestProfile(player_index, ui->comboProfiles->currentText()); + } + RememberProfileMapping(); + UpdateProfileCard(); + }); + connect(card_rename, &QPushButton::clicked, this, &ConfigureInputPlayer::RenameProfile); + connect(card_duplicate, &QPushButton::clicked, this, &ConfigureInputPlayer::DuplicateProfile); + connect(card_delete, &QPushButton::clicked, this, &ConfigureInputPlayer::DeleteProfileAsked); + + // The engine owns the mappings here: picking or saving a profile goes through it + connect(card_profiles, qOverload(&QComboBox::activated), this, [this](int index) { + ui->comboProfiles->setCurrentIndex(index); + RequestProfile(player_index, ui->comboProfiles->currentText()); + RememberProfileMapping(); + UpdateProfileCard(); + }); + connect(card_remove, &QToolButton::clicked, this, [this] { + RequestProfile(player_index, {}, true); + ui->comboProfiles->setCurrentIndex(-1); + RememberProfileMapping(); + UpdateProfileCard(); + }); + connect(card_save, &QPushButton::clicked, this, [this] { + if (ui->comboProfiles->currentIndex() < 0) { + CreateProfile(); + } else { + SaveProfile(); + } + // (A new profile left unnamed leaves nothing selected) + if (ui->comboProfiles->currentIndex() >= 0) { + RequestProfile(player_index, ui->comboProfiles->currentText()); + } + RememberProfileMapping(); + UpdateProfileCard(); + }); + + // Mapping edits come from many places (polling, menus, defaults): look again now and then + card_timer = new QTimer(this); + card_timer->setInterval(500); + connect(card_timer, &QTimer::timeout, this, &ConfigureInputPlayer::UpdateProfileCard); + + if (auto* layout = LayoutHolding(this->layout(), ui->profilesGroup)) { + layout->insertWidget(layout->indexOf(ui->profilesGroup) + 1, profile_card, 1); + } + // A bold title like the other sections, with its contents in the normal font + QFont normal = font(); + normal.setBold(false); // explicit, or the card's bold is inherited after all + QFont bold = normal; + bold.setBold(true); + profile_card->setFont(bold); + for (QWidget* child : profile_card->findChildren(Qt::FindDirectChildrenOnly)) { + child->setFont(normal); + } + profile_card->setVisible(false); +} + +void ConfigureInputPlayer::UpdateProfileCard() { + if (!profile_card || !profile_card->isVisible()) { + return; + } + // Mirror the profile list the rest of the tab keeps in comboProfiles + bool same = card_profiles->count() == ui->comboProfiles->count(); + for (int i = 0; same && i < card_profiles->count(); ++i) { + same = card_profiles->itemText(i) == ui->comboProfiles->itemText(i); + } + if (!same || card_profiles->currentIndex() != ui->comboProfiles->currentIndex()) { + const bool blocked = card_profiles->blockSignals(true); + card_profiles->clear(); + for (int i = 0; i < ui->comboProfiles->count(); ++i) { + card_profiles->addItem(ui->comboProfiles->itemText(i)); + } + card_profiles->setCurrentIndex(ui->comboProfiles->currentIndex()); + card_profiles->blockSignals(blocked); + } + // The engine changes the player's profile too (a swap, the Controllers tab): follow it + const QString carried = + QString::fromStdString(Settings::values.players.GetValue()[player_index].profile_name); + if (carried != card_engine_profile) { + card_engine_profile = carried; + const bool blocked = ui->comboProfiles->blockSignals(true); + ui->comboProfiles->setCurrentIndex(ui->comboProfiles->findText(carried)); + ui->comboProfiles->blockSignals(blocked); + card_profiles->setCurrentIndex(ui->comboProfiles->currentIndex()); + } + const QString name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() >= 0 && name != card_profile_name) { + RememberProfileMapping(); + } + + const QString follows = tr("Applies to this player's controller, whichever one it is."); + const bool selected = ui->comboProfiles->currentIndex() >= 0; + card_remove->setVisible(selected); + card_rename->setEnabled(selected); + card_duplicate->setEnabled(selected); + card_delete->setEnabled(selected); + if (ui->comboProfiles->currentIndex() < 0) { + card_save->setToolTip(tr("Keep this mapping as a new profile (asks for its name)")); + // In Modern Console a remap lasts the session; a profile is what keeps controls + card_status->setText(tr("No profile: this player uses its usual controls. Changes made " + "here last for this session; Save as New keeps them.")); + card_status->setPalette(palette()); + return; + } + card_save->setToolTip(tr("Save this mapping to \u201c%1\u201d").arg(name)); + QPalette status = palette(); + if (CurrentMappingKey() != card_profile_key) { + card_status->setText(tr("\u25cf Unsaved changes \u00b7 %1").arg(follows)); + status.setColor(QPalette::WindowText, QColor(208, 138, 30)); + } else { + card_status->setText(tr("\u2713 Saved \u00b7 %1").arg(follows)); + status.setColor(QPalette::WindowText, status.color(QPalette::PlaceholderText)); + } + card_status->setPalette(status); +} + +std::string ConfigureInputPlayer::CurrentMappingKey() const { + // Motion is the engine's while players are assigned automatically, so it is left out + std::string key; + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + key += MappingKey(emulated_controller->GetButtonParam(i).Serialize()) + '|'; + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + key += MappingKey(emulated_controller->GetStickParam(i).Serialize()) + '|'; + } + return key; +} + +void ConfigureInputPlayer::RememberProfileMapping() { + card_profile_name = ui->comboProfiles->currentText(); + // The player keeps its profile's name, so the card shows it the next time too + Settings::values.players.GetValue()[player_index].profile_name = + ui->comboProfiles->currentIndex() < 0 ? std::string{} : card_profile_name.toStdString(); + card_profile_key.clear(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + const auto read = profiles->PeekProfile(card_profile_name.toStdString(), player_index); + if (!read) { + return; + } + for (const auto& button : read->buttons) { + card_profile_key += MappingKey(button) + '|'; + } + for (const auto& analog : read->analogs) { + card_profile_key += MappingKey(analog) + '|'; + } +} + +void ConfigureInputPlayer::SelectProfile(const QString& name) { + UpdateInputProfiles(); + ui->comboProfiles->setCurrentIndex(ui->comboProfiles->findText(name)); + emit RefreshInputProfiles(player_index); + RequestProfile(player_index, name); + RememberProfileMapping(); + UpdateProfileCard(); +} + +void ConfigureInputPlayer::RenameProfile() { + const QString old_name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + const auto new_name = + LimitableInputDialog::GetText(this, tr("Rename Profile"), tr("Enter a new name:"), 1, 30, + LimitableInputDialog::InputLimiter::Filesystem); + if (new_name.isEmpty() || new_name == old_name) { + return; + } + if (!InputProfiles::IsProfileNameValid(new_name.toStdString()) || + !profiles->RenameProfile(old_name.toStdString(), new_name.toStdString())) { + QMessageBox::critical( + this, tr("Rename Input Profile"), + tr("Could not rename \u201c%1\u201d to \u201c%2\u201d.").arg(old_name, new_name)); + return; + } + SelectProfile(new_name); +} + +void ConfigureInputPlayer::DuplicateProfile() { + const QString name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + QString copy = tr("%1 copy").arg(name); + for (int n = 2; ui->comboProfiles->findText(copy) >= 0; ++n) { + copy = tr("%1 copy %2").arg(name).arg(n); + } + if (!profiles->DuplicateProfile(name.toStdString(), copy.toStdString())) { + QMessageBox::critical(this, tr("Duplicate Input Profile"), + tr("Could not duplicate \u201c%1\u201d.").arg(name)); + return; + } + SelectProfile(copy); +} + +void ConfigureInputPlayer::DeleteProfileAsked() { + const QString name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + if (QMessageBox::question(this, tr("Delete Input Profile"), + tr("Delete the profile \u201c%1\u201d? This player keeps its " + "current mapping.") + .arg(name)) != QMessageBox::Yes) { + return; + } + DeleteProfile(); + RequestProfile(player_index, {}); + RememberProfileMapping(); + UpdateProfileCard(); +} + void ConfigureInputPlayer::UpdateInputDeviceCombobox() { // Skip input device persistence if "Input Devices" is set to "Any". if (ui->comboDevices->currentIndex() == 0) { @@ -978,10 +1569,76 @@ void ConfigureInputPlayer::UpdateInputDeviceCombobox() { } void ConfigureInputPlayer::RestoreDefaults() { + if (joycon_locked) { + return; + } + // In the automatic modes the device list is hidden (and may say "Any"): the defaults are + // those of the controller the bindings point at, or pointed at before Clear + if (connection_locked) { + if (const auto device = CurrentSdlDevice()) { + locked_device = device; + } + if (locked_device) { + const auto it = std::find_if( + input_devices.begin(), input_devices.end(), [this](const auto& param) { + return param.Get("engine", "") == "sdl" && + param.Get("guid", "") == locked_device->first && + param.Get("port", 0) == locked_device->second; + }); + if (it != input_devices.end()) { + ui->comboDevices->setCurrentIndex( + static_cast(std::distance(input_devices.begin(), it))); + } + } + } + // (Motion is the engine's in these modes, and hidden: a DSU binding stays) + const auto motions = KeptMotions(); UpdateMappingWithDefaults(); + RestoreMotions(motions); +} + +std::optional> ConfigureInputPlayer::CurrentSdlDevice() const { + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + const auto param = emulated_controller->GetButtonParam(button_id); + if (param.Get("engine", "") == "sdl") { + return std::pair{param.Get("guid", ""), param.Get("port", 0)}; + } + } + return std::nullopt; +} + +std::vector ConfigureInputPlayer::KeptMotions() const { + std::vector motions; + if (connection_locked) { + for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) { + motions.push_back( + emulated_controller->GetMotionParam(static_cast(motion_id))); + } + } + return motions; +} + +void ConfigureInputPlayer::RestoreMotions(const std::vector& motions) { + for (std::size_t motion_id = 0; motion_id < motions.size(); ++motion_id) { + emulated_controller->SetMotionParam(motion_id, motions[motion_id]); + } } void ConfigureInputPlayer::ClearAll() { + if (joycon_locked) { + return; + } + // (The controller is remembered, for Defaults afterwards) + if (const auto device = CurrentSdlDevice()) { + locked_device = device; + } + // (Motion is the engine's in the automatic modes, and hidden: a DSU binding stays) + const auto motions = KeptMotions(); + ClearMappings(); + RestoreMotions(motions); +} + +void ConfigureInputPlayer::ClearMappings() { for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { const auto* const button = button_map[button_id]; if (button == nullptr) { @@ -1634,6 +2291,11 @@ void ConfigureInputPlayer::LoadProfile() { ApplyConfiguration(); + // In the automatic modes the profile goes onto the player's current controller (the one + // saved with it would read as a swap) + auto& player = Settings::values.players.GetValue()[player_index]; + const auto device = connection_locked ? SdlDevice(player) : std::nullopt; + if (!profiles->LoadProfile(profile_name.toStdString(), player_index)) { QMessageBox::critical(this, tr("Load Input Profile"), tr("Failed to load the input profile \"%1\"").arg(profile_name)); @@ -1641,6 +2303,9 @@ void ConfigureInputPlayer::LoadProfile() { emit RefreshInputProfiles(player_index); return; } + if (device) { + KeepSdlDevice(player, *device); + } LoadConfiguration(); } diff --git a/src/yuzu/configuration/configure_input_player.h b/src/yuzu/configuration/configure_input_player.h index fda09e9257..2c0db9bbca 100644 --- a/src/yuzu/configuration/configure_input_player.h +++ b/src/yuzu/configuration/configure_input_player.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -10,6 +13,7 @@ #include #include +#include #include #include "common/param_package.h" @@ -17,6 +21,8 @@ #include "ui_configure_input.h" class QCheckBox; +class QComboBox; +class QGroupBox; class QKeyEvent; class QLabel; class QPushButton; @@ -24,6 +30,7 @@ class QSlider; class QSpinBox; class QString; class QTimer; +class QToolButton; class QWidget; class InputProfiles; @@ -65,6 +72,18 @@ public: /// Update the input devices combobox. void UpdateInputDeviceCombobox(); + /// Redraws the tab from its emulated controller after the engine reloaded it + void RefreshFromController(); + + /// Use Any Controller as Joy-Cons: while this player plays as a single Joy-Con its mapping + /// is set automatically, and read-only here (with a note why) + [[nodiscard]] bool JoyConLocked() const { + return joycon_locked; + } + + /// In the automatic modes the tab hides the connection, device and motion choices + void SetConnectionLocked(bool locked); + /// Updates the list of controller profiles. void UpdateInputProfiles(); @@ -74,6 +93,15 @@ public: /// Clear all input configuration. void ClearAll(); + /// The SDL controller (guid, port) the player's bindings point at now, if any + [[nodiscard]] std::optional> CurrentSdlDevice() const; + /// Clear's work: every binding emptied + void ClearMappings(); + /// The player's motion bindings, kept across Clear and Defaults in the automatic modes + /// (motion is hidden there); none otherwise + [[nodiscard]] std::vector KeptMotions() const; + void RestoreMotions(const std::vector& motions); + signals: /// Emitted when this controller is (dis)connected by the user. void Connected(bool connected); @@ -172,6 +200,44 @@ private: std::size_t player_index; bool debug; bool is_powered_on; + /// Players are assigned automatically: the connection and controller are not this tab's + bool connection_locked{}; + /// The SDL controller (guid, port) the bindings last pointed at, for Defaults after Clear + std::optional> locked_device; + bool style_applied{}; + bool home_centred{}; + bool shoulders_raised{}; + /// The section boxes given a bold title in the automatic modes + std::vector> bolded_boxes; + + /// The profile card shown instead of the Profile group in the automatic modes (comboProfiles + /// stays the selection) + void BuildProfileCard(); + void UpdateProfileCard(); + /// The selected profile's mapping, kept to tell when the tab's mapping differs from it + void RememberProfileMapping(); + std::string CurrentMappingKey() const; + void SelectProfile(const QString& name); + void RenameProfile(); + void DuplicateProfile(); + void DeleteProfileAsked(); + QGroupBox* profile_card{}; + QComboBox* card_profiles{}; + QPushButton* card_save{}; + QToolButton* card_remove{}; + QPushButton* card_save_new{}; + QPushButton* card_rename{}; + QPushButton* card_duplicate{}; + QPushButton* card_delete{}; + QLabel* card_status{}; + QLabel* joycon_note{}; + bool joycon_locked = false; + void ApplyJoyConLock(); + QTimer* card_timer{}; + std::string card_profile_key; + QString card_profile_name; + /// The profile the engine last reported for this player + QString card_engine_profile; InputCommon::InputSubsystem* input_subsystem; diff --git a/src/yuzu/configuration/configure_input_player_widget.cpp b/src/yuzu/configuration/configure_input_player_widget.cpp index faf4e021ec..5d6584612a 100644 --- a/src/yuzu/configuration/configure_input_player_widget.cpp +++ b/src/yuzu/configuration/configure_input_player_widget.cpp @@ -230,6 +230,14 @@ void PlayerControlPreview::paintEvent(QPaintEvent* event) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); const QPointF center = rect().center(); + // The drawings need about 450x330: a smaller frame (the Steam Deck's 800-pixel screen) gets + // them scaled down to fit instead of cut off at the bottom + const qreal fit = std::min({1.0, width() / 450.0, height() / 330.0}); + if (fit < 1.0) { + p.translate(center); + p.scale(fit, fit); + p.translate(-center); + } switch (controller_type) { case Core::HID::NpadStyleIndex::Handheld: diff --git a/src/yuzu/configuration/configure_modern_per_game.cpp b/src/yuzu/configuration/configure_modern_per_game.cpp new file mode 100644 index 0000000000..97a61f2345 --- /dev/null +++ b/src/yuzu/configuration/configure_modern_per_game.cpp @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include "common/settings.h" +#include "core/core.h" +#include "yuzu/configuration/configure_modern_per_game.h" +#include "yuzu/configuration/shared_widget.h" + +ConfigureModernPerGame::ConfigureModernPerGame( + Core::System& system_, std::shared_ptr> group, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group, parent), system{system_} { + auto* layout = new QVBoxLayout(this); + auto* box = new QGroupBox(tr("Modern Console"), this); + auto* box_layout = new QVBoxLayout(box); + auto* note = new QLabel(tr("For this game only. Each option follows the one in Emulation > " + "Configure > Controls > Controllers until it is set here."), + box); + note->setWordWrap(true); + box_layout->addWidget(note); + for (Settings::BasicSetting* setting : std::initializer_list{ + &Settings::values.console_joycon_auto, &Settings::values.console_button_labels, + &Settings::values.console_pause_disconnect, &Settings::values.console_player_lights, + &Settings::values.console_debug_overlay, &Settings::values.console_hints}) { + ConfigurationShared::Widget* widget = builder.BuildWidget(setting, apply_funcs); + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + box_layout->addWidget(widget); + } + layout->addWidget(box); + layout->addStretch(1); +} + +ConfigureModernPerGame::~ConfigureModernPerGame() = default; + +void ConfigureModernPerGame::SetConfiguration() {} + +void ConfigureModernPerGame::ApplyConfiguration() { + // (While a game runs, only the options it can change in game are applied) + const bool powered_on = system.IsPoweredOn(); + for (const auto& apply : apply_funcs) { + apply(powered_on); + } +} diff --git a/src/yuzu/configuration/configure_modern_per_game.h b/src/yuzu/configuration/configure_modern_per_game.h new file mode 100644 index 0000000000..644f247550 --- /dev/null +++ b/src/yuzu/configuration/configure_modern_per_game.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "yuzu/configuration/configuration_shared.h" + +namespace ConfigurationShared { +class Builder; +} + +namespace Core { +class System; +} + +/// The Modern Console options a game can have of its own (in its properties): each follows the +/// global choice (Emulation > Configure > Controls) unless set for the game +class ConfigureModernPerGame : public ConfigurationShared::Tab { +public: + explicit ConfigureModernPerGame(Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, + QWidget* parent = nullptr); + ~ConfigureModernPerGame() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + Core::System& system; + std::vector> apply_funcs; +}; diff --git a/src/yuzu/configuration/configure_per_game.cpp b/src/yuzu/configuration/configure_per_game.cpp index 6f530eb0ab..692db5ab48 100644 --- a/src/yuzu/configuration/configure_per_game.cpp +++ b/src/yuzu/configuration/configure_per_game.cpp @@ -41,6 +41,7 @@ #include "yuzu/configuration/configure_graphics_advanced.h" #include "yuzu/configuration/configure_graphics_extensions.h" #include "yuzu/configuration/configure_input_per_game.h" +#include "yuzu/configuration/configure_modern_per_game.h" #include "yuzu/configuration/configure_network.h" #include "yuzu/configuration/configure_per_game.h" #include "yuzu/configuration/configure_per_game_addons.h" @@ -72,6 +73,7 @@ ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::st system_tab = std::make_unique(system_, tab_group, *builder, this); network_tab = std::make_unique(system_, this); applets_tab = std::make_unique(system_, tab_group, *builder, this); + modern_tab = std::make_unique(system_, tab_group, *builder, this); ui->setupUi(this); @@ -85,6 +87,7 @@ ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::st ui->tabWidget->addTab(input_tab.get(), tr("Input Profiles")); ui->tabWidget->addTab(network_tab.get(), tr("Network")); ui->tabWidget->addTab(applets_tab.get(), tr("Applets")); + ui->tabWidget->addTab(modern_tab.get(), tr("Modern Console")); setFocusPolicy(Qt::ClickFocus); setWindowTitle(tr("Properties")); diff --git a/src/yuzu/configuration/configure_per_game.h b/src/yuzu/configuration/configure_per_game.h index bc6561235e..b7ff975022 100644 --- a/src/yuzu/configuration/configure_per_game.h +++ b/src/yuzu/configuration/configure_per_game.h @@ -39,6 +39,7 @@ class ConfigureInputPerGame; class ConfigureSystem; class ConfigureNetwork; class ConfigureApplets; +class ConfigureModernPerGame; class QGraphicsScene; class QStandardItem; @@ -95,4 +96,5 @@ private: std::unique_ptr system_tab; std::unique_ptr network_tab; std::unique_ptr applets_tab; + std::unique_ptr modern_tab; }; diff --git a/src/yuzu/configuration/configure_player_assignment.cpp b/src/yuzu/configuration/configure_player_assignment.cpp new file mode 100644 index 0000000000..1e8711b5df --- /dev/null +++ b/src/yuzu/configuration/configure_player_assignment.cpp @@ -0,0 +1,1634 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/controller_console_state.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "qt_common/config/uisettings.h" +#include "yuzu/configuration/configure_player_assignment.h" +#include "yuzu/configuration/input_profiles.h" + +namespace { + +/// A dark look: one of Eden's dark themes, or the system's dark mode under the default theme +bool DarkUi() { + return UISettings::IsDarkTheme() || + QApplication::palette().color(QPalette::Window).lightness() < 128; +} + +bool IsAutomatic(Settings::AppletMode mode) { + return mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; +} + +constexpr int MaxPlayers = 8; + +/// The players' colours, as the overlay draws their badges +constexpr std::array PlayerColors{ + QColor(79, 142, 247), QColor(239, 91, 91), QColor(47, 191, 113), QColor(245, 184, 46), + QColor(155, 108, 242), QColor(242, 140, 56), QColor(46, 196, 201), QColor(227, 107, 174), +}; + +/// An on/off switch: a rounded track with a knob, like a phone's settings +class ToggleSwitch : public QAbstractButton { +public: + explicit ToggleSwitch(QWidget* parent) : QAbstractButton(parent) { + setCheckable(true); + setCursor(Qt::PointingHandCursor); + } + + QSize sizeHint() const override { + return {42, 24}; + } + +protected: + void paintEvent(QPaintEvent*) override { + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + const QRectF track = QRectF(rect()).adjusted(1, 1, -1, -1); + QColor fill = isChecked() ? ConfigurePlayerAssignment::Accent(this) + : (DarkUi() ? QColor(255, 255, 255, 70) + : palette().mid().color()); + if (!isEnabled()) { + fill.setAlpha(110); + } + painter.setBrush(fill); + painter.drawRoundedRect(track, track.height() / 2, track.height() / 2); + const qreal knob = track.height() - 6; + const qreal x = isChecked() ? track.right() - 3 - knob : track.left() + 3; + painter.setBrush(Qt::white); + painter.drawEllipse(QRectF(x, track.top() + 3, knob, knob)); + } +}; +constexpr char PlayerMime[] = "application/x-eden-player"; + +/// Keeps a drop-down's ✕ inside it, left of its arrow +class ClearButtonPlacer : public QObject { +public: + ClearButtonPlacer(QComboBox* combo_, QToolButton* button_) + : QObject(combo_), combo{combo_}, button{button_} {} + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched == combo && + (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { + button->move(combo->width() - 22 - button->width(), + (combo->height() - button->height()) / 2); + } + return QObject::eventFilter(watched, event); + } + +private: + QComboBox* combo; + QToolButton* button; +}; + +/// The power symbol on Turn Off: white, grey while it waits +QIcon PowerIcon() { + const auto draw = [](const QColor& color) { + QPixmap pixmap(QSize(14, 14) * 2); + pixmap.setDevicePixelRatio(2); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(color, 2, Qt::SolidLine, Qt::RoundCap)); + painter.drawArc(QRectF(2, 2.5, 10, 10), 125 * 16, 290 * 16); + painter.drawLine(QPointF(7, 1), QPointF(7, 7)); + painter.end(); + return pixmap; + }; + QIcon icon; + icon.addPixmap(draw(Qt::white), QIcon::Normal); + icon.addPixmap(draw(QColor(138, 143, 150)), QIcon::Disabled); + return icon; +} + +/// The edit field of a player's name: Enter and Esc don't reach the dialog +class PlayerNameEdit : public QLineEdit { +public: + using QLineEdit::QLineEdit; + + std::function on_apply; + std::function on_cancel; + +protected: + void keyPressEvent(QKeyEvent* event) override { + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + event->accept(); + if (on_apply) { + on_apply(); + } + return; + } + if (event->key() == Qt::Key_Escape) { + event->accept(); + if (on_cancel) { + on_cancel(); + } + return; + } + QLineEdit::keyPressEvent(event); + } +}; + +/// A setting's row: its title in bold, a line about it, and its control on the right +void AddOptionRow(QBoxLayout* to, QWidget* parent, const QString& title, const QString& note, + QWidget* control) { + auto* line = new QHBoxLayout(); + auto* text = new QVBoxLayout(); + text->setSpacing(1); + auto* title_label = new QLabel(title, parent); + QFont font = title_label->font(); + font.setBold(true); + title_label->setFont(font); + auto* note_label = new QLabel(note, parent); + note_label->setWordWrap(true); + note_label->setObjectName(QStringLiteral("note")); + text->addWidget(title_label); + text->addWidget(note_label); + line->addLayout(text, 1); + line->addWidget(control, 0, Qt::AlignVCenter); + to->addLayout(line); +} + +/// A controller's battery, as the overlay draws it +QPixmap BatteryIcon(int percent, bool charging) { + QPixmap pixmap(QSize(20, 12) * 2); + pixmap.setDevicePixelRatio(2); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + const QRectF cell(1, 2, 15, 8); + painter.setPen(QPen(QColor(150, 155, 162), 1.2)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(cell, 2, 2); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(150, 155, 162)); + painter.drawRect(QRectF(cell.right() + 0.6, cell.center().y() - 1.5, 1.8, 3)); + painter.setBrush(percent <= 15 && !charging ? QColor(220, 60, 60) : QColor(70, 180, 100)); + painter.drawRect(QRectF(cell.left() + 1.5, cell.top() + 1.5, + (cell.width() - 3) * (std::clamp)(percent, 0, 100) / 100.0, + cell.height() - 3)); + if (charging) { + painter.setPen(QPen(Qt::white, 1.2)); + const QPointF c = cell.center(); + const QPointF bolt[3] = {QPointF(c.x() + 1, c.y() - 3), QPointF(c.x() - 1.5, c.y() + 0.5), + QPointF(c.x() + 1.5, c.y() - 0.5)}; + painter.drawPolyline(bolt, 3); + painter.drawLine(bolt[2], QPointF(c.x() - 1, c.y() + 3)); + } + painter.end(); + return pixmap; +} + +/// The name's icons (edit, apply, remove), drawn so they look the same everywhere +QIcon NameIcon(int kind, const QColor& color) { + QPixmap pixmap(QSize(14, 14) * 2); + pixmap.setDevicePixelRatio(2); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(color, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + if (kind == 0) { // pencil + painter.drawLine(QPointF(3, 11), QPointF(10.5, 3.5)); + painter.drawLine(QPointF(9, 2), QPointF(12, 5)); + painter.drawLine(QPointF(2.5, 11.5), QPointF(2, 12)); + } else if (kind == 1) { // check + const QPointF check[3] = {QPointF(2.5, 7.5), QPointF(5.5, 10.5), QPointF(11.5, 3.5)}; + painter.drawPolyline(check, 3); + } else { // bin + painter.drawLine(QPointF(2, 3.5), QPointF(12, 3.5)); + painter.drawLine(QPointF(5.5, 3.5), QPointF(5.5, 2)); + painter.drawLine(QPointF(5.5, 2), QPointF(8.5, 2)); + painter.drawLine(QPointF(8.5, 2), QPointF(8.5, 3.5)); + painter.drawRoundedRect(QRectF(3.5, 3.5, 7, 9), 1, 1); + painter.drawLine(QPointF(6, 6), QPointF(6, 10)); + painter.drawLine(QPointF(8, 6), QPointF(8, 10)); + } + painter.end(); + return QIcon{pixmap}; +} + +/// One player in the list: a click rumbles its controller, a drag onto another player swaps them +class PlayerRow : public QFrame { +public: + PlayerRow(int player_, QWidget* parent) : QFrame(parent), player{player_} { + setObjectName(QStringLiteral("playerRow")); + setAcceptDrops(true); + setCursor(Qt::OpenHandCursor); + long_press.setSingleShot(true); + long_press.setInterval(400); + QObject::connect(&long_press, &QTimer::timeout, this, [this] { StartDrag(); }); + } + + std::function on_click; + std::function on_drop; + +protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + press_pos = event->position().toPoint(); + pressed = true; + long_press.start(); + } + QFrame::mousePressEvent(event); + } + + void mouseMoveEvent(QMouseEvent* event) override { + if (pressed && (event->position().toPoint() - press_pos).manhattanLength() >= + QApplication::startDragDistance()) { + StartDrag(); + } + QFrame::mouseMoveEvent(event); + } + + void mouseReleaseEvent(QMouseEvent* event) override { + if (pressed && long_press.isActive() && on_click) { + on_click(player); + } + pressed = false; + long_press.stop(); + QFrame::mouseReleaseEvent(event); + } + + void dragEnterEvent(QDragEnterEvent* event) override { + if (event->mimeData()->hasFormat(QString::fromLatin1(PlayerMime))) { + event->acceptProposedAction(); + drop_hover = true; + update(); + } + } + + void dragLeaveEvent(QDragLeaveEvent* event) override { + drop_hover = false; + update(); + QFrame::dragLeaveEvent(event); + } + + void paintEvent(QPaintEvent* event) override { + QFrame::paintEvent(event); + if (drop_hover) { + // Where the dragged player lands + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(ConfigurePlayerAssignment::Accent(this), 2, Qt::DashLine)); + painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 4, 4); + } + } + + void dropEvent(QDropEvent* event) override { + drop_hover = false; + update(); + const int from = event->mimeData()->data(QString::fromLatin1(PlayerMime)).toInt(); + if (from != player && on_drop) { + on_drop(from, player); + } + event->acceptProposedAction(); + } + +private: + void StartDrag() { + if (!pressed) { + return; + } + pressed = false; + long_press.stop(); + auto* mime = new QMimeData; + mime->setData(QString::fromLatin1(PlayerMime), QByteArray::number(player)); + auto* drag = new QDrag(this); + drag->setMimeData(mime); + drag->setPixmap(grab()); + drag->setHotSpot(press_pos); + setCursor(Qt::ClosedHandCursor); + drag->exec(Qt::MoveAction); + setCursor(Qt::OpenHandCursor); + } + + int player; + bool pressed = false; + bool drop_hover = false; + QPoint press_pos; + QTimer long_press; +}; + +} // Anonymous namespace + +QToolButton* ConfigurePlayerAssignment::AddClearButton(QComboBox* combo) { + auto* button = new QToolButton(combo); + button->setText(QStringLiteral("\u2715")); + button->setCursor(Qt::ArrowCursor); + button->setFixedSize(18, 18); + button->setStyleSheet(Themed( + QStringLiteral("QToolButton { border: none; border-radius: 9px; background: transparent; " + "color: muted-text; padding: 0px; }" + "QToolButton:hover { background: palette(highlight); " + "color: palette(highlighted-text); }"))); + // (Room for it between the text and the arrow) + combo->setStyleSheet(QStringLiteral("QComboBox { padding-right: 40px; }")); + combo->installEventFilter(new ClearButtonPlacer(combo, button)); + return button; +} + +QString ConfigurePlayerAssignment::Themed(QString style) { + if (!DarkUi()) { + return style.replace(QStringLiteral("muted-text"), QStringLiteral("#7d838b")) + .replace(QStringLiteral("popup-base"), QStringLiteral("palette(base)")); + } + // An open drop-down list is a window of its own: a solid background (a tint shows white) + style.replace( + QStringLiteral("popup-base"), + !UISettings::IsDarkTheme() + ? QApplication::palette().color(QPalette::Window).name() + : QString::fromStdString(UISettings::values.theme).contains(QStringLiteral("midnight")) + ? QStringLiteral("#19232D") + : QStringLiteral("#31363B")); + // Cards show the theme's background, fields and buttons a light tint + static const std::array, 8> dark{{ + {"palette(alternate-base)", "transparent"}, + {"palette(base)", "rgba(255, 255, 255, 14)"}, + {"palette(button)", "rgba(255, 255, 255, 22)"}, + {"palette(mid)", "rgba(255, 255, 255, 70)"}, + {"palette(highlighted-text)", "#ffffff"}, + {"palette(highlight)", "#3daee9"}, + {"palette(text)", "#eff0f1"}, + {"muted-text", "#9da3aa"}, + }}; + for (const auto& [from, to] : dark) { + style.replace(QString::fromLatin1(from), QString::fromLatin1(to)); + } + return style; +} + +QColor ConfigurePlayerAssignment::Accent(const QWidget* widget) { + return DarkUi() ? QColor(0x3d, 0xae, 0xe9) + : widget->palette().highlight().color(); +} + +QPixmap ConfigurePlayerAssignment::Battery(int percent, bool charging) { + return BatteryIcon(percent, charging); +} + +QColor ConfigurePlayerAssignment::PlayerColor(int player) { + return PlayerColors[static_cast(player) % PlayerColors.size()]; +} + +QString ConfigurePlayerAssignment::ModernComboStyle() { + return Themed(QStringLiteral( + "QComboBox { border: 1px solid palette(mid); border-radius: 6px; padding: 3px 10px; " + "background: palette(base); }" + "QComboBox:hover, QComboBox:focus { border-color: palette(highlight); }" + "QComboBox:disabled { color: palette(mid); }" + // The open list: readable rows, the hovered one in the highlight colour + "QComboBox QAbstractItemView { background: popup-base; color: palette(text); " + "border: 1px solid palette(mid); outline: 0; padding: 2px; }" + "QComboBox QAbstractItemView::item { min-height: 24px; padding: 0 8px; }" + "QComboBox QAbstractItemView::item:hover, QComboBox QAbstractItemView::item:selected { " + "background: palette(highlight); color: palette(highlighted-text); }")); +} + +void ConfigurePlayerAssignment::ModernizeCombos(QWidget* root) { + // A combo box's own list painter ignores the style sheet: the standard one follows it + for (auto* combo : root->findChildren()) { + if (!combo->property("modern_list").toBool()) { + combo->setItemDelegate(new QStyledItemDelegate(combo)); + // The list's frame takes its solid background (its padding showed white) + if (QWidget* frame = combo->view()->window(); frame && frame != combo->window()) { + frame->setStyleSheet(Themed(QStringLiteral( + "QFrame { background: popup-base; border: 1px solid palette(mid); }"))); + } + combo->setProperty("modern_list", true); + } + } +} + +QString ConfigurePlayerAssignment::OutlinedButtonStyle() { + return Themed(QStringLiteral( + "QPushButton, QToolButton { border: 1px solid palette(highlight); border-radius: 6px; " + "padding: 3px 12px; color: palette(highlight); font-weight: bold; " + "background: palette(base); }" + "QPushButton:hover, QToolButton:hover { background: palette(highlight); " + "color: palette(highlighted-text); }" + "QPushButton:disabled, QToolButton:disabled { border-color: palette(mid); " + "color: palette(mid); }")); +} + +QWidget* ConfigurePlayerAssignment::MakeSegmented(QWidget* parent, const QStringList& labels, + const std::vector& ids, + QButtonGroup* group) { + auto* bar = new QWidget(parent); + auto* bar_layout = new QHBoxLayout(bar); + bar_layout->setContentsMargins(0, 0, 0, 0); + bar_layout->setSpacing(0); + for (int k = 0; k < labels.size(); ++k) { + auto* button = new QPushButton(labels[k], bar); + button->setCheckable(true); + button->setCursor(Qt::PointingHandCursor); + QString style = + QStringLiteral("QPushButton { padding: 5px 14px; border: 1px solid palette(mid); " + "background: palette(button); }" + "QPushButton:checked { background: palette(highlight); " + "color: palette(highlighted-text); border-color: palette(highlight); }" + "QPushButton:disabled { color: palette(mid); }"); + if (k == 0) { + style += QStringLiteral("QPushButton { border-top-left-radius: 6px; " + "border-bottom-left-radius: 6px; }"); + } else { + style += QStringLiteral("QPushButton { border-left-width: 0px; }"); + } + if (k == labels.size() - 1) { + style += QStringLiteral("QPushButton { border-top-right-radius: 6px; " + "border-bottom-right-radius: 6px; }"); + } + button->setStyleSheet(Themed(style)); + group->addButton(button, ids[static_cast(k)]); + bar_layout->addWidget(button); + } + return bar; +} + +ConfigurePlayerAssignment::ConfigurePlayerAssignment(QWidget* parent) : QWidget(parent) { + // The cards keep their height and the page scrolls when they don't fit + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + auto* scroll = new QScrollArea(this); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->viewport()->setAutoFillBackground(false); + auto* content = new QWidget(scroll); + content->setAutoFillBackground(false); + scroll->setWidget(content); + outer->addWidget(scroll); + auto* layout = new QVBoxLayout(content); + content_layout = layout; + layout->setSpacing(8); + // Each section is a rounded card (kept tight: the Deck's screen is 800 pixels tall) + setStyleSheet( + Themed(QStringLiteral("QGroupBox { border: 1px solid palette(mid); border-radius: 10px; " + "margin-top: 10px; padding: 8px 8px 4px 8px; " + "background: palette(alternate-base); }" + "QGroupBox::title { subcontrol-origin: margin; left: 14px; " + "padding: 0 4px; font-weight: bold; }" + // Labels show the card behind them (a dark theme paints every widget) + "QLabel { background: transparent; }" + "QLabel#note { color: muted-text; }")) + + ModernComboStyle()); + + // The handler, as a segmented control + auto* mode_box = new QGroupBox(tr("Controller Assignment Handler"), this); + auto* mode_layout = new QVBoxLayout(mode_box); + handler_group = new QButtonGroup(this); + auto* handler_row = new QHBoxLayout(); + handler_row->addWidget(MakeSegmented(mode_box, {tr("Modern Console"), tr("Manual")}, + {HandlerModern, HandlerManual}, handler_group)); + handler_row->addStretch(1); + mode_layout->addLayout(handler_row); + + // Under Manual: the controller applet a game sees when it asks for its players + manual_panel = new QFrame(mode_box); + manual_panel->setObjectName(QStringLiteral("manualPanel")); + manual_panel->setStyleSheet( + Themed(QStringLiteral("#manualPanel { border: 1px solid palette(mid); " + "border-radius: 8px; background: palette(base); }"))); + auto* manual_layout = new QVBoxLayout(manual_panel); + manual_layout->setContentsMargins(12, 8, 12, 8); + auto* manual_title = new QLabel(tr("Controller Applet"), manual_panel); + QFont manual_font = manual_title->font(); + manual_font.setBold(true); + manual_title->setFont(manual_font); + auto* manual_note = new QLabel(tr("What a game sees when it asks for its players. These are " + "the legacy ways of handling controllers: they do not have " + "Modern Console's features, such as players assigned " + "automatically, the overlay and the hints."), + manual_panel); + manual_note->setWordWrap(true); + manual_note->setObjectName(QStringLiteral("note")); + manual_group = new QButtonGroup(this); + auto* manual_row = new QHBoxLayout(); + manual_row->addWidget(MakeSegmented( + manual_panel, {tr("Disabled"), tr("Custom Frontend"), tr("Real Hardware")}, + {static_cast(Settings::AppletMode::Disabled), + static_cast(Settings::AppletMode::HLE), static_cast(Settings::AppletMode::LLE)}, + manual_group)); + manual_row->addStretch(1); + // What Manual itself does, under the choices + auto* manual_about = new QLabel(tr("In Manual, each player's controller is set by hand in " + "its player tab (P1 to P8) and stays until you change " + "it. The same choices are in System > Applets, also per " + "game."), + manual_panel); + manual_about->setWordWrap(true); + manual_about->setObjectName(QStringLiteral("note")); + manual_layout->addWidget(manual_title); + manual_layout->addWidget(manual_note); + manual_layout->addLayout(manual_row); + manual_layout->addWidget(manual_about); + auto* manual_indent = new QHBoxLayout(); + manual_indent->setContentsMargins(16, 0, 0, 0); + manual_indent->addWidget(manual_panel); + mode_layout->addLayout(manual_indent); + + // Under Modern Console: whether this is a handheld with its own controls + handheld_panel = new QFrame(mode_box); + handheld_panel->setObjectName(QStringLiteral("handheldPanel")); + handheld_panel->setStyleSheet( + Themed(QStringLiteral("#handheldPanel { border: 1px solid palette(mid); " + "border-radius: 8px; background: palette(base); }"))); + auto* panel_layout = new QHBoxLayout(handheld_panel); + panel_layout->setContentsMargins(12, 8, 12, 8); + auto* panel_text = new QVBoxLayout(); + auto* panel_title = + new QLabel(tr("Portable Handheld Gaming Device with Built-in Controller"), handheld_panel); + QFont title_font = panel_title->font(); + title_font.setBold(true); + panel_title->setFont(title_font); + panel_title->setWordWrap(true); + auto* panel_note = new QLabel(tr("For a handheld with its own controls, like a Steam Deck, " + "Legion Go or ROG Ally: connected controllers take the " + "players first, and the built-in controls play only when " + "no other controller is connected."), + handheld_panel); + panel_note->setWordWrap(true); + panel_note->setObjectName(QStringLiteral("note")); + panel_text->addWidget(panel_title); + panel_text->addWidget(panel_note); + panel_layout->addLayout(panel_text, 1); + handheld_switch = new ToggleSwitch(handheld_panel); + handheld_switch->setToolTip(tr("On: this is a handheld with its own controls")); + panel_layout->addWidget(handheld_switch, 0, Qt::AlignVCenter); + auto* indent = new QHBoxLayout(); + indent->setContentsMargins(16, 0, 0, 0); + indent->addWidget(handheld_panel); + mode_layout->addLayout(indent); + + mode_info = new QLabel(mode_box); + mode_info->setWordWrap(true); + mode_layout->addWidget(mode_info); + layout->addWidget(mode_box); + + // Under Steam: read the controllers directly instead of through Steam Input + auto* steam_box = new QGroupBox(tr("Steam Input"), this); + steam_panel = steam_box; + auto* steam_column = new QVBoxLayout(steam_box); + auto* steam_layout = new QHBoxLayout(); + steam_column->addLayout(steam_layout); + auto* steam_text = new QVBoxLayout(); + steam_text->setSpacing(1); + auto* steam_title = new QLabel(tr("Bypass Steam Input"), steam_box); + steam_title->setFont(title_font); + auto* steam_note = new QLabel( + tr("Eden reads your controllers directly instead of through Steam's virtual " + "controllers, so Steam Input layouts don't apply in Eden. Try this if a controller " + "doesn't work under Steam, such as the Steam Controller or a newer controller. Takes " + "effect after restarting Eden; connected controllers may be assigned to different " + "players."), + steam_box); + steam_note->setWordWrap(true); + steam_note->setObjectName(QStringLiteral("note")); + steam_restart = new QLabel(tr("Restart Eden to apply this change."), steam_box); + steam_text->addWidget(steam_title); + steam_text->addWidget(steam_note); + steam_layout->addLayout(steam_text, 1); + steam_switch = new ToggleSwitch(steam_box); + steam_switch->setToolTip(tr("On: Eden reads the controllers directly")); + steam_layout->addWidget(steam_switch, 0, Qt::AlignVCenter); + original_bypass = Settings::values.console_bypass_steam.GetValue(); + steam_switch->setChecked(original_bypass); + connect(steam_switch, &QAbstractButton::toggled, this, [this](bool on) { + Settings::values.console_bypass_steam.SetValue(on); + UpdateModeUi(); + }); + + steam_column->addWidget(steam_restart); + layout->addWidget(steam_box); + + // Assign Handheld Controller: declare the handheld's own controller (that mode only) + builtin_row = new QGroupBox(tr("Assign Handheld Controller"), this); + auto* builtin_layout = new QVBoxLayout(builtin_row); + auto* picker_row = new QHBoxLayout(); + picker_row->addWidget(new QLabel(tr("Handheld's controller:"), builtin_row)); + builtin_picker = new QComboBox(builtin_row); + builtin_picker->addItem(tr("Auto-detect"), QString{}); + picker_row->addWidget(builtin_picker, 1); + builtin_identify = new QPushButton(tr("Identify"), builtin_row); + builtin_identify->setCursor(Qt::PointingHandCursor); + builtin_identify->setStyleSheet(OutlinedButtonStyle()); + picker_row->addWidget(builtin_identify); + builtin_layout->addLayout(picker_row); + // The ✕ inside the picker removes the handheld's controller (auto-detect then skips it) + builtin_remove = AddClearButton(builtin_picker); + builtin_remove->setToolTip(tr("Not the handheld's controller: remove it. Auto-detect will " + "not pick it again until you allow it.")); + builtin_remove->hide(); + // A removed controller: said plainly, with the way to allow it again + blocked_row = new QWidget(builtin_row); + auto* blocked_line = new QHBoxLayout(blocked_row); + blocked_line->setContentsMargins(0, 0, 0, 0); + blocked_label = new QLabel(blocked_row); + blocked_label->setWordWrap(true); + blocked_label->setObjectName(QStringLiteral("note")); + blocked_line->addWidget(blocked_label, 1); + blocked_allow = new QPushButton(tr("Allow Again"), blocked_row); + blocked_allow->setCursor(Qt::PointingHandCursor); + blocked_allow->setStyleSheet(OutlinedButtonStyle()); + blocked_line->addWidget(blocked_allow); + builtin_layout->addWidget(blocked_row); + builtin_status = new QLabel(builtin_row); + builtin_status->setWordWrap(true); + builtin_layout->addWidget(builtin_status); + connect(builtin_remove, &QToolButton::clicked, this, [] { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.builtin_remove_request = true; + }); + connect(blocked_allow, &QPushButton::clicked, this, + [] { Settings::values.builtin_pad_blocked.SetValue(std::string{}); }); + layout->addWidget(builtin_row); + + // The players right now, as the engine assigned them + now_box = new QGroupBox(tr("Connected Players"), this); + auto* now_layout = new QVBoxLayout(now_box); + players_box = new QWidget(now_box); + players_list = new QVBoxLayout(players_box); + players_list->setContentsMargins(0, 0, 0, 0); + players_list->setSpacing(6); + now_layout->addWidget(players_box); + players_note = new QLabel(now_box); + players_note->setWordWrap(true); + now_layout->addWidget(players_note); + layout->addWidget(now_box); + + // Gameplay: how players' controllers behave in games + auto* gameplay = new QGroupBox(tr("Gameplay"), this); + gameplay_box = gameplay; + auto* gameplay_layout = new QVBoxLayout(gameplay); + const auto add_option = [gameplay, gameplay_layout](const QString& title, const QString& note, + QWidget* control) { + AddOptionRow(gameplay_layout, gameplay, title, note, control); + }; + joycon_switch = new ToggleSwitch(gameplay); + add_option(tr("Use Any Controller as Joy-Cons"), + tr("When a game only takes Joy-Cons, such as one Joy-Con per player, your " + "controllers act as Joy-Cons automatically, with the buttons arranged to fit. A " + "controller type picked in a player's tab is left as it is."), + joycon_switch); + original_joycon = Settings::values.console_joycon_auto.GetValue(); + joycon_switch->setChecked(original_joycon); + connect(joycon_switch, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_joycon_auto.SetValue(on); }); + + layout_group = new QButtonGroup(this); + auto* layout_choice = + MakeSegmented(gameplay, {tr("By Position"), tr("By Label")}, {0, 1}, layout_group); + add_option(tr("Button Layout"), + tr("For Xbox and PlayStation style controllers. By Position: the button where the " + "Switch has A acts as A. By Label: the button marked A acts as A. Nintendo " + "controllers already match both ways."), + layout_choice); + original_labels = Settings::values.console_button_labels.GetValue(); + layout_group->button(original_labels ? 1 : 0)->setChecked(true); + connect(layout_group, &QButtonGroup::idClicked, this, + [](int id) { Settings::values.console_button_labels.SetValue(id == 1); }); + + pause_switch = new ToggleSwitch(gameplay); + add_option(tr("Pause When a Controller Disconnects"), + tr("Pauses the game when a player's controller is turned off, or has been gone " + "for 3 seconds, and resumes when a controller takes that player again. Press + " + "on another controller to go on without that player."), + pause_switch); + original_pause = Settings::values.console_pause_disconnect.GetValue(); + pause_switch->setChecked(original_pause); + connect(pause_switch, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_pause_disconnect.SetValue(on); }); + + lights_switch = new ToggleSwitch(gameplay); + add_option(tr("Player Lights"), + tr("Controllers' player lights and light bars show their player, in its colour. " + "Under Steam, Steam sets the lights of the controllers it handles."), + lights_switch); + original_lights = Settings::values.console_player_lights.GetValue(); + lights_switch->setChecked(original_lights); + connect(lights_switch, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_player_lights.SetValue(on); }); + layout->addWidget(gameplay); + + // What shows over the game: a title, a line about it and a switch per item + auto* display = new QGroupBox(tr("On-Screen Display"), this); + display_box = display; + auto* display_layout = new QVBoxLayout(display); + const auto add_switch_to = [display](QVBoxLayout* to, const QString& title, + const QString& note) { + auto* toggle = new ToggleSwitch(display); + AddOptionRow(to, display, title, note, toggle); + return toggle; + }; + const auto add_switch = [&](const QString& title, const QString& note) { + return add_switch_to(display_layout, title, note); + }; + // The same switch as View > Show Controller Overlay + overlay_toggle = add_switch(tr("Controller Overlay"), + tr("The players, their controllers and their last buttons in " + "the top-left corner.")); + original_overlay = Settings::values.console_debug_overlay.GetValue(); + overlay_toggle->setChecked(original_overlay); + connect(overlay_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_debug_overlay.SetValue(shown); }); + + // The overlay's own options, indented under it: the button history and how it shows + history_options = new QWidget(display); + auto* history_layout = new QVBoxLayout(history_options); + history_layout->setContentsMargins(20, 0, 0, 4); + history_toggle = add_switch_to(history_layout, tr("Button History"), + tr("The last buttons each player pressed, beside their row. " + "Buttons pressed together show as one input.")); + // A slider with its value beside it + const auto add_slider = [this, display, history_layout](const QString& title, int min, int max, + int value, const QString& unit) { + auto* line = new QHBoxLayout(); + auto* label = new QLabel(title, display); + line->addWidget(label); + auto* slider = new QSlider(Qt::Horizontal, display); + slider->setRange(min, max); + slider->setValue(value); + slider->setPageStep(1); + line->addWidget(slider, 1); + auto* shown = new QLabel(display); + shown->setMinimumWidth(shown->fontMetrics().horizontalAdvance(unit.arg(max)) + 4); + shown->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + shown->setText(unit.arg(value)); + line->addWidget(shown); + connect(slider, &QSlider::valueChanged, this, + [shown, unit](int v) { shown->setText(unit.arg(v)); }); + auto* row = new QWidget(display); + row->setLayout(line); + line->setContentsMargins(0, 0, 0, 0); + history_layout->addWidget(row); + return std::pair{slider, row}; + }; + original_history = Settings::values.console_history.GetValue(); + original_history_size = Settings::values.console_history_size.GetValue(); + original_history_seconds = Settings::values.console_history_seconds.GetValue(); + original_history_gaps = Settings::values.console_history_gaps.GetValue(); + QWidget* size_row = nullptr; + QWidget* seconds_row = nullptr; + std::tie(history_size, size_row) = + add_slider(tr("Inputs kept"), 5, 20, original_history_size, tr("%1")); + std::tie(history_seconds, seconds_row) = + add_slider(tr("Expire after"), 1, 30, original_history_seconds, tr("%1 s")); + gaps_toggle = add_switch_to(history_layout, tr("Time Between Presses"), + tr("The milliseconds between inputs, never more than the expire " + "time.")); + display_layout->addWidget(history_options); + history_toggle->setChecked(original_history); + gaps_toggle->setChecked(original_history_gaps); + connect(history_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_history.SetValue(shown); }); + connect(history_size, &QSlider::valueChanged, this, + [](int v) { Settings::values.console_history_size.SetValue(v); }); + connect(history_seconds, &QSlider::valueChanged, this, + [](int v) { Settings::values.console_history_seconds.SetValue(v); }); + connect(gaps_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_history_gaps.SetValue(shown); }); + // Only what applies can be changed + const auto update_history_options = [this, size_row, seconds_row] { + const bool history_on = history_toggle->isChecked(); + history_options->setEnabled(overlay_toggle->isChecked()); + size_row->setEnabled(history_on); + seconds_row->setEnabled(history_on); + gaps_toggle->setEnabled(history_on); + }; + connect(overlay_toggle, &QAbstractButton::toggled, this, update_history_options); + connect(history_toggle, &QAbstractButton::toggled, this, update_history_options); + update_history_options(); + hints_toggle = add_switch(tr("Controller Hints"), + tr("Names a controller in the bottom-left corner for a moment " + "when it joins as a player or leaves.")); + original_hints = Settings::values.console_hints.GetValue(); + hints_toggle->setChecked(original_hints); + connect(hints_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_hints.SetValue(shown); }); + layout->addWidget(display); + + // Logging for bug reports + auto* debug = new QGroupBox(tr("Troubleshooting"), this); + debug_box = debug; + auto* debug_layout = new QVBoxLayout(debug); + probes_toggle = add_switch_to(debug_layout, tr("Debug Probes"), + tr("Writes detailed controller traces to the log several times " + "a second, for bug reports. Connected controllers are " + "always described in the log.")); + original_probes = Common::ControllerProbes(); + probes_toggle->setChecked(original_probes); + connect(probes_toggle, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_debug_probes.SetValue(on); }); + layout->addWidget(debug); + + // A short word on the two automatic modes, last on the page + auto* about = new QGroupBox(tr("How It Works"), this); + about_box = about; + auto* about_layout = new QVBoxLayout(about); + const auto add_about = [about, about_layout](const QString& title, const QString& text) { + auto* title_label = new QLabel(title, about); + QFont font = title_label->font(); + font.setBold(true); + title_label->setFont(font); + auto* text_label = new QLabel(text, about); + text_label->setWordWrap(true); + text_label->setObjectName(QStringLiteral("note")); + about_layout->addWidget(title_label); + about_layout->addWidget(text_label); + }; + add_about(tr("Modern Console"), + tr("Controllers are assigned like on the console: each one that connects becomes " + "the next player, with nothing to set up. Players keep their own controls when " + "they swap, and a profile picked for a player is used again next time. Its " + "mappings are its own: each controller starts with its standard mapping, and " + "the Manual modes' players are never changed. Keyboard and mouse: use Manual.")); + add_about(tr("Handheld Console"), + tr("For a handheld PC with built-in controls. The same as Modern Console, but the " + "built-in controls always come last: connect a controller and it becomes " + "Player 1, so a docked handheld plays like a console.")); + add_about(tr("Steam Controller"), + tr("Under Steam, turn on Bypass Steam Input (Steam Input card) so Eden reads the " + "Steam Controller itself. Steam Input layouts then don't apply in Eden.")); + layout->addWidget(about); + layout->addStretch(1); + + // The mode applies at once; closing without applying puts the previous one back + original_mode = static_cast(Settings::values.controller_applet_mode.GetValue()); + connect(handler_group, &QButtonGroup::idClicked, this, + &ConfigurePlayerAssignment::OnModeChanged); + connect(manual_group, &QButtonGroup::idClicked, this, + &ConfigurePlayerAssignment::OnModeChanged); + connect(handheld_switch, &QAbstractButton::clicked, this, + &ConfigurePlayerAssignment::OnModeChanged); + connect(builtin_picker, &QComboBox::currentIndexChanged, this, + &ConfigurePlayerAssignment::OnBuiltinPicked); + connect(builtin_identify, &QPushButton::clicked, this, + &ConfigurePlayerAssignment::OnIdentifyPressed); + + timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, &ConfigurePlayerAssignment::Refresh); + // Fast enough for the highlight to follow a button press + timer->start(50); + + LoadConfiguration(); + Refresh(); +} + +ConfigurePlayerAssignment::~ConfigurePlayerAssignment() { + if (!applied) { + Settings::values.controller_applet_mode.SetValue( + static_cast(original_mode)); + Settings::values.console_debug_overlay.SetValue(original_overlay); + Settings::values.console_hints.SetValue(original_hints); + Settings::values.console_history.SetValue(original_history); + Settings::values.console_history_size.SetValue(original_history_size); + Settings::values.console_history_seconds.SetValue(original_history_seconds); + Settings::values.console_history_gaps.SetValue(original_history_gaps); + Settings::values.console_debug_probes.SetValue(original_probes); + Settings::values.console_bypass_steam.SetValue(original_bypass); + Settings::values.console_joycon_auto.SetValue(original_joycon); + Settings::values.console_button_labels.SetValue(original_labels); + Settings::values.console_pause_disconnect.SetValue(original_pause); + Settings::values.console_player_lights.SetValue(original_lights); + } +} + +void ConfigurePlayerAssignment::LoadConfiguration() { + const auto current = Settings::values.controller_applet_mode.GetValue(); + const bool modern = IsAutomatic(current); + handler_group->button(modern ? HandlerModern : HandlerManual)->setChecked(true); + // Under Manual the applet as set; the default there is Custom Frontend + QAbstractButton* applet = manual_group->button(static_cast(current)); + (applet && !modern ? applet : manual_group->button(static_cast(Settings::AppletMode::HLE))) + ->setChecked(true); + handheld_switch->setChecked(current == Settings::AppletMode::HandheldConsoleMode); + UpdateModeUi(); +} + +int ConfigurePlayerAssignment::SelectedMode() const { + switch (handler_group->checkedId()) { + case HandlerModern: + return static_cast(handheld_switch->isChecked() + ? Settings::AppletMode::HandheldConsoleMode + : Settings::AppletMode::ConsoleMode); + default: + return manual_group->checkedId(); + } +} + +void ConfigurePlayerAssignment::OnModeChanged() { + Settings::values.controller_applet_mode.SetValue( + static_cast(SelectedMode())); + UpdateModeUi(); +} + +void ConfigurePlayerAssignment::ApplyConfiguration() { + Settings::values.controller_applet_mode.SetValue( + static_cast(SelectedMode())); + applied = true; +} + +void ConfigurePlayerAssignment::UpdateModeUi() { + const auto selected = static_cast(SelectedMode()); + handheld_panel->setVisible(handler_group->checkedId() == HandlerModern); + // (Bypass Steam Input: only under Steam; applies to a Modern Console start) + steam_panel->setVisible(handler_group->checkedId() == HandlerModern && + Common::RunningUnderSteam()); + steam_restart->setVisible(steam_switch->isChecked() != + Common::GetControllerConsoleState().steam_bypassed); + manual_panel->setVisible(handler_group->checkedId() == HandlerManual); + // Only the automatic modes have live players to show + const bool automatic = IsAutomatic(selected); + switch (selected) { + case Settings::AppletMode::ConsoleMode: + mode_info->setText(tr("Controller assignment is automatic. Each controller is assigned " + "to the next available player when it connects.")); + break; + case Settings::AppletMode::HandheldConsoleMode: + mode_info->setText(tr("Controller assignment is automatic, with the handheld's own " + "controls last.")); + break; + case Settings::AppletMode::Disabled: + mode_info->setText(tr("Controllers are assigned in each player's tab. Games use the " + "configured players without showing a controller applet.")); + break; + case Settings::AppletMode::LLE: + mode_info->setText(tr("Controllers are assigned in each player's tab. When a game " + "requests controller setup, the console's own controller applet " + "is shown. This requires the system firmware.")); + break; + default: + mode_info->setText(tr("Controllers are assigned in each player's tab. When a game " + "requests controller setup, Eden's controller applet is shown.")); + break; + } + now_box->setVisible(automatic); + display_box->setVisible(automatic); + gameplay_box->setVisible(automatic); + about_box->setVisible(automatic); + debug_box->setVisible(automatic); + builtin_row->setVisible(selected == Settings::AppletMode::HandheldConsoleMode); + Refresh(); +} + +void ConfigurePlayerAssignment::Refresh() { + RefreshBuiltin(); + RefreshPlayers(); +} + +void ConfigurePlayerAssignment::RefreshBuiltin() { + auto& state = Common::GetControllerConsoleState(); + std::vector> pads; + std::size_t builtin_pads = 0; + bool armed = false; + std::chrono::steady_clock::time_point armed_at{}; + std::string detected_name; + { + std::scoped_lock lock{state.mutex}; + pads = state.pads; + builtin_pads = state.builtin_pads; + armed = state.identify_armed; + armed_at = state.identify_armed_at; + detected_name = state.handheld_name; + } + + // The entries' data: the Steam handle under Steam (the virtual pads share one GUID), else + // the pad's identity + QString signature = QString::number(builtin_pads) + QLatin1Char('|'); + for (const auto& [display, ident] : pads) { + signature += QString::fromStdString(ident) + QLatin1Char('|'); + } + if (signature != builtin_signature) { + builtin_signature = signature; + const QString current = builtin_picker->currentData().toString(); + const bool blocked = builtin_picker->blockSignals(true); + builtin_picker->clear(); + builtin_picker->addItem(tr("Auto-detect"), QString{}); + for (std::size_t i = 0; i < pads.size(); ++i) { + if (i == builtin_pads) { + // A heading (not pickable) before the standalone controllers + builtin_picker->insertSeparator(builtin_picker->count()); + builtin_picker->addItem(tr("Other controllers")); + if (auto* model = qobject_cast(builtin_picker->model())) { + model->item(builtin_picker->count() - 1)->setEnabled(false); + } + } + builtin_picker->addItem(QString::fromStdString(pads[i].first), + QString::fromStdString(pads[i].second)); + } + const int keep = builtin_picker->findData(current); + builtin_picker->setCurrentIndex(keep > 0 ? keep : 0); + builtin_picker->blockSignals(blocked); + } + + if (armed) { + const auto left = + std::chrono::duration_cast( + std::chrono::seconds{15} - (std::chrono::steady_clock::now() - armed_at)) + .count(); + builtin_status->setText( + tr("Waiting for a button press (%1 seconds)...").arg(std::max(0, left))); + // (The line below counts down) + builtin_identify->setText(tr("Press a button...")); + return; + } + builtin_identify->setText(tr("Identify")); + + // Stored per launch kind: the Steam handle under Steam, else the pad's identity + const bool steam = Common::SteamPadsInUse(); + const QString identity = + QString::fromStdString(Settings::values.builtin_pad_identity.GetValue()); + const QString handle = + QString::fromStdString(Settings::values.builtin_pad_steam_handle.GetValue()); + const QString stored = + steam ? (handle.isEmpty() ? QString{} : QStringLiteral("steamhandle:") + handle) : identity; + const bool declared = steam ? Settings::values.builtin_pad_steam_manual.GetValue() + : Settings::values.builtin_pad_manual.GetValue(); + + // Keep the picker on a declared pad, else on automatic detection + const int stored_index = stored.isEmpty() ? -1 : builtin_picker->findData(stored); + const int want_index = declared && stored_index > 0 ? stored_index : 0; + if (builtin_picker->currentIndex() != want_index) { + const bool blocked = builtin_picker->blockSignals(true); + builtin_picker->setCurrentIndex(want_index); + builtin_picker->blockSignals(blocked); + } + + bool engine_active = false; + { + std::scoped_lock lock{state.mutex}; + engine_active = state.engine_active; + } + if (engine_active && detected_name.empty() && + Settings::values.controller_applet_mode.GetValue() == + Settings::AppletMode::HandheldConsoleMode) { + builtin_status->setText(tr("Handheld controller not detected \u2014 working like Modern " + "until it is. Pick it above, or use Identify.")); + } else if (stored.isEmpty() && detected_name.empty()) { + builtin_status->setText( + tr("Not set. Pick the handheld's controller above, or use Identify.")); + } else { + const QString name = stored_index > 0 ? builtin_picker->itemText(stored_index) + : !detected_name.empty() ? QString::fromStdString(detected_name) + : tr("not set"); + builtin_status->setText( + tr("Handheld: %1 (%2). %3") + .arg(name) + .arg(want_index > 0 ? tr("declared") : tr("auto-detected")) + .arg(!detected_name.empty() + ? tr("Takes no player while external controllers are connected.") + : tr("Not currently connected."))); + } + + // The picker shows the handheld's controller, with the ✕ inside it to remove it + const bool shown = engine_active && !detected_name.empty(); + const QString first = shown && want_index == 0 + ? tr("%1 (auto-detected)").arg(QString::fromStdString(detected_name)) + : tr("Auto-detect"); + if (builtin_picker->itemText(0) != first) { + builtin_picker->setItemText(0, first); + } + builtin_remove->setVisible(shown); + builtin_status->setVisible(true); + // A removed controller, kept from auto-detect + std::string blocked_name; + { + std::scoped_lock lock{state.mutex}; + blocked_name = state.handheld_blocked_name; + } + const bool blocked = !Settings::values.builtin_pad_blocked.GetValue().empty(); + blocked_row->setVisible(blocked); + if (blocked) { + blocked_label->setText( + tr("Auto-detect is off for %1: it will not be picked as the handheld's controller.") + .arg(blocked_name.empty() ? tr("the removed controller") + : QString::fromStdString(blocked_name))); + } +} + +void ConfigurePlayerAssignment::RefreshPlayers() { + auto& state = Common::GetControllerConsoleState(); + std::vector players; + std::string handheld_name; + bool handheld_has_player = false; + bool engine_active = false; + { + std::scoped_lock lock{state.mutex}; + players = state.players; + handheld_name = state.handheld_name; + handheld_has_player = state.handheld_has_player; + engine_active = state.engine_active; + } + const auto selected = static_cast(SelectedMode()); + const bool in_effect = selected == Settings::values.controller_applet_mode.GetValue(); + + // The engine decides the flashes, as in the overlay + constexpr qint64 flash_ms = 1500; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + int last_player = -1; + for (const auto& entry : players) { + if (!entry.leaving) { + last_player = (std::max)(last_player, entry.player); + } + } + + // The saved input profiles, by their files (cheap enough to look at every refresh) + QStringList profiles; + { + const QDir dir{QString::fromStdString(Common::FS::PathToUTF8String( + Common::FS::GetEdenPath(Common::FS::EdenPath::ConfigDir) / "input"))}; + for (const QString& file : + dir.entryList({QStringLiteral("*.ini")}, QDir::Files, QDir::Name)) { + const QString name = file.chopped(4); + if (InputProfiles::IsProfileNameValid(name.toStdString())) { + profiles.push_back(name); + } + } + } + + // Rebuild the rows when they change; the colours only restyle them + QString signature = QString::number(swap_from) + QLatin1Char(';') + + profiles.join(QLatin1Char(',')) + QLatin1Char(';'); + for (const auto& entry : players) { + signature += + QString::number(entry.player) + QLatin1Char(':') + QString::fromStdString(entry.name) + + QLatin1Char('/') + QString::fromStdString(entry.profile) + + (entry.leaving ? QStringLiteral("x") : QString{}) + QString::number(entry.turn_off) + + QLatin1Char('/') + QString::number(entry.origin) + + (entry.guessed ? QStringLiteral("?") : QString{}) + QLatin1Char('/') + + QString::number(entry.battery / 5) + + (entry.charging ? QStringLiteral("c") : QString{}) + QLatin1Char('|'); + } + if (!handheld_has_player) { + signature += QStringLiteral("h:") + QString::fromStdString(handheld_name); + } + // (Not while a name is being typed: the rows would be rebuilt under it) + if (signature != players_signature && !editing_name) { + players_signature = signature; + const bool any_turn_off = std::any_of(players.begin(), players.end(), + [](const auto& e) { return e.turn_off != 0; }); + for (QFrame* row : player_rows) { + players_list->removeWidget(row); + row->hide(); + row->deleteLater(); + } + player_rows.clear(); + row_players.clear(); + for (const auto& entry : players) { + const int player = entry.player; + auto* row = new PlayerRow(player, players_box); + row->setToolTip(tr("Click to rumble this controller. Hold, then drop on another " + "player to swap them.")); + row->on_click = [this](int p) { RequestRumble(p); }; + row->on_drop = [this](int from, int to) { RequestSwap(from, to); }; + auto* line = new QHBoxLayout(row); + line->setContentsMargins(8, 5, 6, 5); + line->setSpacing(6); + auto* grip = new QLabel(QStringLiteral("\u283F"), row); + grip->setToolTip(tr("Drag onto another player to swap")); + grip->setObjectName(QStringLiteral("note")); + line->addWidget(grip); + // The player's badge, marked with ? for a reconnect's guess, else a dot in the + // colour of the player the controller started as + auto* badge_box = new QWidget(row); + badge_box->setFixedSize(40, 24); + // (Only the badge and its mark show: the dark themes paint a plain widget) + badge_box->setAttribute(Qt::WA_TranslucentBackground); + badge_box->setStyleSheet(QStringLiteral("background: transparent;")); + auto* badge = new QLabel(QStringLiteral("P%1").arg(player + 1), badge_box); + badge->setAlignment(Qt::AlignCenter); + badge->setGeometry(0, 3, 34, 20); + badge->setStyleSheet( + QStringLiteral("QLabel { background: %1; color: white; " + "border-radius: 10px; font-weight: bold; }") + .arg(PlayerColors[static_cast(player) % 8].name())); + if (!entry.leaving && (entry.guessed || entry.origin >= 0)) { + auto* mark = new QLabel(badge_box); + if (entry.guessed) { + mark->setText(QStringLiteral("?")); + mark->setAlignment(Qt::AlignCenter); + mark->setGeometry(27, 0, 13, 13); + mark->setStyleSheet(QStringLiteral( + "QLabel { background: white; color: #0e0e12; border: 1px solid #0e0e12; " + "border-radius: 6px; font-weight: bold; font-size: 9px; }")); + badge_box->setToolTip(tr("This controller came back without a way to tell " + "which one it is. Swap it if it's in the wrong " + "place.")); + } else { + mark->setGeometry(29, 1, 10, 10); + mark->setStyleSheet( + QStringLiteral("QLabel { background: %1; border: 1px solid #0e0e12; " + "border-radius: 5px; }") + .arg(PlayerColors[static_cast(entry.origin) % 8].name())); + badge_box->setToolTip(tr("Started as P%1").arg(entry.origin + 1)); + } + } + line->addWidget(badge_box); + // The name, with a pencil to name the controller ("John" shows as "John (Xbox)") + const QString given = QString::fromStdString(entry.custom_name); + auto* name_label = new QLabel(QString::fromStdString(entry.name), row); + auto* name_edit = new PlayerNameEdit(given, row); + name_edit->setObjectName(QStringLiteral("playerName")); + name_edit->setMaxLength(16); + name_edit->setPlaceholderText(tr("Name this controller")); + name_edit->setStyleSheet(ModernComboStyle().replace(QStringLiteral("QComboBox"), + QStringLiteral("QLineEdit"))); + name_edit->hide(); + const QColor icon_color = Accent(this); + const auto name_button = [row, icon_color](int kind, const QString& tip) { + auto* button = new QToolButton(row); + button->setIcon(NameIcon(kind, icon_color)); + button->setIconSize(QSize(14, 14)); + button->setAutoRaise(true); + button->setCursor(Qt::PointingHandCursor); + button->setToolTip(tip); + // (Clicking it keeps the field focused: leaving the field cancels) + button->setFocusPolicy(Qt::NoFocus); + return button; + }; + auto* name_edit_button = name_button(0, tr("Name P%1's controller").arg(player + 1)); + auto* name_apply = name_button(1, tr("Apply the name")); + auto* name_remove = name_button(2, tr("Remove the name")); + name_apply->hide(); + // (The bin removes a given name) + name_remove->setVisible(!given.isEmpty()); + name_edit_button->setEnabled(!entry.leaving); + name_remove->setEnabled(!entry.leaving); + const auto request = [player](const QString& value) { + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.rename_requests.emplace_back(player, value.trimmed().toStdString()); + }; + const auto editing = [=, this](bool on) { + // (While a name is typed, the rows aren't rebuilt under it) + editing_name = on; + name_label->setVisible(!on); + name_edit_button->setVisible(!on); + name_edit->setVisible(on); + name_apply->setVisible(on); + name_remove->setVisible(!given.isEmpty()); + if (on) { + name_edit->setText(given); + name_edit->setFocus(); + name_edit->selectAll(); + } + }; + name_edit->on_apply = [=] { + editing(false); + if (name_edit->text().trimmed() != given) { + request(name_edit->text()); + } + }; + name_edit->on_cancel = [=] { editing(false); }; + connect(name_edit_button, &QToolButton::clicked, this, [=] { editing(true); }); + connect(name_apply, &QToolButton::clicked, this, [=] { name_edit->on_apply(); }); + connect(name_remove, &QToolButton::clicked, this, [=] { + editing(false); + request(QString{}); + }); + // The controller's battery, when it tells its charge + QLabel* battery = nullptr; + if (entry.battery >= 0 && !entry.leaving) { + battery = new QLabel(row); + battery->setPixmap(BatteryIcon(entry.battery, entry.charging)); + battery->setText(QString{}); + battery->setToolTip(entry.charging ? tr("Battery %1% (charging)").arg(entry.battery) + : tr("Battery %1%").arg(entry.battery)); + } + auto* battery_text = + battery ? new QLabel(QStringLiteral("%1%").arg(entry.battery), row) : nullptr; + if (battery_text) { + battery_text->setObjectName(QStringLiteral("note")); + battery_text->setToolTip(battery->toolTip()); + } + auto* name_line = new QHBoxLayout(); + name_line->setSpacing(2); + name_line->addWidget(name_label); + if (battery) { + name_line->addSpacing(6); + name_line->addWidget(battery_text); + name_line->addWidget(battery); + } + name_line->addWidget(name_edit, 1); + name_line->addWidget(name_edit_button); + name_line->addWidget(name_apply); + name_line->addWidget(name_remove); + name_line->addStretch(1); + line->addLayout(name_line, 1); + const bool live = !entry.leaving; + + // The controller's profile; "No profile" (or the ✕) takes it off + auto* profile = new QComboBox(row); + auto* clear = AddClearButton(profile); + profile->addItem(tr("No profile")); + profile->addItems(profiles); + // (Not found is -1: "No profile") + profile->setCurrentIndex( + static_cast(profiles.indexOf(QString::fromStdString(entry.profile)) + 1)); + profile->setToolTip(tr("Mapping profile for P%1's controller").arg(player + 1)); + // One width for every row, whatever the profile's name + profile->setFixedWidth(170); + // (A profile name with no file behind it shows as none: there is nothing to pick) + profile->setEnabled(live && !profiles.isEmpty()); + const auto take_off = [player] { + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.profile_requests.push_back({player, {}, true}); + }; + connect(profile, qOverload(&QComboBox::activated), this, + [player, profile, take_off](int index) { + if (index == 0) { + take_off(); + return; + } + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.profile_requests.push_back( + {player, profile->itemText(index).toStdString()}); + }); + clear->setToolTip(tr("Take the profile off P%1").arg(player + 1)); + // (A profile renamed or deleted elsewhere shows as none: no ✕ for it) + clear->setVisible(live && profiles.contains(QString::fromStdString(entry.profile))); + connect(clear, &QToolButton::clicked, this, take_off); + line->addWidget(profile); + + // (A disabled arrow is drawn plain, not as a dark block) + const QString plain_disabled = + QStringLiteral("QToolButton:disabled { background: transparent; border: none; }"); + auto* up = new QToolButton(row); + up->setArrowType(Qt::UpArrow); + up->setAutoRaise(true); + up->setStyleSheet(plain_disabled); + up->setToolTip(tr("Swap with P%1").arg(player)); + up->setEnabled(live && player > 0); + connect(up, &QToolButton::clicked, this, + [this, player] { RequestSwap(player, player - 1); }); + line->addWidget(up); + auto* down = new QToolButton(row); + down->setArrowType(Qt::DownArrow); + down->setAutoRaise(true); + down->setStyleSheet(plain_disabled); + down->setToolTip(tr("Swap with P%1").arg(player + 2)); + down->setEnabled(live && player < last_player); + connect(down, &QToolButton::clicked, this, + [this, player] { RequestSwap(player, player + 1); }); + line->addWidget(down); + + // Swap and Config outlined in the highlight colour (Swap filled while it waits) + const QString outlined = OutlinedButtonStyle(); + const QString filled = Themed(QStringLiteral( + "QToolButton { border: 1px solid palette(highlight); border-radius: 6px; " + "padding: 3px 12px; background: palette(highlight); " + "color: palette(highlighted-text); font-weight: bold; }")); + auto* swap = new QToolButton(row); + swap->setCursor(Qt::PointingHandCursor); + swap->setStyleSheet(swap_from >= 0 ? filled : outlined); + if (swap_from < 0) { + swap->setText(tr("Swap")); + } else if (swap_from == player) { + swap->setText(tr("Cancel")); + } else { + swap->setText(tr("Swap with P%1").arg(swap_from + 1)); + } + swap->setEnabled(live); + connect(swap, &QToolButton::clicked, this, [this, player] { OnSwapPressed(player); }); + line->addWidget(swap); + + auto* settings = new QToolButton(row); + settings->setText(tr("Config")); + settings->setCursor(Qt::PointingHandCursor); + settings->setStyleSheet(outlined); + settings->setToolTip(tr("Open P%1's tab").arg(player + 1)); + settings->setEnabled(static_cast(open_player_tab)); + connect(settings, &QToolButton::clicked, this, [this, player] { + if (open_player_tab) { + open_player_tab(player); + } + }); + line->addWidget(settings); + + // Turn Off, for a Bluetooth controller: Eden disconnects it, which turns most + // controllers off. (Room kept on the other rows so the buttons line up.) + if (any_turn_off) { + line->addSpacing(6); + auto* turn_off = new QToolButton(row); + turn_off->setText(tr("Turn Off")); + turn_off->setIcon(PowerIcon()); + turn_off->setIconSize(QSize(14, 14)); + turn_off->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + turn_off->setCursor(Qt::PointingHandCursor); + turn_off->setStyleSheet(Themed(QStringLiteral( + "QToolButton { border: 1px solid #c83c3c; border-radius: 6px; " + "padding: 3px 10px; background: #c83c3c; color: white; font-weight: bold; }" + "QToolButton:hover { background: #a82c2c; border-color: #a82c2c; }" + "QToolButton:disabled { background: transparent; border-color: palette(mid); " + "color: palette(mid); }"))); + turn_off->setToolTip( + entry.turn_off == 2 + ? tr("Turns off P%1's controller and frees the player. Turn the " + "controller on to join again.") + .arg(player + 1) + : tr("Press a button on this controller first, so Eden can tell it " + "from the others like it.")); + turn_off->setEnabled(live && entry.turn_off == 2); + QSizePolicy keep = turn_off->sizePolicy(); + keep.setRetainSizeWhenHidden(true); + turn_off->setSizePolicy(keep); + turn_off->setVisible(entry.turn_off != 0); + connect(turn_off, &QToolButton::clicked, this, [player] { + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.remove_requests.push_back(player); + }); + line->addWidget(turn_off); + } + + players_list->addWidget(row); + player_rows.push_back(row); + row_players.push_back(player); + } + if (!handheld_name.empty() && !handheld_has_player) { + auto* row = new QFrame(players_box); + auto* line = new QHBoxLayout(row); + line->setContentsMargins(4, 2, 4, 2); + line->addWidget(new QLabel(QStringLiteral("\u2014"), row)); + line->addWidget(new QLabel(tr("%1 (built-in, not a player while other controllers " + "are connected)") + .arg(QString::fromStdString(handheld_name)), + row), + 1); + players_list->addWidget(row); + player_rows.push_back(row); + row_players.push_back(-1); + } + } + + // Rows are cards; a fill marks what happens to them: red leaving, blue input held or + // rumbling, green new + const QString card = Themed(QStringLiteral("QFrame#playerRow { border: 1px solid " + "palette(mid); border-radius: 8px; " + "background: palette(base); }")); + const auto fill = [](const char* rgba) { + return Themed( + QStringLiteral("QFrame#playerRow { border: 1px solid palette(mid); border-radius: " + "8px; background-color: rgba(%1); } " + "QFrame#playerRow QLabel { color: white; }") + .arg(QString::fromLatin1(rgba))); + }; + const auto blink_on = [now](qint64 since) { + const qint64 elapsed = now - since; + return elapsed < flash_ms && (elapsed / 125) % 2 == 0; + }; + for (size_t k = 0; k < player_rows.size(); ++k) { + const int player = row_players[k]; + const auto entry = std::find_if(players.begin(), players.end(), + [&](const auto& row) { return row.player == player; }); + if (player < 0 || entry == players.end()) { + continue; + } + const auto rumbled = rumbled_at.find(player); + const bool active = + entry->active || (rumbled != rumbled_at.end() && now - rumbled->second < 500); + const bool blink = entry->flash != 0 && blink_on(entry->flash_since); + QString style = card; + if (entry->flash == 2) { + if (blink) { + style = fill("210, 60, 60, 190"); + } + } else if (active && !entry->leaving) { + style = fill("60, 120, 230, 190"); + } else if (blink) { + style = fill("60, 180, 90, 190"); + } + if (player_rows[k]->styleSheet() != style) { + player_rows[k]->setStyleSheet(style); + } + } + + if (!in_effect || (IsAutomatic(selected) && !engine_active)) { + players_note->setText(tr("Switching...")); + } else if (last_player < 0) { + players_note->setText(tr("No controller is a player right now.")); + } else { + players_note->setText(tr("Press a button on a controller to see its player. Swaps last " + "for this session and do not change your saved controls.")); + } + players_box->setVisible(in_effect && engine_active); + ModernizeCombos(this); +} + +void ConfigurePlayerAssignment::OnBuiltinPicked(int index) { + // Picking is the user's say: a removed controller is no longer kept from auto-detect + Settings::values.builtin_pad_blocked.SetValue(std::string{}); + const QString ident = builtin_picker->itemData(index).toString(); + if (ident.isEmpty()) { + // Detect automatically: forget the stored pad, so the engine detects it afresh + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_steam_handle.SetValue(std::string{}); + Settings::values.builtin_pad_steam_manual.SetValue(false); + Settings::values.builtin_pad_manual.SetValue(false); + return; + } + // Under Steam the entries are Steam handles, set for Steam launches only + const QString prefix = QStringLiteral("steamhandle:"); + if (ident.startsWith(prefix)) { + Settings::values.builtin_pad_steam_handle.SetValue(ident.mid(prefix.size()).toStdString()); + Settings::values.builtin_pad_steam_manual.SetValue(true); + } else { + Settings::values.builtin_pad_identity.SetValue(ident.toStdString()); + Settings::values.builtin_pad_manual.SetValue(true); + } +} + +void ConfigurePlayerAssignment::OnIdentifyPressed() { + Settings::values.builtin_pad_blocked.SetValue(std::string{}); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.identify_armed = true; + state.identify_armed_at = std::chrono::steady_clock::now(); +} + +void ConfigurePlayerAssignment::OnSwapPressed(int player) { + if (swap_from < 0) { + swap_from = player; + } else { + if (swap_from != player) { + RequestSwap(swap_from, player); + } + swap_from = -1; + } + RefreshPlayers(); +} + +void ConfigurePlayerAssignment::RequestSwap(int a, int b) { + if (a == b || a < 0 || b < 0 || a >= MaxPlayers || b >= MaxPlayers) { + return; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(a, b); +} + +void ConfigurePlayerAssignment::RequestRumble(int player) { + rumbled_at[player] = QDateTime::currentMSecsSinceEpoch(); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(player); +} + +void ConfigurePlayerAssignment::AddTopWidget(QWidget* widget) { + content_layout->insertWidget(0, widget); +} + +void ConfigurePlayerAssignment::SetOpenPlayerTab(std::function open) { + open_player_tab = std::move(open); + players_signature.clear(); + RefreshPlayers(); +} diff --git a/src/yuzu/configuration/configure_player_assignment.h b/src/yuzu/configuration/configure_player_assignment.h new file mode 100644 index 0000000000..b90237b766 --- /dev/null +++ b/src/yuzu/configuration/configure_player_assignment.h @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +class QAbstractButton; +class QButtonGroup; +class QCheckBox; +class QComboBox; +class QFrame; +class QVBoxLayout; +class QLabel; +class QPushButton; +class QSlider; +class QTimer; +class QToolButton; +class QWidget; + +/// The Controllers tab: how players are assigned, the handheld's own controller, and the live +/// list of players. +class ConfigurePlayerAssignment : public QWidget { + Q_OBJECT + +public: + explicit ConfigurePlayerAssignment(QWidget* parent = nullptr); + ~ConfigurePlayerAssignment() override; + + void ApplyConfiguration(); + + /// How to bring a player's tab to the front (the settings dialog owns the tabs) + void SetOpenPlayerTab(std::function open); + + /// Puts a widget above everything else in the tab + void AddTopWidget(QWidget* widget); + + /// Rounded fields and outlined buttons, as the Controllers tab draws them + static QString ModernComboStyle(); + static QString OutlinedButtonStyle(); + /// A style sheet for the current theme: under a dark look the palette colours become + /// dark-friendly ones ("muted-text" is a note's colour) + static QString Themed(QString style); + /// The accent colour, as Themed gives palette(highlight) + static QColor Accent(const QWidget* widget); + /// A player's colour, as the overlay and the player badges draw it + static QColor PlayerColor(int player); + /// A controller's battery as a small cell (20x12), as the overlay draws it + static QPixmap Battery(int percent, bool charging); + /// A ✕ inside a drop-down, left of its arrow (hidden or shown by the caller) + static QToolButton* AddClearButton(QComboBox* combo); + /// Lets the open lists of a widget's drop-downs follow ModernComboStyle + static void ModernizeCombos(QWidget* root); + + /// A row of joined buttons, one checked at a time (the group gets them, with these ids) + static QWidget* MakeSegmented(QWidget* parent, const QStringList& labels, + const std::vector& ids, QButtonGroup* group); + +private: + void LoadConfiguration(); + /// The controller applet mode the controls select + int SelectedMode() const; + void OnModeChanged(); + void UpdateModeUi(); + void Refresh(); + void RefreshBuiltin(); + void RefreshPlayers(); + void OnBuiltinPicked(int index); + void OnIdentifyPressed(); + void OnSwapPressed(int player); + void RequestSwap(int a, int b); + void RequestRumble(int player); + + /// The handler (Manual, Modern Console): under Manual the controller applet, under Modern + /// Console whether this is a handheld + enum Handler { HandlerManual, HandlerModern }; + QButtonGroup* handler_group = nullptr; + QFrame* manual_panel = nullptr; + QButtonGroup* manual_group = nullptr; + QFrame* handheld_panel = nullptr; + QAbstractButton* handheld_switch = nullptr; + /// Bypass Steam Input (under Steam), a card of its own for both Modern Console modes + QWidget* steam_panel = nullptr; + QAbstractButton* steam_switch = nullptr; + QLabel* steam_restart = nullptr; + bool original_bypass = false; + /// The Gameplay card + QWidget* gameplay_box = nullptr; + QAbstractButton* joycon_switch = nullptr; + bool original_joycon = true; + QButtonGroup* layout_group = nullptr; + bool original_labels = false; + QAbstractButton* pause_switch = nullptr; + bool original_pause = false; + QAbstractButton* lights_switch = nullptr; + bool original_lights = true; + QLabel* mode_info = nullptr; + QWidget* display_box = nullptr; + QWidget* about_box = nullptr; + /// The scrolled page the cards stack in + QVBoxLayout* content_layout = nullptr; + QAbstractButton* overlay_toggle = nullptr; + bool original_overlay = false; + QAbstractButton* hints_toggle = nullptr; + bool original_hints = true; + /// The overlay's button history options (indented under Controller Overlay) + QWidget* history_options = nullptr; + QAbstractButton* history_toggle = nullptr; + QSlider* history_size = nullptr; + QSlider* history_seconds = nullptr; + QAbstractButton* gaps_toggle = nullptr; + bool original_history = true; + int original_history_size = 10; + int original_history_seconds = 5; + bool original_history_gaps = false; + /// Debug Probes, in the Troubleshooting card + QWidget* debug_box = nullptr; + QAbstractButton* probes_toggle = nullptr; + bool original_probes = false; + QWidget* now_box = nullptr; + + QWidget* builtin_row = nullptr; + QComboBox* builtin_picker = nullptr; + QPushButton* builtin_identify = nullptr; + QLabel* builtin_status = nullptr; + /// Removing the handheld's controller (the ✕), and allowing a removed one again + QToolButton* builtin_remove = nullptr; + QWidget* blocked_row = nullptr; + QLabel* blocked_label = nullptr; + QPushButton* blocked_allow = nullptr; + QString builtin_signature; + + QWidget* players_box = nullptr; + QVBoxLayout* players_list = nullptr; + QLabel* players_note = nullptr; + QString players_signature; + /// A player's name is being typed (the rows wait) + bool editing_name = false; + /// The player whose Swap was pressed first, waiting for the second (-1: none) + int swap_from = -1; + /// The rows and the player each shows, for restyling them while a controller is active + std::vector player_rows; + std::vector row_players; + /// When each player's row was clicked to rumble (blue for a moment) + std::map rumbled_at; + std::function open_player_tab; + + QTimer* timer = nullptr; + /// The mode when the dialog opened, put back unless the dialog applies + int original_mode = 0; + bool applied = false; +}; diff --git a/src/yuzu/configuration/input_profiles.cpp b/src/yuzu/configuration/input_profiles.cpp index 952b5ab7b0..15437e0233 100644 --- a/src/yuzu/configuration/input_profiles.cpp +++ b/src/yuzu/configuration/input_profiles.cpp @@ -16,9 +16,12 @@ namespace FS = Common::FS; namespace { +std::filesystem::path ProfilePath(std::string_view profile_name) { + return FS::GetEdenPath(FS::EdenPath::ConfigDir) / "input" / fmt::format("{}.ini", profile_name); +} + bool ProfileExistsInFilesystem(std::string_view profile_name) { - return FS::Exists(FS::GetEdenPath(FS::EdenPath::ConfigDir) / "input" / - fmt::format("{}.ini", profile_name)); + return FS::Exists(ProfilePath(profile_name)); } bool IsINI(const std::filesystem::path& filename) { @@ -132,6 +135,46 @@ bool InputProfiles::SaveProfile(const std::string& profile_name, std::size_t pla return true; } +bool InputProfiles::RenameProfile(const std::string& profile_name, const std::string& new_name) { + if (!ProfileExistsInMap(profile_name) || ProfileExistsInMap(new_name) || + ProfileExistsInFilesystem(new_name) || + !FS::RenameFile(ProfilePath(profile_name), ProfilePath(new_name))) { + return false; + } + map_profiles.erase(profile_name); + map_profiles.insert_or_assign( + new_name, std::make_unique(new_name, Config::ConfigType::InputProfile)); + return true; +} + +bool InputProfiles::DuplicateProfile(const std::string& profile_name, const std::string& new_name) { + if (!ProfileExistsInMap(profile_name) || ProfileExistsInMap(new_name) || + ProfileExistsInFilesystem(new_name)) { + return false; + } + std::error_code error; + std::filesystem::copy_file(ProfilePath(profile_name), ProfilePath(new_name), error); + if (error) { + return false; + } + map_profiles.insert_or_assign( + new_name, std::make_unique(new_name, Config::ConfigType::InputProfile)); + return true; +} + +std::optional InputProfiles::PeekProfile(const std::string& profile_name, + std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return std::nullopt; + } + auto& player = Settings::values.players.GetValue()[player_index]; + const auto kept = player; + map_profiles[profile_name]->ReadQtControlPlayerValues(player_index); + auto read = player; + player = kept; + return read; +} + bool InputProfiles::ProfileExistsInMap(const std::string& profile_name) const { return map_profiles.find(profile_name) != map_profiles.end(); } diff --git a/src/yuzu/configuration/input_profiles.h b/src/yuzu/configuration/input_profiles.h index 4ee7a266c4..84237ff409 100644 --- a/src/yuzu/configuration/input_profiles.h +++ b/src/yuzu/configuration/input_profiles.h @@ -6,6 +6,7 @@ #pragma once +#include #include #include "common/container/unordered_map.h" @@ -31,6 +32,11 @@ public: bool DeleteProfile(const std::string& profile_name); bool LoadProfile(const std::string& profile_name, std::size_t player_index); bool SaveProfile(const std::string& profile_name, std::size_t player_index); + bool RenameProfile(const std::string& profile_name, const std::string& new_name); + bool DuplicateProfile(const std::string& profile_name, const std::string& new_name); + /// A profile's player values, read without touching the player they are read through + std::optional PeekProfile(const std::string& profile_name, + std::size_t player_index); private: bool ProfileExistsInMap(const std::string& profile_name) const; diff --git a/src/yuzu/configuration/shared_widget.cpp b/src/yuzu/configuration/shared_widget.cpp index 5e8e04be58..9410988bdb 100644 --- a/src/yuzu/configuration/shared_widget.cpp +++ b/src/yuzu/configuration/shared_widget.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -139,13 +140,34 @@ QWidget* Widget::CreateCombobox(std::function& serializer, const ComboboxTranslations* enumeration{nullptr}; if (combobox_enumerations.contains(type)) { enumeration = &combobox_enumerations.at(type); + const bool applet = type == Settings::EnumMetadata::Index(); + const bool controller = setting.Id() == Settings::values.controller_applet_mode.Id(); for (const auto& [id, name] : *enumeration) { - if (type == Settings::EnumMetadata::Index() && - id == static_cast(Settings::AppletMode::Disabled) && - setting.Id() != Settings::values.controller_applet_mode.Id()) { + // The automatic modes are the controller applet's alone + const bool automatic = + id == static_cast(Settings::AppletMode::ConsoleMode) || + id == static_cast(Settings::AppletMode::HandheldConsoleMode); + if (applet && automatic && !controller) { continue; } combobox->addItem(name, id); + // Handheld Console needs to know the handheld's own controller + const bool handheld_set = !Settings::values.builtin_pad_identity.GetValue().empty() || + !Settings::values.builtin_pad_steam_handle.GetValue().empty(); + if (applet && controller && + id == static_cast(Settings::AppletMode::HandheldConsoleMode) && + !handheld_set) { + const int row = combobox->count() - 1; + if (auto* model = qobject_cast(combobox->model())) { + model->item(row)->setEnabled(false); + } + combobox->setItemData( + row, + tr("No handheld controller is set. Pick Modern Console in Controls > " + "Controllers, turn on Portable Handheld Gaming Device, and pick or " + "identify the handheld's controller."), + Qt::ToolTipRole); + } } } else { return combobox; @@ -163,7 +185,14 @@ QWidget* Widget::CreateCombobox(std::function& serializer, const u32 setting_value = std::strtoul(setting.ToString().c_str(), nullptr, 0); combobox->setCurrentIndex(find_index(setting_value)); - serializer = [this]() { + // The controller applet's mode is also set in Controls > Controllers, which applies at once: + // unless it was changed here, this keeps what it is by then (this tab applies last) + const bool controller_mode = setting.Id() == Settings::values.controller_applet_mode.Id(); + const int loaded_index = combobox->currentIndex(); + serializer = [this, controller_mode, loaded_index]() { + if (controller_mode && combobox->currentIndex() == loaded_index) { + return setting.ToString(); + } return std::to_string(combobox->currentData().toUInt()); }; diff --git a/src/yuzu/game/game_list.cpp b/src/yuzu/game/game_list.cpp index 6b89e59253..6a70b71ae7 100644 --- a/src/yuzu/game/game_list.cpp +++ b/src/yuzu/game/game_list.cpp @@ -76,6 +76,22 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid if (system.IsPoweredOn()) { return; } + if (controller_context_menu) { + // A submenu is its own popup window (isAncestorOf stops at windows) + QWidget* popup = QApplication::activePopupWidget(); + bool ours = false; + for (QWidget* w = popup; w != nullptr && !ours; w = w->parentWidget()) { + ours = w == controller_context_menu; + } + if (ours) { + QCoreApplication::postEvent( + popup, new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier)); + } + return; + } + if (QApplication::activePopupWidget() || QApplication::activeModalWidget()) { + return; + } if (!this->isActiveWindow()) { return; } @@ -83,6 +99,28 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid QCoreApplication::postEvent(m_currentView, event); }); + connect( + controller_navigation, &ControllerNavigation::TriggerContextMenu, this, + [this] { + if (system.IsPoweredOn() || !isActiveWindow() || QApplication::activePopupWidget() || + QApplication::activeModalWidget() || controller_context_menu) { + return; + } + const QModelIndex current = m_currentView->currentIndex(); + const QModelIndex selected = current.sibling(current.row(), 0); + if (!selected.isValid() || + selected.data(GameListItem::TypeRole).value() != + GameListItemType::Game) { + return; + } + m_currentView->scrollTo(current); + const QRect rect = m_currentView->visualRect(current); + if (rect.isValid()) { + PopupContextMenu(rect.center()); + } + }, + Qt::QueuedConnection); + connect(item_model, &GameListModel::PopulatingCompleted, this, &GameList::OnPopulatingCompleted); @@ -443,7 +481,9 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { default: break; } + controller_context_menu = &context_menu; context_menu.exec(m_currentView->viewport()->mapToGlobal(menu_location)); + controller_context_menu = nullptr; } void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path) { diff --git a/src/yuzu/game/game_list.h b/src/yuzu/game/game_list.h index 1540dd9cf0..1305783e5d 100644 --- a/src/yuzu/game/game_list.h +++ b/src/yuzu/game/game_list.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,7 @@ enum class StartGameType; class GameCarousel; class QAbstractItemView; +class QMenu; namespace Core { class System; } @@ -155,6 +157,7 @@ private: Settings::GameListMode game_list_mode; ControllerNavigation* controller_navigation = nullptr; + QPointer controller_context_menu; QVariantAnimation* vertical_scroll = nullptr; QVariantAnimation* horizontal_scroll = nullptr; diff --git a/src/yuzu/main.ui b/src/yuzu/main.ui index d79fcbe396..e432095be8 100644 --- a/src/yuzu/main.ui +++ b/src/yuzu/main.ui @@ -156,6 +156,8 @@ + + @@ -614,6 +616,22 @@ Show &Performance Overlay + + + true + + + Show &Controller Overlay + + + + + true + + + Show Controller &Hints + + Post-Processing &Shaders... diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 69b7338a66..3bf345e62b 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -3,12 +3,15 @@ // Static Qt on macOS doesn't use Vulkan // Other platforms do, and thus have conflicting VulkanMemoryAllocator symbols +#include +#include #include "core/launch_params.h" #if defined(QT_STATICPLUGIN) && !defined(__APPLE__) #undef VMA_IMPLEMENTATION #endif #include +#include "common/controller_console_state.h" #include "common/fs/path_util.h" #include "common/settings.h" #include "common/settings_enums.h" @@ -42,6 +45,7 @@ #include "set_play_time_dialog.h" #include "util/util.h" #include "yuzu/game/game_list.h" +#include "yuzu/overlay_panel.h" #include "applets/qt_amiibo_settings.h" #include "applets/qt_controller.h" @@ -53,10 +57,13 @@ #include "configuration/configure_dialog.h" #include "configuration/configure_input.h" #include "configuration/configure_per_game.h" +#include "configuration/configure_player_assignment.h" #include "configuration/configure_tas.h" +#include "configuration/input_profiles.h" #include "util/clickable_label.h" #include "util/controller_navigation.h" +#include "util/gamescope.h" #include "util/overlay_dialog.h" #include "multiplayer/state.h" @@ -64,15 +71,39 @@ // Qt Stuff // #define QT_NO_OPENGL +#include +#include #include +#include +#include +#include #include #include #include #include +#include #include +#include #include #include +#include +#include +#include +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif #include #include #include @@ -167,6 +198,10 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "video_core/shader_notify.h" #include +#ifdef __linux__ +#include +#include +#endif #include @@ -185,6 +220,12 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #endif +#include +#include +#include +#include +#include "common/overlay_panels.h" +#include "common/param_package.h" #include "qt_common/gamemode.h" #ifdef _WIN32 @@ -192,8 +233,8 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "core/core_timing.h" #include -#include #include +#include #include #ifdef _MSC_VER #pragma comment(lib, "Dwmapi.lib") @@ -349,6 +390,387 @@ inline static bool isDarkMode() { } #endif // _WIN32 +namespace { + +/// Asks the engine to put the players in this order: place k takes the controller player +/// order[k] has now, one swap at a time +void RequestPlayerOrder(const std::vector& order) { + std::vector places = order; + std::sort(places.begin(), places.end()); + if (order == places) { + return; + } + std::map holder; + for (const int place : places) { + holder[place] = place; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + for (std::size_t k = 0; k < places.size(); ++k) { + int from = places[k]; + for (const auto& [place, original] : holder) { + if (original == order[k]) { + from = place; + } + } + if (from != places[k]) { + state.swap_requests.emplace_back(places[k], from); + std::swap(holder[places[k]], holder[from]); + } + } +} + +/// The players as chips after the last menu: a click rumbles the controller and opens its tab; +/// a chip drags left or right to change the order +class PlayerChips : public QWidget { +public: + PlayerChips(QMenuBar* bar_, std::function open_settings_) + : QWidget(bar_), bar{bar_}, open_settings{std::move(open_settings_)} { + setAttribute(Qt::WA_NoSystemBackground); + // (A dark theme gives a plain widget a solid background.) By name, or the chips' + // tooltip takes the rule too and shows as a black box under gamescope + setObjectName(QStringLiteral("playerChips")); + setStyleSheet(QStringLiteral("#playerChips { background: transparent; }")); + setCursor(Qt::PointingHandCursor); + long_press.setSingleShot(true); + long_press.setInterval(400); + QObject::connect(&long_press, &QTimer::timeout, this, [this] { + if (pressed >= 0) { + dragging = true; + setCursor(Qt::ClosedHandCursor); + update(); + } + }); + QObject::connect(&poll, &QTimer::timeout, this, [this] { Refresh(); }); + poll.start(100); + hide(); + } + +protected: + void paintEvent(QPaintEvent*) override { + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + QFont font = bar->font(); + font.setBold(true); + painter.setFont(font); + const bool several = rows.size() > 1; + const auto chip = [&](const Common::ControllerConsoleState::PlayerRow& row, QRectF rect, + int k) { + const QColor color = ConfigurePlayerAssignment::PlayerColor(row.player); + // Filled while an input is held, else outlined; blinking green after a swap + const qint64 since = QDateTime::currentMSecsSinceEpoch() - row.flash_since; + const bool blink = row.flash == 1 && since < 1500 && (since / 125) % 2 == 0; + const bool lit = row.active || blink; + if (lit) { + painter.setPen(Qt::NoPen); + painter.setBrush(blink ? QColor(45, 160, 80) : color); + } else { + painter.setPen(QPen(color, 1.5)); + painter.setBrush(Qt::NoBrush); + } + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2); + const QColor ink = lit ? QColor(Qt::white) : color; + // The battery's percent, then its icon, at the right end + QRectF label = rect; + if (row.battery >= 0 && batteries) { + const QRectF cell(rect.right() - (several ? 16 : 6) - battery_w, + rect.center().y() - 6, battery_w, 12); + painter.drawPixmap(cell.topLeft(), + ConfigurePlayerAssignment::Battery(row.battery, row.charging)); + const QRectF percent(cell.left() - 2 - percent_w, rect.top(), percent_w, + rect.height()); + painter.save(); + QFont percent_font = painter.font(); + percent_font.setPointSizeF(percent_font.pointSizeF() * 0.85); + painter.setFont(percent_font); + painter.setPen(ink); + painter.drawText(percent, Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1%").arg(row.battery)); + painter.restore(); + label.setRight(percent.left() + (several ? 16 : 6)); + } + // With other players around: the grip, and the arrows swapping with a neighbour + painter.setPen(ink); + painter.drawText(label, Qt::AlignCenter, + several ? QStringLiteral("\u283F P%1").arg(row.player + 1) + : QStringLiteral("P%1").arg(row.player + 1)); + if (several) { + const auto arrow = [&](QRectF area, bool left, bool enabled) { + QColor fill = ink; + fill.setAlpha(enabled ? 255 : 70); + painter.setPen(Qt::NoPen); + painter.setBrush(fill); + const QPointF c = area.center(); + const qreal s = 3.5; + const QPointF tri[3] = {left ? QPointF(c.x() - s, c.y()) + : QPointF(c.x() + s, c.y()), + left ? QPointF(c.x() + s * 0.7, c.y() - s) + : QPointF(c.x() - s * 0.7, c.y() - s), + left ? QPointF(c.x() + s * 0.7, c.y() + s) + : QPointF(c.x() - s * 0.7, c.y() + s)}; + painter.drawPolygon(tri, 3); + }; + arrow(LeftArrow(rect), true, k > 0); + arrow(RightArrow(rect), false, k + 1 < static_cast(rows.size())); + } + }; + for (std::size_t k = 0; k < rows.size(); ++k) { + const QRectF rect = ChipRect(static_cast(k)); + if (dragging && static_cast(k) == pressed) { + // Where the dragged chip came from + QColor edge = ConfigurePlayerAssignment::PlayerColor(rows[k].player); + edge.setAlpha(100); + painter.setPen(QPen(edge, 1, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2); + continue; + } + chip(rows[k], rect, static_cast(k)); + } + if (dragging && pressed >= 0 && pressed < static_cast(rows.size())) { + chip(rows[static_cast(pressed)], ChipRect(pressed).translated(drag_dx, 0), + pressed); + } + } + + void mousePressEvent(QMouseEvent* event) override { + if (event->button() != Qt::LeftButton) { + return; + } + pressed = IndexAt(event->position().x()); + press_x = event->position().x(); + drag_dx = 0; + dragging = false; + // (Nothing to reorder with one player) + if (pressed >= 0 && rows.size() > 1) { + long_press.start(); + } + } + + void mouseMoveEvent(QMouseEvent* event) override { + if (dragging) { + drag_dx = event->position().x() - press_x; + update(); + } + } + + void mouseReleaseEvent(QMouseEvent* event) override { + long_press.stop(); + const int index = pressed; + pressed = -1; + setCursor(Qt::PointingHandCursor); + if (index < 0 || index >= static_cast(rows.size())) { + dragging = false; + return; + } + if (dragging) { + dragging = false; + // The players in the order of their chips, the dragged one where it was let go + std::vector> centres; + for (std::size_t k = 0; k < rows.size(); ++k) { + const double x = ChipRect(static_cast(k)).center().x() + + (static_cast(k) == index ? drag_dx : 0.0); + centres.emplace_back(x, rows[k].player); + } + std::stable_sort(centres.begin(), centres.end()); + std::vector order; + for (const auto& [x, player] : centres) { + order.push_back(player); + } + RequestPlayerOrder(order); + update(); + return; + } + if (IndexAt(event->position().x()) != index) { + return; + } + // An arrow: swap with the neighbouring chip's player + if (rows.size() > 1) { + const QRectF rect = ChipRect(index); + const QPointF at = event->position(); + const int step = LeftArrow(rect).contains(at) ? -1 + : RightArrow(rect).contains(at) ? 1 + : 0; + if (step != 0) { + const int other = index + step; + if (other >= 0 && other < static_cast(rows.size())) { + const int a = rows[static_cast(index)].player; + const int b = rows[static_cast(other)].player; + LOG_INFO(Frontend, "console-mode: menu chip swap P{} <-> P{}", a + 1, b + 1); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(a, b); + } + return; + } + } + // A click: the controller rumbles (vibration on), and its settings open + const int player = rows[static_cast(index)].player; + if (Settings::values.vibration_enabled.GetValue() && + Settings::values.players.GetValue()[player].vibration_enabled) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(player); + } + QTimer::singleShot(0, this, [this, player] { open_settings(player); }); + } + + bool event(QEvent* event) override { + if (event->type() == QEvent::ToolTip) { + const auto* help = static_cast(event); + const int index = IndexAt(help->pos().x()); + if (index >= 0) { + const auto& row = rows[static_cast(index)]; + const QString battery = + row.battery < 0 ? QString{} + : row.charging ? QObject::tr("\nBattery %1% (charging)").arg(row.battery) + : QObject::tr("\nBattery %1%").arg(row.battery); + QToolTip::showText( + help->globalPos(), + (rows.size() > 1 + ? QObject::tr("P%1: %2\nClick: rumble and open its settings\nArrows, " + "or hold then drag: change the player order") + .arg(row.player + 1) + .arg(QString::fromStdString(row.name)) + : QObject::tr("P%1: %2\nClick: rumble and open its settings") + .arg(row.player + 1) + .arg(QString::fromStdString(row.name))) + + battery, + this); + } else { + QToolTip::hideText(); + } + return true; + } + return QWidget::event(event); + } + +private: + static constexpr int chip_w = 34; + /// With arrows and the grip, when there are several players + static constexpr int chip_w_full = 74; + static constexpr int chip_gap = 4; + /// A chip's battery, when its controller tells its charge (and the room it takes) + static constexpr int battery_w = 20; + /// Its percent after it ("100%" fits) + static constexpr int percent_w = 28; + static constexpr int battery_room = battery_w + percent_w + 4; + + /// A chip's width: wider with arrows and the grip (several players), and with a battery + static int ChipWidth(const std::vector& among, + std::size_t k, bool with_batteries) { + return (among.size() > 1 ? chip_w_full : chip_w) + + (with_batteries && among[k].battery >= 0 ? battery_room : 0); + } + + QRectF ChipRect(int k) const { + double x = 4; + for (int j = 0; j < k; ++j) { + x += ChipWidth(rows, static_cast(j), batteries) + chip_gap; + } + return QRectF(x, 3, ChipWidth(rows, static_cast(k), batteries), + height() - 6); + } + + static QRectF LeftArrow(const QRectF& chip) { + return QRectF(chip.left() + 2, chip.top(), 14, chip.height()); + } + + static QRectF RightArrow(const QRectF& chip) { + return QRectF(chip.right() - 16, chip.top(), 14, chip.height()); + } + + int IndexAt(double x) const { + for (int k = 0; k < static_cast(rows.size()); ++k) { + if (ChipRect(k) + .adjusted(-chip_gap / 2.0, 0, chip_gap / 2.0, 0) + .contains(x, height() / 2.0)) { + return k; + } + } + return -1; + } + + void Refresh() { + std::vector now; + bool engine_active = false; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + engine_active = state.engine_active; + for (const auto& row : state.players) { + if (!row.leaving) { + now.push_back(row); + } + } + } + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + // Right after the last menu + int x = 0; + for (QAction* action : bar->actions()) { + if (action->isVisible()) { + const QRect place = bar->actionGeometry(action); + if (place.isValid()) { + x = (std::max)(x, place.right()); + } + } + } + // (Batteries go when the chips would run past the menu bar; they stay in the tooltips) + const auto chips_width = [&](bool with_batteries) { + int w = 8; + for (std::size_t k = 0; k < now.size(); ++k) { + w += ChipWidth(now, k, with_batteries) + chip_gap; + } + return w; + }; + const bool fit = x + 10 + chips_width(true) <= bar->width(); + if (fit != batteries) { + batteries = fit; + update(); + } + const int chips_w = chips_width(batteries); + const QRect place(x + 10, 0, chips_w, bar->height()); + if (geometry() != place) { + setGeometry(place); + } + const bool shown = automatic && engine_active && !now.empty(); + if (isVisible() != shown) { + setVisible(shown); + } + // Repainted when a player changes and while one blinks (not while dragging) + const auto same = [](const auto& a, const auto& b) { + return a.player == b.player && a.active == b.active && a.name == b.name && + a.flash == b.flash && a.flash_since == b.flash_since && + a.battery == b.battery && a.charging == b.charging; + }; + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + const bool blinking = std::any_of(now.begin(), now.end(), [now_ms](const auto& r) { + return r.flash != 0 && now_ms - r.flash_since < 1600; + }); + if (!dragging && (blinking || now.size() != rows.size() || + !std::equal(now.begin(), now.end(), rows.begin(), same))) { + rows = std::move(now); + update(); + } + } + + QMenuBar* bar; + std::function open_settings; + std::vector rows; + /// The chips show their batteries (they fit in the menu bar with them) + bool batteries = true; + QTimer poll; + QTimer long_press; + int pressed = -1; + double press_x = 0; + double drag_dx = 0; + bool dragging = false; +}; + +} // Anonymous namespace + MainWindow::MainWindow(bool has_broken_vulkan) : ui{std::make_unique()}, input_subsystem{std::make_shared()}, user_data_migrator{this} { @@ -414,6 +836,8 @@ MainWindow::MainWindow(bool has_broken_vulkan) SetDefaultUIGeometry(); RestoreUIState(); + FitToScreenUnderGamescope(this); + Gamescope::FitDialogsAndMenus(this); ConnectMenuEvents(); ConnectWidgetEvents(); @@ -459,6 +883,11 @@ MainWindow::MainWindow(bool has_broken_vulkan) mouse_hide_timer.setInterval(default_mouse_hide_timeout); connect(&mouse_hide_timer, &QTimer::timeout, this, &MainWindow::HideMouseCursor); connect(ui->menubar, &QMenuBar::hovered, this, &MainWindow::ShowMouseCursor); + // The players, after the last menu, in the automatic modes + new PlayerChips(ui->menubar, [this](int player) { + open_player_tab = player; + OnConfigure(); + }); update_input_timer.setInterval(default_input_update_timeout); connect(&update_input_timer, &QTimer::timeout, this, &MainWindow::UpdateInputDrivers); @@ -538,6 +967,9 @@ MainWindow::MainWindow(bool has_broken_vulkan) } MainWindow::~MainWindow() { + // The config saves itself on destruction: it must write the user's own players + RestoreConsoleProfile(); + // will get automatically deleted otherwise if (render_window->parent() == nullptr) { delete render_window; @@ -594,7 +1026,7 @@ void MainWindow::ControllerSelectorReconfigureControllers( // Don't forget to apply settings. QtCommon::system->HIDCore().DisableAllControllerConfiguration(); QtCommon::system->ApplySettings(); - config->SaveAllValues(); + SaveAllConfigValues(); UpdateStatusButtons(); @@ -906,6 +1338,7 @@ void MainWindow::WebBrowserRequestExit() { void MainWindow::InitializeWidgets() { render_window = new GRenderWindow(this, input_subsystem); + render_window->installEventFilter(this); render_window->hide(); game_list = new GameList(QtCommon::vfs, QtCommon::provider.get(), *play_time_manager, @@ -1277,6 +1710,8 @@ void MainWindow::InitializeHotkeys() { LinkActionShortcut(ui->action_Show_Status_Bar, QStringLiteral("Toggle Status Bar")); LinkActionShortcut(ui->action_Show_Performance_Overlay, QStringLiteral("Toggle Performance Overlay")); + LinkActionShortcut(ui->action_Show_Controller_Overlay, + QStringLiteral("Toggle Controller Overlay")); LinkActionShortcut(ui->action_Fullscreen, QStringLiteral("Fullscreen")); LinkActionShortcut(ui->action_Capture_Screenshot, QStringLiteral("Capture Screenshot")); LinkActionShortcut(ui->action_TAS_Start, QStringLiteral("TAS Start/Stop"), true); @@ -1385,6 +1820,9 @@ void MainWindow::RestoreUIState() { ui->action_Show_Performance_Overlay->setChecked( UISettings::values.show_perf_overlay.GetValue()); + ui->action_Show_Controller_Overlay->setChecked( + Settings::values.console_debug_overlay.GetValue()); + ui->action_Show_Controller_Hints->setChecked(Settings::values.console_hints.GetValue()); if (perf_overlay) perf_overlay->setVisible(ui->action_Show_Performance_Overlay->isChecked()); Debugger::ToggleConsole(); @@ -1462,6 +1900,11 @@ void MainWindow::ConnectWidgetEvents() { connect(this, &MainWindow::EmulationStopping, this, &MainWindow::SoftwareKeyboardExit); connect(this, &MainWindow::EmulationStarting, this, &MainWindow::RefreshRuntimeCheats); connect(this, &MainWindow::EmulationStopping, this, &MainWindow::ClearRuntimeCheats); + connect(&console_mode_timer, &QTimer::timeout, this, &MainWindow::ApplyConsoleControllerMode); + connect(&console_activity_timer, &QTimer::timeout, this, &MainWindow::PollControllerActivity); + console_activity_timer.start(std::chrono::milliseconds{40}); + // 250ms: instant to the user, negligible cost (the input reload only runs on changes) + console_mode_timer.start(std::chrono::milliseconds{250}); connect(&status_bar_update_timer, &QTimer::timeout, this, &MainWindow::UpdateStatusBar); @@ -1518,7 +1961,5178 @@ void MainWindow::ClearRuntimeCheats() { ui->menu_Cheats->menuAction()->setVisible(false); } +namespace { + +/// The joystick API is shared with the input driver's threads (the rumble worker); +/// SDL_LockJoysticks is the official guard for calling into it elsewhere. +struct JoystickApiLock { + JoystickApiLock() { + SDL_LockJoysticks(); + } + ~JoystickApiLock() { + SDL_UnlockJoysticks(); + } +}; + +/// Calls `use` with the pad opened as a joystick for the call (under the lock); `fallback` when it +/// can't be opened +template +T WithJoystick(SDL_JoystickID id, T fallback, Use&& use) { + const JoystickApiLock lock; + SDL_Joystick* js = SDL_OpenJoystick(id); + if (!js) { + return fallback; + } + T result = use(js); + SDL_CloseJoystick(js); + return result; +} + +/// Calls `use` with the pad opened as a gamepad for the call (under the lock); `fallback` when it +/// can't be opened +template +T WithGamepad(SDL_JoystickID id, T fallback, Use&& use) { + const JoystickApiLock lock; + SDL_Gamepad* pad = SDL_OpenGamepad(id); + if (!pad) { + return fallback; + } + T result = use(pad); + SDL_CloseGamepad(pad); + return result; +} + +/// A pad's stable identity: its hardware serial when it reports one, else its model GUID (which +/// two pads can share: slots are keyed by connection, this only matches returning pads) +std::string PadIdentity(SDL_JoystickID id) { + const JoystickApiLock lock; + SDL_Joystick* js = SDL_OpenJoystick(id); + if (!js) { + return {}; + } + const char* serial = SDL_GetJoystickSerial(js); + if (serial && serial[0]) { + std::string out{"serial:"}; + out += serial; + SDL_CloseJoystick(js); + return out; + } + const SDL_GUID guid = SDL_GetJoystickGUID(js); + SDL_CloseJoystick(js); + char buf[33]{}; + for (int i = 0; i < 16; ++i) { + unsigned byte = guid.data[i]; + // Bytes 2-3 are a CRC of the name, which Steam changes on the same pad: zeroed, as + // SDLDriver::GetGUID does + if (i == 2 || i == 3) { + byte = 0; + } + std::snprintf(buf + i * 2, 3, "%02x", byte); + } + return std::string{"guid:"} + buf; +} + +/// What Steam says is in one of its virtual pads, from the file it writes for the game. SDL +/// reads it at most every 3 seconds, so a controller Steam just moved showed as the old one. +struct SteamSlot { + Uint64 handle = 0; + Uint16 vendor = 0; + Uint16 product = 0; + SDL_GamepadType type = SDL_GAMEPAD_TYPE_UNKNOWN; + QString name; +}; + +std::optional SteamSlotOf(SDL_JoystickID id); + +/// A pad's display name +QString PadName(SDL_JoystickID id) { + // (Steam names the Deck's controls with a translation key: not a name to show) + const auto slot = SteamSlotOf(id); + if (slot && !slot->name.isEmpty() && !slot->name.startsWith(QLatin1Char('#'))) { + return slot->name; + } + const Uint16 vendor = slot ? slot->vendor : SDL_GetJoystickVendorForID(id); + const Uint16 product = slot ? slot->product : SDL_GetJoystickProductForID(id); + if (vendor == 0x28DE && product == 0x1205) { + return QStringLiteral("Steam Deck"); + } + return WithJoystick(id, QStringLiteral("?"), [](SDL_Joystick* js) { + const char* name = SDL_GetJoystickName(js); + return name ? QString::fromUtf8(name) : QStringLiteral("?"); + }); +} + +/// True for Steam Input's virtual pads, whose GUID and serial aren't stable (Steam re-binds them) +bool IsSteamVirtualPad(SDL_JoystickID id) { + if (!Common::RunningUnderSteam()) { + return false; + } + // Every virtual pad's GUID carries Valve's vendor (0x28DE, bytes 4-5); the name isn't + // usable (one pad shows as "SteamDeck" and "Xbox" in a session) + // Valve makes real controllers too: the virtual gamepad product (0x11FF, bytes 8-9) marks them + return WithJoystick(id, false, [](SDL_Joystick* js) { + const SDL_GUID guid = SDL_GetJoystickGUID(js); + return guid.data[4] == 0xDE && guid.data[5] == 0x28 && guid.data[8] == 0xFF && + guid.data[9] == 0x11; + }); +} + +/// The OS device node of a pad (e.g. /dev/input/event5); empty where unsupported +QString PadPath(SDL_JoystickID id) { + return WithJoystick(id, QString{}, [](SDL_Joystick* js) { + const char* path = SDL_GetJoystickPath(js); + return path ? QString::fromUtf8(path) : QString{}; + }); +} + +/// A pad's slot as Steam says now (nothing outside Steam's pads); reread when Steam writes it +std::optional SteamSlotOf(SDL_JoystickID id) { +#ifdef __linux__ + static const QByteArray file = qgetenv("SteamVirtualGamepadInfo"); + if (file.isEmpty()) { + return std::nullopt; + } + // The pad's slot: Steam names its device "Microsoft X-Box 360 pad " (read once a pad) + static std::map slot_of_pad; + auto known = slot_of_pad.find(id); + if (known == slot_of_pad.end()) { + const QString node = QFileInfo{PadPath(id)}.fileName(); + if (node.isEmpty()) { + return std::nullopt; + } + int slot = -1; + QFile name_file{QStringLiteral("/sys/class/input/%1/device/name").arg(node)}; + if (name_file.open(QIODevice::ReadOnly)) { + const QString name = QString::fromUtf8(name_file.readAll()).trimmed(); + const QString prefix = QStringLiteral("Microsoft X-Box 360 pad "); + bool ok = false; + const int number = name.startsWith(prefix) ? name.mid(prefix.size()).toInt(&ok) : -1; + slot = ok ? number : -1; + } + known = slot_of_pad.emplace(id, slot).first; + } + if (known->second < 0) { + return std::nullopt; + } + // The file, read again when Steam writes it + static std::map said; + static timespec said_at{}; + struct ::stat st {}; + if (::stat(file.constData(), &st) != 0) { + return std::nullopt; + } + if (st.st_mtim.tv_sec != said_at.tv_sec || st.st_mtim.tv_nsec != said_at.tv_nsec) { + QFile in{QString::fromLocal8Bit(file)}; + if (!in.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + said_at = st.st_mtim; + said.clear(); + SteamSlot* current = nullptr; + for (const QByteArray& raw : in.readAll().split('\n')) { + const QByteArray line = raw.trimmed(); + if (line.startsWith("[slot ") && line.endsWith(']')) { + current = &said[line.mid(6, line.size() - 7).toInt()]; + continue; + } + const auto at = line.indexOf('='); + if (!current || at < 0) { + continue; + } + const QByteArray key = line.left(at); + const QByteArray value = line.mid(at + 1); + if (key == "handle") { + current->handle = value.toULongLong(nullptr, 16); + } else if (key == "VID") { + current->vendor = static_cast(value.toUInt(nullptr, 16)); + } else if (key == "PID") { + current->product = static_cast(value.toUInt(nullptr, 16)); + } else if (key == "type") { + current->type = SDL_GetGamepadTypeFromString(value.constData()); + } else if (key == "name") { + current->name = QString::fromUtf8(value); + } + } + } + if (const auto slot = said.find(known->second); slot != said.end()) { + return slot->second; + } +#endif + return std::nullopt; +} + +/// A pad's USB vendor id (under Steam, its controller's, as Steam says now) +Uint16 PadVendor(SDL_JoystickID id) { + if (const auto slot = SteamSlotOf(id)) { + return slot->vendor; + } + return SDL_GetJoystickVendorForID(id); +} + +/// A pad's USB product id (under Steam, its controller's, as Steam says now) +Uint16 PadProduct(SDL_JoystickID id) { + if (const auto slot = SteamSlotOf(id)) { + return slot->product; + } + return SDL_GetJoystickProductForID(id); +} + +/// A pad's gamepad type (under Steam, its controller's, as Steam says now) +SDL_GamepadType PadType(SDL_JoystickID id) { + if (const auto slot = SteamSlotOf(id)) { + return slot->type; + } + return SDL_GetGamepadTypeForID(id); +} + +#ifdef __linux__ +/// The kernel's HID entry for a controller (bus, vendor, product, address), from its uevent +struct HidEntry { + unsigned bus = 0; + unsigned vendor = 0; + unsigned product = 0; + std::string address; +}; + +std::optional ReadHidEntry(const QString& dir) { + QFile file{dir + QStringLiteral("/uevent")}; + if (dir.isEmpty() || !file.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + HidEntry entry; + bool has_id = false; + for (const QByteArray& line : file.readAll().split('\n')) { + if (line.startsWith("HID_ID=")) { + const QList parts = line.mid(7).split(':'); + if (parts.size() == 3) { + entry.bus = parts[0].toUInt(nullptr, 16); + entry.vendor = parts[1].toUInt(nullptr, 16); + entry.product = parts[2].toUInt(nullptr, 16); + has_id = true; + } + } else if (line.startsWith("HID_UNIQ=")) { + entry.address = line.mid(9).trimmed().toLower().toStdString(); + } + } + return has_id ? std::optional{entry} : std::nullopt; +} + +/// A controller as the kernel has it: address (or HID entry), Bluetooth or not, input nodes +struct RealController { + std::string key; + std::string address; + bool bluetooth = false; + std::vector events; +}; + +constexpr unsigned BusBluetooth = 0x5; + +/// The controllers of a model (one controller's several HID entries share its address) +std::vector ControllersOfModel(unsigned vendor, unsigned product) { + std::map found; + const QDir devices{QStringLiteral("/sys/bus/hid/devices")}; + for (const QString& name : devices.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + const QString dir = devices.filePath(name); + const auto entry = ReadHidEntry(dir); + if (!entry || entry->vendor != vendor || entry->product != product) { + continue; + } + const std::string key = entry->address.empty() ? name.toStdString() : entry->address; + auto& controller = found[key]; + controller.key = key; + controller.address = entry->address; + controller.bluetooth = entry->bus == BusBluetooth && entry->address.size() == 17; + const QDir inputs{dir + QStringLiteral("/input")}; + for (const QString& input : inputs.entryList({QStringLiteral("input*")}, QDir::Dirs)) { + const QDir nodes{inputs.filePath(input)}; + for (const QString& event : nodes.entryList({QStringLiteral("event*")}, QDir::Dirs)) { + controller.events.push_back(QStringLiteral("/dev/input/") + event); + } + } + } + std::vector out; + for (auto& [key, controller] : found) { + out.push_back(std::move(controller)); + } + return out; +} + +/// Whether a pad's controller is on Bluetooth, and its address: from the HID device behind the +/// pad, else (under Steam) the only controller of its model. Unsure with several of the model +/// until a button press tells them apart, so Turn Off never hits the wrong one. +struct PadBluetooth { + std::string address; + bool unsure = false; +}; + +PadBluetooth FindPadBluetooth(SDL_JoystickID id) { + const QString node = QFileInfo{PadPath(id)}.fileName(); + if (!node.isEmpty()) { + const QString dir = + node.startsWith(QStringLiteral("hidraw")) + ? QFileInfo{QStringLiteral("/sys/class/hidraw/%1/device").arg(node)} + .canonicalFilePath() + : QFileInfo{QStringLiteral("/sys/class/input/%1/device/device").arg(node)} + .canonicalFilePath(); + if (const auto entry = ReadHidEntry(dir)) { + const bool bluetooth = entry->bus == BusBluetooth && entry->address.size() == 17; + return {bluetooth ? entry->address : std::string{}}; + } + } + const auto controllers = + ControllersOfModel(PadVendor(id), PadProduct(id)); + const bool any_bluetooth = std::any_of(controllers.begin(), controllers.end(), + [](const auto& c) { return c.bluetooth; }); + if (controllers.size() == 1) { + return {controllers[0].bluetooth ? controllers[0].address : std::string{}}; + } + return {{}, any_bluetooth}; +} + +/// How long a press read from a controller's input node counts (checks come every 100ms) +constexpr s64 RealPressWindowUs = 300'000; + +/// True while a controller has an input held, or had one in the last 300ms (a quick tap falls +/// between checks), read from its input nodes +bool RealButtonHeld(const RealController& controller) { + static std::map open_nodes; + // (Each node's axes, read when it is opened: their ranges, and where they rest then) + static std::map, input_absinfo> ranges; + // (Each node's last press, by the kernel's timestamp) + static std::map pressed_at_us; + timespec real{}; + clock_gettime(CLOCK_REALTIME, &real); + const s64 now_us = static_cast(real.tv_sec) * 1'000'000 + real.tv_nsec / 1000; + for (const QString& event : controller.events) { + auto it = open_nodes.find(event); + if (it == open_nodes.end()) { + const int fd = + ::open(event.toLocal8Bit().constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + // (A node that can't be opened yet, as it connects, is tried again next time) + if (fd < 0) { + continue; + } + it = open_nodes.emplace(event, fd).first; + for (u16 code = 0; code <= ABS_MAX; ++code) { + input_absinfo info{}; + if (ioctl(fd, EVIOCGABS(code), &info) == 0 && info.maximum > info.minimum) { + ranges[{event, code}] = info; + } + } + } + if (it->second < 0) { + continue; + } + // Its queue: the presses since the last check (and drained, so the kernel keeps nothing) + std::array events; + ssize_t got = 0; + while ((got = ::read(it->second, events.data(), sizeof(events))) > 0) { + for (std::size_t k = 0; k < static_cast(got) / sizeof(input_event); ++k) { + const input_event& e = events[k]; + bool pressed = e.type == EV_KEY && e.value == 1; + if (const auto range = ranges.find({event, e.code}); + e.type == EV_ABS && range != ranges.end()) { + // A quarter of its range away from rest + const auto& info = range->second; + const double moved = std::abs(static_cast(e.value - info.value)) / + static_cast(info.maximum - info.minimum); + pressed = moved > 0.25; + } + if (pressed) { + pressed_at_us[event] = static_cast(e.input_event_sec) * 1'000'000 + + static_cast(e.input_event_usec); + } + } + } + if (const auto pressed = pressed_at_us.find(event); + pressed != pressed_at_us.end() && now_us - pressed->second < RealPressWindowUs) { + return true; + } + std::array keys{}; + if (ioctl(it->second, EVIOCGKEY(keys.size()), keys.data()) < 0) { + ::close(it->second); + open_nodes.erase(it); + std::erase_if(ranges, [&](const auto& e) { return e.first.first == event; }); + continue; + } + if (std::any_of(keys.begin(), keys.end(), [](unsigned char b) { return b != 0; })) { + return true; + } + } + return false; +} + +/// A controller's BlueZ object paths, one per Bluetooth adapter +QStringList BluezDevicePaths(const std::string& address) { + QString device = QString::fromStdString(address).toUpper(); + device.replace(QLatin1Char(':'), QLatin1Char('_')); + QStringList paths; + const QDir adapters{QStringLiteral("/sys/class/bluetooth")}; + for (const QString& adapter : adapters.entryList({QStringLiteral("hci*")}, QDir::AllEntries)) { + if (!adapter.contains(QLatin1Char(':'))) { // (hci0:12 is a connection, not an adapter) + paths.push_back(QStringLiteral("/org/bluez/%1/dev_%2").arg(adapter, device)); + } + } + return paths; +} + +/// Asks BlueZ for a property of a controller without waiting: `answer` gets it on the GUI +/// thread (while `context` lives), or never +void AskBluezProperty(const std::string& address, const QString& interface, + const QString& property, QObject* context, + std::function answer) { + QDBusConnection bus = QDBusConnection::systemBus(); + if (!bus.isConnected()) { + return; + } + for (const QString& path : BluezDevicePaths(address)) { + QDBusMessage get = QDBusMessage::createMethodCall( + QStringLiteral("org.bluez"), path, QStringLiteral("org.freedesktop.DBus.Properties"), + QStringLiteral("Get")); + get << interface << property; + auto* watcher = new QDBusPendingCallWatcher(bus.asyncCall(get, 1000)); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, context, + [answer](QDBusPendingCallWatcher* w) { + const QDBusPendingReply reply = *w; + if (!reply.isError()) { + answer(reply.value().variant()); + } + w->deleteLater(); + }); + } +} + +/// Asks BlueZ whether a controller is connected, without waiting (see AskBluezProperty) +void AskBluezConnected(const std::string& address, QObject* context, + std::function answer) { + AskBluezProperty(address, QStringLiteral("org.bluez.Device1"), QStringLiteral("Connected"), + context, [answer](const QVariant& value) { answer(value.toBool()); }); +} + +/// Asks BlueZ to disconnect a controller, which turns most controllers off +void DisconnectBluetooth(const std::string& address) { + QDBusConnection bus = QDBusConnection::systemBus(); + if (!bus.isConnected()) { + LOG_WARNING(Frontend, "console-mode: no system bus to disconnect {}", address); + return; + } + for (const QString& path : BluezDevicePaths(address)) { + const QDBusMessage call = QDBusMessage::createMethodCall( + QStringLiteral("org.bluez"), path, QStringLiteral("org.bluez.Device1"), + QStringLiteral("Disconnect")); + auto* watcher = new QDBusPendingCallWatcher(bus.asyncCall(call)); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, [address, path](auto* w) { + const QDBusPendingReply<> reply = *w; + if (reply.isError()) { + if (reply.error().type() != QDBusError::UnknownObject) { + LOG_WARNING(Frontend, "console-mode: disconnect {} failed: {}", address, + reply.error().message().toStdString()); + } + } else { + LOG_INFO(Frontend, "console-mode: disconnected {} ({})", address, + path.toStdString()); + } + w->deleteLater(); + }); + } +} +#endif + +/// True while any button of a pad is held: the Identify handshake reads this. +bool PadAnyButtonPressed(SDL_JoystickID id) { + return WithJoystick(id, false, [](SDL_Joystick* js) { + const int count = SDL_GetNumJoystickButtons(js); + for (int i = 0; i < count; ++i) { + if (SDL_GetJoystickButton(js, i)) { + return true; + } + } + return false; + }); +} + +/// True while a pad's + (Start, Menu, Options) is held +bool PadStartHeld(SDL_JoystickID id) { + return WithGamepad(id, false, [](SDL_Gamepad* pad) { + return SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_START); + }); +} + +/// Steam's own player index for a pad (-1 when not assigned by Steam Input). +int PadPlayerIndex(SDL_JoystickID id) { + return WithJoystick(id, -1, [](SDL_Joystick* js) { return SDL_GetJoystickPlayerIndex(js); }); +} + +/// True when SDL reports a gyro on the pad +bool PadHasGyro(SDL_JoystickID id) { + return WithGamepad(id, false, + [](SDL_Gamepad* pad) { return SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO); }); +} + +/// True while a pad's accelerometer reads nothing (gravity always registers when on) +bool PadMotionSilent(SDL_JoystickID id) { + return WithGamepad(id, false, [](SDL_Gamepad* pad) { + std::array accel{}; + return SDL_GetGamepadSensorData(pad, SDL_SENSOR_ACCEL, accel.data(), 3) && + accel[0] == 0.0f && accel[1] == 0.0f && accel[2] == 0.0f; + }); +} + +/// Asks the Steam Deck's controller to turn its motion sensors on (Steam keeps them off unless +/// its layout uses the gyro): IMU mode 0x30 = 0x18 in feature report 0x87, as SDL sends it, on +/// a thread of its own +void EnableDeckMotion(const QString& path) { +#ifdef __linux__ + static std::atomic busy{false}; + if (!path.startsWith(QStringLiteral("/dev/hidraw")) || busy.exchange(true)) { + return; + } + std::thread([node = path.toStdString()] { + const int fd = ::open(node.c_str(), O_RDWR | O_CLOEXEC); + if (fd >= 0) { + std::array report{0x00, 0x87, 0x03, 0x30, 0x18, 0x00}; + if (ioctl(fd, HIDIOCSFEATURE(report.size()), report.data()) < 0 && + Common::ControllerProbes()) { + LOG_WARNING(Frontend, "console-mode: deck motion request failed on {}", node); + } + ::close(fd); + } + busy = false; + }).detach(); +#endif +} + +/// The controller applet modes, as the log names them +const char* AppletModeName(Settings::AppletMode mode) { + switch (mode) { + case Settings::AppletMode::HLE: + return "Custom Frontend"; + case Settings::AppletMode::LLE: + return "Real Hardware"; + case Settings::AppletMode::Disabled: + return "Disabled"; + case Settings::AppletMode::ConsoleMode: + return "Modern Console"; + case Settings::AppletMode::HandheldConsoleMode: + return "Handheld Console"; + default: + return "unknown"; + } +} + +/// A controller type, as the log names it +const char* StyleName(Core::HID::NpadStyleIndex style) { + using Core::HID::NpadStyleIndex; + switch (style) { + case NpadStyleIndex::Fullkey: + return "Pro Controller"; + case NpadStyleIndex::Handheld: + return "Handheld"; + case NpadStyleIndex::JoyconDual: + return "Dual Joycons"; + case NpadStyleIndex::JoyconLeft: + return "Left Joycon"; + case NpadStyleIndex::JoyconRight: + return "Right Joycon"; + case NpadStyleIndex::GameCube: + return "GameCube"; + default: + return "other"; + } +} + +/// Whether a game's supported controller types include one (as hid_core decides it) +bool StyleSupported(Core::HID::NpadStyleIndex style, const Core::HID::NpadStyleTag& tag) { + using Core::HID::NpadStyleIndex; + switch (style) { + case NpadStyleIndex::Fullkey: + return tag.fullkey; + case NpadStyleIndex::Handheld: + return tag.handheld; + case NpadStyleIndex::JoyconDual: + return tag.joycon_dual; + case NpadStyleIndex::JoyconLeft: + return tag.joycon_left; + case NpadStyleIndex::JoyconRight: + return tag.joycon_right; + case NpadStyleIndex::GameCube: + return tag.gamecube; + case NpadStyleIndex::Pokeball: + return tag.palma; + case NpadStyleIndex::NES: + return tag.lark; + case NpadStyleIndex::SNES: + return tag.lucia; + case NpadStyleIndex::N64: + return tag.lagoon; + case NpadStyleIndex::SegaGenesis: + return tag.lager; + default: + return false; + } +} + +/// A controller's short name, for a named player ("John (Xbox)") +QString ShortControllerName(SDL_JoystickID id, const QString& full) { + const Uint16 vendor = PadVendor(id); + const Uint16 product = PadProduct(id); + if (vendor == 0x28DE && product == 0x1205) { + return QStringLiteral("Steam Deck"); + } + // (The Steam Controller and the Steam Controller (2026), wired or through a dongle) + if (vendor == 0x28DE && (product == 0x1102 || product == 0x1142 || product == 0x1302 || + product == 0x1304 || product == 0x1305)) { + return QStringLiteral("Steam Controller"); + } + switch (PadType(id)) { + case SDL_GAMEPAD_TYPE_XBOX360: + case SDL_GAMEPAD_TYPE_XBOXONE: + return QStringLiteral("Xbox"); + case SDL_GAMEPAD_TYPE_PS3: + return QStringLiteral("DualShock 3"); + case SDL_GAMEPAD_TYPE_PS4: + return QStringLiteral("DualShock 4"); + case SDL_GAMEPAD_TYPE_PS5: + return QStringLiteral("DualSense"); + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + return QStringLiteral("Pro Controller"); + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + return QStringLiteral("Joy-Con"); + case SDL_GAMEPAD_TYPE_GAMECUBE: + return QStringLiteral("GameCube"); + default: + return full; + } +} + +/// A stick binding turned for a single Joy-Con held sideways (the right one a quarter clockwise, +/// the left one the other way); only SDL bindings turn +std::string TurnStickSideways(const std::string& raw, bool right) { + Common::ParamPackage param{raw}; + if (param.Get("engine", "") != "sdl" || !param.Has("axis_x") || !param.Has("axis_y")) { + return raw; + } + const int axis_x = param.Get("axis_x", 0); + const int axis_y = param.Get("axis_y", 1); + const std::string invert_x = param.Get("invert_x", "+"); + const std::string invert_y = param.Get("invert_y", "+"); + const auto flip = [](const std::string& sign) { + return sign == "-" ? std::string{"+"} : std::string{"-"}; + }; + param.Set("axis_x", axis_y); + param.Set("axis_y", axis_x); + // Right: its x is the push's -y, its y the push's x. Left: its x is y, its y is -x. + // (An SDL stick's y is read negated, after the axes are picked: SDL's y points down. So + // the new x, SDL's y as read, is already the push's -y, and the new y needs a flip of x; + // the left one the other way round.) + param.Set("invert_x", right ? invert_y : flip(invert_y)); + param.Set("invert_y", right ? flip(invert_x) : invert_x); + // (The centre offsets follow their axes; each is minus the axis's resting value as read, + // and the swap reads each axis with the other's sign) + if (param.Has("offset_x") || param.Has("offset_y")) { + const float offset_x = param.Get("offset_x", 0.0f); + const float offset_y = param.Get("offset_y", 0.0f); + param.Set("offset_x", -offset_y); + param.Set("offset_y", -offset_x); + } + return param.Serialize(); +} + +/// A stick binding the D-pad also moves (up, down, left, right: the D-pad buttons that push +/// the stick that way); an empty D-pad leaves the stick as it is +std::string StickWithDpad(const std::string& stick, const std::string& up, + const std::string& down, const std::string& left, + const std::string& right) { + if (up.empty() && down.empty() && left.empty() && right.empty()) { + return stick; + } + Common::ParamPackage param{{"engine", "analog_from_button"}}; + param.Set("up", up); + param.Set("down", down); + param.Set("left", left); + param.Set("right", right); + if (!stick.empty()) { + param.Set("stick", stick); + } + return param.Serialize(); +} + +/// A player's bindings for a single Joy-Con held upright: the right one's stick is the left +/// stick (the D-pad moves it too), L / ZL its SL / SR; the left one's SL / SR are R / ZR +void BindUprightJoyCon(Settings::PlayerInput& set, bool right) { + using namespace Settings::NativeButton; + const auto pad = set.buttons; + auto& out = set.buttons; + if (right) { + out[RStick] = pad[LStick]; + out[SLRight] = pad[L]; + out[SRRight] = pad[ZL]; + set.analogs[Settings::NativeAnalog::RStick] = + StickWithDpad(set.analogs[Settings::NativeAnalog::LStick], pad[DUp], pad[DDown], + pad[DLeft], pad[DRight]); + } else { + out[SLLeft] = pad[R]; + out[SRLeft] = pad[ZR]; + } +} + +/// A player's bindings for a single Joy-Con held sideways: face buttons by position, the +/// shoulders to SL / SR, the left stick to its stick (the D-pad moves it too), the motion +/// turned to the Joy-Con's own axes (a game turns a sideways Joy-Con's stick and motion itself) +void BindSidewaysJoyCon(Settings::PlayerInput& set, bool right) { + for (std::string& motion : set.motions) { + Common::ParamPackage param{motion}; + if (param.Has("motion")) { + param.Set("turn", right ? 1 : -1); + motion = param.Serialize(); + } + } + using namespace Settings::NativeButton; + const auto pad = set.buttons; + auto& out = set.buttons; + const std::string bottom = pad[B], east = pad[A], west = pad[Y], top = pad[X]; + if (right) { + // Turned a quarter clockwise: A below, X to the right, B to the left, Y on top + out[A] = bottom; + out[X] = east; + out[B] = west; + out[Y] = top; + out[SLRight] = pad[L]; + out[SRRight] = pad[R]; + out[RStick] = pad[LStick]; + // (The D-pad as the player sees it: up pushes the turned stick as the stick's up does) + set.analogs[Settings::NativeAnalog::RStick] = + StickWithDpad(TurnStickSideways(set.analogs[Settings::NativeAnalog::LStick], true), + pad[DRight], pad[DLeft], pad[DUp], pad[DDown]); + } else { + // Turned a quarter the other way + out[DLeft] = bottom; + out[DDown] = east; + out[DUp] = west; + out[DRight] = top; + out[SLLeft] = pad[L]; + out[SRLeft] = pad[R]; + set.analogs[Settings::NativeAnalog::LStick] = + StickWithDpad(TurnStickSideways(set.analogs[Settings::NativeAnalog::LStick], false), + pad[DLeft], pad[DRight], pad[DDown], pad[DUp]); + } +} + +/// A battery as Linux lists it (/sys/class/power_supply): charge and whether it charges +#ifdef __linux__ +std::optional> ReadPowerSupply(const QString& dir) { + const auto read = [&](const char* name) { + QFile file{dir + QLatin1Char('/') + QLatin1String(name)}; + return file.open(QIODevice::ReadOnly) ? QString::fromLatin1(file.readAll()).trimmed() + : QString{}; + }; + int percent = -1; + bool ok = false; + if (const int capacity = read("capacity").toInt(&ok); ok) { + percent = capacity; + } else { + const QString level = read("capacity_level"); + percent = level == QStringLiteral("Full") ? 100 + : level == QStringLiteral("High") ? 75 + : level == QStringLiteral("Normal") ? 50 + : level == QStringLiteral("Low") ? 15 + : level == QStringLiteral("Critical") ? 5 + : -1; + } + if (percent < 0) { + return std::nullopt; + } + const QString status = read("status"); + return std::pair{percent, + status == QStringLiteral("Charging") || status == QStringLiteral("Full")}; +} + +/// A Bluetooth controller's battery, by its address (hid-
-battery) +std::optional> BluetoothBattery(const std::string& address) { + const QDir supplies{QStringLiteral("/sys/class/power_supply")}; + const QString wanted = QString::fromStdString(address).toLower(); + for (const QString& name : supplies.entryList(QDir::AllEntries | QDir::NoDotAndDotDot)) { + const QString dir = supplies.filePath(name); + bool match = name.toLower().contains(wanted); + if (!match) { + if (const auto entry = + ReadHidEntry(QFileInfo{dir + QStringLiteral("/device")}.canonicalFilePath())) { + match = entry->address == address; + } + } + if (match) { + return ReadPowerSupply(dir); + } + } + return std::nullopt; +} + +/// Asks BlueZ for a Bluetooth LE controller's battery (Xbox Series controllers have no power +/// supply in Linux), without waiting: `answer` gets it on the GUI thread +void AskBluezBattery(const std::string& address, QObject* context, + std::function answer) { + AskBluezProperty(address, QStringLiteral("org.bluez.Battery1"), QStringLiteral("Percentage"), + context, [answer](const QVariant& value) { + bool ok = false; + const int percent = value.toInt(&ok); + if (ok && percent >= 0 && percent <= 100) { + answer(percent); + } + }); +} + +/// The system's own battery (the handheld's own controls run on it) +std::optional> SystemBattery() { + const QDir supplies{QStringLiteral("/sys/class/power_supply")}; + for (const QString& name : supplies.entryList({QStringLiteral("BAT*")}, QDir::AllEntries)) { + if (const auto battery = ReadPowerSupply(supplies.filePath(name))) { + return battery; + } + } + return std::nullopt; +} +#endif + +/// What SDL knows of a pad's battery: percent (-1 unknown), charging, wired +std::tuple SdlBattery(SDL_JoystickID id) { + const JoystickApiLock lock; + SDL_Joystick* js = SDL_OpenJoystick(id); + if (!js) { + return {-1, false, false}; + } + int percent = -1; + const SDL_PowerState state = SDL_GetJoystickPowerInfo(js, &percent); + SDL_CloseJoystick(js); + switch (state) { + case SDL_POWERSTATE_ON_BATTERY: + return {percent, false, false}; + case SDL_POWERSTATE_CHARGING: + case SDL_POWERSTATE_CHARGED: + return {percent, true, false}; + case SDL_POWERSTATE_NO_BATTERY: + return {-1, false, true}; + default: + return {-1, false, false}; + } +} + +/// True for Nintendo's controllers and look-alikes: their buttons already match the Switch's +bool IsNintendoPad(SDL_JoystickID id) { + switch (PadType(id)) { + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + case SDL_GAMEPAD_TYPE_GAMECUBE: + return true; + default: + return PadVendor(id) == 0x057E; + } +} + +/// The players controllers take in the console modes: a Switch's eight (a ninth controller +/// waits). The settings' players past them are the handheld's own and one more. +constexpr int ConsolePlayers = 8; + +/// Makers of standalone controllers: never a handheld's built-in controls, even when alone +bool IsControllerMaker(Uint16 vendor) { + switch (vendor) { + case 0x045E: // Microsoft + case 0x054C: // Sony + case 0x057E: // Nintendo + case 0x2DC8: // 8BitDo + case 0x046D: // Logitech + case 0x20D6: // PowerA + case 0x0E6F: // PDP + case 0x0F0D: // Hori + case 0x1532: // Razer + case 0x0738: // Mad Catz + case 0x146B: // Nacon + case 0x3537: // GameSir + return true; + default: + return false; + } +} + +/// Makers of handhelds with built-in controls: a pad from one is the handheld's own on a +/// direct launch +bool IsHandheldMaker(Uint16 vendor) { + return vendor == 0x28DE || vendor == 0x17EF || vendor == 0x0B05 || vendor == 0x0DB0; +} + +/// Whether a pad can be a handheld's own controls (of Valve's, only the Deck's) +bool CouldBeHandheld(Uint16 vendor, Uint16 product) { + if (vendor == 0x28DE) { + return product == 0x1205; + } + return !IsControllerMaker(vendor); +} + +/// Whether a pad is plainly a standalone controller, never a handheld's controls: Valve's other +/// controllers, controller-only makers, and wireless Xbox controllers (a wired Microsoft id +/// stays: some handhelds' controls report one, such as the ROG Xbox Ally) +bool IsPlainlyStandalone(SDL_JoystickID id) { + const Uint16 vendor = PadVendor(id); + const Uint16 product = PadProduct(id); + if (vendor == 0x28DE) { + return product != 0x1205; + } + if (vendor == 0x045E) { + switch (product) { + case 0x02E0: // Xbox One S, Bluetooth + case 0x02E6: // Xbox Wireless Adapter + case 0x02FD: // Xbox One S, Bluetooth + case 0x0719: // Xbox 360 wireless receiver + case 0x0B05: // Xbox Elite Series 2, Bluetooth + case 0x0B13: // Xbox Series X|S, Bluetooth + case 0x0B20: // Xbox Series X|S, Bluetooth + case 0x0B22: // Xbox Elite Series 2, Bluetooth + return true; + default: + break; + } + } else if (IsControllerMaker(vendor)) { + return true; + } + const JoystickApiLock lock; + SDL_Gamepad* pad = SDL_OpenGamepad(id); + if (!pad) { + return false; + } + const bool wireless = SDL_GetJoystickConnectionState(SDL_GetGamepadJoystick(pad)) == + SDL_JOYSTICK_CONNECTION_WIRELESS; + SDL_CloseGamepad(pad); + return wireless; +} + +/// True for the controller types Steam reports for standalone controllers +bool IsStandaloneControllerType(SDL_GamepadType type) { + return type != SDL_GAMEPAD_TYPE_UNKNOWN && type != SDL_GAMEPAD_TYPE_STANDARD; +} + +/// Steam Input's handle for the physical controller behind a pad (0 none): Steam refills its +/// virtual pads in its own order, so only the handle tells which controller came back +Uint64 PadSteamHandle(SDL_JoystickID id) { + // (As Steam says now: SDL's copy is up to 3 seconds old) + if (const auto slot = SteamSlotOf(id)) { + return slot->handle; + } + return WithGamepad(id, Uint64{0}, + [](SDL_Gamepad* pad) { return SDL_GetGamepadSteamHandle(pad); }); +} + +/// The key a pad's Bluetooth state is kept under: its Steam handle under Steam (it follows the +/// controller between virtual pads), else the connection instance +std::string BluetoothKey(SDL_JoystickID id) { + if (const Uint64 handle = PadSteamHandle(id)) { + return fmt::format("steamhandle:{:#x}", handle); + } + return fmt::format("inst:{}", id); +} + +/// How a pad is stored as the handheld's: Steam handle under Steam, else its identity +std::string PadKey(SDL_JoystickID id, const std::string& ident) { + if (Common::SteamPadsInUse()) { + if (const Uint64 handle = PadSteamHandle(id)) { + return fmt::format("steamhandle:{:#x}", handle); + } + } + return ident; +} + +/// True while a pad has an input held: any button, or a stick or trigger past half travel. +bool PadInputHeld(SDL_JoystickID id) { + return WithGamepad(id, false, [](SDL_Gamepad* pad) { + for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) { + if (SDL_GetGamepadButton(pad, static_cast(b))) { + return true; + } + } + for (int a = 0; a < SDL_GAMEPAD_AXIS_COUNT; ++a) { + const Sint16 value = SDL_GetGamepadAxis(pad, static_cast(a)); + if (value > 16384 || value < -16384) { + return true; + } + } + return false; + }); +} + +/// A short rumble pulse on a pad, if it supports rumble. +void PadRumble(SDL_JoystickID id, Uint16 strength, Uint32 duration_ms) { + const JoystickApiLock lock; + if (SDL_Gamepad* pad = SDL_OpenGamepad(id)) { + SDL_RumbleGamepad(pad, strength, strength, duration_ms); + SDL_CloseGamepad(pad); + } +} + +/// An SDL motion binding for a device, as InputCommon's auto-mapping builds it +std::string SdlMotionParam(const std::string& guid, int port) { + Common::ParamPackage param; + param.Set("engine", "sdl"); + param.Set("motion", 0); + param.Set("port", port); + param.Set("guid", guid); + return param.Serialize(); +} + +/// Points every SDL binding of a player at one device (guid + port), leaving other engines +/// alone. True when it changed. +bool RouteSdlBindings(Settings::PlayerInput& player, const std::string& guid, int port) { + bool routed = false; + const auto route = [&](auto& params) { + for (std::string& raw : params) { + Common::ParamPackage param{raw}; + if (param.Get("engine", "") != "sdl") { + continue; + } + const std::string old_guid = param.Get("guid", ""); + if (old_guid == guid && param.Get("port", -1) == port) { + continue; + } + param.Set("guid", guid); + param.Set("port", port); + if (param.Has("guid2") && param.Get("guid2", "") == old_guid) { + param.Set("guid2", guid); + } + raw = param.Serialize(); + routed = true; + } + }; + route(player.buttons); + route(player.analogs); + route(player.motions); + return routed; +} + +/// The binding sets the console modes start from: the user's players without mappings or +/// profiles (each controller gets its defaults or a picked profile). Only a DSU server's motion +/// stays: the console modes don't expose motion. +std::array ModernStartSets( + const std::array& user_players) { + auto sets = user_players; + for (auto& set : sets) { + set.buttons.fill({}); + set.analogs.fill({}); + for (std::string& motion : set.motions) { + if (Common::ParamPackage{motion}.Get("engine", "") != "cemuhookudp") { + motion.clear(); + } + } + set.profile_name.clear(); + } + return sets; +} + +} // Anonymous namespace + +void MainWindow::SaveAllConfigValues() { + if (!console_profile) { + config->SaveAllValues(); + return; + } + // A console mode drives the players at runtime; the file keeps the user's own + auto& players = Settings::values.players.GetValue(true); + std::swap(players, *console_profile); + config->SaveAllValues(); + std::swap(players, *console_profile); +} + +namespace { +/// The handheld set's motion bindings, to trace what clears the DSU gyro (Debug Probes) +std::string MotionTrace(const Settings::PlayerInput& player) { + std::string out; + for (const auto& motion : player.motions) { + out += '[' + motion + ']'; + } + return out; +} +} // Anonymous namespace + +void MainWindow::RestoreConsoleProfile() { + if (!console_profile) { + return; + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: restore, profile P1 motions {}", + MotionTrace((*console_profile)[0])); + } + // A running game has its own copy of the players, which the engine drove: both copies get + // the user's players back (the game's as read at boot) + Settings::values.players.GetValue(true) = *console_profile; + if (!Settings::values.players.UsingGlobal()) { + Settings::values.players.GetValue() = game_profiles ? *game_profiles : *console_profile; + } + console_profile.reset(); + game_profiles.reset(); + session_sets.reset(); +} + +Settings::PlayerInput& MainWindow::SessionSet(int set) { + if (game_profiles && !(*game_profiles)[set].profile_name.empty()) { + return (*game_profiles)[set]; + } + if (!session_sets) { + session_sets = ModernStartSets(*console_profile); + } + return (*session_sets)[set]; +} + +std::array MainWindow::ConsoleBindingSets() const { + auto sets = session_sets ? *session_sets : ModernStartSets(*console_profile); + if (game_profiles) { + for (size_t i = 0; i < sets.size(); ++i) { + if (!(*game_profiles)[i].profile_name.empty()) { + sets[i] = (*game_profiles)[i]; + } + } + } + return sets; +} + +namespace { + +/// A binding's mapping without its device (guid, port): what a Controls tab edit changes. +/// Order- and float-format-insensitive ("0.5" and "0.500000" are the same). +std::string BindingMapping(const std::string& raw) { + if (raw.empty()) { + return {}; + } + Common::ParamPackage param{raw}; + // Only an SDL binding's guid/port is routing (a DSU server's address is the mapping) + if (param.Get("engine", "") == "sdl") { + param.Erase("guid"); + param.Erase("guid2"); + param.Erase("port"); + } + std::vector fields; + std::string field; + for (const char c : param.Serialize() + ",") { + if (c != ',') { + field += c; + continue; + } + const auto colon = field.find(':'); + if (colon != std::string::npos) { + const std::string value = field.substr(colon + 1); + char* end = nullptr; + const float number = std::strtof(value.c_str(), &end); + if (!value.empty() && end && *end == '\0') { + field = field.substr(0, colon + 1) + fmt::format("{}", number); + } + } + fields.push_back(field); + field.clear(); + } + std::sort(fields.begin(), fields.end()); + std::string out; + for (const auto& f : fields) { + out += f + ','; + } + return out; +} + +/// Puts a profile's mappings on a binding set, keeping the set's controller on SDL bindings +void ApplyProfileMappings(Settings::PlayerInput& set, const Settings::PlayerInput& profile) { + const auto apply = [](auto& to, const auto& from) { + for (size_t i = 0; i < to.size(); ++i) { + Common::ParamPackage taken{from[i]}; + const Common::ParamPackage kept{to[i]}; + if (!from[i].empty() && !to[i].empty() && taken.Get("engine", "") == "sdl" && + kept.Get("engine", "") == "sdl") { + taken.Set("guid", kept.Get("guid", "")); + taken.Set("port", kept.Get("port", 0)); + if (kept.Has("guid2")) { + taken.Set("guid2", kept.Get("guid2", "")); + } else { + taken.Erase("guid2"); + } + to[i] = taken.Serialize(); + } else { + to[i] = from[i]; + } + } + }; + apply(set.buttons, profile.buttons); + apply(set.analogs, profile.analogs); + apply(set.motions, profile.motions); +} + +/// Brings edited mappings into the profile's, keeping the profile's own device on SDL bindings. +/// True when anything was taken. +template +bool MergeMappings(Array& profile, const Array& before, const Array& after) { + bool taken = false; + for (size_t i = 0; i < after.size(); ++i) { + if (BindingMapping(after[i]) == BindingMapping(before[i])) { + continue; + } + Common::ParamPackage edited{after[i]}; + const Common::ParamPackage original{profile[i]}; + if (!after[i].empty() && !profile[i].empty() && edited.Get("engine", "") == "sdl" && + original.Get("engine", "") == "sdl") { + edited.Set("guid", original.Get("guid", "")); + edited.Set("port", original.Get("port", 0)); + if (original.Has("guid2")) { + edited.Set("guid2", original.Get("guid2", "")); + } else { + edited.Erase("guid2"); + } + profile[i] = edited.Serialize(); + } else { + profile[i] = after[i]; + } + taken = true; + } + return taken; +} + +} // Anonymous namespace + +void MainWindow::ApplySeatProfiles() { + const std::string stored = Settings::values.modern_seat_profiles.GetValue(); + std::vector seats; + std::stringstream entries{stored}; + for (std::string entry; std::getline(entries, entry, ',');) { + seats.push_back(entry); + } + std::optional input_profiles; + for (size_t i = 0; i < seats.size() && i < 8; ++i) { + if (seats[i].empty()) { + continue; + } + if (!input_profiles) { + input_profiles.emplace(); + } + const auto read = input_profiles->PeekProfile(seats[i], i); + if (!read) { + LOG_INFO(Frontend, "console-mode: P{} seat profile \"{}\" is gone", i + 1, seats[i]); + SetSeatProfile(static_cast(i), {}); + continue; + } + ApplyProfileMappings((*session_sets)[i], *read); + (*session_sets)[i].profile_name = seats[i]; + LOG_INFO(Frontend, "console-mode: P{} starts with its seat profile \"{}\"", i + 1, + seats[i]); + } +} + +void MainWindow::SetSeatProfile(int player, const std::string& name) { + if (player < 0 || player >= 8) { + return; + } + std::vector seats; + std::stringstream entries{Settings::values.modern_seat_profiles.GetValue()}; + for (std::string entry; std::getline(entries, entry, ',');) { + seats.push_back(entry); + } + seats.resize(8); + seats[static_cast(player)] = name; + std::string joined; + for (size_t i = 0; i < seats.size(); ++i) { + joined += (i ? "," : "") + seats[i]; + } + // (Nothing stored when no seat has a profile) + if (joined.find_first_not_of(',') == std::string::npos) { + joined.clear(); + } + Settings::values.modern_seat_profiles.SetValue(joined); +} + +void MainWindow::MergeConsoleProfileEdits(const std::array& before) { + if (!console_profile) { + return; + } + // The Controls tab edits the global copy, whatever copy a running game uses + const auto& after = Settings::values.players.GetValue(true); + // The handheld's own non-SDL motion (a DSU server) must survive edits on players it doesn't + // hold: kept here and put back at the end + const auto handheld_motions_kept = SessionSet(0).motions; + const auto own_slot = pad_slots.find(builtin_pad_id); + // (Only a handheld without a player can't have edited its set) + const bool handheld_has_player = builtin_pad_id && own_slot != pad_slots.end(); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: merge start, profile P1 motions {} before {} after {} " + "handheld_has_player={}", + MotionTrace((*console_profile)[0]), MotionTrace(before[0]), MotionTrace(after[0]), + handheld_has_player); + } + // The SDL device (guid, port) a player's bindings point at + const auto sdl_device = [](const Settings::PlayerInput& player) { + for (const std::string& raw : player.buttons) { + const Common::ParamPackage param{raw}; + if (!raw.empty() && param.Get("engine", "") == "sdl") { + return std::optional>{ + {param.Get("guid", ""), param.Get("port", 0)}}; + } + } + return std::optional>{}; + }; + for (size_t i = 0; i < after.size(); ++i) { + const auto& a = after[i]; + const auto& b = before[i]; + // A device change is a controller swap (below), not an edit + if (const auto device = sdl_device(a); device && device != sdl_device(b)) { + continue; + } + // A single Joy-Con's mapping is arranged from the set, not the set itself (its player + // tab is read-only meanwhile): nothing there is an edit + if (i < joycon_auto_set.size() && joycon_auto_set[i] && + (*joycon_auto_set[i] == Settings::ControllerType::RightJoycon || + *joycon_auto_set[i] == Settings::ControllerType::LeftJoycon)) { + continue; + } + // Only mapping changes count as edits (connection and routing are the engine's). The + // handheld's own pad's edits go to player one's set. + const auto own = pad_slots.find(builtin_pad_id); + size_t target = static_cast(slot_set[i]); + // For this session only: the user's own setup is the manual modes' + auto& p = SessionSet(static_cast(target)); + bool remapped = MergeMappings(p.buttons, b.buttons, a.buttons); + remapped = MergeMappings(p.analogs, b.analogs, a.analogs) || remapped; + bool edited = remapped; + // An external pad never gets the handheld's non-SDL motion: its absence on another + // player is the engine's doing, not an edit, and must not wipe it from the set + auto edited_motions = a.motions; + if (target != 0 || + !(builtin_pad_id && own != pad_slots.end() && own->second == static_cast(i))) { + const auto& handheld_motions = SessionSet(0).motions; + for (size_t k = 0; k < edited_motions.size(); ++k) { + const std::string& kept = p.motions[k]; + const bool handheld_motion = + !kept.empty() && Common::ParamPackage{kept}.Get("engine", "") != "sdl" && + std::find(handheld_motions.begin(), handheld_motions.end(), kept) != + handheld_motions.end(); + if (handheld_motion && edited_motions[k].empty()) { + edited_motions[k] = b.motions[k]; + } + } + } + edited = MergeMappings(p.motions, b.motions, edited_motions) || edited; + // Remapped and not saved: the controls are no longer the profile's + if (remapped && !p.profile_name.empty()) { + InputProfiles input_profiles; + const auto saved = input_profiles.PeekProfile(p.profile_name, i); + bool same = saved.has_value(); + for (size_t k = 0; same && k < p.buttons.size(); ++k) { + same = BindingMapping(p.buttons[k]) == BindingMapping(saved->buttons[k]); + } + for (size_t k = 0; same && k < p.analogs.size(); ++k) { + same = BindingMapping(p.analogs[k]) == BindingMapping(saved->analogs[k]); + } + if (!same) { + LOG_INFO(Frontend, "console-mode: P{} remapped: profile \"{}\" let go", i + 1, + p.profile_name); + p.profile_name.clear(); + SetSeatProfile(static_cast(i), {}); + } + } + if (!edited) { + continue; + } + set_default_of[target] = 0; // (the user's own mapping now) + LOG_INFO(Frontend, "console-mode: Controls edit on P{} kept for the session in set P{}", + i + 1, target + 1); + } + + if (!handheld_has_player) { + auto& motions = SessionSet(0).motions; + for (size_t k = 0; k < motions.size(); ++k) { + const std::string& kept = handheld_motions_kept[k]; + if (motions[k].empty() && !kept.empty() && + Common::ParamPackage{kept}.Get("engine", "") != "sdl") { + motions[k] = kept; + LOG_INFO(Frontend, "console-mode: kept the handheld's motion {} on P1", kept); + } + } + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: merge end, profile P1 motions {}", + MotionTrace((*console_profile)[0])); + } + + // A swap from the Controls tab: a player whose device was changed to another player's pad + // trades slots with it; the routing pass re-points them next tick + if (!input_subsystem) { + return; + } + // The pads and their (guid, port); listed first, as GetSDLBinding can't run under the lock + std::vector instances; + { + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + for (int k = 0; k < count; ++k) { + instances.push_back(ids[k]); + } + SDL_free(ids); + } + std::map> bound; + for (const u32 inst : instances) { + if (const auto binding = input_subsystem->GetSDLBinding(inst)) { + bound[inst] = *binding; + } + } + // (The eight players only) + for (size_t i = 0; i < after.size() && i < static_cast(ConsolePlayers); ++i) { + const auto chosen = sdl_device(after[i]); + if (!chosen || chosen == sdl_device(before[i])) { + continue; + } + u32 pad = 0; + for (const auto& [inst, binding] : bound) { + if (binding == *chosen) { + pad = inst; + break; + } + } + if (!pad) { + continue; + } + if (pad == builtin_pad_id && Settings::values.controller_applet_mode.GetValue() == + Settings::AppletMode::HandheldConsoleMode) { + LOG_INFO(Frontend, "console-mode: Controls swap to P{} ignored: the handheld's own pad", + i + 1); + continue; + } + const auto pad_slot = pad_slots.find(pad); + if (pad_slot != pad_slots.end() && pad_slot->second == static_cast(i)) { + continue; + } + u32 holder = 0; + for (const auto& [inst, slot] : pad_slots) { + if (slot == static_cast(i) && bound.contains(inst)) { + holder = inst; + break; + } + } + const int from = pad_slot != pad_slots.end() ? pad_slot->second : -1; + if (holder) { + if (from >= 0) { + pad_slots[holder] = from; + } else { + pad_slots.erase(holder); + pad_identity.erase(holder); + } + } + pad_slots[pad] = static_cast(i); + LOG_INFO(Frontend, "console-mode: Controls swap: inst={} P{} -> P{}{}", pad, + from >= 0 ? from + 1 : 0, i + 1, + holder + ? fmt::format(", inst={} P{} -> P{}", holder, i + 1, from >= 0 ? from + 1 : 0) + : std::string{}); + } +} + +namespace { +/// The green/red flashes blink four times a second for their 1.5 seconds +bool BlinkOn(qint64 elapsed_ms) { + return elapsed_ms < 1500 && (elapsed_ms / 125) % 2 == 0; +} +} // Anonymous namespace + +void MainWindow::RenderControllerOverlay() { + // A row lights while its controller has an input held, or a moment after a tap. Never for + // motion: a resting gyro is never still. + const auto now = std::chrono::steady_clock::now(); + std::set active; + for (const auto& row : overlay_rows) { + if (row.pad == 0) { + continue; + } + const auto last = + input_subsystem ? input_subsystem->GetSDLLastActivity(row.pad) : std::nullopt; + if (PadInputHeld(row.pad) || (last && now - *last < std::chrono::milliseconds{250})) { + active.insert(row.pad); + } + } + overlay_active_pads = active; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + for (auto& player : state.players) { + const auto row = std::find_if(overlay_rows.begin(), overlay_rows.end(), + [&](const auto& r) { return r.player == player.player; }); + player.active = row != overlay_rows.end() && active.contains(row->pad); + } + } + + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + const auto publish = [](Common::OverlayPanel& panel, const QImage* image) { + GameOverlay::Publish(panel, image); + }; + + const auto player_color = [](int player) { + return ConfigurePlayerAssignment::PlayerColor(player); + }; + const QFont badge_font = GameOverlay::Style::LabelFont(); + + // Hints: a controller that took or left a player, named for a few seconds at the bottom left + constexpr qint64 hint_ms = 3000; + for (const auto& row : overlay_rows) { + const int i = row.player; + if (row.flash == 1 && row.pad != 0 && row.flash_since > hinted_join[i]) { + hinted_join[i] = row.flash_since; + controller_hints.push_back({overlay_row_name[i], i, true, row.flash_since}); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: hint \"{} joined\" P{}", + overlay_row_name[i].toStdString(), i + 1); + } + } else if (row.flash == 2 && row.flash_since > hinted_leave[i]) { + hinted_leave[i] = row.flash_since; + // (Removed on the tick it left) + const bool turned_off = std::abs(row.flash_since - turned_off_at[i]) < 1000; + // (Named after the controller that left: another may have the player already) + const QString& left_name = + overlay_left_name[i].isEmpty() ? overlay_row_name[i] : overlay_left_name[i]; + controller_hints.push_back({left_name, i, false, row.flash_since, turned_off}); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: hint \"{} {}\" P{}", left_name.toStdString(), + turned_off ? "turned off" : "left", i + 1); + } + } + } + // (A pause's notice stays while the game is paused for it, and shows even with the hints off) + const bool hints_on = Settings::values.console_hints.GetValue(); + std::erase_if(controller_hints, [&](const auto& hint) { + if (hint.pause_notice && !disconnect_paused.empty()) { + return false; + } + return now_ms - hint.at >= hint_ms || (!hints_on && !hint.pause_notice); + }); + if (!automatic || controller_hints.empty()) { + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Hints), nullptr); + } else { + QFont hint_font; + hint_font.setPixelSize(15); + hint_font.setBold(true); + const QFontMetrics hint_fm{hint_font}; + constexpr int hint_h = 30; + constexpr int hint_gap = 6; + constexpr int badge_w = 36; + const auto text_of = [](const ControllerHint& hint) { + if (!hint.note.isEmpty()) { + return hint.note; + } + if (hint.joined) { + return tr("%1 joined").arg(hint.name); + } + return hint.turned_off ? tr("%1 turned off").arg(hint.name) + : tr("%1 left").arg(hint.name); + }; + int width = 0; + for (const auto& hint : controller_hints) { + width = + (std::max)(width, 14 + hint_fm.horizontalAdvance(text_of(hint)) + 10 + badge_w + 6); + } + // The widest a hint can be, so the image keeps its size while hints come and go + width = (std::max)(width, 260) + 2; + const int count = static_cast(controller_hints.size()); + QImage image{width, count * (hint_h + hint_gap), QImage::Format_RGBA8888}; + image.fill(Qt::transparent); + QPainter painter{&image}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + // Newest at the bottom, nearest the corner + int y = 0; + for (const auto& hint : controller_hints) { + const QString text = text_of(hint); + const int w = 14 + hint_fm.horizontalAdvance(text) + 10 + badge_w + 6; + // (A pause's notice doesn't fade while the game is paused for it) + const qint64 left = hint.pause_notice && !disconnect_paused.empty() + ? hint_ms + : hint_ms - (now_ms - hint.at); + const int alpha = left < 500 ? static_cast(255 * (std::max)(left, qint64{0}) / 500) + : 255; + painter.setPen(Qt::NoPen); + painter.setBrush(hint.joined ? QColor(18, 18, 22, 215 * alpha / 255) + : QColor(96, 22, 28, 220 * alpha / 255)); + painter.drawRoundedRect(QRectF(1, y, w, hint_h), hint_h / 2.0, hint_h / 2.0); + painter.setFont(hint_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(QRect(15, y, w, hint_h), Qt::AlignVCenter | Qt::AlignLeft, text); + QColor badge = player_color(hint.player); + badge.setAlpha(hint.joined ? alpha : alpha / 2); + painter.setPen(Qt::NoPen); + painter.setBrush(badge); + const QRectF badge_rect(1 + w - 6 - badge_w, y + 5, badge_w, hint_h - 10); + painter.drawRoundedRect(badge_rect, badge_rect.height() / 2, badge_rect.height() / 2); + painter.setFont(badge_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(badge_rect, Qt::AlignCenter, + QStringLiteral("P%1").arg(hint.player + 1)); + y += hint_h + hint_gap; + } + painter.end(); + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Hints), &image); + } + + overlay_blink.clear(); + for (const auto& row : overlay_rows) { + overlay_blink.push_back(row.flash != 0 && BlinkOn(now_ms - row.flash_since)); + } + if (!Settings::values.console_debug_overlay.GetValue() || !automatic || overlay_rows.empty()) { + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Players), nullptr); + return; + } + + // The Switch buttons as chips: a label and a colour (stick moves: the stick and an arrow) + const auto chip = [](int button) -> std::pair { + using namespace Settings::NativeButton; + if (button >= StickMoveButton) { + static constexpr std::array arrows{u"\u2190", u"\u2191", u"\u2192", + u"\u2193"}; + const int stick = (button - StickMoveButton) / 4; + const int direction = (button - StickMoveButton) % 4; + return {(stick == 0 ? QStringLiteral("LS") : QStringLiteral("RS")) + + QString::fromUtf16(arrows[static_cast(direction)]), + QColor(52, 73, 94)}; + } + switch (button) { + case A: + return {QStringLiteral("A"), QColor(231, 76, 60)}; + case B: + return {QStringLiteral("B"), QColor(230, 180, 20)}; + case X: + return {QStringLiteral("X"), QColor(52, 152, 219)}; + case Y: + return {QStringLiteral("Y"), QColor(46, 184, 104)}; + case LStick: + return {QStringLiteral("L3"), QColor(70, 90, 112)}; + case RStick: + return {QStringLiteral("R3"), QColor(70, 90, 112)}; + case L: + return {QStringLiteral("L"), QColor(120, 132, 140)}; + case R: + return {QStringLiteral("R"), QColor(120, 132, 140)}; + case ZL: + return {QStringLiteral("ZL"), QColor(98, 108, 116)}; + case ZR: + return {QStringLiteral("ZR"), QColor(98, 108, 116)}; + case Plus: + return {QStringLiteral("+"), QColor(142, 88, 182)}; + case Minus: + return {QStringLiteral("−"), QColor(142, 88, 182)}; + case DLeft: + return {QStringLiteral("←"), QColor(84, 99, 116)}; + case DUp: + return {QStringLiteral("↑"), QColor(84, 99, 116)}; + case DRight: + return {QStringLiteral("→"), QColor(84, 99, 116)}; + case DDown: + return {QStringLiteral("↓"), QColor(84, 99, 116)}; + case Home: + return {QStringLiteral("⌂"), QColor(60, 60, 66)}; + case Screenshot: + return {QStringLiteral("◉"), QColor(60, 60, 66)}; + case SLLeft: + case SLRight: + return {QStringLiteral("SL"), QColor(120, 132, 140)}; + default: + return {QStringLiteral("SR"), QColor(120, 132, 140)}; + } + }; + const QFont name_font = GameOverlay::Style::TextFont(); + const QFont chip_font = GameOverlay::Style::SmallFont(); + const QFontMetrics name_fm{name_font}; + const QFontMetrics chip_fm{chip_font}; + + constexpr int row_h = 28; + constexpr int row_gap = 4; + constexpr int badge_w = 30; + constexpr int inner = 6; + constexpr int bubble_gap = 6; + constexpr int chip_h = 18; + constexpr int chip_gap = 3; + const auto chip_width = [&](const QString& text) { + return (std::max)(chip_h, chip_fm.horizontalAdvance(text) + 10); + }; + // The widest chip there can be (a stick move, "LS→", is wider than a button) + int widest_chip = 26; + for (int k = 0; k < 8; ++k) { + widest_chip = (std::max)(widest_chip, chip_width(chip(StickMoveButton + k).first)); + } + // An entry lives as long as set, then shrinks away + const bool show_history = Settings::values.console_history.GetValue(); + const bool show_gaps = Settings::values.console_history_gaps.GetValue(); + const int history_size = Settings::values.console_history_size.GetValue(); + const qint64 press_life_ms = Settings::values.console_history_seconds.GetValue() * 1000LL; + constexpr qint64 press_shrink_ms = 400; + // The time between inputs: a tag between their chips, in ms, or seconds from one ("1.2") + QFont gap_font; + gap_font.setPixelSize(8); + gap_font.setBold(true); + const QFontMetrics gap_fm{gap_font}; + const auto gap_text = [press_life_ms](qint64 ms) { + ms = (std::min)(ms, press_life_ms); + return ms < 1000 ? QString::number(ms) + : QStringLiteral("%1s").arg(static_cast(ms) / 1000.0, 0, 'f', + ms < 10000 ? 1 : 0); + }; + constexpr int gap_pad = 3; + const int widest_gap = gap_fm.horizontalAdvance(QStringLiteral("999")) + gap_pad * 2; + const auto press_scale = [now_ms, press_life_ms](const ButtonPress& press) { + const qint64 over = now_ms - press.at - press_life_ms; + if (over <= 0) { + return 1.0; + } + return (std::max)(0.0, 1.0 - static_cast(over) / press_shrink_ms); + }; + + int name_w = 0; + for (const auto& row : overlay_rows) { + name_w = (std::max)(name_w, name_fm.horizontalAdvance(overlay_row_name[row.player])); + } + // (A name being typed has room for its 16 characters) + if (overlay_edit_player >= 0) { + name_w = (std::max)(name_w, name_fm.horizontalAdvance(QString(16, QLatin1Char('M')))); + } + // Room for a battery after the name, when any controller tells its charge + const bool batteries = std::any_of(overlay_rows.begin(), overlay_rows.end(), + [](const auto& row) { return row.battery >= 0; }); + const int battery_w = + batteries ? 8 + chip_fm.horizontalAdvance(QStringLiteral("100%")) + 5 + 20 : 0; + const int controller_w = inner + badge_w + 8 + name_w + battery_w + inner + 4; + // Room for as many of the widest chips as the history keeps, so the image keeps its size + int history_room = show_history ? inner * 2 + history_size * (widest_chip + chip_gap) + + (show_gaps ? (history_size - 1) * widest_gap : 0) + : 200; + // Short of the panels at the top right: the oldest inputs drop off sooner + if (render_window) { + const auto& layout = render_window->GetFramebufferLayout(); + const int room = static_cast(Common::OverlayRoomTopLeft( + static_cast(layout.screen.right - layout.screen.left))) - + (controller_w + bubble_gap + 2); + history_room = (std::clamp)(room, inner * 2 + widest_chip, history_room); + } + const int width = controller_w + bubble_gap + history_room + 2; + const int count = static_cast(overlay_rows.size()); + const int height = count * row_h + (count - 1) * row_gap + 2; + + QImage image{width, height, QImage::Format_RGBA8888}; + image.fill(Qt::transparent); + QPainter painter{&image}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + painter.setPen(Qt::NoPen); + // The controller bubble: its state lights it (blue held, green joined, red left) + const auto controller_bubble = [&](const OverlayRow& row, bool blink, int at_y) { + const bool gone = row.pad == 0; + QColor fill = GameOverlay::Style::Fill; + if (row.flash == 2) { + if (blink) { + fill = GameOverlay::Style::Bad; + } + } else if (!gone && active.contains(row.pad)) { + fill = GameOverlay::Style::Active; + } else if (blink) { + fill = GameOverlay::Style::Good; + } + GameOverlay::Style::Pill(painter, QRectF(1, at_y, controller_w, row_h), fill); + QColor badge = player_color(row.player); + if (gone) { + badge.setAlpha(110); + } + painter.setBrush(badge); + const QRectF badge_rect(1 + inner, at_y + 5, badge_w, row_h - 10); + painter.drawRoundedRect(badge_rect, badge_rect.height() / 2, badge_rect.height() / 2); + painter.setFont(badge_font); + painter.setPen(Qt::white); + painter.drawText(badge_rect, Qt::AlignCenter, QStringLiteral("P%1").arg(row.player + 1)); + // On the badge's corner: ? for a reconnect's guess, else the starting player's colour + if (!gone && (row.guessed || row.origin >= 0)) { + const QPointF corner(badge_rect.right() - 1, badge_rect.top() + 1); + painter.setPen(QPen(QColor(14, 14, 18), 1.5)); + if (row.guessed) { + painter.setBrush(Qt::white); + painter.drawEllipse(corner, 6, 6); + QFont mark_font = badge_font; + mark_font.setPixelSize(9); + painter.setFont(mark_font); + painter.setPen(QColor(14, 14, 18)); + painter.drawText(QRectF(corner.x() - 6, corner.y() - 6, 12, 12), Qt::AlignCenter, + QStringLiteral("?")); + } else { + painter.setBrush(player_color(row.origin)); + painter.drawEllipse(corner, 4.5, 4.5); + } + painter.setPen(Qt::NoPen); + } + painter.setFont(name_font); + if (row.player == overlay_edit_player && !gone) { + // The name being typed: a field with its caret (blinking), or what to type + const QRectF field(1 + inner + badge_w + 4, at_y + 4, name_w + 10, row_h - 8); + painter.setPen(QPen(QColor(120, 170, 255, 230), 1.2)); + painter.setBrush(QColor(255, 255, 255, 30)); + painter.drawRoundedRect(field, 5, 5); + const QRectF text_rect = field.adjusted(5, 0, -5, 0); + painter.setPen(overlay_edit_text.isEmpty() ? QColor(255, 255, 255, 110) + : QColor(245, 245, 245)); + painter.drawText(text_rect, Qt::AlignVCenter | Qt::AlignLeft, + overlay_edit_text.isEmpty() ? tr("Name") : overlay_edit_text); + if ((QDateTime::currentMSecsSinceEpoch() / 500) % 2 == 0) { + const double caret = text_rect.left() + + (overlay_edit_text.isEmpty() + ? 0 + : name_fm.horizontalAdvance(overlay_edit_text)) + + 1; + painter.setPen(QPen(Qt::white, 1.2)); + painter.drawLine(QPointF(caret, field.top() + 4), QPointF(caret, field.bottom() - 4)); + } + painter.setPen(Qt::NoPen); + return; + } + painter.setPen(gone ? GameOverlay::Style::DimText : GameOverlay::Style::Text); + painter.drawText(QRect(1 + inner + badge_w + 8, at_y, name_w + 4, row_h), + Qt::AlignVCenter | Qt::AlignLeft, overlay_row_name[row.player]); + // The battery's percent, then its icon (the same as the tabs' and the menu bar's) + if (!gone && row.battery >= 0) { + const int percent_w = chip_fm.horizontalAdvance(QStringLiteral("100%")); + const double bx = 1 + inner + badge_w + 8 + name_w + 8 + percent_w + 5; + painter.drawPixmap(QPointF(bx, at_y + row_h / 2.0 - 6), + ConfigurePlayerAssignment::Battery(row.battery, row.charging)); + painter.setFont(chip_font); + painter.setPen(QColor(220, 220, 220)); + painter.drawText(QRectF(bx - 5 - percent_w, at_y, percent_w, row_h), + Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1%").arg(row.battery)); + } + painter.setPen(Qt::NoPen); + }; + // A name being typed: apply, remove and cancel, where the history goes + const auto edit_buttons = [&](std::size_t r, int at_y) { + const auto& row = overlay_rows[r]; + constexpr int round_w = 24; + const bool named = !overlay_custom_name[static_cast(row.player)].empty(); + const int buttons = named ? 3 : 2; + const double x0 = 1 + controller_w + bubble_gap; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(14, 14, 18, 200)); + painter.drawRoundedRect(QRectF(x0, at_y, 8 + buttons * (round_w + chip_gap) - chip_gap, + row_h), + row_h / 2.0, row_h / 2.0); + double x = x0 + 4; + const double h = row_h - 6; + const auto round = [&](int action, const QColor& fill) { + const QRectF rect(x, at_y + 3, round_w, h); + overlay_buttons.push_back({rect.toAlignedRect(), row.player, action, -1}); + x += round_w + chip_gap; + painter.setPen(Qt::NoPen); + painter.setBrush(fill); + painter.drawEllipse(rect.center(), h / 2.0, h / 2.0); + painter.setPen(QPen(Qt::white, 1.8, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + return rect.center(); + }; + // Apply: a check + { + const QPointF c = round(4, QColor(45, 160, 80, 230)); + const QPointF check[3] = {QPointF(c.x() - 4.5, c.y()), QPointF(c.x() - 1.5, c.y() + 3), + QPointF(c.x() + 4.5, c.y() - 3.5)}; + painter.drawPolyline(check, 3); + } + // Remove the name: a bin + if (named) { + const QPointF c = round(5, QColor(200, 50, 50, 230)); + painter.drawLine(QPointF(c.x() - 4.5, c.y() - 3.5), QPointF(c.x() + 4.5, c.y() - 3.5)); + painter.drawLine(QPointF(c.x() - 1.5, c.y() - 5), QPointF(c.x() + 1.5, c.y() - 5)); + painter.drawRoundedRect(QRectF(c.x() - 3.5, c.y() - 3.5, 7, 8.5), 1, 1); + } + // Cancel: a cross + { + const QPointF c = round(6, QColor(255, 255, 255, 40)); + painter.drawLine(QPointF(c.x() - 3.5, c.y() - 3.5), QPointF(c.x() + 3.5, c.y() + 3.5)); + painter.drawLine(QPointF(c.x() + 3.5, c.y() - 3.5), QPointF(c.x() - 3.5, c.y() + 3.5)); + } + painter.setPen(Qt::NoPen); + }; + // The row under the mouse shows its buttons where its history goes + overlay_buttons.clear(); + const auto neighbour = [this](std::size_t r, int step) { + for (int k = static_cast(r) + step; + k >= 0 && k < static_cast(overlay_rows.size()); k += step) { + if (overlay_rows[static_cast(k)].pad != 0) { + return overlay_rows[static_cast(k)].player; + } + } + return -1; + }; + const auto row_buttons = [&](std::size_t r, int at_y) { + const auto& row = overlay_rows[r]; + const auto address = pad_bluetooth.find(BluetoothKey(row.pad)); + const bool turns_off = address != pad_bluetooth.end() && !address->second.empty(); + const QString label = tr("Turn Off"); + const QString config_label = tr("Config"); + constexpr int round_w = 24; + const int remove_w = turns_off ? 22 + chip_fm.horizontalAdvance(label) + 10 : 0; + const int config_w = chip_fm.horizontalAdvance(config_label) + 20; + const int above = neighbour(r, -1); + const int below = neighbour(r, 1); + const int count_round = (above >= 0 ? 1 : 0) + (below >= 0 ? 1 : 0); + const double x0 = 1 + controller_w + bubble_gap; + const double pill_w = 4 + (count_round + 1) * (round_w + chip_gap) + config_w + + (turns_off ? chip_gap + remove_w : 0) + 4; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(14, 14, 18, 200)); + painter.drawRoundedRect(QRectF(x0, at_y, pill_w, row_h), row_h / 2.0, row_h / 2.0); + double x = x0 + 4; + const double top = at_y + 3; + const double h = row_h - 6; + const auto add = [&](double w, int action, int target) { + const QRectF rect(x, top, w, h); + overlay_buttons.push_back({rect.toAlignedRect(), row.player, action, target}); + x += w + chip_gap; + return rect; + }; + const auto arrow = [&](int step, int target) { + const QRectF rect = add(round_w, step, target); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 255, 255, 40)); + painter.drawEllipse(rect.center(), h / 2.0, h / 2.0); + const QPointF c = rect.center(); + const double d = step < 0 ? -1.0 : 1.0; + const QPointF tri[3] = {QPointF(c.x() - 5, c.y() - 2.5 * d), + QPointF(c.x() + 5, c.y() - 2.5 * d), + QPointF(c.x(), c.y() + 3.5 * d)}; + painter.setBrush(Qt::white); + painter.drawPolygon(tri, 3); + }; + if (above >= 0) { + arrow(-1, above); + } + if (below >= 0) { + arrow(1, below); + } + // Rename: a pencil, opening a small dialog for the name + { + const QRectF rect = add(round_w, 3, -1); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 255, 255, 40)); + painter.drawEllipse(rect.center(), h / 2.0, h / 2.0); + const QPointF c = rect.center(); + painter.setPen(QPen(Qt::white, 1.6, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(QPointF(c.x() - 4, c.y() + 4), QPointF(c.x() + 3, c.y() - 3)); + painter.drawLine(QPointF(c.x() + 1.5, c.y() - 4.5), QPointF(c.x() + 4.5, c.y() - 1.5)); + painter.setPen(Qt::NoPen); + } + // Config: the player's tab in the controls settings + { + const QRectF config_rect = add(config_w, 2, -1); + painter.setPen(QPen(QColor(120, 170, 255, 230), 1.5)); + painter.setBrush(QColor(45, 95, 200, 120)); + painter.drawRoundedRect(config_rect.adjusted(0.75, 0.75, -0.75, -0.75), h / 2.0, + h / 2.0); + painter.setFont(chip_font); + painter.setPen(Qt::white); + painter.drawText(config_rect, Qt::AlignCenter, config_label); + painter.setPen(Qt::NoPen); + } + if (!turns_off) { + return true; + } + const QRectF rect = add(remove_w, 0, -1); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(200, 50, 50, 230)); + painter.drawRoundedRect(rect, h / 2.0, h / 2.0); + // A power symbol + const QPointF c(rect.left() + 12, rect.center().y()); + painter.setPen(QPen(Qt::white, 2, Qt::SolidLine, Qt::RoundCap)); + painter.setBrush(Qt::NoBrush); + painter.drawArc(QRectF(c.x() - 5, c.y() - 4.5, 10, 10), 125 * 16, 290 * 16); + painter.drawLine(QPointF(c.x(), c.y() - 6), QPointF(c.x(), c.y())); + painter.setFont(chip_font); + painter.setPen(Qt::white); + painter.drawText(rect.adjusted(22, 0, 0, 0), Qt::AlignVCenter | Qt::AlignLeft, label); + painter.setPen(Qt::NoPen); + return true; + }; + // Where each row is, for the mouse (a row drags onto another to swap them) + overlay_hits.clear(); + int drag_row = -1; + int drag_y = 0; + int y = 1; + for (std::size_t r = 0; r < overlay_rows.size(); ++r) { + const auto& row = overlay_rows[r]; + const bool blink = overlay_blink[r]; + overlay_hits.emplace_back(row.player, QRect(1, y, width - 2, row_h)); + if (overlay_dragging && row.player == overlay_drag_player) { + // Where the dragged row came from: an empty slot (the row follows the mouse) + QColor edge = player_color(row.player); + edge.setAlpha(150); + painter.setPen(QPen(edge, 1.5, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(QRectF(1.5, y + 0.5, controller_w - 1, row_h - 1), row_h / 2.0, + row_h / 2.0); + painter.setPen(Qt::NoPen); + drag_row = static_cast(r); + drag_y = y; + } else { + controller_bubble(row, blink, y); + } + if (row.player == overlay_edit_player && row.pad != 0) { + edit_buttons(r, y); + y += row_h + row_gap; + continue; + } + if (row.player == overlay_hover_player && row.pad != 0 && !overlay_dragging && + row_buttons(r, y)) { + y += row_h + row_gap; + continue; + } + + // The history bubble: the last inputs, newest first, shrinking away as they expire. + // Buttons pressed together share a capsule, joined by "+". + static const std::deque none; + const auto found = press_history.find(overlay_row_history[row.player]); + const auto& presses = show_history && found != press_history.end() ? found->second : none; + struct Input { + std::size_t first; + std::size_t count; + double scale; + double width; + double gap_w; ///< the time to the newer input, drawn before this one (0: none) + }; + constexpr double plus_w = 10; + constexpr double capsule_pad = 3; + std::vector inputs; // newest first + double chips_w = 0; + for (std::size_t end = presses.size(); end > 0;) { + std::size_t first = end - 1; + while (first > 0 && presses[first - 1].at == presses[end - 1].at) { + --first; + } + const double scale = press_scale(presses[first]); + double input_w = 0; + for (std::size_t k = first; k < end; ++k) { + input_w += chip_width(chip(presses[k].button).first); + } + if (end - first > 1) { + input_w += capsule_pad * 2 + plus_w * static_cast(end - first - 1); + } + input_w *= scale; + if (scale > 0.0) { + // The time from this input to the newer one before it + double gap_w = 0; + if (show_gaps && !inputs.empty()) { + gap_w = gap_fm.horizontalAdvance( + gap_text(presses[inputs.back().first].at - presses[first].at)) + + gap_pad * 2; + } + if (chips_w + gap_w + input_w > history_room - inner * 2) { + break; + } + inputs.push_back({first, end - first, scale, input_w, gap_w}); + chips_w += gap_w + input_w + chip_gap * scale; + } + end = first; + } + if (chips_w > 0.5) { + const double bubble_x = 1 + controller_w + bubble_gap; + const double bubble_w = inner * 2 + chips_w - chip_gap; + GameOverlay::Style::Pill( + painter, QRectF(bubble_x, y, (std::max)(bubble_w, double{row_h}), row_h), + GameOverlay::Style::Fill); + double cx = bubble_x + inner; + for (std::size_t g = 0; g < inputs.size(); ++g) { + const auto& input = inputs[g]; + const double scale = input.scale; + // (Older inputs fade a step each; past twenty they're transparent) + const int alpha = + (std::clamp)(static_cast((255 - static_cast(g) * 12) * scale), 0, 255); + const double h = chip_h * scale; + const bool together = input.count > 1; + if (input.gap_w > 0) { + // A tag sitting in the gap, overlapping the space around it + const QRectF tag(cx - chip_gap * scale, y + (row_h - 12) / 2.0, input.gap_w, + 12); + painter.setBrush(QColor(255, 255, 255, 22 * alpha / 255)); + painter.drawRoundedRect(tag, 6, 6); + painter.setFont(gap_font); + painter.setPen(QColor(190, 195, 202, alpha)); + painter.drawText( + tag, Qt::AlignCenter, + gap_text(presses[inputs[g - 1].first].at - presses[input.first].at)); + painter.setPen(Qt::NoPen); + cx += input.gap_w - chip_gap * scale; + } + double x = cx; + if (together) { + // The capsule: a light band with an outline around the buttons + const double ch = (chip_h + capsule_pad * 2) * scale; + const QRectF capsule(cx, y + (row_h - ch) / 2.0, input.width, ch); + painter.setBrush(QColor(255, 255, 255, 40 * alpha / 255)); + painter.setPen(QPen(QColor(255, 255, 255, 120 * alpha / 255), 1)); + painter.drawRoundedRect(capsule, ch / 2.0, ch / 2.0); + painter.setPen(Qt::NoPen); + x += capsule_pad * scale; + } + for (std::size_t k = input.first; k < input.first + input.count; ++k) { + const auto& [label, color] = chip(presses[k].button); + const double w = chip_width(label) * scale; + QColor chip_color = color; + chip_color.setAlpha(alpha); + const QRectF rect(x, y + (row_h - h) / 2.0, w, h); + painter.setBrush(chip_color); + painter.drawRoundedRect(rect, h / 2.0, h / 2.0); + if (scale > 0.6) { + painter.setFont(chip_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(rect, Qt::AlignCenter, label); + painter.setPen(Qt::NoPen); + } + x += w; + if (k + 1 < input.first + input.count) { + if (scale > 0.6) { + painter.setFont(chip_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(QRectF(x, y, plus_w * scale, row_h), Qt::AlignCenter, + QStringLiteral("+")); + painter.setPen(Qt::NoPen); + } + x += plus_w * scale; + } + } + cx += input.width + chip_gap * scale; + } + } + y += row_h + row_gap; + } + // The dragged row, last, over the others + if (drag_row >= 0) { + const int at_y = std::clamp(drag_y + overlay_drag_dy, 0, height - row_h - 1); + controller_bubble(overlay_rows[static_cast(drag_row)], + overlay_blink[static_cast(drag_row)], at_y); + } + painter.end(); + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Players), &image); +} + +bool MainWindow::HandleOverlayMouse(QEvent* event) { + const auto type = event->type(); + // A name being typed in the overlay takes the keys + if (overlay_edit_player >= 0) { + bool shown_now = false; + { + auto& frame = Common::GetOverlayPanel(Common::OverlayPanelId::Players); + std::scoped_lock lock{frame.mutex}; + shown_now = frame.enabled; + } + const bool still_there = + std::any_of(overlay_rows.begin(), overlay_rows.end(), [this](const auto& row) { + return row.player == overlay_edit_player && row.pad != 0; + }); + if (!shown_now || !still_there) { + overlay_edit_player = -1; + } + } + if (overlay_edit_player >= 0 && + (type == QEvent::KeyPress || type == QEvent::KeyRelease || + type == QEvent::ShortcutOverride)) { + event->accept(); + if (type != QEvent::KeyPress) { + return true; + } + const auto* key = static_cast(event); + const int player = overlay_edit_player; + if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || + key->key() == Qt::Key_Escape) { + if (key->key() != Qt::Key_Escape) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rename_requests.emplace_back(player, + overlay_edit_text.trimmed().toStdString()); + } + overlay_edit_player = -1; + } else if (key->key() == Qt::Key_Backspace) { + overlay_edit_text.chop(1); + } else if (!key->text().isEmpty() && key->text().at(0).isPrint() && + overlay_edit_text.size() < 16) { + overlay_edit_text += key->text(); + } + RenderControllerOverlay(); + return true; + } + if (type == QEvent::Leave && overlay_hover_player >= 0) { + overlay_hover_player = -1; + RenderControllerOverlay(); + return false; + } + // (With no button held, the pointer's moves come as hover moves) + const bool hover_move = type == QEvent::HoverMove; + if (type != QEvent::MouseButtonPress && type != QEvent::MouseMove && + type != QEvent::MouseButtonRelease && !hover_move) { + return false; + } + bool shown = false; + { + auto& frame = Common::GetOverlayPanel(Common::OverlayPanelId::Players); + std::scoped_lock lock{frame.mutex}; + shown = frame.enabled; + } + if (!shown || overlay_hits.empty() || !render_window) { + overlay_drag_player = -1; + overlay_dragging = false; + overlay_hover_player = -1; + return false; + } + // The cursor in the overlay image + const QPoint at = + GameOverlay::CursorOn(Common::GetOverlayPanel(Common::OverlayPanelId::Players), + *render_window) + .at; + const auto row_at = [this](QPoint point) { + for (const auto& [player, rect] : overlay_hits) { + if (rect.contains(point)) { + return player; + } + } + return -1; + }; + const auto* mouse = static_cast(event); + // The row under the mouse shows its buttons (not while one is dragged) + if ((type == QEvent::MouseMove || hover_move) && !overlay_dragging) { + int hover = row_at(at); + if (hover >= 0 && + std::none_of(overlay_rows.begin(), overlay_rows.end(), + [&](const auto& row) { return row.player == hover && row.pad != 0; })) { + hover = -1; + } + if (hover != overlay_hover_player) { + overlay_hover_player = hover; + RenderControllerOverlay(); + } + } + if (hover_move) { + return false; // (the rest is the press, drag and release's) + } + // A button acts on its press; its release is the overlay's too + if (overlay_button_down && type == QEvent::MouseButtonRelease) { + overlay_button_down = false; + return true; + } + if (type == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton) { + const auto button = std::find_if(overlay_buttons.begin(), overlay_buttons.end(), + [&](const auto& b) { return b.rect.contains(at); }); + if (button != overlay_buttons.end()) { + overlay_button_down = true; + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + if (button->action == 3) { + // Name it in place: typing goes to the field (the window takes the keys) + overlay_edit_player = button->player; + overlay_edit_text = QString::fromStdString( + overlay_custom_name[static_cast(button->player)]); + render_window->setFocus(); + QTimer::singleShot(0, this, [this] { RenderControllerOverlay(); }); + } else if (button->action >= 4 && button->action <= 6) { + // Apply (4), remove the name (5), cancel (6) + if (button->action != 6) { + state.rename_requests.emplace_back( + button->player, button->action == 4 + ? overlay_edit_text.trimmed().toStdString() + : std::string{}); + } + overlay_edit_player = -1; + QTimer::singleShot(0, this, [this] { RenderControllerOverlay(); }); + } else if (button->action == 2) { + // (After the event: the dialog runs its own loop) + const int player = button->player; + QTimer::singleShot(0, this, [this, player] { + open_player_tab = player; + OnConfigure(); + }); + } else if (button->action == 0) { + LOG_INFO(Frontend, "console-mode: overlay turn off P{}", button->player + 1); + state.remove_requests.push_back(button->player); + } else { + LOG_INFO(Frontend, "console-mode: overlay swap P{} <-> P{}", button->player + 1, + button->target + 1); + state.swap_requests.emplace_back(button->player, button->target); + // The buttons follow the controller to its new row + overlay_hover_player = button->target; + } + return true; + } + } + if (type == QEvent::MouseButtonPress) { + if (mouse->button() != Qt::LeftButton) { + return false; + } + const int player = row_at(at); + if (player < 0) { + return false; // the game's + } + overlay_drag_player = player; + overlay_press = at; + overlay_drag_dy = 0; + overlay_dragging = false; + return true; + } + if (overlay_drag_player < 0) { + return false; + } + if (type == QEvent::MouseMove) { + const int dy = at.y() - overlay_press.y(); + if (!overlay_dragging && std::abs(dy) < QApplication::startDragDistance()) { + return true; + } + overlay_dragging = true; + overlay_drag_dy = dy; + RenderControllerOverlay(); + return true; + } + // Released: dropped on another row swaps the two players; a click rumbles the controller + const int player = overlay_drag_player; + const bool dragged = overlay_dragging; + overlay_drag_player = -1; + overlay_dragging = false; + overlay_drag_dy = 0; + if (dragged) { + const int target = row_at(QPoint(10, at.y())); + if (target >= 0 && target != player) { + LOG_INFO(Frontend, "console-mode: overlay swap P{} <-> P{}", player + 1, target + 1); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(player, target); + } + RenderControllerOverlay(); + return true; + } + if (Settings::values.vibration_enabled.GetValue() && + Settings::values.players.GetValue()[player].vibration_enabled) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(player); + } + return true; +} + +bool MainWindow::OverlayAnimating() const { + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + if (!controller_hints.empty() || overlay_edit_player >= 0) { + return true; + } + const qint64 life_ms = Settings::values.console_history_seconds.GetValue() * 1000LL; + for (const auto& [ident, history] : press_history) { + if (!history.empty() && now_ms - history.front().at > life_ms - 100) { + return true; + } + } + return false; +} + +void MainWindow::LearnBluetoothPads() { +#ifdef __linux__ + // Pads with several controllers of their model: the one controller with a button held while + // just one of those pads has one is that pad's + if (pad_bt_pending.empty()) { + return; + } + // The pads now, each with the controller behind it now (Steam may have just moved one) + std::map, std::vector>> by_model; + { + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + for (int i = 0; i < count; ++i) { + const u32 id = ids[i]; + // (Not a real device Steam hid: its virtual pad is the player) + if (Common::SteamPadsInUse() && !IsSteamVirtualPad(id) && + Common::SteamHidModel(PadVendor(id), + PadProduct(id))) { + continue; + } + std::string key = BluetoothKey(id); + if (pad_bt_pending.contains(key)) { + by_model[{PadVendor(id), PadProduct(id)}] + .emplace_back(id, std::move(key)); + } + } + SDL_free(ids); + } + // Debug Probes: controllers waiting for a press whose pads can't be found (every 2 seconds) + if (Common::ControllerProbes()) { + std::size_t found = 0; + for (const auto& [model, pads] : by_model) { + found += pads.size(); + } + static auto last_missing = std::chrono::steady_clock::time_point{}; + const auto now = std::chrono::steady_clock::now(); + if (found < pad_bt_pending.size() && now - last_missing > std::chrono::seconds{2}) { + last_missing = now; + std::string keys; + for (const auto& key : pad_bt_pending) { + keys += " " + key; + } + LOG_INFO(Frontend, "console-mode: waiting for a press:{}, {} of them on a pad", keys, + found); + } + } + std::set taken; + for (const auto& [key, address] : pad_bluetooth) { + if (!address.empty()) { + taken.insert(address); + } + } + for (const auto& [model, pads] : by_model) { + std::vector controllers = ControllersOfModel(model.first, model.second); + std::erase_if(controllers, [&](const auto& c) { return taken.contains(c.address); }); + // The last of its model left to tell apart: that one is it + if (pads.size() == 1 && controllers.size() == 1) { + const auto& [id, key] = pads[0]; + pad_bluetooth[key] = controllers[0].bluetooth ? controllers[0].address : std::string{}; + pad_bt_pending.erase(key); + if (Common::ControllerProbes()) { + LOG_INFO( + Frontend, "console-mode: inst={} ({}) is the last of its model: bluetooth={}", + id, key, controllers[0].bluetooth ? controllers[0].address : std::string{"no"}); + } + continue; + } + // A pad's button held now, or pressed in the last 200ms (a quick tap) + const auto now_steady = std::chrono::steady_clock::now(); + std::vector> pressed_pads; + for (const auto& pad : pads) { + const auto last = + input_subsystem ? input_subsystem->GetSDLLastActivity(pad.first) : std::nullopt; + if (PadAnyButtonPressed(pad.first) || + (last && now_steady - *last < std::chrono::milliseconds{200})) { + pressed_pads.push_back(pad); + } + } + // (Every check reads the controllers' queues, so a press counts when it happened) + const RealController* pressed = nullptr; + int count = 0; + for (const auto& controller : controllers) { + if (RealButtonHeld(controller)) { + pressed = &controller; + ++count; + } + } + if (pressed_pads.size() != 1) { + // Debug Probes: presses that can't tell them apart (several pads at once) + if (pressed_pads.size() > 1 && Common::ControllerProbes()) { + static auto last_many = std::chrono::steady_clock::time_point{}; + if (now_steady - last_many > std::chrono::seconds{1}) { + last_many = now_steady; + std::string which; + for (const auto& [id, key] : pressed_pads) { + which += fmt::format(" inst={}({})", id, key); + } + LOG_INFO(Frontend, + "console-mode: {} pads of model {:04x}:{:04x} pressed at once, " + "{} of {} controllers show one held:{}", + pressed_pads.size(), model.first, model.second, count, + controllers.size(), which); + } + } + continue; + } + // Debug Probes: why a press didn't tell them apart (at most once a second) + if (count != 1 && Common::ControllerProbes()) { + static auto last = std::chrono::steady_clock::time_point{}; + if (const auto now = std::chrono::steady_clock::now(); + now - last > std::chrono::seconds{1}) { + last = now; + std::string nodes; + for (const auto& controller : controllers) { + nodes += fmt::format(" {}[{}]", controller.address, + controller.events.empty() + ? std::string{"no input node"} + : controller.events.front().toStdString()); + } + LOG_INFO(Frontend, + "console-mode: {} pressed a button, {} of {} controllers of its model " + "show one held:{}", + pressed_pads.front().second, count, controllers.size(), nodes); + } + } + if (count != 1) { + continue; + } + const auto& [id, key] = pressed_pads[0]; + // (The controller behind the pad must not have changed while it was read) + if (BluetoothKey(id) != key) { + continue; + } + pad_bluetooth[key] = pressed->bluetooth ? pressed->address : std::string{}; + pad_bt_pending.erase(key); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: inst={} ({}) told apart by a button press: bluetooth={}", id, + key, pressed->bluetooth ? pressed->address : std::string{"no"}); + } + } +#endif +} + +#ifdef __linux__ +namespace { + +/// The presses since the last look: the pads that just got input, and the real controllers +/// with input in the last 150ms, read from their input nodes +struct FreshPresses { + std::set pads; + std::set controllers; +}; + +/// Reads the pads' and the real controllers' presses (a pad's is SDL's record of its input) +FreshPresses WatchControllerPresses(InputCommon::InputSubsystem* input) { + FreshPresses fresh; + // The pads with a button down, and the models behind them + std::vector pads; + std::set> models; + { + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + for (int i = 0; i < count; ++i) { + pads.push_back(ids[i]); + models.emplace(PadVendor(ids[i]), PadProduct(ids[i])); + } + SDL_free(ids); + } + // (A pad's input newer than at the last look) + static std::map pads_seen; + for (const SDL_JoystickID id : pads) { + const auto last = input ? input->GetSDLLastActivity(id) : std::nullopt; + if (!last) { + continue; + } + auto& seen = pads_seen[id]; + if (*last != seen) { + const bool first = seen == std::chrono::steady_clock::time_point{}; + seen = *last; + if (!first) { + fresh.pads.insert(id); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: probe: pad inst={} ({}) input", id, + BluetoothKey(id)); + } + } + } + } + // The controllers' input nodes, listed again every 2 seconds + struct Node { + int fd; + std::string controller; + /// Its axes' ranges and where they rested when it was opened + std::map axes; + }; + static std::map nodes; + static std::map pressed_at; + static auto listed = std::chrono::steady_clock::time_point{}; + const auto now = std::chrono::steady_clock::now(); + if (now - listed > std::chrono::seconds{2}) { + listed = now; + for (const auto& [vendor, product] : models) { + for (const auto& controller : ControllersOfModel(vendor, product)) { + for (const QString& event : controller.events) { + if (!nodes.contains(event)) { + const int fd = ::open(event.toLocal8Bit().constData(), + O_RDONLY | O_NONBLOCK | O_CLOEXEC); + // (Not a motion sensor's node: its axes move all the time) + std::array props{}; + if (fd >= 0 && ioctl(fd, EVIOCGPROP(props.size()), props.data()) >= 0 && + (props[INPUT_PROP_ACCELEROMETER / 8] & + (1 << (INPUT_PROP_ACCELEROMETER % 8)))) { + ::close(fd); + continue; + } + std::map axes; + for (u16 code = 0; fd >= 0 && code <= ABS_MAX; ++code) { + input_absinfo info{}; + if (ioctl(fd, EVIOCGABS(code), &info) == 0 && + info.maximum > info.minimum) { + axes[code] = info; + } + } + nodes[event] = {fd, controller.key, std::move(axes)}; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: probe: watching {} of {} ({:04x}:{:04x}){}", + event.toStdString(), controller.key, vendor, product, + nodes[event].fd < 0 ? " - can't open" : ""); + } + } + } + } + } + } + for (auto it = nodes.begin(); it != nodes.end();) { + std::array events; + ssize_t got = 0; + while (it->second.fd >= 0 && + (got = ::read(it->second.fd, events.data(), sizeof(events))) > 0) { + for (std::size_t k = 0; k < static_cast(got) / sizeof(input_event); ++k) { + const input_event& e = events[k]; + bool pressed = e.type == EV_KEY && e.value == 1; + if (const auto axis = it->second.axes.find(e.code); + e.type == EV_ABS && axis != it->second.axes.end()) { + const auto& info = axis->second; + pressed = std::abs(static_cast(e.value - info.value)) / + static_cast(info.maximum - info.minimum) > + 0.25; + } + if (!pressed) { + continue; + } + pressed_at[it->second.controller] = now; + // (Logged once a burst: a stick held out sends events all the time) + static std::map logged_at; + auto& logged = logged_at[it->second.controller]; + const bool burst = now - logged < std::chrono::milliseconds{500}; + logged = now; + if (Common::ControllerProbes() && !burst) { + LOG_INFO(Frontend, "console-mode: probe: controller {} input ({} {:#x})", + it->second.controller, e.type == EV_KEY ? "button" : "axis", + e.code); + } + } + } + // (Gone: listed again if it comes back) + if (it->second.fd < 0 || (got < 0 && errno == ENODEV)) { + if (it->second.fd >= 0) { + ::close(it->second.fd); + } + it = nodes.erase(it); + } else { + ++it; + } + } + for (auto it = pressed_at.begin(); it != pressed_at.end();) { + if (now - it->second < std::chrono::milliseconds{150}) { + fresh.controllers.insert(it->first); + ++it; + } else { + it = pressed_at.erase(it); + } + } + return fresh; +} + +} // Anonymous namespace +#endif + +void MainWindow::WatchSteamPadInputs() { +#ifdef __linux__ + // (Read under Steam, and for Debug Probes) + if (!Common::SteamPadsInUse() && !Common::ControllerProbes()) { + return; + } + const FreshPresses fresh = WatchControllerPresses(input_subsystem.get()); + // A pad's input together with exactly one real controller's: when Steam then moves a + // controller to that pad, that is the one + if (Common::SteamPadsInUse() && fresh.pads.size() == 1 && fresh.controllers.size() == 1) { + last_lone_input = LoneInput{*fresh.pads.begin(), *fresh.controllers.begin(), + std::chrono::steady_clock::now()}; + } +#endif +} + +void MainWindow::PollControllerActivity() { + // (The automatic modes only) + const auto mode = Settings::values.controller_applet_mode.GetValue(); + if (mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode) { + WatchSteamPadInputs(); + LearnBluetoothPads(); + } + // Redrawn faster than the 250ms tick, only when who is active changes or while animating + if (overlay_rows.empty()) { + if (!controller_hints.empty()) { + RenderControllerOverlay(); + } + return; + } + const auto now = std::chrono::steady_clock::now(); + std::set active; + for (const auto& row : overlay_rows) { + if (row.pad == 0) { + continue; + } + const auto last = + input_subsystem ? input_subsystem->GetSDLLastActivity(row.pad) : std::nullopt; + if (PadInputHeld(row.pad) || (last && now - *last < std::chrono::milliseconds{250})) { + active.insert(row.pad); + } + } + // ...and when a green/red flash blinks + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + std::vector blink; + for (const auto& row : overlay_rows) { + blink.push_back(row.flash != 0 && BlinkOn(now_ms - row.flash_since)); + } + const bool pressed = PollButtonHistory(); + const auto& layout = render_window->GetFramebufferLayout(); + const float room = Common::OverlayRoomTopLeft( + static_cast(layout.screen.right - layout.screen.left)); + const bool room_changed = std::exchange(overlay_room, room) != room; + if (pressed || OverlayAnimating() || active != overlay_active_pads || blink != overlay_blink || + room_changed) { + RenderControllerOverlay(); + } +} + +bool MainWindow::PollButtonHistory() { + if (!QtCommon::system || !QtCommon::system->IsPoweredOn()) { + return false; + } + auto& hid = QtCommon::system->HIDCore(); + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + bool pressed = false; + const qint64 life_ms = Settings::values.console_history_seconds.GetValue() * 1000LL; + const std::size_t size = + static_cast(Settings::values.console_history_size.GetValue()); + // A controller that left takes its history with it (back, it starts over) + std::erase_if(press_history, [this, &pressed](const auto& entry) { + const bool kept = + std::any_of(overlay_rows.begin(), overlay_rows.end(), [&](const auto& row) { + return row.pad != 0 && overlay_row_history[row.player] == entry.first; + }); + pressed |= !kept; + return !kept; + }); + for (const auto& row : overlay_rows) { + if (row.pad == 0 || row.player < 0 || row.player >= 8 || + overlay_row_history[row.player].empty()) { + continue; + } + const int i = row.player; + const std::string& ident = overlay_row_history[i]; + const bool fresh = history_ident[i] != ident; + history_ident[i] = ident; + const auto& player = Settings::values.players.GetValue()[i]; + auto* controller = i == 0 && player.controller_type == Settings::ControllerType::Handheld + ? hid.GetEmulatedController(Core::HID::NpadIdType::Handheld) + : hid.GetEmulatedControllerByIndex(i); + if (!controller) { + continue; + } + // Entries gone through their life and shrink leave the history + auto& history = press_history[ident]; + while (!history.empty() && now_ms - history.front().at > life_ms + 400) { + history.pop_front(); + pressed = true; + } + // Presses within 50ms of an input's first (about three frames) are one input + constexpr qint64 together_ms = 50; + const auto record = [&](int button) { + qint64 at = now_ms; + if (!history.empty() && now_ms - history.back().at <= together_ms) { + const qint64 group = history.back().at; + // (A stick turning again is a new input, not a second press) + const bool same_stick = + button >= StickMoveButton && + std::any_of(history.begin(), history.end(), [&](const ButtonPress& p) { + return p.at == group && p.button >= StickMoveButton && + (p.button - StickMoveButton) / 4 == (button - StickMoveButton) / 4; + }); + if (!same_stick) { + at = group; + } + } + history.push_back({button, at}); + std::set inputs; + for (const auto& press : history) { + inputs.insert(press.at); + } + while (inputs.size() > size) { + const qint64 oldest = history.front().at; + while (!history.empty() && history.front().at == oldest) { + history.pop_front(); + } + inputs.erase(oldest); + } + pressed = true; + }; + // A stick counts when it leaves the middle, and again when it turns another way + const auto sticks = controller->GetSticksValues(); + for (int s = 0; s < 2; ++s) { + const float x = sticks[static_cast(s)].x.value; + const float y = sticks[static_cast(s)].y.value; + const float reach = std::hypot(x, y); + auto& pointing = stick_direction[i][static_cast(s)]; + if (reach > 0.6f) { + const int direction = std::abs(x) > std::abs(y) ? (x < 0 ? 0 : 2) : (y > 0 ? 1 : 3); + if (direction + 1 != pointing) { + pointing = direction + 1; + if (!fresh) { + record(StickMoveButton + s * 4 + direction); + } + } + } else if (reach < 0.3f) { + pointing = 0; + } + } + const auto values = controller->GetButtonsValues(); + auto& down = buttons_down[i]; + for (int b = 0; b < Settings::NativeButton::NumButtons; ++b) { + const bool held = values[b].value; + if (held && !down[b] && !fresh) { + record(b); + } + down[b] = held; + } + } + return pressed; +} + +void MainWindow::ApplyConsoleControllerMode() { + // (Closing: the controllers' inputs are unloaded; a reload now would outlive the input + // engines and crash at exit) + if (closing) { + return; + } + // A tick longer than 250ms means a controller or the system held Eden up: always logged + struct SlowCheckWarning { + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + ~SlowCheckWarning() { + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + if (ms > 250) { + LOG_WARNING(Frontend, "console-mode: controller check took {} ms", ms); + } + } + } slow_check_warning; + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool handheld = mode == Settings::AppletMode::HandheldConsoleMode; + const bool console = mode == Settings::AppletMode::ConsoleMode; + // The mode, when it changes (from the Controllers tab or System > Applets): always logged + if (mode != logged_mode) { + LOG_INFO(Frontend, "console-mode: mode {} -> {}", + logged_mode ? AppletModeName(*logged_mode) : "(start)", AppletModeName(mode)); + logged_mode = mode; + // Debug Probes: what every report needs, once per mode + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: setup steam={} bypass={} overlay={} hints={} history={} " + "inputs={} expire={}s gaps={} seat_profiles=\"{}\"", + Common::RunningUnderSteam(), + Common::GetControllerConsoleState().steam_bypassed.load(), + Settings::values.console_debug_overlay.GetValue(), + Settings::values.console_hints.GetValue(), + Settings::values.console_history.GetValue(), + Settings::values.console_history_size.GetValue(), + Settings::values.console_history_seconds.GetValue(), + Settings::values.console_history_gaps.GetValue(), + Settings::values.modern_seat_profiles.GetValue()); + } + } + // The handheld's own pad is stored per way of launching: the Steam handle under Steam, the + // raw pad's identity otherwise. A Steam virtual pad's GUID stored as the identity (older + // builds) never matches a raw pad: dropped, so auto-detection works again. + if (!Common::SteamPadsInUse()) { + const std::string identity = Settings::values.builtin_pad_identity.GetValue(); + if (identity.size() >= 25 && identity.starts_with("guid:") && + identity.compare(5 + 8, 4, "de28") == 0 && identity.compare(5 + 16, 4, "ff11") == 0) { + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_manual.SetValue(false); + LOG_INFO(Frontend, + "console-mode: stored handheld was a Steam virtual pad ({}), " + "back to auto-detect for direct launches", + identity); + } + } + // A declaration with no identity (older builds) would pin "no handheld" forever: undone + if (Settings::values.builtin_pad_manual.GetValue() && + Settings::values.builtin_pad_identity.GetValue().empty()) { + Settings::values.builtin_pad_manual.SetValue(false); + LOG_INFO(Frontend, "console-mode: declared handheld had no identity, back to auto"); + } + + // The live gamepads, keyed by connection instance (always unique); pad_identity keeps the + // stable identity used to reclaim slots. + // + // Under Steam the virtual pads share one GUID and Steam moves controllers between them, so + // the Steam handle (which follows the controller) is the identity. Steam fills it in a + // moment after a pad appears: until then the pad isn't live (at most 3 seconds). + std::map live; + /// Under Steam: real devices unhidden for their motion (never players) + std::vector motion_devices; + { + static std::map first_seen; + const auto now = std::chrono::steady_clock::now(); + std::set present; + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + // Steam's virtual pads around (none when Steam Input is off for Eden) + Common::GetControllerConsoleState().steam_pads_present = + std::any_of(ids, ids + count, [](SDL_JoystickID id) { return IsSteamVirtualPad(id); }); + for (int i = 0; i < count; ++i) { + const u32 id = ids[i]; + present.insert(id); + first_seen.try_emplace(id, now); + // Bypassed: the real controllers play, Steam's virtual pads are left alone + if (Common::GetControllerConsoleState().steam_bypassed && IsSteamVirtualPad(id)) { + continue; + } + // A real device Steam hid, opened again for its motion: never a player + if (Common::SteamPadsInUse() && !IsSteamVirtualPad(id) && + Common::SteamHidModel(PadVendor(id), + PadProduct(id))) { + motion_devices.push_back(id); + // (Only marked once logged: Debug Probes turned on later still describes it) + static std::set probed; + if (Common::ControllerProbes() && probed.insert(id).second) { + std::string twins; + const Uint16 vendor = PadVendor(id); + const Uint16 product = PadProduct(id); + for (int k = 0; k < count; ++k) { + if (ids[k] != id && IsSteamVirtualPad(ids[k]) && + PadVendor(ids[k]) == vendor && + PadProduct(ids[k]) == product) { + twins += fmt::format("{} ", ids[k]); + } + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: real device inst={} {:04x}:{:04x} name={} " + "gyro={} path={} same model as virtual inst=[{}]", + id, vendor, product, PadName(id).toStdString(), PadHasGyro(id), + PadPath(id).toStdString(), twins); + } + } + continue; + } + std::string ident = PadIdentity(id); + if (Common::SteamPadsInUse() && IsSteamVirtualPad(id)) { + if (const Uint64 handle = PadSteamHandle(id)) { + ident = fmt::format("steamhandle:{:#x}", handle); + } else if (now - first_seen[id] < std::chrono::seconds{3}) { + continue; + } + } + // (For identifying a user's handheld from their log) + static std::set described; + if (Common::ControllerProbes() && described.insert(ident).second) { + const Uint16 vendor = PadVendor(id); + // (SDL has no name for an unknown type: null, which fmt refuses) + const char* type = SDL_GetGamepadStringForType(PadType(id)); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: device {:04x}:{:04x} name=\"{}\" type={} steam={} " + "gyro={} handheld_maker={} controller_maker={} ident={}", + vendor, PadProduct(id), PadName(id).toStdString(), + type ? type : "unknown", IsSteamVirtualPad(id), PadHasGyro(id), + IsHandheldMaker(vendor), IsControllerMaker(vendor), ident); + } + } + live[id] = std::move(ident); + } + SDL_free(ids); + std::erase_if(first_seen, [&present](const auto& e) { return !present.contains(e.first); }); + } + + // The Deck's controller only reports motion while its sensors are on, which Steam leaves off + // unless its layout uses the gyro (in Desktop Mode too): when it goes silent, ask again (at + // most every 2 seconds). Under Steam it is a motion device, else one of the pads. + { + const auto is_deck = [](u32 id) { + return PadVendor(id) == 0x28DE && PadProduct(id) == 0x1205; + }; + std::optional deck; + if (const auto it = std::find_if(motion_devices.begin(), motion_devices.end(), is_deck); + it != motion_devices.end()) { + deck = *it; + } else if (!Common::SteamPadsInUse()) { + for (const auto& [id, ident] : live) { + if (is_deck(id) && PadPath(id).startsWith(QStringLiteral("/dev/hidraw"))) { + deck = id; + break; + } + } + } + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + if (deck && now_ms - deck_motion_asked_at >= 2000 && PadMotionSilent(*deck)) { + deck_motion_asked_at = now_ms; + const QString path = PadPath(*deck); + EnableDeckMotion(path); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: deck motion silent, asked for it via {}", + path.toStdString()); + } + } + } + + // A controller Steam moved to another virtual pad takes its slot along + if (Common::SteamPadsInUse()) { + static std::map handle_inst; + std::map moves; // to <- from + for (const auto& [id, ident] : live) { + if (!ident.starts_with("steamhandle:")) { + continue; + } + const auto it = handle_inst.find(ident); + if (it != handle_inst.end() && it->second != id) { + moves[id] = it->second; + } + } + if (!moves.empty()) { + std::map old_slot; + std::set involved; + for (const auto& [to, from] : moves) { + involved.insert(to); + involved.insert(from); + } + for (const u32 inst : involved) { + if (const auto it = pad_slots.find(inst); it != pad_slots.end()) { + old_slot[inst] = it->second; + pad_slots.erase(it); + } + pad_identity.erase(inst); + } + u32 new_builtin = builtin_pad_id; + const std::set benched_before = benched_pads; + const auto address_before = inst_address; + const auto origin_before = pad_origin; + const auto guessed_before = pad_guessed; + for (const auto& [to, from] : moves) { + const auto slot = old_slot.find(from); + LOG_INFO(Frontend, "console-mode: Steam moved {} from inst={} to inst={} (P{})", + live[to], from, to, slot != old_slot.end() ? slot->second + 1 : 0); + // Steam moves a controller on its first use and says so later, so its input + // already came on the new pad: the one controller that had input with this pad + // just before is the one moved (a match made meanwhile is put right) + const std::string key = BluetoothKey(to); + if (last_lone_input && last_lone_input->pad == to && + std::chrono::steady_clock::now() - last_lone_input->at < + std::chrono::seconds{5} && + last_lone_input->controller.size() == 17 && pad_bluetooth.contains(key)) { + const std::string& address = last_lone_input->controller; + const auto had = pad_bluetooth.find(key); + if (had->second != address) { + // (The controller it was taken for is unsure again) + for (auto& [other, other_address] : pad_bluetooth) { + if (other != key && other_address == address) { + other_address.clear(); + pad_bt_pending.insert(other); + } + } + LOG_INFO(Frontend, + "console-mode: inst={} ({}) told apart by the input Steam moved " + "it for: bluetooth={}{}", + to, key, address, + had->second.empty() ? std::string{} + : " (was " + had->second + ")"); + had->second = address; + pad_bt_pending.erase(key); + } + } + if (slot != old_slot.end()) { + pad_slots[to] = slot->second; + pad_identity[to] = live[to]; + } + if (builtin_pad_id == from) { + new_builtin = to; + } + // (From the state before the moves: two pads can trade instances) + if (benched_before.contains(from)) { + benched_pads.insert(to); + } else { + benched_pads.erase(to); + } + const auto carry = [from, to](auto& now_map, const auto& before) { + if (const auto it = before.find(from); it != before.end()) { + now_map[to] = it->second; + } else { + now_map.erase(to); + } + }; + carry(inst_address, address_before); + carry(pad_origin, origin_before); + if (guessed_before.contains(from)) { + pad_guessed.insert(to); + } else { + pad_guessed.erase(to); + } + } + builtin_pad_id = new_builtin; + } + handle_inst.clear(); + for (const auto& [id, ident] : live) { + if (ident.starts_with("steamhandle:")) { + handle_inst[ident] = id; + } + } + } + + // Publish the live pads to the settings UI, and serve Identify: the next pad with a button + // held becomes the handheld's own. Whatever the mode (the tab declares it before OK). + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.pads.clear(); + // Every pad is offered, possible handheld controls first (they can pose as a + // standalone controller, such as an emulated DualSense) + std::vector> standalone; + for (const auto& [id, ident] : live) { + // Under Steam the entries carry the Steam handle (every pad shares one GUID) + const Uint64 handle = Common::SteamPadsInUse() ? PadSteamHandle(id) : 0; + auto entry = std::pair{PadName(id).toStdString(), + handle ? fmt::format("steamhandle:{:#x}", handle) : ident}; + if (IsPlainlyStandalone(id)) { + standalone.push_back(std::move(entry)); + } else { + state.pads.push_back(std::move(entry)); + } + } + state.builtin_pads = state.pads.size(); + state.pads.insert(state.pads.end(), standalone.begin(), standalone.end()); + // A tap is shorter than 250ms: poll faster while Identify waits, while twins wait for + // a press, and while paused for a disconnect (for +) + console_mode_timer.start(std::chrono::milliseconds{ + state.identify_armed || !disconnect_paused.empty() ? 50 + : pad_bt_pending.empty() ? 250 + : 100}); + if (state.identify_armed) { + const auto now = std::chrono::steady_clock::now(); + if (now - state.identify_armed_at > std::chrono::seconds{15}) { + state.identify_armed = false; + } else { + for (const auto& [id, ident] : live) { + // (Any pad: the user presses on the handheld's own on purpose) + if (PadAnyButtonPressed(id)) { + state.identify_armed = false; + builtin_pad_id = id; + builtin_identity = ident; + builtin_seen_first = true; + builtin_warned = false; + // Each way of launching keeps its own: the other one's stays + const Uint64 handle = Common::SteamPadsInUse() ? PadSteamHandle(id) : 0; + if (Common::SteamPadsInUse()) { + if (handle) { + Settings::values.builtin_pad_steam_handle.SetValue( + fmt::format("{:#x}", handle)); + Settings::values.builtin_pad_steam_manual.SetValue(true); + } + } else { + Settings::values.builtin_pad_identity.SetValue(ident); + Settings::values.builtin_pad_manual.SetValue(true); + } + LOG_INFO(Frontend, + "console-mode: identify captured inst={} ident={} " + "steam_handle={:#x}", + id, ident, handle); + break; + } + } + } + } + } + + // Under Steam the picker declares a pad by its Steam handle + { + const std::string declared = Settings::values.builtin_pad_identity.GetValue(); + constexpr std::string_view prefix = "steamhandle:"; + if (declared.starts_with(prefix)) { + const std::string handle = declared.substr(prefix.size()); + for (const auto& [id, ident] : live) { + if (fmt::format("{:#x}", PadSteamHandle(id)) == handle) { + builtin_pad_id = id; + break; + } + } + // The identity is the direct launches' own: a Steam pick only sets the handle + Settings::values.builtin_pad_steam_handle.SetValue(handle); + Settings::values.builtin_pad_steam_manual.SetValue(true); + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_manual.SetValue(false); + LOG_INFO(Frontend, "console-mode: picker declared Steam handle {} inst={}", handle, + builtin_pad_id); + } + } + + // The overlay and hints only exist in the automatic modes: so do their View menu toggles + ui->action_Show_Controller_Overlay->setVisible(handheld || console); + ui->action_Show_Controller_Hints->setVisible(handheld || console); + if (!handheld && !console) { + if (!overlay_rows.empty()) { + overlay_rows.clear(); + RenderControllerOverlay(); + } + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.engine_active = false; + state.players.clear(); + state.handheld_name.clear(); + state.handheld_has_player = false; + state.swap_requests.clear(); + state.remove_requests.clear(); + } + benched_pads.clear(); + if (console_profile) { + // Leaving the console modes: the user's own players come back exactly as they were + RestoreConsoleProfile(); + pad_slots.clear(); + pad_identity.clear(); + pad_sets.clear(); + address_origin.clear(); + address_claims.clear(); + slot_set = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + set_default_of.fill(0); + set_before_profile.clear(); + LOG_INFO(Frontend, "console-mode: left, the user's players are restored"); + if (QtCommon::system && QtCommon::system->IsPoweredOn()) { + QtCommon::system->HIDCore().ReloadInputDevices(); + } + } + if (builtin_seen_first) { + // Leaving forgets the auto-detected handheld; a declared one stays + builtin_pad_id = 0; + builtin_identity.clear(); + builtin_seen_first = false; + builtin_warned = false; + // (The Steam handle stays: it is the same controller in every session) + if (!Settings::values.builtin_pad_manual.GetValue()) { + Settings::values.builtin_pad_identity.SetValue(std::string{}); + } + } + return; + } + if (!console_profile) { + // Entering a console mode: the user's players are kept aside; the engine's changes stay + // runtime only (see SaveAllConfigValues) + console_profile = Settings::values.players.GetValue(true); + game_profiles.reset(); + session_sets = ModernStartSets(*console_profile); + ApplySeatProfiles(); + LOG_INFO(Frontend, "console-mode: entered, the user's players are kept aside"); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: entered with profile P1 motions {}", + MotionTrace((*console_profile)[0])); + } + } + // A game with input profiles of its own: those players' sets are the profile's + if (Settings::values.players.UsingGlobal()) { + game_profiles.reset(); + } else if (!game_profiles) { + game_profiles = Settings::values.players.GetValue(); + for (size_t i = 0; i < game_profiles->size(); ++i) { + if (!(*game_profiles)[i].profile_name.empty()) { + LOG_INFO(Frontend, "console-mode: P{} uses the game's input profile \"{}\"", i + 1, + (*game_profiles)[i].profile_name); + } + } + } + if (builtin_identity.empty()) { + // Console Mode also needs to know the handheld's own pad: it carries player one's set + builtin_identity = Settings::values.builtin_pad_identity.GetValue(); + } + + // Names as the Controls tab lists them, by the device the pad is bound as + std::vector input_devices; + if (input_subsystem) { + input_devices = input_subsystem->GetInputDevices(); + } + const auto label_of = [&](u32 id, const std::string&) { + QString display; + if (input_subsystem) { + if (const auto binding = input_subsystem->GetSDLBinding(id)) { + for (const auto& device : input_devices) { + if (device.Get("engine", "") == "sdl" && + device.Get("guid", "") == binding->first && + device.Get("port", -1) == binding->second) { + display = QString::fromStdString(device.Get("display", "")); + break; + } + } + } + } + // Under Steam, SDL's names lag up to 3 seconds behind a move: until SDL has Steam's + // controller type, the name is Steam's controller's + if (const auto slot = SteamSlotOf(id); + slot && SDL_GetGamepadTypeForID(id) != slot->type) { + switch (slot->type) { + case SDL_GAMEPAD_TYPE_XBOX360: + display = QStringLiteral("Xbox 360 Controller"); + break; + case SDL_GAMEPAD_TYPE_XBOXONE: + display = QStringLiteral("Xbox One Controller"); + break; + case SDL_GAMEPAD_TYPE_PS4: + display = QStringLiteral("PS4 Controller"); + break; + case SDL_GAMEPAD_TYPE_PS5: + display = QStringLiteral("DualSense Wireless Controller"); + break; + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + display = QStringLiteral("Nintendo Switch Pro Controller"); + break; + default: + display = slot->name.startsWith(QLatin1Char('#')) ? PadName(id) : slot->name; + break; + } + } + // (Steam's names can be translation keys: not a name to show) + if (display.startsWith(QLatin1Char('#'))) { + display = PadName(id); + } + return std::pair{display, PadName(id)}; + }; + + // Debug Probes: the pad identities and the slot mapping, every tick + if (Common::ControllerProbes()) { + std::string trace; + for (const auto& [id, ident] : live) { + const auto it = pad_slots.find(id); + trace += fmt::format("\n pad inst={} slot={} idx={} name={} path={} ident={}", id, + it == pad_slots.end() ? -1 : static_cast(it->second), + PadPlayerIndex(id), PadName(id).toStdString(), + PadPath(id).toStdString(), ident); + } + LOG_INFO(Frontend, "console-mode: live={}{}, builtin={}{}", trace, + live.empty() ? " (none)" : "", builtin_pad_id, + builtin_seen_first ? "" : " (unlatched)"); + } + { + // Steam fills a virtual pad's handle, type and name a moment after it appears: traced + static std::map traced_steam; + for (const auto& [id, ident] : live) { + const std::string desc = fmt::format( + "steam_handle={:#x} type={} name={}", PadSteamHandle(id), + static_cast(PadType(id)), PadName(id).toStdString()); + auto& last = traced_steam[id]; + if (last != desc) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: pad inst={} slot={} {}", id, + pad_slots.contains(id) ? pad_slots.at(id) + 1 : 0, desc); + } + last = desc; + } + } + + static std::set traced_insts; + for (const auto& [id, ident] : live) { + if (traced_insts.insert(id).second) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: pad connected inst={} ident={} name={} idx={} gyro={} " + "steam_handle={:#x} type={} real_type={}", + id, ident, PadName(id).toStdString(), PadPlayerIndex(id), + PadHasGyro(id), PadSteamHandle(id), + static_cast(PadType(id)), + static_cast(SDL_GetRealGamepadTypeForID(id))); + } + } + } + for (auto it = traced_insts.begin(); it != traced_insts.end();) { + if (live.find(*it) == live.end()) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: pad disconnected inst={}", *it); + } + it = traced_insts.erase(it); + } else { + ++it; + } + } + } + + // Turn Off: Eden disconnects a Bluetooth controller (most turn off); it sits out until it + // reconnects, and the next controller to connect takes the player + { + // (By the controller, see BluetoothKey) + std::map keys; + std::set live_keys; + for (const auto& [id, ident] : live) { + keys[id] = BluetoothKey(id); + live_keys.insert(keys[id]); + } + std::erase_if(pad_bluetooth, [&](const auto& e) { return !live_keys.contains(e.first); }); + std::erase_if(pad_bt_pending, [&](const auto& key) { return !live_keys.contains(key); }); +#ifdef __linux__ + for (const auto& [id, ident] : live) { + if (!pad_bluetooth.contains(keys[id])) { + const PadBluetooth found = FindPadBluetooth(id); + pad_bluetooth[keys[id]] = found.address; + if (found.unsure) { + pad_bt_pending.insert(keys[id]); + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} bluetooth={}", id, + found.unsure ? std::string{"unsure until a button press"} + : found.address.empty() ? std::string{"no"} + : found.address); + } + } + } +#endif + std::vector removals; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + removals.swap(state.remove_requests); + } + for (const int player : removals) { + const auto held = std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == player && live.contains(e.first); + }); + if (player < 0 || player >= 10 || held == pad_slots.end()) { + continue; + } + const u32 id = held->first; + if (pad_bluetooth[keys[id]].empty()) { + continue; // (Only a Bluetooth controller is turned off) + } + pad_slots.erase(held); + pad_identity.erase(id); + benched_pads.insert(id); + turned_off_at[player] = QDateTime::currentMSecsSinceEpoch(); + const std::string address = pad_bluetooth[keys[id]]; +#ifdef __linux__ + DisconnectBluetooth(address); +#endif + LOG_INFO(Frontend, "console-mode: P{} turned off, inst={} {}", player + 1, id, address); + } + std::erase_if(benched_pads, [&](u32 id) { return !live.contains(id); }); + std::erase_if(live, [&](const auto& e) { return benched_pads.contains(e.first); }); + } + + // The handheld's controller removed with its ✕: auto-detect leaves it alone until it is + // allowed again (or picked, or identified) + { + bool remove = false; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + remove = std::exchange(state.builtin_remove_request, false); + } + if (remove && builtin_pad_id && live.contains(builtin_pad_id)) { + const std::string key = PadKey(builtin_pad_id, live[builtin_pad_id]); + Settings::values.builtin_pad_blocked.SetValue(key); + Settings::values.builtin_pad_steam_handle.SetValue(std::string{}); + Settings::values.builtin_pad_steam_manual.SetValue(false); + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_manual.SetValue(false); + builtin_pad_id = 0; + builtin_identity.clear(); + LOG_INFO(Frontend, "console-mode: handheld controller {} removed: auto-detect skips it", + key); + } + } + const std::string blocked_key = Settings::values.builtin_pad_blocked.GetValue(); + const auto is_blocked = [&](u32 id) { + return !blocked_key.empty() && live.contains(id) && PadKey(id, live[id]) == blocked_key; + }; + // The handheld's rules apply only to the pad proven to be its own: the latched instance + // still alive, its Steam handle under Steam, or a unique identity match. Otherwise none. + u32 builtin_match_id = 0; + bool builtin_valid = false; + const bool steam_handle_known = + Common::SteamPadsInUse() && !Settings::values.builtin_pad_steam_handle.GetValue().empty(); + if (builtin_pad_id && live.find(builtin_pad_id) != live.end() && !steam_handle_known && + !is_blocked(builtin_pad_id)) { + builtin_match_id = builtin_pad_id; + builtin_valid = true; + } else if (Common::SteamPadsInUse()) { + // Under Steam neither the GUID nor Steam's player index tells the Deck's pad: its Steam + // handle, stable across sessions, does. With none stored yet, a lone pad is latched. + const std::string want = Settings::values.builtin_pad_steam_handle.GetValue(); + int count = 0; + u32 holder = 0; + if (!want.empty()) { + for (const auto& [id, ident] : live) { + if (fmt::format("{:#x}", PadSteamHandle(id)) != want || is_blocked(id)) { + continue; + } + if (!Settings::values.builtin_pad_steam_manual.GetValue() && + !CouldBeHandheld(PadVendor(id), PadProduct(id))) { + // A standalone controller stored as the handheld's (older builds) is dropped, + // unless the user declared it + LOG_INFO(Frontend, + "console-mode: stored handheld {} is a {:04x}:{:04x} " + "controller, not a handheld's: forgotten", + want, PadVendor(id), PadProduct(id)); + Settings::values.builtin_pad_steam_handle.SetValue(std::string{}); + continue; + } + ++count; + holder = id; + } + } + bool adopted = false; + if (count == 0) { + // No pad holds the stored handle (it can change after a reboot or update): the + // Deck's controller is the only one Steam reports with no gamepad type. Adopted when + // exactly one pad fits. + int fits = 0; + u32 fit = 0; + for (const auto& [id, ident] : live) { + if (PadType(id) == SDL_GAMEPAD_TYPE_UNKNOWN && + PadSteamHandle(id) != 0 && CouldBeHandheld(PadVendor(id), PadProduct(id)) && + !is_blocked(id)) { + ++fits; + fit = id; + } + } + if (fits == 1) { + count = 1; + holder = fit; + adopted = true; + } else if (want.empty() && live.size() == 1 && + !IsStandaloneControllerType(PadType(live.begin()->first)) && + CouldBeHandheld(PadVendor(live.begin()->first), + PadProduct(live.begin()->first)) && + !is_blocked(live.begin()->first)) { + count = 1; + holder = live.begin()->first; + } + } + if (adopted) { + const std::string handle = fmt::format("{:#x}", PadSteamHandle(holder)); + if (handle != want) { + Settings::values.builtin_pad_steam_handle.SetValue(handle); + Settings::values.builtin_pad_steam_manual.SetValue(false); + LOG_INFO(Frontend, + "console-mode: builtin adopted inst={} as the deck's controller, " + "Steam handle {} -> {}", + holder, want.empty() ? std::string{"(none)"} : want, handle); + } + } + static std::string last_resolved; + const std::string resolved = + count == 1 ? fmt::format("inst={}", holder) : fmt::format("{} matches", count); + if (resolved != last_resolved) { + last_resolved = resolved; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: builtin by Steam handle {}: {}", + want.empty() ? std::string{"(none stored)"} : want, resolved); + } + } + if (count == 1) { + builtin_match_id = holder; + builtin_valid = true; + } + } else if (!builtin_identity.empty()) { + int identity_count = 0; + u32 identity_holder = 0; + for (const auto& [id, ident] : live) { + if (ident == builtin_identity && !is_blocked(id)) { + ++identity_count; + identity_holder = id; + } + } + if (identity_count == 1) { + builtin_match_id = identity_holder; + builtin_valid = true; + } + if (!builtin_valid) { + static int last_count = -1; + if (identity_count != last_count) { + last_count = identity_count; + LOG_INFO(Frontend, + "console-mode: builtin unresolved ({} pads match identity {}), " + "built-in rules paused", + identity_count, builtin_identity); + } + } + } + if (builtin_valid) { + builtin_pad_id = builtin_match_id; + if (Common::SteamPadsInUse() && + Settings::values.builtin_pad_steam_handle.GetValue().empty()) { + // Latch the handheld's Steam handle as soon as Steam has filled it in + if (const Uint64 handle = PadSteamHandle(builtin_match_id)) { + Settings::values.builtin_pad_steam_handle.SetValue(fmt::format("{:#x}", handle)); + LOG_INFO(Frontend, "console-mode: builtin Steam handle latched {:#x} inst={}", + handle, builtin_match_id); + } + } + } + + if (handheld) { + const std::string stored{Settings::values.builtin_pad_identity.GetValue()}; + const bool manual = Settings::values.builtin_pad_manual.GetValue(); + if (manual) { + // A declared pad (picker or Identify): resolve its current instance + builtin_identity = stored; + builtin_seen_first = true; + builtin_pad_id = 0; + if (builtin_valid) { + builtin_pad_id = builtin_match_id; + } + } else if (!builtin_pad_id && !stored.empty() && !Common::SteamPadsInUse()) { + // (Under Steam the stored GUID is every pad's: the Steam handle resolves it) + for (const auto& [id, ident] : live) { + if (ident == stored) { + builtin_pad_id = id; + builtin_identity = ident; + builtin_seen_first = true; + builtin_match_id = id; + builtin_valid = true; + break; + } + } + } + // (Direct launches only: under Steam the handle resolves it above) + if (!builtin_pad_id && !manual && !Common::SteamPadsInUse()) { + auto handheld_candidate = [&]() -> u32 { + std::vector makers; + std::vector decks; + for (const auto& [id, ident] : live) { + if (IsSteamVirtualPad(id) || is_blocked(id)) { + // (A virtual pad carries Valve's vendor: no evidence) + continue; + } + const Uint16 vendor = PadVendor(id); + if (IsHandheldMaker(vendor) && CouldBeHandheld(vendor, PadProduct(id))) { + makers.push_back(id); + // Valve also makes standalone controllers: the deck's own wins + if (vendor == 0x28DE && PadProduct(id) == 0x1205) { + decks.push_back(id); + } + } + } + if (makers.size() == 1) { + return makers.front(); + } + return decks.size() == 1 ? decks.front() : 0; + }; + const u32 candidate = handheld_candidate(); + // The handheld's own from this tick on: the claims below must already skip it, or + // with externals on at launch it takes P1 and leaves it empty a tick later + if (candidate) { + LOG_INFO(Frontend, "console-mode: handheld {:04x}:{:04x} detected by its maker", + PadVendor(candidate), PadProduct(candidate)); + builtin_pad_id = candidate; + builtin_identity = live[candidate]; + builtin_seen_first = true; + Settings::values.builtin_pad_identity.SetValue(live[candidate]); + builtin_warned = false; + builtin_match_id = candidate; + builtin_valid = true; + } else if (live.size() == 1 && !IsControllerMaker(PadVendor(live.begin()->first)) && + !is_blocked(live.begin()->first)) { + LOG_INFO(Frontend, "console-mode: handheld {:04x}:{:04x} assumed: the only pad", + PadVendor(live.begin()->first), PadProduct(live.begin()->first)); + builtin_match_id = live.begin()->first; + builtin_valid = true; + builtin_pad_id = live.begin()->first; + builtin_identity = live.begin()->second; + builtin_seen_first = true; + Settings::values.builtin_pad_identity.SetValue(live.begin()->second); + // (A lone Steam virtual pad is the handheld's own: only unknown raw hardware is + // noted) + if (!builtin_warned && !IsSteamVirtualPad(live.begin()->first)) { + QMessageBox::information( + this, tr("Assign Handheld Controller"), + tr("Unknown handheld detected.\n\n" + "Careful: a less popular controller could be labeled as a handheld.")); + builtin_warned = true; + } + } + // Nothing identified: this tick works like Modern; the next looks again + } + } + + auto& players = Settings::values.players.GetValue(); + bool changed = false; + + // A disconnected pad keeps its reservation so a quick blip reclaims its player, for a grace + // period only (a different controller must not leave a player dead) + { + static std::map pad_last_seen; + const auto now = std::chrono::steady_clock::now(); + for (const auto& [id, ident] : live) { + pad_last_seen[id] = now; + } + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + const bool is_live = live.find(it->first) != live.end(); + const auto seen = pad_last_seen.find(it->first); + const bool expired = !is_live && seen != pad_last_seen.end() && + now - seen->second > std::chrono::seconds{45}; + if (expired) { + LOG_INFO(Frontend, "console-mode: reservation expired inst={} (slot {} free again)", + it->first, it->second); + pad_identity.erase(it->first); + pad_last_seen.erase(it->first); + it = pad_slots.erase(it); + changed = true; + } else { + ++it; + } + } + } + + // The handheld's own pad takes no player while external controllers are around: released, + // so they fill from player one. It claims player one again when they leave. + if (handheld && builtin_valid && live.size() > 1) { + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + if (it->first == builtin_match_id) { + pad_identity.erase(it->first); + it = pad_slots.erase(it); + changed = true; + } else { + ++it; + } + } + } + + // Alone, the handheld's own pad is player one: the departed externals' reservations are + // dropped (they would push it down), and returning externals fill from player one + if (handheld && builtin_valid && live.size() == 1) { + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + const bool own = it->first == builtin_match_id; + if (!own || it->second != 0) { + if (!own) { + LOG_INFO(Frontend, + "console-mode: handheld alone, dropping reservation inst={} " + "(slot {})", + it->first, it->second); + } + pad_identity.erase(it->first); + it = pad_slots.erase(it); + changed = true; + } else { + ++it; + } + } + } + + // The addresses of the Bluetooth controllers holding players; promises to away controllers + // last as long as a reservation (45 seconds) + const auto now_steady = std::chrono::steady_clock::now(); + for (const auto& [id, ident] : live) { + if (const auto known = pad_bluetooth.find(BluetoothKey(id)); + known != pad_bluetooth.end() && !known->second.empty()) { + // A controller just told apart: its battery is read now + if (auto& address = inst_address[id]; address != known->second) { + address = known->second; + battery_read.erase(id); + } + if (const auto origin = pad_origin.find(id); origin != pad_origin.end()) { + address_origin.try_emplace(known->second, origin->second); + } + } + } + std::erase_if(address_claims, [&](const auto& e) { + return now_steady - e.second.at > std::chrono::seconds{45}; + }); + + // New pads claim the lowest free slot (the handheld's own skipped while others are around), + // in player index order where there is one + std::set used; + for (const auto& [id, slot] : pad_slots) { + used.insert(slot); + } + std::vector claim_order; + claim_order.reserve(live.size()); + for (const auto& [id, ident] : live) { + claim_order.push_back(id); + } + std::sort(claim_order.begin(), claim_order.end(), [](u32 a, u32 b) { + const int ia = PadPlayerIndex(a); + const int ib = PadPlayerIndex(b); + if (ia != ib) { + return ia >= 0 && (ib < 0 || ia < ib); + } + return a < b; + }); + for (const u32 id : claim_order) { + const std::string& ident = live.at(id); + // The handheld's own is excluded by identity: its instance changes when it cycles + if (handheld && builtin_valid && id == builtin_match_id && live.size() > 1) { + continue; + } + // Steam's player index only orders the claims; slots fill lowest-free + if (pad_slots.find(id) == pad_slots.end()) { + // How many controllers are away (with several, an untold return is a guess) + const auto away = std::count_if(pad_slots.begin(), pad_slots.end(), + [&](const auto& e) { return !live.contains(e.first); }); + // A Bluetooth controller comes back to its player by address (Steam's handles don't + // survive a reconnect) + const std::string bt_key = BluetoothKey(id); + const auto known = pad_bluetooth.find(bt_key); + const std::string address = + known != pad_bluetooth.end() ? known->second : std::string{}; + if (!address.empty()) { + const auto own = + std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + if (live.contains(e.first)) { + return false; + } + const auto held = inst_address.find(e.first); + return held != inst_address.end() && held->second == address; + }); + int slot = -1; + int origin = -1; + if (own != pad_slots.end()) { + slot = own->second; + const auto from = pad_origin.find(own->first); + origin = from != pad_origin.end() ? from->second : slot; + pad_identity.erase(own->first); + pad_slots.erase(own); + } else if (const auto claim = address_claims.find(address); + claim != address_claims.end() && !used.contains(claim->second.slot)) { + // (Its player was taken by a guess, then given back) + slot = claim->second.slot; + origin = claim->second.origin; + address_claims.erase(claim); + } + if (slot >= 0) { + LOG_INFO(Frontend, "console-mode: {} back as P{} by its Bluetooth address", + address, slot + 1); + pad_slots[id] = slot; + pad_identity[id] = ident; + pad_origin[id] = origin; + used.insert(slot); + changed = true; + continue; + } + } else if (pad_bt_pending.contains(bt_key) && away > 1) { + // Its address is about to be known: wait a moment rather than guess + const auto since = claim_wait_since.try_emplace(id, now_steady).first->second; + if (now_steady - since < std::chrono::seconds{3}) { + continue; + } + } + // Reclaim a reservation with a matching identity. Identical pads share one, so an + // ambiguous pad takes the lowest of them (one the game is paused for first), or a + // lower free slot + const auto before = [&](int slot, int other) { + const bool waited = disconnect_paused.contains(slot); + if (waited != disconnect_paused.contains(other)) { + return waited; + } + return slot < other; + }; + auto match = pad_slots.end(); + int matches = 0; + for (auto it = pad_slots.begin(); it != pad_slots.end(); ++it) { + if (live.find(it->first) == live.end() && pad_identity[it->first] == ident) { + if (match == pad_slots.end() || before(it->second, match->second)) { + match = it; + } + ++matches; + } + } + if (matches == 0 && Common::SteamPadsInUse()) { + // A new Steam handle on return: no reservation is provably its own, the lowest + // is taken + for (auto it = pad_slots.begin(); it != pad_slots.end(); ++it) { + if (live.find(it->first) == live.end() && + (match == pad_slots.end() || before(it->second, match->second))) { + match = it; + } + } + if (match != pad_slots.end()) { + matches = 2; + } + } + if (matches > 1) { + int lowest_free = -1; + for (int slot = 0; slot < ConsolePlayers; ++slot) { + if (used.find(slot) == used.end()) { + lowest_free = slot; + break; + } + } + if (lowest_free < 0 || match->second < lowest_free || + disconnect_paused.contains(match->second)) { + LOG_INFO(Frontend, + "console-mode: ambiguous reclaim inst={} ident={} ({} candidates) " + "takes the lowest, slot {} from dead inst={}", + id, ident, matches, match->second, match->first); + matches = 1; + } else { + match = pad_slots.end(); + matches = 0; + } + } + if (matches == 1) { + // A guess (several away): marked (?) until its address proves it. The player + // stays promised to its controller's address + const bool guessed = away > 1; + LOG_INFO(Frontend, + "console-mode: reclaim inst={} ident={} takes slot {} from dead inst={}{}", + id, ident, match->second, match->first, guessed ? " (a guess)" : ""); + const auto from = pad_origin.find(match->first); + pad_origin[id] = from != pad_origin.end() ? from->second : match->second; + if (guessed) { + pad_guessed.insert(id); + if (const auto held = inst_address.find(match->first); + held != inst_address.end()) { + address_claims[held->second] = {match->second, pad_origin[id], now_steady}; + } + } + // Its own set comes back at once (kept by the address, learned only later) + if (const auto held = inst_address.find(match->first); + !guessed && held != inst_address.end() && !pad_sets.contains(ident)) { + if (const auto set = pad_sets.find("bt:" + held->second); + set != pad_sets.end()) { + pad_sets[ident] = set->second; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} carries its own P{} set back", + id, set->second + 1); + } + } + } + pad_slots[id] = match->second; + pad_identity[id] = ident; + pad_identity.erase(match->first); + pad_slots.erase(match); + changed = true; + continue; + } + // Claim the lowest free slot + bool assigned = false; + for (int slot = 0; slot < ConsolePlayers && !assigned; ++slot) { + if (used.find(slot) == used.end()) { + LOG_INFO(Frontend, "console-mode: claim inst={} ident={} -> slot {}", id, ident, + slot); + pad_slots[id] = slot; + pad_identity[id] = ident; + // (A known Bluetooth controller keeps the player it started as) + const auto first = address_origin.find(address); + pad_origin[id] = first != address_origin.end() ? first->second : slot; + used.insert(slot); + assigned = true; + changed = true; + } + } + if (!assigned) { + // Retire reservations and retry, never a live pad's slot + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + const bool live_pad = live.find(it->first) != live.end(); + if (!live_pad) { + pad_identity.erase(it->first); + it = pad_slots.erase(it); + } else { + ++it; + } + } + used.clear(); + for (const auto& [k, slot] : pad_slots) { + used.insert(slot); + } + for (int slot = 0; slot < ConsolePlayers; ++slot) { + if (used.find(slot) == used.end()) { + LOG_INFO(Frontend, "console-mode: claim inst={} ident={} -> slot {}", id, + ident, slot); + pad_slots[id] = slot; + pad_identity[id] = ident; + pad_origin[id] = slot; + used.insert(slot); + changed = true; + break; + } + } + } + } + } + + // What is kept per connection lasts while the pad is live or its player is held for it + { + const auto kept = [&](u32 id) { return live.contains(id) || pad_slots.contains(id); }; + std::erase_if(inst_address, [&](const auto& e) { return !kept(e.first); }); + std::erase_if(pad_origin, [&](const auto& e) { return !kept(e.first); }); + std::erase_if(pad_guessed, [&](u32 id) { return !kept(id); }); + std::erase_if(claim_wait_since, [&](const auto& e) { + return !live.contains(e.first) || pad_slots.contains(e.first); + }); + } + + // A guessed controller whose address became known stays, or moves to its real player + for (auto it = pad_guessed.begin(); it != pad_guessed.end();) { + const u32 id = *it; + const auto known = pad_bluetooth.find(BluetoothKey(id)); + const auto at = pad_slots.find(id); + if (!live.contains(id) || at == pad_slots.end() || known == pad_bluetooth.end() || + known->second.empty()) { + ++it; + continue; + } + const std::string& address = known->second; + const auto own = std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + const auto held = inst_address.find(e.first); + return !live.contains(e.first) && held != inst_address.end() && held->second == address; + }); + if (own != pad_slots.end()) { + LOG_INFO(Frontend, "console-mode: {} was guessed as P{}: moved back to P{}", address, + at->second + 1, own->second + 1); + const auto from = pad_origin.find(own->first); + pad_origin[id] = from != pad_origin.end() ? from->second : own->second; + at->second = own->second; + pad_identity.erase(own->first); + pad_slots.erase(own); + changed = true; + } else { + address_claims.erase(address); // (the guess was right) + } + it = pad_guessed.erase(it); + } + + // Swaps from the settings or the overlay: the pads trade slots, for this session + { + std::vector> requests; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + requests.swap(state.swap_requests); + } + for (const auto& [a, b] : requests) { + if (a < 0 || b < 0 || a >= ConsolePlayers || b >= ConsolePlayers || a == b) { + continue; + } + u32 pad_a = 0; + u32 pad_b = 0; + for (const auto& [id, slot] : pad_slots) { + if (live.find(id) == live.end()) { + continue; + } + if (slot == a) { + pad_a = id; + } + if (slot == b) { + pad_b = id; + } + } + if (!pad_a && !pad_b) { + continue; + } + // Everything on the two players changes places, held players too (else a returning + // controller could find its player taken) + for (auto& [id, slot] : pad_slots) { + if (slot == a) { + slot = b; + } else if (slot == b) { + slot = a; + } + } + // (A swap is the user's say: a guess is settled) + pad_guessed.erase(pad_a); + pad_guessed.erase(pad_b); + LOG_INFO(Frontend, "console-mode: swap P{} <-> P{} (inst={} inst={})", a + 1, b + 1, + pad_a, pad_b); + changed = true; + } + } + + // Profiles picked in the Controls page go on the set the player's controller carries + { + std::vector picks; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + picks.swap(state.profile_requests); + } + std::optional input_profiles; + for (const auto& [player, name, restore] : picks) { + if (!console_profile || player < 0 || player >= static_cast(players.size())) { + continue; + } + const int set = slot_set[player]; + const bool game_set = game_profiles && !(*game_profiles)[set].profile_name.empty(); + auto& target = SessionSet(set); + if (name.empty() && restore) { + if (const auto before = set_before_profile.find(set); + before != set_before_profile.end()) { + target.buttons = before->second.buttons; + target.analogs = before->second.analogs; + target.motions = before->second.motions; + set_before_profile.erase(before); + } + } + if (!name.empty()) { + set_before_profile.try_emplace(set, target); + if (!input_profiles) { + input_profiles.emplace(); + } + const auto read = input_profiles->PeekProfile(name, player); + if (!read) { + LOG_WARNING(Frontend, "console-mode: input profile \"{}\" not found", name); + continue; + } + ApplyProfileMappings(target, *read); + } + target.profile_name = name; + // The seat remembers it: the next session's P{player} starts with it + SetSeatProfile(player, name); + LOG_INFO(Frontend, "console-mode: P{} input profile \"{}\" on the P{} set{}", + player + 1, name, set + 1, game_set ? " (the game's)" : ""); + changed = true; + } + } + + // Rumble requested from the Controllers tab: a short pulse on the player's controller + { + std::vector rumbles; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + rumbles.swap(state.rumble_requests); + } + for (const int player : rumbles) { + for (const auto& [id, slot] : pad_slots) { + if (slot != player || live.find(id) == live.end()) { + continue; + } + // Sent twice: a single request through Steam's virtual pads is sometimes lost + const u32 pad = id; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: rumble P{} inst={}", player + 1, pad); + } + PadRumble(pad, 0xC000, 300); + QTimer::singleShot(150, this, [pad] { PadRumble(pad, 0xC000, 200); }); + break; + } + } + } + + // Each live pad's player gets the pad's binding set, pointed at the pad (bindings address + // pads by guid and port, which follow attach order, so the slot map alone isn't the + // routing). The handheld's own pad carries player one's set; an external pad its own set, + // without the handheld's non-SDL motion, plus its SDL gyro when the set has no motion. + slot_set = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + // Use Any Controller as Joy-Cons: a game that doesn't take Pro Controllers gets Dual + // Joy-Cons, else a single one held sideways. Only a Pro Controller player is changed (and + // put back), never a type set by hand. + { + std::optional supported; + bool horizontal = true; + if (QtCommon::system && QtCommon::system->IsPoweredOn() && + Settings::values.console_joycon_auto.GetValue()) { + supported = QtCommon::system->HIDCore().GetSupportedStyleTag(); + horizontal = QtCommon::system->HIDCore().IsJoyHoldHorizontal(); + } + for (int i = 0; i < static_cast(players.size()) && i < 8; ++i) { + auto& player = players[i]; + auto& from = joycon_auto_from[static_cast(i)]; + auto& set = joycon_auto_set[static_cast(i)]; + if (set && player.controller_type != *set) { + from.reset(); // (changed by hand since) + set.reset(); + } + const auto base = from ? *from : player.controller_type; + if (base != Settings::ControllerType::ProController) { + continue; + } + auto want = base; + if (supported && player.connected && supported->raw != Core::HID::NpadStyleSet::None && + !supported->fullkey) { + if (supported->joycon_dual) { + want = Settings::ControllerType::DualJoyconDetached; + } else if (supported->joycon_right) { + want = Settings::ControllerType::RightJoycon; + } else if (supported->joycon_left) { + want = Settings::ControllerType::LeftJoycon; + } + } + // A single Joy-Con is held as the game holds it: sideways or upright + const bool single = want == Settings::ControllerType::RightJoycon || + want == Settings::ControllerType::LeftJoycon; + const bool sideways = single && horizontal; + auto& was_sideways = joycon_auto_sideways[static_cast(i)]; + const auto say = [&](const QString& note) { + if (player.connected) { + controller_hints.push_back( + {QString{}, i, true, QDateTime::currentMSecsSinceEpoch(), false, note}); + } + }; + if (want == player.controller_type) { + // (The game turned its Joy-Cons: the bindings follow on the routing pass) + if (set && single && was_sideways != sideways) { + was_sideways = sideways; + LOG_INFO(Frontend, "console-mode: P{} now holds its Joy-Con {}", i + 1, + sideways ? "sideways" : "upright"); + say(sideways ? tr("P%1 plays as a sideways Joy-Con").arg(i + 1) + : tr("P%1 plays as an upright Joy-Con").arg(i + 1)); + } + continue; + } + player.controller_type = want; + changed = true; + if (want == base) { + from.reset(); + set.reset(); + was_sideways = false; + LOG_INFO(Frontend, "console-mode: P{} back to a Pro Controller", i + 1); + say(tr("P%1 plays as a Pro Controller again").arg(i + 1)); + continue; + } + from = base; + set = want; + was_sideways = sideways; + LOG_INFO(Frontend, "console-mode: P{} plays as {} (the game takes no Pro Controller)", + i + 1, + !single ? "Dual Joy-Cons" + : sideways ? "a sideways Joy-Con" + : "an upright Joy-Con"); + say(!single ? tr("P%1 plays as Dual Joy-Cons").arg(i + 1) + : sideways ? tr("P%1 plays as a sideways Joy-Con").arg(i + 1) + : tr("P%1 plays as an upright Joy-Con").arg(i + 1)); + } + // (For the player tabs: a single Joy-Con's mapping is the engine's) + std::array held{}; + for (std::size_t i = 0; i < held.size(); ++i) { + const auto& set = joycon_auto_set[i]; + if (set && (*set == Settings::ControllerType::RightJoycon || + *set == Settings::ControllerType::LeftJoycon)) { + held[i] = joycon_auto_sideways[i] ? 1 : 2; + } + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.joycon_held = held; + } + + if (input_subsystem && console_profile) { + const auto profile = ConsoleBindingSets(); + const auto is_handheld_motion = [&profile](const std::string& raw) { + if (raw.empty() || Common::ParamPackage{raw}.Get("engine", "") == "sdl") { + return false; + } + return std::find(profile[0].motions.begin(), profile[0].motions.end(), raw) != + profile[0].motions.end(); + }; + for (const auto& [id, ident] : live) { + const auto it = pad_slots.find(id); + if (it == pad_slots.end() || it->second < 0 || + it->second >= static_cast(players.size())) { + continue; + } + const auto binding = input_subsystem->GetSDLBinding(id); + if (!binding) { + continue; + } + const int slot = it->second; + const bool is_builtin = builtin_valid && id == builtin_match_id; + // The set's key: a Bluetooth controller's address (so its set follows it through + // reconnects), else its identity; a set made before the address is known moves to it + const auto address = inst_address.find(id); + const std::string set_key = + address != inst_address.end() ? "bt:" + address->second : ident; + if (set_key != ident && !pad_sets.contains(set_key) && pad_sets.contains(ident)) { + pad_sets[set_key] = pad_sets[ident]; + pad_sets.erase(ident); + } else if (set_key != ident && pad_sets.contains(set_key) && + pad_sets.contains(ident) && pad_sets[ident] == pad_sets[set_key]) { + // (Lent to it on a reclaim, until its address was known) + pad_sets.erase(ident); + } + // A controller keeps the set it first got: its slot's, else the first set nobody has + if (!is_builtin && !pad_sets.contains(set_key)) { + std::set taken; + for (const auto& [other, set] : pad_sets) { + taken.insert(set); + } + int set = slot; + for (int k = 0; taken.contains(set) && k < static_cast(players.size()); ++k) { + set = k; + } + if (taken.contains(set)) { + set = slot; + } + pad_sets[set_key] = set; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} carries the P{} set", id, set + 1); + } + } + // A set with no controller mappings gives the pad its default mapping, below + const auto has_sdl = [](const Settings::PlayerInput& p) { + return std::any_of(p.buttons.begin(), p.buttons.end(), [](const std::string& b) { + return !b.empty() && Common::ParamPackage{b}.Get("engine", "") == "sdl"; + }); + }; + const int set_index = is_builtin ? 0 : pad_sets[set_key]; + slot_set[slot] = set_index; + Settings::PlayerInput want = players[slot]; + // A set holding only a gone controller's default mapping is rebuilt for this one (an + // Xbox pad's has no gyro or back buttons). Not while that controller is connected: + // identical ones without an address share a set. + const u32 defaults_of = set_default_of[static_cast(set_index)]; + const bool others_defaults = defaults_of != 0 && defaults_of != id && + !live.contains(defaults_of) && + profile[set_index].profile_name.empty(); + if (others_defaults && Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: P{} set holds inst={}'s defaults: rebuilt for inst={}", + set_index + 1, defaults_of, id); + } + if (has_sdl(profile[set_index]) && !others_defaults) { + const auto& own = profile[set_index]; + want.buttons = own.buttons; + want.analogs = own.analogs; + want.motions = own.motions; + want.profile_name = own.profile_name; + RouteSdlBindings(want, binding->first, binding->second); + } else { + // No controller mappings: the controller's default mapping, for this session + Common::ParamPackage device; + device.Set("engine", "sdl"); + device.Set("guid", binding->first); + device.Set("port", binding->second); + // A DSU server's motion stays; only SDL motion is the device's to fill + const auto kept_motions = profile[set_index].motions; + const bool motion_kept = + std::any_of(kept_motions.begin(), kept_motions.end(), [](const auto& m) { + return !m.empty() && + Common::ParamPackage{m}.Get("engine", "") == "cemuhookudp"; + }); + want.buttons.fill({}); + want.analogs.fill({}); + want.motions.fill({}); + for (const auto& [index, param] : + input_subsystem->GetButtonMappingForDevice(device)) { + want.buttons[index] = param.Serialize(); + } + for (const auto& [index, param] : + input_subsystem->GetAnalogMappingForDevice(device)) { + want.analogs[index] = param.Serialize(); + } + if (motion_kept) { + want.motions = kept_motions; + } else { + for (const auto& [index, param] : + input_subsystem->GetMotionMappingForDevice(device)) { + want.motions[index] = param.Serialize(); + } + } + want.profile_name.clear(); + // Kept for the session: rebuilt every tick, it would differ once a game polls the + // sensors and reload the input four times a second + auto& own = SessionSet(set_index); + own.buttons = want.buttons; + own.analogs = want.analogs; + own.motions = want.motions; + set_default_of[static_cast(set_index)] = id; + static std::set defaulted; + if (defaulted.insert(id).second) { + LOG_INFO(Frontend, + "console-mode: P{} set has no controller mappings: inst={} " + "gets its default mapping{}", + set_index + 1, id, motion_kept ? " (its set's motion kept)" : ""); + } + } + // Under Steam the virtual pad has no motion: take the paired real device's (the Deck, + // or the only one of a model) + u32 motion_source = 0; + if (Common::SteamPadsInUse()) { + const Uint16 vendor = is_builtin ? 0x28DE : PadVendor(id); + const Uint16 product = is_builtin ? 0x1205 : PadProduct(id); + const auto same_model = [vendor, product](u32 other) { + return PadVendor(other) == vendor && + PadProduct(other) == product; + }; + const auto real = + std::count_if(motion_devices.begin(), motion_devices.end(), same_model); + const auto virtuals = std::count_if( + live.begin(), live.end(), [&](const auto& e) { return same_model(e.first); }); + if (real == 1 && (is_builtin || virtuals == 1)) { + const u32 device = + *std::find_if(motion_devices.begin(), motion_devices.end(), same_model); + if (PadHasGyro(device)) { + motion_source = device; + } + } + } + if (motion_source != overlay_motion_source[slot]) { + overlay_motion_source[slot] = motion_source; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: P{} motion from real device inst={}", + slot + 1, motion_source); + } + } + if (motion_source) { + if (const auto source = input_subsystem->GetSDLBinding(motion_source)) { + want.motions.fill(SdlMotionParam(source->first, source->second)); + } + } else { + if (!is_builtin) { + for (std::string& motion : want.motions) { + if (is_handheld_motion(motion)) { + motion.clear(); + } + } + } + // (The handheld's own pad too: its set may have been an external's) + const bool no_motion = std::all_of(want.motions.begin(), want.motions.end(), + [](const std::string& m) { return m.empty(); }); + if (no_motion && PadHasGyro(id)) { + want.motions.fill(SdlMotionParam(binding->first, binding->second)); + } + } + auto& player = players[slot]; + // A single Joy-Con (set above): the buttons and stick arranged to fit, sideways or + // upright as the game holds it + const bool single = + joycon_auto_set[static_cast(slot)] && + (player.controller_type == Settings::ControllerType::RightJoycon || + player.controller_type == Settings::ControllerType::LeftJoycon); + const bool right_joycon = + player.controller_type == Settings::ControllerType::RightJoycon; + if (single && joycon_auto_sideways[static_cast(slot)]) { + BindSidewaysJoyCon(want, right_joycon); + } else { + if (Settings::values.console_button_labels.GetValue() && !IsNintendoPad(id)) { + // Button Layout by label: the controller's A is the Switch's A + using namespace Settings::NativeButton; + std::swap(want.buttons[A], want.buttons[B]); + std::swap(want.buttons[X], want.buttons[Y]); + } + if (single) { + BindUprightJoyCon(want, right_joycon); + } + } + player.profile_name = want.profile_name; + if (player.buttons != want.buttons || player.analogs != want.analogs || + player.motions != want.motions) { + player.buttons = want.buttons; + player.analogs = want.analogs; + player.motions = want.motions; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: P{} <- inst={} guid={} port={} set=P{}{}", + slot + 1, id, binding->first, binding->second, set_index + 1, + is_builtin ? " (handheld)" : ""); + } + changed = true; + } + } + } + + // Only the players with a live pad are connected + for (int i = 0; i < static_cast(players.size()); ++i) { + bool want = false; + for (const auto& [id, ident] : live) { + const auto it = pad_slots.find(id); + if (it != pad_slots.end() && it->second == i) { + want = true; + break; + } + } + if (players[i].connected != want) { + players[i].connected = want; + changed = true; + } + } + + // A game running with no controller is told so once (keyboard and mouse are Manual's) + { + const bool running = QtCommon::system && QtCommon::system->IsPoweredOn(); + const bool any_player = std::any_of(players.begin(), players.end(), + [](const auto& player) { return player.connected; }); + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + if (!running) { + no_controller_told = false; + no_controller_since = 0; + } else if (any_player) { + no_controller_since = 0; + } else if (!no_controller_told) { + if (no_controller_since == 0) { + no_controller_since = now_ms; + } else if (now_ms - no_controller_since >= 3000) { + no_controller_told = true; + controller_hints.push_back( + {QString{}, 0, false, now_ms, false, + tr("No controller: connect one, or use Manual for keyboard and mouse")}); + LOG_INFO(Frontend, "console-mode: a game runs with no controller"); + } + } + } + + if (changed) { + std::string out; + for (int i = 0; i < static_cast(players.size()); ++i) { + out += players[i].connected ? "1" : "0"; + } + + static std::string last_written; + if (out != last_written) { + for (size_t i = 0; i < out.size(); ++i) { + if (last_written.size() != out.size() || last_written[i] != out[i]) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: player {} {}", i + 1, + out[i] == '1' ? "activated" : "deactivated"); + } + } + } + last_written = out; + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: players written={}", out); + } + } + + // A live pad holds at most one slot, in range, never shared: if this logs, it's a bug + { + std::set seen_slots; + for (const auto& [id, slot] : pad_slots) { + const bool in_range = slot >= 0 && slot < ConsolePlayers; + const bool unique = in_range && seen_slots.insert(slot).second; + if (live.find(id) != live.end() && (!in_range || !unique)) { + LOG_ERROR(Frontend, + "console-mode: INVARIANT BROKEN inst={} slot={} in_range={} " + "unique={} - two pads fighting over one player or a corrupt slot", + id, slot, in_range, unique); + } + } + } + + // Pause When a Controller Disconnects: a player whose controller left and didn't come back + // within 3 seconds pauses the game; it resumes when they all have a controller again, or + // with + on another controller (those players are then let go) + { + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + for (int i = 0; i < static_cast(players.size()) && i < 10; ++i) { + const bool held = std::any_of(pad_slots.begin(), pad_slots.end(), + [&](const auto& e) { return e.second == i; }); + const bool here = std::any_of(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == i && live.contains(e.first); + }); + auto& since = waiting_since[static_cast(i)]; + since = held && !here ? (since ? since : now_ms) : 0; + if (since == 0) { + pause_done.erase(i); // (a new wait may pause again) + } + } + // Who holds each player now (0 nobody), and whether that is an external controller + std::array holder{}; + for (const auto& [id, slot] : pad_slots) { + if (slot >= 0 && slot < 10 && live.contains(id)) { + holder[static_cast(slot)] = id; + } + } + const auto on_handheld = [&](u32 id) { + return id != 0 && builtin_valid && id == builtin_match_id; + }; + const auto was_external = held_by_external; + for (std::size_t i = 0; i < holder.size(); ++i) { + held_by_external[i] = holder[i] != 0 && !on_handheld(holder[i]); + } + const bool handheld_took_over = + !std::exchange(handheld_was_playing, + std::any_of(holder.begin(), holder.end(), on_handheld)) && + handheld_was_playing; + const bool running = + emulation_running && QtCommon::emu_thread && QtCommon::emu_thread->IsRunning(); + // (Pausing takes a moment: running again counts as a resume by hand only once seen paused) + if (!disconnect_paused.empty() && !running && !disconnect_pause_pending) { + disconnect_pause_seen = true; + } + if (!disconnect_paused.empty() && running && disconnect_pause_seen) { + disconnect_paused.clear(); // (resumed by hand) + disconnect_continue_held = false; + disconnect_pause_seen = false; + handover_paused = -1; + handover_pressed = false; + } + // The overlay is drawn into frames: the notice first, the pause a moment later + const auto pause_with_notice = [&](int player, const QString& notice) { + controller_hints.push_back({QString{}, player, false, now_ms, false, notice, true}); + RenderControllerOverlay(); + disconnect_pause_pending = true; + disconnect_pause_seen = false; + QTimer::singleShot(150, this, [this] { + disconnect_pause_pending = false; + if (!disconnect_paused.empty() && emulation_running && QtCommon::emu_thread && + QtCommon::emu_thread->IsRunning()) { + OnPauseGame(); + } + }); + }; + if (Settings::values.console_pause_disconnect.GetValue() && running && + !disconnect_pause_pending) { + // A disconnect that is surely real (Turn Off, or BlueZ says disconnected) pauses at + // once; any other waits 3 seconds, as Steam briefly drops its pads now and then + const auto surely_gone = [&](int player) { + if (now_ms - turned_off_at[static_cast(player)] < 5000) { + return true; + } +#ifdef __linux__ + // (BlueZ is asked without waiting, at most every half second; an answer counts if + // it came after the controller left) + for (const auto& [id, slot] : pad_slots) { + if (slot != player || live.contains(id)) { + continue; + } + const auto address = inst_address.find(id); + if (address == inst_address.end()) { + continue; + } + auto& known = bluez_connected[address->second]; + if (known.asked_ms == 0 || now_ms - known.asked_ms >= 500) { + known.asked_ms = now_ms; + AskBluezConnected(address->second, this, + [this, key = address->second](bool on) { + auto& entry = bluez_connected[key]; + entry.connected = on; + entry.answered_ms = + QDateTime::currentMSecsSinceEpoch(); + }); + } + if (!known.connected && known.answered_ms >= + waiting_since[static_cast(player)]) { + return true; + } + } +#endif + return false; + }; + std::set gone; + for (int i = 0; i < 10; ++i) { + // (Each wait pauses once: resumed by hand, the game keeps running) + const qint64 since = waiting_since[static_cast(i)]; + if (since && !pause_done.contains(i) && + (now_ms - since >= 3000 || surely_gone(i))) { + gone.insert(i); + } + } + // A handover: the last external controller left (whichever player it held) and the + // handheld's controls took over. Paused until someone picks the handheld up or + // reconnects. + int handover = -1; + const bool externals_left = + std::any_of(was_external.begin(), was_external.end(), [](bool b) { return b; }) && + std::none_of(held_by_external.begin(), held_by_external.end(), + [](bool b) { return b; }); + if (gone.empty() && disconnect_paused.empty() && externals_left && + handheld_took_over) { + for (std::size_t i = 0; i < holder.size() && handover < 0; ++i) { + if (on_handheld(holder[i])) { + handover = static_cast(i); + } + } + } + if (handover >= 0) { + disconnect_paused = {handover}; + handover_paused = handover; + handover_pressed = PadInputHeld(holder[static_cast(handover)]); + LOG_INFO(Frontend, "console-mode: paused, P{} handed over to the built-in controls", + handover + 1); + pause_with_notice(handover, + tr("P%1 is on the built-in controls now: press a button on " + "them, or reconnect a controller, to go on") + .arg(handover + 1)); + } else if (!gone.empty()) { + disconnect_paused = gone; + pause_done.insert(gone.begin(), gone.end()); + LOG_INFO(Frontend, "console-mode: paused, P{}'s controller disconnected", + *gone.begin() + 1); + QStringList names; + for (const int player : gone) { + names.push_back(QStringLiteral("P%1").arg(player + 1)); + } + const QString who = names.join(QStringLiteral(", ")); + // (Said once: the pause's notice takes the place of their "left" hints) + std::erase_if(controller_hints, [&](const auto& hint) { + return !hint.joined && !hint.pause_notice && gone.contains(hint.player); + }); + // (The leave is stamped a moment later, when the overlay draws it: a margin) + for (const int player : gone) { + auto& said = hinted_leave[static_cast(player)]; + said = (std::max)(said, now_ms + 2'000); + } + // (With no other controller left, reconnecting is the only way on) + pause_with_notice(*gone.begin(), + live.empty() + ? tr("%1 disconnected: reconnect to go on").arg(who) + : tr("%1 disconnected: reconnect, or press + on another " + "controller to go on without %1") + .arg(who)); + } + } else if (!disconnect_paused.empty() && !running && emulation_running && + !disconnect_pause_pending) { + // Paused for a disconnect when the last external controller left too: the handheld's + // controls took a player, and the pause becomes a handover (not a resume) + if (handover_paused < 0 && handheld_took_over && + std::none_of(held_by_external.begin(), held_by_external.end(), + [](bool b) { return b; })) { + for (std::size_t i = 0; i < holder.size(); ++i) { + if (on_handheld(holder[i])) { + handover_paused = static_cast(i); + handover_pressed = PadInputHeld(holder[i]); + disconnect_paused = {handover_paused}; + LOG_INFO(Frontend, + "console-mode: pause handed over to the built-in controls (P{})", + i + 1); + // (The disconnect's notice gives way; running a moment, so the new + // one is drawn into a frame) + std::erase_if(controller_hints, + [](const auto& hint) { return hint.pause_notice; }); + OnStartGame(); + pause_with_notice(handover_paused, + tr("P%1 is on the built-in controls now: press a button " + "on them, or reconnect a controller, to go on") + .arg(handover_paused + 1)); + break; + } + } + } + bool all_back = + std::all_of(disconnect_paused.begin(), disconnect_paused.end(), [&](int player) { + return std::any_of(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == player && live.contains(e.first); + }); + }); + // A handover goes on when an external controller takes the player back, or at a + // press on the handheld (once let go, so the game never sees it) + if (handover_paused >= 0) { + const u32 at = holder[static_cast(handover_paused)]; + all_back = at != 0 && !on_handheld(at); + if (on_handheld(at)) { + if (PadInputHeld(at)) { + handover_pressed = true; + } else if (std::exchange(handover_pressed, false)) { + all_back = true; + } + } + } + // (+ counts once let go, so the game never sees it on resuming) + const bool plus = std::any_of(live.begin(), live.end(), + [](const auto& e) { return PadStartHeld(e.first); }); + if (all_back) { + LOG_INFO(Frontend, "console-mode: resumed, the controllers are back"); + disconnect_paused.clear(); + disconnect_continue_held = false; + handover_paused = -1; + OnStartGame(); + } else if (plus) { + disconnect_continue_held = true; + } else if (std::exchange(disconnect_continue_held, false)) { + // On without them: their players are no longer held for their controllers + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + if (disconnect_paused.contains(it->second) && !live.contains(it->first)) { + pad_identity.erase(it->first); + it = pad_slots.erase(it); + } else { + ++it; + } + } + QStringList names; + for (const int player : disconnect_paused) { + names.push_back(QStringLiteral("P%1").arg(player + 1)); + } + LOG_INFO(Frontend, "console-mode: resumed without {}", + names.join(QStringLiteral(", ")).toStdString()); + controller_hints.push_back({QString{}, *disconnect_paused.begin(), false, now_ms, + false, + tr("Going on without %1") + .arg(names.join(QStringLiteral(", ")))}); + disconnect_paused.clear(); + handover_paused = -1; + handover_pressed = false; + OnStartGame(); + } + } + } + + // Player Lights: each controller shows its player in the player's colour (not under Steam, + // which sets them) + if (Settings::values.console_player_lights.GetValue() && + !(Common::RunningUnderSteam() && Common::GetControllerConsoleState().steam_pads_present)) { + std::erase_if(lights_player, [&](const auto& e) { return !live.contains(e.first); }); + for (const auto& [id, slot] : pad_slots) { + if (!live.contains(id) || IsSteamVirtualPad(id)) { + continue; + } + auto& shown = lights_player[id]; + if (shown == slot + 1) { + continue; + } + shown = slot + 1; + const JoystickApiLock lock; + if (SDL_Gamepad* pad = SDL_OpenGamepad(id)) { + SDL_SetGamepadPlayerIndex(pad, slot); + const QColor color = ConfigurePlayerAssignment::PlayerColor(slot); + SDL_SetGamepadLED(pad, static_cast(color.red()), + static_cast(color.green()), + static_cast(color.blue())); + SDL_CloseGamepad(pad); + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} lights show P{}", id, slot + 1); + } + } + } + + // A player whose controller type the game doesn't support: logged once per change + if (QtCommon::system && QtCommon::system->IsPoweredOn()) { + auto& hid = QtCommon::system->HIDCore(); + const auto supported = hid.GetSupportedStyleTag(); + for (int i = 0; i < static_cast(players.size()) && i < 8; ++i) { + // (A handheld P1 is the handheld's own controller, as the game sees it) + const auto* controller = + i == 0 && players[0].controller_type == Settings::ControllerType::Handheld + ? hid.GetEmulatedController(Core::HID::NpadIdType::Handheld) + : hid.GetEmulatedControllerByIndex(static_cast(i)); + if (!controller || !players[i].connected) { + refused_logged[static_cast(i)].clear(); + continue; + } + const auto style = controller->GetNpadStyleIndex(); + const bool taken = StyleSupported(style, supported); + const std::string what = taken ? std::string{} + : fmt::format("{}/{:#x}", static_cast(style), + static_cast(supported.raw)); + auto& logged = refused_logged[static_cast(i)]; + if (what != logged) { + if (!taken) { + LOG_WARNING(Frontend, + "console-mode: P{} is a {} controller, which the game doesn't " + "support (supported styles {:#x})", + i + 1, StyleName(style), static_cast(supported.raw)); + } + logged = what; + } + } + } + + // The controller overlay is drawn into the presented frame (as an overlay panel): no Qt + // window can composite over the game under gamescope + { + // One row per player: green when its pad connects, red when it leaves, then gone. The + // name drops InputCommon's port number, which shifts as Steam moves controllers. + constexpr qint64 flash_ms = 1500; + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + static const QRegularExpression port_number{QStringLiteral(" \\d+(?=( \\(Steam\\))?$)")}; + static const QRegularExpression steam_suffix{QStringLiteral(" \\(Steam\\)$")}; + // A controller's name, or the one given to it with its short name ("John (Xbox)") + const auto name_key = [&](u32 pad) { + if (builtin_valid && pad == builtin_match_id) { + return std::string{"handheld"}; + } + if (const auto address = inst_address.find(pad); address != inst_address.end()) { + return "bt:" + address->second; + } + return "id:" + live[pad]; + }; + // Names given from the Controllers tab belong to the controller + { + std::vector> renames; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + renames.swap(state.rename_requests); + } + for (const auto& [player, given] : renames) { + const auto held = + std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == player && live.contains(e.first); + }); + if (held == pad_slots.end()) { + continue; + } + const std::string key = name_key(held->first); + if (given.empty()) { + controller_names.erase(key); + } else { + controller_names[key] = given; + } + LOG_INFO(Frontend, "console-mode: P{} named \"{}\"", player + 1, given); + } + } + std::array custom_names{}; + overlay_rows.clear(); + std::erase_if(battery_read, [&](const auto& e) { return !live.contains(e.first); }); + // (A controller that leaves starts afresh when it connects again) + const auto left = [&](const std::string& key) { + return std::none_of(live.begin(), live.end(), [&](const auto& pad) { + const auto address = inst_address.find(pad.first); + return address != inst_address.end() && address->second == key; + }); + }; + std::erase_if(bluez_first, [&](const auto& e) { return left(e.first); }); + std::erase_if(bluez_battery, [&](const auto& e) { return left(e.first); }); + std::erase_if(battery_whose, [&](const auto& e) { return !live.contains(e.first); }); + std::vector tab_rows; + // Flashes follow the controller behind a player (in handheld mode P1 stays connected + // while it changes): green when one takes a player, red when one leaves + for (int i = 0; i < static_cast(players.size()); ++i) { + u32 pad = 0; + for (const auto& [id, slot] : pad_slots) { + if (slot == i && live.find(id) != live.end()) { + pad = id; + break; + } + } + auto& since = overlay_row_since[i]; + auto& gone = overlay_row_gone[i]; + auto& flash = overlay_flash[i]; + auto& flash_since = overlay_flash_since[i]; + auto& green_at = overlay_green_at[i]; + if (players[i].connected && pad) { + const auto [display, raw] = label_of(pad, std::string{}); + QString name = display.isEmpty() ? raw : display; + name.remove(port_number); + name.remove(steam_suffix); + if (name == QStringLiteral("SteamDeck")) { + name = QStringLiteral("Steam Deck"); // (Steam's name for the Deck's own) + } + // (The name of the controller that had the player, should it have just left) + const QString previous = overlay_row_name[i]; + // (A name given before the address was known moves to it, like the bindings) + if (const std::string key = name_key(pad); key.starts_with("bt:")) { + const auto earlier = controller_names.find("id:" + live[pad]); + if (earlier != controller_names.end()) { + controller_names[key] = earlier->second; + controller_names.erase(earlier); + } + } + const auto given = controller_names.find(name_key(pad)); + // Handheld Console: the controller that took P1 from the handheld's controls + // carries the handheld's name when it has none (the same person, docked) + const auto handed = controller_names.find("handheld"); + const bool inherits = given == controller_names.end() && i == 0 && handheld && + builtin_valid && pad != builtin_match_id && + !pad_slots.contains(builtin_match_id) && + handed != controller_names.end(); + if (given != controller_names.end()) { + custom_names[static_cast(i)] = given->second; + name = QStringLiteral("%1 (%2)").arg(QString::fromStdString(given->second), + ShortControllerName(pad, name)); + } else if (inherits) { + name = QStringLiteral("%1 (%2)").arg(QString::fromStdString(handed->second), + ShortControllerName(pad, name)); + } + overlay_row_name[i] = name; + if (since == 0 || gone != 0) { + since = now_ms; + gone = 0; + flash = 1; + flash_since = now_ms; + green_at = 0; + } else if (live[pad] != overlay_row_ident[i]) { + const bool old_left = + std::none_of(live.begin(), live.end(), [&](const auto& entry) { + return entry.second == overlay_row_ident[i]; + }); + if (old_left) { + flash = 2; + flash_since = now_ms; + green_at = now_ms + flash_ms / 2; + overlay_left_name[i] = previous; + } else { + flash = 1; + flash_since = now_ms; + green_at = 0; + } + } + overlay_row_ident[i] = live[pad]; + // The input history's key: the controller's own (its address, else its + // connection too when identical controllers share an identity) + std::string history_key = name_key(pad); + if (history_key.starts_with("id:") && + std::any_of(live.begin(), live.end(), [&](const auto& entry) { + return entry.first != pad && entry.second == live[pad]; + })) { + history_key += fmt::format("#{}", pad); + } + overlay_row_history[i] = std::move(history_key); + } else if (since != 0 && gone == 0) { + gone = now_ms; + flash = 2; + flash_since = now_ms; + green_at = 0; + overlay_left_name[i] = overlay_row_name[i]; + } + if (since == 0) { + continue; + } + if (gone != 0 && now_ms - gone > flash_ms) { + since = 0; + gone = 0; + flash = 0; + overlay_row_ident[i].clear(); + overlay_row_history[i].clear(); + continue; + } + if (green_at != 0 && now_ms >= green_at) { + flash = 1; + flash_since = green_at; + green_at = 0; + } + if (flash != 0 && gone == 0 && green_at == 0 && now_ms - flash_since >= flash_ms) { + flash = 0; + } + // The controller's battery: by its Bluetooth address, the system's for the handheld's + // own controls, else SDL's (never a Steam virtual pad's). Read every 10 seconds, more + // often while a new controller's value settles. + Battery battery; + if (pad) { + auto& read = battery_read[pad]; + // Whose reading it is: Steam reshuffles its pads, so another controller is read + // afresh + const bool own = builtin_valid && pad == builtin_match_id; + const std::string whose = (own ? "handheld:" : "") + BluetoothKey(pad); + if (auto& was = battery_whose[pad]; was != whose) { + was = whose; + read = {}; + } + if (read.first == 0 || now_ms - read.first > 10'000) { + Battery fresh; + const char* source = "sdl"; + if (!IsSteamVirtualPad(pad)) { + const auto [percent, charging, wired] = SdlBattery(pad); + fresh = {percent, charging, wired}; + } +#ifdef __linux__ + std::optional> found; + bool by_address = false; + bool settling = false; + if (builtin_valid && pad == builtin_match_id) { + found = SystemBattery(); + source = "system"; + } else if (const auto address = inst_address.find(pad); + address != inst_address.end()) { + by_address = true; + found = BluetoothBattery(address->second); + source = "power supply"; + if (!found) { + // (Asked without waiting: a new answer triggers a reread) + const std::string& key = address->second; + auto& bluez = bluez_battery[key]; + if (bluez.asked_ms == 0 || now_ms - bluez.asked_ms >= 500) { + bluez.asked_ms = now_ms; + AskBluezBattery(key, this, [this, key](int percent) { + auto& entry = bluez_battery[key]; + const bool moved = entry.percent != percent; + entry.percent = percent; + if (moved) { + for (const auto& [inst, at] : inst_address) { + if (at == key) { + battery_read[inst].first = 0; + } + } + } + }); + } + if (bluez.percent >= 0) { + found = std::pair{bluez.percent, false}; + } + source = "bluez"; + // BlueZ first reads 50% for a new controller (the real charge + // comes about 10 seconds later): a first 50% counts once it changes, + // or after 12 seconds + if (found) { + // (By controller: Steam reshuffling its pads is no new connection) + const auto [first, added] = + bluez_first.try_emplace(address->second, now_ms, found->first); + // (Only its placeholder: a first value of anything else is real) + if (first->second.second == 50 && + now_ms - first->second.first < 12'000 && + found->first == first->second.second) { + found.reset(); + settling = true; + } + } + } + } + if (!found && !settling && fresh.percent < 0 && !fresh.wired && + Common::ControllerProbes()) { + const auto address = inst_address.find(pad); + LOG_INFO(Frontend, "console-mode: P{} inst={} battery unknown (address {})", + i + 1, pad, + address != inst_address.end() ? address->second : "none"); + } + if (found) { + fresh = {found->first, found->second, false}; + } + // (No value yet, or BlueZ's first one settling: read again sooner) + const qint64 read_at = settling ? now_ms - 9'000 + : by_address && !found ? now_ms - 8'000 + : now_ms; +#else + const qint64 read_at = now_ms; +#endif + if (Common::ControllerProbes() && fresh.percent >= 0 && + (fresh.percent != read.second.percent || + fresh.charging != read.second.charging)) { + LOG_INFO(Frontend, "console-mode: P{} inst={} battery {}%{} ({})", i + 1, + pad, fresh.percent, fresh.charging ? " charging" : "", source); + } + read = {read_at, fresh}; + } + battery = read.second; + // A low battery, once a session per controller: named on screen + const std::string key = name_key(pad); + if (battery.percent >= 0 && battery.percent <= 15 && !battery.charging && + battery_low_told.insert(key).second) { + controller_hints.push_back({QString{}, i, false, now_ms, false, + tr("%1: battery low (%2%)") + .arg(overlay_row_name[i]) + .arg(battery.percent)}); + LOG_INFO(Frontend, "console-mode: P{} battery low ({}%)", i + 1, + battery.percent); + } + } + const auto started = pad ? pad_origin.find(pad) : pad_origin.end(); + const int origin = + started != pad_origin.end() && started->second != i ? started->second : -1; + const bool guessed = pad && pad_guessed.contains(pad); + overlay_rows.push_back({i, + QStringLiteral("P%1: %2").arg(i + 1).arg(overlay_row_name[i]), + gone != 0 ? 0u : pad, flash, flash_since, origin, guessed, + battery.percent, battery.charging}); + const std::string key = pad ? BluetoothKey(pad) : std::string{}; + const auto address = pad_bluetooth.find(key); + const int turn_off = address != pad_bluetooth.end() && !address->second.empty() ? 2 + : pad_bt_pending.contains(key) ? 1 + : 0; + overlay_custom_name[static_cast(i)] = + custom_names[static_cast(i)]; + tab_rows.push_back({i, overlay_row_name[i].toStdString(), false, gone != 0, flash, + flash_since, ConsoleBindingSets()[slot_set[i]].profile_name, + turn_off, origin, guessed, + custom_names[static_cast(i)], battery.percent, + battery.charging, battery.wired}); + } + { + std::string handheld_name; + bool handheld_has_player = false; + if (builtin_valid && live.find(builtin_match_id) != live.end()) { + const auto [display, raw] = label_of(builtin_match_id, std::string{}); + QString name = display.isEmpty() ? raw : display; + name.remove(port_number); + handheld_name = name.toStdString(); + handheld_has_player = pad_slots.contains(builtin_match_id); + } + // The removed controller, by name while it is connected + std::string blocked_name; + for (const auto& [id, ident] : live) { + if (is_blocked(id)) { + blocked_name = PadName(id).toStdString(); + } + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.engine_active = true; + state.players = std::move(tab_rows); + state.handheld_name = std::move(handheld_name); + state.handheld_has_player = handheld_has_player; + state.handheld_blocked_name = std::move(blocked_name); + } + RenderControllerOverlay(); + } + + if (!changed) { + return; + } + // Reloaded whether a game runs or not: the Controls tab reads the players back from the + // emulated controllers, and stale ones would be taken for the user's edits + if (QtCommon::system) { + QtCommon::system->HIDCore().ReloadInputDevices(); + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + ++state.generation; +} + bool MainWindow::eventFilter(QObject* watched, QEvent* event) { + // The controller overlay takes the mouse over its rows (drag to swap, click to rumble) + if (watched == render_window && HandleOverlayMouse(event)) { + return true; + } if (watched == ui->menu_Cheats && event->type() == QEvent::MouseButtonRelease) { const auto* mouse_event = static_cast(event); auto* action = ui->menu_Cheats->actionAt(mouse_event->pos()); @@ -1562,6 +7176,10 @@ void MainWindow::ConnectMenuEvents() { connect_menu(ui->action_Show_Filter_Bar, &MainWindow::OnToggleFilterBar); connect_menu(ui->action_Show_Status_Bar, &MainWindow::OnToggleStatusBar); connect_menu(ui->action_Show_Performance_Overlay, &MainWindow::OnTogglePerfOverlay); + connect(ui->action_Show_Controller_Overlay, &QAction::toggled, this, + [](bool shown) { Settings::values.console_debug_overlay.SetValue(shown); }); + connect(ui->action_Show_Controller_Hints, &QAction::toggled, this, + [](bool shown) { Settings::values.console_hints.SetValue(shown); }); #ifdef HAS_RESHADE connect_menu(ui->action_Post_Processing_Shaders, &MainWindow::OnPostProcessingShaders); #else @@ -1842,7 +7460,11 @@ bool MainWindow::LoadROM(const QString& filename, Service::AM::FrontendAppletPar QtCommon::system->SetFrontendAppletSet({ std::make_unique(*this), // Amiibo Settings - (Settings::values.controller_applet_mode.GetValue() == Settings::AppletMode::Disabled) + // The console modes own the controllers and suppress the applet, like Disabled + (Settings::values.controller_applet_mode.GetValue() == Settings::AppletMode::Disabled || + Settings::values.controller_applet_mode.GetValue() == Settings::AppletMode::ConsoleMode || + Settings::values.controller_applet_mode.GetValue() == + Settings::AppletMode::HandheldConsoleMode) ? nullptr : std::make_unique(*this), // Controller Selector std::make_unique(*this), // Error Display @@ -1955,7 +7577,7 @@ void MainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletPa // Save configurations UpdateUISettings(); game_list->SaveInterfaceLayout(); - config->SaveAllValues(); + SaveAllConfigValues(); u64 title_id{0}; @@ -2801,7 +8423,7 @@ void MainWindow::OnLinkToRyujinx(const u64& program_id) { UISettings::values.ryujinx_link_paths.remove(program_id); } - config->SaveAllValues(); + SaveAllConfigValues(); } void MainWindow::OnMenuLoadFile() { @@ -3174,7 +8796,7 @@ void MainWindow::OnExit() { void MainWindow::OnSaveConfig() { QtCommon::system->ApplySettings(); - config->SaveAllValues(); + SaveAllConfigValues(); } void MainWindow::ErrorDisplayDisplayError(QString error_code, QString error_text) { @@ -3325,6 +8947,7 @@ void MainWindow::HideFullscreen() { statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked()); ui->menubar->show(); + FitToScreenUnderGamescope(this); } else { if (UsingExclusiveFullscreen()) { render_window->showNormal(); @@ -3339,6 +8962,21 @@ void MainWindow::HideFullscreen() { } } +void MainWindow::FitToScreenUnderGamescope(QWidget* window) { + // Under gamescope a window is scaled to fit together with any tooltip reaching past it, so + // a smaller window grows and shrinks on hover: filling the screen keeps them inside + if (!Gamescope::IsRunningUnder()) { + return; + } + if (window->isFullScreen()) { + return; + } + QScreen* screen = window->screen() ? window->screen() : QGuiApplication::primaryScreen(); + if (screen) { + window->setGeometry(screen->geometry()); + } +} + void MainWindow::ToggleWindowMode() { if (ui->action_Single_Window_Mode->isChecked()) { // Render in the main window... @@ -3457,10 +9095,18 @@ void MainWindow::OnConfigure() { const auto old_language_index = Settings::values.language_index.GetValue(); const bool old_gamemode = UISettings::values.enable_gamemode.GetValue(); + // Taken before the dialog exists (its Controls tab writes the players), from the copy in + // use: during a game the global copy holds stale routing, which would invent swaps + const auto players_before = Settings::values.players.GetValue(); Settings::SetConfiguringGlobal(true); ConfigureDialog configure_dialog(this, hotkey_registry, input_subsystem.get(), vk_device_records, *QtCommon::system, !multiplayer_state->IsHostingPublicRoom()); + // Opened from a player chip: straight to that player's tab + if (open_player_tab >= 0) { + configure_dialog.OpenPlayerTab(open_player_tab); + open_player_tab = -1; + } connect(&configure_dialog, &ConfigureDialog::LanguageChanged, this, &MainWindow::OnLanguageChanged); connect(&configure_dialog, &ConfigureDialog::ExternalContentDirsChanged, this, @@ -3498,6 +9144,7 @@ void MainWindow::OnConfigure() { Settings::values.disabled_addons.clear(); + RestoreConsoleProfile(); config = std::make_unique(); UISettings::values.reset_to_defaults = false; @@ -3509,6 +9156,10 @@ void MainWindow::OnConfigure() { SetDefaultUIGeometry(); RestoreUIState(); } + MergeConsoleProfileEdits(players_before); + ui->action_Show_Controller_Overlay->setChecked( + Settings::values.console_debug_overlay.GetValue()); + ui->action_Show_Controller_Hints->setChecked(Settings::values.console_hints.GetValue()); InitializeHotkeys(); if (UISettings::values.theme != old_theme) { @@ -3534,7 +9185,7 @@ void MainWindow::OnConfigure() { UISettings::values.configuration_applied = false; - config->SaveAllValues(); + SaveAllConfigValues(); if ((UISettings::values.hide_mouse || Settings::values.mouse_panning) && emulation_running) { render_window->installEventFilter(render_window); @@ -3754,7 +9405,26 @@ void MainWindow::OpenPerGameConfiguration(u64 title_id, const std::string& file_ Settings::RestoreGlobalState(QtCommon::system->IsPoweredOn()); return; } else if (result == QDialog::Accepted) { + // A game with its own controller config keeps its own in the file, not the engine's + // runtime players (as a global save does, see SaveAllConfigValues) + std::optional> engine_players; + if (console_profile && game_profiles && QtCommon::system->IsPoweredOn() && + QtCommon::system->GetApplicationProcessProgramID() == title_id && + !Settings::values.players.UsingGlobal()) { + auto& game_players = Settings::values.players.GetValue(); + engine_players = game_players; + game_players = *game_profiles; + } dialog.ApplyConfiguration(); + if (engine_players && !Settings::values.players.UsingGlobal()) { + Settings::values.players.GetValue() = *engine_players; + } + // (The running game's own value; otherwise the View menu keeps showing the global one) + if (QtCommon::system->IsPoweredOn()) { + ui->action_Show_Controller_Overlay->setChecked( + Settings::values.console_debug_overlay.GetValue()); + ui->action_Show_Controller_Hints->setChecked(Settings::values.console_hints.GetValue()); + } } const auto reload = UISettings::values.is_game_list_reload_pending.exchange(false); @@ -3770,7 +9440,7 @@ void MainWindow::OpenPerGameConfiguration(u64 title_id, const std::string& file_ UISettings::values.configuration_applied = false; if (!is_powered_on) { - config->SaveAllValues(); + SaveAllConfigValues(); } } @@ -4526,6 +10196,21 @@ void MainWindow::closeEvent(QCloseEvent* event) { event->ignore(); return; } + // Debug Probes: who was which player when Eden closed + if (Common::ControllerProbes() && !overlay_rows.empty()) { + std::string players_now; + for (const auto& row : overlay_rows) { + players_now += fmt::format("\n P{} {} ident={} profile=\"{}\"{}", row.player + 1, + overlay_row_name[row.player].toStdString(), + overlay_row_ident[row.player], + ConsoleBindingSets()[slot_set[row.player]].profile_name, + row.pad == 0 ? " (left)" : ""); + } + LOG_INFO(Frontend, "console-mode: closing, mode {}, players:{}", + AppletModeName(Settings::values.controller_applet_mode.GetValue()), players_now); + } + closing = true; + console_mode_timer.stop(); UpdateUISettings(); game_list->SaveInterfaceLayout(); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index e7ed34768b..ea02fd48f9 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -9,7 +9,16 @@ #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include #include #include @@ -19,6 +28,7 @@ #include "common/common_types.h" #include "common/settings_enums.h" +#include "common/settings_input.h" #include "frontend_common/content_manager.h" #include "input_common/drivers/tas_input.h" #include "qt_common/config/qt_config.h" @@ -49,6 +59,7 @@ class LoadingScreen; class OverlayDialog; class ControllerDialog; class QLabel; +class QShortcut; class MultiplayerState; class QPushButton; class QProgressDialog; @@ -176,9 +187,6 @@ public: std::filesystem::path GetShortcutPath(QtCommon::Game::ShortcutTarget target); -protected: - bool eventFilter(QObject* watched, QEvent* event) override; - signals: /** @@ -556,6 +564,256 @@ private: QActionGroup* game_size_actions; std::vector runtime_cheat_actions; + /// The console modes' engine: assigns the connected controllers to players, on a timer + QTimer console_mode_timer; + /// Eden is closing: the engine stops (it would reload the inputs closeEvent unloaded) + bool closing = false; + void ApplyConsoleControllerMode(); + /// connection instance -> player slot; kept after a disconnect so the pad reclaims its player + std::map pad_slots; + /// Per connection: its controller's Bluetooth address, the player it started as, and + /// whether its player was a guess on a reconnect + std::map inst_address; + std::map pad_origin; + std::set pad_guessed; + /// Returning pads waiting a moment for their Bluetooth address before they claim a player + std::map claim_wait_since; + /// Players taken by a guess, kept for the Bluetooth controller that held them + struct AddressClaim { + int slot; + int origin; + std::chrono::steady_clock::time_point at; + }; + std::map address_claims; + /// Each Bluetooth controller's starting player, by address: kept through Turn Off and + /// reconnects + std::map address_origin; + /// Names given to controllers ("bt:
", "handheld" or "id:"). Never saved: + /// owners can't tell identical controllers apart in a later session + std::map controller_names; + /// The mode and each player's refused controller type as last logged + std::optional logged_mode; + std::array refused_logged{}; + /// When the deck's controller was last asked to turn its motion sensors back on + qint64 deck_motion_asked_at = 0; + /// Pads whose player was removed: they sit out until they reconnect (connection instance) + std::set benched_pads; + /// When each player's controller was last turned off (Turn Off) + std::array turned_off_at{}; + /// Each pad's Bluetooth address by BluetoothKey ("" none or unknown), and the pads not yet + /// told apart from others of their model + std::map pad_bluetooth; + std::set pad_bt_pending; + void LearnBluetoothPads(); + /// Reads the pads' and the real controllers' inputs + void WatchSteamPadInputs(); + /// The last time a pad had input together with exactly one real controller: when Steam + /// then moves a controller to that pad, that is the one + struct LoneInput { + u32 pad; + std::string controller; + std::chrono::steady_clock::time_point at; + }; + std::optional last_lone_input; + /// connection instance -> stable identity (serial, else model GUID), to match a returning pad + std::map pad_identity; + /// identity -> the binding set the controller carries, so its mappings follow it + std::map pad_sets; + /// player -> the set its controller carries, as of the last tick + std::array slot_set{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + /// set -> the controller whose default mapping it holds (0: the user's, or empty); a set + /// holding a gone controller's defaults is rebuilt for the one taking it + std::array set_default_of{}; + /// This session's binding sets: profiles and remaps stay here, so the user's own setup + /// (the manual modes') is never touched + std::optional> session_sets; + /// The set a change goes to: the session's, or a game's own profile set + Settings::PlayerInput& SessionSet(int set); + /// The profile each seat (player number) starts a session with + void ApplySeatProfiles(); + void SetSeatProfile(int player, const std::string& name); + /// A set's mappings before this session's first profile on it, for taking the profile off + std::map set_before_profile; + /// The handheld's own pad: never a player while other pads are connected + u32 builtin_pad_id = 0; + /// The built-in's stable identity: the exclusion must survive its instance changing + std::string builtin_identity; + /// Latched once the built-in has been identified + bool builtin_seen_first = false; + /// Warn once per engagement when the built-in cannot be identified + bool builtin_warned = false; + /// Overlay rows per player: when it appeared, when its pad left, and its name + std::array overlay_row_since{}; + std::array overlay_row_gone{}; + std::array overlay_row_name{}; + /// Per player: the controller behind the row (its identity), and its flash (1 green, 2 red) + /// with when it began; a red then green keeps the green's start in overlay_green_at + std::array overlay_row_ident{}; + /// The key each player's input history is kept under (identical controllers can share an + /// identity) + std::array overlay_row_history{}; + std::array overlay_flash{}; + std::array overlay_flash_since{}; + std::array overlay_green_at{}; + /// Per player: the real device its motion comes from under Steam (0: none) + std::array overlay_motion_source{}; + /// The overlay's rows as the last assignment tick left them: flash 1 green, 2 red + struct OverlayRow { + int player; + QString text; + u32 pad; + int flash; + qint64 flash_since; + /// The player its controller started as (-1: this one), and whether it was a guess + int origin = -1; + bool guessed = false; + /// Its controller's battery (percent, -1 unknown) and whether it charges + int battery = -1; + bool charging = false; + }; + std::vector overlay_rows; + std::vector overlay_blink; + /// The room the players panel had last: redrawn when a top-right panel changes + float overlay_room = 0.0f; + std::set overlay_active_pads; + /// Polls controller input faster than the assignment tick, for the blue highlight + QTimer console_activity_timer; + /// The overlay's player rows as drawn, for the mouse + std::vector> overlay_hits; + int overlay_drag_player = -1; + int overlay_drag_dy = 0; + bool overlay_dragging = false; + QPoint overlay_press; + /// The row under the mouse (-1: none) and its buttons as drawn: swap above (-1) or below + /// (1), Turn Off (0), Config (2), rename (3); while naming apply (4), remove (5), cancel (6) + int overlay_hover_player = -1; + bool overlay_button_down = false; + struct OverlayButton { + QRect rect; + int player; + int action; + int target; + }; + std::vector overlay_buttons; + bool HandleOverlayMouse(QEvent* event); + /// Each player's given name as shown, and the name being typed (player -1: none) + std::array overlay_custom_name{}; + /// The name of the controller that last left each player, for its hint + std::array overlay_left_name{}; + /// Batteries, read at most every 10 seconds, and the controllers already warned of a low one + struct Battery { + int percent = -1; + bool charging = false; + bool wired = false; + }; + std::map> battery_read; + /// A controller's first BlueZ battery reading and when, by address (BlueZ says 50% at first) + std::map> bluez_first; + /// Whose controller each pad's battery reading is (Steam handle or identity, handheld or not) + std::map battery_whose; + std::set battery_low_told; + /// Use Any Controller as Joy-Cons: each player's type before the engine changed it, and the + /// type it set + std::array, 10> joycon_auto_from{}; + std::array, 10> joycon_auto_set{}; + /// A single Joy-Con set by it: held sideways (else upright), as the game holds it + std::array joycon_auto_sideways{}; + /// Pause When a Controller Disconnects: since when each player waits (0 none), and the + /// players a pause is for + std::array waiting_since{}; + std::set disconnect_paused; + /// A handover pause: the player (-1 none) the handheld's controls took over mid-game when + /// the last external controller left (it may be docked). Goes on at a press on the handheld + /// or when an external controller returns. Also who held each player at the last tick. + int handover_paused = -1; + bool handover_pressed = false; + std::array held_by_external{}; + /// Whether the handheld's controls held a player at the last tick (in Modern Console they + /// may have played all along: no handover) + bool handheld_was_playing = false; + /// What BlueZ last said of a controller (by address): connected, and when asked and answered + struct BluezConnection { + bool connected = true; + qint64 asked_ms = 0; + qint64 answered_ms = 0; + }; + std::map bluez_connected; + /// What BlueZ last said of a controller's battery (by address; -1 nothing yet), and when + struct BluezBattery { + int percent = -1; + qint64 asked_ms = 0; + }; + std::map bluez_battery; + /// The pause for a disconnect is about to come (its notice is drawn first) + bool disconnect_pause_pending = false; + /// The game was seen paused (until then, running isn't a resume by hand) + bool disconnect_pause_seen = false; + /// + is held on another controller during that pause: on without those players once let go + bool disconnect_continue_held = false; + std::set pause_done; + /// Player Lights: the player each controller's lights were last set to (by connection) + std::map lights_player; + int overlay_edit_player = -1; + QString overlay_edit_text; + /// The player whose tab the next settings dialog opens on (-1: as usual) + int open_player_tab = -1; + /// Under gamescope, makes a window fill the screen (see the definition) + void FitToScreenUnderGamescope(QWidget* window); + void RenderControllerOverlay(); + void PollControllerActivity(); + /// The last buttons each controller pressed, as the game sees them, newest last; by + /// controller, so the history follows a swap + struct ButtonPress { + int button; + qint64 at; + }; + std::map> press_history; + /// The controller each player's buttons were last read for (a new one records no presses) + std::array history_ident{}; + std::array, 10> buttons_down{}; + /// Stick moves in the history: StickMoveButton + stick * 4 + direction (left, up, right, + /// down), stick 0 the left one + static constexpr int StickMoveButton = 64; + /// The direction each player's sticks point, plus one (0 at rest) + std::array, 10> stick_direction{}; + /// Reads the players' buttons; true when the history changed (a press, or one expiring) + bool PollButtonHistory(); + /// A controller named on screen for a moment: it joined as a player, or left one + struct ControllerHint { + QString name; + int player; + bool joined; + qint64 at; + /// Left because Turn Off turned its controller off + bool turned_off = false; + /// Said instead of joined / left ("" none): how the player's controller changed + QString note; + /// Says the game is paused for a disconnect: kept while it is, shown with hints off too + bool pause_notice = false; + }; + std::vector controller_hints; + /// A game running with no controller: since when (0 none), and whether it was said + qint64 no_controller_since = 0; + bool no_controller_told = false; + /// The flashes already turned into hints, per player (green, red) + std::array hinted_join{}; + std::array hinted_leave{}; + /// True while the overlay animates (hints fading, history entries shrinking away) + bool OverlayAnimating() const; + /// The user's own players while a console mode drives them: what the config file sees, and + /// what leaving the mode puts back + std::optional> console_profile; + /// The running game's own players, when it has input profiles of its own + std::optional> game_profiles; + /// The binding sets the pads carry: the user's own, with a game's input profiles on top + std::array ConsoleBindingSets() const; + /// Saves the configuration, writing the user's own players while a console mode runs + void SaveAllConfigValues(); + /// Puts the user's own players back (leaving a console mode, or before the config saves) + void RestoreConsoleProfile(); + /// Folds edits made in the Controls tab during a console mode into the user's profile + void MergeConsoleProfileEdits(const std::array& before); + // Debugger panes ControllerDialog* controller_dialog = nullptr; @@ -603,6 +861,7 @@ private: std::string arguments, const bool needs_title); protected: + bool eventFilter(QObject* watched, QEvent* event) override; void dropEvent(QDropEvent* event) override; void dragEnterEvent(QDragEnterEvent* event) override; void dragMoveEvent(QDragMoveEvent* event) override; diff --git a/src/yuzu/overlay_panel.cpp b/src/yuzu/overlay_panel.cpp new file mode 100644 index 0000000000..b518377c3e --- /dev/null +++ b/src/yuzu/overlay_panel.cpp @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include +#include +#include +#include + +#include "yuzu/bootmanager.h" +#include "yuzu/overlay_panel.h" + +namespace GameOverlay { + +void Publish(Common::OverlayPanel& panel, const QImage* image) { + std::scoped_lock lock{panel.mutex}; + if (!image) { + if (panel.enabled) { + panel.enabled = false; + ++panel.version; + } + return; + } + panel.width = static_cast(image->width()); + panel.height = static_cast(image->height()); + panel.pixels.resize(static_cast(image->sizeInBytes())); + std::memcpy(panel.pixels.data(), image->constBits(), + static_cast(image->sizeInBytes())); + panel.enabled = true; + ++panel.version; +} + +Cursor CursorOn(Common::OverlayPanel& panel, const GRenderWindow& window) { + const auto& layout = window.GetFramebufferLayout(); + Common::OverlayRect place{}; + { + std::scoped_lock lock{panel.mutex}; + place = Common::PlaceOverlayPanel( + panel, static_cast(layout.screen.left), static_cast(layout.screen.top), + static_cast(layout.screen.right), static_cast(layout.screen.bottom)); + } + // The panel is drawn at its own size, in the framebuffer's pixels + const qreal ratio = window.devicePixelRatioF(); + const QPointF logical = window.mapFromGlobal(QCursor::pos()); + const QPoint at(static_cast(logical.x() * ratio) - static_cast(place.x), + static_cast(logical.y() * ratio) - static_cast(place.y)); + const bool inside = at.x() >= 0 && at.y() >= 0 && at.x() < static_cast(place.width) && + at.y() < static_cast(place.height); + return {at, inside}; +} + +namespace Style { + +QFont TextFont() { + QFont font; + font.setPixelSize(13); + return font; +} + +QFont LabelFont() { + QFont font; + font.setPixelSize(12); + font.setBold(true); + return font; +} + +QFont SmallFont() { + QFont font; + font.setPixelSize(11); + font.setBold(true); + return font; +} + +void Pill(QPainter& painter, const QRectF& rect, const QColor& fill) { + painter.setPen(Qt::NoPen); + painter.setBrush(fill); + painter.drawRoundedRect(rect, rect.height() / 2.0, rect.height() / 2.0); +} + +} // namespace Style + +} // namespace GameOverlay diff --git a/src/yuzu/overlay_panel.h b/src/yuzu/overlay_panel.h new file mode 100644 index 0000000000..8a8e05e077 --- /dev/null +++ b/src/yuzu/overlay_panel.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "common/overlay_panels.h" + +class GRenderWindow; +class QImage; +class QPainter; +class QRectF; +class QString; + +/// The frontend's side of the in-game overlay panels (common/overlay_panels.h) +namespace GameOverlay { + +/// Shows an image as the panel (copied), or hides the panel (nullptr) +void Publish(Common::OverlayPanel& panel, const QImage* image); + +/// The cursor in a panel's image, and whether it is over the panel (which must be shown) +struct Cursor { + QPoint at; + bool inside = false; +}; +[[nodiscard]] Cursor CursorOn(Common::OverlayPanel& panel, const GRenderWindow& window); + +/// The overlay's look, shared by its panels +namespace Style { + +/// A panel's dark, see-through fill +inline const QColor Fill{14, 14, 18, 175}; +/// A held, hovered or active item +inline const QColor Active{45, 95, 200, 200}; +/// Something that joined or turned on +inline const QColor Good{45, 160, 80, 200}; +/// Something that left or failed +inline const QColor Bad{190, 50, 50, 200}; +inline const QColor Text{240, 240, 240}; +inline const QColor DimText{255, 255, 255, 120}; + +/// The fonts: names and lines, labels (bold), small labels (bold) +[[nodiscard]] QFont TextFont(); +[[nodiscard]] QFont LabelFont(); +[[nodiscard]] QFont SmallFont(); + +/// A pill: a rounded rectangle with fully round ends +void Pill(QPainter& painter, const QRectF& rect, const QColor& fill); + + +} // namespace Style + +} // namespace GameOverlay diff --git a/src/yuzu/util/controller_navigation.cpp b/src/yuzu/util/controller_navigation.cpp index 0dbfca243e..5d9ed7e2f2 100644 --- a/src/yuzu/util/controller_navigation.cpp +++ b/src/yuzu/util/controller_navigation.cpp @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -70,6 +73,12 @@ void ControllerNavigation::ControllerUpdateButton() { case Core::HID::NpadStyleIndex::JoyconDual: case Core::HID::NpadStyleIndex::Handheld: case Core::HID::NpadStyleIndex::GameCube: + if ((button_values[Settings::NativeButton::Minus].value && + !button_values[Settings::NativeButton::Minus].locked) || + (button_values[Settings::NativeButton::Plus].value && + !button_values[Settings::NativeButton::Plus].locked)) { + emit TriggerContextMenu(); + } TriggerButton(Settings::NativeButton::A, Qt::Key_Enter); TriggerButton(Settings::NativeButton::B, Qt::Key_Escape); TriggerButton(Settings::NativeButton::DDown, Qt::Key_Down); @@ -78,10 +87,18 @@ void ControllerNavigation::ControllerUpdateButton() { TriggerButton(Settings::NativeButton::DUp, Qt::Key_Up); break; case Core::HID::NpadStyleIndex::JoyconLeft: + if (button_values[Settings::NativeButton::Minus].value && + !button_values[Settings::NativeButton::Minus].locked) { + emit TriggerContextMenu(); + } TriggerButton(Settings::NativeButton::DDown, Qt::Key_Enter); TriggerButton(Settings::NativeButton::DLeft, Qt::Key_Escape); break; case Core::HID::NpadStyleIndex::JoyconRight: + if (button_values[Settings::NativeButton::Plus].value && + !button_values[Settings::NativeButton::Plus].locked) { + emit TriggerContextMenu(); + } TriggerButton(Settings::NativeButton::X, Qt::Key_Enter); TriggerButton(Settings::NativeButton::A, Qt::Key_Escape); break; diff --git a/src/yuzu/util/controller_navigation.h b/src/yuzu/util/controller_navigation.h index 86e210368f..d0bd8f5898 100644 --- a/src/yuzu/util/controller_navigation.h +++ b/src/yuzu/util/controller_navigation.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -29,6 +32,7 @@ public: signals: void TriggerKeyboardEvent(Qt::Key key); + void TriggerContextMenu(); private: void TriggerButton(Settings::NativeButton::Values native_button, Qt::Key key); diff --git a/src/yuzu/util/gamescope.cpp b/src/yuzu/util/gamescope.cpp new file mode 100644 index 0000000000..d79ec6863a --- /dev/null +++ b/src/yuzu/util/gamescope.cpp @@ -0,0 +1,449 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/logging.h" +#include "common/settings.h" +#include "yuzu/util/gamescope.h" + +namespace Gamescope { +namespace { + +// Under gamescope (installed on the application only there). Gamescope shows one window at a +// time, scaled to fit the screen, with no title bar, and only one popup over it: +// - A dialog takes the whole screen as it shows, before it's mapped, so it's never scaled up +// (blurry, and a tooltip past its edge rescaled it). A smaller dialog keeps its own size, +// centred. Its own show handling runs first (a message box fixes its size +// there), then its size is freed. +// - A dialog with no buttons of its own to leave it gets a Close button. +// - A closing menu can leave the mouse grabbed by the main window: taps then reach the main +// window, which the open dialog blocks, so the dialog never gets them. A modal window shown +// with no menu open lets go of any such grab. +// - A submenu's popup would hide its parent menu, which still took the mouse unseen: a +// submenu opens in its menu's place instead. +// - A smaller dialog is a card on a darker screen, like its own window on a desktop, with an +// Expand button to fill the screen (Shrink to go back). +class GamescopeDialogs final : public QObject { +public: + explicit GamescopeDialogs(QWidget* main_) : QObject(main_), main{main_} { + // The menu bar's menus got their actions before this was installed + for (QMenu* menu : main->findChildren()) { + PrepareMenu(menu); + } + } + + bool eventFilter(QObject* watched, QEvent* event) override { + if (auto* menu = qobject_cast(watched)) { + if (event->type() == QEvent::ActionAdded) { + PrepareMenu(menu); + return false; + } + return TurnPage(menu, event); + } + if (event->type() == QEvent::LayoutRequest && watched->isWidgetType() && + watched->property("gamescope_fit").isValid()) { + // A message box fixes its own size on every layout change: it lays itself out, then + // takes the whole screen again + // (Taken over here, so the layout is activated first, as Qt would before event()) + auto* widget = static_cast(watched); + if (QLayout* layout = widget->layout()) { + layout->activate(); + } + static_cast(widget)->event(event); + if (QScreen* screen = widget->screen(); screen && widget->geometry() != screen->geometry()) { + widget->setMinimumSize(0, 0); + widget->setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); + widget->setGeometry(screen->geometry()); + } + return true; + } + if (event->type() != QEvent::Show || !watched->isWidgetType()) { + return false; + } + auto* widget = static_cast(watched); + if (widget == main || !widget->isWindow()) { + return false; + } + if (widget->windowModality() != Qt::NonModal) { + QTimer::singleShot(0, this, [this] { + if (QApplication::activePopupWidget() || !main->windowHandle()) { + return; + } + main->windowHandle()->setMouseGrabEnabled(false); + if (Settings::values.console_debug_probes.GetValue()) { + LOG_INFO(Frontend, "gamescope: dialog shown, mouse grab released"); + } + }); + } + // Eden's own overlays over the game (software keyboard, applet messages) size and place + // themselves over it + if (widget->windowType() != Qt::Dialog || widget->isFullScreen() || + (widget->windowFlags() & Qt::FramelessWindowHint)) { + return false; + } + static_cast(widget)->event(event); + FitDialog(widget); + return true; + } + +private: + static void FitDialog(QWidget* dialog) { + QScreen* screen = dialog->screen() ? dialog->screen() : QGuiApplication::primaryScreen(); + if (!screen) { + return; + } + const QRect area = screen->geometry(); + const QSize natural = dialog->size(); + const bool first = !dialog->property("gamescope_fit").isValid(); + dialog->setProperty("gamescope_fit", true); + dialog->setMinimumSize(0, 0); + dialog->setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); + dialog->setGeometry(area); + QLayout* layout = dialog->layout(); + if (!first || !layout) { + return; + } + // A smaller dialog keeps its size, a card in the middle; it can be expanded to fill the + // screen and shrunk back (each time it opens it starts as a card) + const bool centred = natural.width() < area.width() * 85 / 100 && + natural.height() < area.height() * 85 / 100; + QList corner; + if (NeedsClose(dialog)) { + auto* close = new QPushButton(tr("Close"), dialog); + connect(close, &QPushButton::clicked, dialog, &QWidget::close); + corner.append(close); + } + QPushButton* expand = nullptr; + if (centred && !IsSmallPrompt(dialog)) { + expand = new QPushButton(tr("Expand"), dialog); + expand->setToolTip(tr("Fill the screen")); + corner.prepend(expand); + } + int strip = 0; + for (QPushButton* button : corner) { + button->setCursor(Qt::PointingHandCursor); + button->adjustSize(); + strip = (std::max)(strip, button->height() + 8); + } + const QMargins own = layout->contentsMargins(); + const QRect card_area((area.width() - natural.width()) / 2, + (area.height() - natural.height()) / 2, natural.width(), + natural.height()); + QFrame* card = centred ? AddCard(dialog, card_area) : nullptr; + // Card: margins around the dialog's own size, buttons above its top right corner. + // Full: the buttons in a strip at the top right of the screen. + // (The dialog's current layout each time, and guarded pointers: a message box rebuilds + // its layout on a style change, so AddCard's style sheet already deleted the one read + // above; the kept pointer crashed in QLayout::setContentsMargins) + QList> corner_buttons; + for (QPushButton* button : corner) { + corner_buttons.append(button); + } + const auto place = [dialog_ptr = QPointer{dialog}, own, area, card_area, + card_ptr = QPointer{card}, corner_buttons, strip, + expand_ptr = QPointer{expand}](bool full) { + QWidget* const target = dialog_ptr.data(); + QLayout* const target_layout = target ? target->layout() : nullptr; + if (!target_layout) { + return; + } + QFrame* const target_card = card_ptr.data(); + QPushButton* const target_expand = expand_ptr.data(); + QList buttons; + for (const QPointer& button : corner_buttons) { + if (button) { + buttons.append(button.data()); + } + } + if (full || !target_card) { + target_layout->setContentsMargins(own + + QMargins(0, buttons.isEmpty() ? 0 : strip, 0, 0)); + } else { + target_layout->setContentsMargins(own + + QMargins(card_area.left(), card_area.top(), + area.width() - card_area.right() - 1, + area.height() - card_area.bottom() - 1)); + } + if (target_card) { + target_card->setVisible(!full); + } + if (target_expand) { + target_expand->setText(full ? tr("Shrink") : tr("Expand")); + target_expand->setToolTip(full ? tr("Back to its own size") + : tr("Fill the screen")); + target_expand->adjustSize(); + } + if (buttons.isEmpty()) { + target->update(); + return; + } + int right = full || !target_card ? area.width() - 8 : card_area.right() + 1; + const int top = + full || !target_card + ? 6 + : (std::max)(6, card_area.top() - 14 - buttons.first()->height() - 6); + for (auto it = buttons.rbegin(); it != buttons.rend(); ++it) { + (*it)->move(right - (*it)->width(), top); + (*it)->raise(); + (*it)->show(); + right -= (*it)->width() + 6; + } + target->update(); + }; + if (expand) { + connect(expand, &QPushButton::clicked, dialog, [place, expand] { + // (The connection ends with the button, so expand is alive here) + const bool full = !expand->property("gamescope_full").toBool(); + expand->setProperty("gamescope_full", full); + place(full); + }); + } + // File pickers, per-game settings and the room browser open expanded (Shrink: a card) + const bool starts_full = expand && (qobject_cast(dialog) || + dialog->inherits("ConfigurePerGame") || + dialog->inherits("Lobby")); + if (starts_full) { + expand->setProperty("gamescope_full", true); + } + place(starts_full); + } + + // A prompt or short form: a few lines and fields, nothing to gain from filling the screen + static bool IsSmallPrompt(QWidget* dialog) { + static constexpr const char* small[] = { + "QMessageBox", "QInputDialog", "AboutDialog", + "DataDialog", "DirectConnectWindow", "HostRoomWindow", + "RyujinxDialog", "LimitableInputDialog", "SetPlayTimeDialog", + "SequenceDialog", "NewUserDialog", "ConfigureProfileManagerDeleteDialog", + "MigrationDialog", "CalibrationConfigurationDialog", + }; + for (const char* name : small) { + if (dialog->inherits(name)) { + return true; + } + } + return false; + } + + // A dialog leaves through its button row; without one (the room browser, Create Room) it + // has no way out + static bool NeedsClose(QWidget* dialog) { + for (auto* box : dialog->findChildren()) { + if (!box->buttons().isEmpty()) { + return false; + } + } + return true; + } + + // The dialog's own area is a card on a darker screen, so it doesn't float edgeless + static QFrame* AddCard(QWidget* dialog, const QRect& inner) { + const QColor base = dialog->palette().color(QPalette::Window); + if (dialog->objectName().isEmpty()) { + dialog->setObjectName(QStringLiteral("gamescopeDialog")); + } + dialog->setStyleSheet(dialog->styleSheet() + + QStringLiteral("\n#%1 { background-color: %2; }") + .arg(dialog->objectName(), base.darker(160).name())); + auto* card = new QFrame(dialog); + card->setObjectName(QStringLiteral("gamescopeCard")); + card->setStyleSheet(QStringLiteral("#gamescopeCard { background-color: %1; border: 1px " + "solid %2; border-radius: 10px; }") + .arg(base.name(), base.lighter(150).name())); + card->setGeometry(inner.adjusted(-14, -14, 14, 14)); + card->lower(); + return card; + } + + // A submenu opens in its menu's place: the menu swaps its entries for the submenu's, with + // ‹ Back on top, so only one menu is ever up. Set up as a menu gets its first action, so + // it's in place before the menu first measures itself. + static void PrepareMenu(QMenu* menu) { + if (menu->property("gamescope_menu").isValid()) { + return; + } + menu->setProperty("gamescope_menu", true); + connect(menu, &QMenu::aboutToShow, menu, [menu] { + menu->setProperty("gamescope_pages", QVariantList{}); + ShowPage(menu, nullptr); + }); + } + + // A tap (or Enter / Right) on a submenu's entry or on Back turns the page, the menu kept open + static bool TurnPage(QMenu* menu, QEvent* event) { + if (event->type() == QEvent::KeyPress) { + const int key = static_cast(event)->key(); + if (key != Qt::Key_Return && key != Qt::Key_Enter && key != Qt::Key_Right && + key != Qt::Key_Left) { + return false; + } + } else if (event->type() != QEvent::MouseButtonRelease) { + return false; + } + QAction* active = menu->activeAction(); + if (!active || !active->isEnabled()) { + return false; + } + QVariantList pages = menu->property("gamescope_pages").toList(); + const bool is_left = event->type() == QEvent::KeyPress && + static_cast(event)->key() == Qt::Key_Left; + if (active->property("gamescope_back").isValid() || (is_left && !pages.isEmpty())) { + pages.removeLast(); + } else if (auto* sub = qobject_cast(active->property("gamescope_sub").value()); + sub && !is_left) { + pages.append(QVariant::fromValue(sub)); + } else { + return false; + } + menu->setProperty("gamescope_pages", pages); + auto* page = pages.isEmpty() ? nullptr : qobject_cast(pages.last().value()); + ShowPage(menu, page); + return true; + } + + // Shows the menu's own entries (page null) or a submenu's, submenus as entries ending in › + static void ShowPage(QMenu* menu, QMenu* page) { + for (QAction* action : menu->actions()) { + if (action->property("gamescope_added").isValid()) { + menu->removeAction(action); + action->deleteLater(); + } else if (action->property("gamescope_hidden").isValid()) { + action->setVisible(true); + action->setProperty("gamescope_hidden", QVariant{}); + } + } + const QList own = menu->actions(); + QAction* first = own.isEmpty() ? nullptr : own.first(); + const auto add = [menu](QAction* added, QAction* before) { + added->setProperty("gamescope_added", true); + menu->insertAction(before, added); + }; + if (page) { + for (QAction* action : own) { + if (action->isVisible()) { + action->setVisible(false); + action->setProperty("gamescope_hidden", true); + } + } + QMetaObject::invokeMethod(page, "aboutToShow"); + auto* back = new QAction(QStringLiteral("‹ ") + tr("Back"), menu); + back->setProperty("gamescope_back", true); + add(back, first); + auto* separator = new QAction(menu); + separator->setSeparator(true); + add(separator, first); + for (QAction* original : page->actions()) { + if (!original->isVisible()) { + continue; + } + QAction* copy = nullptr; + if (original->isSeparator()) { + copy = new QAction(menu); + copy->setSeparator(true); + } else if (QMenu* sub = original->menu()) { + copy = new QAction(original->icon(), original->text() + QStringLiteral(" ›"), menu); + copy->setEnabled(original->isEnabled()); + copy->setProperty("gamescope_sub", QVariant::fromValue(sub)); + } else { + copy = new QAction(original->icon(), original->text(), menu); + copy->setEnabled(original->isEnabled()); + copy->setCheckable(original->isCheckable()); + copy->setChecked(original->isChecked()); + connect(copy, &QAction::triggered, original, [original] { original->trigger(); }); + } + add(copy, first); + } + } else { + for (QAction* action : own) { + QMenu* sub = action->menu(); + if (!sub || !action->isVisible()) { + continue; + } + // (A hidden action reads as disabled: its state is taken first) + const bool enabled = action->isEnabled(); + action->setVisible(false); + action->setProperty("gamescope_hidden", true); + auto* entry = new QAction(action->icon(), action->text() + QStringLiteral(" ›"), menu); + entry->setEnabled(enabled); + entry->setProperty("gamescope_sub", QVariant::fromValue(sub)); + add(entry, action); + } + } + if (!menu->isVisible()) { + return; + } + // An open menu turning its page: sized again and kept on the screen + menu->adjustSize(); + if (QScreen* screen = menu->screen()) { + const QRect area = screen->availableGeometry(); + QPoint at = menu->pos(); + at.setX((std::max)(area.left(), (std::min)(at.x(), area.right() - menu->width()))); + at.setY((std::max)(area.top(), (std::min)(at.y(), area.bottom() - menu->height()))); + menu->move(at); + } + } + + QWidget* main; +}; +} // namespace + +bool IsRunningUnder() { + return qEnvironmentVariableIsSet("GAMESCOPE_WAYLAND_DISPLAY") || + qgetenv("XDG_CURRENT_DESKTOP") == "gamescope"; +} + +// Opening a folder starts the desktop's file manager, which can't show under gamescope (in Game +// Mode it doesn't even reach a display): folders and files open in Eden's own file browser +class FolderBrowser final : public QObject { + Q_OBJECT + +public: + explicit FolderBrowser(QWidget* main_) : QObject(main_), main{main_} {} + +public slots: + void Open(const QUrl& url) { + const QFileInfo info(url.toLocalFile()); + const QString folder = info.isDir() ? info.absoluteFilePath() : info.absolutePath(); + QWidget* parent = QApplication::activeWindow() ? QApplication::activeWindow() : main; + QFileDialog browser(parent, folder); + browser.setOption(QFileDialog::DontUseNativeDialog); + browser.setFileMode(QFileDialog::ExistingFiles); + browser.setLabelText(QFileDialog::Reject, tr("Close")); + browser.setDirectory(folder); + if (!info.isDir()) { + browser.selectFile(info.fileName()); + } + browser.exec(); + } + +private: + QWidget* main; +}; + +void FitDialogsAndMenus(QWidget* main) { + if (IsRunningUnder()) { + qApp->installEventFilter(new GamescopeDialogs(main)); + QDesktopServices::setUrlHandler(QStringLiteral("file"), new FolderBrowser(main), "Open"); + } +} + +} // namespace Gamescope + +#include "gamescope.moc" diff --git a/src/yuzu/util/gamescope.h b/src/yuzu/util/gamescope.h new file mode 100644 index 0000000000..fd6e9c95cd --- /dev/null +++ b/src/yuzu/util/gamescope.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +class QWidget; + +// Steam's Game Mode runs Eden under gamescope, which shows one window at a time, scaled to fit +// the screen, with no title bars, and only one popup over it +namespace Gamescope { + +/// Whether Eden runs under gamescope +bool IsRunningUnder(); + +/// Under gamescope only: fits Eden's dialogs and menus to how gamescope shows windows +/// (see gamescope.cpp). `main` is the main window. +void FitDialogsAndMenus(QWidget* main); + +} // namespace Gamescope diff --git a/src/yuzu/util/overlay_dialog.cpp b/src/yuzu/util/overlay_dialog.cpp index 2d50f22acb..da74fc3772 100644 --- a/src/yuzu/util/overlay_dialog.cpp +++ b/src/yuzu/util/overlay_dialog.cpp @@ -12,6 +12,7 @@ #include "hid_core/hid_types.h" #include "ui_overlay_dialog.h" #include "yuzu/util/overlay_dialog.h" +#include "yuzu/util/gamescope.h" namespace { @@ -29,8 +30,12 @@ OverlayDialog::OverlayDialog(QWidget* parent, Core::System& system, const QStrin : QDialog(parent), ui{std::make_unique()}, use_rich_text{use_rich_text_} { ui->setupUi(this); + // Under gamescope only one normal window shows at a time: a window bypassing the window + // manager is drawn over the game instead of in its place + const bool gamescope = Gamescope::IsRunningUnder(); setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowTitleHint | - Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint); + Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint | + (gamescope ? Qt::X11BypassWindowManagerHint : Qt::WindowFlags{})); setWindowModality(Qt::WindowModal); setAttribute(Qt::WA_TranslucentBackground);