First Commit
This commit is contained in:
138
pcsx2-qt/GameList/EmptyGameListWidget.ui
Normal file
138
pcsx2-qt/GameList/EmptyGameListWidget.ui
Normal file
@@ -0,0 +1,138 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>EmptyGameListWidget</class>
|
||||
<widget class="QWidget" name="EmptyGameListWidget">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>687</width>
|
||||
<height>470</height>
|
||||
</rect>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string><html><head/><body><p><span style=" font-weight:700;">No games in supported formats were found.</span></p><p>Please add a directory with games to begin.</p><p>Game dumps in the following formats will be scanned and listed:</p></body></html></string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="supportedFormats">
|
||||
<property name="text">
|
||||
<string notr="true">TextLabel</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="addGameDirectory">
|
||||
<property name="text">
|
||||
<string>Add Game Directory...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="scanForNewGames">
|
||||
<property name="text">
|
||||
<string>Scan For New Games</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
613
pcsx2-qt/GameList/GameListModel.cpp
Normal file
613
pcsx2-qt/GameList/GameListModel.cpp
Normal file
@@ -0,0 +1,613 @@
|
||||
// SPDX-FileCopyrightText: 2002-2025 PCSX2 Dev Team
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
#include "GameListModel.h"
|
||||
#include "QtHost.h"
|
||||
#include "QtUtils.h"
|
||||
#include "common/FileSystem.h"
|
||||
#include "common/Path.h"
|
||||
#include "common/StringUtil.h"
|
||||
#include "fmt/format.h"
|
||||
#include <QtCore/QDate>
|
||||
#include <QtCore/QDateTime>
|
||||
#include <QtCore/QFuture>
|
||||
#include <QtCore/QFutureWatcher>
|
||||
#include <QtConcurrent/QtConcurrent>
|
||||
#include <QtGui/QGuiApplication>
|
||||
#include <QtGui/QIcon>
|
||||
#include <QtGui/QPainter>
|
||||
|
||||
static constexpr std::array<const char*, GameListModel::Column_Count> s_column_names = {
|
||||
{"Type", "Code", "Title", "File Title", "CRC", "Time Played", "Last Played", "Size", "Region", "Compatibility", "Cover"}};
|
||||
|
||||
static constexpr int COVER_ART_WIDTH = 350;
|
||||
static constexpr int COVER_ART_HEIGHT = 512;
|
||||
static constexpr int COVER_ART_SPACING = 32;
|
||||
static constexpr int MIN_COVER_CACHE_SIZE = 256;
|
||||
|
||||
static int DPRScale(int size, qreal dpr)
|
||||
{
|
||||
return static_cast<int>(static_cast<qreal>(size) * dpr);
|
||||
}
|
||||
|
||||
static int DPRUnscale(int size, qreal dpr)
|
||||
{
|
||||
return static_cast<int>(static_cast<qreal>(size) / dpr);
|
||||
}
|
||||
|
||||
static void resizeAndPadPixmap(QPixmap* pm, int expected_width, int expected_height, qreal dpr)
|
||||
{
|
||||
const int dpr_expected_width = DPRScale(expected_width, dpr);
|
||||
const int dpr_expected_height = DPRScale(expected_height, dpr);
|
||||
if (pm->width() == dpr_expected_width && pm->height() == dpr_expected_height)
|
||||
return;
|
||||
|
||||
*pm = pm->scaled(dpr_expected_width, dpr_expected_height, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
if (pm->width() == dpr_expected_width && pm->height() == dpr_expected_height)
|
||||
return;
|
||||
|
||||
// QPainter works in unscaled coordinates.
|
||||
int xoffs = 0;
|
||||
int yoffs = 0;
|
||||
if (pm->width() < dpr_expected_width)
|
||||
xoffs = DPRUnscale((dpr_expected_width - pm->width()) / 2, dpr);
|
||||
if (pm->height() < dpr_expected_height)
|
||||
yoffs = DPRUnscale((dpr_expected_height - pm->height()) / 2, dpr);
|
||||
|
||||
QPixmap padded_image(dpr_expected_width, dpr_expected_height);
|
||||
padded_image.setDevicePixelRatio(dpr);
|
||||
padded_image.fill(Qt::transparent);
|
||||
QPainter painter;
|
||||
if (painter.begin(&padded_image))
|
||||
{
|
||||
painter.setCompositionMode(QPainter::CompositionMode_Source);
|
||||
painter.drawPixmap(xoffs, yoffs, *pm);
|
||||
painter.setCompositionMode(QPainter::CompositionMode_Destination);
|
||||
painter.fillRect(padded_image.rect(), QColor(0, 0, 0, 0));
|
||||
painter.end();
|
||||
}
|
||||
|
||||
*pm = padded_image;
|
||||
}
|
||||
|
||||
static QPixmap createPlaceholderImage(const QPixmap& placeholder_pixmap, int width, int height, float scale,
|
||||
qreal dpr, const std::string& title)
|
||||
{
|
||||
QPixmap pm(placeholder_pixmap.copy());
|
||||
pm.setDevicePixelRatio(dpr);
|
||||
if (pm.isNull())
|
||||
return QPixmap();
|
||||
|
||||
resizeAndPadPixmap(&pm, width, height, dpr);
|
||||
QPainter painter;
|
||||
if (painter.begin(&pm))
|
||||
{
|
||||
QFont font;
|
||||
font.setPointSize(std::max(static_cast<int>(32.0f * scale), 1));
|
||||
painter.setFont(font);
|
||||
painter.setPen(Qt::white);
|
||||
|
||||
const QRect text_rc(0, 0, static_cast<int>(static_cast<float>(width)),
|
||||
static_cast<int>(static_cast<float>(height)));
|
||||
painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(title));
|
||||
painter.end();
|
||||
}
|
||||
|
||||
return pm;
|
||||
}
|
||||
|
||||
std::optional<GameListModel::Column> GameListModel::getColumnIdForName(std::string_view name)
|
||||
{
|
||||
for (int column = 0; column < Column_Count; column++)
|
||||
{
|
||||
if (name == s_column_names[column])
|
||||
return static_cast<Column>(column);
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const char* GameListModel::getColumnName(Column col)
|
||||
{
|
||||
return s_column_names[static_cast<int>(col)];
|
||||
}
|
||||
|
||||
GameListModel::GameListModel(float cover_scale, bool show_cover_titles, qreal dpr, QObject* parent /* = nullptr */)
|
||||
: QAbstractTableModel(parent)
|
||||
, m_show_titles_for_covers(show_cover_titles)
|
||||
, m_dpr{dpr}
|
||||
{
|
||||
loadSettings();
|
||||
loadCommonImages();
|
||||
setCoverScale(cover_scale);
|
||||
setColumnDisplayNames();
|
||||
}
|
||||
GameListModel::~GameListModel() = default;
|
||||
|
||||
void GameListModel::reloadThemeSpecificImages()
|
||||
{
|
||||
loadThemeSpecificImages();
|
||||
refresh();
|
||||
}
|
||||
|
||||
void GameListModel::setCoverScale(float scale)
|
||||
{
|
||||
if (m_cover_scale == scale)
|
||||
return;
|
||||
|
||||
m_cover_pixmap_cache.Clear();
|
||||
m_cover_scale = scale;
|
||||
m_cover_scale_counter.fetch_add(1, std::memory_order_release);
|
||||
m_loading_pixmap = QPixmap(getCoverArtWidth(), getCoverArtHeight());
|
||||
m_loading_pixmap.fill(QColor(0, 0, 0, 0));
|
||||
|
||||
emit coverScaleChanged();
|
||||
}
|
||||
|
||||
void GameListModel::refreshCovers()
|
||||
{
|
||||
m_cover_pixmap_cache.Clear();
|
||||
refresh();
|
||||
}
|
||||
|
||||
void GameListModel::updateCacheSize(int width, int height)
|
||||
{
|
||||
// This is a bit conversative, since it doesn't consider padding, but better to be over than under.
|
||||
const int cover_width = getCoverArtWidth();
|
||||
const int cover_height = getCoverArtHeight();
|
||||
const int num_columns = ((width + (cover_width - 1)) / cover_width);
|
||||
const int num_rows = ((height + (cover_height - 1)) / cover_height);
|
||||
m_cover_pixmap_cache.SetMaxCapacity(static_cast<int>(std::max(num_columns * num_rows, MIN_COVER_CACHE_SIZE)));
|
||||
}
|
||||
|
||||
void GameListModel::setDevicePixelRatio(qreal dpr)
|
||||
{
|
||||
m_dpr = dpr;
|
||||
loadCommonImages();
|
||||
refreshCovers();
|
||||
}
|
||||
|
||||
void GameListModel::loadOrGenerateCover(const GameList::Entry* ge)
|
||||
{
|
||||
// Why this counter: Every time we change the cover scale, we increment the counter variable. This way if the scale is changed
|
||||
// while there's outstanding jobs, the old jobs won't proceed (at the wrong size), or get added into the grid.
|
||||
const u32 counter = m_cover_scale_counter.load(std::memory_order_acquire);
|
||||
|
||||
QFuture<QPixmap> future = QtConcurrent::run([this, entry = *ge, counter]() -> QPixmap {
|
||||
QPixmap image;
|
||||
if (m_cover_scale_counter.load(std::memory_order_acquire) == counter)
|
||||
{
|
||||
const std::string cover_path(GameList::GetCoverImagePathForEntry(&entry));
|
||||
if (!cover_path.empty())
|
||||
{
|
||||
image = QPixmap(QString::fromStdString(cover_path));
|
||||
if (!image.isNull())
|
||||
{
|
||||
image.setDevicePixelRatio(m_dpr);
|
||||
resizeAndPadPixmap(&image, getCoverArtWidth(), getCoverArtHeight(), m_dpr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const std::string& title = entry.GetTitle(m_prefer_english_titles);
|
||||
|
||||
if (image.isNull())
|
||||
image = createPlaceholderImage(m_placeholder_pixmap, getCoverArtWidth(), getCoverArtHeight(), m_cover_scale, m_dpr, title);
|
||||
|
||||
if (m_cover_scale_counter.load(std::memory_order_acquire) != counter)
|
||||
image = {};
|
||||
|
||||
return image;
|
||||
});
|
||||
|
||||
// Context must be 'this' so we run on the UI thread.
|
||||
future.then(this, [this, path = ge->path, counter](QPixmap pm) {
|
||||
if (m_cover_scale_counter.load(std::memory_order_acquire) != counter)
|
||||
return;
|
||||
|
||||
m_cover_pixmap_cache.Insert(std::move(path), std::move(pm));
|
||||
invalidateCoverForPath(path);
|
||||
});
|
||||
}
|
||||
|
||||
void GameListModel::invalidateCoverForPath(const std::string& path)
|
||||
{
|
||||
// This isn't ideal, but not sure how else we can get the row, when it might change while scanning...
|
||||
auto lock = GameList::GetLock();
|
||||
const u32 count = GameList::GetEntryCount();
|
||||
std::optional<u32> row;
|
||||
for (u32 i = 0; i < count; i++)
|
||||
{
|
||||
if (GameList::GetEntryByIndex(i)->path == path)
|
||||
{
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!row.has_value())
|
||||
{
|
||||
// Game removed?
|
||||
return;
|
||||
}
|
||||
|
||||
const QModelIndex mi(index(static_cast<int>(row.value()), Column_Cover));
|
||||
emit dataChanged(mi, mi, {Qt::DecorationRole});
|
||||
}
|
||||
|
||||
int GameListModel::getCoverArtWidth() const
|
||||
{
|
||||
return std::max(static_cast<int>(static_cast<float>(COVER_ART_WIDTH) * m_cover_scale), 1);
|
||||
}
|
||||
|
||||
int GameListModel::getCoverArtHeight() const
|
||||
{
|
||||
return std::max(static_cast<int>(static_cast<float>(COVER_ART_HEIGHT) * m_cover_scale), 1);
|
||||
}
|
||||
|
||||
int GameListModel::getCoverArtSpacing() const
|
||||
{
|
||||
return std::max(static_cast<int>(static_cast<float>(COVER_ART_SPACING) * m_cover_scale), 1);
|
||||
}
|
||||
|
||||
int GameListModel::rowCount(const QModelIndex& parent) const
|
||||
{
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
|
||||
return static_cast<int>(GameList::GetEntryCount());
|
||||
}
|
||||
|
||||
int GameListModel::columnCount(const QModelIndex& parent) const
|
||||
{
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
|
||||
return Column_Count;
|
||||
}
|
||||
|
||||
QString GameListModel::formatTimespan(time_t timespan)
|
||||
{
|
||||
// avoid an extra string conversion
|
||||
const u32 hours = static_cast<u32>(timespan / 3600);
|
||||
const u32 minutes = static_cast<u32>((timespan % 3600) / 60);
|
||||
if (hours > 0)
|
||||
return qApp->translate("GameList", "%n hours", "", hours);
|
||||
else
|
||||
return qApp->translate("GameList", "%n minutes", "", minutes);
|
||||
}
|
||||
|
||||
QVariant GameListModel::data(const QModelIndex& index, int role) const
|
||||
{
|
||||
if (!index.isValid())
|
||||
return {};
|
||||
|
||||
const int row = index.row();
|
||||
if (row < 0 || row >= static_cast<int>(GameList::GetEntryCount()))
|
||||
return {};
|
||||
|
||||
const auto lock = GameList::GetLock();
|
||||
const GameList::Entry* ge = GameList::GetEntryByIndex(row);
|
||||
if (!ge)
|
||||
return {};
|
||||
|
||||
switch (role)
|
||||
{
|
||||
case Qt::DisplayRole:
|
||||
{
|
||||
switch (index.column())
|
||||
{
|
||||
case Column_Serial:
|
||||
return QString::fromStdString(ge->serial);
|
||||
|
||||
case Column_Title:
|
||||
return QString::fromStdString(ge->GetTitle(m_prefer_english_titles));
|
||||
|
||||
case Column_FileTitle:
|
||||
return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path));
|
||||
|
||||
case Column_CRC:
|
||||
return QString::fromStdString(fmt::format("{:08X}", ge->crc));
|
||||
|
||||
case Column_TimePlayed:
|
||||
{
|
||||
if (ge->total_played_time == 0)
|
||||
return {};
|
||||
else
|
||||
return formatTimespan(ge->total_played_time);
|
||||
}
|
||||
|
||||
case Column_LastPlayed:
|
||||
return QString::fromStdString(GameList::FormatTimestamp(ge->last_played_time));
|
||||
|
||||
case Column_Size:
|
||||
return QString("%1 MB").arg(static_cast<double>(ge->total_size) / 1048576.0, 0, 'f', 2);
|
||||
|
||||
case Column_Cover:
|
||||
{
|
||||
if (m_show_titles_for_covers)
|
||||
return QString::fromStdString(ge->GetTitle(m_prefer_english_titles));
|
||||
else
|
||||
return {};
|
||||
}
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
case Qt::InitialSortOrderRole:
|
||||
{
|
||||
switch (index.column())
|
||||
{
|
||||
case Column_Type:
|
||||
return static_cast<int>(ge->type);
|
||||
|
||||
case Column_Serial:
|
||||
return QString::fromStdString(ge->serial);
|
||||
|
||||
case Column_Title:
|
||||
case Column_Cover:
|
||||
return QString::fromStdString(ge->GetTitleSort(m_prefer_english_titles));
|
||||
|
||||
case Column_FileTitle:
|
||||
return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path));
|
||||
|
||||
case Column_CRC:
|
||||
return static_cast<int>(ge->crc);
|
||||
|
||||
case Column_TimePlayed:
|
||||
return static_cast<qlonglong>(ge->total_played_time);
|
||||
|
||||
case Column_LastPlayed:
|
||||
return static_cast<qlonglong>(ge->last_played_time);
|
||||
|
||||
case Column_Region:
|
||||
return static_cast<int>(ge->region);
|
||||
|
||||
case Column_Compatibility:
|
||||
return static_cast<int>(ge->compatibility_rating);
|
||||
|
||||
case Column_Size:
|
||||
return static_cast<qulonglong>(ge->total_size);
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
case Qt::DecorationRole:
|
||||
{
|
||||
switch (index.column())
|
||||
{
|
||||
case Column_Type:
|
||||
{
|
||||
return m_type_pixmaps[static_cast<u32>(ge->type)];
|
||||
}
|
||||
|
||||
case Column_Region:
|
||||
{
|
||||
return m_region_pixmaps[static_cast<u32>(ge->region)];
|
||||
}
|
||||
|
||||
case Column_Compatibility:
|
||||
{
|
||||
return m_compatibility_pixmaps[static_cast<u32>(
|
||||
(static_cast<u32>(ge->compatibility_rating) >= GameList::CompatibilityRatingCount) ?
|
||||
GameList::CompatibilityRating::Unknown :
|
||||
ge->compatibility_rating)];
|
||||
}
|
||||
|
||||
case Column_Cover:
|
||||
{
|
||||
QPixmap* pm = m_cover_pixmap_cache.Lookup(ge->path);
|
||||
if (pm)
|
||||
return *pm;
|
||||
|
||||
// We insert the placeholder into the cache, so that we don't repeatedly
|
||||
// queue loading jobs for this game.
|
||||
const_cast<GameListModel*>(this)->loadOrGenerateCover(ge);
|
||||
return *m_cover_pixmap_cache.Insert(ge->path, m_loading_pixmap);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QVariant GameListModel::headerData(int section, Qt::Orientation orientation, int role) const
|
||||
{
|
||||
if (orientation != Qt::Horizontal || role != Qt::DisplayRole || section < 0 || section >= Column_Count)
|
||||
return {};
|
||||
|
||||
return m_column_display_names[section];
|
||||
}
|
||||
|
||||
void GameListModel::refresh()
|
||||
{
|
||||
beginResetModel();
|
||||
loadSettings();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
bool GameListModel::titlesLessThan(int left_row, int right_row) const
|
||||
{
|
||||
if (left_row < 0 || left_row >= static_cast<int>(GameList::GetEntryCount()) || right_row < 0 ||
|
||||
right_row >= static_cast<int>(GameList::GetEntryCount()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const GameList::Entry* left = GameList::GetEntryByIndex(left_row);
|
||||
const GameList::Entry* right = GameList::GetEntryByIndex(right_row);
|
||||
return QtHost::LocaleSensitiveCompare(QString::fromStdString(left->GetTitleSort(m_prefer_english_titles)),
|
||||
QString::fromStdString(right->GetTitleSort(m_prefer_english_titles))) < 0;
|
||||
}
|
||||
|
||||
bool GameListModel::lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const
|
||||
{
|
||||
if (!left_index.isValid() || !right_index.isValid())
|
||||
return false;
|
||||
|
||||
const int left_row = left_index.row();
|
||||
const int right_row = right_index.row();
|
||||
if (left_row < 0 || left_row >= static_cast<int>(GameList::GetEntryCount()) || right_row < 0 ||
|
||||
right_row >= static_cast<int>(GameList::GetEntryCount()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto lock = GameList::GetLock();
|
||||
const GameList::Entry* left = GameList::GetEntryByIndex(left_row);
|
||||
const GameList::Entry* right = GameList::GetEntryByIndex(right_row);
|
||||
if (!left || !right)
|
||||
return false;
|
||||
|
||||
switch (column)
|
||||
{
|
||||
case Column_Type:
|
||||
{
|
||||
if (left->type == right->type)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
|
||||
return static_cast<int>(left->type) < static_cast<int>(right->type);
|
||||
}
|
||||
|
||||
case Column_Serial:
|
||||
{
|
||||
if (left->serial == right->serial)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
return (StringUtil::Strcasecmp(left->serial.c_str(), right->serial.c_str()) < 0);
|
||||
}
|
||||
|
||||
case Column_Title:
|
||||
{
|
||||
return titlesLessThan(left_row, right_row);
|
||||
}
|
||||
|
||||
case Column_FileTitle:
|
||||
{
|
||||
const std::string_view file_title_left(Path::GetFileTitle(left->path));
|
||||
const std::string_view file_title_right(Path::GetFileTitle(right->path));
|
||||
if (file_title_left == file_title_right)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
|
||||
const std::size_t smallest = std::min(file_title_left.size(), file_title_right.size());
|
||||
return (StringUtil::Strncasecmp(file_title_left.data(), file_title_right.data(), smallest) < 0);
|
||||
}
|
||||
|
||||
case Column_Region:
|
||||
{
|
||||
if (left->region == right->region)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
return (static_cast<int>(left->region) < static_cast<int>(right->region));
|
||||
}
|
||||
|
||||
case Column_Compatibility:
|
||||
{
|
||||
if (left->compatibility_rating == right->compatibility_rating)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
|
||||
return (static_cast<int>(left->compatibility_rating) < static_cast<int>(right->compatibility_rating));
|
||||
}
|
||||
|
||||
case Column_Size:
|
||||
{
|
||||
if (left->total_size == right->total_size)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
|
||||
return (left->total_size < right->total_size);
|
||||
}
|
||||
|
||||
case Column_CRC:
|
||||
{
|
||||
if (left->crc == right->crc)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
|
||||
return (left->crc < right->crc);
|
||||
}
|
||||
|
||||
case Column_TimePlayed:
|
||||
{
|
||||
if (left->total_played_time == right->total_played_time)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
|
||||
return (left->total_played_time < right->total_played_time);
|
||||
}
|
||||
|
||||
case Column_LastPlayed:
|
||||
{
|
||||
if (left->last_played_time == right->last_played_time)
|
||||
return titlesLessThan(left_row, right_row);
|
||||
|
||||
return (left->last_played_time < right->last_played_time);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void GameListModel::loadSettings()
|
||||
{
|
||||
m_prefer_english_titles = Host::GetBaseBoolSettingValue("UI", "PreferEnglishGameList", false);
|
||||
}
|
||||
|
||||
QIcon GameListModel::getIconForType(GameList::EntryType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case GameList::EntryType::PS2Disc:
|
||||
case GameList::EntryType::PS1Disc:
|
||||
return QIcon::fromTheme("disc-2-line");
|
||||
|
||||
case GameList::EntryType::ELF:
|
||||
default:
|
||||
return QIcon::fromTheme("file-settings-line");
|
||||
}
|
||||
}
|
||||
|
||||
QIcon GameListModel::getIconForRegion(GameList::Region region)
|
||||
{
|
||||
return QIcon(
|
||||
QStringLiteral("%1/icons/flags/%2.svg").arg(QtHost::GetResourcesBasePath()).arg(GameList::RegionToString(region, false)));
|
||||
}
|
||||
|
||||
void GameListModel::loadThemeSpecificImages()
|
||||
{
|
||||
for (u32 type = 0; type < static_cast<u32>(GameList::EntryType::Count); type++)
|
||||
m_type_pixmaps[type] = getIconForType(static_cast<GameList::EntryType>(type)).pixmap(QSize(24, 24), m_dpr);
|
||||
|
||||
for (u32 i = 0; i < static_cast<u32>(GameList::Region::Count); i++)
|
||||
m_region_pixmaps[i] = getIconForRegion(static_cast<GameList::Region>(i)).pixmap(QSize(36, 26), m_dpr);
|
||||
}
|
||||
|
||||
void GameListModel::loadCommonImages()
|
||||
{
|
||||
loadThemeSpecificImages();
|
||||
|
||||
const QString base_path(QtHost::GetResourcesBasePath());
|
||||
for (u32 i = 1; i < GameList::CompatibilityRatingCount; i++)
|
||||
m_compatibility_pixmaps[i] = QIcon((QStringLiteral("%1/icons/star-%2.svg").arg(base_path).arg(i - 1))).pixmap(QSize(88, 16), m_dpr);
|
||||
|
||||
m_placeholder_pixmap.load(QStringLiteral("%1/cover-placeholder.png").arg(base_path));
|
||||
}
|
||||
|
||||
void GameListModel::setColumnDisplayNames()
|
||||
{
|
||||
m_column_display_names[Column_Type] = tr("Type");
|
||||
m_column_display_names[Column_Serial] = tr("Code");
|
||||
m_column_display_names[Column_Title] = tr("Title");
|
||||
m_column_display_names[Column_FileTitle] = tr("File Title");
|
||||
m_column_display_names[Column_CRC] = tr("CRC");
|
||||
m_column_display_names[Column_TimePlayed] = tr("Time Played");
|
||||
m_column_display_names[Column_LastPlayed] = tr("Last Played");
|
||||
m_column_display_names[Column_Size] = tr("Size");
|
||||
m_column_display_names[Column_Region] = tr("Region");
|
||||
m_column_display_names[Column_Compatibility] = tr("Compatibility");
|
||||
}
|
||||
103
pcsx2-qt/GameList/GameListModel.h
Normal file
103
pcsx2-qt/GameList/GameListModel.h
Normal file
@@ -0,0 +1,103 @@
|
||||
// SPDX-FileCopyrightText: 2002-2025 PCSX2 Dev Team
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "pcsx2/GameList.h"
|
||||
|
||||
#include "common/LRUCache.h"
|
||||
|
||||
#include <QtCore/QAbstractTableModel>
|
||||
#include <QtGui/QPixmap>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
|
||||
class GameListModel final : public QAbstractTableModel
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum Column : int
|
||||
{
|
||||
Column_Type,
|
||||
Column_Serial,
|
||||
Column_Title,
|
||||
Column_FileTitle,
|
||||
Column_CRC,
|
||||
Column_TimePlayed,
|
||||
Column_LastPlayed,
|
||||
Column_Size,
|
||||
Column_Region,
|
||||
Column_Compatibility,
|
||||
Column_Cover,
|
||||
|
||||
Column_Count
|
||||
};
|
||||
|
||||
static std::optional<Column> getColumnIdForName(std::string_view name);
|
||||
static const char* getColumnName(Column col);
|
||||
|
||||
static QIcon getIconForType(GameList::EntryType type);
|
||||
static QIcon getIconForRegion(GameList::Region region);
|
||||
|
||||
GameListModel(float cover_scale, bool show_cover_titles, qreal dpr, QObject* parent = nullptr);
|
||||
~GameListModel();
|
||||
|
||||
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
|
||||
int columnCount(const QModelIndex& parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||
|
||||
__fi const QString& getColumnDisplayName(int column) { return m_column_display_names[column]; }
|
||||
|
||||
void refresh();
|
||||
void reloadThemeSpecificImages();
|
||||
|
||||
bool titlesLessThan(int left_row, int right_row) const;
|
||||
|
||||
bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const;
|
||||
|
||||
bool getShowCoverTitles() const { return m_show_titles_for_covers; }
|
||||
void setShowCoverTitles(bool enabled) { m_show_titles_for_covers = enabled; }
|
||||
|
||||
float getCoverScale() const { return m_cover_scale; }
|
||||
void setCoverScale(float scale);
|
||||
int getCoverArtWidth() const;
|
||||
int getCoverArtHeight() const;
|
||||
int getCoverArtSpacing() const;
|
||||
void refreshCovers();
|
||||
void updateCacheSize(int width, int height);
|
||||
|
||||
void setDevicePixelRatio(qreal dpr);
|
||||
|
||||
Q_SIGNALS:
|
||||
void coverScaleChanged();
|
||||
|
||||
private:
|
||||
void loadSettings();
|
||||
void loadCommonImages();
|
||||
void loadThemeSpecificImages();
|
||||
void setColumnDisplayNames();
|
||||
void loadOrGenerateCover(const GameList::Entry* ge);
|
||||
void invalidateCoverForPath(const std::string& path);
|
||||
|
||||
static QString formatTimespan(time_t timespan);
|
||||
|
||||
float m_cover_scale = 0.0f;
|
||||
std::atomic<u32> m_cover_scale_counter{0};
|
||||
bool m_show_titles_for_covers = false;
|
||||
bool m_prefer_english_titles = false;
|
||||
|
||||
std::array<QString, Column_Count> m_column_display_names;
|
||||
std::array<QPixmap, static_cast<u32>(GameList::EntryType::Count)> m_type_pixmaps;
|
||||
std::array<QPixmap, static_cast<u32>(GameList::Region::Count)> m_region_pixmaps;
|
||||
QPixmap m_placeholder_pixmap;
|
||||
QPixmap m_loading_pixmap;
|
||||
qreal m_dpr;
|
||||
|
||||
std::array<QPixmap, static_cast<int>(GameList::CompatibilityRatingCount)> m_compatibility_pixmaps;
|
||||
mutable LRUCache<std::string, QPixmap> m_cover_pixmap_cache;
|
||||
};
|
||||
116
pcsx2-qt/GameList/GameListRefreshThread.cpp
Normal file
116
pcsx2-qt/GameList/GameListRefreshThread.cpp
Normal file
@@ -0,0 +1,116 @@
|
||||
// SPDX-FileCopyrightText: 2002-2025 PCSX2 Dev Team
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
#include "GameListRefreshThread.h"
|
||||
|
||||
#include "pcsx2/GameList.h"
|
||||
|
||||
#include "common/ProgressCallback.h"
|
||||
#include "common/Timer.h"
|
||||
|
||||
#include <QtWidgets/QMessageBox>
|
||||
|
||||
AsyncRefreshProgressCallback::AsyncRefreshProgressCallback(GameListRefreshThread* parent)
|
||||
: m_parent(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::Cancel()
|
||||
{
|
||||
// Not atomic, but we don't need to cancel immediately.
|
||||
m_cancelled = true;
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::SetStatusText(const char* text)
|
||||
{
|
||||
QString new_text(QString::fromUtf8(text));
|
||||
if (new_text == m_status_text)
|
||||
return;
|
||||
|
||||
m_status_text = new_text;
|
||||
fireUpdate();
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::SetProgressRange(u32 range)
|
||||
{
|
||||
BaseProgressCallback::SetProgressRange(range);
|
||||
if (static_cast<int>(m_progress_range) == m_last_range)
|
||||
return;
|
||||
|
||||
m_last_range = static_cast<int>(m_progress_range);
|
||||
fireUpdate();
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::SetProgressValue(u32 value)
|
||||
{
|
||||
BaseProgressCallback::SetProgressValue(value);
|
||||
if (static_cast<int>(m_progress_value) == m_last_value)
|
||||
return;
|
||||
|
||||
m_last_value = static_cast<int>(m_progress_value);
|
||||
fireUpdate();
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::SetTitle(const char* title)
|
||||
{
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::DisplayError(const char* message)
|
||||
{
|
||||
QMessageBox::critical(nullptr, QStringLiteral("Error"), QString::fromUtf8(message));
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::DisplayWarning(const char* message)
|
||||
{
|
||||
QMessageBox::warning(nullptr, QStringLiteral("Warning"), QString::fromUtf8(message));
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::DisplayInformation(const char* message)
|
||||
{
|
||||
QMessageBox::information(nullptr, QStringLiteral("Information"), QString::fromUtf8(message));
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::DisplayDebugMessage(const char* message)
|
||||
{
|
||||
qDebug() << message;
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::ModalError(const char* message)
|
||||
{
|
||||
QMessageBox::critical(nullptr, QStringLiteral("Error"), QString::fromUtf8(message));
|
||||
}
|
||||
|
||||
bool AsyncRefreshProgressCallback::ModalConfirmation(const char* message)
|
||||
{
|
||||
return QMessageBox::question(nullptr, QStringLiteral("Question"), QString::fromUtf8(message)) == QMessageBox::Yes;
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::ModalInformation(const char* message)
|
||||
{
|
||||
QMessageBox::information(nullptr, QStringLiteral("Information"), QString::fromUtf8(message));
|
||||
}
|
||||
|
||||
void AsyncRefreshProgressCallback::fireUpdate()
|
||||
{
|
||||
m_parent->refreshProgress(m_status_text, m_last_value, m_last_range);
|
||||
}
|
||||
|
||||
GameListRefreshThread::GameListRefreshThread(bool invalidate_cache)
|
||||
: QThread()
|
||||
, m_progress(this)
|
||||
, m_invalidate_cache(invalidate_cache)
|
||||
{
|
||||
}
|
||||
|
||||
GameListRefreshThread::~GameListRefreshThread() = default;
|
||||
|
||||
void GameListRefreshThread::cancel()
|
||||
{
|
||||
m_progress.Cancel();
|
||||
}
|
||||
|
||||
void GameListRefreshThread::run()
|
||||
{
|
||||
GameList::Refresh(m_invalidate_cache, false, &m_progress);
|
||||
emit refreshComplete();
|
||||
}
|
||||
63
pcsx2-qt/GameList/GameListRefreshThread.h
Normal file
63
pcsx2-qt/GameList/GameListRefreshThread.h
Normal file
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: 2002-2025 PCSX2 Dev Team
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QtCore/QThread>
|
||||
#include <QtCore/QSemaphore>
|
||||
|
||||
#include "common/ProgressCallback.h"
|
||||
#include "common/Timer.h"
|
||||
|
||||
class GameListRefreshThread;
|
||||
|
||||
class AsyncRefreshProgressCallback : public BaseProgressCallback
|
||||
{
|
||||
public:
|
||||
AsyncRefreshProgressCallback(GameListRefreshThread* parent);
|
||||
|
||||
void Cancel();
|
||||
|
||||
void SetStatusText(const char* text) override;
|
||||
void SetProgressRange(u32 range) override;
|
||||
void SetProgressValue(u32 value) override;
|
||||
void SetTitle(const char* title) override;
|
||||
void DisplayError(const char* message) override;
|
||||
void DisplayWarning(const char* message) override;
|
||||
void DisplayInformation(const char* message) override;
|
||||
void DisplayDebugMessage(const char* message) override;
|
||||
void ModalError(const char* message) override;
|
||||
bool ModalConfirmation(const char* message) override;
|
||||
void ModalInformation(const char* message) override;
|
||||
|
||||
private:
|
||||
void fireUpdate();
|
||||
|
||||
GameListRefreshThread* m_parent;
|
||||
Common::Timer m_last_update_time;
|
||||
QString m_status_text;
|
||||
int m_last_range = 1;
|
||||
int m_last_value = 0;
|
||||
};
|
||||
|
||||
class GameListRefreshThread final : public QThread
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
GameListRefreshThread(bool invalidate_cache);
|
||||
~GameListRefreshThread();
|
||||
|
||||
void cancel();
|
||||
|
||||
Q_SIGNALS:
|
||||
void refreshProgress(const QString& status, int current, int total);
|
||||
void refreshComplete();
|
||||
|
||||
protected:
|
||||
void run();
|
||||
|
||||
private:
|
||||
AsyncRefreshProgressCallback m_progress;
|
||||
bool m_invalidate_cache;
|
||||
};
|
||||
735
pcsx2-qt/GameList/GameListWidget.cpp
Normal file
735
pcsx2-qt/GameList/GameListWidget.cpp
Normal file
@@ -0,0 +1,735 @@
|
||||
// SPDX-FileCopyrightText: 2002-2025 PCSX2 Dev Team
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
#include "GameListModel.h"
|
||||
#include "GameListRefreshThread.h"
|
||||
#include "GameListWidget.h"
|
||||
#include "QtHost.h"
|
||||
#include "QtUtils.h"
|
||||
|
||||
#include "pcsx2/GameList.h"
|
||||
#include "pcsx2/Host.h"
|
||||
|
||||
#include "common/Assertions.h"
|
||||
#include "common/Console.h"
|
||||
#include "common/StringUtil.h"
|
||||
|
||||
#include "fmt/format.h"
|
||||
|
||||
#include <QtCore/QSortFilterProxyModel>
|
||||
#include <QtGui/QPainter>
|
||||
#include <QtGui/QPixmap>
|
||||
#include <QtGui/QPixmapCache>
|
||||
#include <QtGui/QWheelEvent>
|
||||
#include <QtWidgets/QApplication>
|
||||
#include <QtWidgets/QHeaderView>
|
||||
#include <QtWidgets/QMenu>
|
||||
#include <QtWidgets/QScrollBar>
|
||||
#include <QtWidgets/QStyledItemDelegate>
|
||||
|
||||
static const char* SUPPORTED_FORMATS_STRING = QT_TRANSLATE_NOOP(GameListWidget,
|
||||
".bin/.iso (ISO Disc Images)\n"
|
||||
".mdf (Media Descriptor File)\n"
|
||||
".chd (Compressed Hunks of Data)\n"
|
||||
".cso (Compressed ISO)\n"
|
||||
".zso (Compressed ISO)\n"
|
||||
".gz (Gzip Compressed ISO)");
|
||||
|
||||
static constexpr float MIN_SCALE = 0.1f;
|
||||
static constexpr float MAX_SCALE = 2.0f;
|
||||
|
||||
class GameListSortModel final : public QSortFilterProxyModel
|
||||
{
|
||||
public:
|
||||
explicit GameListSortModel(GameListModel* parent)
|
||||
: QSortFilterProxyModel(parent)
|
||||
, m_model(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void setFilterType(GameList::EntryType type)
|
||||
{
|
||||
m_filter_type = type;
|
||||
invalidateRowsFilter();
|
||||
}
|
||||
void setFilterRegion(GameList::Region region)
|
||||
{
|
||||
m_filter_region = region;
|
||||
invalidateRowsFilter();
|
||||
}
|
||||
void setFilterName(const QString& name)
|
||||
{
|
||||
m_filter_name = name;
|
||||
invalidateRowsFilter();
|
||||
}
|
||||
|
||||
bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override
|
||||
{
|
||||
if (m_filter_type != GameList::EntryType::Count ||
|
||||
m_filter_region != GameList::Region::Count ||
|
||||
!m_filter_name.isEmpty())
|
||||
{
|
||||
const auto lock = GameList::GetLock();
|
||||
const GameList::Entry* entry = GameList::GetEntryByIndex(source_row);
|
||||
if (m_filter_type != GameList::EntryType::Count && entry->type != m_filter_type)
|
||||
return false;
|
||||
if (m_filter_region != GameList::Region::Count && entry->region != m_filter_region)
|
||||
return false;
|
||||
if (!m_filter_name.isEmpty() &&
|
||||
!QString::fromStdString(entry->path).contains(m_filter_name, Qt::CaseInsensitive) &&
|
||||
!QString::fromStdString(entry->serial).contains(m_filter_name, Qt::CaseInsensitive) &&
|
||||
!QString::fromStdString(entry->title).contains(m_filter_name, Qt::CaseInsensitive) &&
|
||||
!QString::fromStdString(entry->title_en).contains(m_filter_name, Qt::CaseInsensitive))
|
||||
return false;
|
||||
}
|
||||
|
||||
return QSortFilterProxyModel::filterAcceptsRow(source_row, source_parent);
|
||||
}
|
||||
|
||||
bool lessThan(const QModelIndex& source_left, const QModelIndex& source_right) const override
|
||||
{
|
||||
return m_model->lessThan(source_left, source_right, source_left.column());
|
||||
}
|
||||
|
||||
private:
|
||||
GameListModel* m_model;
|
||||
GameList::EntryType m_filter_type = GameList::EntryType::Count;
|
||||
GameList::Region m_filter_region = GameList::Region::Count;
|
||||
QString m_filter_name;
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
class GameListIconStyleDelegate final : public QStyledItemDelegate
|
||||
{
|
||||
public:
|
||||
GameListIconStyleDelegate(QWidget* parent)
|
||||
: QStyledItemDelegate(parent)
|
||||
{
|
||||
}
|
||||
~GameListIconStyleDelegate() = default;
|
||||
|
||||
void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override
|
||||
{
|
||||
// https://stackoverflow.com/questions/32216568/how-to-set-icon-center-in-qtableview
|
||||
Q_ASSERT(index.isValid());
|
||||
|
||||
// Draw the base item, with a blank icon
|
||||
QStyleOptionViewItem opt = option;
|
||||
initStyleOption(&opt, index);
|
||||
opt.icon = QIcon();
|
||||
// Based on QStyledItemDelegate::paint()
|
||||
const QStyle* style = option.widget ? option.widget->style() : QApplication::style();
|
||||
style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, option.widget);
|
||||
|
||||
// Fetch icon pixmap
|
||||
const QRect r = option.rect;
|
||||
const QPixmap pix = qvariant_cast<QPixmap>(index.data(Qt::DecorationRole));
|
||||
const int pix_width = static_cast<int>(pix.width() / pix.devicePixelRatio());
|
||||
const int pix_height = static_cast<int>(pix.height() / pix.devicePixelRatio());
|
||||
|
||||
// Clip the pixmaps so they don't extend outside the column
|
||||
painter->save();
|
||||
painter->setClipRect(option.rect);
|
||||
|
||||
// Draw the icon, using code derived from QItemDelegate::drawDecoration()
|
||||
const bool enabled = option.state & QStyle::State_Enabled;
|
||||
const QPoint p = QPoint((r.width() - pix_width) / 2, (r.height() - pix_height) / 2);
|
||||
if (option.state & QStyle::State_Selected)
|
||||
{
|
||||
// See QItemDelegate::selectedPixmap()
|
||||
QColor color = option.palette.color(enabled ? QPalette::Normal : QPalette::Disabled, QPalette::Highlight);
|
||||
color.setAlphaF(0.3f);
|
||||
|
||||
QString key = QString::fromStdString(fmt::format("{:016X}-{:d}-{:08X}", pix.cacheKey(), enabled, color.rgba()));
|
||||
QPixmap pm;
|
||||
if (!QPixmapCache::find(key, &pm))
|
||||
{
|
||||
QImage img = pix.toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
|
||||
|
||||
QPainter tinted_painter(&img);
|
||||
tinted_painter.setCompositionMode(QPainter::CompositionMode_SourceAtop);
|
||||
tinted_painter.fillRect(0, 0, img.width(), img.height(), color);
|
||||
tinted_painter.end();
|
||||
|
||||
pm = QPixmap(QPixmap::fromImage(img));
|
||||
QPixmapCache::insert(key, pm);
|
||||
}
|
||||
|
||||
painter->drawPixmap(r.topLeft() + p, pm);
|
||||
}
|
||||
else
|
||||
{
|
||||
painter->drawPixmap(r.topLeft() + p, pix);
|
||||
}
|
||||
|
||||
// Restore the old clip path.
|
||||
painter->restore();
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
GameListWidget::GameListWidget(QWidget* parent /* = nullptr */)
|
||||
: QWidget(parent)
|
||||
{
|
||||
}
|
||||
|
||||
GameListWidget::~GameListWidget() = default;
|
||||
|
||||
void GameListWidget::initialize()
|
||||
{
|
||||
const float cover_scale = Host::GetBaseFloatSettingValue("UI", "GameListCoverArtScale", 0.45f);
|
||||
const bool show_cover_titles = Host::GetBaseBoolSettingValue("UI", "GameListShowCoverTitles", true);
|
||||
m_model = new GameListModel(cover_scale, show_cover_titles, devicePixelRatioF(), this);
|
||||
m_model->updateCacheSize(width(), height());
|
||||
|
||||
m_sort_model = new GameListSortModel(m_model);
|
||||
m_sort_model->setSourceModel(m_model);
|
||||
|
||||
m_ui.setupUi(this);
|
||||
|
||||
for (u32 type = 0; type < static_cast<u32>(GameList::EntryType::Count); type++)
|
||||
{
|
||||
if (type != static_cast<u32>(GameList::EntryType::Invalid))
|
||||
{
|
||||
m_ui.filterType->addItem(GameListModel::getIconForType(static_cast<GameList::EntryType>(type)),
|
||||
GameList::EntryTypeToString(static_cast<GameList::EntryType>(type), true));
|
||||
}
|
||||
}
|
||||
|
||||
for (u32 region = 0; region < static_cast<u32>(GameList::Region::Count); region++)
|
||||
{
|
||||
m_ui.filterRegion->addItem(GameListModel::getIconForRegion(static_cast<GameList::Region>(region)),
|
||||
GameList::RegionToString(static_cast<GameList::Region>(region), true));
|
||||
}
|
||||
|
||||
connect(m_ui.viewGameList, &QPushButton::clicked, this, &GameListWidget::showGameList);
|
||||
connect(m_ui.viewGameGrid, &QPushButton::clicked, this, &GameListWidget::showGameGrid);
|
||||
connect(m_ui.gridScale, &QSlider::valueChanged, this, &GameListWidget::gridIntScale);
|
||||
connect(m_ui.viewGridTitles, &QPushButton::toggled, this, &GameListWidget::setShowCoverTitles);
|
||||
connect(m_ui.filterType, &QComboBox::currentIndexChanged, this, [this](int index) {
|
||||
m_sort_model->setFilterType((index == 0) ? GameList::EntryType::Count : static_cast<GameList::EntryType>(index - 1));
|
||||
});
|
||||
connect(m_ui.filterRegion, &QComboBox::currentIndexChanged, this, [this](int index) {
|
||||
m_sort_model->setFilterRegion((index == 0) ? GameList::Region::Count : static_cast<GameList::Region>(index - 1));
|
||||
});
|
||||
connect(m_ui.searchText, &QLineEdit::textChanged, this, [this](const QString& text) {
|
||||
m_sort_model->setFilterName(text);
|
||||
});
|
||||
|
||||
m_table_view = new QTableView(m_ui.stack);
|
||||
m_table_view->setModel(m_sort_model);
|
||||
m_table_view->setSortingEnabled(true);
|
||||
m_table_view->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_table_view->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
m_table_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
m_table_view->setAlternatingRowColors(true);
|
||||
m_table_view->setMouseTracking(true);
|
||||
m_table_view->setShowGrid(false);
|
||||
m_table_view->setCurrentIndex({});
|
||||
m_table_view->horizontalHeader()->setHighlightSections(false);
|
||||
m_table_view->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
m_table_view->verticalHeader()->hide();
|
||||
m_table_view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
|
||||
m_table_view->setVerticalScrollMode(QAbstractItemView::ScrollMode::ScrollPerPixel);
|
||||
m_table_view->setItemDelegateForColumn(0, new GameListIconStyleDelegate(this));
|
||||
m_table_view->setItemDelegateForColumn(8, new GameListIconStyleDelegate(this));
|
||||
m_table_view->setItemDelegateForColumn(9, new GameListIconStyleDelegate(this));
|
||||
|
||||
loadTableViewColumnVisibilitySettings();
|
||||
loadTableViewColumnSortSettings();
|
||||
|
||||
connect(m_table_view->selectionModel(), &QItemSelectionModel::currentChanged, this,
|
||||
&GameListWidget::onSelectionModelCurrentChanged);
|
||||
connect(m_table_view, &QTableView::activated, this, &GameListWidget::onTableViewItemActivated);
|
||||
connect(m_table_view, &QTableView::customContextMenuRequested, this,
|
||||
&GameListWidget::onTableViewContextMenuRequested);
|
||||
connect(m_table_view->horizontalHeader(), &QHeaderView::customContextMenuRequested, this,
|
||||
&GameListWidget::onTableViewHeaderContextMenuRequested);
|
||||
connect(m_table_view->horizontalHeader(), &QHeaderView::sortIndicatorChanged, this,
|
||||
&GameListWidget::onTableViewHeaderSortIndicatorChanged);
|
||||
|
||||
m_ui.stack->insertWidget(0, m_table_view);
|
||||
|
||||
m_list_view = new GameListGridListView(m_ui.stack);
|
||||
m_list_view->setModel(m_sort_model);
|
||||
m_list_view->setModelColumn(GameListModel::Column_Cover);
|
||||
m_list_view->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_list_view->setViewMode(QListView::IconMode);
|
||||
m_list_view->setResizeMode(QListView::Adjust);
|
||||
m_list_view->setUniformItemSizes(true);
|
||||
m_list_view->setItemAlignment(Qt::AlignHCenter);
|
||||
m_list_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
m_list_view->setFrameStyle(QFrame::NoFrame);
|
||||
m_list_view->setVerticalScrollMode(QAbstractItemView::ScrollMode::ScrollPerPixel);
|
||||
m_list_view->verticalScrollBar()->setSingleStep(15);
|
||||
onCoverScaleChanged();
|
||||
|
||||
connect(m_list_view->selectionModel(), &QItemSelectionModel::currentChanged, this,
|
||||
&GameListWidget::onSelectionModelCurrentChanged);
|
||||
connect(m_list_view, &GameListGridListView::zoomIn, this, &GameListWidget::gridZoomIn);
|
||||
connect(m_list_view, &GameListGridListView::zoomOut, this, &GameListWidget::gridZoomOut);
|
||||
connect(m_list_view, &QListView::activated, this, &GameListWidget::onListViewItemActivated);
|
||||
connect(m_list_view, &QListView::customContextMenuRequested, this, &GameListWidget::onListViewContextMenuRequested);
|
||||
connect(m_model, &GameListModel::coverScaleChanged, this, &GameListWidget::onCoverScaleChanged);
|
||||
|
||||
m_ui.stack->insertWidget(1, m_list_view);
|
||||
|
||||
m_empty_widget = new QWidget(m_ui.stack);
|
||||
m_empty_ui.setupUi(m_empty_widget);
|
||||
m_empty_ui.supportedFormats->setText(qApp->translate("GameListWidget", SUPPORTED_FORMATS_STRING));
|
||||
connect(m_empty_ui.addGameDirectory, &QPushButton::clicked, this, [this]() { emit addGameDirectoryRequested(); });
|
||||
connect(m_empty_ui.scanForNewGames, &QPushButton::clicked, this, [this]() { refresh(false); });
|
||||
m_ui.stack->insertWidget(2, m_empty_widget);
|
||||
|
||||
if (Host::GetBaseBoolSettingValue("UI", "GameListGridView", false))
|
||||
m_ui.stack->setCurrentIndex(1);
|
||||
else
|
||||
m_ui.stack->setCurrentIndex(0);
|
||||
|
||||
setFocusProxy(m_ui.stack->currentWidget());
|
||||
|
||||
updateToolbar();
|
||||
resizeTableViewColumnsToFit();
|
||||
}
|
||||
|
||||
bool GameListWidget::isShowingGameList() const
|
||||
{
|
||||
return m_ui.stack->currentIndex() == 0;
|
||||
}
|
||||
|
||||
bool GameListWidget::isShowingGameGrid() const
|
||||
{
|
||||
return m_ui.stack->currentIndex() == 1;
|
||||
}
|
||||
|
||||
bool GameListWidget::getShowGridCoverTitles() const
|
||||
{
|
||||
return m_model->getShowCoverTitles();
|
||||
}
|
||||
|
||||
void GameListWidget::refresh(bool invalidate_cache)
|
||||
{
|
||||
cancelRefresh();
|
||||
|
||||
m_refresh_thread = new GameListRefreshThread(invalidate_cache);
|
||||
connect(m_refresh_thread, &GameListRefreshThread::refreshProgress, this, &GameListWidget::onRefreshProgress,
|
||||
Qt::QueuedConnection);
|
||||
connect(m_refresh_thread, &GameListRefreshThread::refreshComplete, this, &GameListWidget::onRefreshComplete,
|
||||
Qt::QueuedConnection);
|
||||
m_refresh_thread->start();
|
||||
}
|
||||
|
||||
void GameListWidget::cancelRefresh()
|
||||
{
|
||||
if (!m_refresh_thread)
|
||||
return;
|
||||
|
||||
m_refresh_thread->cancel();
|
||||
m_refresh_thread->wait();
|
||||
|
||||
// Cancelling might not be instant if we're say, scanning a gzip dump. Wait until it's done.
|
||||
while (m_refresh_thread)
|
||||
QApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 1);
|
||||
}
|
||||
|
||||
void GameListWidget::reloadThemeSpecificImages()
|
||||
{
|
||||
m_model->reloadThemeSpecificImages();
|
||||
}
|
||||
|
||||
void GameListWidget::onRefreshProgress(const QString& status, int current, int total)
|
||||
{
|
||||
// switch away from the placeholder while we scan, in case we find anything
|
||||
if (m_ui.stack->currentIndex() == 2)
|
||||
{
|
||||
m_ui.stack->setCurrentIndex(Host::GetBaseBoolSettingValue("UI", "GameListGridView", false) ? 1 : 0);
|
||||
setFocusProxy(m_ui.stack->currentWidget());
|
||||
}
|
||||
|
||||
m_model->refresh();
|
||||
emit refreshProgress(status, current, total);
|
||||
}
|
||||
|
||||
void GameListWidget::onRefreshComplete()
|
||||
{
|
||||
m_model->refresh();
|
||||
emit refreshComplete();
|
||||
|
||||
pxAssertRel(m_refresh_thread, "Has a refresh thread");
|
||||
m_refresh_thread->wait();
|
||||
delete m_refresh_thread;
|
||||
m_refresh_thread = nullptr;
|
||||
|
||||
// if we still had no games, switch to the helper widget
|
||||
if (m_model->rowCount() == 0)
|
||||
{
|
||||
m_ui.stack->setCurrentIndex(2);
|
||||
setFocusProxy(nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void GameListWidget::onSelectionModelCurrentChanged(const QModelIndex& current, const QModelIndex& previous)
|
||||
{
|
||||
const QModelIndex source_index = m_sort_model->mapToSource(current);
|
||||
if (!source_index.isValid() || source_index.row() >= static_cast<int>(GameList::GetEntryCount()))
|
||||
return;
|
||||
|
||||
emit selectionChanged();
|
||||
}
|
||||
|
||||
void GameListWidget::onTableViewItemActivated(const QModelIndex& index)
|
||||
{
|
||||
const QModelIndex source_index = m_sort_model->mapToSource(index);
|
||||
if (!source_index.isValid() || source_index.row() >= static_cast<int>(GameList::GetEntryCount()))
|
||||
return;
|
||||
|
||||
emit entryActivated();
|
||||
}
|
||||
|
||||
void GameListWidget::onTableViewContextMenuRequested(const QPoint& point)
|
||||
{
|
||||
emit entryContextMenuRequested(m_table_view->mapToGlobal(point));
|
||||
}
|
||||
|
||||
void GameListWidget::onListViewItemActivated(const QModelIndex& index)
|
||||
{
|
||||
const QModelIndex source_index = m_sort_model->mapToSource(index);
|
||||
if (!source_index.isValid() || source_index.row() >= static_cast<int>(GameList::GetEntryCount()))
|
||||
return;
|
||||
|
||||
emit entryActivated();
|
||||
}
|
||||
|
||||
void GameListWidget::onListViewContextMenuRequested(const QPoint& point)
|
||||
{
|
||||
emit entryContextMenuRequested(m_list_view->mapToGlobal(point));
|
||||
}
|
||||
|
||||
void GameListWidget::onTableViewHeaderContextMenuRequested(const QPoint& point)
|
||||
{
|
||||
QMenu menu;
|
||||
|
||||
for (int column = 0; column < GameListModel::Column_Count; column++)
|
||||
{
|
||||
if (column == GameListModel::Column_Cover)
|
||||
continue;
|
||||
|
||||
QAction* action = menu.addAction(m_model->getColumnDisplayName(column));
|
||||
action->setCheckable(true);
|
||||
action->setChecked(!m_table_view->isColumnHidden(column));
|
||||
connect(action, &QAction::toggled, [this, column](bool enabled) {
|
||||
m_table_view->setColumnHidden(column, !enabled);
|
||||
saveTableViewColumnVisibilitySettings(column);
|
||||
resizeTableViewColumnsToFit();
|
||||
});
|
||||
}
|
||||
|
||||
menu.exec(m_table_view->mapToGlobal(point));
|
||||
}
|
||||
|
||||
void GameListWidget::onTableViewHeaderSortIndicatorChanged(int, Qt::SortOrder)
|
||||
{
|
||||
saveTableViewColumnSortSettings();
|
||||
}
|
||||
|
||||
void GameListWidget::onCoverScaleChanged()
|
||||
{
|
||||
m_model->updateCacheSize(width(), height());
|
||||
|
||||
m_list_view->setSpacing(m_model->getCoverArtSpacing());
|
||||
|
||||
QFont font;
|
||||
font.setPointSizeF(20.0f * m_model->getCoverScale());
|
||||
m_list_view->setFont(font);
|
||||
}
|
||||
|
||||
void GameListWidget::listZoom(float delta)
|
||||
{
|
||||
const float new_scale = std::clamp(m_model->getCoverScale() + delta, MIN_SCALE, MAX_SCALE);
|
||||
Host::SetBaseFloatSettingValue("UI", "GameListCoverArtScale", new_scale);
|
||||
Host::CommitBaseSettingChanges();
|
||||
m_model->setCoverScale(new_scale);
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
void GameListWidget::gridZoomIn()
|
||||
{
|
||||
listZoom(0.05f);
|
||||
}
|
||||
|
||||
void GameListWidget::gridZoomOut()
|
||||
{
|
||||
listZoom(-0.05f);
|
||||
}
|
||||
|
||||
void GameListWidget::gridIntScale(int int_scale)
|
||||
{
|
||||
const float new_scale = std::clamp(static_cast<float>(int_scale) / 100.0f, MIN_SCALE, MAX_SCALE);
|
||||
|
||||
Host::SetBaseFloatSettingValue("UI", "GameListCoverArtScale", new_scale);
|
||||
Host::CommitBaseSettingChanges();
|
||||
m_model->setCoverScale(new_scale);
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
void GameListWidget::refreshGridCovers()
|
||||
{
|
||||
m_model->refreshCovers();
|
||||
}
|
||||
|
||||
void GameListWidget::showGameList()
|
||||
{
|
||||
if (m_ui.stack->currentIndex() == 0 || m_model->rowCount() == 0)
|
||||
{
|
||||
// We can click the toolbar multiple times, so keep it correct.
|
||||
updateToolbar();
|
||||
return;
|
||||
}
|
||||
|
||||
Host::SetBaseBoolSettingValue("UI", "GameListGridView", false);
|
||||
Host::CommitBaseSettingChanges();
|
||||
m_ui.stack->setCurrentIndex(0);
|
||||
setFocusProxy(m_ui.stack->currentWidget());
|
||||
resizeTableViewColumnsToFit();
|
||||
updateToolbar();
|
||||
emit layoutChange();
|
||||
}
|
||||
|
||||
void GameListWidget::showGameGrid()
|
||||
{
|
||||
if (m_ui.stack->currentIndex() == 1 || m_model->rowCount() == 0)
|
||||
{
|
||||
// We can click the toolbar multiple times, so keep it correct.
|
||||
updateToolbar();
|
||||
return;
|
||||
}
|
||||
|
||||
Host::SetBaseBoolSettingValue("UI", "GameListGridView", true);
|
||||
Host::CommitBaseSettingChanges();
|
||||
m_ui.stack->setCurrentIndex(1);
|
||||
setFocusProxy(m_ui.stack->currentWidget());
|
||||
updateToolbar();
|
||||
emit layoutChange();
|
||||
}
|
||||
|
||||
void GameListWidget::setShowCoverTitles(bool enabled)
|
||||
{
|
||||
if (m_model->getShowCoverTitles() == enabled)
|
||||
return;
|
||||
|
||||
Host::SetBaseBoolSettingValue("UI", "GameListShowCoverTitles", enabled);
|
||||
Host::CommitBaseSettingChanges();
|
||||
m_model->setShowCoverTitles(enabled);
|
||||
if (isShowingGameGrid())
|
||||
m_model->refresh();
|
||||
updateToolbar();
|
||||
emit layoutChange();
|
||||
}
|
||||
|
||||
void GameListWidget::updateToolbar()
|
||||
{
|
||||
const bool grid_view = isShowingGameGrid();
|
||||
{
|
||||
QSignalBlocker sb(m_ui.viewGameGrid);
|
||||
m_ui.viewGameGrid->setChecked(grid_view);
|
||||
}
|
||||
{
|
||||
QSignalBlocker sb(m_ui.viewGameList);
|
||||
m_ui.viewGameList->setChecked(!grid_view);
|
||||
}
|
||||
{
|
||||
QSignalBlocker sb(m_ui.viewGridTitles);
|
||||
m_ui.viewGridTitles->setChecked(m_model->getShowCoverTitles());
|
||||
}
|
||||
{
|
||||
QSignalBlocker sb(m_ui.gridScale);
|
||||
m_ui.gridScale->setValue(static_cast<int>(m_model->getCoverScale() * 100.0f));
|
||||
}
|
||||
|
||||
m_ui.viewGridTitles->setEnabled(grid_view);
|
||||
m_ui.gridScale->setEnabled(grid_view);
|
||||
}
|
||||
|
||||
void GameListWidget::resizeEvent(QResizeEvent* event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
resizeTableViewColumnsToFit();
|
||||
m_model->updateCacheSize(width(), height());
|
||||
}
|
||||
|
||||
bool GameListWidget::event(QEvent* event)
|
||||
{
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)
|
||||
if (event->type() == QEvent::DevicePixelRatioChange)
|
||||
{
|
||||
m_model->setDevicePixelRatio(devicePixelRatioF());
|
||||
QWidget::event(event);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
return QWidget::event(event);
|
||||
}
|
||||
|
||||
void GameListWidget::resizeTableViewColumnsToFit()
|
||||
{
|
||||
QtUtils::ResizeColumnsForTableView(m_table_view, {
|
||||
45, // type
|
||||
80, // code
|
||||
-1, // title
|
||||
-1, // file title
|
||||
65, // crc
|
||||
80, // time played
|
||||
80, // last played
|
||||
80, // size
|
||||
60, // region
|
||||
120 // compatibility
|
||||
});
|
||||
}
|
||||
|
||||
static std::string getColumnVisibilitySettingsKeyName(int column)
|
||||
{
|
||||
return StringUtil::StdStringFromFormat("Show%s",
|
||||
GameListModel::getColumnName(static_cast<GameListModel::Column>(column)));
|
||||
}
|
||||
|
||||
void GameListWidget::loadTableViewColumnVisibilitySettings()
|
||||
{
|
||||
static constexpr std::array<bool, GameListModel::Column_Count> DEFAULT_VISIBILITY = {{
|
||||
true, // type
|
||||
true, // code
|
||||
true, // title
|
||||
false, // file title
|
||||
false, // crc
|
||||
true, // time played
|
||||
true, // last played
|
||||
true, // size
|
||||
true, // region
|
||||
true // compatibility
|
||||
}};
|
||||
|
||||
for (int column = 0; column < GameListModel::Column_Count; column++)
|
||||
{
|
||||
const bool visible = Host::GetBaseBoolSettingValue(
|
||||
"GameListTableView", getColumnVisibilitySettingsKeyName(column).c_str(), DEFAULT_VISIBILITY[column]);
|
||||
m_table_view->setColumnHidden(column, !visible);
|
||||
}
|
||||
}
|
||||
|
||||
void GameListWidget::saveTableViewColumnVisibilitySettings()
|
||||
{
|
||||
for (int column = 0; column < GameListModel::Column_Count; column++)
|
||||
{
|
||||
const bool visible = !m_table_view->isColumnHidden(column);
|
||||
Host::SetBaseBoolSettingValue("GameListTableView", getColumnVisibilitySettingsKeyName(column).c_str(), visible);
|
||||
Host::CommitBaseSettingChanges();
|
||||
}
|
||||
}
|
||||
|
||||
void GameListWidget::saveTableViewColumnVisibilitySettings(int column)
|
||||
{
|
||||
const bool visible = !m_table_view->isColumnHidden(column);
|
||||
Host::SetBaseBoolSettingValue("GameListTableView", getColumnVisibilitySettingsKeyName(column).c_str(), visible);
|
||||
Host::CommitBaseSettingChanges();
|
||||
}
|
||||
|
||||
void GameListWidget::loadTableViewColumnSortSettings()
|
||||
{
|
||||
const GameListModel::Column DEFAULT_SORT_COLUMN = GameListModel::Column_Type;
|
||||
const bool DEFAULT_SORT_DESCENDING = false;
|
||||
|
||||
const GameListModel::Column sort_column =
|
||||
GameListModel::getColumnIdForName(Host::GetBaseStringSettingValue("GameListTableView", "SortColumn"))
|
||||
.value_or(DEFAULT_SORT_COLUMN);
|
||||
const bool sort_descending =
|
||||
Host::GetBaseBoolSettingValue("GameListTableView", "SortDescending", DEFAULT_SORT_DESCENDING);
|
||||
const Qt::SortOrder sort_order = sort_descending ? Qt::DescendingOrder : Qt::AscendingOrder;
|
||||
m_sort_model->sort(sort_column, sort_order);
|
||||
if (QHeaderView* hv = m_table_view->horizontalHeader())
|
||||
hv->setSortIndicator(sort_column, sort_order);
|
||||
}
|
||||
|
||||
void GameListWidget::saveTableViewColumnSortSettings()
|
||||
{
|
||||
const int sort_column = m_table_view->horizontalHeader()->sortIndicatorSection();
|
||||
const bool sort_descending = (m_table_view->horizontalHeader()->sortIndicatorOrder() == Qt::DescendingOrder);
|
||||
|
||||
if (sort_column >= 0 && sort_column < GameListModel::Column_Count)
|
||||
{
|
||||
Host::SetBaseStringSettingValue(
|
||||
"GameListTableView", "SortColumn", GameListModel::getColumnName(static_cast<GameListModel::Column>(sort_column)));
|
||||
}
|
||||
|
||||
Host::SetBaseBoolSettingValue("GameListTableView", "SortDescending", sort_descending);
|
||||
Host::CommitBaseSettingChanges();
|
||||
}
|
||||
|
||||
const GameList::Entry* GameListWidget::getSelectedEntry() const
|
||||
{
|
||||
if (m_ui.stack->currentIndex() == 0)
|
||||
{
|
||||
const QItemSelectionModel* selection_model = m_table_view->selectionModel();
|
||||
if (!selection_model->hasSelection())
|
||||
return nullptr;
|
||||
|
||||
const QModelIndexList selected_rows = selection_model->selectedRows();
|
||||
if (selected_rows.empty())
|
||||
return nullptr;
|
||||
|
||||
const QModelIndex source_index = m_sort_model->mapToSource(selected_rows[0]);
|
||||
if (!source_index.isValid())
|
||||
return nullptr;
|
||||
|
||||
return GameList::GetEntryByIndex(source_index.row());
|
||||
}
|
||||
else
|
||||
{
|
||||
const QItemSelectionModel* selection_model = m_list_view->selectionModel();
|
||||
if (!selection_model->hasSelection())
|
||||
return nullptr;
|
||||
|
||||
const QModelIndex source_index = m_sort_model->mapToSource(selection_model->currentIndex());
|
||||
if (!source_index.isValid())
|
||||
return nullptr;
|
||||
|
||||
return GameList::GetEntryByIndex(source_index.row());
|
||||
}
|
||||
}
|
||||
|
||||
void GameListWidget::rescanFile(const std::string& path)
|
||||
{
|
||||
// We can't do this while there's a VM running, because of CDVD state... ugh.
|
||||
if (QtHost::IsVMValid())
|
||||
{
|
||||
Console.Error(fmt::format("Can't re-scan ELF at '{}' because we have a VM running.", path));
|
||||
return;
|
||||
}
|
||||
|
||||
GameList::RescanPath(path);
|
||||
m_model->refresh();
|
||||
}
|
||||
|
||||
GameListGridListView::GameListGridListView(QWidget* parent /*= nullptr*/)
|
||||
: QListView(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void GameListGridListView::wheelEvent(QWheelEvent* e)
|
||||
{
|
||||
if (e->modifiers() & Qt::ControlModifier)
|
||||
{
|
||||
int dy = e->angleDelta().y();
|
||||
if (dy != 0)
|
||||
{
|
||||
if (dy < 0)
|
||||
zoomOut();
|
||||
else
|
||||
zoomIn();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
QListView::wheelEvent(e);
|
||||
}
|
||||
118
pcsx2-qt/GameList/GameListWidget.h
Normal file
118
pcsx2-qt/GameList/GameListWidget.h
Normal file
@@ -0,0 +1,118 @@
|
||||
// SPDX-FileCopyrightText: 2002-2025 PCSX2 Dev Team
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "ui_EmptyGameListWidget.h"
|
||||
#include "ui_GameListWidget.h"
|
||||
|
||||
#include "pcsx2/GameList.h"
|
||||
|
||||
#include <QtWidgets/QListView>
|
||||
#include <QtWidgets/QTableView>
|
||||
|
||||
Q_DECLARE_METATYPE(const GameList::Entry*);
|
||||
|
||||
class GameListModel;
|
||||
class GameListSortModel;
|
||||
class GameListRefreshThread;
|
||||
|
||||
class GameListGridListView : public QListView
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
GameListGridListView(QWidget* parent = nullptr);
|
||||
|
||||
Q_SIGNALS:
|
||||
void zoomOut();
|
||||
void zoomIn();
|
||||
|
||||
protected:
|
||||
void wheelEvent(QWheelEvent* e);
|
||||
};
|
||||
|
||||
class GameListWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
GameListWidget(QWidget* parent = nullptr);
|
||||
~GameListWidget();
|
||||
|
||||
__fi GameListModel* getModel() const { return m_model; }
|
||||
|
||||
void initialize();
|
||||
void resizeTableViewColumnsToFit();
|
||||
|
||||
void refresh(bool invalidate_cache);
|
||||
void cancelRefresh();
|
||||
void reloadThemeSpecificImages();
|
||||
|
||||
bool isShowingGameList() const;
|
||||
bool isShowingGameGrid() const;
|
||||
bool getShowGridCoverTitles() const;
|
||||
|
||||
const GameList::Entry* getSelectedEntry() const;
|
||||
|
||||
/// Rescans a single file. NOTE: Happens on UI thread.
|
||||
void rescanFile(const std::string& path);
|
||||
|
||||
Q_SIGNALS:
|
||||
void refreshProgress(const QString& status, int current, int total);
|
||||
void refreshComplete();
|
||||
|
||||
void selectionChanged();
|
||||
void entryActivated();
|
||||
void entryContextMenuRequested(const QPoint& point);
|
||||
|
||||
void addGameDirectoryRequested();
|
||||
void layoutChange();
|
||||
|
||||
private Q_SLOTS:
|
||||
void onRefreshProgress(const QString& status, int current, int total);
|
||||
void onRefreshComplete();
|
||||
|
||||
void onSelectionModelCurrentChanged(const QModelIndex& current, const QModelIndex& previous);
|
||||
void onTableViewItemActivated(const QModelIndex& index);
|
||||
void onTableViewContextMenuRequested(const QPoint& point);
|
||||
void onTableViewHeaderContextMenuRequested(const QPoint& point);
|
||||
void onTableViewHeaderSortIndicatorChanged(int, Qt::SortOrder);
|
||||
void onListViewItemActivated(const QModelIndex& index);
|
||||
void onListViewContextMenuRequested(const QPoint& point);
|
||||
void onCoverScaleChanged();
|
||||
|
||||
public Q_SLOTS:
|
||||
void showGameList();
|
||||
void showGameGrid();
|
||||
void setShowCoverTitles(bool enabled);
|
||||
void gridZoomIn();
|
||||
void gridZoomOut();
|
||||
void gridIntScale(int int_scale);
|
||||
void refreshGridCovers();
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
bool event(QEvent* event) override;
|
||||
|
||||
private:
|
||||
void loadTableViewColumnVisibilitySettings();
|
||||
void saveTableViewColumnVisibilitySettings();
|
||||
void saveTableViewColumnVisibilitySettings(int column);
|
||||
void loadTableViewColumnSortSettings();
|
||||
void saveTableViewColumnSortSettings();
|
||||
void listZoom(float delta);
|
||||
void updateToolbar();
|
||||
|
||||
Ui::GameListWidget m_ui;
|
||||
|
||||
GameListModel* m_model = nullptr;
|
||||
GameListSortModel* m_sort_model = nullptr;
|
||||
QTableView* m_table_view = nullptr;
|
||||
GameListGridListView* m_list_view = nullptr;
|
||||
|
||||
QWidget* m_empty_widget = nullptr;
|
||||
Ui::EmptyGameListWidget m_empty_ui;
|
||||
|
||||
GameListRefreshThread* m_refresh_thread = nullptr;
|
||||
};
|
||||
217
pcsx2-qt/GameList/GameListWidget.ui
Normal file
217
pcsx2-qt/GameList/GameListWidget.ui
Normal file
@@ -0,0 +1,217 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>GameListWidget</class>
|
||||
<widget class="QWidget" name="GameListWidget">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>758</width>
|
||||
<height>619</height>
|
||||
</rect>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="spacing">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QToolButton" name="viewGameList">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>32</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Game List</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset theme="list-check">
|
||||
<normaloff>.</normaloff>.</iconset>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="viewGameGrid">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>32</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Game Grid</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset theme="function-line">
|
||||
<normaloff>.</normaloff>.</iconset>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="viewGridTitles">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>32</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Show Titles</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset theme="price-tag-3-line">
|
||||
<normaloff>.</normaloff>.</iconset>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSlider" name="gridScale">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>125</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>125</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>200</number>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="spacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QComboBox" name="filterType">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>All Types</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset theme="filter-line">
|
||||
<normaloff>.</normaloff>.</iconset>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="filterRegion">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>All Regions</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset theme="global-line">
|
||||
<normaloff>.</normaloff>.</iconset>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="searchText">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>Search...</string>
|
||||
</property>
|
||||
<property name="clearButtonEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QStackedWidget" name="stack"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="../resources/resources.qrc"/>
|
||||
</resources>
|
||||
<connections/>
|
||||
</ui>
|
||||
Reference in New Issue
Block a user