[desktop] Add smooth scrolling and shift scrolling to game list (#3452)

Intercepts Qt's awful scroll wheel handling and passes it off to a
continuously self-integrating animation on the scroll bar positions.
Code kind of sucks right now and I think has a bit of a perf bottleneck,
but that's somewhat expected in the way I implemented it. Maybe look for
a better sln? QScroller?

Also while I was at it I added shift scrolling.

TODO:
- [ ] TouchGesture? With overshoot off
- [ ] Perf

Signed-off-by: crueter <crueter@eden-emu.dev>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3452
This commit is contained in:
crueter
2026-02-02 19:07:12 +01:00
parent 33067af283
commit 8ed0ed5828
2 changed files with 136 additions and 42 deletions

View File

@@ -1,7 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include "yuzu/game_list.h"
#include <QApplication>
#include <QDir>
#include <QFileInfo>
@@ -11,22 +10,28 @@
#include <QJsonObject>
#include <QList>
#include <QMenu>
#include <QScroller>
#include <QScrollBar>
#include <QThreadPool>
#include <QToolButton>
#include <QVariantAnimation>
#include <fmt/ranges.h>
#include <qnamespace.h>
#include <qscroller.h>
#include <qscrollerproperties.h>
#include "common/common_types.h"
#include "common/logging/log.h"
#include "core/core.h"
#include "core/file_sys/patch_manager.h"
#include "core/file_sys/registered_cache.h"
#include "qt_common/util/game.h"
#include "qt_common/config/uisettings.h"
#include "qt_common/util/game.h"
#include "yuzu/compatibility_list.h"
#include "yuzu/game_list.h"
#include "yuzu/game_list_p.h"
#include "yuzu/game_list_worker.h"
#include "yuzu/main_window.h"
#include "yuzu/util/controller_navigation.h"
#include <fmt/ranges.h>
#include <regex>
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent)
: QObject(parent), gamelist{gamelist_} {}
@@ -328,6 +333,22 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
item_model = new QStandardItemModel(tree_view);
tree_view->setModel(item_model);
SetupScrollAnimation();
tree_view->viewport()->installEventFilter(this);
// touch gestures
tree_view->viewport()->grabGesture(Qt::SwipeGesture);
tree_view->viewport()->grabGesture(Qt::PanGesture);
// TODO: touch?
QScroller::grabGesture(tree_view->viewport(), QScroller::LeftMouseButtonGesture);
auto scroller = QScroller::scroller(tree_view->viewport());
QScrollerProperties props;
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
scroller->setScrollerProperties(props);
tree_view->setAlternatingRowColors(true);
tree_view->setSelectionMode(QHeaderView::SingleSelection);
tree_view->setSelectionBehavior(QHeaderView::SelectRows);
@@ -352,7 +373,7 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu);
connect(tree_view, &QTreeView::expanded, this, &GameList::OnItemExpanded);
connect(tree_view, &QTreeView::collapsed, this, &GameList::OnItemExpanded);
connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent,
connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, this,
[this](Qt::Key key) {
// Avoid pressing buttons while playing
if (system.IsPoweredOn()) {
@@ -477,7 +498,7 @@ void GameList::DonePopulating(const QStringList& watch_list) {
UISettings::values.favorited_ids.size() == 0);
tree_view->setExpanded(item_model->invisibleRootItem()->child(0)->index(),
UISettings::values.favorites_expanded.GetValue());
for (const auto id : UISettings::values.favorited_ids) {
for (const auto id : std::as_const(UISettings::values.favorited_ids)) {
AddFavorite(id);
}
@@ -613,73 +634,73 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id);
navigate_to_gamedb_entry->setVisible(it != compatibility_list.end() && program_id != 0);
connect(favorite, &QAction::triggered, [this, program_id]() { ToggleFavorite(program_id); });
connect(open_save_location, &QAction::triggered, [this, program_id, path]() {
connect(favorite, &QAction::triggered, this, [this, program_id]() { ToggleFavorite(program_id); });
connect(open_save_location, &QAction::triggered, this, [this, program_id, path]() {
emit OpenFolderRequested(program_id, GameListOpenTarget::SaveData, path);
});
connect(start_game, &QAction::triggered,
connect(start_game, &QAction::triggered, this,
[this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Normal); });
connect(start_game_global, &QAction::triggered,
connect(start_game_global, &QAction::triggered, this,
[this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Global); });
connect(open_mod_location, &QAction::triggered, [this, program_id, path]() {
connect(open_mod_location, &QAction::triggered, this, [this, program_id, path]() {
emit OpenFolderRequested(program_id, GameListOpenTarget::ModData, path);
});
connect(open_transferable_shader_cache, &QAction::triggered,
connect(open_transferable_shader_cache, &QAction::triggered, this,
[this, program_id]() { emit OpenTransferableShaderCacheRequested(program_id); });
connect(remove_all_content, &QAction::triggered, [this, program_id]() {
connect(remove_all_content, &QAction::triggered, this, [this, program_id]() {
emit RemoveInstalledEntryRequested(program_id, QtCommon::Game::InstalledEntryType::Game);
});
connect(remove_update, &QAction::triggered, [this, program_id]() {
connect(remove_update, &QAction::triggered, this, [this, program_id]() {
emit RemoveInstalledEntryRequested(program_id, QtCommon::Game::InstalledEntryType::Update);
});
connect(remove_dlc, &QAction::triggered, [this, program_id]() {
connect(remove_dlc, &QAction::triggered, this, [this, program_id]() {
emit RemoveInstalledEntryRequested(program_id, QtCommon::Game::InstalledEntryType::AddOnContent);
});
connect(remove_gl_shader_cache, &QAction::triggered, [this, program_id, path]() {
connect(remove_gl_shader_cache, &QAction::triggered, this, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::GlShaderCache, path);
});
connect(remove_vk_shader_cache, &QAction::triggered, [this, program_id, path]() {
connect(remove_vk_shader_cache, &QAction::triggered, this, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::VkShaderCache, path);
});
connect(remove_shader_cache, &QAction::triggered, [this, program_id, path]() {
connect(remove_shader_cache, &QAction::triggered, this, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::AllShaderCache, path);
});
connect(remove_custom_config, &QAction::triggered, [this, program_id, path]() {
connect(remove_custom_config, &QAction::triggered, this, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CustomConfiguration, path);
});
connect(set_play_time, &QAction::triggered,
connect(set_play_time, &QAction::triggered, this,
[this, program_id]() { emit SetPlayTimeRequested(program_id); });
connect(remove_play_time_data, &QAction::triggered,
connect(remove_play_time_data, &QAction::triggered, this,
[this, program_id]() { emit RemovePlayTimeRequested(program_id); });
connect(remove_cache_storage, &QAction::triggered, [this, program_id, path] {
connect(remove_cache_storage, &QAction::triggered, this, [this, program_id, path] {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CacheStorage, path);
});
connect(dump_romfs, &QAction::triggered, [this, program_id, path]() {
connect(dump_romfs, &QAction::triggered, this, [this, program_id, path]() {
emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::Normal);
});
connect(dump_romfs_sdmc, &QAction::triggered, [this, program_id, path]() {
connect(dump_romfs_sdmc, &QAction::triggered, this, [this, program_id, path]() {
emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::SDMC);
});
connect(verify_integrity, &QAction::triggered,
connect(verify_integrity, &QAction::triggered, this,
[this, path]() { emit VerifyIntegrityRequested(path); });
connect(copy_tid, &QAction::triggered,
connect(copy_tid, &QAction::triggered, this,
[this, program_id]() { emit CopyTIDRequested(program_id); });
connect(navigate_to_gamedb_entry, &QAction::triggered, [this, program_id]() {
connect(navigate_to_gamedb_entry, &QAction::triggered, this, [this, program_id]() {
emit NavigateToGamedbEntryRequested(program_id, compatibility_list);
});
// TODO: Implement shortcut creation for macOS
#if !defined(__APPLE__)
connect(create_desktop_shortcut, &QAction::triggered, [this, program_id, path]() {
connect(create_desktop_shortcut, &QAction::triggered, this, [this, program_id, path]() {
emit CreateShortcut(program_id, path, QtCommon::Game::ShortcutTarget::Desktop);
});
connect(create_applications_menu_shortcut, &QAction::triggered, [this, program_id, path]() {
connect(create_applications_menu_shortcut, &QAction::triggered, this, [this, program_id, path]() {
emit CreateShortcut(program_id, path, QtCommon::Game::ShortcutTarget::Applications);
});
#endif
connect(properties, &QAction::triggered,
connect(properties, &QAction::triggered, this,
[this, path]() { emit OpenPerGameGeneralRequested(path); });
connect(ryujinx, &QAction::triggered, [this, program_id]() { emit LinkToRyujinxRequested(program_id);
connect(ryujinx, &QAction::triggered, this, [this, program_id]() { emit LinkToRyujinxRequested(program_id);
});
};
@@ -693,11 +714,11 @@ void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) {
deep_scan->setCheckable(true);
deep_scan->setChecked(game_dir.deep_scan);
connect(deep_scan, &QAction::triggered, [this, &game_dir] {
connect(deep_scan, &QAction::triggered, this, [this, &game_dir] {
game_dir.deep_scan = !game_dir.deep_scan;
PopulateAsync(UISettings::values.game_dirs);
});
connect(delete_dir, &QAction::triggered, [this, &game_dir, selected] {
connect(delete_dir, &QAction::triggered, this, [this, &game_dir, selected] {
UISettings::values.game_dirs.removeOne(game_dir);
item_model->invisibleRootItem()->removeRow(selected.row());
OnTextChanged(search_field->filterText());
@@ -716,7 +737,7 @@ void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) {
move_up->setEnabled(row > 1);
move_down->setEnabled(row < item_model->rowCount() - 2);
connect(move_up, &QAction::triggered, [this, selected, row, game_dir_index] {
connect(move_up, &QAction::triggered, this, [this, selected, row, game_dir_index] {
const int other_index = selected.sibling(row - 1, 0).data(GameListDir::GameDirRole).toInt();
// swap the items in the settings
std::swap(UISettings::values.game_dirs[game_dir_index],
@@ -732,7 +753,7 @@ void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) {
UISettings::values.game_dirs[other_index].expanded);
});
connect(move_down, &QAction::triggered, [this, selected, row, game_dir_index] {
connect(move_down, &QAction::triggered, this, [this, selected, row, game_dir_index] {
const int other_index = selected.sibling(row + 1, 0).data(GameListDir::GameDirRole).toInt();
// swap the items in the settings
std::swap(UISettings::values.game_dirs[game_dir_index],
@@ -748,7 +769,7 @@ void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) {
UISettings::values.game_dirs[other_index].expanded);
});
connect(open_directory_location, &QAction::triggered, [this, game_dir_index] {
connect(open_directory_location, &QAction::triggered, this, [this, game_dir_index] {
emit OpenDirectory(
QString::fromStdString(UISettings::values.game_dirs[game_dir_index].path));
});
@@ -757,8 +778,8 @@ void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) {
void GameList::AddFavoritesPopup(QMenu& context_menu) {
QAction* clear = context_menu.addAction(tr("Clear"));
connect(clear, &QAction::triggered, [this] {
for (const auto id : UISettings::values.favorited_ids) {
connect(clear, &QAction::triggered, this, [this] {
for (const auto id : std::as_const(UISettings::values.favorited_ids)) {
RemoveFavorite(id);
}
UISettings::values.favorited_ids.clear();
@@ -788,7 +809,7 @@ void GameList::LoadCompatibilityList() {
const QJsonDocument json = QJsonDocument::fromJson(content);
const QJsonArray arr = json.array();
for (const QJsonValue value : arr) {
for (const QJsonValue &value : arr) {
const QJsonObject game = value.toObject();
const QString compatibility_key = QStringLiteral("compatibility");
@@ -800,7 +821,7 @@ void GameList::LoadCompatibilityList() {
const QString directory = game[QStringLiteral("directory")].toString();
const QJsonArray ids = game[QStringLiteral("releases")].toArray();
for (const QJsonValue id_ref : ids) {
for (const QJsonValue &id_ref : ids) {
const QJsonObject id_object = id_ref.toObject();
const QString id = id_object[QStringLiteral("id")].toString();
@@ -919,7 +940,7 @@ void GameList::ToggleFavorite(u64 program_id) {
tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true);
}
}
SaveConfig();
emit SaveConfig();
}
void GameList::AddFavorite(u64 program_id) {
@@ -989,6 +1010,70 @@ void GameListPlaceholder::mouseDoubleClickEvent(QMouseEvent* event) {
emit GameListPlaceholder::AddDirectory();
}
void GameList::SetupScrollAnimation() {
auto setup = [this](QVariantAnimation* anim, QScrollBar* bar) {
// animation handles moving the bar instead of Qt's built in crap
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setDuration(200);
connect(anim, &QVariantAnimation::valueChanged, this, [bar](const QVariant& value) {
bar->setValue(value.toInt());
});
};
vertical_scroll = new QVariantAnimation(this);
horizontal_scroll = new QVariantAnimation(this);
setup(vertical_scroll, tree_view->verticalScrollBar());
setup(horizontal_scroll, tree_view->horizontalScrollBar());
}
bool GameList::eventFilter(QObject* obj, QEvent* event) {
if (obj == tree_view->viewport() && event->type() == QEvent::Wheel) {
QWheelEvent* wheelEvent = static_cast<QWheelEvent*>(event);
bool horizontal = wheelEvent->modifiers() & Qt::ShiftModifier;
int deltaX = wheelEvent->angleDelta().x();
int deltaY = wheelEvent->angleDelta().y();
// if shift is held do a horizontal scroll
if (horizontal && deltaY != 0 && deltaX == 0) {
deltaX = deltaY;
deltaY = 0;
}
// TODO(crueter): dedup this
if (deltaY != 0) {
if (vertical_scroll->state() == QAbstractAnimation::Stopped)
vertical_scroll_target = tree_view->verticalScrollBar()->value();
vertical_scroll_target -= deltaY;
vertical_scroll_target = qBound(0, vertical_scroll_target, tree_view->verticalScrollBar()->maximum());
vertical_scroll->stop();
vertical_scroll->setStartValue(tree_view->verticalScrollBar()->value());
vertical_scroll->setEndValue(vertical_scroll_target);
vertical_scroll->start();
}
if (deltaX != 0) {
if (horizontal_scroll->state() == QAbstractAnimation::Stopped)
horizontal_scroll_target = tree_view->horizontalScrollBar()->value();
horizontal_scroll_target -= deltaX;
horizontal_scroll_target = qBound(0, horizontal_scroll_target, tree_view->horizontalScrollBar()->maximum());
horizontal_scroll->stop();
horizontal_scroll->setStartValue(tree_view->horizontalScrollBar()->value());
horizontal_scroll->setEndValue(horizontal_scroll_target);
horizontal_scroll->start();
}
return true;
}
return QWidget::eventFilter(obj, event);
}
void GameListPlaceholder::changeEvent(QEvent* event) {
if (event->type() == QEvent::LanguageChange) {
RetranslateUI();

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2015 Citra Emulator Project
@@ -25,6 +25,7 @@
#include "yuzu/compatibility_list.h"
#include "frontend_common/play_time_manager.h"
class QVariantAnimation;
namespace Core {
class System;
}
@@ -162,6 +163,14 @@ private:
ControllerNavigation* controller_navigation = nullptr;
CompatibilityList compatibility_list;
QVariantAnimation* vertical_scroll = nullptr;
QVariantAnimation* horizontal_scroll = nullptr;
int vertical_scroll_target = 0;
int horizontal_scroll_target = 0;
void SetupScrollAnimation();
bool eventFilter(QObject* obj, QEvent* event) override;
friend class GameListSearchField;
const PlayTime::PlayTimeManager& play_time_manager;