mirror of
https://git.eden-emu.dev/eden-emu/eden
synced 2026-11-08 04:22:39 +01:00
[common, controller] [AI] Controller assignment modes, Controllers tab, Steam Gamemode/Gamescope fixes (gyroscope, controller) (#4583)
## Summary Automatic controller assignment for Eden, aimed at couch co-op and handheld PCs (Steam Deck, Legion Go, ROG Ally, AYN Odin), plus a set of fixes for running Eden in Steam's Game Mode (gamescope) and under Steam Input. ## Controller Assignment Handler Settings > Controls gains a **Controllers** tab. The handler is a segmented choice: | Handler | Behaviour | |---|---| | **Modern Console** | Automatic: each controller that connects becomes the next player (up to 8). | | **Manual** (default) | Players are set in each player's tab, as before. Its Controller Applet choice: **Disabled**, **Custom Frontend** or **Real Hardware** (the console's own applet, LLE). | Under Modern Console, **Portable Handheld Gaming Device with Built-in Controller** (on/off) makes external controllers take the players first, with the handheld's own controls used only when none is connected. In the automatic modes: - Players follow their controllers across reconnects: a controller that leaves keeps its player for 45 seconds and takes it back when it returns. Each controller keeps its own mappings when it changes player, for the session. - At most eight players; a ninth controller waits until a player is free. - The user's saved controller setup is never rewritten; remaps made in the Controls tab last for the session, and input profiles are what persist. - Input profiles can be picked or removed per player, from the Controllers tab or each player tab. - A controller whose player has no controller mappings (a fresh setup) gets its default mapping. A DSU (cemuhook) motion binding from the manual setup is kept, for handhelds whose own gyro needs it. ## Gameplay options - **Pause When a Controller Disconnects** (on by default): the game pauses when a player's controller is turned off, or has been gone for 3 seconds, and resumes when a controller takes that player again. Pressing + on another controller goes on without it. - **Use Any Controller as Joy-Cons**: a game that only takes Joy-Cons gets them automatically (dual, or a single sideways one with the buttons arranged to fit). - **Button Layout**: By Position or By Label, for Xbox and PlayStation style controllers. - **Player Lights**: controllers that have them show their player number. ## Controllers tab and player tabs - Connected Players: each player with its controller and battery, profile picker, Swap, Config, Turn Off (Bluetooth), reorder arrows, drag to swap, and click to rumble. Controllers can be given a name for the session. - Assign Handheld Controller: auto-detect, or pick/Identify the handheld's own pad. - P1-P8 tabs show each player's battery and can be dragged or moved with arrows to change the player order. - In Modern Console the Controllers and player tabs use a modern card layout that follows the light and dark themes, including the system's dark mode (macOS). ## On-screen display - **Controller Overlay** (View menu, Ctrl+Shift+V): players, their controllers and batteries, and each player's recent buttons, including stick moves (LS/RS) and clicks (L3/R3). How many inputs are kept and for how long can be set. - **Controller Hints**: names a controller in the bottom-left corner when it joins or leaves a player, and says when the game pauses for a disconnected one. Works without the overlay. Both are drawn into the presented frame as crisp overlay panels (not scaled with the game, not in screenshots), so they show inside gamescope. ## Steam Input and gamescope fixes - **Native gyro under Steam Input.** Steam's virtual pads carry no motion sensors. Eden now opens the real motion-capable controller behind each virtual pad for its sensors only (Steam Deck, Steam Controller incl. the 2026 model, Sony, Nintendo), and turns on the Deck's IMU, which Steam keeps off unless the layout uses the gyro. No DSU server or Steam layout change needed. - **Bypass Steam Input** (Controllers tab, shown when launched through Steam): Eden opens the real controllers itself instead of Steam's virtual pads. - **Controller identity under Steam.** All virtual pads share one GUID and Steam moves physical controllers between them; controllers are tracked by their Steam handle and slot instead. - **Handheld detection under Steam.** The Deck's own controls are recognised behind Steam Input without mistaking standalone controllers (e.g. the Steam Controller) for them. - **Windows no longer rescale on hover in Game Mode.** gamescope scales a window to fit the screen together with any tooltip reaching past it, so the main window and Settings grew and shrank on hover. Under gamescope they now fill the screen. - **VSync modes in Game Mode.** gamescope offers only some present modes, so Immediate and FIFO Relaxed vanished silently. They are now listed greyed out with a tooltip pointing to Steam's Disable Frame Limit and Allow Tearing settings. - **Stick range testers on small screens.** The controller drawing scales down to fit on 800-pixel screens instead of cutting off the stick range circles. - **Midnight Blue themes.** A hovered menu-bar item is readable again, and hovered tabs no longer change size (which made the Settings tabs shake). ## Fixes - Crash when HID received a controller update before NPad's applet resource was set up. ## Testing Tested on a Steam Deck (Desktop and Game Mode, direct and Steam launches) with Xbox controllers, plus a stress test with up to 10 virtual controllers connecting, disconnecting and pressing at random in both automatic modes (no assignment errors, never more than 8 players, disconnect pauses resume as expected). ## AI disclosure Developed with Claude Opus 5.5 (implementation and debugging). I reviewed, tested and can explain every change. - [x] I have read and followed the [Contribution Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/CONTRIBUTING.md#code-contributions). - [x] I have read and followed the [AI Policy](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/AI.md). - [x] I have read and followed the [Coding Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/Coding.md) to the best of my ability. --- Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4583 Reviewed-by: CamilleLaVey <camillelavey99@gmail.com>
This commit is contained in:
parent
885b333c08
commit
873fde41d0
57 changed files with 11334 additions and 90 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
125
src/common/controller_console_state.h
Normal file
125
src/common/controller_console_state.h
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<std::pair<std::string, std::string>> 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<PlayerRow> 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<std::pair<int, int>> swap_requests;
|
||||
/// Players whose Bluetooth controller is to be turned off; the player is freed
|
||||
std::vector<int> remove_requests;
|
||||
/// Names given to players' controllers from the Controllers tab ("" takes it off)
|
||||
std::vector<std::pair<int, std::string>> rename_requests;
|
||||
/// Players whose controller the Controllers tab asked to rumble (a click on its row)
|
||||
std::vector<int> 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<ProfileRequest> profile_requests;
|
||||
/// Bypass Steam Input took effect at startup: SDL opens the real controllers
|
||||
std::atomic<bool> 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<bool> steam_pads_present{false};
|
||||
std::set<unsigned> 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<int, 10> 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
|
||||
107
src/common/overlay_panels.h
Normal file
107
src/common/overlay_panels.h
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#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<u8> 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<std::size_t>(OverlayPanelId::Count);
|
||||
|
||||
[[nodiscard]] inline std::array<OverlayPanel, OverlayPanelCount>& GetOverlayPanels() {
|
||||
static std::array<OverlayPanel, OverlayPanelCount> panels;
|
||||
static const bool placed = [] {
|
||||
panels[static_cast<std::size_t>(OverlayPanelId::Hints)].anchor =
|
||||
OverlayAnchor::BottomLeft;
|
||||
panels[static_cast<std::size_t>(OverlayPanelId::Cheats)].anchor =
|
||||
OverlayAnchor::TopRight;
|
||||
return true;
|
||||
}();
|
||||
(void)placed;
|
||||
return panels;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline OverlayPanel& GetOverlayPanel(OverlayPanelId id) {
|
||||
return GetOverlayPanels()[static_cast<std::size_t>(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<float>(panel.width);
|
||||
const float height = static_cast<float>(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<float>(panel.width) + margin + apart);
|
||||
}
|
||||
}
|
||||
return screen_width - margin - right_taken;
|
||||
}
|
||||
|
||||
} // namespace Common
|
||||
|
|
@ -202,6 +202,66 @@ struct Values {
|
|||
|
||||
// Core
|
||||
SwitchableSetting<bool> 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<bool> 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<std::string> 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<bool> 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<bool> console_history{linkage, true, "console_history",
|
||||
Category::LibraryApplet};
|
||||
SwitchableSetting<int, true> console_history_size{
|
||||
linkage, 10, 5, 20, "console_history_size", Category::LibraryApplet};
|
||||
SwitchableSetting<int, true> console_history_seconds{
|
||||
linkage, 5, 1, 30, "console_history_seconds", Category::LibraryApplet};
|
||||
SwitchableSetting<bool> 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<bool> 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<bool> 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<bool> console_pause_disconnect{linkage, true, "console_pause_disconnect",
|
||||
Category::LibraryApplet};
|
||||
/// Modern Console: controllers' player lights and light bars show their player
|
||||
SwitchableSetting<bool> console_player_lights{linkage, true, "console_player_lights",
|
||||
Category::LibraryApplet};
|
||||
/// Modern Console: Xbox and PlayStation style buttons by label instead of position
|
||||
SwitchableSetting<bool> console_button_labels{linkage, false, "console_button_labels",
|
||||
Category::LibraryApplet};
|
||||
/// Detailed controller traces in the log, for bug reports
|
||||
SwitchableSetting<bool> 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<bool> builtin_pad_manual{linkage, false, "builtin_pad_manual",
|
||||
Category::Core};
|
||||
|
||||
/// The handheld's own pad identity, latched when Handheld Console Mode engages
|
||||
SwitchableSetting<std::string> 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<std::string> 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<std::string> 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<bool> builtin_pad_steam_manual{linkage, false, "builtin_pad_steam_manual",
|
||||
Category::Core};
|
||||
SwitchableSetting<MemoryLayout, true> memory_layout_mode{linkage,
|
||||
MemoryLayout::Memory_4Gb,
|
||||
"memory_layout_mode",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
#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<EmulatedController> player_1;
|
||||
std::unique_ptr<EmulatedController> player_2;
|
||||
std::unique_ptr<EmulatedController> player_3;
|
||||
|
|
@ -90,6 +99,7 @@ public:
|
|||
std::unique_ptr<EmulatedDevices> devices;
|
||||
Kernel::KernelCore& kernel;
|
||||
NpadStyleTag supported_style_tag{NpadStyleSet::All};
|
||||
std::atomic<bool> joy_hold_horizontal{false};
|
||||
NpadIdType last_active_controller{NpadIdType::Handheld};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,19 @@
|
|||
// SPDX-FileCopyrightText: 2018 Citra Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <numbers>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#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<SDLJoystick> SDLDriver::GetSDLJoystickBySDLID(SDL_JoystickID sdl
|
|||
return *vec_it;
|
||||
}
|
||||
|
||||
std::optional<std::pair<std::string, int>> 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<std::chrono::steady_clock::time_point> 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<SDLJoystick> 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<int>(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<int>(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<u32>& UnhiddenControllers() {
|
||||
static std::set<u32> 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<u32>(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<char>{file}, std::istreambuf_iterator<char>{});
|
||||
}
|
||||
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<Common::ParamPackage> 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<Common::ParamPackage> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@
|
|||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include "common/container/unordered_map.h"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
|
@ -45,6 +49,15 @@ public:
|
|||
std::shared_ptr<SDLJoystick> GetSDLJoystickBySDLID(SDL_JoystickID sdl_id);
|
||||
std::shared_ptr<SDLJoystick> 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<std::pair<std::string, int>> 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<std::chrono::steady_clock::time_point> 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<Common::UUID, std::vector<std::shared_ptr<SDLJoystick>>> joystick_map;
|
||||
std::mutex joystick_map_mutex;
|
||||
|
||||
void MarkActivity(SDL_JoystickID sdl_id);
|
||||
std::unordered_map<SDL_JoystickID, std::chrono::steady_clock::time_point> last_activity;
|
||||
mutable std::mutex activity_mutex;
|
||||
|
||||
std::atomic<bool> initialized = false;
|
||||
bool start_thread;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,10 +25,25 @@ public:
|
|||
using Button = std::unique_ptr<Common::Input::InputDevice>;
|
||||
|
||||
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<Common::Input::InputDevice> 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<Stick>(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
|
||||
|
|
|
|||
|
|
@ -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<Common::Input::InputDevice> 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<InputFromMotion>(identifier, motion_sensor, gyro_threshold,
|
||||
return std::make_unique<InputFromMotion>(identifier, motion_sensor, gyro_threshold, turn,
|
||||
input_engine.get());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -442,6 +442,23 @@ std::vector<Common::ParamPackage> InputSubsystem::GetInputDevices() const {
|
|||
return impl->GetInputDevices();
|
||||
}
|
||||
|
||||
std::optional<std::pair<std::string, int>> InputSubsystem::GetSDLBinding(u32 sdl_instance) const {
|
||||
#ifdef HAVE_SDL3
|
||||
return impl->sdl->GetBindingForSDLID(static_cast<SDL_JoystickID>(sdl_instance));
|
||||
#else
|
||||
return std::nullopt;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::optional<std::chrono::steady_clock::time_point> InputSubsystem::GetSDLLastActivity(
|
||||
u32 sdl_instance) const {
|
||||
#ifdef HAVE_SDL3
|
||||
return impl->sdl->GetLastActivity(static_cast<SDL_JoystickID>(sdl_instance));
|
||||
#else
|
||||
return std::nullopt;
|
||||
#endif
|
||||
}
|
||||
|
||||
AnalogMapping InputSubsystem::GetAnalogMappingForDevice(const Common::ParamPackage& device) const {
|
||||
return impl->GetAnalogMappingForDevice(device);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include "common/container/unordered_map.h"
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#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<std::pair<std::string, int>> GetSDLBinding(u32 sdl_instance) const;
|
||||
|
||||
/// When an SDL joystick last had a deliberate input (a stick or trigger past half travel)
|
||||
[[nodiscard]] std::optional<std::chrono::steady_clock::time_point> GetSDLLastActivity(
|
||||
u32 sdl_instance) const;
|
||||
|
||||
/// Reloads the input devices.
|
||||
void ReloadInputDevices();
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,41 @@ std::unique_ptr<TranslationMap> 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<ComboboxTranslationMap> ComboboxEnumeration(QObject* parent) {
|
|||
// Intentionally skipping VSyncMode to let the UI fill that one out
|
||||
translations->insert({Settings::EnumMetadata<Settings::AppletMode>::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<Settings::SpirvOptimizeMode>::Index(),
|
||||
|
|
|
|||
|
|
@ -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<Shortcut, 33> default_hotkeys{{
|
||||
const std::array<Shortcut, 34> 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<Shortcut, 33> 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
|
||||
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ void SaveWindowState();
|
|||
void RestoreWindowState(std::unique_ptr<QtConfig>& qtConfig);
|
||||
|
||||
// sync with uisettings.cpp
|
||||
extern const std::array<Shortcut, 33> default_hotkeys;
|
||||
extern const std::array<Shortcut, 34> default_hotkeys;
|
||||
|
||||
} // namespace UISettings
|
||||
|
||||
|
|
|
|||
|
|
@ -30,29 +30,29 @@
|
|||
|
||||
namespace Vulkan {
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeNearestNeighbor(const Device& device, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateNearestNeighborSampler(device),
|
||||
std::unique_ptr<WindowAdaptPass> MakeNearestNeighbor(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateNearestNeighborSampler(device),
|
||||
BuildShader(device, VULKAN_PRESENT_FRAG_SPV));
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeBilinear(const Device& device, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device),
|
||||
std::unique_ptr<WindowAdaptPass> MakeBilinear(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateBilinearSampler(device),
|
||||
BuildShader(device, VULKAN_PRESENT_FRAG_SPV));
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeSpline1(const Device& device, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device),
|
||||
std::unique_ptr<WindowAdaptPass> MakeSpline1(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateBilinearSampler(device),
|
||||
BuildShader(device, PRESENT_SPLINE1_FRAG_SPV));
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeBicubic(const Device& device, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights) {
|
||||
std::unique_ptr<WindowAdaptPass> 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<WindowAdaptPass>(device, frame_format, CreateCubicSampler(device,
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateCubicSampler(device,
|
||||
qcom_weights), BuildShader(device, VULKAN_PRESENT_FRAG_SPV));
|
||||
} else {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device), [&](){
|
||||
return std::make_unique<WindowAdaptPass>(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<WindowAdaptPass> MakeBicubic(const Device& device, VkFormat fram
|
|||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeGaussian(const Device& device, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device),
|
||||
std::unique_ptr<WindowAdaptPass> MakeGaussian(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateBilinearSampler(device),
|
||||
BuildShader(device, PRESENT_GAUSSIAN_FRAG_SPV));
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device),
|
||||
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateBilinearSampler(device),
|
||||
BuildShader(device, PRESENT_LANCZOS_FRAG_SPV));
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeScaleForce(const Device& device, VkFormat frame_format) {
|
||||
std::unique_ptr<WindowAdaptPass> 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<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device), select_fn());
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateBilinearSampler(device), select_fn());
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeArea(const Device& device, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device),
|
||||
std::unique_ptr<WindowAdaptPass> MakeArea(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateBilinearSampler(device),
|
||||
BuildShader(device, PRESENT_AREA_FRAG_SPV));
|
||||
}
|
||||
|
||||
std::unique_ptr<WindowAdaptPass> MakeMmpx(const Device& device, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateNearestNeighborSampler(device),
|
||||
std::unique_ptr<WindowAdaptPass> MakeMmpx(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) {
|
||||
return std::make_unique<WindowAdaptPass>(device, memory_allocator, frame_format, CreateNearestNeighborSampler(device),
|
||||
BuildShader(device, PRESENT_MMPX_FRAG_SPV));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<WindowAdaptPass> MakeNearestNeighbor(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeBilinear(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeBicubic(const Device& device, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights);
|
||||
std::unique_ptr<WindowAdaptPass> MakeSpline1(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeGaussian(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeScaleForce(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeArea(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeMmpx(const Device& device, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeNearestNeighbor(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeBilinear(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeBicubic(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights);
|
||||
std::unique_ptr<WindowAdaptPass> MakeSpline1(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeGaussian(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeScaleForce(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeArea(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
std::unique_ptr<WindowAdaptPass> MakeMmpx(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format);
|
||||
|
||||
} // namespace Vulkan
|
||||
|
|
|
|||
|
|
@ -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 <mutex>
|
||||
|
||||
#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<Layer>& layers,
|
||||
std::span<const Tegra::FramebufferConfig> 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<bool, Common::OverlayPanelCount> overlay_draw{};
|
||||
std::array<PresentPushConstants, Common::OverlayPanelCount> overlay_push{};
|
||||
std::array<VkDescriptorSet, Common::OverlayPanelCount> 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<f32>(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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <list>
|
||||
|
||||
#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<Layer>& layers, std::span<const Tegra::FramebufferConfig> 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<vk::Buffer, 2> staging;
|
||||
std::array<std::size_t, 2> staging_size{};
|
||||
std::array<std::uint64_t, 2> 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<OverlayQuad, Common::OverlayPanelCount> overlays;
|
||||
};
|
||||
|
||||
} // namespace Vulkan
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<WindowAdaptPass> window_adapt{};
|
||||
bool overlay_panels = false;
|
||||
std::list<Layer> layers{};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <cstdlib>
|
||||
#include <mutex>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#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) {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,17 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
#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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<QList<QWidget*>>(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();
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// SPDX-FileCopyrightText: 2016 Citra Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <QBoxLayout>
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QSlider>
|
||||
#include <QStandardItemModel>
|
||||
#include <QStringLiteral>
|
||||
#include <QtCore/qobjectdefs.h>
|
||||
#include <qabstractbutton.h>
|
||||
|
|
@ -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<unsigned long>(index) < vsync_mode_combobox_enum_map.size()) {
|
||||
// (A mode greyed out for Game Mode is never selected)
|
||||
const auto* vsync_model = qobject_cast<QStandardItemModel*>(vsync_mode_combobox->model());
|
||||
if (static_cast<unsigned long>(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<QStandardItemModel*>(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());
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,34 @@
|
|||
// SPDX-FileCopyrightText: 2016 Citra Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
|
||||
#include <algorithm>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <QApplication>
|
||||
#include <QButtonGroup>
|
||||
#include <QCursor>
|
||||
#include <QDateTime>
|
||||
#include <QEvent>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFontMetrics>
|
||||
#include <QIcon>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
#include <QProxyStyle>
|
||||
#include <QRadioButton>
|
||||
#include <QStyleOptionTab>
|
||||
#include <QTabBar>
|
||||
#include <QTabWidget>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#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<QTabWidget*>(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<Common::ControllerConsoleState::PlayerRow> 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<QTabWidget*>(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<int>(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<int>(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<std::size_t>(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<int>(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<ConfigureInputPlayer*>(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<PlayerTabLook> TabLook(const QVariant& data) {
|
||||
if (data.metaType() != QMetaType::fromType<QVariantList>()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const QVariantList list = data.toList();
|
||||
if (list.size() != 2) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return PlayerTabLook{list[0].value<QColor>(), 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<const QStyleOptionTab*>(option);
|
||||
const auto* bar = qobject_cast<const QTabBar*>(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<const QStyleOptionTab*>(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<QMouseEvent*>(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<QMouseEvent*>(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<int> 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<int>(it - player_tabs.begin()));
|
||||
}
|
||||
}
|
||||
// The tabs go back in player order: the controllers move instead
|
||||
for (int k = 0; k < static_cast<int>(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<int>(dragged - player_tabs.begin())
|
||||
: -1);
|
||||
}
|
||||
|
||||
void ConfigureInput::ApplyDroppedOrder(const std::vector<int>& dropped, int dragged_player) {
|
||||
std::vector<int> 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<int, int> 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<QMouseEvent*>(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<std::pair<int, int>> 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<int>(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<int> 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<int>(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<QColor>() != 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<int>(player_tabs.size()) ? player_tabs[player]
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
QList<QWidget*> 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();
|
||||
|
|
|
|||
|
|
@ -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 <array>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QKeyEvent>
|
||||
#include <QList>
|
||||
#include <QPointer>
|
||||
#include <QProxyStyle>
|
||||
#include <QToolButton>
|
||||
#include <QWidget>
|
||||
|
||||
#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<QWidget*> 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<int>& 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<InputProfiles> profiles;
|
||||
|
||||
std::array<ConfigureInputPlayer*, 8> 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<void()> mirror_console_mode;
|
||||
/// The settings dialog's tab widget whose tab clicks rumble the players' controllers
|
||||
QTabWidget* rumble_tabs = nullptr;
|
||||
std::array<QWidget*, 8> 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<QTabBar> pill_bar;
|
||||
QPointer<PlayerTabOverlay> 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<QWidget*> tab_order;
|
||||
QWidget* dragged_tab = nullptr;
|
||||
bool tab_dragging = false;
|
||||
std::array<QString, 8> player_tab_titles{};
|
||||
std::vector<Common::ControllerConsoleState::PlayerRow> last_rows;
|
||||
/// How each player tab is lit (0 idle, 1 input held, 2 green blink, 3 red blink)
|
||||
std::array<int, 8> tab_lit{};
|
||||
/// The battery each player tab shows (percent * 2 + charging, -1 none)
|
||||
std::array<int, 8> tab_battery{-1, -1, -1, -1, -1, -1, -1, -1};
|
||||
std::array<QPointer<QToolButton>, 8> tab_lefts;
|
||||
std::array<QPointer<QToolButton>, 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<QCheckBox*, 8> connected_controller_checkboxes{};
|
||||
ConfigureInputAdvanced* advanced = nullptr;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,21 @@
|
|||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <QComboBox>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QInputDialog>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QMouseEvent>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
#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<std::pair<std::string, int>> 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<std::string, int>{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<std::string, int>& 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<QWidget*>{
|
||||
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<QBoxLayout*>(
|
||||
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<QWidget*>(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<QGroupBox*>()) {
|
||||
// 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<QGroupBox*>(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<QWidget*>(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<QBoxLayout*>(
|
||||
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<std::string> 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<int>(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<QBoxLayout*>(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<int>(&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<QWidget*>(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<int>(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<std::pair<std::string, int>> 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<std::string, int>{param.Get("guid", ""), param.Get("port", 0)};
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<Common::ParamPackage> ConfigureInputPlayer::KeptMotions() const {
|
||||
std::vector<Common::ParamPackage> motions;
|
||||
if (connection_locked) {
|
||||
for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) {
|
||||
motions.push_back(
|
||||
emulated_controller->GetMotionParam(static_cast<std::size_t>(motion_id)));
|
||||
}
|
||||
}
|
||||
return motions;
|
||||
}
|
||||
|
||||
void ConfigureInputPlayer::RestoreMotions(const std::vector<Common::ParamPackage>& 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPointer>
|
||||
#include <QWidget>
|
||||
|
||||
#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<std::pair<std::string, int>> 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<Common::ParamPackage> KeptMotions() const;
|
||||
void RestoreMotions(const std::vector<Common::ParamPackage>& 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<std::pair<std::string, int>> locked_device;
|
||||
bool style_applied{};
|
||||
bool home_centred{};
|
||||
bool shoulders_raised{};
|
||||
/// The section boxes given a bold title in the automatic modes
|
||||
std::vector<QPointer<QGroupBox>> 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
53
src/yuzu/configuration/configure_modern_per_game.cpp
Normal file
53
src/yuzu/configuration/configure_modern_per_game.cpp
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#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<std::vector<ConfigurationShared::Tab*>> 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::BasicSetting*>{
|
||||
&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);
|
||||
}
|
||||
}
|
||||
36
src/yuzu/configuration/configure_modern_per_game.h
Normal file
36
src/yuzu/configuration/configure_modern_per_game.h
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#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<std::vector<ConfigurationShared::Tab*>> group,
|
||||
const ConfigurationShared::Builder& builder,
|
||||
QWidget* parent = nullptr);
|
||||
~ConfigureModernPerGame() override;
|
||||
|
||||
void ApplyConfiguration() override;
|
||||
void SetConfiguration() override;
|
||||
|
||||
private:
|
||||
Core::System& system;
|
||||
std::vector<std::function<void(bool)>> apply_funcs;
|
||||
};
|
||||
|
|
@ -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<ConfigureSystem>(system_, tab_group, *builder, this);
|
||||
network_tab = std::make_unique<ConfigureNetwork>(system_, this);
|
||||
applets_tab = std::make_unique<ConfigureApplets>(system_, tab_group, *builder, this);
|
||||
modern_tab = std::make_unique<ConfigureModernPerGame>(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"));
|
||||
|
|
|
|||
|
|
@ -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<ConfigureSystem> system_tab;
|
||||
std::unique_ptr<ConfigureNetwork> network_tab;
|
||||
std::unique_ptr<ConfigureApplets> applets_tab;
|
||||
std::unique_ptr<ConfigureModernPerGame> modern_tab;
|
||||
};
|
||||
|
|
|
|||
1634
src/yuzu/configuration/configure_player_assignment.cpp
Normal file
1634
src/yuzu/configuration/configure_player_assignment.cpp
Normal file
File diff suppressed because it is too large
Load diff
160
src/yuzu/configuration/configure_player_assignment.h
Normal file
160
src/yuzu/configuration/configure_player_assignment.h
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QColor>
|
||||
#include <QPixmap>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
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<void(int)> 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<int>& 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<QFrame*> player_rows;
|
||||
std::vector<int> row_players;
|
||||
/// When each player's row was clicked to rumble (blue for a moment)
|
||||
std::map<int, long long> rumbled_at;
|
||||
std::function<void(int)> 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;
|
||||
};
|
||||
|
|
@ -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<QtConfig>(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<QtConfig>(new_name, Config::ConfigType::InputProfile));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<Settings::PlayerInput> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#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<Settings::PlayerInput> PeekProfile(const std::string& profile_name,
|
||||
std::size_t player_index);
|
||||
|
||||
private:
|
||||
bool ProfileExistsInMap(const std::string& profile_name) const;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
#include <QSizePolicy>
|
||||
#include <QSlider>
|
||||
#include <QSpinBox>
|
||||
#include <QStandardItemModel>
|
||||
#include <QStyle>
|
||||
#include <QValidator>
|
||||
#include <QVariant>
|
||||
|
|
@ -139,13 +140,34 @@ QWidget* Widget::CreateCombobox(std::function<std::string()>& serializer,
|
|||
const ComboboxTranslations* enumeration{nullptr};
|
||||
if (combobox_enumerations.contains(type)) {
|
||||
enumeration = &combobox_enumerations.at(type);
|
||||
const bool applet = type == Settings::EnumMetadata<Settings::AppletMode>::Index();
|
||||
const bool controller = setting.Id() == Settings::values.controller_applet_mode.Id();
|
||||
for (const auto& [id, name] : *enumeration) {
|
||||
if (type == Settings::EnumMetadata<Settings::AppletMode>::Index() &&
|
||||
id == static_cast<u32>(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<u32>(Settings::AppletMode::ConsoleMode) ||
|
||||
id == static_cast<u32>(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<u32>(Settings::AppletMode::HandheldConsoleMode) &&
|
||||
!handheld_set) {
|
||||
const int row = combobox->count() - 1;
|
||||
if (auto* model = qobject_cast<QStandardItemModel*>(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<std::string()>& 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());
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>() !=
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QList>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QStandardItemModel>
|
||||
#include <QString>
|
||||
|
|
@ -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<QMenu> controller_context_menu;
|
||||
|
||||
QVariantAnimation* vertical_scroll = nullptr;
|
||||
QVariantAnimation* horizontal_scroll = nullptr;
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@
|
|||
<addaction name="action_Show_Filter_Bar"/>
|
||||
<addaction name="action_Show_Status_Bar"/>
|
||||
<addaction name="action_Show_Performance_Overlay"/>
|
||||
<addaction name="action_Show_Controller_Overlay"/>
|
||||
<addaction name="action_Show_Controller_Hints"/>
|
||||
<addaction name="action_Post_Processing_Shaders"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="menu_Reset_Window_Size"/>
|
||||
|
|
@ -614,6 +616,22 @@
|
|||
<string>Show &Performance Overlay</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_Show_Controller_Overlay">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Show &Controller Overlay</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_Show_Controller_Hints">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Show Controller &Hints</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_Post_Processing_Shaders">
|
||||
<property name="text">
|
||||
<string>Post-Processing &Shaders...</string>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,16 @@
|
|||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
#include <array>
|
||||
#include <bitset>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <QMainWindow>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
|
|
@ -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<QAction*> 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<u32, int> 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<u32, std::string> inst_address;
|
||||
std::map<u32, int> pad_origin;
|
||||
std::set<u32> pad_guessed;
|
||||
/// Returning pads waiting a moment for their Bluetooth address before they claim a player
|
||||
std::map<u32, std::chrono::steady_clock::time_point> 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<std::string, AddressClaim> address_claims;
|
||||
/// Each Bluetooth controller's starting player, by address: kept through Turn Off and
|
||||
/// reconnects
|
||||
std::map<std::string, int> address_origin;
|
||||
/// Names given to controllers ("bt:<address>", "handheld" or "id:<identity>"). Never saved:
|
||||
/// owners can't tell identical controllers apart in a later session
|
||||
std::map<std::string, std::string> controller_names;
|
||||
/// The mode and each player's refused controller type as last logged
|
||||
std::optional<Settings::AppletMode> logged_mode;
|
||||
std::array<std::string, 8> 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<u32> benched_pads;
|
||||
/// When each player's controller was last turned off (Turn Off)
|
||||
std::array<qint64, 10> 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<std::string, std::string> pad_bluetooth;
|
||||
std::set<std::string> 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<LoneInput> last_lone_input;
|
||||
/// connection instance -> stable identity (serial, else model GUID), to match a returning pad
|
||||
std::map<u32, std::string> pad_identity;
|
||||
/// identity -> the binding set the controller carries, so its mappings follow it
|
||||
std::map<std::string, int> pad_sets;
|
||||
/// player -> the set its controller carries, as of the last tick
|
||||
std::array<int, 10> 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<u32, 10> 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<std::array<Settings::PlayerInput, 10>> 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<int, Settings::PlayerInput> 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<qint64, 10> overlay_row_since{};
|
||||
std::array<qint64, 10> overlay_row_gone{};
|
||||
std::array<QString, 10> 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<std::string, 10> overlay_row_ident{};
|
||||
/// The key each player's input history is kept under (identical controllers can share an
|
||||
/// identity)
|
||||
std::array<std::string, 10> overlay_row_history{};
|
||||
std::array<int, 10> overlay_flash{};
|
||||
std::array<qint64, 10> overlay_flash_since{};
|
||||
std::array<qint64, 10> overlay_green_at{};
|
||||
/// Per player: the real device its motion comes from under Steam (0: none)
|
||||
std::array<u32, 10> 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<OverlayRow> overlay_rows;
|
||||
std::vector<bool> overlay_blink;
|
||||
/// The room the players panel had last: redrawn when a top-right panel changes
|
||||
float overlay_room = 0.0f;
|
||||
std::set<u32> 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<std::pair<int, QRect>> 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<OverlayButton> overlay_buttons;
|
||||
bool HandleOverlayMouse(QEvent* event);
|
||||
/// Each player's given name as shown, and the name being typed (player -1: none)
|
||||
std::array<std::string, 10> overlay_custom_name{};
|
||||
/// The name of the controller that last left each player, for its hint
|
||||
std::array<QString, 10> 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<u32, std::pair<qint64, Battery>> battery_read;
|
||||
/// A controller's first BlueZ battery reading and when, by address (BlueZ says 50% at first)
|
||||
std::map<std::string, std::pair<qint64, int>> bluez_first;
|
||||
/// Whose controller each pad's battery reading is (Steam handle or identity, handheld or not)
|
||||
std::map<u32, std::string> battery_whose;
|
||||
std::set<std::string> 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<std::optional<Settings::ControllerType>, 10> joycon_auto_from{};
|
||||
std::array<std::optional<Settings::ControllerType>, 10> joycon_auto_set{};
|
||||
/// A single Joy-Con set by it: held sideways (else upright), as the game holds it
|
||||
std::array<bool, 10> joycon_auto_sideways{};
|
||||
/// Pause When a Controller Disconnects: since when each player waits (0 none), and the
|
||||
/// players a pause is for
|
||||
std::array<qint64, 10> waiting_since{};
|
||||
std::set<int> 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<bool, 10> 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<std::string, BluezConnection> 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<std::string, BluezBattery> 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<int> pause_done;
|
||||
/// Player Lights: the player each controller's lights were last set to (by connection)
|
||||
std::map<u32, int> 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<std::string, std::deque<ButtonPress>> press_history;
|
||||
/// The controller each player's buttons were last read for (a new one records no presses)
|
||||
std::array<std::string, 10> history_ident{};
|
||||
std::array<std::bitset<32>, 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<std::array<int, 2>, 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<ControllerHint> 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<qint64, 10> hinted_join{};
|
||||
std::array<qint64, 10> 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<std::array<Settings::PlayerInput, 10>> console_profile;
|
||||
/// The running game's own players, when it has input profiles of its own
|
||||
std::optional<std::array<Settings::PlayerInput, 10>> game_profiles;
|
||||
/// The binding sets the pads carry: the user's own, with a game's input profiles on top
|
||||
std::array<Settings::PlayerInput, 10> 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<Settings::PlayerInput, 10>& 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;
|
||||
|
|
|
|||
84
src/yuzu/overlay_panel.cpp
Normal file
84
src/yuzu/overlay_panel.cpp
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
|
||||
#include <QCursor>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QRectF>
|
||||
|
||||
#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<u32>(image->width());
|
||||
panel.height = static_cast<u32>(image->height());
|
||||
panel.pixels.resize(static_cast<std::size_t>(image->sizeInBytes()));
|
||||
std::memcpy(panel.pixels.data(), image->constBits(),
|
||||
static_cast<std::size_t>(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<float>(layout.screen.left), static_cast<float>(layout.screen.top),
|
||||
static_cast<float>(layout.screen.right), static_cast<float>(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<int>(logical.x() * ratio) - static_cast<int>(place.x),
|
||||
static_cast<int>(logical.y() * ratio) - static_cast<int>(place.y));
|
||||
const bool inside = at.x() >= 0 && at.y() >= 0 && at.x() < static_cast<int>(place.width) &&
|
||||
at.y() < static_cast<int>(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
|
||||
56
src/yuzu/overlay_panel.h
Normal file
56
src/yuzu/overlay_panel.h
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QPoint>
|
||||
|
||||
#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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
449
src/yuzu/util/gamescope.cpp
Normal file
449
src/yuzu/util/gamescope.cpp
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <QApplication>
|
||||
#include <QDesktopServices>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QUrl>
|
||||
#include <QFrame>
|
||||
#include <QGuiApplication>
|
||||
#include <QKeyEvent>
|
||||
#include <QLayout>
|
||||
#include <QMenu>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QScreen>
|
||||
#include <QTimer>
|
||||
#include <QWindow>
|
||||
|
||||
#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<QMenu*>()) {
|
||||
PrepareMenu(menu);
|
||||
}
|
||||
}
|
||||
|
||||
bool eventFilter(QObject* watched, QEvent* event) override {
|
||||
if (auto* menu = qobject_cast<QMenu*>(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<QWidget*>(watched);
|
||||
if (QLayout* layout = widget->layout()) {
|
||||
layout->activate();
|
||||
}
|
||||
static_cast<QObject*>(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<QWidget*>(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<QObject*>(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<QPushButton*> 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<QPointer<QPushButton>> corner_buttons;
|
||||
for (QPushButton* button : corner) {
|
||||
corner_buttons.append(button);
|
||||
}
|
||||
const auto place = [dialog_ptr = QPointer<QWidget>{dialog}, own, area, card_area,
|
||||
card_ptr = QPointer<QFrame>{card}, corner_buttons, strip,
|
||||
expand_ptr = QPointer<QPushButton>{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<QPushButton*> buttons;
|
||||
for (const QPointer<QPushButton>& 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<QFileDialog*>(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<QDialogButtonBox*>()) {
|
||||
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<QKeyEvent*>(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<QKeyEvent*>(event)->key() == Qt::Key_Left;
|
||||
if (active->property("gamescope_back").isValid() || (is_left && !pages.isEmpty())) {
|
||||
pages.removeLast();
|
||||
} else if (auto* sub = qobject_cast<QMenu*>(active->property("gamescope_sub").value<QObject*>());
|
||||
sub && !is_left) {
|
||||
pages.append(QVariant::fromValue<QObject*>(sub));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
menu->setProperty("gamescope_pages", pages);
|
||||
auto* page = pages.isEmpty() ? nullptr : qobject_cast<QMenu*>(pages.last().value<QObject*>());
|
||||
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<QAction*> 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<QObject*>(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<QObject*>(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"
|
||||
19
src/yuzu/util/gamescope.h
Normal file
19
src/yuzu/util/gamescope.h
Normal file
|
|
@ -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
|
||||
|
|
@ -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<Ui::OverlayDialog>()}, 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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue