[desktop] Add basic carousel view (#4112)
Some checks are pending
tx-src / sources (push) Waiting to run
Check Strings / check-strings (push) Waiting to run

Adds a basic carousel view, or essentially a horizontal list a la Android/Qt Quick.

Lacks a lot of niceties like autoscroll, smooth shifts, etc. Will work on those later

Also fixed a bug introduced recently that capped game icon size to 8 at the low end, breaking the None option

Signed-off-by: crueter <crueter@eden-emu.dev>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4112
Reviewed-by: MaranBr <maranbr@eden-emu.dev>
Reviewed-by: Shinmegumi <shinmegumi@eden-emu.dev>
Reviewed-by: Lizzie <lizzie@eden-emu.dev>
This commit is contained in:
crueter 2026-06-22 21:04:47 +02:00
parent 68aaea6085
commit 39be450fa3
No known key found for this signature in database
GPG key ID: 425ACD2D4830EBC6
16 changed files with 300 additions and 45 deletions

View file

@ -158,7 +158,7 @@ ENUM(GpuUnswizzleChunk, VeryLow, Low, Normal, Medium, High)
ENUM(TemperatureUnits, Celsius, Fahrenheit)
ENUM(ExtendedDynamicState, Disabled, EDS1, EDS2, EDS3);
ENUM(GpuLogLevel, Off, Errors, Standard, Verbose, All)
ENUM(GameListMode, TreeView, GridView);
ENUM(GameListMode, TreeView, GridView, CarouselView);
ENUM(SpeedMode, Standard, Turbo, Slow);
template <typename Type>

View file

@ -207,7 +207,7 @@ struct Values {
// Game List
Setting<bool> show_add_ons{linkage, true, "show_add_ons", Category::UiGameList};
Setting<u32, true> game_icon_size{linkage, 64, 8, 512, "game_icon_size", Category::UiGameList};
Setting<u32, true> game_icon_size{linkage, 64, 0, 512, "game_icon_size", Category::UiGameList};
Setting<u32, true> folder_icon_size{linkage, 48, 8, 512, "folder_icon_size", Category::UiGameList};
Setting<u8> row_1_text_id{linkage, 3, "row_1_text_id", Category::UiGameList};
Setting<u8> row_2_text_id{linkage, 2, "row_2_text_id", Category::UiGameList};

View file

@ -165,6 +165,7 @@ public:
return QStringLiteral("%1\n %2").arg(row1, row2);
}
case Settings::GameListMode::GridView:
case Settings::GameListMode::CarouselView:
return row1;
default:
break;

View file

@ -240,7 +240,9 @@ add_executable(yuzu
render/performance_overlay.h render/performance_overlay.cpp render/performance_overlay.ui
updater/update_dialog.h updater/update_dialog.cpp updater/update_dialog.ui
game/common.h)
game/common.h
game/carousel.h game/carousel.cpp
)
set_target_properties(yuzu PROPERTIES OUTPUT_NAME "eden")

107
src/yuzu/game/carousel.cpp Normal file
View file

@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include "qt_common/config/uisettings.h"
#include "qt_common/game_list/model.h"
#include "yuzu/game/common.h"
#include "yuzu/game/game_card.h"
#include "yuzu/game/carousel.h"
GameCarousel::GameCarousel(QWidget* parent) : QListView{parent} {
m_gameCard = new GameCard(this);
setItemDelegate(m_gameCard);
setViewMode(QListView::IconMode);
setMovement(QListView::Static);
setUniformItemSizes(true);
setSelectionMode(QAbstractItemView::SingleSelection);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setEditTriggers(QAbstractItemView::NoEditTriggers);
setContextMenuPolicy(Qt::CustomContextMenu);
setSpacing(10);
setWordWrap(true);
setTextElideMode(Qt::ElideRight);
setFlow(QListView::LeftToRight);
setWrapping(false);
}
void GameCarousel::SetModel(GameListModel* model) {
QListView::setModel(model);
UpdateIconSize();
}
void GameCarousel::ApplyFilter(const QString& edit_filter_text, GameListModel* model) {
int row_count = model->rowCount();
for (int i = 0; i < row_count; ++i) {
QStandardItem* item = model->item(i, 0);
if (!item)
continue;
if (Yuzu::FilterMatches(edit_filter_text, item)) {
setRowHidden(i, false);
} else {
setRowHidden(i, true);
}
}
}
void GameCarousel::UpdateIconSize() {
const u32 icon_size = UISettings::values.game_icon_size.GetValue();
int heightMargin = 0;
int widthMargin = 80;
// TODO(crueter): get rid of this nonsense
if (UISettings::values.show_game_name) {
switch (icon_size) {
case 128:
heightMargin = 65;
break;
case 0:
widthMargin = 120;
heightMargin = 120;
break;
case 64:
heightMargin = 77;
break;
case 32:
case 256:
heightMargin = 81;
break;
}
} else {
widthMargin = 24;
heightMargin = 24;
}
const int min_item_width = icon_size + widthMargin;
const int min_item_height = icon_size + heightMargin;
const int grid_height = std::max(min_item_height, viewport()->height());
QSize content_size(min_item_width, min_item_height);
QSize grid_size(min_item_width, grid_height);
if (gridSize() != grid_size) {
setUpdatesEnabled(false);
setGridSize(grid_size);
m_gameCard->setSize(grid_size, content_size, 0, 0);
setUpdatesEnabled(true);
}
}
QModelIndex GameCarousel::indexAt(const QPoint& point) const {
QModelIndex index = QListView::indexAt(point);
if (!index.isValid())
return {};
if (m_gameCard && !m_gameCard->hitTest(point, index, this, visualRect(index)))
return {};
return index;
}

27
src/yuzu/game/carousel.h Normal file
View file

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QListView>
#include <QString>
class GameCard;
class GameListModel;
class QResizeEvent;
class GameCarousel : public QListView {
Q_OBJECT
public:
explicit GameCarousel(QWidget* parent = nullptr);
void SetModel(GameListModel* model);
void ApplyFilter(const QString& edit_filter_text, GameListModel* model);
void UpdateIconSize();
QModelIndex indexAt(const QPoint& point) const override;
private:
GameCard* m_gameCard = nullptr;
};

View file

@ -18,27 +18,16 @@ void GameCard::paint(QPainter* painter, const QStyleOptionViewItem& option,
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
constexpr int cardMargin = 8;
constexpr int cardCornerRadius = 10;
const int column = index.row() % m_columns;
const int cell_width = option.rect.width();
const int card_width = cell_width - m_padding;
const int row_width = m_columns * cell_width;
const int total_gap = row_width - cardMargin * 2 - m_columns * card_width;
const int gap = (m_columns > 1) ? (total_gap / (m_columns - 1)) : 0;
const int card_left = option.rect.left() - column * cell_width + cardMargin + column * (card_width + gap) + 4;
const QRect cardRect(card_left, option.rect.top() + 4, card_width - 8,
option.rect.height() - cardMargin);
const QRect cardRect = getCardRect(option, index);
QPalette palette = option.palette;
QColor backgroundColor = palette.window().color();
QColor borderColor = palette.dark().color();
QColor textColor = palette.text().color();
// highlight blue on select
// highlight on select or hover
if (option.state & QStyle::State_Selected) {
backgroundColor = palette.highlight().color();
borderColor = palette.highlight().color().lighter(150);
@ -60,7 +49,7 @@ void GameCard::paint(QPainter* painter, const QStyleOptionViewItem& option,
scaled.scale(icon_size, icon_size, Qt::KeepAspectRatio);
iconRect = {cardRect.left() + (cardRect.width() - scaled.width()) / 2,
cardRect.top() + cardMargin, scaled.width(), scaled.height()};
cardRect.top() + cardMargin - 1, scaled.width(), scaled.height()};
painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
@ -94,12 +83,62 @@ void GameCard::paint(QPainter* painter, const QStyleOptionViewItem& option,
painter->restore();
}
QRect GameCard::getCardRect(const QStyleOptionViewItem& option, const QModelIndex& index) const {
const int cell_width = option.rect.width();
const int card_width = cell_width - m_padding;
int card_left, card_top, card_height;
if (m_columns >= 1) {
// grid mode
// center everything in-line, such that the leftmost and rightmost cards
// have ~ equal padding to the edge of the viewport
// spacing between each card is larger, but equal to each other
const int column = index.row() % m_columns;
const int row_width = m_columns * cell_width;
const int total_gap = row_width - cardMargin * 2 - m_columns * card_width;
const int gap = (m_columns > 1) ? (total_gap / (m_columns - 1)) : 0;
card_left =
option.rect.left() - column * cell_width + cardMargin + column * (card_width + gap) + 4;
// fill cell vertically
card_top = option.rect.top() + cardMargin;
card_height = option.rect.height() - cardMargin - 1;
} else {
// carousel mode
card_left = option.rect.left() + cardMargin + 4;
// the delegate itself takes up the full height, but the card itself
// gets centered
const int content_height = m_contentSize.height() - cardMargin;
const int cell_height = option.rect.height();
card_height = std::min(content_height, cell_height - cardMargin * 2) - 1;
card_top = option.rect.top() + (cell_height - card_height) / 2;
}
return QRect(card_left, card_top, card_width - cardMargin, card_height);
}
bool GameCard::hitTest(const QPoint& point, const QModelIndex& index, const QWidget* widget,
const QRect& cellRect) const {
QStyleOptionViewItem option;
option.initFrom(widget);
option.rect = cellRect;
return getCardRect(option, index).contains(point);
}
QSize GameCard::sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const {
return m_size;
}
void GameCard::setSize(const QSize& newSize, const int padding, const int columns) {
void GameCard::setSize(const QSize& newSize, const QSize& contentSize, const int padding,
const int columns) {
m_size = newSize;
m_contentSize = contentSize;
m_padding = padding;
m_columns = columns;
}

View file

@ -17,11 +17,19 @@ public:
void paint(QPainter* painter, const QStyleOptionViewItem& option,
const QModelIndex& index) const override;
QRect getCardRect(const QStyleOptionViewItem &option, const QModelIndex &index) const;
bool hitTest(const QPoint& point, const QModelIndex& index,
const QWidget* widget, const QRect& cellRect) const;
QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override;
void setSize(const QSize& newSize, const int padding, const int columns);
void setSize(const QSize& newSize, const QSize& contentSize, const int padding, const int columns);
private:
static constexpr int cardMargin = 8;
QSize m_size;
QSize m_contentSize;
int m_padding;
int m_columns;
};

View file

@ -25,8 +25,6 @@ GameGrid::GameGrid(QWidget* parent) : QListView{parent} {
setEditTriggers(QAbstractItemView::NoEditTriggers);
setContextMenuPolicy(Qt::CustomContextMenu);
setGridSize(QSize(140, 160));
m_gameCard->setSize(gridSize(), 0, 4);
setSpacing(10);
setWordWrap(true);
@ -97,8 +95,17 @@ void GameGrid::UpdateIconSize() {
setUpdatesEnabled(false);
setGridSize(grid_size);
m_gameCard->setSize(grid_size, stretched_width - min_item_width, columns);
m_gameCard->setSize(grid_size, grid_size, stretched_width - min_item_width, columns);
setUpdatesEnabled(true);
}
}
QModelIndex GameGrid::indexAt(const QPoint& point) const {
QModelIndex index = QListView::indexAt(point);
if (!index.isValid())
return {};
if (m_gameCard && !m_gameCard->hitTest(point, index, this, visualRect(index)))
return {};
return index;
}

View file

@ -6,8 +6,6 @@
#include <QListView>
#include <QString>
#include "common/common_types.h"
class GameCard;
class GameListModel;
@ -21,6 +19,8 @@ public:
void ApplyFilter(const QString& edit_filter_text, GameListModel* model);
void UpdateIconSize();
QModelIndex indexAt(const QPoint& point) const override;
private:
GameCard* m_gameCard = nullptr;
};

View file

@ -20,6 +20,7 @@
#include <QScrollerProperties>
#include <QToolButton>
#include <QVariantAnimation>
#include <qlayoutitem.h>
#include "common/common_types.h"
#include "core/core.h"
@ -31,6 +32,7 @@
#include "qt_common/qt_common.h"
#include "qt_common/util/game.h"
#include "yuzu/compatibility_list.h"
#include "yuzu/game/carousel.h"
#include "yuzu/game/game_grid.h"
#include "yuzu/game/game_list.h"
#include "yuzu/game/game_tree.h"
@ -67,6 +69,9 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
connect(grid_view, &QListView::activated, this, &GameList::ValidateEntry);
connect(grid_view, &QListView::customContextMenuRequested, this, &GameList::PopupContextMenu);
connect(carousel_view, &QListView::activated, this, &GameList::ValidateEntry);
connect(carousel_view, &QListView::customContextMenuRequested, this, &GameList::PopupContextMenu);
connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, this,
[this](Qt::Key key) {
if (system.IsPoweredOn()) {
@ -86,6 +91,7 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
connect(item_model, &GameListModel::SaveConfig, this, &GameList::SaveConfig);
connect(item_model, &GameListModel::PopulatingStarted, this, &GameList::OnPopulate);
// TODO: impl on grid/carousel
connect(tree_view, &GameTree::FilterResultReady, search_field,
[this](int visible, int total) { search_field->setFilterResult(visible, total); });
@ -101,12 +107,11 @@ GameList::~GameList() {
void GameList::SetupViews() {
tree_view = new GameTree(this);
grid_view = new GameGrid(this);
carousel_view = new GameCarousel(this);
tree_view->SetModel(item_model);
grid_view->SetModel(item_model);
layout->addWidget(tree_view);
layout->addWidget(grid_view);
carousel_view->SetModel(item_model);
}
QString GameList::GetLastFilterResultItem() const {
@ -138,10 +143,16 @@ void GameList::LoadCompatibilityList() {
void GameList::OnPopulate() {
m_currentView->setEnabled(false);
if (m_isTreeMode) {
grid_view->UpdateIconSize();
} else {
switch (game_list_mode) {
case Settings::GameListMode::TreeView:
tree_view->UpdateColumnVisibility(item_model);
break;
case Settings::GameListMode::GridView:
grid_view->UpdateIconSize();
break;
case Settings::GameListMode::CarouselView:
carousel_view->UpdateIconSize();
break;
}
}
@ -166,26 +177,46 @@ void GameList::UnloadController() {
}
void GameList::ResetViewMode() {
auto& setting = UISettings::values.game_list_mode;
const auto mode = UISettings::values.game_list_mode.GetValue();
game_list_mode = mode;
if (m_currentView)
layout->removeWidget(m_currentView);
bool newTreeMode = false;
switch (setting.GetValue()) {
switch (mode) {
case Settings::GameListMode::TreeView:
m_currentView = tree_view;
newTreeMode = true;
tree_view->setVisible(true);
grid_view->setVisible(false);
break;
case Settings::GameListMode::GridView:
m_currentView = grid_view;
newTreeMode = false;
grid_view->setVisible(true);
tree_view->setVisible(false);
break;
case Settings::GameListMode::CarouselView:
m_currentView = carousel_view;
newTreeMode = false;
break;
default:
UNREACHABLE();
}
tree_view->setVisible(false);
grid_view->setVisible(false);
carousel_view->setVisible(false);
tree_view->setEnabled(false);
grid_view->setEnabled(false);
carousel_view->setEnabled(false);
m_currentView->setVisible(true);
m_currentView->setEnabled(true);
layout->insertWidget(0, m_currentView);
auto view = m_currentView->viewport();
view->installEventFilter(this);
@ -297,11 +328,13 @@ void GameList::OnPopulatingCompleted(const QStringList& watch_list) {
for (int i = 1; i < item_model->rowCount() - 1; ++i) {
children_total += item_model->item(i, 0)->rowCount();
}
search_field->setFilterResult(children_total, children_total);
if (children_total > 0) {
search_field->setFocus();
}
// TODO: carousel/grid impl.
item_model->sort(tree_view->header()->sortIndicatorSection(),
tree_view->header()->sortIndicatorOrder());
@ -317,8 +350,15 @@ void GameList::RefreshExternalContent() {
}
void GameList::UpdateIconSizes() {
if (!m_isTreeMode) {
switch (game_list_mode) {
case Settings::GameListMode::GridView:
grid_view->UpdateIconSize();
break;
case Settings::GameListMode::CarouselView:
carousel_view->UpdateIconSize();
break;
case Settings::GameListMode::TreeView:
break;
}
}
@ -712,6 +752,11 @@ bool GameList::eventFilter(QObject* obj, QEvent* event) {
return true;
}
if (obj == carousel_view->viewport() && event->type() == QEvent::Resize) {
carousel_view->UpdateIconSize();
return true;
}
return QWidget::eventFilter(obj, event);
}

View file

@ -15,12 +15,12 @@
#include <QVBoxLayout>
#include <QVector>
#include <QWidget>
#include <qabstractitemview.h>
#include "common/common_types.h"
#include "core/core.h"
#include "frontend_common/play_time_manager.h"
#include "qt_common/config/uisettings.h"
#include "qt_common/game_list/model.h"
#include "qt_common/util/game.h"
#include "yuzu/compatibility_list.h"
@ -38,6 +38,9 @@ class MainWindow;
enum class AmLaunchType;
enum class StartGameType;
class GameCarousel;
class QAbstractItemView;
namespace Core {
class System;
}
@ -151,7 +154,9 @@ private:
GameTree* tree_view = nullptr;
GameGrid* grid_view = nullptr;
GameCarousel* carousel_view = nullptr;
GameListModel* item_model = nullptr;
Settings::GameListMode game_list_mode;
ControllerNavigation* controller_navigation = nullptr;

View file

@ -3,8 +3,6 @@
#pragma once
#include <QHeaderView>
#include <QString>
#include <QTreeView>
class GameListModel;

View file

@ -45,7 +45,7 @@
<x>0</x>
<y>0</y>
<width>1280</width>
<height>22</height>
<height>23</height>
</rect>
</property>
<widget class="QMenu" name="menu_File">
@ -111,6 +111,7 @@
</property>
<addaction name="action_Tree_View"/>
<addaction name="action_Grid_View"/>
<addaction name="action_Carousel_View"/>
</widget>
<widget class="QMenu" name="menuGame_Icon_Size">
<property name="title">
@ -620,6 +621,14 @@
<string>Show &amp;Performance Overlay</string>
</property>
</action>
<action name="action_Carousel_View">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>&amp;Carousel View</string>
</property>
</action>
</widget>
<resources>
<include location="yuzu.qrc"/>

View file

@ -1529,6 +1529,7 @@ void MainWindow::ConnectMenuEvents() {
connect_menu(ui->action_Grid_View, &MainWindow::SetGridView);
connect_menu(ui->action_Tree_View, &MainWindow::SetTreeView);
connect_menu(ui->action_Carousel_View, &MainWindow::SetCarouselView);
game_size_actions = new QActionGroup(this);
game_size_actions->setExclusive(true);
@ -3317,9 +3318,10 @@ void MainWindow::ResetWindowSize1080() {
void MainWindow::SetGameListMode(Settings::GameListMode mode) {
ui->action_Grid_View->setChecked(mode == Settings::GameListMode::GridView);
ui->action_Tree_View->setChecked(mode == Settings::GameListMode::TreeView);
ui->action_Carousel_View->setChecked(mode == Settings::GameListMode::CarouselView);
UISettings::values.game_list_mode = mode;
ui->action_Show_Game_Name->setEnabled(mode == Settings::GameListMode::GridView);
ui->action_Show_Game_Name->setEnabled(mode != Settings::GameListMode::TreeView);
CheckIconSize();
game_list->ResetViewMode();
@ -3333,11 +3335,15 @@ void MainWindow::SetTreeView() {
SetGameListMode(Settings::GameListMode::TreeView);
}
void MainWindow::SetCarouselView() {
SetGameListMode(Settings::GameListMode::CarouselView);
}
void MainWindow::CheckIconSize() {
// When in grid view mode, with text off
// there is no point in having icons turned off..
// When in grid/carousel view mode, with text off
// there is no point in having icons turned off
auto actions = game_size_actions->actions();
if (UISettings::values.game_list_mode.GetValue() == Settings::GameListMode::GridView &&
if (UISettings::values.game_list_mode.GetValue() != Settings::GameListMode::TreeView &&
!UISettings::values.show_game_name.GetValue()) {
u32 newSize = UISettings::values.game_icon_size.GetValue();
if (newSize == 0) {

View file

@ -410,6 +410,7 @@ private slots:
void SetGameListMode(Settings::GameListMode mode);
void SetGridView();
void SetTreeView();
void SetCarouselView();
void CheckIconSize();
void ToggleShowGameName();