diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt
index 0d4acdc9a..fbb9b0b54 100644
--- a/src/launcher/CMakeLists.txt
+++ b/src/launcher/CMakeLists.txt
@@ -37,6 +37,16 @@ endif()
target_include_directories(launcher PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/src")
set_target_properties(launcher PROPERTIES AUTOUIC ON AUTOMOC ON AUTORCC ON)
+add_executable(launcher_game_views_tests EXCLUDE_FROM_ALL
+ "${KYTY_TESTS_DIR}/LauncherGameViewsTests.cpp"
+ src/gameContent.cpp
+ src/launcherTheme.cpp
+ resources.qrc
+)
+target_link_libraries(launcher_game_views_tests Qt6::Widgets common)
+target_include_directories(launcher_game_views_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")
+set_target_properties(launcher_game_views_tests PROPERTIES AUTOUIC ON AUTORCC ON)
+
if(WIN32 AND NOT KYTY_CLANG_CL)
set_target_properties(launcher PROPERTIES LINK_FLAGS "${KYTY_LD_OPTIONS}")
elseif(LINUX)
diff --git a/src/launcher/forms/configuration_list_widget.ui b/src/launcher/forms/configuration_list_widget.ui
index 64ca37bbf..3eaf81a9c 100644
--- a/src/launcher/forms/configuration_list_widget.ui
+++ b/src/launcher/forms/configuration_list_widget.ui
@@ -304,8 +304,11 @@
-
-
-
+
+
+
+ #cfgs_list { background: transparent; }
+
false
@@ -350,7 +353,11 @@
-
+
+
+ #cfgs_grid { background: transparent; }
+
+
@@ -369,9 +376,10 @@
- GameListTreeWidget
- QTreeWidget
-
+ GameViews
+ QStackedWidget
+
+ 1
diff --git a/src/launcher/include/gameViews.h b/src/launcher/include/gameViews.h
index 376577461..1374e613f 100644
--- a/src/launcher/include/gameViews.h
+++ b/src/launcher/include/gameViews.h
@@ -1,23 +1,34 @@
-#ifndef GAME_LIST_TREE_WIDGET_H
-#define GAME_LIST_TREE_WIDGET_H
+#ifndef GAME_VIEWS_H
+#define GAME_VIEWS_H
#include "gameContent.h"
+#include
+#include
+#include
#include
#include
#include
#include
-#include
#include
+#include
#include
+#include
#include
-class GameListTreeWidget: public QTreeWidget {
+inline constexpr int GAME_NAME_COLUMN = 0;
+inline constexpr int GAME_SERIAL_COLUMN = 1;
+inline constexpr int GAME_VERSION_COLUMN = 2;
+inline constexpr int GAME_FIRMWARE_VERSION_COLUMN = 3;
+inline constexpr int GAME_SIZE_COLUMN = 4;
+inline constexpr int GAME_PATH_COLUMN = 5;
+inline constexpr int GAME_STATUS_COLUMN = 6;
+inline constexpr int GAME_COMMENT_COLUMN = 7;
+
+// Holds the transparent list and grid views and paints the selected game's pic0 behind both.
+class GameViews: public QStackedWidget {
public:
- explicit GameListTreeWidget(QWidget* parent = nullptr): QTreeWidget(parent) {
- setAutoFillBackground(false);
- viewport()->setAutoFillBackground(false);
- }
+ using QStackedWidget::QStackedWidget;
void SetBackgroundImage(const QString& key) {
if (m_path == key) {
@@ -30,48 +41,108 @@ public:
GameContent::MaxImageSize));
}
UpdateScaledBackground();
- viewport()->update();
+ update();
}
protected:
void resizeEvent(QResizeEvent* event) override {
- QTreeWidget::resizeEvent(event);
+ QStackedWidget::resizeEvent(event);
UpdateScaledBackground();
}
void paintEvent(QPaintEvent* event) override {
- {
- QPainter painter(viewport());
- if (m_scaled.isNull()) {
- painter.fillRect(event->rect(), palette().brush(QPalette::Base));
- } else {
- painter.drawPixmap(m_scaled_pos, m_scaled);
- auto overlay = palette().color(QPalette::Base);
- overlay.setAlpha(185);
- painter.fillRect(event->rect(), overlay);
- }
+ QPainter painter(this);
+ auto overlay = palette().color(QPalette::Base);
+ if (!m_scaled.isNull()) {
+ painter.drawPixmap((width() - m_scaled.width()) / 2, (height() - m_scaled.height()) / 2,
+ m_scaled);
+ overlay.setAlpha(160);
}
-
- QTreeWidget::paintEvent(event);
+ painter.fillRect(event->rect(), overlay);
}
private:
void UpdateScaledBackground() {
- m_scaled = QPixmap();
- if (m_source.isNull() || viewport()->size().isEmpty()) {
- return;
- }
-
- m_scaled = m_source.scaled(viewport()->size(), Qt::KeepAspectRatioByExpanding,
- Qt::FastTransformation);
- m_scaled_pos = QPoint((viewport()->width() - m_scaled.width()) / 2,
- (viewport()->height() - m_scaled.height()) / 2);
+ m_scaled = m_source.isNull() ? QPixmap()
+ : m_source.scaled(size(), Qt::KeepAspectRatioByExpanding,
+ Qt::FastTransformation);
}
QString m_path;
QPixmap m_source;
QPixmap m_scaled;
- QPoint m_scaled_pos;
};
-#endif // GAME_LIST_TREE_WIDGET_H
+// A card on a translucent panel, so the text keeps its contrast over the game background.
+class GameGridDelegate: public QStyledItemDelegate {
+public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+
+ QSize sizeHint(const QStyleOptionViewItem& option,
+ const QModelIndex& /*index*/) const override {
+ return option.decorationSize +
+ QSize(2 * CARD_PADDING, 3 * CARD_PADDING + 2 * option.fontMetrics.lineSpacing());
+ }
+
+ void paint(QPainter* painter, const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override {
+ // The stylesheet made the view's own palette transparent, so take the panel from the app's.
+ auto panel = QGuiApplication::palette().color(QPalette::Window);
+ auto text_color = option.palette.color(QPalette::Text);
+ panel.setAlpha(210);
+ painter->save();
+ painter->setRenderHint(QPainter::Antialiasing);
+ painter->setPen(option.state.testFlag(QStyle::State_Selected) ? QPen(text_color)
+ : Qt::NoPen);
+ painter->setBrush(panel);
+ painter->drawRoundedRect(QRectF(option.rect).adjusted(0.5, 0.5, -0.5, -0.5), 4, 4);
+
+ const QRect cover(option.rect.topLeft() + QPoint(CARD_PADDING, CARD_PADDING),
+ option.decorationSize);
+ index.data(Qt::DecorationRole).value().paint(painter, cover);
+
+ QRect line(cover.left(), cover.bottom() + 1 + CARD_PADDING, cover.width(),
+ option.fontMetrics.lineSpacing());
+ auto title_font = option.font;
+ title_font.setBold(true);
+ painter->setFont(title_font);
+ painter->setPen(text_color);
+ painter->drawText(line, Qt::AlignLeft | Qt::AlignVCenter,
+ painter->fontMetrics().elidedText(index.data().toString(), Qt::ElideRight,
+ line.width()));
+
+ line.translate(0, line.height());
+ const auto size = index.siblingAtColumn(GAME_SIZE_COLUMN).data().toString();
+ const auto details = QStringLiteral("%1 %2").arg(
+ index.siblingAtColumn(GAME_SERIAL_COLUMN).data().toString(),
+ index.siblingAtColumn(GAME_VERSION_COLUMN).data().toString());
+ text_color.setAlpha(200);
+ painter->setFont(option.font);
+ painter->setPen(text_color);
+ painter->drawText(line, Qt::AlignRight | Qt::AlignVCenter, size);
+ line.setRight(line.right() - option.fontMetrics.horizontalAdvance(size) - CARD_PADDING);
+ painter->drawText(line, Qt::AlignLeft | Qt::AlignVCenter,
+ option.fontMetrics.elidedText(details, Qt::ElideRight, line.width()));
+ painter->restore();
+ }
+
+private:
+ static constexpr int CARD_PADDING = 8;
+};
+
+// Shows the list's games as cards in the grid: both views share the items and the current game.
+inline void SetupGameGrid(QTreeWidget* list, QListView* grid) {
+ grid->setModel(list->model());
+ grid->setSelectionModel(list->selectionModel());
+ grid->setViewMode(QListView::IconMode);
+ grid->setMovement(QListView::Static);
+ grid->setResizeMode(QListView::Adjust);
+ grid->setSpacing(8);
+ grid->setUniformItemSizes(true);
+ grid->setItemDelegate(new GameGridDelegate(grid));
+ // A card also shows other columns (the size arrives late), which the grid does not repaint on.
+ QObject::connect(list->model(), &QAbstractItemModel::dataChanged, grid->viewport(),
+ qOverload<>(&QWidget::update));
+}
+
+#endif // GAME_VIEWS_H
diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp
index 397775635..10fb67943 100644
--- a/src/launcher/src/configurationListWidget.cpp
+++ b/src/launcher/src/configurationListWidget.cpp
@@ -8,7 +8,7 @@
#include "configurationEditDialog.h"
#include "configurationItem.h"
#include "gameContent.h"
-#include "gameListTreeWidget.h"
+#include "gameViews.h"
#include "inputMappingDialog.h"
#include "mainDialog.h"
#include "patchesDialog.h"
@@ -53,7 +53,6 @@
#include
#include
#include
-#include
#include
#include
#include
@@ -83,15 +82,6 @@ constexpr char CONF_ADDONS_DIR[] = "addons_dir";
constexpr char CONF_GLOBAL[] = "GlobalConfiguration";
constexpr char SAVE_DATA_DIR[] = "_SaveData";
-constexpr int GAME_NAME_COLUMN = 0;
-constexpr int GAME_SERIAL_COLUMN = 1;
-constexpr int GAME_VERSION_COLUMN = 2;
-constexpr int GAME_FIRMWARE_VERSION_COLUMN = 3;
-constexpr int GAME_SIZE_COLUMN = 4;
-constexpr int GAME_PATH_COLUMN = 5;
-constexpr int GAME_STATUS_COLUMN = 6;
-constexpr int GAME_COMMENT_COLUMN = 7;
-
static QString NormalizeGameDirectory(const QString& dir) {
const auto trimmed = dir.trimmed();
if (trimmed.isEmpty()) {
@@ -151,54 +141,6 @@ static QStringList SettingsStringList(const QVariant& value) {
return text.isEmpty() ? QStringList() : QStringList({text});
}
-class GameGridDelegate: public QStyledItemDelegate {
-public:
- using QStyledItemDelegate::QStyledItemDelegate;
-
- QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex&) const override {
- return option.decorationSize + QSize(16, 24 + option.fontMetrics.lineSpacing() * 2);
- }
-
-protected:
- void initStyleOption(QStyleOptionViewItem* option, const QModelIndex& index) const override {
- const auto image_size = option->decorationSize;
- QStyledItemDelegate::initStyleOption(option, index);
- option->decorationSize = image_size;
- option->decorationPosition = QStyleOptionViewItem::Top;
- option->decorationAlignment = Qt::AlignCenter;
- option->displayAlignment = Qt::AlignHCenter | Qt::AlignTop;
- option->features |= QStyleOptionViewItem::WrapText;
- }
-};
-
-static void ConfigureGameList(Ui::ConfigurationListWidget* ui) {
- ui->cfgs_list->setAlternatingRowColors(false);
- ui->cfgs_list->setAllColumnsShowFocus(true);
- ui->cfgs_list->setIndentation(0);
- ui->cfgs_list->setMouseTracking(false);
- ui->cfgs_list->setSelectionBehavior(QAbstractItemView::SelectRows);
- ui->cfgs_list->setSelectionMode(QAbstractItemView::SingleSelection);
- ui->cfgs_list->setTextElideMode(Qt::ElideMiddle);
- ui->cfgs_list->header()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
- ui->cfgs_list->header()->setHighlightSections(false);
- ui->cfgs_list->header()->setStretchLastSection(true);
-
- // Both presentations use the same items, icons and current game.
- ui->cfgs_grid->setModel(ui->cfgs_list->model());
- ui->cfgs_grid->setSelectionModel(ui->cfgs_list->selectionModel());
- ui->cfgs_grid->setViewMode(QListView::IconMode);
- ui->cfgs_grid->setMovement(QListView::Static);
- ui->cfgs_grid->setResizeMode(QListView::Adjust);
- ui->cfgs_grid->setSelectionMode(QAbstractItemView::SingleSelection);
- ui->cfgs_grid->setSelectionBehavior(QAbstractItemView::SelectRows);
- ui->cfgs_grid->setEditTriggers(QAbstractItemView::NoEditTriggers);
- ui->cfgs_grid->setSpacing(8);
- ui->cfgs_grid->setUniformItemSizes(true);
- ui->cfgs_grid->setWordWrap(true);
- ui->cfgs_grid->setItemDelegate(new GameGridDelegate(ui->cfgs_grid));
- ui->cfgs_grid->setContextMenuPolicy(Qt::CustomContextMenu);
-}
-
static void AddSaveDataDir(QStringList* dirs, QSet* seen, const QString& root,
const QString& title_id) {
const auto path = QDir(root).filePath(
@@ -252,7 +194,6 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
m_compatibility = new CompatibilityDatabase(
QCoreApplication::arguments().contains(QStringLiteral("--local")), this);
m_ui->setupUi(this);
- ConfigureGameList(m_ui);
m_ui->refresh_action->setShortcuts(QKeySequence::Refresh);
m_ui->refresh_button->setDefaultAction(m_ui->refresh_action);
@@ -266,8 +207,9 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
m_ui->edit_button->setEnabled(false);
m_ui->cfgs_list->setContextMenuPolicy(Qt::CustomContextMenu);
+ m_ui->cfgs_list->setAllColumnsShowFocus(true);
+ m_ui->cfgs_list->setTextElideMode(Qt::ElideMiddle);
m_ui->cfgs_list->setIconSize(QSize(48, 48));
- m_ui->cfgs_list->setRootIsDecorated(false);
m_ui->cfgs_list->setUniformRowHeights(true);
m_ui->cfgs_list->setSortingEnabled(true);
m_ui->cfgs_list->setColumnWidth(GAME_NAME_COLUMN, 320);
@@ -279,6 +221,8 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
m_ui->cfgs_list->setColumnWidth(GAME_STATUS_COLUMN, 150);
m_ui->cfgs_list->setColumnWidth(GAME_COMMENT_COLUMN, 240);
m_ui->cfgs_list->sortItems(GAME_NAME_COLUMN, Qt::AscendingOrder);
+ SetupGameGrid(m_ui->cfgs_list, m_ui->cfgs_grid);
+ m_ui->cfgs_grid->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_ui->refresh_action, &QAction::triggered, this,
&ConfigurationListWidget::ScanGameDirectory);
@@ -323,16 +267,12 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
connect(m_compatibility, &CompatibilityDatabase::Updated, this,
&ConfigurationListWidget::ApplyCompatibility);
- m_ui->cfgs_list->setDragDropMode(QAbstractItemView::NoDragDrop);
-
ReadSettings();
m_ui->grid_size_slider->setVisible(m_ui->grid_view_button->isChecked());
- const auto resize_grid = [this](int width) {
- m_ui->cfgs_grid->setIconSize(QSize(width, width * 9 / 16));
- };
- resize_grid(m_ui->grid_size_slider->value());
- connect(m_ui->grid_size_slider, &QSlider::valueChanged, this, [this, resize_grid](int width) {
- resize_grid(width);
+ const int grid_size = m_ui->grid_size_slider->value();
+ m_ui->cfgs_grid->setIconSize(QSize(grid_size, grid_size));
+ connect(m_ui->grid_size_slider, &QSlider::valueChanged, this, [this](int width) {
+ m_ui->cfgs_grid->setIconSize(QSize(width, width));
if (!m_ui->grid_size_slider->isSliderDown()) {
WriteSettings();
}
@@ -1274,7 +1214,7 @@ void ConfigurationListWidget::SelectItem(QTreeWidgetItem* witem) {
auto* item = static_cast(witem);
if (item == nullptr) {
m_selected_item = nullptr;
- m_ui->cfgs_list->SetBackgroundImage({});
+ m_ui->game_views->SetBackgroundImage({});
m_ui->edit_button->setEnabled(false);
m_ui->delete_button->setEnabled(false);
emit Select();
@@ -1285,7 +1225,7 @@ void ConfigurationListWidget::SelectItem(QTreeWidgetItem* witem) {
m_ui->edit_button->setEnabled(!item->IsRunning());
m_selected_item = item;
- m_ui->cfgs_list->SetBackgroundImage(item->GetInfo().basedir);
+ m_ui->game_views->SetBackgroundImage(item->GetInfo().basedir);
emit Select();
}
diff --git a/src/launcher/styles/launcher.qss b/src/launcher/styles/launcher.qss
index d49d811a4..495788cd6 100644
--- a/src/launcher/styles/launcher.qss
+++ b/src/launcher/styles/launcher.qss
@@ -1,32 +1,24 @@
+/* The views get their transparent background from their own styleSheet in
+ configuration_list_widget.ui: Breeze paints black behind the scrollbars of views without one. */
+QTreeWidget#cfgs_list,
QListView#cfgs_grid {
- background: palette(base);
- border: 1px solid palette(mid);
- color: palette(text);
-}
-
-QListView#cfgs_grid::item {
- padding: 8px;
-}
-
-GameListTreeWidget#cfgs_list {
- background: transparent;
border: 1px solid palette(mid);
color: palette(text);
outline: 0;
}
-GameListTreeWidget#cfgs_list::item {
+QTreeWidget#cfgs_list::item {
background: transparent;
border-bottom: 1px solid palette(mid);
padding: 4px;
}
-GameListTreeWidget#cfgs_list::item:selected {
+QTreeWidget#cfgs_list::item:selected {
background: palette(highlight);
color: palette(highlighted-text);
}
-GameListTreeWidget#cfgs_list QHeaderView::section {
+QTreeWidget#cfgs_list QHeaderView::section {
background: palette(button);
color: palette(button-text);
border: 0;
diff --git a/tests/LauncherGameViewsTests.cpp b/tests/LauncherGameViewsTests.cpp
new file mode 100644
index 000000000..cdbb4da75
--- /dev/null
+++ b/tests/LauncherGameViewsTests.cpp
@@ -0,0 +1,122 @@
+#include "gameViews.h"
+#include "launcherTheme.h"
+#include "ui_configuration_list_widget.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace {
+
+void Check(bool value, const char *message) {
+ if (!value) {
+ std::fprintf(stderr, "LauncherGameViewsTests: failed: %s\n", message);
+ std::abort();
+ }
+}
+
+// The red pic0 dominates a pixel only where nothing opaque is painted over the background.
+bool ShowsBackground(const QColor &color) {
+ return color.red() > color.green() + 40 && color.red() > color.blue() + 40;
+}
+
+// The card panel is the app's (white) Window color over the background.
+bool ShowsPanel(const QColor &color) {
+ return !ShowsBackground(color) &&
+ qAbs(color.lightness() - QApplication::palette().color(QPalette::Window).lightness()) < 32;
+}
+
+class PaintCounter final : public QObject {
+public:
+ int paints = 0;
+
+protected:
+ bool eventFilter(QObject * /*watched*/, QEvent *event) override {
+ paints += static_cast(event->type() == QEvent::Paint);
+ return false;
+ }
+};
+
+} // namespace
+
+int main(int argc, char **argv) {
+ qputenv("QT_QPA_PLATFORM", "offscreen");
+ QApplication application(argc, argv);
+ // Breeze, where installed, paints its own color behind scroll bars; Fusion's are opaque.
+ const bool breeze = QApplication::setStyle(QStringLiteral("Breeze")) != nullptr;
+ // A fixed light palette, whatever the desktop colors: a black panel then stands out.
+ QApplication::setPalette(QPalette(Qt::white));
+ LauncherTheme::Initialize(application);
+
+ QTemporaryDir game;
+ Check(QDir(game.path()).mkpath(QStringLiteral("sce_sys")), "cannot create sce_sys");
+ QImage pic0(64, 36, QImage::Format_RGB32);
+ pic0.fill(Qt::red);
+ Check(pic0.save(game.filePath(QStringLiteral("sce_sys/pic0.png"))), "cannot write pic0.png");
+
+ QWidget host;
+ Ui::ConfigurationListWidget ui;
+ ui.setupUi(&host);
+ auto *views = ui.game_views;
+ auto *list = ui.cfgs_list;
+ auto *grid = ui.cfgs_grid;
+ SetupGameGrid(list, grid);
+ grid->setIconSize(QSize(64, 64));
+
+ QPixmap cover(64, 64);
+ cover.fill(Qt::blue);
+ auto *item = new QTreeWidgetItem(list, {QStringLiteral("Game")});
+ item->setIcon(GAME_NAME_COLUMN, QIcon(cover));
+
+ host.resize(480, 360);
+ views->SetBackgroundImage(game.path());
+ host.show();
+ QApplication::processEvents();
+ const QPoint empty(views->width() - 60, views->height() - 30);
+
+ Check(ShowsBackground(views->grab().toImage().pixelColor(empty)),
+ "the game background is hidden behind the list view");
+
+ views->setCurrentWidget(grid);
+ QApplication::processEvents();
+ const auto image = views->grab().toImage();
+ Check(ShowsBackground(image.pixelColor(empty)),
+ "the game background is hidden behind the grid view");
+
+ const auto card = grid->visualRect(grid->model()->index(0, GAME_NAME_COLUMN))
+ .translated(grid->viewport()->mapTo(views, QPoint()));
+ Check(card.width() == 64 + 16, "the card is not the cover plus its padding wide");
+ Check(image.pixelColor(card.topLeft() + QPoint(8 + 32, 8 + 32)) == QColor(Qt::blue),
+ "the cover is not drawn inside the card padding");
+ Check(ShowsPanel(image.pixelColor(card.topLeft() + QPoint(4, 40))),
+ "the card panel does not back its padding");
+
+ PaintCounter counter;
+ grid->viewport()->installEventFilter(&counter);
+ item->setText(GAME_SIZE_COLUMN, QStringLiteral("1.00 GB"));
+ QApplication::processEvents();
+ Check(counter.paints > 0, "the card does not repaint when the game size arrives");
+
+ if (breeze) {
+ for (QAbstractScrollArea *view : {static_cast(grid),
+ static_cast(list)}) {
+ view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
+ views->setCurrentWidget(view);
+ QApplication::processEvents();
+ const auto *bar = view->verticalScrollBar();
+ Check(ShowsBackground(views->grab().toImage().pixelColor(
+ bar->mapTo(views, QPoint(2, bar->height() - 20)))),
+ "Breeze hides the game background behind a scroll bar");
+ }
+ }
+
+ std::printf("LauncherGameViewsTests: passed%s\n", breeze ? "" : " (Breeze checks skipped)");
+ return 0;
+}