diff --git a/src/graphics/presentation/systemOverlay.cpp b/src/graphics/presentation/systemOverlay.cpp index 22076f211..ce6f0a002 100644 --- a/src/graphics/presentation/systemOverlay.cpp +++ b/src/graphics/presentation/systemOverlay.cpp @@ -195,6 +195,37 @@ std::mutex g_trophy_notification_mutex; std::deque g_trophy_notifications; std::atomic g_trophy_notification_active {false}; +std::vector WrapTrophyTitle(std::string_view text, float font_size, float width) { + std::vector lines; + auto* const font = ImGui::GetFont(); + for (;;) { + const auto newline = text.find('\n'); + const auto paragraph = text.substr(0, newline); + auto line = paragraph.substr(0, 0); + for (auto start = paragraph.find_first_not_of(" \t\r"); start != std::string_view::npos;) { + const auto end = std::min(paragraph.find_first_of(" \t\r", start), paragraph.size()); + const auto word = paragraph.substr(start, end - start); + const std::string_view candidate(line.empty() ? word.begin() : line.begin(), + word.end()); + // Keep an oversized word intact; the toast's clip rectangle handles overflow. + if (!line.empty() && font->CalcTextSizeA(font_size, std::numeric_limits::max(), + 0.0f, candidate.data(), + candidate.data() + candidate.size()).x > width) { + lines.push_back(line); + line = word; + } else { + line = candidate; + } + start = paragraph.find_first_not_of(" \t\r", end); + } + lines.push_back(line); + if (newline == std::string_view::npos) { + return lines; + } + text.remove_prefix(newline + 1); + } +} + void ClearInputEvents() { std::scoped_lock lock(g_input_mutex); g_input_events.clear(); @@ -1101,7 +1132,26 @@ struct SystemOverlay::Impl { 0.65f, 1.25f); const float width = std::max( 0.0f, std::min(520.0f * screen_scale, static_cast(frame_extent.width) - 32.0f)); - const float height = 104.0f * screen_scale; + constexpr std::array GRADE_COLORS { + IM_COL32(161, 225, 245, 255), IM_COL32(255, 207, 82, 255), IM_COL32(211, 220, 232, 255), + IM_COL32(211, 142, 91, 255)}; + const int grade = trophy_notification->grade; + const bool has_grade = grade > 0 && grade <= static_cast(GRADE_COLORS.size()); + const float pad = 10.0f * screen_scale; + const float base_height = 104.0f * screen_scale; + const float icon_size = base_height - 2.0f * pad; + const float text_offset = trophy_notification_image != nullptr + ? pad + icon_size + 18.0f * screen_scale + : 20.0f * screen_scale; + const float title_offset = text_offset + (has_grade ? 30.0f * screen_scale : 0.0f); + const float title_size = 26.0f * screen_scale; + const float line_height = 30.0f * screen_scale; + const float text_padding = 16.0f * screen_scale; + const auto title_lines = WrapTrophyTitle(trophy_notification->name, title_size, + width - title_offset - text_padding); + const float block_height = + static_cast(title_lines.size()) * line_height + 28.0f * screen_scale; + const float height = std::max(base_height, block_height + 2.0f * text_padding); const float rounding = 14.0f * screen_scale; const float fade_in = std::clamp(age / 0.22f, 0.0f, 1.0f); const float fade_out = std::clamp((TOAST_LIFETIME - age) / 0.4f, 0.0f, 1.0f); @@ -1121,26 +1171,19 @@ struct SystemOverlay::Impl { draw->AddRect(top_left, bottom_right, fade(255, 255, 255, 28), rounding, ImDrawFlags_RoundCornersAll, 1.0f); - const float pad = 10.0f * screen_scale; - const float icon_size = height - 2.0f * pad; - float text_left = top_left.x + 20.0f * screen_scale; + const float text_left = top_left.x + text_offset; if (trophy_notification_image != nullptr) { - const ImVec2 icon_min {top_left.x + pad, top_left.y + pad}; + const ImVec2 icon_min {top_left.x + pad, top_left.y + (height - icon_size) * 0.5f}; const ImVec2 icon_max {icon_min.x + icon_size, icon_min.y + icon_size}; draw->AddImageRounded(trophy_notification_image->GetTexRef(), icon_min, icon_max, {0, 0}, {1, 1}, fade(255, 255, 255, 255), 8.0f * screen_scale); draw->AddRect(icon_min, icon_max, fade(255, 255, 255, 40), 8.0f * screen_scale, ImDrawFlags_RoundCornersAll, 1.0f); - text_left = icon_max.x + 18.0f * screen_scale; } - constexpr std::array GRADE_COLORS {IM_COL32(161, 225, 245, 255), IM_COL32(255, 207, 82, 255), - IM_COL32(211, 220, 232, 255), - IM_COL32(211, 142, 91, 255)}; - const int grade = trophy_notification->grade; - const float title_y = top_left.y + height * 0.5f - 25.0f * screen_scale; - float title_x = text_left; - if (grade > 0 && grade <= static_cast(GRADE_COLORS.size())) { + const float title_y = top_left.y + (height - block_height) * 0.5f; + const float title_x = top_left.x + title_offset; + if (has_grade) { // Small cup glyph tinted with the trophy grade. const ImU32 cup = ImGui::GetColorU32(GRADE_COLORS[grade - 1], alpha); const float u = screen_scale; @@ -1155,12 +1198,15 @@ struct SystemOverlay::Impl { draw->AddRectFilled({cx - 1.5f * u, cy + 2.0f * u}, {cx + 1.5f * u, cy + 7.0f * u}, cup); draw->AddRectFilled({cx - 5.0f * u, cy + 7.0f * u}, {cx + 5.0f * u, cy + 10.0f * u}, cup, 1.5f * u); - title_x += 30.0f * u; } - draw->PushClipRect(top_left, {bottom_right.x - 16.0f * screen_scale, bottom_right.y}, true); - draw->AddText(nullptr, 26.0f * screen_scale, {title_x, title_y}, - fade(255, 255, 255, 255), trophy_notification->name.c_str()); - draw->AddText(nullptr, 20.0f * screen_scale, {text_left, title_y + 38.0f * screen_scale}, + draw->PushClipRect(top_left, {bottom_right.x - text_padding, bottom_right.y}, true); + float line_y = title_y; + for (const auto line : title_lines) { + draw->AddText(nullptr, title_size, {title_x, line_y}, fade(255, 255, 255, 255), + line.data(), line.data() + line.size()); + line_y += line_height; + } + draw->AddText(nullptr, 20.0f * screen_scale, {text_left, line_y + 8.0f * screen_scale}, fade(176, 180, 192, 255), "Trophy earned!"); draw->PopClipRect(); } diff --git a/src/launcher/src/trophyViewerDialog.cpp b/src/launcher/src/trophyViewerDialog.cpp index 748cfa71c..88ec1ef07 100644 --- a/src/launcher/src/trophyViewerDialog.cpp +++ b/src/launcher/src/trophyViewerDialog.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +27,8 @@ #include #include +#include + namespace { static const QRegularExpression TrophyFilePattern(QStringLiteral("^trophy(\\d+)\\.ucp$")); @@ -54,17 +58,115 @@ static QString GradeToText(int grade) { } } -static QString TrophyTooltip(const Common::Trophies::Trophy& trophy) { - QStringList lines {QString::fromStdString(trophy.name), - QString::fromStdString(trophy.description), - QObject::tr("Grade: %1").arg(GradeToText(trophy.grade))}; - if (trophy.hidden) { - lines.append(QObject::tr("Hidden trophy")); +QString TrophyName(int id, const Common::Trophies::Trophy& trophy, bool masked) { + return masked ? QObject::tr("Hidden trophy") + : trophy.name.empty() ? QObject::tr("Trophy %1").arg(id) + : QString::fromStdString(trophy.name); +} + +QPixmap TrophyIcon(const Common::Trophies::Trophy& trophy, bool unlocked, bool masked, int size) { + QPixmap pixmap; + if (masked) { + pixmap = QPixmap(QStringLiteral(":/icons/hidden-trophy.png")); + } else if (pixmap.loadFromData(reinterpret_cast(trophy.icon_png.data()), + static_cast(trophy.icon_png.size())) && + !unlocked) { + auto image = pixmap.toImage().convertToFormat(QImage::Format_ARGB32); + for (int y = 0; y < image.height(); ++y) { + auto* line = reinterpret_cast(image.scanLine(y)); + for (int x = 0; x < image.width(); ++x) { + const int gray = qGray(line[x]); + line[x] = qRgba(gray, gray, gray, qAlpha(line[x])); + } + } + pixmap = QPixmap::fromImage(image); } - if (trophy.has_reward && !trophy.reward.empty()) { - lines.append(QObject::tr("Reward: %1").arg(QString::fromStdString(trophy.reward))); + return pixmap.isNull() + ? pixmap + : pixmap.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation); +} + +QString TrophyEarnedDate(int id, const Common::Trophies::UnlockData& unlocks) { + const auto found = unlocks.timestamps.find(id); + if (found == unlocks.timestamps.end()) { + return {}; } - return lines.join(QLatin1Char('\n')); + return QDateTime::fromMSecsSinceEpoch( + static_cast((found->second - Common::Trophies::UnixEpochTick) / 1000)) + .toLocalTime() + .toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); +} + +void ShowTrophyInspector(QWidget* parent, int id, const Common::Trophies::Trophy& trophy, + bool unlocked, const QString& earned_date) { + QDialog dialog(parent); + dialog.setWindowTitle(QObject::tr("Trophy Details")); + dialog.resize(640, 380); + auto* layout = new QVBoxLayout(&dialog); + auto* top = new QHBoxLayout; + auto* icon = new QLabel(&dialog); + icon->setFixedSize(148, 148); + icon->setAlignment(Qt::AlignCenter); + auto* text_box = new QVBoxLayout; + auto* name = new QLabel(&dialog); + name->setTextFormat(Qt::PlainText); + name->setStyleSheet(QStringLiteral("font-size: 26px;")); + name->setWordWrap(true); + auto* subtitle = new QLabel(QObject::tr("Hidden trophy"), &dialog); + subtitle->setStyleSheet(QStringLiteral("font-size: 16px;")); + auto* reveal = new QPushButton(QObject::tr("Show Hidden Trophy"), &dialog); + text_box->addWidget(name); + text_box->addWidget(subtitle); + text_box->addWidget(reveal, 0, Qt::AlignLeft); + text_box->addStretch(1); + top->addWidget(icon); + top->addSpacing(16); + top->addLayout(text_box, 1); + layout->addLayout(top); + + auto* grid = new QGridLayout; + grid->setColumnStretch(1, 1); + const auto make_row = [&](const QString& label, const QString& text = {}) { + auto* key = new QLabel(label, &dialog); + auto* value = new QLabel(text, &dialog); + key->setStyleSheet(QStringLiteral("font-size: 17px;")); + value->setStyleSheet(QStringLiteral("font-size: 17px;")); + value->setTextFormat(Qt::PlainText); + value->setWordWrap(true); + const int row = grid->rowCount(); + grid->addWidget(key, row, 0); + grid->addWidget(value, row, 1); + return value; + }; + auto* grade = make_row(QObject::tr("Grade")); + make_row(QObject::tr("Status"), unlocked ? QObject::tr("Earned") : QObject::tr("Not earned")); + make_row(QObject::tr("Earned date"), + unlocked && !earned_date.isEmpty() ? earned_date : QStringLiteral("-")); + auto* details = make_row(QObject::tr("Details")); + auto* reward = + trophy.has_reward && !trophy.reward.empty() ? make_row(QObject::tr("Reward")) : nullptr; + layout->addLayout(grid); + layout->addStretch(1); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + const auto refresh = [&](bool masked) { + icon->setPixmap(TrophyIcon(trophy, unlocked, masked, 148)); + name->setText(TrophyName(id, trophy, masked)); + subtitle->setVisible(trophy.hidden && !masked); + reveal->setVisible(masked); + grade->setText(masked ? QObject::tr("Hidden trophy") : GradeToText(trophy.grade)); + details->setText(masked || trophy.description.empty() + ? QStringLiteral("-") + : QString::fromStdString(trophy.description)); + if (reward != nullptr) { + reward->setText(masked ? QStringLiteral("-") : QString::fromStdString(trophy.reward)); + } + }; + QObject::connect(reveal, &QPushButton::clicked, &dialog, [&] { refresh(false); }); + refresh(trophy.hidden && !unlocked); + dialog.exec(); } } // namespace @@ -337,13 +439,13 @@ bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runt } QStringList errors; for (const auto& file: trophy_files) { - const auto package = + auto package = Common::Trophies::LoadPackage(GameContent::ToPath(file), info.console_language); if (package.trophies.empty()) { errors.append(tr("Could not read trophy package %1.").arg(QFileInfo(file).fileName())); continue; } - const auto unlocks = LoadUnlocks(info, runtime_directory, file); + auto unlocks = LoadUnlocks(info, runtime_directory, file); const auto counts = Common::Trophies::GetProgress(package, unlocks); auto* page = new QWidget(m_tabs); auto* page_layout = new QVBoxLayout(page); @@ -395,8 +497,10 @@ bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runt const auto hidden_locked = trophy.hidden && !trophy_unlocked; auto* item = new QListWidgetItem(list); item->setSizeHint(QSize(900, 96)); + item->setData(Qt::UserRole, id); auto* card = new QWidget(list); + card->setAttribute(Qt::WA_TransparentForMouseEvents); card->setStyleSheet( QStringLiteral("QWidget#card { background: palette(base); border-radius: 12px; }")); card->setObjectName(QStringLiteral("card")); @@ -404,26 +508,12 @@ bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runt auto* icon = new QLabel(card); icon->setFixedSize(76, 76); icon->setAlignment(Qt::AlignCenter); - QPixmap pixmap; - if (hidden_locked) { - pixmap = QPixmap(QStringLiteral(":/icons/hidden-trophy.png")); - } else if (pixmap.loadFromData(reinterpret_cast(trophy.icon_png.data()), - static_cast(trophy.icon_png.size())) && - !trophy_unlocked) { - pixmap = - QPixmap::fromImage(pixmap.toImage().convertToFormat(QImage::Format_Grayscale8)); - } - if (!pixmap.isNull()) { - icon->setPixmap( - pixmap.scaled(76, 76, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - } + icon->setPixmap(TrophyIcon(trophy, trophy_unlocked, hidden_locked, 76)); row->addWidget(icon); - auto* left = new QVBoxLayout; - const auto name_text = hidden_locked ? tr("Hidden trophy") - : trophy.name.empty() ? tr("Trophy %1").arg(id) - : QString::fromStdString(trophy.name); - auto* name = new QLabel(name_text, card); + auto* left = new QVBoxLayout; + auto* name = new QLabel(TrophyName(id, trophy, hidden_locked), card); + name->setTextFormat(Qt::PlainText); name->setStyleSheet(QStringLiteral("font-size: 20px; font-weight: bold;")); left->addWidget(name); if (!hidden_locked) { @@ -440,15 +530,9 @@ bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runt row->addLayout(left, 2); - auto* right = new QVBoxLayout; - QString date_text; - if (const auto found = unlocks.timestamps.find(id); found != unlocks.timestamps.end()) { - date_text = QDateTime::fromMSecsSinceEpoch( - static_cast( - (found->second - Common::Trophies::UnixEpochTick) / 1000)) - .toLocalTime() - .toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); - } else if (trophy_unlocked) { + auto* right = new QVBoxLayout; + auto date_text = TrophyEarnedDate(id, unlocks); + if (date_text.isEmpty() && trophy_unlocked) { date_text = tr("Earned"); } auto* date = new QLabel(date_text, card); @@ -456,15 +540,22 @@ bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runt date->setStyleSheet(QStringLiteral("font-size: 15px;")); auto* detail = new QLabel( hidden_locked ? QString {} : QString::fromStdString(trophy.description), card); + detail->setTextFormat(Qt::PlainText); detail->setWordWrap(true); detail->setAlignment(Qt::AlignLeft | Qt::AlignTop); detail->setStyleSheet(QStringLiteral("font-size: 17px;")); right->addWidget(date); right->addWidget(detail, 1); row->addLayout(right, 3); - card->setToolTip(hidden_locked ? name_text : TrophyTooltip(trophy)); list->setItemWidget(item, card); } + connect(list, &QListWidget::itemClicked, this, + [this, trophies = std::move(package.trophies), + unlocks = std::move(unlocks)](QListWidgetItem* item) { + const int id = item->data(Qt::UserRole).toInt(); + ShowTrophyInspector(this, id, trophies.at(id), unlocks.unlocked.contains(id), + TrophyEarnedDate(id, unlocks)); + }); m_tabs->addTab(page, QString::fromStdString(package.title)); } m_tabs->tabBar()->setVisible(m_tabs->count() > 1);