From 7da3c4ed218a45767dbb64e8e22936b91ff41943 Mon Sep 17 00:00:00 2001 From: nmzik Date: Fri, 9 Oct 2026 19:03:35 +0200 Subject: [PATCH] launcher: add resizable game grid/improve controller setup --- .../forms/configuration_list_widget.ui | 155 +++++--- src/launcher/icons/grid-view.svg | 8 + .../include/configurationListWidget.h | 6 +- src/launcher/include/controllerLightbar.h | 29 -- src/launcher/include/controllerPreview.h | 53 +++ src/launcher/include/dualsenseWidget.h | 40 ++ src/launcher/include/inputMappingDialog.h | 30 +- src/launcher/resources.qrc | 1 + src/launcher/src/configurationListWidget.cpp | 86 ++++- src/launcher/src/controllerLightbar.cpp | 84 ----- src/launcher/src/controllerPreview.cpp | 202 ++++++++++ src/launcher/src/dualsenseWidget.cpp | 300 +++++++++++++++ src/launcher/src/inputMappingDialog.cpp | 352 +++++++++++------- src/launcher/src/mainDialog.cpp | 14 +- src/launcher/styles/launcher.qss | 14 + 15 files changed, 1063 insertions(+), 311 deletions(-) create mode 100644 src/launcher/icons/grid-view.svg delete mode 100644 src/launcher/include/controllerLightbar.h create mode 100644 src/launcher/include/controllerPreview.h create mode 100644 src/launcher/include/dualsenseWidget.h delete mode 100644 src/launcher/src/controllerLightbar.cpp create mode 100644 src/launcher/src/controllerPreview.cpp create mode 100644 src/launcher/src/dualsenseWidget.cpp 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,