diff --git a/src/duckstation-qt/gamelistwidget.cpp b/src/duckstation-qt/gamelistwidget.cpp index 9c711ced9..18f2fb74b 100644 --- a/src/duckstation-qt/gamelistwidget.cpp +++ b/src/duckstation-qt/gamelistwidget.cpp @@ -38,8 +38,10 @@ LOG_CHANNEL(GameList); -static constexpr float MIN_SCALE = 0.1f; -static constexpr float MAX_SCALE = 2.0f; +static constexpr float MIN_ICON_SCALE = 1.0f; +static constexpr float MAX_ICON_SCALE = 5.0f; +static constexpr float MIN_COVER_SCALE = 0.1f; +static constexpr float MAX_COVER_SCALE = 2.0f; static const char* SUPPORTED_FORMATS_STRING = QT_TRANSLATE_NOOP(GameListWidget, ".cue (Cue Sheets)\n" @@ -57,6 +59,8 @@ static constexpr int COVER_ART_SIZE = 512; static constexpr int COVER_ART_SPACING = 32; static constexpr int MIN_COVER_CACHE_SIZE = 256; static constexpr int MIN_COVER_CACHE_ROW_BUFFER = 4; +static constexpr int MEMORY_CARD_ICON_SIZE = 16; +static constexpr int MEMORY_CARD_ICON_PADDING = 12; static void resizeAndPadImage(QImage* image, int expected_width, int expected_height, bool fill_with_top_left) { @@ -127,6 +131,7 @@ GameListModel::GameListModel(QObject* parent) : QAbstractTableModel(parent), m_memcard_pixmap_cache(MIN_COVER_CACHE_SIZE) { m_cover_scale = Host::GetBaseFloatSettingValue("UI", "GameListCoverArtScale", 0.45f); + m_icon_scale = Host::GetBaseFloatSettingValue("UI", "GameListIconScale", 1.00f); m_show_localized_titles = GameList::ShouldShowLocalizedTitles(); m_show_titles_for_covers = Host::GetBaseBoolSettingValue("UI", "GameListShowCoverTitles", true); m_show_game_icons = Host::GetBaseBoolSettingValue("UI", "GameListShowGameIcons", true); @@ -174,6 +179,26 @@ void GameListModel::refreshIcons() emit dataChanged(index(0, Column_Icon), index(rowCount() - 1, Column_Icon), {Qt::DecorationRole}); } +void GameListModel::setIconScale(float scale) +{ + if (m_icon_scale == scale) + return; + + m_icon_scale = scale; + + Host::SetBaseFloatSettingValue("UI", "GameListIconScale", scale); + Host::CommitBaseSettingChanges(); + updateIconScale(); +} + +void GameListModel::updateIconScale() +{ + m_memcard_pixmap_cache.Clear(); + + emit iconScaleChanged(m_icon_scale); + refresh(); +} + void GameListModel::setCoverScale(float scale) { if (m_cover_scale == scale) @@ -393,7 +418,7 @@ const QPixmap& GameListModel::getIconPixmapForEntry(const GameList::Entry* ge) c QPixmap pm; if (!path.empty() && pm.load(QString::fromStdString(path))) { - fixIconPixmapSize(pm); + const_cast(this)->fixIconPixmapSize(pm); return *m_memcard_pixmap_cache.Insert(ge->serial, std::move(pm)); } @@ -467,16 +492,16 @@ void GameListModel::fixIconPixmapSize(QPixmap& pm) const int width = static_cast(static_cast(pm.width()) * dpr); const int height = static_cast(static_cast(pm.height()) * dpr); const int max_dim = std::max(width, height); - if (max_dim == 16) - return; const float wanted_dpr = qApp->devicePixelRatio(); pm.setDevicePixelRatio(wanted_dpr); - const float scale = static_cast(max_dim) / 16.0f / wanted_dpr; + const float scale = static_cast(max_dim) / MEMORY_CARD_ICON_SIZE / wanted_dpr / m_icon_scale; const int new_width = static_cast(static_cast(width) / scale); const int new_height = static_cast(static_cast(height) / scale); - pm = pm.scaled(new_width, new_height); + + if (width != new_width || height != new_height) + QtUtils::scaleMemoryCardIconWithSharpBilinear(pm, std::max(new_width, new_height)); } int GameListModel::getCoverArtSize() const @@ -1248,6 +1273,7 @@ void GameListWidget::initialize(QAction* actionGameList, QAction* actionGameGrid { m_model = new GameListModel(this); connect(m_model, &GameListModel::coverScaleChanged, this, &GameListWidget::onCoverScaleChanged); + connect(m_model, &GameListModel::iconScaleChanged, this, &GameListWidget::onIconScaleChanged); m_sort_model = new GameListSortModel(m_model); m_sort_model->setSourceModel(m_model); @@ -1284,6 +1310,7 @@ void GameListWidget::initialize(QAction* actionGameList, QAction* actionGameGrid m_ui.showLocalizedTitles->setDefaultAction(actionShowLocalizedTitles); connect(m_ui.gridScale, &QSlider::valueChanged, m_grid_view, &GameListGridView::setZoomPct); + connect(m_ui.listScale, &QSlider::valueChanged, m_list_view, &GameListListView::setZoomPct); connect(m_ui.filterType, &QComboBox::currentIndexChanged, this, [this](int index) { m_sort_model->setFilterType((index == 0) ? GameList::EntryType::MaxCount : static_cast(index - 1)); @@ -1318,6 +1345,7 @@ void GameListWidget::initialize(QAction* actionGameList, QAction* actionGameGrid actionListShowIcons->setChecked(m_model->getShowGameIcons()); actionGridShowTitles->setChecked(m_model->getShowCoverTitles()); onCoverScaleChanged(m_model->getCoverScale()); + onIconScaleChanged(m_model->getIconScale()); updateView(grid_view); updateToolbar(grid_view); @@ -1623,6 +1651,7 @@ void GameListWidget::updateToolbar(bool grid_view) m_ui.showGameIcons->setVisible(!grid_view); m_ui.showGridTitles->setVisible(grid_view); m_ui.gridScale->setVisible(grid_view); + m_ui.listScale->setVisible(!grid_view); } void GameListWidget::onCoverScaleChanged(float scale) @@ -1631,6 +1660,12 @@ void GameListWidget::onCoverScaleChanged(float scale) m_ui.gridScale->setValue(static_cast(scale * 100.0f)); } +void GameListWidget::onIconScaleChanged(float scale) +{ + QSignalBlocker sb(m_ui.listScale); + m_ui.listScale->setValue(static_cast(scale * 4.0f)); +} + void GameListWidget::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); @@ -1688,8 +1723,12 @@ GameListListView::GameListListView(GameListModel* model, GameListSortModel* sort horizontal_header->setSectionResizeMode(GameListModel::Column_Title, QHeaderView::Stretch); horizontal_header->setSectionResizeMode(GameListModel::Column_FileTitle, QHeaderView::Stretch); + horizontal_header->setSectionResizeMode(GameListModel::Column_Icon, QHeaderView::ResizeToContents); - verticalHeader()->hide(); + QHeaderView* const vertical_header = verticalHeader(); + vertical_header->hide(); + vertical_header->setDefaultSectionSize(MEMORY_CARD_ICON_SIZE + MEMORY_CARD_ICON_PADDING + + style()->pixelMetric(QStyle::PM_FocusFrameVMargin, nullptr, this)); setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); setVerticalScrollMode(QAbstractItemView::ScrollMode::ScrollPerPixel); @@ -1706,10 +1745,30 @@ GameListListView::GameListListView(GameListModel* model, GameListSortModel* sort connect(horizontal_header, &QHeaderView::sortIndicatorChanged, this, &GameListListView::onHeaderSortIndicatorChanged); connect(horizontal_header, &QHeaderView::customContextMenuRequested, this, &GameListListView::onHeaderContextMenuRequested); + connect(m_model, &GameListModel::iconScaleChanged, this, &GameListListView::onIconScaleChanged); } GameListListView::~GameListListView() = default; +void GameListListView::wheelEvent(QWheelEvent* e) +{ + if (e->modifiers() & Qt::ControlModifier) + { + int dy = e->angleDelta().y(); + if (dy != 0) + { + if (dy < 0) + zoomOut(); + else + zoomIn(); + + return; + } + } + + QTableView::wheelEvent(e); +} + void GameListListView::setFixedColumnWidth(int column, int width) { horizontalHeader()->setSectionResizeMode(column, QHeaderView::Fixed); @@ -1865,6 +1924,44 @@ void GameListListView::onHeaderContextMenuRequested(const QPoint& point) menu.exec(mapToGlobal(point)); } +void GameListListView::onIconScaleChanged(float scale) +{ + updateLayout(); +} + +void GameListListView::adjustZoom(float delta) +{ + const float new_scale = std::clamp(m_model->getIconScale() + delta, MIN_ICON_SCALE, MAX_ICON_SCALE); + m_model->setIconScale(new_scale); +} + +void GameListListView::zoomIn() +{ + adjustZoom(0.25f); +} + +void GameListListView::zoomOut() +{ + adjustZoom(-0.25f); +} + +void GameListListView::setZoomPct(int int_scale) +{ + const float new_scale = std::clamp(static_cast(int_scale) / 4.0f, MIN_ICON_SCALE, MAX_ICON_SCALE); + m_model->setIconScale(new_scale); +} + +void GameListListView::updateLayout() +{ + const float row_count = m_model->rowCount(); + const float icon_scale = m_model->getIconScale(); + const int height = + icon_scale * MEMORY_CARD_ICON_SIZE + 12 + style()->pixelMetric(QStyle::PM_FocusFrameVMargin, nullptr, this); + + for (int i = 0; i < row_count; i++) + setRowHeight(i, height); +} + GameListGridView::GameListGridView(GameListModel* model, GameListSortModel* sort_model, QWidget* parent) : QListView(parent), m_model(model) { @@ -1922,7 +2019,7 @@ void GameListGridView::onCoverScaleChanged(float scale) void GameListGridView::adjustZoom(float delta) { - const float new_scale = std::clamp(m_model->getCoverScale() + delta, MIN_SCALE, MAX_SCALE); + const float new_scale = std::clamp(m_model->getCoverScale() + delta, MIN_COVER_SCALE, MAX_COVER_SCALE); m_model->setCoverScale(new_scale); } @@ -1938,7 +2035,7 @@ void GameListGridView::zoomOut() void GameListGridView::setZoomPct(int int_scale) { - const float new_scale = std::clamp(static_cast(int_scale) / 100.0f, MIN_SCALE, MAX_SCALE); + const float new_scale = std::clamp(static_cast(int_scale) / 100.0f, MIN_COVER_SCALE, MAX_COVER_SCALE); m_model->setCoverScale(new_scale); } diff --git a/src/duckstation-qt/gamelistwidget.h b/src/duckstation-qt/gamelistwidget.h index f6978d5ec..c3e0cf42c 100644 --- a/src/duckstation-qt/gamelistwidget.h +++ b/src/duckstation-qt/gamelistwidget.h @@ -93,6 +93,8 @@ public: bool getShowCoverTitles() const { return m_show_titles_for_covers; } void setShowCoverTitles(bool enabled) { m_show_titles_for_covers = enabled; } + float getIconScale() const { return m_icon_scale; } + void setIconScale(float scale); bool getShowGameIcons() const { return m_show_game_icons; } void setShowGameIcons(bool enabled); QIcon getIconForGame(const QString& path); @@ -107,6 +109,7 @@ public: Q_SIGNALS: void coverScaleChanged(float scale); + void iconScaleChanged(float scale); private: void rowsChanged(const QList& rows); @@ -116,6 +119,7 @@ private: void loadThemeSpecificImages(); void setColumnDisplayNames(); void updateCoverScale(); + void updateIconScale(); void loadOrGenerateCover(const GameList::Entry* ge); void invalidateCoverForPath(const std::string& path); void coverLoaded(const std::string& path, const QImage& image, float scale); @@ -128,10 +132,12 @@ private: const QPixmap& getIconPixmapForEntry(const GameList::Entry* ge) const; const QPixmap& getFlagPixmapForEntry(const GameList::Entry* ge) const; - static void fixIconPixmapSize(QPixmap& pm); + void fixIconPixmapSize(QPixmap& pm); + std::optional m_taken_entries; float m_cover_scale = 0.0f; + float m_icon_scale = 0.0f; bool m_show_localized_titles = false; bool m_show_titles_for_covers = false; bool m_show_game_icons = false; @@ -163,8 +169,20 @@ public: ~GameListListView() override; void setAndSaveColumnHidden(int column, bool hidden); + void updateLayout(); + +public Q_SLOTS: + void zoomOut(); + void zoomIn(); + void setZoomPct(int int_scale); + +protected: + void wheelEvent(QWheelEvent* e) override; private: + void onIconScaleChanged(float scale); + void adjustZoom(float delta); + void onHeaderSortIndicatorChanged(int, Qt::SortOrder); void onHeaderContextMenuRequested(const QPoint& point); @@ -250,6 +268,7 @@ private Q_SLOTS: void onRefreshComplete(); void onCoverScaleChanged(float scale); + void onIconScaleChanged(float scale); void onSelectionModelCurrentChanged(const QModelIndex& current, const QModelIndex& previous); void onListViewItemActivated(const QModelIndex& index); diff --git a/src/duckstation-qt/gamelistwidget.ui b/src/duckstation-qt/gamelistwidget.ui index 08ac1bfa6..f6ce282cd 100644 --- a/src/duckstation-qt/gamelistwidget.ui +++ b/src/duckstation-qt/gamelistwidget.ui @@ -6,7 +6,7 @@ 0 0 - 821 + 1063 619 @@ -182,6 +182,31 @@ + + + + + 125 + 0 + + + + + 125 + 16777215 + + + + 4 + + + 20 + + + Qt::Orientation::Horizontal + + + diff --git a/src/duckstation-qt/memorycardeditorwindow.cpp b/src/duckstation-qt/memorycardeditorwindow.cpp index a6929afa5..9e14d1bd1 100644 --- a/src/duckstation-qt/memorycardeditorwindow.cpp +++ b/src/duckstation-qt/memorycardeditorwindow.cpp @@ -86,8 +86,8 @@ public: // doing this on the UI thread is a bit ehh, but whatever, they're small images. const MemoryCardImage::IconFrame& frame = fi.icon_frames[real_frame_index]; - const int pixmap_width = static_cast(std::ceil(static_cast(rc.width()) * dpr)); - const int pixmap_height = static_cast(std::ceil(static_cast(rc.height()) * dpr)); + const int pixmap_width = static_cast(std::ceil(static_cast(rc.width() - 1) * dpr)); + const int pixmap_height = static_cast(std::ceil(static_cast(rc.height() - 1) * dpr)); const int icon_size = std::min(pixmap_width, pixmap_height); const int xoffs = std::max(static_cast((static_cast(pixmap_width - icon_size) * static_cast(0.5)) / dpr), 0); @@ -97,20 +97,7 @@ public: QImage src_image = QImage(reinterpret_cast(frame.pixels), MemoryCardImage::ICON_WIDTH, MemoryCardImage::ICON_HEIGHT, QImage::Format_RGBA8888); if (src_image.width() != icon_size || src_image.height() != icon_size) - { - // Sharp Bilinear scaling - // First, scale the icon by the largest integer size using nearest-neighbor... - const float scaled_icon_size = MEMORY_CARD_ICON_SIZE * dpr; - const int integer_icon_size = - static_cast(scaled_icon_size / static_cast(MemoryCardImage::ICON_HEIGHT)) * - static_cast(MemoryCardImage::ICON_HEIGHT); - src_image = - src_image.scaled(integer_icon_size, integer_icon_size, Qt::IgnoreAspectRatio, Qt::FastTransformation); - - // ...then scale any remainder using bilinear interpolation. - if (scaled_icon_size - integer_icon_size > 0) - src_image = src_image.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - } + QtUtils::scaleMemoryCardIconWithSharpBilinear(src_image, icon_size); src_image.setDevicePixelRatio(dpr); diff --git a/src/duckstation-qt/qtutils.h b/src/duckstation-qt/qtutils.h index fbb679382..0d419d6f6 100644 --- a/src/duckstation-qt/qtutils.h +++ b/src/duckstation-qt/qtutils.h @@ -116,6 +116,22 @@ QIcon GetIconForEntryType(GameList::EntryType type); QIcon GetIconForCompatibility(GameDatabase::CompatibilityRating rating); QIcon GetIconForLanguage(std::string_view language_name); +/// Scales a Memory Card Icon (QPixmap or QImage) using Sharp Bilinear scaling +template +inline void scaleMemoryCardIconWithSharpBilinear(T& pm, int size) +{ + const int base_size = 16; + + // Sharp Bilinear scaling + // First, scale the icon by the largest integer size using nearest-neighbor... + const int integer_icon_size = static_cast(size / base_size) * base_size; + pm = pm.scaled(integer_icon_size, integer_icon_size, Qt::IgnoreAspectRatio, Qt::FastTransformation); + + // ...then scale any remainder using bilinear interpolation. + if (size - integer_icon_size > 0) + pm = pm.scaled(size, size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); +} + /// Returns the pixel ratio/scaling factor for a widget. qreal GetDevicePixelRatioForWidget(const QWidget* widget);