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);