diff --git a/src/launcher/forms/configuration_list_widget.ui b/src/launcher/forms/configuration_list_widget.ui
index e41f1b51d..64ca37bbf 100644
--- a/src/launcher/forms/configuration_list_widget.ui
+++ b/src/launcher/forms/configuration_list_widget.ui
@@ -204,6 +204,72 @@
+ -
+
+
+ 12016777215
+
+
+ Game image size
+
+
+ Game image size
+
+
+ 128
+
+
+ 320
+
+
+ 16
+
+
+ 32
+
+
+ 224
+
+
+ Qt::Horizontal
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Fixed
+
+
+ 80
+
+
+
+ -
+
+
+ 3636
+
+
+ Grid view
+
+
+ Toggle grid view
+
+
+ 3232
+
+
+ true
+
+
+ true
+
+
+
-
@@ -238,50 +304,53 @@
-
-
-
- false
-
-
-
- Game
+
+
+
+ false
-
-
-
- Serial
-
-
-
-
- Game Version
-
-
-
-
- Firmware Version
-
-
-
-
- Size
-
-
-
-
- Path
-
-
-
-
- Status
-
-
-
-
- Comments
-
-
+
+
+ Game
+
+
+
+
+ Serial
+
+
+
+
+ Game Version
+
+
+
+
+ Firmware Version
+
+
+
+
+ Size
+
+
+
+
+ Path
+
+
+
+
+ Status
+
+
+
+
+ Comments
+
+
+
+
diff --git a/src/launcher/icons/grid-view.svg b/src/launcher/icons/grid-view.svg
new file mode 100644
index 000000000..4ec705271
--- /dev/null
+++ b/src/launcher/icons/grid-view.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/launcher/include/configurationListWidget.h b/src/launcher/include/configurationListWidget.h
index 2adf4052a..2f5ef2e45 100644
--- a/src/launcher/include/configurationListWidget.h
+++ b/src/launcher/include/configurationListWidget.h
@@ -12,9 +12,9 @@
class ConfigurationItem;
class CompatibilityDatabase;
+class ControllerPreview;
class QEvent;
class QTreeWidgetItem;
-class QPoint;
namespace Ui {
class ConfigurationListWidget;
@@ -29,6 +29,7 @@ public:
void SetRunEnabled(bool flag) { m_run_enabled = flag; }
void SetRuntimeDirectory(const QString& directory) { m_runtime_directory = directory; }
+ void SetControllerPreview(ControllerPreview* preview) { m_controller_preview = preview; }
[[nodiscard]] const ConfigurationItem* GetSelectedItem() const { return m_selected_item; }
ConfigurationItem* GetSelectedItem() { return m_selected_item; }
@@ -65,13 +66,13 @@ protected slots:
void edit_global_settings();
void edit_input_mapping();
void list_itemDoubleClicked(QTreeWidgetItem* witem, int column);
- void show_context_menu(const QPoint& pos);
void open_game_folder();
void remove_save_data();
void filter_configurations(const QString& text);
private:
void SelectItem(QTreeWidgetItem* witem);
+ void ShowContextMenu(QTreeWidgetItem* witem);
void ApplyCompatibility();
void UpdateToolbarIcons();
void ImportGameSettings(QWidget* parent);
@@ -87,6 +88,7 @@ private:
Configuration m_global_info;
QMap m_custom_infos;
CompatibilityDatabase* m_compatibility = nullptr;
+ ControllerPreview* m_controller_preview = nullptr;
};
#endif // CONFIGURATION_LIST_WIDGET_H
diff --git a/src/launcher/include/controllerLightbar.h b/src/launcher/include/controllerLightbar.h
deleted file mode 100644
index 84b69f458..000000000
--- a/src/launcher/include/controllerLightbar.h
+++ /dev/null
@@ -1,29 +0,0 @@
-#ifndef CONTROLLER_LIGHTBAR_H
-#define CONTROLLER_LIGHTBAR_H
-
-#include
-#include
-#include
-#include
-#include
-
-#include
-
-class ControllerLightbar final: public QObject {
-public:
- explicit ControllerLightbar(QObject* parent = nullptr);
- ~ControllerLightbar() override;
-
- void SetColor(const QString& color);
- void Stop();
-
-private:
- void Refresh();
- void ApplyColor(SDL_Gamepad* gamepad) const;
-
- QTimer m_timer;
- QHash m_gamepads;
- QColor m_color;
-};
-
-#endif // CONTROLLER_LIGHTBAR_H
diff --git a/src/launcher/include/controllerPreview.h b/src/launcher/include/controllerPreview.h
new file mode 100644
index 000000000..cf9917fe8
--- /dev/null
+++ b/src/launcher/include/controllerPreview.h
@@ -0,0 +1,53 @@
+#ifndef CONTROLLER_PREVIEW_H
+#define CONTROLLER_PREVIEW_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+class ControllerPreview final: public QObject {
+ Q_OBJECT
+
+public:
+ explicit ControllerPreview(QObject* parent = nullptr);
+ ~ControllerPreview() override;
+
+ void SetColor(const QString& color);
+ void SetPreviewEnabled(bool enabled);
+ void Stop();
+ void SetSuspended(bool suspended);
+
+ [[nodiscard]] const QSet& Pressed() const { return m_pressed; }
+ [[nodiscard]] QPointF LeftStick() const { return m_left_stick; }
+ [[nodiscard]] QPointF RightStick() const { return m_right_stick; }
+ [[nodiscard]] const QString& DeviceName() const { return m_device_name; }
+ [[nodiscard]] const QString& Error() const { return m_error; }
+
+signals:
+ void StateChanged();
+
+private:
+ void UpdateRunning();
+ void OpenGamepad(SDL_JoystickID id);
+ void Refresh();
+ void ApplyColor(SDL_Gamepad* gamepad) const;
+
+ QTimer m_timer;
+ QMap m_gamepads;
+ QColor m_color;
+ bool m_preview_enabled = false;
+ bool m_suspended = false;
+ QSet m_pressed;
+ QPointF m_left_stick;
+ QPointF m_right_stick;
+ QString m_device_name;
+ QString m_error;
+};
+
+#endif // CONTROLLER_PREVIEW_H
diff --git a/src/launcher/include/dualsenseWidget.h b/src/launcher/include/dualsenseWidget.h
new file mode 100644
index 000000000..14c61ee98
--- /dev/null
+++ b/src/launcher/include/dualsenseWidget.h
@@ -0,0 +1,40 @@
+#ifndef LAUNCHER_INCLUDE_DUALSENSE_WIDGET_H_
+#define LAUNCHER_INCLUDE_DUALSENSE_WIDGET_H_
+
+#include
+#include
+#include
+#include
+#include
+
+class DualSenseWidget final: public QWidget {
+ Q_OBJECT
+
+public:
+ explicit DualSenseWidget(QWidget* parent = nullptr);
+ [[nodiscard]] QSize sizeHint() const override;
+ void SetPressed(const QSet& pressed);
+ void SetSelected(const QString& selected);
+ void SetSticks(const QPointF& left, const QPointF& right);
+
+signals:
+ void ControlClicked(const QString& id);
+
+protected:
+ void paintEvent(QPaintEvent* event) override;
+ void mouseMoveEvent(QMouseEvent* event) override;
+ void mousePressEvent(QMouseEvent* event) override;
+ void leaveEvent(QEvent* event) override;
+
+private:
+ [[nodiscard]] QTransform ViewTransform() const;
+ [[nodiscard]] QString HitTest(const QPointF& position) const;
+
+ QSet m_pressed;
+ QString m_selected;
+ QString m_hovered;
+ QPointF m_left_stick;
+ QPointF m_right_stick;
+};
+
+#endif /* LAUNCHER_INCLUDE_DUALSENSE_WIDGET_H_ */
diff --git a/src/launcher/include/inputMappingDialog.h b/src/launcher/include/inputMappingDialog.h
index ff7aac256..7b3b22c16 100644
--- a/src/launcher/include/inputMappingDialog.h
+++ b/src/launcher/include/inputMappingDialog.h
@@ -2,32 +2,38 @@
#define LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_
#include
+#include
#include
+class ControllerPreview;
+class DualSenseWidget;
+class QLabel;
class QPushButton;
class QDoubleSpinBox;
-class QTreeWidget;
-class QTreeWidgetItem;
-class QWidget;
class InputMappingDialog final: public QDialog {
public:
- explicit InputMappingDialog(const QStringList& mapping, QWidget* parent = nullptr);
+ explicit InputMappingDialog(const QStringList& mapping, ControllerPreview* preview,
+ QWidget* parent = nullptr);
+ ~InputMappingDialog() override;
[[nodiscard]] QStringList Mapping() const;
private:
- void ChangeBinding();
- void ClearBinding();
+ void ChangeBinding(const QString& id);
void RestoreDefaults();
- void SetBinding(QTreeWidgetItem* item, const QString& binding);
void UpdateButtons();
+ void UpdatePreview();
+ void UpdateHighlights();
- QTreeWidget* m_bindings = nullptr;
- QPushButton* m_change_button = nullptr;
- QPushButton* m_clear_button = nullptr;
- QDoubleSpinBox* m_sensitivity = nullptr;
- bool m_custom_bindings = false;
+ ControllerPreview* m_preview;
+ DualSenseWidget* m_controller = nullptr;
+ QHash m_bindings;
+ QHash m_buttons;
+ QString m_selected;
+ QLabel* m_status = nullptr;
+ QDoubleSpinBox* m_sensitivity = nullptr;
+ bool m_custom_bindings = false;
};
#endif /* LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_ */
diff --git a/src/launcher/resources.qrc b/src/launcher/resources.qrc
index 86d0c9863..36f3f45c4 100644
--- a/src/launcher/resources.qrc
+++ b/src/launcher/resources.qrc
@@ -4,6 +4,7 @@
icons/global-settings.svg
icons/input-mapping.svg
icons/refresh.svg
+ icons/grid-view.svg
icons/remove-configuration.svg
icons/trophy.svg
../../assets/images/hidden-trophy.png
diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp
index 66ffc1031..9501c805a 100644
--- a/src/launcher/src/configurationListWidget.cpp
+++ b/src/launcher/src/configurationListWidget.cpp
@@ -34,6 +34,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -46,7 +47,9 @@
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -141,6 +144,26 @@ static QStringList SettingsStringList(const QVariant& value) {
return text.isEmpty() ? QStringList() : QStringList({text});
}
+class GameGridDelegate: public QStyledItemDelegate {
+public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+
+ QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex&) const override {
+ return option.decorationSize + QSize(16, 24 + option.fontMetrics.lineSpacing() * 2);
+ }
+
+protected:
+ void initStyleOption(QStyleOptionViewItem* option, const QModelIndex& index) const override {
+ const auto image_size = option->decorationSize;
+ QStyledItemDelegate::initStyleOption(option, index);
+ option->decorationSize = image_size;
+ option->decorationPosition = QStyleOptionViewItem::Top;
+ option->decorationAlignment = Qt::AlignCenter;
+ option->displayAlignment = Qt::AlignHCenter | Qt::AlignTop;
+ option->features |= QStyleOptionViewItem::WrapText;
+ }
+};
+
static void ConfigureGameList(Ui::ConfigurationListWidget* ui) {
ui->cfgs_list->setAlternatingRowColors(false);
ui->cfgs_list->setAllColumnsShowFocus(true);
@@ -152,6 +175,21 @@ static void ConfigureGameList(Ui::ConfigurationListWidget* ui) {
ui->cfgs_list->header()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
ui->cfgs_list->header()->setHighlightSections(false);
ui->cfgs_list->header()->setStretchLastSection(true);
+
+ // Both presentations use the same items, icons and current game.
+ ui->cfgs_grid->setModel(ui->cfgs_list->model());
+ ui->cfgs_grid->setSelectionModel(ui->cfgs_list->selectionModel());
+ ui->cfgs_grid->setViewMode(QListView::IconMode);
+ ui->cfgs_grid->setMovement(QListView::Static);
+ ui->cfgs_grid->setResizeMode(QListView::Adjust);
+ ui->cfgs_grid->setSelectionMode(QAbstractItemView::SingleSelection);
+ ui->cfgs_grid->setSelectionBehavior(QAbstractItemView::SelectRows);
+ ui->cfgs_grid->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ ui->cfgs_grid->setSpacing(8);
+ ui->cfgs_grid->setUniformItemSizes(true);
+ ui->cfgs_grid->setWordWrap(true);
+ ui->cfgs_grid->setItemDelegate(new GameGridDelegate(ui->cfgs_grid));
+ ui->cfgs_grid->setContextMenuPolicy(Qt::CustomContextMenu);
}
static void AddSaveDataDir(QStringList* dirs, QSet* seen, const QString& root,
@@ -252,7 +290,27 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
connect(m_ui->cfgs_list, &QTreeWidget::itemDoubleClicked, this,
&ConfigurationListWidget::list_itemDoubleClicked);
connect(m_ui->cfgs_list, &QTreeWidget::customContextMenuRequested, this,
- &ConfigurationListWidget::show_context_menu);
+ [this](const QPoint& pos) { ShowContextMenu(m_ui->cfgs_list->itemAt(pos)); });
+ connect(m_ui->cfgs_grid, &QListView::doubleClicked, this, [this](const QModelIndex& index) {
+ list_itemDoubleClicked(m_ui->cfgs_list->itemFromIndex(index), GAME_NAME_COLUMN);
+ });
+ connect(m_ui->cfgs_grid, &QListView::customContextMenuRequested, this,
+ [this](const QPoint& pos) {
+ ShowContextMenu(m_ui->cfgs_list->itemFromIndex(m_ui->cfgs_grid->indexAt(pos)));
+ });
+ connect(m_ui->grid_view_button, &QToolButton::toggled, this, [this](bool grid) {
+ m_ui->grid_size_slider->setVisible(grid);
+ auto* view = grid ? static_cast(m_ui->cfgs_grid) : m_ui->cfgs_list;
+ m_ui->game_views->setCurrentWidget(view);
+ view->setCurrentIndex(view->currentIndex().siblingAtColumn(GAME_NAME_COLUMN));
+ view->scrollTo(view->currentIndex());
+ view->setFocus();
+ });
+ connect(m_ui->grid_view_button, &QToolButton::clicked, this,
+ &ConfigurationListWidget::WriteSettings);
+ connect(m_ui->cfgs_list->model(), &QAbstractItemModel::layoutChanged, this, [this]() {
+ filter_configurations(m_ui->search_line_edit->text());
+ });
connect(m_ui->search_line_edit, &QLineEdit::textChanged, this,
&ConfigurationListWidget::filter_configurations);
connect(m_compatibility, &CompatibilityDatabase::Updated, this,
@@ -261,6 +319,19 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
m_ui->cfgs_list->setDragDropMode(QAbstractItemView::NoDragDrop);
ReadSettings();
+ m_ui->grid_size_slider->setVisible(m_ui->grid_view_button->isChecked());
+ const auto resize_grid = [this](int width) {
+ m_ui->cfgs_grid->setIconSize(QSize(width, width * 9 / 16));
+ };
+ resize_grid(m_ui->grid_size_slider->value());
+ connect(m_ui->grid_size_slider, &QSlider::valueChanged, this, [this, resize_grid](int width) {
+ resize_grid(width);
+ if (!m_ui->grid_size_slider->isSliderDown()) {
+ WriteSettings();
+ }
+ });
+ connect(m_ui->grid_size_slider, &QSlider::sliderReleased, this,
+ &ConfigurationListWidget::WriteSettings);
if (m_compatibility->IsLocal()) {
m_compatibility->Load();
}
@@ -300,6 +371,7 @@ void ConfigurationListWidget::UpdateToolbarIcons() {
set_icon(m_ui->edit_button, QStringLiteral(":/icons/edit-configuration.svg"));
set_icon(m_ui->delete_button, QStringLiteral(":/icons/remove-configuration.svg"));
set_icon(m_ui->trophy_overview_button, QStringLiteral(":/icons/trophy.svg"));
+ set_icon(m_ui->grid_view_button, QStringLiteral(":/icons/grid-view.svg"));
}
void ConfigurationListWidget::WriteSettings() {
@@ -323,6 +395,8 @@ void ConfigurationListWidget::WriteSettings() {
s->beginGroup(CONF_LAUNCHER);
m_game_dirs = NormalizeGameDirectories(m_game_dirs);
s->setValue(CONF_GAME_DIRS, m_game_dirs);
+ s->setValue("grid_view", m_ui->grid_view_button->isChecked());
+ s->setValue("grid_icon_width", m_ui->grid_size_slider->value());
s->remove(CONF_GAME_DIR);
s->endGroup();
@@ -363,6 +437,9 @@ void ConfigurationListWidget::ReadSettings() {
ConfigurationEditDialog::ReadSettings(*s);
s->beginGroup(CONF_LAUNCHER);
+ m_ui->grid_view_button->setChecked(s->value("grid_view", false).toBool());
+ m_ui->grid_size_slider->setValue(
+ s->value("grid_icon_width", m_ui->grid_size_slider->value()).toInt());
m_game_dirs = NormalizeGameDirectories(SettingsStringList(s->value(CONF_GAME_DIRS)));
if (m_game_dirs.isEmpty()) {
m_game_dirs = NormalizeGameDirectories(SettingsStringList(s->value(CONF_GAME_DIR)));
@@ -959,7 +1036,7 @@ void ConfigurationListWidget::ExportGameSettings(QWidget* parent) const {
}
void ConfigurationListWidget::edit_input_mapping() {
- InputMappingDialog dialog(m_global_info.host_input_mapping, this);
+ InputMappingDialog dialog(m_global_info.host_input_mapping, m_controller_preview, this);
if (dialog.exec() == QDialog::Accepted) {
m_global_info.host_input_mapping = dialog.Mapping();
WriteSettings();
@@ -1097,6 +1174,7 @@ void ConfigurationListWidget::filter_configurations(const QString& text) {
item->text(GAME_NAME_COLUMN).contains(query, Qt::CaseInsensitive) ||
item->text(GAME_SERIAL_COLUMN).contains(query, Qt::CaseInsensitive);
item->setHidden(!match);
+ m_ui->cfgs_grid->setRowHidden(item_index, !match);
if (match && item == m_selected_item) {
selection_is_shown = true;
@@ -1137,8 +1215,8 @@ void ConfigurationListWidget::list_itemDoubleClicked(QTreeWidgetItem* witem, int
}
}
-void ConfigurationListWidget::show_context_menu(const QPoint& pos) {
- auto* item = static_cast(m_ui->cfgs_list->itemAt(pos));
+void ConfigurationListWidget::ShowContextMenu(QTreeWidgetItem* witem) {
+ auto* item = static_cast(witem);
if (item != nullptr) {
m_ui->cfgs_list->setCurrentItem(item);
diff --git a/src/launcher/src/controllerLightbar.cpp b/src/launcher/src/controllerLightbar.cpp
deleted file mode 100644
index 807743460..000000000
--- a/src/launcher/src/controllerLightbar.cpp
+++ /dev/null
@@ -1,84 +0,0 @@
-#include "controllerLightbar.h"
-
-#include
-
-#include
-
-ControllerLightbar::ControllerLightbar(QObject* parent): QObject(parent) {
- m_timer.setInterval(100);
- connect(&m_timer, &QTimer::timeout, this, &ControllerLightbar::Refresh);
-}
-
-ControllerLightbar::~ControllerLightbar() {
- Stop();
-}
-
-void ControllerLightbar::SetColor(const QString& color) {
- const QColor parsed(color);
- if (!parsed.isValid()) {
- Stop();
- return;
- }
- if (m_color == parsed) {
- return;
- }
- if (!m_timer.isActive()) {
- if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
- qWarning() << "Cannot initialize controller color preview:" << SDL_GetError();
- return;
- }
- m_timer.start();
- }
- m_color = parsed;
- for (auto* gamepad: m_gamepads) {
- ApplyColor(gamepad);
- }
- Refresh();
-}
-
-void ControllerLightbar::Stop() {
- if (!m_timer.isActive()) {
- return;
- }
- m_timer.stop();
- for (auto* gamepad: m_gamepads) {
- SDL_CloseGamepad(gamepad);
- }
- m_gamepads.clear();
- SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
- m_color = {};
-}
-
-void ControllerLightbar::Refresh() {
- // Bluetooth DualSense may defer an accepted LED command until its connection
- // animation ends. Polling events also delivers those deferred commands.
- SDL_Event event;
- while (SDL_PollEvent(&event)) {
- if (event.type == SDL_EVENT_GAMEPAD_ADDED && !m_gamepads.contains(event.gdevice.which)) {
- auto* gamepad = SDL_OpenGamepad(event.gdevice.which);
- if (gamepad == nullptr) {
- qWarning() << "Cannot open controller color preview:" << SDL_GetError();
- continue;
- }
- if (SDL_GetBooleanProperty(SDL_GetGamepadProperties(gamepad),
- SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false)) {
- m_gamepads.insert(event.gdevice.which, gamepad);
- ApplyColor(gamepad);
- } else {
- SDL_CloseGamepad(gamepad);
- }
- } else if (event.type == SDL_EVENT_GAMEPAD_REMOVED) {
- if (auto* gamepad = m_gamepads.take(event.gdevice.which); gamepad != nullptr) {
- SDL_CloseGamepad(gamepad);
- }
- }
- }
-}
-
-void ControllerLightbar::ApplyColor(SDL_Gamepad* gamepad) const {
- if (!SDL_SetGamepadLED(gamepad, static_cast(m_color.red()),
- static_cast(m_color.green()),
- static_cast(m_color.blue()))) {
- qWarning() << "Cannot set controller LED:" << SDL_GetError();
- }
-}
diff --git a/src/launcher/src/controllerPreview.cpp b/src/launcher/src/controllerPreview.cpp
new file mode 100644
index 000000000..cabe2894b
--- /dev/null
+++ b/src/launcher/src/controllerPreview.cpp
@@ -0,0 +1,202 @@
+#include "controllerPreview.h"
+
+#include
+
+#include
+#include
+
+namespace {
+
+constexpr struct {
+ SDL_GamepadButton button;
+ const char* control;
+} BUTTONS[] = {
+ {SDL_GAMEPAD_BUTTON_SOUTH, "Cross"}, {SDL_GAMEPAD_BUTTON_EAST, "Circle"},
+ {SDL_GAMEPAD_BUTTON_WEST, "Square"}, {SDL_GAMEPAD_BUTTON_NORTH, "Triangle"},
+ {SDL_GAMEPAD_BUTTON_DPAD_UP, "Up"}, {SDL_GAMEPAD_BUTTON_DPAD_DOWN, "Down"},
+ {SDL_GAMEPAD_BUTTON_DPAD_LEFT, "Left"}, {SDL_GAMEPAD_BUTTON_DPAD_RIGHT, "Right"},
+ {SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, "L1"}, {SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, "R1"},
+ {SDL_GAMEPAD_BUTTON_LEFT_STICK, "L3"}, {SDL_GAMEPAD_BUTTON_RIGHT_STICK, "R3"},
+ {SDL_GAMEPAD_BUTTON_START, "Options"},
+};
+
+double Axis(SDL_Gamepad* gamepad, SDL_GamepadAxis axis) {
+ const int value = SDL_GetGamepadAxis(gamepad, axis);
+ return value / (value < 0 ? 32768.0 : 32767.0);
+}
+
+} // namespace
+
+ControllerPreview::ControllerPreview(QObject* parent): QObject(parent) {
+ m_timer.setInterval(100);
+ connect(&m_timer, &QTimer::timeout, this, &ControllerPreview::Refresh);
+}
+
+ControllerPreview::~ControllerPreview() {
+ Stop();
+}
+
+void ControllerPreview::SetColor(const QString& color) {
+ const QColor parsed(color);
+ if (m_color == parsed && m_timer.isActive()) {
+ return;
+ }
+ m_color = parsed;
+ UpdateRunning();
+ for (auto* gamepad: m_gamepads) {
+ ApplyColor(gamepad);
+ }
+}
+
+void ControllerPreview::SetPreviewEnabled(bool enabled) {
+ m_preview_enabled = enabled;
+ m_timer.setInterval(enabled ? 16 : 100);
+ UpdateRunning();
+}
+
+void ControllerPreview::Stop() {
+ m_preview_enabled = false;
+ m_color = {};
+ UpdateRunning();
+}
+
+void ControllerPreview::SetSuspended(bool suspended) {
+ if (m_suspended != suspended) {
+ m_suspended = suspended;
+ UpdateRunning();
+ }
+}
+
+void ControllerPreview::UpdateRunning() {
+ if (!m_suspended && (m_preview_enabled || m_color.isValid())) {
+ if (m_timer.isActive()) {
+ return;
+ }
+ SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
+ if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
+ m_error = QString::fromUtf8(SDL_GetError());
+ qWarning() << "Cannot initialize controller preview:" << m_error;
+ emit StateChanged();
+ return;
+ }
+ m_error.clear();
+ int count = 0;
+ auto* ids = SDL_GetGamepads(&count);
+ for (int index = 0; index < count; ++index) {
+ OpenGamepad(ids[index]);
+ }
+ SDL_free(ids);
+ m_timer.start();
+ Refresh();
+ emit StateChanged();
+ return;
+ }
+ if (m_timer.isActive()) {
+ m_timer.stop();
+ for (auto* gamepad: m_gamepads) {
+ SDL_CloseGamepad(gamepad);
+ }
+ m_gamepads.clear();
+ SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
+ }
+ m_pressed.clear();
+ m_left_stick = {};
+ m_right_stick = {};
+ m_device_name.clear();
+ m_error = m_suspended ? tr("Preview is paused while a game is running.") : QString();
+ emit StateChanged();
+}
+
+void ControllerPreview::OpenGamepad(SDL_JoystickID id) {
+ if (!m_gamepads.contains(id)) {
+ if (auto* gamepad = SDL_OpenGamepad(id); gamepad != nullptr) {
+ m_gamepads.insert(id, gamepad);
+ ApplyColor(gamepad);
+ }
+ }
+}
+
+void ControllerPreview::Refresh() {
+ // One SDL event pump serves both the live diagram and lightbar preview.
+ // It also delivers deferred Bluetooth DualSense LED commands.
+ SDL_Event event;
+ while (SDL_PollEvent(&event)) {
+ if (event.type == SDL_EVENT_GAMEPAD_ADDED) {
+ OpenGamepad(event.gdevice.which);
+ } else if (event.type == SDL_EVENT_GAMEPAD_REMOVED) {
+ if (auto* gamepad = m_gamepads.take(event.gdevice.which); gamepad != nullptr) {
+ SDL_CloseGamepad(gamepad);
+ }
+ }
+ }
+
+ QSet pressed;
+ QPointF left;
+ QPointF right;
+ QString name;
+ if (!m_gamepads.isEmpty()) {
+ auto* gamepad = m_gamepads.first();
+ name = QString::fromUtf8(SDL_GetGamepadName(gamepad));
+ for (const auto& binding: BUTTONS) {
+ if (SDL_GetGamepadButton(gamepad, binding.button)) {
+ pressed.insert(QString::fromLatin1(binding.control));
+ }
+ }
+ const auto stick = [&](SDL_GamepadAxis x, SDL_GamepadAxis y, const char* prefix) {
+ QPointF position(Axis(gamepad, x), Axis(gamepad, y));
+ if (std::hypot(position.x(), position.y()) < 0.15) {
+ position = {};
+ }
+ const auto id = QString::fromLatin1(prefix);
+ if (position.x() < -0.25) pressed.insert(id + QStringLiteral("Left"));
+ if (position.x() > 0.25) pressed.insert(id + QStringLiteral("Right"));
+ if (position.y() < -0.25) pressed.insert(id + QStringLiteral("Up"));
+ if (position.y() > 0.25) pressed.insert(id + QStringLiteral("Down"));
+ return position;
+ };
+ left = stick(SDL_GAMEPAD_AXIS_LEFTX, SDL_GAMEPAD_AXIS_LEFTY, "LeftStick");
+ right = stick(SDL_GAMEPAD_AXIS_RIGHTX, SDL_GAMEPAD_AXIS_RIGHTY, "RightStick");
+ if (Axis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > 0.05) {
+ pressed.insert(QStringLiteral("L2"));
+ }
+ if (Axis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > 0.05) {
+ pressed.insert(QStringLiteral("R2"));
+ }
+ bool touching = false;
+ if (SDL_GetNumGamepadTouchpads(gamepad) > 0) {
+ for (int finger = 0; finger < SDL_GetNumGamepadTouchpadFingers(gamepad, 0); ++finger) {
+ bool down = false;
+ float x = 0;
+ if (SDL_GetGamepadTouchpadFinger(gamepad, 0, finger, &down, &x, nullptr, nullptr) &&
+ down) {
+ pressed.insert(x < 0.5f ? QStringLiteral("TouchPad")
+ : QStringLiteral("TouchPadRight"));
+ touching = true;
+ }
+ }
+ }
+ if (!touching && SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_TOUCHPAD)) {
+ pressed.insert(QStringLiteral("TouchPad"));
+ pressed.insert(QStringLiteral("TouchPadRight"));
+ }
+ }
+ if (pressed != m_pressed || left != m_left_stick || right != m_right_stick ||
+ name != m_device_name) {
+ m_pressed = pressed;
+ m_left_stick = left;
+ m_right_stick = right;
+ m_device_name = name;
+ emit StateChanged();
+ }
+}
+
+void ControllerPreview::ApplyColor(SDL_Gamepad* gamepad) const {
+ if (m_color.isValid() &&
+ SDL_GetBooleanProperty(SDL_GetGamepadProperties(gamepad),
+ SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false) &&
+ !SDL_SetGamepadLED(gamepad, static_cast(m_color.red()),
+ static_cast(m_color.green()),
+ static_cast(m_color.blue()))) {
+ qWarning() << "Cannot set controller LED:" << SDL_GetError();
+ }
+}
diff --git a/src/launcher/src/dualsenseWidget.cpp b/src/launcher/src/dualsenseWidget.cpp
new file mode 100644
index 000000000..e9f4c8efd
--- /dev/null
+++ b/src/launcher/src/dualsenseWidget.cpp
@@ -0,0 +1,300 @@
+#include "dualsenseWidget.h"
+
+#include
+#include
+#include
+
+#include
+#include
+
+namespace {
+
+enum class Shape { Button, Shoulder, Touchpad, Face, Dpad, Stick, Direction };
+
+struct Control {
+ const char* id;
+ QRectF bounds;
+ Shape shape = Shape::Button;
+ int angle = 0;
+};
+
+// Painting and hit testing share a canvas with room above the shell for the shoulders.
+const QRectF CANVAS(0, -20, 480, 330);
+
+const Control CONTROLS[] = {
+ {"L2", {90, -12, 58, 18}, Shape::Shoulder},
+ {"R2", {332, -12, 58, 18}, Shape::Shoulder},
+ {"L1", {84, 12, 64, 12}, Shape::Shoulder},
+ {"R1", {332, 12, 64, 12}, Shape::Shoulder},
+ {"TouchPad", {150, 25, 90, 100}, Shape::Touchpad},
+ {"TouchPadRight", {240, 25, 90, 100}, Shape::Touchpad},
+ {"Options", {330, 60, 9, 20}},
+ {"Up", {105, 72, 22, 25}, Shape::Dpad, 0},
+ {"Right", {124, 94, 25, 22}, Shape::Dpad, 90},
+ {"Down", {105, 113, 22, 25}, Shape::Dpad, 180},
+ {"Left", {83, 94, 25, 22}, Shape::Dpad, 270},
+ {"Triangle", {353, 67, 26, 26}, Shape::Face},
+ {"Circle", {382, 96, 26, 26}, Shape::Face},
+ {"Cross", {353, 125, 26, 26}, Shape::Face},
+ {"Square", {324, 96, 26, 26}, Shape::Face},
+ {"L3", {143, 131, 66, 66}, Shape::Stick},
+ {"R3", {271, 131, 66, 66}, Shape::Stick},
+ {"LeftStickUp", {143, 131, 66, 66}, Shape::Direction, 0},
+ {"LeftStickRight", {143, 131, 66, 66}, Shape::Direction, 90},
+ {"LeftStickDown", {143, 131, 66, 66}, Shape::Direction, 180},
+ {"LeftStickLeft", {143, 131, 66, 66}, Shape::Direction, 270},
+ {"RightStickUp", {271, 131, 66, 66}, Shape::Direction, 0},
+ {"RightStickRight", {271, 131, 66, 66}, Shape::Direction, 90},
+ {"RightStickDown", {271, 131, 66, 66}, Shape::Direction, 180},
+ {"RightStickLeft", {271, 131, 66, 66}, Shape::Direction, 270},
+};
+
+QPainterPath TouchpadPath() {
+ QPainterPath path;
+ path.moveTo(158, 42);
+ path.cubicTo(156, 33, 168, 32, 178, 31);
+ path.cubicTo(219, 28, 261, 28, 302, 31);
+ path.cubicTo(312, 32, 324, 33, 322, 42);
+ path.cubicTo(318, 64, 315, 87, 309, 105);
+ path.cubicTo(306, 117, 299, 122, 286, 122);
+ path.lineTo(194, 122);
+ path.cubicTo(181, 122, 174, 117, 171, 105);
+ path.cubicTo(165, 87, 162, 64, 158, 42);
+ path.closeSubpath();
+ return path;
+}
+
+QPainterPath ControlPath(const Control& control) {
+ QPainterPath path;
+ if (control.shape == Shape::Direction) {
+ const auto inner = control.bounds.adjusted(7, 7, -7, -7);
+ const int start = 49 - control.angle;
+ path.arcMoveTo(control.bounds, start);
+ path.arcTo(control.bounds, start, 82);
+ path.arcTo(inner, start + 82, -82);
+ path.closeSubpath();
+ } else if (control.shape == Shape::Touchpad) {
+ path.addRect(control.bounds);
+ return TouchpadPath().intersected(path);
+ } else if (control.shape == Shape::Face || control.shape == Shape::Stick) {
+ path.addEllipse(control.shape == Shape::Stick ? control.bounds.adjusted(7, 7, -7, -7)
+ : control.bounds);
+ } else if (control.shape == Shape::Dpad) {
+ path.moveTo(-8, -12);
+ path.quadTo(-11, -12, -11, -8);
+ path.lineTo(-10, 3);
+ path.quadTo(-10, 6, -7, 8);
+ path.lineTo(0, 12);
+ path.lineTo(7, 8);
+ path.quadTo(10, 6, 10, 3);
+ path.lineTo(11, -8);
+ path.quadTo(11, -12, 8, -12);
+ path.closeSubpath();
+ QTransform transform;
+ transform.translate(control.bounds.center().x(), control.bounds.center().y());
+ transform.rotate(control.angle);
+ return transform.map(path);
+ } else if (control.shape == Shape::Shoulder) {
+ const auto& bounds = control.bounds;
+ path.moveTo(bounds.bottomLeft());
+ path.lineTo(bounds.left() + 2, bounds.top() + bounds.height() / 2);
+ path.cubicTo(bounds.left() + 10, bounds.top(), bounds.right() - 10, bounds.top(),
+ bounds.right() - 2, bounds.top() + bounds.height() / 2);
+ path.lineTo(bounds.bottomRight());
+ path.closeSubpath();
+ } else {
+ path.addRoundedRect(control.bounds, 4, 4);
+ }
+ return path;
+}
+
+void DrawSymbol(QPainter& painter, const Control& control) {
+ const auto id = QLatin1String(control.id);
+ painter.save();
+ painter.translate(control.bounds.center());
+ painter.setBrush(Qt::NoBrush);
+ if (control.shape == Shape::Dpad) {
+ painter.rotate(control.angle);
+ painter.drawPolyline(QPolygonF({{-4, 2}, {0, -2}, {4, 2}}));
+ } else if (id == QLatin1String("Triangle")) {
+ painter.drawPolygon(QPolygonF({{0, -7}, {7, 5}, {-7, 5}}));
+ } else if (id == QLatin1String("Circle")) {
+ painter.drawEllipse(QPointF(), 7, 7);
+ } else if (id == QLatin1String("Square")) {
+ painter.drawRect(QRectF(-6, -6, 12, 12));
+ } else if (id == QLatin1String("Cross")) {
+ painter.drawLine(QPointF(-5, -5), QPointF(5, 5));
+ painter.drawLine(QPointF(-5, 5), QPointF(5, -5));
+ }
+ painter.restore();
+}
+
+} // namespace
+
+DualSenseWidget::DualSenseWidget(QWidget* parent): QWidget(parent) {
+ setMinimumSize(330, 230);
+ setMouseTracking(true);
+ setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ setAccessibleName(tr("DualSense controller"));
+ setAccessibleDescription(tr("Click a control to select its binding. Connected controller input "
+ "lights up the controls."));
+}
+
+QSize DualSenseWidget::sizeHint() const {
+ return CANVAS.size().toSize();
+}
+
+void DualSenseWidget::SetPressed(const QSet& pressed) {
+ if (m_pressed != pressed) {
+ m_pressed = pressed;
+ update();
+ }
+}
+
+void DualSenseWidget::SetSelected(const QString& selected) {
+ if (m_selected != selected) {
+ m_selected = selected;
+ update();
+ }
+}
+
+void DualSenseWidget::SetSticks(const QPointF& left, const QPointF& right) {
+ if (m_left_stick != left || m_right_stick != right) {
+ m_left_stick = left;
+ m_right_stick = right;
+ update();
+ }
+}
+
+QTransform DualSenseWidget::ViewTransform() const {
+ const qreal scale = std::min(width() / CANVAS.width(), height() / CANVAS.height());
+ QTransform transform;
+ transform.translate(width() / 2.0, height() / 2.0);
+ transform.scale(scale, scale);
+ transform.translate(-CANVAS.center().x(), -CANVAS.center().y());
+ return transform;
+}
+
+QString DualSenseWidget::HitTest(const QPointF& position) const {
+ const auto point = ViewTransform().inverted().map(position);
+ for (const auto& control: CONTROLS) {
+ if (ControlPath(control).contains(point)) {
+ return QString::fromLatin1(control.id);
+ }
+ }
+ return {};
+}
+
+void DualSenseWidget::paintEvent(QPaintEvent*) {
+ const QColor black("#111111");
+ const QColor white("#eeeeee");
+ QPainter painter(this);
+ painter.setRenderHint(QPainter::Antialiasing);
+ painter.setTransform(ViewTransform());
+
+ QPainterPath shell;
+ shell.moveTo(81, 48);
+ shell.cubicTo(151, 21, 329, 21, 399, 48);
+ shell.cubicTo(420, 94, 445, 166, 445, 215);
+ shell.cubicTo(447, 253, 437, 291, 419, 296);
+ shell.cubicTo(403, 302, 395, 297, 389, 283);
+ shell.cubicTo(381, 268, 373, 236, 354, 213);
+ shell.cubicTo(347, 205, 341, 205, 325, 205);
+ shell.lineTo(155, 205);
+ shell.cubicTo(139, 205, 133, 205, 126, 213);
+ shell.cubicTo(107, 236, 99, 268, 91, 283);
+ shell.cubicTo(85, 297, 77, 302, 61, 296);
+ shell.cubicTo(43, 291, 33, 253, 35, 215);
+ shell.cubicTo(35, 166, 60, 94, 81, 48);
+ shell.closeSubpath();
+ painter.setBrush(black);
+ painter.setPen(QPen(white, 1.5));
+ painter.drawPath(shell);
+
+ // Panel seams flow into the inner grips without a second outline across the belly.
+ QPainterPath seams;
+ seams.moveTo(153, 35);
+ seams.cubicTo(153, 69, 176, 105, 158, 127);
+ seams.cubicTo(123, 166, 108, 226, 76, 297);
+ seams.moveTo(327, 35);
+ seams.cubicTo(327, 69, 304, 105, 322, 127);
+ seams.cubicTo(357, 166, 372, 226, 404, 297);
+ painter.setBrush(Qt::NoBrush);
+ painter.drawPath(seams);
+ painter.setBrush(black);
+ painter.drawPath(TouchpadPath());
+ painter.drawRoundedRect(QRectF(141, 60, 9, 20), 4, 4); // Create button, currently unmapped.
+ painter.drawEllipse(QPointF(240, 166), 8, 8);
+ QFont font = painter.font();
+ font.setPixelSize(9);
+ font.setBold(true);
+ painter.setFont(font);
+
+ for (const auto& control: CONTROLS) {
+ const auto id = QString::fromLatin1(control.id);
+ const bool pressed = m_pressed.contains(id);
+ const bool selected = m_selected == id;
+ const bool hovered = m_hovered == id;
+ const bool active = pressed || selected || hovered;
+ if ((control.shape == Shape::Direction || control.shape == Shape::Touchpad) && !active) {
+ continue;
+ }
+ const QColor fill = pressed ? QColor("#2eb6df") : active ? QColor("#3a6179") : black;
+ painter.setBrush(fill);
+ painter.setPen(QPen(active ? QColor("#79e3ff") : white, selected ? 2 : 1.3));
+ if (control.shape == Shape::Touchpad) {
+ // Clip one shared surface so independent bindings never add a permanent center seam.
+ painter.save();
+ painter.setClipRect(control.bounds);
+ painter.drawPath(TouchpadPath());
+ painter.restore();
+ } else if (control.shape == Shape::Stick) {
+ painter.setBrush(black);
+ painter.drawEllipse(control.bounds);
+ const auto position = id == QLatin1String("L3") ? m_left_stick : m_right_stick;
+ QPointF offset(std::clamp(position.x(), -1.0, 1.0),
+ std::clamp(position.y(), -1.0, 1.0));
+ offset *= 5 / std::max(1.0, std::hypot(offset.x(), offset.y()));
+ painter.setBrush(fill);
+ painter.drawEllipse(control.bounds.adjusted(12, 12, -12, -12).translated(offset));
+ } else {
+ painter.drawPath(ControlPath(control));
+ painter.setPen(QPen(white, 1.3));
+ if (control.shape == Shape::Face || control.shape == Shape::Dpad) {
+ DrawSymbol(painter, control);
+ } else if (control.shape == Shape::Shoulder) {
+ painter.drawText(control.bounds.adjusted(0, 2, 0, 0), Qt::AlignCenter, id);
+ } else if (id == QLatin1String("Options")) {
+ painter.drawLine(control.bounds.center() - QPointF(0, 4),
+ control.bounds.center() + QPointF(0, 4));
+ }
+ }
+ }
+}
+
+void DualSenseWidget::mouseMoveEvent(QMouseEvent* event) {
+ const auto hovered = HitTest(event->position());
+ if (hovered != m_hovered) {
+ m_hovered = hovered;
+ setCursor(hovered.isEmpty() ? Qt::ArrowCursor : Qt::PointingHandCursor);
+ setToolTip(hovered);
+ update();
+ }
+}
+
+void DualSenseWidget::mousePressEvent(QMouseEvent* event) {
+ if (event->button() == Qt::LeftButton) {
+ const auto id = HitTest(event->position());
+ if (!id.isEmpty()) {
+ emit ControlClicked(id);
+ return;
+ }
+ }
+ QWidget::mousePressEvent(event);
+}
+
+void DualSenseWidget::leaveEvent(QEvent*) {
+ m_hovered.clear();
+ unsetCursor();
+ update();
+}
diff --git a/src/launcher/src/inputMappingDialog.cpp b/src/launcher/src/inputMappingDialog.cpp
index 9dbc932f2..4e2a7acc5 100644
--- a/src/launcher/src/inputMappingDialog.cpp
+++ b/src/launcher/src/inputMappingDialog.cpp
@@ -1,61 +1,69 @@
#include "inputMappingDialog.h"
-#include
+#include "controllerPreview.h"
+#include "dualsenseWidget.h"
+
#include
#include
+#include
+#include
#include
#include
-#include
#include
#include
#include
#include
#include
+#include
+#include
#include
-#include
#include
namespace {
-constexpr int BINDING_COLUMN = 1;
constexpr auto DEFAULT_MOUSE_SENSITIVITY = 1.0;
constexpr char MOUSE_SENSITIVITY[] = "MouseSensitivity=";
+enum Group { Dpad, LeftStick, RightStick, Face, Shoulders, Center, Shortcuts, GroupCount };
+
struct PadControl {
const char* id;
const char* label;
const char* default_binding;
+ Group group;
+ int row;
+ int column;
};
constexpr PadControl PAD_CONTROLS[] = {
- {"Up", "D-pad Up", "Up"},
- {"Down", "D-pad Down", "Down"},
- {"Left", "D-pad Left", "Left"},
- {"Right", "D-pad Right", "Right"},
- {"LeftStickUp", "Left stick Up", "W"},
- {"LeftStickDown", "Left stick Down", "S"},
- {"LeftStickLeft", "Left stick Left", "A"},
- {"LeftStickRight", "Left stick Right", "D"},
- {"RightStickUp", "Right stick Up", "T"},
- {"RightStickDown", "Right stick Down", "G"},
- {"RightStickLeft", "Right stick Left", "F"},
- {"RightStickRight", "Right stick Right", "H"},
- {"Triangle", "Triangle", "I"},
- {"Circle", "Circle", "L"},
- {"Cross", "Cross", "J"},
- {"Square", "Square", "K"},
- {"L1", "L1", "Q"},
- {"R1", "R1", "E"},
- {"L2", "L2", "Z"},
- {"R2", "R2", "C"},
- {"L3", "L3", "Left Shift"},
- {"R3", "R3", "Left Ctrl"},
- {"Options", "Options", "Return"},
- {"TouchPad", "Touch pad left (SELECT)", "Backspace"},
- {"TouchPadRight", "Touch pad right (START)", "Tab"},
- {"SpeakerVolume", "Speaker volume (cycle)", "1"},
- {"VibrationIntensity", "Vibration intensity (cycle)", "2"},
- {"TriggerEffectIntensity", "Trigger effect intensity (cycle)", "3"},
+ {"Up", "Up", "Up", Dpad, 0, 1},
+ {"Down", "Down", "Down", Dpad, 2, 1},
+ {"Left", "Left", "Left", Dpad, 1, 0},
+ {"Right", "Right", "Right", Dpad, 1, 2},
+ {"LeftStickUp", "Up", "W", LeftStick, 0, 1},
+ {"LeftStickDown", "Down", "S", LeftStick, 2, 1},
+ {"LeftStickLeft", "Left", "A", LeftStick, 1, 0},
+ {"LeftStickRight", "Right", "D", LeftStick, 1, 2},
+ {"RightStickUp", "Up", "T", RightStick, 0, 1},
+ {"RightStickDown", "Down", "G", RightStick, 2, 1},
+ {"RightStickLeft", "Left", "F", RightStick, 1, 0},
+ {"RightStickRight", "Right", "H", RightStick, 1, 2},
+ {"Triangle", "Triangle", "I", Face, 0, 1},
+ {"Circle", "Circle", "L", Face, 1, 2},
+ {"Cross", "Cross", "J", Face, 2, 1},
+ {"Square", "Square", "K", Face, 1, 0},
+ {"L1", "L1", "Q", Shoulders, 0, 1},
+ {"R1", "R1", "E", Shoulders, 0, 2},
+ {"L2", "L2", "Z", Shoulders, 0, 0},
+ {"R2", "R2", "C", Shoulders, 0, 3},
+ {"L3", "Press / L3", "Left Shift", LeftStick, 1, 1},
+ {"R3", "Press / R3", "Left Ctrl", RightStick, 1, 1},
+ {"Options", "Options", "Return", Center, 0, 1},
+ {"TouchPad", "Touch pad left", "Backspace", Center, 0, 0},
+ {"TouchPadRight", "Touch pad right", "Tab", Center, 0, 2},
+ {"SpeakerVolume", "Speaker volume", "1", Shortcuts, 0, 0},
+ {"VibrationIntensity", "Vibration intensity", "2", Shortcuts, 0, 1},
+ {"TriggerEffectIntensity", "Trigger intensity", "3", Shortcuts, 0, 2},
};
QString KeypadName(int key) {
@@ -183,6 +191,17 @@ private:
QString m_binding;
};
+void AssignBinding(QHash& bindings, const QString& id, const QString& binding) {
+ for (auto item = bindings.begin(); item != bindings.end();) {
+ if (item.value().compare(binding, Qt::CaseInsensitive) == 0) {
+ item = bindings.erase(item);
+ } else {
+ ++item;
+ }
+ }
+ bindings.insert(id, binding);
+}
+
QHash ParseMapping(const QStringList& mapping) {
QHash result;
for (const auto& entry: mapping) {
@@ -191,15 +210,7 @@ QHash ParseMapping(const QStringList& mapping) {
}
const auto separator = entry.indexOf(QLatin1Char('='));
if (separator > 0 && separator + 1 < entry.size()) {
- const auto binding = entry.mid(separator + 1);
- for (auto item = result.begin(); item != result.end();) {
- if (item.value().compare(binding, Qt::CaseInsensitive) == 0) {
- item = result.erase(item);
- } else {
- ++item;
- }
- }
- result.insert(entry.left(separator), binding);
+ AssignBinding(result, entry.left(separator), entry.mid(separator + 1));
}
}
return result;
@@ -216,82 +227,146 @@ double ParseMouseSensitivity(const QStringList& mapping) {
} // namespace
-InputMappingDialog::InputMappingDialog(const QStringList& mapping, QWidget* parent)
- : QDialog(parent) {
- setWindowTitle(tr("Input Mapping"));
- resize(460, 650);
+InputMappingDialog::InputMappingDialog(const QStringList& mapping, ControllerPreview* preview,
+ QWidget* parent)
+ : QDialog(parent), m_preview(preview) {
+ setWindowTitle(tr("Controls"));
+ resize(QSize(1100, 820).boundedTo(screen()->availableGeometry().size()));
auto* layout = new QVBoxLayout(this);
- layout->addWidget(
- new QLabel(tr("Map keyboard or mouse buttons to DualSense controls.\n"
- "Press F7 in-game to toggle mouse movement on the right stick."),
- this));
+ layout->setSpacing(10);
+ auto* title = new QLabel(tr("Controller setup"), this);
+ auto font = title->font();
+ font.setPointSize(font.pointSize() + 7);
+ font.setBold(true);
+ title->setFont(font);
+ layout->addWidget(title);
+ auto* hint = new QLabel(
+ tr("Select a button to remap. Press controller buttons to test."), this);
+ hint->setWordWrap(true);
+ layout->addWidget(hint);
+ m_status = new QLabel(this);
+ m_status->setObjectName(QStringLiteral("controllerStatus"));
+ m_status->setWordWrap(true);
+ layout->addWidget(m_status);
- const auto parsed = ParseMapping(mapping);
- m_custom_bindings = !parsed.isEmpty();
+ auto* scroll = new QScrollArea(this);
+ scroll->setWidgetResizable(true);
+ scroll->setFrameShape(QFrame::NoFrame);
+ auto* content = new QWidget(scroll);
+ auto* grid = new QGridLayout(content);
+ grid->setContentsMargins(0, 0, 0, 0);
+ grid->setSpacing(12);
+ QGroupBox* groups[GroupCount];
+ QGridLayout* cells[GroupCount];
+ const char* titles[] = {"D-Pad",
+ "Left stick",
+ "Right stick",
+ "Face buttons",
+ "Shoulders and triggers",
+ "Touch pad and options",
+ "In-game shortcuts (cycle)"};
+ for (int index = 0; index < GroupCount; ++index) {
+ groups[index] = new QGroupBox(tr(titles[index]), content);
+ cells[index] = new QGridLayout(groups[index]);
+ cells[index]->setSpacing(5);
+ }
+
+ m_bindings = ParseMapping(mapping);
+ m_custom_bindings = !m_bindings.isEmpty();
+ for (const auto& control: PAD_CONTROLS) {
+ const auto id = QString::fromLatin1(control.id);
+ auto* cell = new QGroupBox(tr(control.label), groups[control.group]);
+ auto* cell_layout = new QVBoxLayout(cell);
+ cell_layout->setContentsMargins(3, 5, 3, 3);
+ auto* button = new QPushButton(cell);
+ button->setObjectName(QStringLiteral("binding_") + id);
+ button->setAutoDefault(false);
+ button->setMinimumWidth(64);
+ button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+ button->setAccessibleName(tr(titles[control.group]) + QStringLiteral(" / ") +
+ tr(control.label));
+ cell_layout->addWidget(button);
+ cells[control.group]->addWidget(cell, control.row, control.column);
+ m_buttons.insert(id, button);
+ if (!m_custom_bindings) {
+ m_bindings.insert(id, QString::fromLatin1(control.default_binding));
+ }
+ connect(button, &QPushButton::clicked, this, [this, id] { ChangeBinding(id); });
+ }
+
+ auto* left = new QVBoxLayout;
+ left->addWidget(groups[Dpad]);
+ left->addWidget(groups[LeftStick]);
+ left->addStretch();
+ grid->addLayout(left, 0, 0);
+ auto* center = new QVBoxLayout;
+ center->addWidget(groups[Shoulders]);
+ m_controller = new DualSenseWidget(content);
+ m_controller->setObjectName(QStringLiteral("controllerDiagram"));
+ center->addWidget(m_controller, 1);
+ center->addWidget(groups[Center]);
+ grid->addLayout(center, 0, 1);
+ auto* right = new QVBoxLayout;
+ right->addWidget(groups[Face]);
+ right->addWidget(groups[RightStick]);
+ right->addStretch();
+ grid->addLayout(right, 0, 2);
+ grid->setColumnStretch(1, 1);
+ grid->addWidget(groups[Shortcuts], 1, 0, 1, 3);
+ scroll->setWidget(content);
+ layout->addWidget(scroll, 1);
auto* sensitivity_layout = new QHBoxLayout;
sensitivity_layout->addWidget(new QLabel(tr("Mouse sensitivity"), this));
m_sensitivity = new QDoubleSpinBox(this);
+ m_sensitivity->setObjectName(QStringLiteral("mouseSensitivity"));
m_sensitivity->setRange(0.1, 5.0);
m_sensitivity->setSingleStep(0.1);
m_sensitivity->setDecimals(1);
- m_sensitivity->setSuffix(QStringLiteral("x"));
+ m_sensitivity->setSuffix(QStringLiteral("×"));
m_sensitivity->setValue(ParseMouseSensitivity(mapping));
sensitivity_layout->addWidget(m_sensitivity);
+ sensitivity_layout->addWidget(
+ new QLabel(tr("F7 in-game toggles mouse movement on the right stick."), this));
sensitivity_layout->addStretch();
layout->addLayout(sensitivity_layout);
- m_bindings = new QTreeWidget(this);
- m_bindings->setColumnCount(2);
- m_bindings->setHeaderLabels({tr("DualSense control"), tr("Host input")});
- m_bindings->setRootIsDecorated(false);
- m_bindings->setSelectionMode(QAbstractItemView::SingleSelection);
- m_bindings->header()->setSectionResizeMode(0, QHeaderView::Stretch);
- m_bindings->header()->setSectionResizeMode(1, QHeaderView::Stretch);
- layout->addWidget(m_bindings);
-
- for (const auto& control: PAD_CONTROLS) {
- auto* item = new QTreeWidgetItem(m_bindings);
- item->setText(0, tr(control.label));
- item->setData(0, Qt::UserRole, QString::fromLatin1(control.id));
- SetBinding(item, m_custom_bindings ? parsed.value(QString::fromLatin1(control.id))
- : QString::fromLatin1(control.default_binding));
- }
- m_bindings->setCurrentItem(m_bindings->topLevelItem(0));
-
- auto* controls = new QHBoxLayout;
- m_change_button = new QPushButton(tr("Change..."), this);
- m_clear_button = new QPushButton(tr("Clear"), this);
- auto* defaults = new QPushButton(tr("Defaults"), this);
- controls->addWidget(m_change_button);
- controls->addWidget(m_clear_button);
- controls->addWidget(defaults);
- controls->addStretch();
- layout->addLayout(controls);
-
- auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+ auto* buttons = new QDialogButtonBox(QDialogButtonBox::RestoreDefaults |
+ QDialogButtonBox::Save | QDialogButtonBox::Cancel,
+ this);
+ auto* defaults = buttons->button(QDialogButtonBox::RestoreDefaults);
+ defaults->setObjectName(QStringLiteral("defaults"));
+ defaults->setAutoDefault(false);
layout->addWidget(buttons);
-
- connect(m_bindings, &QTreeWidget::itemDoubleClicked, this,
- [this](QTreeWidgetItem*, int) { ChangeBinding(); });
- connect(m_bindings, &QTreeWidget::itemSelectionChanged, this, [this]() { UpdateButtons(); });
- connect(m_change_button, &QPushButton::clicked, this, [this]() { ChangeBinding(); });
- connect(m_clear_button, &QPushButton::clicked, this, [this]() { ClearBinding(); });
- connect(defaults, &QPushButton::clicked, this, [this]() { RestoreDefaults(); });
+ connect(m_controller, &DualSenseWidget::ControlClicked, this,
+ &InputMappingDialog::ChangeBinding);
+ connect(defaults, &QPushButton::clicked, this, &InputMappingDialog::RestoreDefaults);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ if (m_preview != nullptr) {
+ connect(m_preview, &ControllerPreview::StateChanged, this,
+ &InputMappingDialog::UpdatePreview);
+ m_preview->SetPreviewEnabled(true);
+ }
UpdateButtons();
+ UpdatePreview();
+}
+
+InputMappingDialog::~InputMappingDialog() {
+ if (m_preview != nullptr) {
+ m_preview->SetPreviewEnabled(false);
+ }
}
QStringList InputMappingDialog::Mapping() const {
QStringList result;
if (m_custom_bindings) {
- for (int index = 0; index < m_bindings->topLevelItemCount(); index++) {
- const auto* item = m_bindings->topLevelItem(index);
- const auto binding = item->data(BINDING_COLUMN, Qt::UserRole).toString();
+ for (const auto& control: PAD_CONTROLS) {
+ const auto id = QString::fromLatin1(control.id);
+ const auto binding = m_bindings.value(id);
if (!binding.isEmpty()) {
- result.append(item->data(0, Qt::UserRole).toString() + QLatin1Char('=') + binding);
+ result.append(id + QLatin1Char('=') + binding);
}
}
}
@@ -302,58 +377,73 @@ QStringList InputMappingDialog::Mapping() const {
return result;
}
-void InputMappingDialog::ChangeBinding() {
- auto* item = m_bindings->currentItem();
- if (item == nullptr) {
+void InputMappingDialog::ChangeBinding(const QString& id) {
+ if (!m_buttons.contains(id)) {
return;
}
-
+ m_selected = id;
+ UpdateHighlights();
InputCaptureDialog dialog(this);
- if (dialog.exec() != QDialog::Accepted) {
- return;
+ dialog.setWindowTitle(tr("Set binding — %1").arg(m_buttons.value(id)->accessibleName()));
+ if (dialog.exec() == QDialog::Accepted) {
+ AssignBinding(m_bindings, id, dialog.Binding());
+ m_custom_bindings = true;
}
-
- for (int index = 0; index < m_bindings->topLevelItemCount(); index++) {
- auto* other = m_bindings->topLevelItem(index);
- if (other != item && other->data(BINDING_COLUMN, Qt::UserRole)
- .toString()
- .compare(dialog.Binding(), Qt::CaseInsensitive) == 0) {
- SetBinding(other, {});
- }
- }
- SetBinding(item, dialog.Binding());
- m_custom_bindings = true;
-}
-
-void InputMappingDialog::ClearBinding() {
- SetBinding(m_bindings->currentItem(), {});
- m_custom_bindings = true;
+ m_selected.clear();
+ UpdateButtons();
}
void InputMappingDialog::RestoreDefaults() {
- for (int index = 0; index < m_bindings->topLevelItemCount(); index++) {
- SetBinding(m_bindings->topLevelItem(index),
- QString::fromLatin1(PAD_CONTROLS[index].default_binding));
+ m_bindings.clear();
+ for (const auto& control: PAD_CONTROLS) {
+ m_bindings.insert(QString::fromLatin1(control.id),
+ QString::fromLatin1(control.default_binding));
}
m_sensitivity->setValue(DEFAULT_MOUSE_SENSITIVITY);
m_custom_bindings = false;
-}
-
-void InputMappingDialog::SetBinding(QTreeWidgetItem* item, const QString& binding) {
- if (item == nullptr) {
- return;
- }
- item->setData(BINDING_COLUMN, Qt::UserRole, binding);
- item->setText(BINDING_COLUMN, binding.isEmpty() ? tr("None") : binding);
UpdateButtons();
}
void InputMappingDialog::UpdateButtons() {
- if (m_change_button == nullptr) {
- return;
+ for (auto item = m_buttons.begin(); item != m_buttons.end(); ++item) {
+ auto* button = item.value();
+ const auto binding = m_bindings.value(item.key());
+ const auto label = binding.isEmpty() ? tr("None") : binding;
+ button->setText(QString(label).replace(QLatin1Char('&'), QLatin1String("&&")));
+ button->setToolTip(tr("%1: %2. Click to change.").arg(button->accessibleName(), label));
+ }
+ UpdateHighlights();
+}
+
+void InputMappingDialog::UpdatePreview() {
+ const auto pressed = m_preview != nullptr ? m_preview->Pressed() : QSet();
+ m_controller->SetPressed(pressed);
+ m_controller->SetSticks(m_preview != nullptr ? m_preview->LeftStick() : QPointF(),
+ m_preview != nullptr ? m_preview->RightStick() : QPointF());
+ UpdateHighlights();
+ if (m_preview != nullptr && !m_preview->Error().isEmpty()) {
+ m_status->setText(tr("Controller preview unavailable: %1").arg(m_preview->Error()));
+ } else if (m_preview != nullptr && !m_preview->DeviceName().isEmpty()) {
+ m_status->setText(tr("Connected: %1").arg(m_preview->DeviceName()));
+ } else {
+ m_status->setText(tr("No controller connected"));
+ }
+}
+
+void InputMappingDialog::UpdateHighlights() {
+ m_controller->SetSelected(m_selected);
+ for (auto item = m_buttons.begin(); item != m_buttons.end(); ++item) {
+ const bool down = m_preview != nullptr && m_preview->Pressed().contains(item.key());
+ auto colors = palette();
+ if (down) {
+ colors.setColor(QPalette::Button, colors.color(QPalette::Highlight));
+ colors.setColor(QPalette::ButtonText, colors.color(QPalette::HighlightedText));
+ } else if (item.key() == m_selected) {
+ colors.setColor(QPalette::ButtonText, colors.color(QPalette::Highlight));
+ }
+ auto label_font = font();
+ label_font.setBold(down);
+ item.value()->setPalette(colors);
+ item.value()->setFont(label_font);
}
- const auto* item = m_bindings->currentItem();
- m_change_button->setEnabled(item != nullptr);
- m_clear_button->setEnabled(item != nullptr &&
- !item->data(BINDING_COLUMN, Qt::UserRole).toString().isEmpty());
}
diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp
index 200b7c869..d01456147 100644
--- a/src/launcher/src/mainDialog.cpp
+++ b/src/launcher/src/mainDialog.cpp
@@ -4,7 +4,7 @@
#include "configuration.h"
#include "configurationItem.h"
#include "configurationListWidget.h"
-#include "controllerLightbar.h"
+#include "controllerPreview.h"
#include "gameContent.h"
#include "updateChecker.h"
@@ -89,7 +89,7 @@ private:
Ui::MainDialog* m_ui = {nullptr};
MainDialog* m_main_dialog = nullptr;
UpdateChecker* m_update_checker = nullptr;
- ControllerLightbar m_lightbar;
+ ControllerPreview m_controller_preview;
QString m_interpreter;
QProcess m_process;
@@ -112,6 +112,7 @@ MainDialogPrivate::~MainDialogPrivate() {
void MainDialogPrivate::Setup(MainDialog* main_dialog) {
m_ui = new Ui::MainDialog;
m_ui->setupUi(main_dialog);
+ m_ui->widget->SetControllerPreview(&m_controller_preview);
m_main_dialog = main_dialog;
m_update_checker = new UpdateChecker(main_dialog);
@@ -127,7 +128,7 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
connect(m_ui->widget, &ConfigurationListWidget::PreviewControllerColor, this,
[this](const QString& color) {
if (m_process.state() == QProcess::NotRunning) {
- m_lightbar.SetColor(color);
+ m_controller_preview.SetColor(color);
}
});
connect(m_ui->widget, &ConfigurationListWidget::Run, this, &MainDialogPrivate::Run);
@@ -514,7 +515,7 @@ void MainDialogPrivate::Run() {
}
m_running_item->SetRunning(true);
- m_lightbar.Stop();
+ m_controller_preview.SetSuspended(true);
auto info = m_ui->widget->CreateConfiguration(*m_running_item);
m_main_dialog->RunInterpreter(&m_process, *info);
@@ -535,10 +536,11 @@ void MainDialogPrivate::Update() {
m_ui->widget->SetRunEnabled(run_enabled);
if (m_process.state() != QProcess::NotRunning) {
- m_lightbar.Stop();
+ m_controller_preview.SetSuspended(true);
return;
}
- m_lightbar.SetColor(m_ui->widget->GetGlobalControllerColor());
+ m_controller_preview.SetSuspended(false);
+ m_controller_preview.SetColor(m_ui->widget->GetGlobalControllerColor());
}
#include "mainDialog.moc"
diff --git a/src/launcher/styles/launcher.qss b/src/launcher/styles/launcher.qss
index 07e7f869d..d49d811a4 100644
--- a/src/launcher/styles/launcher.qss
+++ b/src/launcher/styles/launcher.qss
@@ -1,3 +1,13 @@
+QListView#cfgs_grid {
+ background: palette(base);
+ border: 1px solid palette(mid);
+ color: palette(text);
+}
+
+QListView#cfgs_grid::item {
+ padding: 8px;
+}
+
GameListTreeWidget#cfgs_list {
background: transparent;
border: 1px solid palette(mid);
@@ -39,6 +49,7 @@ QLineEdit#search_line_edit:focus {
border-color: palette(highlight);
}
+QToolButton#grid_view_button,
QToolButton#refresh_button,
QToolButton#global_settings_button,
QToolButton#input_mapping_button,
@@ -51,6 +62,7 @@ QToolButton#delete_button {
padding: 3px;
}
+QToolButton#grid_view_button:hover,
QToolButton#refresh_button:hover,
QToolButton#global_settings_button:hover,
QToolButton#input_mapping_button:hover,
@@ -61,6 +73,8 @@ QToolButton#delete_button:hover {
border-color: palette(mid);
}
+QToolButton#grid_view_button:checked,
+QToolButton#grid_view_button:pressed,
QToolButton#refresh_button:pressed,
QToolButton#global_settings_button:pressed,
QToolButton#input_mapping_button:pressed,