diff --git a/dist/font_size.svg b/dist/font_size.svg
new file mode 100644
index 0000000000..8274530d69
--- /dev/null
+++ b/dist/font_size.svg
@@ -0,0 +1,3 @@
+
diff --git a/dist/game_icon.svg b/dist/game_icon.svg
new file mode 100644
index 0000000000..a6bec3dabe
--- /dev/null
+++ b/dist/game_icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/citron/citron.qrc b/src/citron/citron.qrc
index 538ce21119..ad42700676 100644
--- a/src/citron/citron.qrc
+++ b/src/citron/citron.qrc
@@ -13,6 +13,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
../../dist/carousel.svg
../../dist/list.svg
../../dist/grid.svg
+ ../../dist/font_size.svg
+ ../../dist/game_icon.svg
../../dist/sort_ascending.svg
../../dist/sort_descending.svg
../../dist/controller_navigation.svg
diff --git a/src/citron/game_grid_delegate.cpp b/src/citron/game_grid_delegate.cpp
index fe98317a96..eac229b827 100644
--- a/src/citron/game_grid_delegate.cpp
+++ b/src/citron/game_grid_delegate.cpp
@@ -111,7 +111,8 @@ void GameGridDelegate::PaintGridItem(QPainter* painter, const QStyleOptionViewIt
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;
+ const float raw_scale = static_cast(icon_size) / 128.0f;
+ const float scale = std::max(0.1f, raw_scale);
qreal entry_val = 1.0;
const QPersistentModelIndex key(index);
@@ -218,7 +219,7 @@ void GameGridDelegate::PaintGridItem(QPainter* painter, const QStyleOptionViewIt
painter->setPen(fav_gold);
QFont sf = painter->font();
sf.setBold(true);
- sf.setPointSizeF(11.0f * scale);
+ sf.setPointSizeF(std::max(1.0f, 11.0f * scale));
painter->setFont(sf);
painter->drawText(star_rect.adjusted(0, -1 * scale, 0, 0), Qt::AlignCenter,
QStringLiteral("★"));
@@ -238,16 +239,18 @@ void GameGridDelegate::PaintGridItem(QPainter* painter, const QStyleOptionViewIt
if (pixmap.isNull())
pixmap = index.data(Qt::DecorationRole).value();
if (!pixmap.isNull()) {
- QRectF mid_area(label_rect.left(), label_rect.top(), label_rect.width(),
- bottom_bar.top() - label_rect.top());
- painter->drawPixmap(mid_area, pixmap, pixmap.rect());
+ const qreal mid_h = bottom_bar.top() - label_rect.top();
+ if (mid_h > 0) {
+ QRectF mid_area(label_rect.left(), label_rect.top(), label_rect.width(), mid_h);
+ painter->drawPixmap(mid_area, pixmap, pixmap.rect());
+ }
}
QString title = index.data(Qt::DisplayRole).toString().split(QLatin1Char('\n')).first();
painter->setPen(Qt::white);
QFont tf = option.font;
tf.setBold(true);
- tf.setPointSizeF(8.5f * scale);
+ tf.setPointSizeF(std::max(1.0f, 8.5f * scale));
painter->setFont(tf);
QRectF text_rect = bottom_bar.adjusted(10 * scale, 0, -10 * scale, 0);
diff --git a/src/citron/game_list.cpp b/src/citron/game_list.cpp
index 7cf08eb9ca..862bcf09ba 100644
--- a/src/citron/game_list.cpp
+++ b/src/citron/game_list.cpp
@@ -1346,109 +1346,190 @@ GameList::GameList(std::shared_ptr vfs_,
connect(btn_carousel_view, &QToolButton::clicked,
[this]() { SetViewMode(GameList::ViewMode::Carousel); });
- // Title/Icon size slider - compact with rounded corners
+ // Helper to create the small mode-toggle buttons flanking the slider
+ auto makeModeBtn = [&](const QString& label, const QString& tip) -> QToolButton* {
+ auto* btn = new QToolButton(toolbar);
+ btn->setText(label);
+ btn->setToolTip(tip);
+ btn->setCheckable(true);
+ btn->setAutoRaise(true);
+ btn->setFixedSize(26, 26);
+ btn->setStyleSheet(QStringLiteral(
+ "QToolButton {"
+ " border: 1px solid #3e3e42;"
+ " border-radius: 4px;"
+ " background: #2b2b2f;"
+ " color: #aaaaaa;"
+ " font-size: 9px; font-weight: bold;"
+ "}"
+ "QToolButton:hover { background: #3e3e42; color: #ffffff; }"
+ "QToolButton:checked {"
+ " background: #1a3a5c;"
+ " border-color: #0078d4;"
+ " color: #7ec8ff;"
+ "}"));
+ return btn;
+ };
+
+ // Font-size mode toggle
+ btn_slider_font_mode = makeModeBtn(QStringLiteral(""), tr("Slider: Font Size"));
+ QIcon font_icon(QStringLiteral(":/dist/font_size.svg"));
+ btn_slider_font_mode->setIcon(font_icon);
+ btn_slider_font_mode->setIconSize(QSize(16, 16));
+ btn_slider_font_mode->setChecked(true); // default mode
+
+ // Icon-size mode toggle
+ btn_slider_icon_mode = makeModeBtn(QStringLiteral(""), tr("Slider: Game Icon Size"));
+ QIcon img_icon(QStringLiteral(":/dist/game_icon.svg"));
+ btn_slider_icon_mode->setIcon(img_icon);
+ btn_slider_icon_mode->setIconSize(QSize(16, 16));
+
+ // Title/Icon size slider — range and initial value depend on active mode
slider_title_size = new QSlider(Qt::Horizontal, toolbar);
- slider_title_size->setMinimum(32);
- slider_title_size->setMaximum(256);
- slider_title_size->setValue(static_cast(UISettings::values.game_icon_size.GetValue()));
- slider_title_size->setToolTip(tr("Game Icon Size"));
- slider_title_size->setMaximumWidth(120);
- slider_title_size->setMinimumWidth(120);
- slider_title_size->setStyleSheet(QStringLiteral("QSlider::groove:horizontal {"
- " border: 1px solid palette(mid);"
- " height: 4px;"
- " background: palette(base);"
- " border-radius: 2px;"
- "}"
- "QSlider::handle:horizontal {"
- " background: palette(button);"
- " border: 1px solid palette(mid);"
- " width: 12px;"
- " height: 12px;"
- " margin: -4px 0;"
- " border-radius: 6px;"
- "}"
- "QSlider::handle:horizontal:hover {"
- " background: palette(light);"
- "}"));
+ slider_title_size->setMinimum(8); // font points (font mode)
+ slider_title_size->setMaximum(24);
+ // Initialise to current font size
+ slider_title_size->setValue(tree_view->font().pointSize());
+ slider_title_size->setToolTip(tr("Font Size"));
+ slider_title_size->setMaximumWidth(110);
+ slider_title_size->setMinimumWidth(110);
+ slider_title_size->setStyleSheet(QStringLiteral(
+ "QSlider::groove:horizontal {"
+ " border: 1px solid palette(mid);"
+ " height: 4px;"
+ " background: palette(base);"
+ " border-radius: 2px;"
+ "}"
+ "QSlider::handle:horizontal {"
+ " background: palette(button);"
+ " border: 1px solid palette(mid);"
+ " width: 12px; height: 12px;"
+ " margin: -4px 0;"
+ " border-radius: 6px;"
+ "}"
+ "QSlider::handle:horizontal:hover { background: palette(light); }"));
+
+ // Switch to font-size mode
+ connect(btn_slider_font_mode, &QToolButton::clicked, [this]() {
+ slider_icon_mode = false;
+ btn_slider_font_mode->setChecked(true);
+ btn_slider_icon_mode->setChecked(false);
+ slider_title_size->blockSignals(true);
+ slider_title_size->setRange(8, 24);
+ slider_title_size->setValue(qBound(8, tree_view->font().pointSize(), 24));
+ slider_title_size->setToolTip(tr("Font Size"));
+ slider_title_size->blockSignals(false);
+ });
+
+ // Switch to icon-size mode
+ connect(btn_slider_icon_mode, &QToolButton::clicked, [this]() {
+ slider_icon_mode = true;
+ btn_slider_icon_mode->setChecked(true);
+ btn_slider_font_mode->setChecked(false);
+ slider_title_size->blockSignals(true);
+ slider_title_size->setRange(32, 256);
+ slider_title_size->setValue(
+ static_cast(UISettings::values.game_icon_size.GetValue()));
+ slider_title_size->setToolTip(tr("Game Icon Size"));
+ slider_title_size->blockSignals(false);
+ });
+
connect(slider_title_size, &QSlider::valueChanged, [this](int value) {
- // Update title font size in tree view
- QFont font = tree_view->font();
- font.setPointSize(qBound(8, value / 8, 24));
- tree_view->setFont(font);
- tree_view->doItemsLayout(); // Force redraw and size recalculation
+ if (!slider_icon_mode) {
+ // ── Font-size mode ──────────────────────────────────────────────
+ QFont font = tree_view->font();
+ font.setPointSize(qBound(8, value, 24));
+ tree_view->setFont(font);
+ tree_view->doItemsLayout();
+ } else {
+ // ── Icon-size mode ──────────────────────────────────────────────
+ UISettings::values.game_icon_size.SetValue(static_cast(value));
+
+ // Immediately force the tree view to recalculate row heights and repaint
+ // so the game cards ("pills") scale dynamically without clipping.
+ tree_view->doItemsLayout();
+
+ if (grid_view) {
+ grid_view->UpdateGridSize();
+ }
+ if (carousel_view) {
+ carousel_view->update();
+ }
#ifndef __linux__
- // On non-Linux platforms, also update game icon size and repaint grid view
- UISettings::values.game_icon_size.SetValue(static_cast(value));
- if (grid_view->isVisible()) {
- QAbstractItemModel* current_model = grid_view->model();
- if (current_model && current_model != item_model) {
- QStandardItemModel* flat_model = qobject_cast(current_model);
- if (flat_model) {
- const u32 icon_size = static_cast(value);
- grid_view->view()->setGridSize(QSize(icon_size + 60, icon_size + 80));
- int scroll_position = grid_view->view()->verticalScrollBar()->value();
- QModelIndex current_index = grid_view->currentIndex();
+ if (grid_view->isVisible()) {
+ QAbstractItemModel* current_model = grid_view->model();
+ if (current_model && current_model != item_model) {
+ QStandardItemModel* flat_model =
+ qobject_cast(current_model);
+ if (flat_model) {
+ const u32 icon_size = static_cast(value);
+ int scroll_position =
+ grid_view->view()->verticalScrollBar()->value();
+ QModelIndex current_index = grid_view->currentIndex();
- for (int i = 0; i < flat_model->rowCount(); ++i) {
- QStandardItem* item = flat_model->item(i);
- if (item) {
- u64 program_id =
- item->data(GameListItemPath::ProgramIdRole).toULongLong();
- QStandardItem* original_item = nullptr;
- for (int folder_idx = 0; folder_idx < item_model->rowCount();
- ++folder_idx) {
- QStandardItem* folder = item_model->item(folder_idx, 0);
- if (!folder)
- continue;
- for (int game_idx = 0; game_idx < folder->rowCount(); ++game_idx) {
- QStandardItem* game = folder->child(game_idx, 0);
- if (game &&
- game->data(GameListItemPath::ProgramIdRole).toULongLong() ==
- program_id) {
- original_item = game;
- break;
+ for (int i = 0; i < flat_model->rowCount(); ++i) {
+ QStandardItem* item = flat_model->item(i);
+ if (item) {
+ u64 program_id =
+ item->data(GameListItemPath::ProgramIdRole).toULongLong();
+ QStandardItem* original_item = nullptr;
+ for (int folder_idx = 0; folder_idx < item_model->rowCount();
+ ++folder_idx) {
+ QStandardItem* folder = item_model->item(folder_idx, 0);
+ if (!folder)
+ continue;
+ for (int game_idx = 0; game_idx < folder->rowCount();
+ ++game_idx) {
+ QStandardItem* game = folder->child(game_idx, 0);
+ if (game && game->data(GameListItemPath::ProgramIdRole)
+ .toULongLong() == program_id) {
+ original_item = game;
+ break;
+ }
}
+ if (original_item)
+ break;
}
- if (original_item)
- break;
- }
- if (original_item) {
- QVariant orig_icon_data = original_item->data(Qt::DecorationRole);
- if (orig_icon_data.isValid() &&
- orig_icon_data.type() == QVariant::Pixmap) {
- QPixmap orig_pixmap = orig_icon_data.value();
- 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 = orig_pixmap.scaled(icon_size, icon_size,
- Qt::IgnoreAspectRatio,
- Qt::SmoothTransformation);
- painter.drawPixmap(0, 0, scaled);
- item->setData(rounded, Qt::DecorationRole);
+ if (original_item) {
+ QVariant orig_icon_data =
+ original_item->data(Qt::DecorationRole);
+ if (orig_icon_data.isValid() &&
+ orig_icon_data.type() == QVariant::Pixmap) {
+ QPixmap orig_pixmap =
+ orig_icon_data.value();
+ 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 = orig_pixmap.scaled(
+ icon_size, icon_size, Qt::IgnoreAspectRatio,
+ Qt::SmoothTransformation);
+ painter.drawPixmap(0, 0, scaled);
+ item->setData(rounded, Qt::DecorationRole);
+ }
}
}
}
+ if (scroll_position >= 0)
+ grid_view->view()->verticalScrollBar()->setValue(scroll_position);
+ if (current_index.isValid() &&
+ current_index.row() < flat_model->rowCount())
+ grid_view->setCurrentIndex(
+ flat_model->index(current_index.row(), 0));
}
- if (scroll_position >= 0) {
- grid_view->view()->verticalScrollBar()->setValue(scroll_position);
- }
- if (current_index.isValid() && current_index.row() < flat_model->rowCount()) {
- grid_view->setCurrentIndex(flat_model->index(current_index.row(), 0));
- }
+ } else {
+ PopulateGridView();
}
- } else {
- PopulateGridView();
}
- }
#endif
+ }
});
// A-Z sort button - positioned after slider
@@ -1511,6 +1592,8 @@ GameList::GameList(std::shared_ptr vfs_,
toolbar_layout->addWidget(btn_list_view);
toolbar_layout->addWidget(btn_grid_view);
toolbar_layout->addWidget(btn_carousel_view);
+ toolbar_layout->addWidget(btn_slider_font_mode);
+ toolbar_layout->addWidget(btn_slider_icon_mode);
toolbar_layout->addWidget(slider_title_size);
toolbar_layout->addWidget(btn_sort_az);
toolbar_layout->addWidget(btn_surprise_me);
@@ -1932,6 +2015,82 @@ void GameList::AddEntry(const QList& entry_items, const QString&
if (delegate) {
delegate->RegisterEntryAnimation(entry_items.first()->index());
}
+
+ // Dynamically populate grid and carousel views so games appear instantly when parsing
+ QStandardItem* src = entry_items.first();
+ QString path = src->data(GameListItemPath::FullPathRole).toString();
+
+ if (!UISettings::values.hidden_paths.contains(path)) {
+ QString title = src->data(GameListItemPath::TitleRole).toString();
+ if (title.isEmpty()) {
+ std::string fn;
+ Common::SplitPath(path.toStdString(), nullptr, &fn, nullptr);
+ title = QString::fromStdString(fn);
+ }
+
+ QString filter_text = search_field->filterText().toLower();
+ if (filter_text.isEmpty() || ContainsAllWords(title.toLower(), filter_text)) {
+ u64 pid = src->data(GameListItemPath::ProgramIdRole).toULongLong();
+ bool is_fav = UISettings::values.favorited_ids.contains(pid);
+
+ auto cloneToFlatModel = [&](QStandardItemModel* target, bool force_fav) {
+ if (!target) return;
+ QStandardItem* item = src->clone();
+ item->setText(title);
+ item->setData(title, GameListItemPath::SortRole);
+ item->setData(static_cast(force_fav ? GameListItemType::Favorites : GameListItemType::Game), GameListItem::TypeRole);
+
+ // Scale icon for flat layout
+ const u32 icon_size = UISettings::values.game_icon_size.GetValue();
+ QVariant icon_data = item->data(Qt::DecorationRole);
+ if (icon_data.isValid() && icon_data.canConvert()) {
+ QPixmap pixmap = icon_data.value();
+ if (!pixmap.isNull()) {
+ item->setData(pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), Qt::DecorationRole);
+ }
+ }
+
+ target->appendRow(item);
+
+ // We typically want to sort grid models, but NOT the carousel (it uses its own segments)
+ if (target != static_cast(carousel_view->view()->model())) {
+ target->sort(0, current_sort_order);
+ }
+
+ // Trigger "Pop-in" Animations for discovery
+ // Use !isHidden() instead of isVisible() to ensure it triggers during boot-time startup
+ if (grid_view && !grid_view->isHidden()) {
+ QListView* active_grid = force_fav ? grid_view->favView() : grid_view->view();
+ if (active_grid) {
+ auto* grid_delegate = qobject_cast(active_grid->itemDelegate());
+ if (grid_delegate) {
+ // Find the new index in the target model after sorting to ensure animation triggers on the correct item
+ QModelIndex new_idx;
+ auto matches = target->match(target->index(0, 0), GameListItemPath::FullPathRole, path, 1, Qt::MatchExactly);
+ if (!matches.isEmpty()) {
+ new_idx = matches.first();
+ grid_delegate->RegisterEntryAnimation(new_idx);
+ }
+ }
+ }
+ }
+ if (carousel_view && !carousel_view->isHidden() && target == static_cast(carousel_view->view()->model())) {
+ carousel_view->view()->RegisterEntryAnimation(target->index(target->rowCount() - 1, 0));
+ }
+ };
+
+ if (grid_view && grid_view->mainModel()) {
+ QStandardItemModel* target_model = static_cast(is_fav ? grid_view->favModel() : grid_view->mainModel());
+ cloneToFlatModel(target_model, is_fav);
+ grid_view->UpdateGridSize();
+ }
+ if (carousel_view && carousel_view->view()->model()) {
+ QStandardItemModel* target_model = static_cast(carousel_view->view()->model());
+ cloneToFlatModel(target_model, is_fav);
+ }
+ }
+ }
+
}
// Auto-scroll to show new games as they appear (only on first-time or full rebuild)
@@ -3274,6 +3433,10 @@ void GameList::PopulateAsync(QVector& game_dirs, bool is_sm
item_model->clear();
item_model->insertColumns(0, COLUMN_COUNT);
RetranslateUI();
+
+ // Ensure flat models for Grid/Carousel are instantiated empty BEFORE async parsing starts
+ // so that games can be visually streamed straight into them during boot.
+ FilterGridView(search_field->filterText());
} else {
// Targeted metadata updates are now handled by RefreshGame() or worker signals
// to avoid global UI-wide 'refreshing' indicators that can feel unresponsive.
@@ -3859,6 +4022,14 @@ void GameList::UpdateAccentColorStyles() {
if (btn_carousel_view)
btn_carousel_view->setStyleSheet(icon_button_style + button_checked_style);
+ // Apply accent style to our new slider toggles so they match the other tool buttons
+ if (btn_slider_font_mode) {
+ btn_slider_font_mode->setStyleSheet(icon_button_style + button_checked_style);
+ }
+ if (btn_slider_icon_mode) {
+ btn_slider_icon_mode->setStyleSheet(icon_button_style + button_checked_style);
+ }
+
// Also update Sort, Surprise Me and Controller buttons with the locked dark style
btn_sort_az->setStyleSheet(icon_button_style);
btn_surprise_me->setStyleSheet(icon_button_style);
diff --git a/src/citron/game_list.h b/src/citron/game_list.h
index 49c3f8fc05..d6bcf4a3ef 100644
--- a/src/citron/game_list.h
+++ b/src/citron/game_list.h
@@ -254,6 +254,9 @@ private:
QToolButton* btn_grid_view = nullptr;
QToolButton* btn_carousel_view = nullptr;
QSlider* slider_title_size = nullptr;
+ QToolButton* btn_slider_font_mode = nullptr;
+ QToolButton* btn_slider_icon_mode = nullptr;
+ bool slider_icon_mode = false; // false = font size, true = icon size
QToolButton* btn_sort_az = nullptr;
QToolButton* btn_surprise_me = nullptr;
QToolButton* btn_controller_settings = nullptr;
diff --git a/src/citron/game_list_delegate.cpp b/src/citron/game_list_delegate.cpp
index 41ec59e250..9484e89f4a 100644
--- a/src/citron/game_list_delegate.cpp
+++ b/src/citron/game_list_delegate.cpp
@@ -26,6 +26,7 @@
#include
#include
#include
+#include
#include
@@ -114,6 +115,16 @@ void OnyxTooltip::showText(const QPoint& pos, const QString& text, QWidget* w) {
showPos.setY(pos.y() - s_onyx_tooltip_instance->height() - 10);
}
+ // Wayland fix: Popups must have a transient parent to be positioned correctly
+ if (w && w->window()) {
+ s_onyx_tooltip_instance->winId(); // Ensure window handle is created
+ if (auto* window = s_onyx_tooltip_instance->windowHandle()) {
+ if (auto* parent_window = w->window()->windowHandle()) {
+ window->setTransientParent(parent_window);
+ }
+ }
+ }
+
s_onyx_tooltip_instance->move(showPos);
s_onyx_tooltip_instance->show();
}
@@ -159,7 +170,21 @@ QSize GameListDelegate::sizeHint(const QStyleOptionViewItem& option,
const bool is_game_row = index.parent().isValid();
if (is_game_row) {
- size.setHeight(GetCardHeight());
+ int base_height = GetCardHeight();
+
+ // Calculate the height needed for the text based on current font metrics
+ QFontMetrics title_metrics(option.font);
+ const int title_h = title_metrics.ascent() + title_metrics.descent();
+
+ QFont f_id = option.font;
+ f_id.setPointSize(std::max(6, f_id.pointSize() - 2));
+ QFontMetrics id_metrics(f_id);
+ const int id_h = id_metrics.ascent() + id_metrics.descent();
+
+ // 6px spacing between lines + 24px total top/bottom margin
+ const int text_required_height = title_h + id_h + 6 + 24;
+
+ size.setHeight(std::max(base_height, text_required_height));
} else {
size.setHeight(36); // Clean height for folder headers
}
@@ -552,7 +577,9 @@ void GameListDelegate::PaintGameInfo(QPainter* painter, const QRect& rect,
icon_path.addRoundedRect(icon_rect, 6, 6);
painter->save();
painter->setClipPath(icon_path);
- painter->drawPixmap(icon_rect, pixmap.scaled(icon_size, icon_size, Qt::KeepAspectRatio,
+
+ const int final_icon_size = std::max(1, icon_size);
+ painter->drawPixmap(icon_rect, pixmap.scaled(final_icon_size, final_icon_size, Qt::KeepAspectRatio,
Qt::SmoothTransformation));
painter->restore();
diff --git a/src/citron/ui/game_carousel_view.cpp b/src/citron/ui/game_carousel_view.cpp
index 3091781c24..a47d6b7e6c 100644
--- a/src/citron/ui/game_carousel_view.cpp
+++ b/src/citron/ui/game_carousel_view.cpp
@@ -22,7 +22,26 @@ CinematicCarousel::CinematicCarousel(QWidget* parent) : QWidget(parent) {
m_snap_animation->setEasingCurve(QEasingCurve::OutCubic);
m_pulse_timer = new QTimer(this);
- connect(m_pulse_timer, &QTimer::timeout, this, [this]{ m_pulse_tick++; update(); });
+ connect(m_pulse_timer, &QTimer::timeout, this, [this]{
+ m_pulse_tick++;
+
+ // Advance entry animations
+ auto it = m_entry_animations.begin();
+ while (it != m_entry_animations.end()) {
+ if (!it.key().isValid()) {
+ it = m_entry_animations.erase(it);
+ continue;
+ }
+ if (it.value() < 1.0) {
+ it.value() += 0.06;
+ if (it.value() >= 1.0) it.value() = 1.0;
+ ++it;
+ } else {
+ it = m_entry_animations.erase(it);
+ }
+ }
+ update();
+ });
m_pulse_timer->start(32);
m_scroll_timer = new QTimer(this);
@@ -85,8 +104,19 @@ QRect CinematicCarousel::visualRect(const QModelIndex& index) const {
return QRect(x - (cs * s) / 2.0, vcy - (cs * s) / 2.0, cs * s, cs * s);
}
-void CinematicCarousel::setModel(QAbstractItemModel* model) { m_model = model; if (m_model && m_model->rowCount() > 0) setFocalIndex(0.0); update(); }
-
+void CinematicCarousel::setModel(QAbstractItemModel* model) {
+ if (m_model) {
+ m_model->disconnect(this);
+ }
+ m_model = model;
+ if (m_model) {
+ connect(m_model, &QAbstractItemModel::rowsInserted, this, [this]() { update(); });
+ connect(m_model, &QAbstractItemModel::modelReset, this, [this]() { update(); });
+ connect(m_model, &QAbstractItemModel::dataChanged, this, [this]() { update(); });
+ }
+ if (m_model && m_model->rowCount() > 0) setFocalIndex(0.0);
+ update();
+}
void CinematicCarousel::setFocalIndex(qreal index) {
if (!m_model || m_model->rowCount() == 0) m_focal_index = 0.0;
else m_focal_index = std::max(0.0, std::min(static_cast(m_model->rowCount() - 1), index));
@@ -95,6 +125,12 @@ void CinematicCarousel::setFocalIndex(qreal index) {
void CinematicCarousel::scrollTo(int index) { if (!m_model || index < 0 || index >= m_model->rowCount()) return; startSnapAnimation(index); }
+void CinematicCarousel::RegisterEntryAnimation(const QModelIndex& index) {
+ if (index.isValid()) {
+ m_entry_animations[QPersistentModelIndex(index)] = 0.0;
+ }
+}
+
void CinematicCarousel::scrollToLetter(QChar letter) {
if (!m_model) return;
for (int i = 0; i < m_model->rowCount(); ++i) {
@@ -118,10 +154,10 @@ void CinematicCarousel::paintEvent(QPaintEvent* event) {
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 float scale = static_cast(is) / 128.0f;
+ const float raw_scale = static_cast(is) / 128.0f;
+ const float scale = std::max(0.1f, raw_scale);
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));
+ const qreal arrow_ay = std::max(35.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;
@@ -130,9 +166,22 @@ void CinematicCarousel::paintEvent(QPaintEvent* event) {
const qreal d = i - m_focal_index; const qreal dist = std::abs(d * bs);
if (dist > width()) continue;
qreal s = 1.0; qreal dx = 0.0;
- 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);
+ p.save(); p.translate(x, y);
+
+ // Apply Entry Animation (discovery "pop" effect)
+ qreal entry_anim = 1.0;
+ QPersistentModelIndex pidx(m_model->index(i, 0));
+ if (m_entry_animations.contains(pidx)) {
+ entry_anim = m_entry_animations[pidx];
+ }
+ if (entry_anim < 1.0) {
+ qreal pop_s = 0.7 + (entry_anim * 0.3);
+ p.scale(pop_s, pop_s);
+ p.setOpacity(entry_anim);
+ }
+
+ p.scale(s, s);
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);
@@ -166,14 +215,16 @@ void CinematicCarousel::paintEvent(QPaintEvent* event) {
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"));
+ QFont hf = font(); hf.setBold(true); hf.setPointSizeF(std::max(1.0f, 24.0f * scale)); p.setFont(hf);
+ QRectF text_rect(x - 300 * scale, header_ay - 150 * scale, 600 * scale, 150 * scale);
+ p.drawText(text_rect, Qt::AlignHCenter | Qt::AlignBottom | Qt::TextDontClip, 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);
+ QFont hf = font(); hf.setBold(true); hf.setPointSizeF(std::max(1.0f, 48.0f * scale)); p.setFont(hf);
+ QRectF text_rect(x - 200 * scale, header_ay - 200 * scale, 400 * scale, 200 * scale);
+ p.drawText(text_rect, Qt::AlignHCenter | Qt::AlignBottom | Qt::TextDontClip, cl);
}
p.restore();
}
@@ -351,6 +402,7 @@ GameCarouselView::GameCarouselView(QWidget* parent) : QWidget(parent) {
m_top_hint = new QLabel(this);
m_top_hint->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_hint->setAlignment(Qt::AlignCenter);
+ m_top_hint->setWordWrap(true);
m_layout->addSpacing(10);
m_layout->addWidget(m_top_hint);
m_layout->addSpacing(30);
@@ -361,6 +413,7 @@ GameCarouselView::GameCarouselView(QWidget* parent) : QWidget(parent) {
m_bottom_hint = new QLabel(this);
m_bottom_hint->setText(tr("*You can Drag to Scroll, or Click on Game Icons manually, you can also use your mouse wheel!*"));
m_bottom_hint->setAlignment(Qt::AlignCenter);
+ m_bottom_hint->setWordWrap(true);
m_layout->addWidget(m_bottom_hint);
connect(m_carousel, &CinematicCarousel::focalItemChanged, this, &GameCarouselView::itemSelectionChanged);
connect(m_carousel, &CinematicCarousel::itemActivated, this, &GameCarouselView::itemActivated);
@@ -382,4 +435,4 @@ void GameCarouselView::ApplyTheme() {
}
}
void GameCarouselView::setModel(QAbstractItemModel* model) { m_carousel->setModel(model); }
-void GameCarouselView::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); m_carousel->setMinimumHeight(UISettings::values.game_icon_size.GetValue() + 380); }
+void GameCarouselView::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); m_carousel->setMinimumHeight((UISettings::values.game_icon_size.GetValue() * 2.0) + 450); }
diff --git a/src/citron/ui/game_carousel_view.h b/src/citron/ui/game_carousel_view.h
index 8f302370c2..2fecf1e192 100644
--- a/src/citron/ui/game_carousel_view.h
+++ b/src/citron/ui/game_carousel_view.h
@@ -29,6 +29,7 @@ public:
void scrollTo(int index);
void scrollToLetter(QChar letter);
+ void RegisterEntryAnimation(const QModelIndex& index);
void ApplyTheme();
void setControllerFocus(bool focus);
@@ -86,6 +87,8 @@ private:
QTimer* m_momentum_timer = nullptr;
qreal m_velocity = 0.0;
qint64 m_last_move_timestamp = 0;
+
+ mutable QMap m_entry_animations;
};
class GameCarouselView : public QWidget {
diff --git a/src/citron/ui/game_grid_view.cpp b/src/citron/ui/game_grid_view.cpp
index 9b101fa85c..af8f17e3a2 100644
--- a/src/citron/ui/game_grid_view.cpp
+++ b/src/citron/ui/game_grid_view.cpp
@@ -175,6 +175,7 @@ GameGridView::GameGridView(QWidget* parent) : QWidget(parent) {
m_scroll_area->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scroll_area->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_scroll_area->setStyleSheet(QStringLiteral("QScrollArea { background: transparent; }"));
+ m_scroll_area->viewport()->installEventFilter(this);
m_container = new WoodBackgroundContainer(m_scroll_area);
m_layout = new QVBoxLayout(m_container);
@@ -455,6 +456,14 @@ void GameGridView::keyPressEvent(QKeyEvent* event) {
}
}
+bool GameGridView::eventFilter(QObject* obj, QEvent* event) {
+ if (m_scroll_area && obj == m_scroll_area->viewport() && event->type() == QEvent::Resize) {
+ // Trigger grid size update slightly later to let layout settle, preventing feedback loop
+ QTimer::singleShot(10, this, &GameGridView::UpdateGridSize);
+ }
+ return QWidget::eventFilter(obj, event);
+}
+
void GameGridView::showEvent(QShowEvent* event) {
QWidget::showEvent(event);
QTimer::singleShot(10, this, &GameGridView::UpdateGridSize);
@@ -479,22 +488,46 @@ void GameGridView::UpdateGridSize() {
return;
}
- 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);
+ int fav_count = m_fav_view->model() ? m_fav_view->model()->rowCount() : 0;
+ int main_count = m_main_view->model() ? m_main_view->model()->rowCount() : 0;
+
+ if (tw == m_last_tw && is == m_last_is && fav_count == m_last_fav_count && main_count == m_last_main_count) {
+ return;
+ }
+ m_last_tw = tw;
+ m_last_is = is;
+ m_last_fav_count = fav_count;
+ m_last_main_count = main_count;
+
+ // Ensure the entire Grid View widget never compresses horizontally below 1 column.
+ // + 30 for scrollbar allowance, minimizing grid squishing side-effects.
+ setMinimumWidth(bw + 30);
+
+ 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);
+
+ // Unhide Favorites section if we now have items during discovery
+ if (fav_count > 0 && m_fav_view->isHidden()) {
+ m_fav_label->show();
+ m_fav_view->show();
+ }
+
m_fav_view->setGridSize(gs);
m_main_view->setGridSize(gs);
m_fav_view->setFixedWidth(tw);
m_main_view->setFixedWidth(tw);
- }
+ m_fav_view->doItemsLayout();
+ m_main_view->doItemsLayout();
+ m_fav_view->viewport()->update();
+ m_main_view->viewport()->update();
UpdateLayoutHeights();
}
void GameGridView::UpdateLayoutHeights() {
auto updateHeight = [&](QListView* view) {
- if (!view || !view->isVisible() || !view->model() || view->model()->rowCount() == 0) {
+ if (!view || !view->model() || view->model()->rowCount() == 0) {
view->setFixedHeight(0);
return;
}
diff --git a/src/citron/ui/game_grid_view.h b/src/citron/ui/game_grid_view.h
index 729ba69bf0..d468dfcc68 100644
--- a/src/citron/ui/game_grid_view.h
+++ b/src/citron/ui/game_grid_view.h
@@ -43,6 +43,8 @@ public:
void setControllerFocus(bool focus);
bool hasControllerFocus() const { return m_has_focus; }
+ void UpdateGridSize();
+
public slots:
void onNavigated(int dx, int dy);
void onActivated();
@@ -56,10 +58,14 @@ protected:
void keyPressEvent(QKeyEvent* event) override;
void showEvent(QShowEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
+ bool eventFilter(QObject* obj, QEvent* event) override;
private:
- void UpdateGridSize();
void UpdateLayoutHeights();
+ int m_last_tw = -1;
+ int m_last_is = -1;
+ int m_last_fav_count = -1;
+ int m_last_main_count = -1;
QScrollArea* m_scroll_area = nullptr;
QWidget* m_container = nullptr;
diff --git a/src/citron/updater/updater_dialog.cpp b/src/citron/updater/updater_dialog.cpp
index 90418d7f13..937bb4793b 100644
--- a/src/citron/updater/updater_dialog.cpp
+++ b/src/citron/updater/updater_dialog.cpp
@@ -251,6 +251,7 @@ void UpdaterDialog::SetupUI() {
// Kill ghost titles causing overlaps
ui->updateInfoGroup->setTitle(QString());
ui->changelogGroup->setTitle(QString());
+ ui->progressGroup->setTitle(QString());
// Global Alignment & Centering
ui->titleLabel->setAlignment(Qt::AlignCenter);