From 256c924de8b99c981f4f22f1c27190296b7d97fc Mon Sep 17 00:00:00 2001 From: collecting Date: Sun, 5 Apr 2026 19:03:12 -0400 Subject: [PATCH] feat: Carousel & Controller Nav. SVG's / Added Favorites Column to Grid/Carousel / Various Bug Fixes --- dist/carousel.svg | 5 + dist/controller_navigation.svg | 10 + src/citron/citron.qrc | 2 + src/citron/game_grid_delegate.cpp | 61 +++-- src/citron/game_list.cpp | 281 +++++++++++---------- src/citron/main.cpp | 6 +- src/citron/ui/game_carousel_view.cpp | 90 +++++-- src/citron/ui/game_carousel_view.h | 3 + src/citron/ui/game_grid_view.cpp | 353 +++++++++++++++++---------- src/citron/ui/game_grid_view.h | 36 ++- 10 files changed, 538 insertions(+), 309 deletions(-) create mode 100644 dist/carousel.svg create mode 100644 dist/controller_navigation.svg diff --git a/dist/carousel.svg b/dist/carousel.svg new file mode 100644 index 0000000000..258fc1bd19 --- /dev/null +++ b/dist/carousel.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dist/controller_navigation.svg b/dist/controller_navigation.svg new file mode 100644 index 0000000000..8bf4cb2fae --- /dev/null +++ b/dist/controller_navigation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/citron/citron.qrc b/src/citron/citron.qrc index 4f5ed3919c..1ee13909fe 100644 --- a/src/citron/citron.qrc +++ b/src/citron/citron.qrc @@ -10,6 +10,8 @@ SPDX-License-Identifier: GPL-2.0-or-later ../../dist/dice.svg + ../../dist/carousel.svg + ../../dist/controller_navigation.svg ../../dist/citron.svg diff --git a/src/citron/game_grid_delegate.cpp b/src/citron/game_grid_delegate.cpp index b0e4598e0d..0f141c1722 100644 --- a/src/citron/game_grid_delegate.cpp +++ b/src/citron/game_grid_delegate.cpp @@ -31,7 +31,8 @@ void GameGridDelegate::setGridMode(GridMode mode) { QSize GameGridDelegate::sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const { const int icon_size = UISettings::values.game_icon_size.GetValue(); - return QSize(icon_size + 40, icon_size + 85); + const float scale = static_cast(icon_size) / 128.0f; + return QSize(icon_size + static_cast(40 * scale), icon_size + static_cast(85 * scale)); } void GameGridDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, @@ -98,6 +99,8 @@ void GameGridDelegate::PaintGridItem(QPainter* painter, const QStyleOptionViewIt const QModelIndex& index) const { const bool is_selected = option.state & QStyle::State_Selected; QRect rect = option.rect; + const int icon_size = UISettings::values.game_icon_size.GetValue(); + const float scale = static_cast(icon_size) / 128.0f; qreal entry_val = 1.0; const QPersistentModelIndex key(index); @@ -106,10 +109,12 @@ void GameGridDelegate::PaintGridItem(QPainter* painter, const QStyleOptionViewIt qreal final_opacity = entry_val * m_population_fade_global; painter->setOpacity(final_opacity); - QRect card_rect = rect.adjusted(12, 12, -12, -12); - const int icon_size = UISettings::values.game_icon_size.GetValue(); - const int card_h = icon_size + 64; - card_rect.setHeight(card_h); + const int card_w = icon_size + static_cast(16 * scale); + const int card_h = icon_size + static_cast(64 * scale); + const int cx = rect.x() + (rect.width() - card_w) / 2; + QRect card_rect(cx, rect.y() + static_cast(12 * scale), card_w, card_h); + + const int radius = static_cast(14 * scale); if (is_selected) { QColor glow = AccentColor(); @@ -117,31 +122,53 @@ void GameGridDelegate::PaintGridItem(QPainter* painter, const QStyleOptionViewIt painter->save(); painter->setBrush(glow); painter->setPen(Qt::NoPen); - painter->drawRoundedRect(card_rect.adjusted(-4, -4, 4, 4), 16, 16); + painter->drawRoundedRect(card_rect.adjusted(static_cast(-4 * scale), static_cast(-4 * scale), + static_cast(4 * scale), static_cast(4 * scale)), + radius + 2, radius + 2); painter->restore(); } QColor onyx(22, 22, 26); painter->setPen(Qt::NoPen); painter->setBrush(onyx); - painter->drawRoundedRect(card_rect, 14, 14); + painter->drawRoundedRect(card_rect, radius, radius); if (is_selected) { QColor border = AccentColor(); qreal pulse = (m_pulse_states.contains(key)) ? m_pulse_states[key] : 0.0; - painter->setPen(QPen(border, 3.5 + pulse * 1.5)); + painter->setPen(QPen(border, (3.5f + pulse * 1.5f) * scale)); painter->setBrush(Qt::NoBrush); - painter->drawRoundedRect(card_rect, 14, 14); + painter->drawRoundedRect(card_rect, radius, radius); } - QRectF label_rect = card_rect.adjusted(6, 6, -6, -22); + QRectF label_rect = card_rect.adjusted(6 * scale, 6 * scale, -6 * scale, -22 * scale); QPainterPath label_path; - label_path.addRoundedRect(label_rect, 8, 8); + label_path.addRoundedRect(label_rect, radius - 6, radius - 6); + + // --- Favorites Indicator --- + bool is_fav = (index.data(GameListItem::TypeRole).toInt() == static_cast(GameListItemType::Favorites)); + if (is_fav) { + painter->save(); + QColor fav_gold(255, 215, 0, 220); // Vibrant Gold + painter->setPen(QPen(fav_gold, 1.2f * scale)); + painter->setBrush(QColor(40, 40, 45, 180)); + qreal star_size = 22.0f * scale; + QRectF star_rect(card_rect.right() - (10.0f * scale) - star_size, card_rect.top() + (10.0f * scale), + star_size, star_size); + painter->drawRoundedRect(star_rect, 6 * scale, 6 * scale); + painter->setPen(fav_gold); + QFont sf = painter->font(); sf.setBold(true); sf.setPointSizeF(11.0f * scale); painter->setFont(sf); + painter->drawText(star_rect.adjusted(0, -1 * scale, 0, 0), Qt::AlignCenter, QStringLiteral("★")); + painter->restore(); + } + + // Removed old vertical divider logic in favor of section headers painter->save(); painter->setClipPath(label_path); - QRectF bottom_bar(label_rect.left(), label_rect.bottom() - 28, label_rect.width(), 28); + qreal bar_h = 28.0f * scale; + QRectF bottom_bar(label_rect.left(), label_rect.bottom() - bar_h, label_rect.width(), bar_h); painter->fillRect(bottom_bar, QColor(10, 10, 12)); QPixmap pixmap = index.data(GameListItemPath::HighResIconRole).value(); @@ -155,21 +182,21 @@ void GameGridDelegate::PaintGridItem(QPainter* painter, const QStyleOptionViewIt painter->setPen(Qt::white); QFont tf = option.font; tf.setBold(true); - tf.setPointSizeF(8.5); + tf.setPointSizeF(8.5f * scale); painter->setFont(tf); - QRectF text_rect = bottom_bar.adjusted(10, 0, -10, 0); + QRectF text_rect = bottom_bar.adjusted(10 * scale, 0, -10 * scale, 0); QString elided = painter->fontMetrics().elidedText(title, Qt::ElideRight, text_rect.width()); painter->drawText(text_rect, Qt::AlignCenter, elided); painter->restore(); - int pins_y = card_rect.bottom() - 10; + int pins_y = card_rect.bottom() - static_cast(10 * scale); int p_c = 6; - int p_w = (card_rect.width() - 40) / p_c; + int p_w = (card_rect.width() - static_cast(40 * scale)) / p_c; painter->setOpacity(0.8 * final_opacity); for (int i = 0; i < p_c; ++i) { - painter->fillRect(card_rect.left() + 20 + i * p_w, pins_y, p_w - 6, 6, QColor(40, 40, 45)); + painter->fillRect(card_rect.left() + static_cast(20 * scale) + i * p_w, pins_y, p_w - static_cast(6 * scale), static_cast(6 * scale), QColor(40, 40, 45)); } } diff --git a/src/citron/game_list.cpp b/src/citron/game_list.cpp index 32256c371d..83d091edf6 100644 --- a/src/citron/game_list.cpp +++ b/src/citron/game_list.cpp @@ -721,106 +721,107 @@ void GameList::OnTextChanged(const QString& new_text) { } void GameList::FilterGridView(const QString& filter_text) { + auto cleanup = [&](QAbstractItemModel* m) { if (m && m != item_model) m->deleteLater(); }; + cleanup(grid_view->favModel()); + cleanup(grid_view->mainModel()); + cleanup(carousel_view->model()); + QStandardItemModel* hierarchical_model = item_model; - QStandardItemModel* flat_model = nullptr; - const u32 icon_size = UISettings::values.game_icon_size.GetValue(); - QAbstractItemModel* current_model = grid_view->view()->model(); - if (current_model && current_model != item_model) { - QStandardItemModel* existing_flat = qobject_cast(current_model); - if (existing_flat) { - existing_flat->clear(); - flat_model = existing_flat; - } - } + int visible_count = 0, total_count = 0; + + QStandardItemModel* fav_model = new QStandardItemModel(this); + QStandardItemModel* main_model = new QStandardItemModel(this); + QStandardItemModel* carousel_model = new QStandardItemModel(this); + QSet fav_paths; + QStandardItem* h_fav_folder = nullptr; - if (!flat_model) { - if (current_model && current_model != item_model) { - current_model->deleteLater(); - } - flat_model = new QStandardItemModel(this); - } - int visible_count = 0; - int total_count = 0; for (int i = 0; i < hierarchical_model->rowCount(); ++i) { - QStandardItem* folder = hierarchical_model->item(i, 0); - if (!folder || folder->data(GameListItem::TypeRole).value() == - GameListItemType::AddDir) { - continue; - } - for (int j = 0; j < folder->rowCount(); ++j) { - QStandardItem* game_item = folder->child(j, 0); - if (!game_item || game_item->data(GameListItem::TypeRole).value() != - GameListItemType::Game) - continue; - - total_count++; - const QString full_path = game_item->data(GameListItemPath::FullPathRole).toString(); - bool should_show = !UISettings::values.hidden_paths.contains(full_path); - - if (should_show && !filter_text.isEmpty()) { - const QString file_title = - game_item->data(GameListItemPath::TitleRole).toString().toLower(); - const auto program_id = - game_item->data(GameListItemPath::ProgramIdRole).toULongLong(); - const QString file_program_id = - QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char('0')); - const QString file_name = - full_path.mid(full_path.lastIndexOf(QLatin1Char{'/'}) + 1).toLower() + - QLatin1Char{' '} + file_title; - should_show = - ContainsAllWords(file_name, filter_text) || - (file_program_id.size() == 16 && file_program_id.contains(filter_text)); - } - - if (should_show) { - QStandardItem* cloned_item = game_item->clone(); - QString game_title = game_item->data(GameListItemPath::TitleRole).toString(); - if (game_title.isEmpty()) { - std::string filename; - Common::SplitPath(full_path.toStdString(), nullptr, &filename, nullptr); - game_title = QString::fromStdString(filename); - } - cloned_item->setText(game_title); - flat_model->appendRow(cloned_item); - visible_count++; - } + if (hierarchical_model->item(i)->data(GameListItem::TypeRole).value() == GameListItemType::Favorites) { + h_fav_folder = hierarchical_model->item(i); break; } } - grid_view->setModel(flat_model); - carousel_view->setModel(flat_model); - flat_model->setSortRole(GameListItemPath::SortRole); - flat_model->sort(0, current_sort_order); - for (int i = 0; i < flat_model->rowCount(); ++i) { - QStandardItem* item = flat_model->item(i); - if (item) { + auto cloneTo = [&](QStandardItem* src, QStandardItemModel* target, bool force_fav = false) { + QStandardItem* item = src->clone(); + QString title = src->data(GameListItemPath::TitleRole).toString(); + if (title.isEmpty()) { + std::string fn; Common::SplitPath(src->data(GameListItemPath::FullPathRole).toString().toStdString(), nullptr, &fn, nullptr); + title = QString::fromStdString(fn); + } + item->setText(title); + item->setData(title, GameListItemPath::SortRole); + if (force_fav) { + item->setData(static_cast(GameListItemType::Favorites), GameListItem::TypeRole); + } else { + item->setData(static_cast(GameListItemType::Game), GameListItem::TypeRole); + } + target->appendRow(item); + }; + + auto isFavorite = [&](QStandardItem* item) { + u64 pid = item->data(GameListItemPath::ProgramIdRole).toULongLong(); + return UISettings::values.favorited_ids.contains(pid); + }; + + if (h_fav_folder) { + for (int j = 0; j < h_fav_folder->rowCount(); ++j) { + QStandardItem* game = h_fav_folder->child(j, 0); + if (!game || game->data(GameListItem::TypeRole).value() != GameListItemType::Game) continue; + if (!isFavorite(game)) continue; + total_count++; + QString path = game->data(GameListItemPath::FullPathRole).toString(); + if (UISettings::values.hidden_paths.contains(path)) continue; + if (!filter_text.isEmpty()) { + QString title = game->data(GameListItemPath::TitleRole).toString().toLower(); + if (!ContainsAllWords(title, filter_text.toLower())) continue; + } + cloneTo(game, fav_model, true); + cloneTo(game, carousel_model, true); + fav_paths.insert(path); + visible_count++; + } + } + + for (int i = 0; i < hierarchical_model->rowCount(); ++i) { + QStandardItem* folder = hierarchical_model->item(i); + if (!folder || folder->data(GameListItem::TypeRole).value() == GameListItemType::AddDir || + folder->data(GameListItem::TypeRole).value() == GameListItemType::Favorites) continue; + for (int j = 0; j < folder->rowCount(); ++j) { + QStandardItem* game = folder->child(j, 0); + if (!game || game->data(GameListItem::TypeRole).value() != GameListItemType::Game) continue; + total_count++; + QString path = game->data(GameListItemPath::FullPathRole).toString(); + if (fav_paths.contains(path) || UISettings::values.hidden_paths.contains(path)) continue; + if (!filter_text.isEmpty()) { + QString title = game->data(GameListItemPath::TitleRole).toString().toLower(); + if (!ContainsAllWords(title, filter_text.toLower())) continue; + } + cloneTo(game, main_model); + cloneTo(game, carousel_model); + visible_count++; + } + } + + auto scaleIcons = [&](QStandardItemModel* model) { + for (int i = 0; i < model->rowCount(); i++) { + QStandardItem* item = model->item(i); QVariant icon_data = item->data(Qt::DecorationRole); if (icon_data.isValid() && icon_data.canConvert()) { QPixmap pixmap = icon_data.value(); if (!pixmap.isNull()) { -#ifdef __linux__ - QPixmap scaled = pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, - Qt::SmoothTransformation); - item->setData(scaled, Qt::DecorationRole); -#else - QPixmap rounded(icon_size, icon_size); - rounded.fill(Qt::transparent); - QPainter painter(&rounded); - painter.setRenderHint(QPainter::Antialiasing); - const int radius = icon_size / 8; - QPainterPath path; - path.addRoundedRect(0, 0, icon_size, icon_size, radius, radius); - painter.setClipPath(path); - QPixmap scaled = pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, - Qt::SmoothTransformation); - painter.drawPixmap(0, 0, scaled); - item->setData(rounded, Qt::DecorationRole); -#endif + item->setData(pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), Qt::DecorationRole); } } } - } + }; + scaleIcons(fav_model); scaleIcons(main_model); scaleIcons(carousel_model); + + fav_model->setSortRole(GameListItemPath::SortRole); fav_model->sort(0, current_sort_order); + main_model->setSortRole(GameListItemPath::SortRole); main_model->sort(0, current_sort_order); + carousel_model->setSortRole(GameListItemPath::SortRole); // Carousel is usually manual or logic-based, but we sort it for consistency + grid_view->setModels(fav_model, main_model); + carousel_view->setModel(carousel_model); search_field->setFilterResult(visible_count, total_count); } @@ -995,6 +996,7 @@ GameList::GameList(std::shared_ptr vfs_, tree_view->setContextMenuPolicy(Qt::CustomContextMenu); grid_view->view()->setContextMenuPolicy(Qt::CustomContextMenu); + grid_view->favView()->setContextMenuPolicy(Qt::CustomContextMenu); carousel_view->view()->setContextMenuPolicy(Qt::CustomContextMenu); // Load the persistent index instantly to provide a console-grade experience @@ -1003,6 +1005,8 @@ GameList::GameList(std::shared_ptr vfs_, connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu); connect(grid_view->view(), &QListView::customContextMenuRequested, this, &GameList::PopupContextMenu); + connect(grid_view->favView(), &QListView::customContextMenuRequested, this, + &GameList::PopupContextMenu); connect(carousel_view->view(), &QWidget::customContextMenuRequested, this, &GameList::PopupContextMenu); @@ -1121,9 +1125,14 @@ GameList::GameList(std::shared_ptr vfs_, [this]() { SetViewMode(GameList::ViewMode::Grid); }); btn_carousel_view = new QToolButton(toolbar); - QIcon carousel_icon = QIcon::fromTheme(QStringLiteral("view-presentation")); - if (carousel_icon.isNull()) - carousel_icon = QIcon::fromTheme(QStringLiteral("media-playlist-play")); + QIcon carousel_icon(QStringLiteral(":/dist/carousel.svg")); + if (carousel_icon.isNull()) { + carousel_icon = QIcon::fromTheme(QStringLiteral("view-presentation")); + if (carousel_icon.isNull()) + carousel_icon = QIcon::fromTheme(QStringLiteral("media-playlist-play")); + if (carousel_icon.isNull()) + carousel_icon = style()->standardIcon(QStyle::SP_MediaPlay); + } btn_carousel_view->setIcon(carousel_icon); btn_carousel_view->setToolTip(tr("Carousel View")); btn_carousel_view->setCheckable(true); @@ -1315,7 +1324,13 @@ GameList::GameList(std::shared_ptr vfs_, // Controller Settings Button auto* btn_controller_settings = new QToolButton(toolbar); - btn_controller_settings->setIcon(QIcon::fromTheme(QStringLiteral("input-gaming"))); + QIcon ctrl_icon(QStringLiteral(":/dist/controller_navigation.svg")); + if (ctrl_icon.isNull()) { + ctrl_icon = QIcon::fromTheme(QStringLiteral("input-gaming")); + if (ctrl_icon.isNull()) + ctrl_icon = style()->standardIcon(QStyle::SP_ComputerIcon); + } + btn_controller_settings->setIcon(ctrl_icon); btn_controller_settings->setToolTip(tr("Controller Navigation Settings")); btn_controller_settings->setAutoRaise(true); btn_controller_settings->setIconSize(QSize(16, 16)); @@ -1668,7 +1683,8 @@ void GameList::AddEntry(const QList& entry_items, const QString& for (int i = 0; i < parent->rowCount(); ++i) { auto* existing_path_item = static_cast(parent->child(i, COLUMN_NAME)); if (existing_path_item && - (existing_path_item->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id || + (existing_path_item->data(GameListItemPath::ProgramIdRole).toULongLong() == + program_id || existing_path_item->data(GameListItemPath::FullPathRole).toString() == new_path)) { // Update existing row columns for (int col = 0; col < entry_items.size(); ++col) { @@ -1732,10 +1748,12 @@ void GameList::RefreshGame(u64 program_id, u64 play_time) { for (int j = 0; j < folder->rowCount(); ++j) { auto* path_item = static_cast(folder->child(j, COLUMN_NAME)); - if (path_item && path_item->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { + if (path_item && + path_item->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { QStandardItem* play_time_item = folder->child(j, COLUMN_PLAY_TIME); if (play_time_item) { - play_time_item->setData(static_cast(play_time), GameListItemPlayTime::PlayTimeRole); + play_time_item->setData(static_cast(play_time), + GameListItemPlayTime::PlayTimeRole); play_time_item->setData(false, GameListItem::IsRefreshingRole); } return; @@ -2199,30 +2217,22 @@ void GameList::DonePopulating(const QStringList& watch_list) { emit ShowList(!IsEmpty()); // Only add the "Add Directory" item to the main model for List View. // Grid and Carousel use a flat model and explicitly skip this type. - // Deduplicate special rows (Add Directory and Favorites) - bool has_add_dir = false; - bool has_favorites = false; + QStandardItem* fav_folder = nullptr; for (int i = 0; i < item_model->rowCount(); ++i) { - auto type = item_model->item(i)->data(GameListItem::TypeRole).value(); - if (type == GameListItemType::AddDir) - has_add_dir = true; - if (type == GameListItemType::Favorites) - has_favorites = true; + if (item_model->item(i)->data(GameListItem::TypeRole).value() == GameListItemType::Favorites) { + fav_folder = item_model->item(i); + break; + } } - - if (!has_add_dir) { - item_model->invisibleRootItem()->appendRow(new GameListAddDir()); - } - if (!has_favorites) { - item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); - } - tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), - 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) { - AddFavorite(id); + if (!fav_folder) { + fav_folder = new GameListFavorites(); + item_model->invisibleRootItem()->insertRow(0, fav_folder); + } else { + fav_folder->removeRows(0, fav_folder->rowCount()); } + tree_view->setRowHidden(fav_folder->row(), item_model->invisibleRootItem()->index(), UISettings::values.favorited_ids.size() == 0); + tree_view->setExpanded(fav_folder->index(), UISettings::values.favorites_expanded.GetValue()); + for (const auto id : UISettings::values.favorited_ids) AddFavorite(id); auto watch_dirs = watcher->directories(); if (!watch_dirs.isEmpty()) { watcher->removePaths(watch_dirs); @@ -2281,7 +2291,9 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { if (current == 0) { item = tree_view->indexAt(menu_location); } else if (current == 1) { - item = grid_view->indexAt(menu_location); + QListView* sending_view = qobject_cast(sender()); + item = sending_view ? sending_view->indexAt(menu_location) + : grid_view->view()->indexAt(menu_location); } else { item = carousel_view->view()->indexAt(menu_location); } @@ -2306,27 +2318,27 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { "QMenu::item:selected { background-color: #32323a; border: 1px solid #42424a; }" "QMenu::separator { height: 1px; background: #32323a; margin: 4px 10px; }")); switch (selected.data(GameListItem::TypeRole).value()) { + case GameListItemType::Favorites: case GameListItemType::Game: { const u64 program_id = selected.data(GameListItemPath::ProgramIdRole).toULongLong(); const std::string path = selected.data(GameListItemPath::FullPathRole).toString().toStdString(); const auto game_name = selected.data(GameListItemPath::OriginalTitleRole).toString(); - AddGamePopup(context_menu, selected, program_id, path, game_name); - break; + if (program_id != 0 || !path.empty()) { + AddGamePopup(context_menu, selected, program_id, path, game_name); + break; + } + [[fallthrough]]; } case GameListItemType::CustomDir: AddPermDirPopup(context_menu, selected); - AddCustomDirPopup(context_menu, selected, - false); // Pass false to skip adding "Show Hidden Games" + AddCustomDirPopup(context_menu, selected, false); break; case GameListItemType::SdmcDir: case GameListItemType::UserNandDir: case GameListItemType::SysNandDir: AddPermDirPopup(context_menu, selected); break; - case GameListItemType::Favorites: - AddFavoritesPopup(context_menu); - break; default: break; } @@ -2334,7 +2346,10 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { if (current == 0) { context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location)); } else if (current == 1) { - context_menu.exec(grid_view->view()->viewport()->mapToGlobal(menu_location)); + QListView* exec_view = qobject_cast(sender()); + QPoint global_pt = exec_view ? exec_view->viewport()->mapToGlobal(menu_location) + : grid_view->view()->viewport()->mapToGlobal(menu_location); + context_menu.exec(global_pt); } else { context_menu.exec(carousel_view->view()->viewport()->mapToGlobal(menu_location)); } @@ -2848,11 +2863,14 @@ 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) { + QList ids_to_remove(UISettings::values.favorited_ids.begin(), + UISettings::values.favorited_ids.end()); + for (const auto id : ids_to_remove) { RemoveFavorite(id); } UISettings::values.favorited_ids.clear(); tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); + FilterGridView(search_field->filterText()); }); } @@ -2957,7 +2975,7 @@ void GameList::PopulateAsync(QVector& game_dirs, bool is_sm } auto* delegate = qobject_cast(tree_view->itemDelegate()); if (delegate) { - delegate->SetPopulating(true); + delegate->SetPopulating(!is_smart_update); } if (!is_smart_update) { @@ -3098,8 +3116,17 @@ void GameList::ToggleFavorite(u64 program_id) { } void GameList::AddFavorite(u64 program_id) { - auto* favorites_row = item_model->item(0); - for (int i = 1; i < item_model->rowCount() - 1; i++) { + QStandardItem* favorites_row = nullptr; + for (int i = 0; i < item_model->rowCount(); ++i) { + auto* folder = item_model->item(i, 0); + if (folder && folder->data(GameListItem::TypeRole).value() == GameListItemType::Favorites) { + favorites_row = folder; + break; + } + } + if (!favorites_row) return; + + for (int i = 0; i < item_model->rowCount(); i++) { const auto* folder = item_model->item(i); for (int j = 0; j < folder->rowCount(); j++) { if (folder->child(j)->data(GameListItemPath::ProgramIdRole).toULongLong() == diff --git a/src/citron/main.cpp b/src/citron/main.cpp index 23cd7b97df..3db96234f8 100644 --- a/src/citron/main.cpp +++ b/src/citron/main.cpp @@ -1148,7 +1148,7 @@ void GMainWindow::InitializeWidgets() { const QString accent_dim = accent_color.darker(120).name(); unified_top_bar_layout = new QHBoxLayout(unified_top_bar); - unified_top_bar_layout->setContentsMargins(10, 0, 10, 0); + unified_top_bar_layout->setContentsMargins(10, 4, 10, 4); unified_top_bar_layout->setSpacing(0); auto add_menu = [this, accent_str](QMenu* menu) { @@ -1170,9 +1170,9 @@ void GMainWindow::InitializeWidgets() { btn->setFlat(true); btn->setFocusPolicy(Qt::NoFocus); btn->setCursor(Qt::PointingHandCursor); - btn->setFixedHeight(32); + btn->setFixedHeight(42); btn->setStyleSheet(QString::asprintf( - "QPushButton { border: none; padding: 0 10px; font-weight: normal; font-size: 8.5pt; " + "QPushButton { border: none; padding: 0 14px; font-weight: normal; font-size: 9pt; " "background: transparent; color: #e0e0e4; text-align: center; margin: 0; outline: none; }" "QPushButton:hover { background: rgba(255, 255, 255, 0.05); color: #ffffff; " "border-bottom: 2px solid %s; }" diff --git a/src/citron/ui/game_carousel_view.cpp b/src/citron/ui/game_carousel_view.cpp index 1926a18f2a..e7142a40d7 100644 --- a/src/citron/ui/game_carousel_view.cpp +++ b/src/citron/ui/game_carousel_view.cpp @@ -12,6 +12,7 @@ #include #include "citron/ui/game_carousel_view.h" +#include "citron/game_list_p.h" #include "citron/uisettings.h" #include "citron/game_list_p.h" @@ -100,7 +101,12 @@ void CinematicCarousel::paintEvent(QPaintEvent* event) { if (!m_model || m_model->rowCount() == 0) return; QPainter p(this); p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); const int count = m_model->rowCount(); const qreal vcx = width() / 2.0; const qreal vcy = height() / 2.0; - const int is = UISettings::values.game_icon_size.GetValue(); const qreal bs = is + 35.0; const qreal th = is * 2.0; + const int is = UISettings::values.game_icon_size.GetValue(); + const float scale = static_cast(is) / 128.0f; + const qreal bs = is + (35.0f * scale); + const qreal th = is * 2.0; + const qreal arrow_ay = std::max(55.0, vcy - (1.4 * ((is + static_cast(25 * scale)) / 2.0)) - (28.0f * scale)); + QVector order; for (int i = 0; i < count; ++i) order << i; std::sort(order.begin(), order.end(), [this](int a, int b) { return std::abs(a - m_focal_index) > std::abs(b - m_focal_index); }); @@ -111,31 +117,56 @@ void CinematicCarousel::paintEvent(QPaintEvent* event) { if (dist < th) { qreal f = 1.0 - (dist / th); s = 1.0 + (f * 0.40); dx = d * (is / 2.0) * f; } const qreal x = vcx + (d * bs) + dx; const qreal y = vcy; p.save(); p.translate(x, y); p.scale(s, s); - const int cs_w = is + 25; const int cs_h = is + 25; + const int cs_w = is + static_cast(25 * scale); + const int cs_h = is + static_cast(25 * scale); QRectF cr(-cs_w / 2.0, -cs_h / 2.0, cs_w, cs_h); - QPainterPath path; path.addRoundedRect(cr, 16, 16); + QPainterPath path; path.addRoundedRect(cr, 16 * scale, 16 * scale); const bool focal = std::abs(i - m_focal_index) < 0.5; if (focal) { p.save(); QColor acc = AccentColor(); qreal pulse = (std::sin(m_pulse_tick * 0.1) + 1.0) / 2.0; - if (m_has_focus) { p.setPen(QPen(acc, 4.5 + pulse * 1.5)); acc.setAlphaF(static_cast(0.12 + pulse * 0.08)); p.setBrush(acc); } - else { acc.setAlphaF(0.4f); p.setPen(QPen(acc, 3.0)); p.setBrush(Qt::NoBrush); } + if (m_has_focus) { p.setPen(QPen(acc, (4.5f + pulse * 1.5f) * scale)); acc.setAlphaF(static_cast(0.12 + pulse * 0.08)); p.setBrush(acc); } + else { acc.setAlphaF(0.4f); p.setPen(QPen(acc, 3.0f * scale)); p.setBrush(Qt::NoBrush); } p.drawPath(path); p.restore(); - // Draw the alphabetical header ONLY when it changes (category boundary) + // 1. Determine Section Boundary & Header Drawing bool draw_header = (i == 0); + int type = m_model->index(i, 0).data(GameListItem::TypeRole).toInt(); + int prev_type = (i > 0) ? m_model->index(i - 1, 0).data(GameListItem::TypeRole).toInt() : -1; + bool is_fav = (type == static_cast(GameListItemType::Favorites)); + bool was_fav = (prev_type == static_cast(GameListItemType::Favorites)); + if (i > 0) { - QString t1 = m_model->index(i, 0).data(Qt::DisplayRole).toString(); - QString t2 = m_model->index(i - 1, 0).data(Qt::DisplayRole).toString(); - if (t1.isEmpty() || t2.isEmpty() || t1[0].toUpper() != t2[0].toUpper()) draw_header = true; + if (is_fav != was_fav) { + draw_header = true; + } else if (!is_fav) { + QString t1 = m_model->index(i, 0).data(Qt::DisplayRole).toString(); + QString t2 = m_model->index(i - 1, 0).data(Qt::DisplayRole).toString(); + if (t1.isEmpty() || t2.isEmpty() || t1[0].toUpper() != t2[0].toUpper()) draw_header = true; + } } + if (draw_header) { p.save(); p.resetTransform(); p.setPen(acc); p.setOpacity(0.9); - qreal ay = std::max(90.0, y - (s * (cs_h / 2.0)) - 42); - QString title = m_model->index(i, 0).data(Qt::DisplayRole).toString(); - QChar cl = title.isEmpty() ? QLatin1Char('#') : title[0].toUpper(); - if (!cl.isLetter()) cl = QLatin1Char('#'); - QFont hf = font(); hf.setBold(true); hf.setPointSizeF(48.0); p.setFont(hf); p.setPen(acc); - p.drawText(QRectF(x - 80, ay - 130, 160, 100), Qt::AlignCenter, cl); p.restore(); + qreal header_ay = arrow_ay - (15.0f * scale); + + if (is_fav) { + QFont hf = font(); hf.setBold(true); hf.setPointSizeF(24.0f * scale); p.setFont(hf); + p.drawText(QRectF(x - 150 * scale, header_ay - 90 * scale, 300 * scale, 60 * scale), Qt::AlignCenter, tr("★ FAVORITES")); + } else { + QString title = m_model->index(i, 0).data(Qt::DisplayRole).toString(); + QChar cl = title.isEmpty() ? QLatin1Char('#') : title[0].toUpper(); + if (!cl.isLetter()) cl = QLatin1Char('#'); + QFont hf = font(); hf.setBold(true); hf.setPointSizeF(48.0f * scale); p.setFont(hf); + p.drawText(QRectF(x - 80 * scale, header_ay - 110 * scale, 160 * scale, 100 * scale), Qt::AlignCenter, cl); + } + p.restore(); + } + + if (i > 0 && was_fav && !is_fav) { + p.save(); p.setOpacity(0.4); p.setPen(QPen(acc, 2.5f * scale)); + qreal dx_line = -bs / 2.0; + p.drawLine(QPointF(dx_line, -cs_h / 1.5), QPointF(dx_line, cs_h / 1.5)); + p.restore(); } } if (!focal) { p.setPen(Qt::NoPen); p.setBrush(CardBg()); p.setOpacity(0.85); p.drawPath(path); } @@ -152,10 +183,12 @@ void CinematicCarousel::paintEvent(QPaintEvent* event) { p.restore(); } - // Static Down Arrow Indicator (ALWAYS centered at the top) p.save(); QColor acc = AccentColor(); p.setPen(acc); p.setOpacity(0.9); - qreal ay = std::max(55.0, vcy - (1.4 * ((is + 60) / 2.0)) - 28); - QPainterPath static_arr; static_arr.moveTo(vcx, ay); static_arr.lineTo(vcx - 12, ay - 12); static_arr.lineTo(vcx + 12, ay - 12); static_arr.closeSubpath(); + qreal arr_size = 12.0f * scale; + QPainterPath static_arr; static_arr.moveTo(vcx, arrow_ay); + static_arr.lineTo(vcx - arr_size, arrow_ay - arr_size); + static_arr.lineTo(vcx + arr_size, arrow_ay - arr_size); + static_arr.closeSubpath(); p.drawPath(static_arr); p.restore(); const int aw = 60, ah = 60; auto drawArrow = [&](bool left, bool hover) { @@ -176,7 +209,9 @@ void CinematicCarousel::paintEvent(QPaintEvent* event) { void CinematicCarousel::mousePressEvent(QMouseEvent* event) { if (m_left_arrow_hover || m_right_arrow_hover) { m_scroll_timer->start(); return; } if (m_snap_animation->state() == QAbstractAnimation::Running) m_snap_animation->stop(); - m_last_mouse_pos = event->pos(); m_drag_start_pos = event->pos(); m_is_dragging = true; + if (event->button() == Qt::LeftButton) { + m_last_mouse_pos = event->pos(); m_drag_start_pos = event->pos(); m_is_dragging = true; + } } void CinematicCarousel::mouseMoveEvent(QMouseEvent* event) { @@ -226,14 +261,21 @@ void CinematicCarousel::keyPressEvent(QKeyEvent* event) { if (event->key() == Qt::Key_X || event->key() == Qt::Key_Y) { QModelIndex cur = currentIndex(); if (cur.isValid()) { + int type = cur.data(GameListItem::TypeRole).toInt(); QString title = cur.data(Qt::DisplayRole).toString(); QChar cc = title.isEmpty() ? QLatin1Char(' ') : title[0].toUpper(); int tot = m_model->rowCount(); int sr = cur.row(); for (int i = 1; i <= tot; ++i) { int nr = (sr + i) % tot; - QString nt = m_model->index(nr, 0).data(Qt::DisplayRole).toString(); - QChar nc = nt.isEmpty() ? QLatin1Char(' ') : nt[0].toUpper(); - if (nc != cc) { scrollTo(nr); return; } + QModelIndex nidx = m_model->index(nr, 0); + int nt = nidx.data(GameListItem::TypeRole).toInt(); + if (nt != type) { scrollTo(nr); return; } // Section jump + + if (nt != static_cast(GameListItemType::Favorites)) { + QString ntit = nidx.data(Qt::DisplayRole).toString(); + QChar nc = ntit.isEmpty() ? QLatin1Char(' ') : ntit[0].toUpper(); + if (nc != cc) { scrollTo(nr); return; } // Alpha jump + } } } } @@ -248,6 +290,10 @@ void CinematicCarousel::startSnapAnimation(qreal target) { m_snap_animation->sto void CinematicCarousel::updateFocalItem() { if (!m_model) return; int idx = std::round(m_focal_index); if (idx >= 0 && idx < m_model->rowCount()) emit focalItemChanged(m_model->index(idx, 0)); } +void CinematicCarousel::focusOutEvent(QFocusEvent* event) { m_is_dragging = false; m_scroll_timer->stop(); update(); QWidget::focusOutEvent(event); } +void CinematicCarousel::leaveEvent(QEvent* event) { m_is_dragging = false; m_left_arrow_hover = false; m_right_arrow_hover = false; m_hover_icon_index = -1; update(); QWidget::leaveEvent(event); } + + QColor CinematicCarousel::CardBg() const { return QColor(25, 25, 28, 205); } QColor CinematicCarousel::TextColor() const { return QColor(255, 255, 255); } QColor CinematicCarousel::AccentColor() const { const QString h = QString::fromStdString(UISettings::values.accent_color.GetValue()); return QColor(h).isValid() ? QColor(h) : QColor(0, 150, 255); } diff --git a/src/citron/ui/game_carousel_view.h b/src/citron/ui/game_carousel_view.h index 52b037e1ae..013240e122 100644 --- a/src/citron/ui/game_carousel_view.h +++ b/src/citron/ui/game_carousel_view.h @@ -52,6 +52,8 @@ protected: void wheelEvent(QWheelEvent* event) override; void keyPressEvent(QKeyEvent* event) override; void resizeEvent(QResizeEvent* event) override; + void focusOutEvent(QFocusEvent* event) override; + void leaveEvent(QEvent* event) override; private: void startSnapAnimation(qreal target); @@ -91,6 +93,7 @@ public: void ApplyTheme(); CinematicCarousel* view() const { return m_carousel; } + QAbstractItemModel* model() const { return m_carousel->model(); } void setControllerFocus(bool focus) { m_carousel->setControllerFocus(focus); } bool hasControllerFocus() const { return m_carousel->hasControllerFocus(); } diff --git a/src/citron/ui/game_grid_view.cpp b/src/citron/ui/game_grid_view.cpp index 1485d890de..4d9441c226 100644 --- a/src/citron/ui/game_grid_view.cpp +++ b/src/citron/ui/game_grid_view.cpp @@ -1,188 +1,230 @@ #include #include -#include -#include +#include +#include #include #include +#include #include "citron/ui/game_grid_view.h" #include "citron/game_grid_delegate.h" +#include "citron/game_list_p.h" #include "citron/uisettings.h" +class ContentHeightListView : public QListView { +public: + using QListView::QListView; +protected: + void wheelEvent(QWheelEvent* e) override { e->ignore(); } +}; + GameGridView::GameGridView(QWidget* parent) : QWidget(parent) { - m_layout = new QVBoxLayout(this); - m_layout->setContentsMargins(30, 20, 30, 5); // Tighter bottom margin + m_scroll_area = new QScrollArea(this); + m_scroll_area->setWidgetResizable(true); + m_scroll_area->setFrameShape(QFrame::NoFrame); + m_scroll_area->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_scroll_area->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_scroll_area->setStyleSheet(QStringLiteral("QScrollArea { background: transparent; }")); + + m_container = new QWidget(m_scroll_area); + m_layout = new QVBoxLayout(m_container); + m_layout->setContentsMargins(0, 20, 0, 20); m_layout->setSpacing(0); - // --- TOP HELP OVERLAY --- - m_top_help = new QLabel(this); + m_top_help = new QLabel(m_container); m_top_help->setText(tr("if using controller* Press X for Next Alphabetical Letter | Press -/R/ZR for Details Tab | Press B for Back to List")); m_top_help->setStyleSheet(QStringLiteral("QLabel { color: rgba(255, 255, 255, 140); font-weight: bold; font-family: 'Outfit', 'Inter', sans-serif; font-size: 14px; }")); m_top_help->setAlignment(Qt::AlignCenter); m_layout->addSpacing(10); m_layout->addWidget(m_top_help); - m_layout->addSpacing(30); + m_layout->addSpacing(20); - m_view = new QListView(this); - m_view->setViewMode(QListView::IconMode); - m_view->setResizeMode(QListView::Adjust); - m_view->setFlow(QListView::LeftToRight); - m_view->setWrapping(true); - m_view->setSpacing(20); - m_view->setFrameStyle(QFrame::NoFrame); - m_view->setAttribute(Qt::WA_MacShowFocusRect, false); - - // Modernized ScrollBar Styling - m_view->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto setupView = [&](QListView*& view, GameGridDelegate*& delegate) { + view = new ContentHeightListView(m_container); + view->setViewMode(QListView::IconMode); + view->setResizeMode(QListView::Adjust); + view->setFlow(QListView::LeftToRight); + view->setWrapping(true); + view->setFrameStyle(QFrame::NoFrame); + view->setAttribute(Qt::WA_MacShowFocusRect, false); + view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view->setDragEnabled(false); + view->setAcceptDrops(false); + view->setFocusPolicy(Qt::StrongFocus); + delegate = new GameGridDelegate(view, this); + view->setItemDelegate(delegate); + connect(view, &QListView::activated, this, &GameGridView::itemActivated); + }; - // Disable traditional Drag & Drop to ensure Kinetic Scroll takes precedence - m_view->setDragEnabled(false); - m_view->setAcceptDrops(false); - m_view->setDropIndicatorShown(false); - m_view->setDefaultDropAction(Qt::IgnoreAction); + m_fav_label = new QLabel(tr("★ FAVORITES"), m_container); + m_fav_label->setStyleSheet(QStringLiteral("QLabel { color: #ffd700; font-weight: bold; font-size: 18px; padding-left: 24px; margin-bottom: 10px; }")); + m_layout->addWidget(m_fav_label); + setupView(m_fav_view, m_fav_delegate); + m_layout->addWidget(m_fav_view); - m_delegate = new GameGridDelegate(m_view, this); - m_view->setItemDelegate(m_delegate); + m_main_label = new QLabel(tr("ALL GAMES"), m_container); + m_main_label->setStyleSheet(QStringLiteral("QLabel { color: #0096ff; font-weight: bold; font-size: 18px; padding-left: 24px; margin-top: 20px; margin-bottom: 10px; }")); + m_layout->addWidget(m_main_label); + setupView(m_main_view, m_main_delegate); + m_layout->addWidget(m_main_view); - m_layout->addWidget(m_view, 1); - - // --- BOTTOM HINT OVERLAY --- - m_bottom_hint = new QLabel(this); + m_bottom_hint = new QLabel(m_container); m_bottom_hint->setText(tr("*You can use your Mouse Wheel or the Scrollbar to navigate the Grid View*")); m_bottom_hint->setStyleSheet(QStringLiteral("QLabel { color: rgba(255, 255, 255, 100); font-style: italic; font-size: 13px; }")); m_bottom_hint->setAlignment(Qt::AlignCenter); m_layout->addWidget(m_bottom_hint); + m_scroll_area->setWidget(m_container); + QVBoxLayout* main_layout = new QVBoxLayout(this); + main_layout->setContentsMargins(0, 0, 0, 0); + main_layout->addWidget(m_scroll_area); + ApplyTheme(); - - connect(m_view, &QListView::activated, this, &GameGridView::itemActivated); } void GameGridView::ApplyTheme() { - m_view->setStyleSheet(QStringLiteral( - "QListView {" - " background: transparent;" - " border: none;" - " outline: 0;" - " padding: 20px;" - "}" - "QListView::item {" - " padding: 0px;" - " margin: 4px;" - " background: transparent;" - "}" - )); + QString style = QStringLiteral( + "QListView { background: transparent; border: none; outline: 0; padding: 0px; }" + "QListView::item { padding: 0px; margin: 0px; background: transparent; }" + ); + m_fav_view->setStyleSheet(style); + m_main_view->setStyleSheet(style); } -QAbstractItemModel* GameGridView::model() const { - return m_view->model(); +void GameGridView::setModels(QAbstractItemModel* fav_model, QAbstractItemModel* main_model) { + m_fav_view->setModel(fav_model); + m_main_view->setModel(main_model); + + bool has_favs = fav_model && fav_model->rowCount() > 0; + m_fav_label->setVisible(has_favs); + m_fav_view->setVisible(has_favs); + + if (m_fav_view->selectionModel()) { + connect(m_fav_view->selectionModel(), &QItemSelectionModel::currentChanged, + this, [this](const QModelIndex& current) { + if (current.isValid()) { + emit itemSelectionChanged(current); + m_main_view->clearSelection(); + m_main_view->setCurrentIndex(QModelIndex()); + } + }); + } + if (m_main_view->selectionModel()) { + connect(m_main_view->selectionModel(), &QItemSelectionModel::currentChanged, + this, [this](const QModelIndex& current) { + if (current.isValid()) { + emit itemSelectionChanged(current); + m_fav_view->clearSelection(); + m_fav_view->setCurrentIndex(QModelIndex()); + } + }); + } + UpdateGridSize(); } -QItemSelectionModel* GameGridView::selectionModel() const { - return m_view->selectionModel(); +QModelIndex GameGridView::currentIndex() const { + if (m_fav_view->hasFocus()) return m_fav_view->currentIndex(); + return m_main_view->currentIndex(); } -QRect GameGridView::visualRect(const QModelIndex& index) const { - return m_view->visualRect(index); +void GameGridView::setCurrentIndex(const QModelIndex& index) { + if (index.model() == m_fav_view->model()) m_fav_view->setCurrentIndex(index); + else m_main_view->setCurrentIndex(index); } -QWidget* GameGridView::viewport() const { - return m_view->viewport(); -} - -void GameGridView::scrollTo(const QModelIndex& index) { - m_view->scrollTo(index); +QModelIndex GameGridView::indexAt(const QPoint& p) const { + QPoint fp = m_fav_view->mapFrom(this, p); + if (m_fav_view->rect().contains(fp)) return m_fav_view->indexAt(fp); + QPoint mp = m_main_view->mapFrom(this, p); + if (m_main_view->rect().contains(mp)) return m_main_view->indexAt(mp); + return QModelIndex(); } void GameGridView::setControllerFocus(bool focus) { m_has_focus = focus; if (focus) { - m_view->setFocus(); - if (!m_view->currentIndex().isValid() && m_view->model() && m_view->model()->rowCount() > 0) { - m_view->setCurrentIndex(m_view->model()->index(0, 0)); + if (m_fav_view->isVisible() && m_fav_view->model()->rowCount() > 0) { + m_fav_view->setFocus(); + if (!m_fav_view->currentIndex().isValid()) m_fav_view->setCurrentIndex(m_fav_view->model()->index(0, 0)); + } else { + m_main_view->setFocus(); + if (!m_main_view->currentIndex().isValid()) m_main_view->setCurrentIndex(m_main_view->model()->index(0, 0)); } } } void GameGridView::onNavigated(int dx, int dy) { - if (!m_has_focus || !m_view->model()) return; - - QModelIndex current = m_view->currentIndex(); - if (!current.isValid()) { - m_view->setCurrentIndex(m_view->model()->index(0, 0)); + if (!m_has_focus) return; + QListView* current = m_fav_view->hasFocus() ? m_fav_view : m_main_view; + if (!current->model()) return; + + QModelIndex idx = current->currentIndex(); + if (!idx.isValid()) { + current->setCurrentIndex(current->model()->index(0, 0)); + return; + } + + int row = idx.row(); + int total = current->model()->rowCount(); + int cols = qMax(1, current->viewport()->width() / current->gridSize().width()); + + if (dy > 0 && row + cols >= total && current == m_fav_view) { + m_main_view->setFocus(); + m_main_view->setCurrentIndex(m_main_view->model()->index(0, 0)); + return; + } + if (dy < 0 && row < cols && current == m_main_view && m_fav_view->isVisible()) { + m_fav_view->setFocus(); + m_fav_view->setCurrentIndex(m_fav_view->model()->index(m_fav_view->model()->rowCount() - 1, 0)); return; } - // Grid navigation logic (simplified for now, can be improved) - int row = current.row(); - int total = m_view->model()->rowCount(); - if (dx > 0) row++; else if (dx < 0) row--; - - if (dy > 0) { - // Find how many items per row - int icons_per_row = qMax(1, m_view->viewport()->width() / m_view->gridSize().width()); - row += icons_per_row; - } else if (dy < 0) { - int icons_per_row = qMax(1, m_view->viewport()->width() / m_view->gridSize().width()); - row -= icons_per_row; - } - + if (dy > 0) row += cols; + else if (dy < 0) row -= cols; + row = qBound(0, row, total - 1); - m_view->setCurrentIndex(m_view->model()->index(row, 0)); - m_view->scrollTo(m_view->currentIndex()); + current->setCurrentIndex(current->model()->index(row, 0)); + scrollTo(current->currentIndex()); } void GameGridView::onActivated() { if (!m_has_focus) return; - QModelIndex current = m_view->currentIndex(); - if (current.isValid()) { - emit itemActivated(current); - } + QModelIndex cur = currentIndex(); + if (cur.isValid()) emit itemActivated(cur); } void GameGridView::onCancelled() { - if (!m_has_focus) return; m_has_focus = false; - m_view->clearFocus(); -} - -void GameGridView::setModel(QAbstractItemModel* model) { - m_view->setModel(model); - if (m_view->selectionModel()) { - connect(m_view->selectionModel(), &QItemSelectionModel::currentChanged, - this, [this](const QModelIndex& current) { - emit itemSelectionChanged(current); - }); - } + m_fav_view->clearFocus(); + m_main_view->clearFocus(); } void GameGridView::keyPressEvent(QKeyEvent* event) { if (event->key() == Qt::Key_X || event->key() == Qt::Key_Y) { - if (!m_view->model()) return; - - QModelIndex current = m_view->currentIndex(); - if (!current.isValid()) { - if (m_view->model()->rowCount() > 0) { - m_view->setCurrentIndex(m_view->model()->index(0, 0)); - return; - } + QListView* current = m_fav_view->hasFocus() ? m_fav_view : m_main_view; + if (current == m_fav_view && m_main_view->model()->rowCount() > 0) { + m_main_view->setFocus(); + m_main_view->setCurrentIndex(m_main_view->model()->index(0, 0)); + return; } - - QString title = current.data(Qt::DisplayRole).toString(); - QChar current_char = title.isEmpty() ? QLatin1Char(' ') : title[0].toUpper(); - - int total = m_view->model()->rowCount(); - int start_row = current.row(); - for (int i = 1; i <= total; ++i) { - int next_row = (start_row + i) % total; - QString next_title = m_view->model()->index(next_row, 0).data(Qt::DisplayRole).toString(); - QChar next_char = next_title.isEmpty() ? QLatin1Char(' ') : next_title[0].toUpper(); - if (next_char != current_char) { - m_view->setCurrentIndex(m_view->model()->index(next_row, 0)); - m_view->scrollTo(m_view->model()->index(next_row, 0)); + if (!current->model()) return; + QModelIndex cur = current->currentIndex(); + if (!cur.isValid()) return; + QString title = cur.data(Qt::DisplayRole).toString(); + QChar cc = title.isEmpty() ? QLatin1Char(' ') : title[0].toUpper(); + int tot = current->model()->rowCount(); + int sr = cur.row(); + for (int i = 1; i <= tot; ++i) { + int nr = (sr + i) % tot; + QModelIndex nidx = current->model()->index(nr, 0); + QString nt = nidx.data(Qt::DisplayRole).toString(); + QChar nc = nt.isEmpty() ? QLatin1Char(' ') : nt[0].toUpper(); + if (nc != cc) { + current->setCurrentIndex(nidx); + current->scrollTo(nidx); return; } } @@ -191,15 +233,68 @@ void GameGridView::keyPressEvent(QKeyEvent* event) { void GameGridView::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); - - // Adaptive Grid Scaling: Eliminate the Gap - const int icon_size = UISettings::values.game_icon_size.GetValue(); - const int base_width = icon_size + 40; - const int total_width = m_view->viewport()->width() - 30; // margins - - if (total_width > base_width) { - int columns = qMax(1, total_width / base_width); - int adaptive_width = total_width / columns; - m_view->setGridSize(QSize(adaptive_width, base_width + 45)); - } + UpdateGridSize(); } + +void GameGridView::UpdateGridSize() { + const int is = UISettings::values.game_icon_size.GetValue(); + const float s = static_cast(is) / 128.0f; + const int bw = qMax(is + static_cast(40 * s), is + 24); + const int tw = m_scroll_area->viewport()->width(); + if (tw > 50) { + int cols = qMax(1, tw / bw); + int aw = tw / cols; + const int item_h = qMax(is + static_cast(85 * s), is + 40); + QSize gs(aw, item_h); + m_fav_view->setGridSize(gs); + m_main_view->setGridSize(gs); + m_fav_view->setFixedWidth(tw); + m_main_view->setFixedWidth(tw); + } + UpdateLayoutHeights(); +} + +void GameGridView::UpdateLayoutHeights() { + auto updateHeight = [&](QListView* view) { + if (!view || !view->isVisible() || !view->model() || view->model()->rowCount() == 0) { + view->setFixedHeight(0); + return; + } + const int count = view->model()->rowCount(); + const QSize gs = view->gridSize(); + if (gs.width() <= 0 || gs.height() <= 0) return; + const int tw = m_scroll_area->viewport()->width(); + const int cols = qMax(1, tw / gs.width()); + const int rows = (count + cols - 1) / cols; + view->setFixedHeight(rows * gs.height() + 40); + }; + updateHeight(m_fav_view); + updateHeight(m_main_view); +} + +QAbstractItemModel* GameGridView::favModel() const { return m_fav_view->model(); } +QAbstractItemModel* GameGridView::mainModel() const { return m_main_view->model(); } + +QItemSelectionModel* GameGridView::selectionModel() const { + return m_main_view->selectionModel(); +} + +void GameGridView::setModel(QAbstractItemModel* model) { + m_main_view->setModel(model); + m_fav_view->setModel(nullptr); + m_fav_label->hide(); + m_fav_view->hide(); + UpdateGridSize(); +} +QRect GameGridView::visualRect(const QModelIndex& index) const { + if (index.model() == m_fav_view->model()) return m_fav_view->visualRect(index); + return m_main_view->visualRect(index); +} +QWidget* GameGridView::viewport() const { return m_scroll_area->viewport(); } +void GameGridView::scrollTo(const QModelIndex& index) { + QListView* view = (index.model() == m_fav_view->model()) ? m_fav_view : m_main_view; + QRect rect = view->visualRect(index); + rect.moveTop(rect.top() + view->y()); + m_scroll_area->ensureVisible(rect.center().x(), rect.center().y(), 50, 50); +} + diff --git a/src/citron/ui/game_grid_view.h b/src/citron/ui/game_grid_view.h index d4138391b7..fa34a5b58a 100644 --- a/src/citron/ui/game_grid_view.h +++ b/src/citron/ui/game_grid_view.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -19,23 +20,26 @@ class GameGridView : public QWidget { public: explicit GameGridView(QWidget* parent = nullptr); - void setModel(QAbstractItemModel* model); + void setModels(QAbstractItemModel* fav_model, QAbstractItemModel* main_model); void ApplyTheme(); - // Accessors for compatibility - QListView* view() const { return m_view; } - QAbstractItemModel* model() const; + QListView* view() const { return m_main_view; } + QListView* favView() const { return m_fav_view; } + QAbstractItemModel* model() const { return mainModel(); } QItemSelectionModel* selectionModel() const; + void setModel(QAbstractItemModel* model); + + QAbstractItemModel* favModel() const; + QAbstractItemModel* mainModel() const; + QRect visualRect(const QModelIndex& index) const; QWidget* viewport() const; void scrollTo(const QModelIndex& index); - // Forwarded from GameViewBase equivalents - QModelIndex currentIndex() const { return m_view->currentIndex(); } - void setCurrentIndex(const QModelIndex& index) { m_view->setCurrentIndex(index); } - QModelIndex indexAt(const QPoint& p) const { return m_view->indexAt(p); } + QModelIndex currentIndex() const; + void setCurrentIndex(const QModelIndex& index); + QModelIndex indexAt(const QPoint& p) const; - // Controller Navigation void setControllerFocus(bool focus); bool hasControllerFocus() const { return m_has_focus; } @@ -53,10 +57,20 @@ protected: void resizeEvent(QResizeEvent* event) override; private: - QListView* m_view = nullptr; - GameGridDelegate* m_delegate = nullptr; + void UpdateGridSize(); + void UpdateLayoutHeights(); + + QScrollArea* m_scroll_area = nullptr; + QWidget* m_container = nullptr; + QListView* m_fav_view = nullptr; + QListView* m_main_view = nullptr; + QLabel* m_fav_label = nullptr; + QLabel* m_main_label = nullptr; + GameGridDelegate* m_fav_delegate = nullptr; + GameGridDelegate* m_main_delegate = nullptr; QVBoxLayout* m_layout = nullptr; QLabel* m_top_help = nullptr; QLabel* m_bottom_hint = nullptr; bool m_has_focus = false; }; +