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 -
gameListTreeWidget.h
+ GameViews + QStackedWidget +
gameViews.h
+ 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; +}