add modrinth modpack filter

Signed-off-by: Trial97 <alexandru.tripon97@gmail.com>
This commit is contained in:
Trial97 2024-10-04 17:06:47 +03:00
parent dfe3cd849d
commit 859fac604b
No known key found for this signature in database
GPG Key ID: 55EF5DA53DB36318
13 changed files with 170 additions and 88 deletions

View File

@ -123,8 +123,19 @@ QDebug operator<<(QDebug debug, const Version& v)
first = false; first = false;
} }
debug.nospace() << " ]" debug.nospace() << " ]" << " }";
<< " }";
return debug; return debug;
} }
bool checkMcVersions(std::list<Version> filter, QStringList value)
{
bool valid = false;
for (auto mcVersion : filter) {
if (value.contains(mcVersion.toString())) {
valid = true;
break;
}
}
return filter.empty() || valid;
}

View File

@ -161,3 +161,5 @@ class Version {
void parse(); void parse();
}; };
bool checkMcVersions(std::list<Version> filter, QStringList value);

View File

@ -88,9 +88,6 @@ class FlameAPI : public NetworkResourceAPI {
public: public:
static std::optional<QString> getStaticSearchURL(SearchArgs const& args) static std::optional<QString> getStaticSearchURL(SearchArgs const& args)
{ {
auto gameVersionStr =
args.versions.has_value() ? QString("gameVersion=%1").arg(args.versions.value().front().toString()) : QString();
QStringList get_arguments; QStringList get_arguments;
get_arguments.append(QString("classId=%1").arg(getClassId(args.type))); get_arguments.append(QString("classId=%1").arg(getClassId(args.type)));
get_arguments.append(QString("index=%1").arg(args.offset)); get_arguments.append(QString("index=%1").arg(args.offset));
@ -100,12 +97,13 @@ class FlameAPI : public NetworkResourceAPI {
if (args.sorting.has_value()) if (args.sorting.has_value())
get_arguments.append(QString("sortField=%1").arg(args.sorting.value().index)); get_arguments.append(QString("sortField=%1").arg(args.sorting.value().index));
get_arguments.append("sortOrder=desc"); get_arguments.append("sortOrder=desc");
if (args.loaders.has_value()) if (args.loaders.has_value() && args.loaders.value() != 0)
get_arguments.append(QString("modLoaderTypes=%1").arg(getModLoaderFilters(args.loaders.value()))); get_arguments.append(QString("modLoaderTypes=%1").arg(getModLoaderFilters(args.loaders.value())));
if (args.categoryIds.has_value() && !args.categoryIds->empty()) if (args.categoryIds.has_value() && !args.categoryIds->empty())
get_arguments.append(QString("categoryIds=[%1]").arg(args.categoryIds->join(","))); get_arguments.append(QString("categoryIds=[%1]").arg(args.categoryIds->join(",")));
get_arguments.append(gameVersionStr); if (args.versions.has_value() && !args.versions.value().empty())
get_arguments.append(QString("gameVersion=%1").arg(args.versions.value().front().toString()));
return "https://api.curseforge.com/v1/mods/search?gameId=432&" + get_arguments.join('&'); return "https://api.curseforge.com/v1/mods/search?gameId=432&" + get_arguments.join('&');
} }

View File

@ -91,6 +91,8 @@ class ModrinthAPI : public NetworkResourceAPI {
return "resourcepack"; return "resourcepack";
case ModPlatform::ResourceType::SHADER_PACK: case ModPlatform::ResourceType::SHADER_PACK:
return "shader"; return "shader";
case ModPlatform::ResourceType::MODPACK:
return "modpack";
default: default:
qWarning() << "Invalid resource type for Modrinth API!"; qWarning() << "Invalid resource type for Modrinth API!";
break; break;
@ -99,13 +101,13 @@ class ModrinthAPI : public NetworkResourceAPI {
return ""; return "";
} }
[[nodiscard]] QString createFacets(SearchArgs const& args) const [[nodiscard]] static QString createFacets(SearchArgs const& args)
{ {
QStringList facets_list; QStringList facets_list;
if (args.loaders.has_value()) if (args.loaders.has_value() && args.loaders.value() != 0)
facets_list.append(QString("[%1]").arg(getModLoaderFilters(args.loaders.value()))); facets_list.append(QString("[%1]").arg(getModLoaderFilters(args.loaders.value())));
if (args.versions.has_value()) if (args.versions.has_value() && !args.versions.value().empty())
facets_list.append(QString("[%1]").arg(getGameVersionsArray(args.versions.value()))); facets_list.append(QString("[%1]").arg(getGameVersionsArray(args.versions.value())));
if (args.side.has_value()) { if (args.side.has_value()) {
auto side = getSideFilters(args.side.value()); auto side = getSideFilters(args.side.value());
@ -121,9 +123,9 @@ class ModrinthAPI : public NetworkResourceAPI {
} }
public: public:
[[nodiscard]] inline auto getSearchURL(SearchArgs const& args) const -> std::optional<QString> override static std::optional<QString> getStaticSearchURL(SearchArgs const& args)
{ {
if (args.loaders.has_value()) { if (args.loaders.has_value() && args.loaders.value() != 0) {
if (!validateModLoaders(args.loaders.value())) { if (!validateModLoaders(args.loaders.value())) {
qWarning() << "Modrinth - or our interface - does not support any the provided mod loaders!"; qWarning() << "Modrinth - or our interface - does not support any the provided mod loaders!";
return {}; return {};
@ -142,6 +144,11 @@ class ModrinthAPI : public NetworkResourceAPI {
return BuildConfig.MODRINTH_PROD_URL + "/search?" + get_arguments.join('&'); return BuildConfig.MODRINTH_PROD_URL + "/search?" + get_arguments.join('&');
}; };
[[nodiscard]] inline auto getSearchURL(SearchArgs const& args) const -> std::optional<QString> override
{
return getStaticSearchURL(args);
}
inline auto getInfoURL(QString const& id) const -> std::optional<QString> override inline auto getInfoURL(QString const& id) const -> std::optional<QString> override
{ {
return BuildConfig.MODRINTH_PROD_URL + "/project/" + id; return BuildConfig.MODRINTH_PROD_URL + "/project/" + id;
@ -164,7 +171,7 @@ class ModrinthAPI : public NetworkResourceAPI {
.arg(BuildConfig.MODRINTH_PROD_URL, args.pack.addonId.toString(), get_arguments.isEmpty() ? "" : "?", get_arguments.join('&')); .arg(BuildConfig.MODRINTH_PROD_URL, args.pack.addonId.toString(), get_arguments.isEmpty() ? "" : "?", get_arguments.join('&'));
}; };
auto getGameVersionsArray(std::list<Version> mcVersions) const -> QString static QString getGameVersionsArray(std::list<Version> mcVersions)
{ {
QString s; QString s;
for (auto& ver : mcVersions) { for (auto& ver : mcVersions) {

View File

@ -135,6 +135,21 @@ auto loadIndexedVersion(QJsonObject& obj) -> ModpackVersion
if (!gameVersions.isEmpty()) { if (!gameVersions.isEmpty()) {
file.gameVersion = Json::ensureString(gameVersions[0]); file.gameVersion = Json::ensureString(gameVersions[0]);
} }
auto loaders = Json::requireArray(obj, "loaders");
for (auto loader : loaders) {
if (loader == "neoforge")
file.loaders |= ModPlatform::NeoForge;
else if (loader == "forge")
file.loaders |= ModPlatform::Forge;
else if (loader == "cauldron")
file.loaders |= ModPlatform::Cauldron;
else if (loader == "liteloader")
file.loaders |= ModPlatform::LiteLoader;
else if (loader == "fabric")
file.loaders |= ModPlatform::Fabric;
else if (loader == "quilt")
file.loaders |= ModPlatform::Quilt;
}
file.version_type = ModPlatform::IndexedVersionType(Json::requireString(obj, "version_type")); file.version_type = ModPlatform::IndexedVersionType(Json::requireString(obj, "version_type"));
file.changelog = Json::ensureString(obj, "changelog"); file.changelog = Json::ensureString(obj, "changelog");

View File

@ -87,6 +87,7 @@ struct ModpackVersion {
QString gameVersion; QString gameVersion;
ModPlatform::IndexedVersionType version_type; ModPlatform::IndexedVersionType version_type;
QString changelog; QString changelog;
ModPlatform::ModLoaderTypes loaders = {};
QString id; QString id;
QString project_id; QString project_id;

View File

@ -104,18 +104,6 @@ bool checkSide(QString filter, QString value)
return filter.isEmpty() || value.isEmpty() || filter == "both" || value == "both" || filter == value; return filter.isEmpty() || value.isEmpty() || filter == "both" || value == "both" || filter == value;
} }
bool checkMcVersions(std::list<Version> filter, QStringList value)
{
bool valid = false;
for (auto mcVersion : filter) {
if (value.contains(mcVersion.toString())) {
valid = true;
break;
}
}
return filter.empty() || valid;
}
bool ModModel::checkFilters(ModPlatform::IndexedPack::Ptr pack) bool ModModel::checkFilters(ModPlatform::IndexedPack::Ptr pack)
{ {
if (!m_filter) if (!m_filter)

View File

@ -34,6 +34,7 @@
*/ */
#include "FlamePage.h" #include "FlamePage.h"
#include "Version.h"
#include "modplatform/flame/FlamePackIndex.h" #include "modplatform/flame/FlamePackIndex.h"
#include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/CustomMessageBox.h"
#include "ui/widgets/ModFilterWidget.h" #include "ui/widgets/ModFilterWidget.h"
@ -144,16 +145,6 @@ void FlamePage::triggerSearch()
m_fetch_progress.watch(listModel->activeSearchJob().get()); m_fetch_progress.watch(listModel->activeSearchJob().get());
} }
bool checkMcVersions(std::list<Version> filter, QString value)
{
for (auto mcVersion : filter) {
if (value == mcVersion.toString()) {
return true;
}
}
return filter.empty();
}
bool checkVersionFilters(const Flame::IndexedVersion& v, std::shared_ptr<ModFilterWidget::Filter> filter) bool checkVersionFilters(const Flame::IndexedVersion& v, std::shared_ptr<ModFilterWidget::Filter> filter)
{ {
if (!filter) if (!filter)
@ -161,7 +152,7 @@ bool checkVersionFilters(const Flame::IndexedVersion& v, std::shared_ptr<ModFilt
return ((!filter->loaders || !v.loaders || filter->loaders & v.loaders) && // loaders return ((!filter->loaders || !v.loaders || filter->loaders & v.loaders) && // loaders
(filter->releases.empty() || // releases (filter->releases.empty() || // releases
std::find(filter->releases.cbegin(), filter->releases.cend(), v.version_type) != filter->releases.cend()) && std::find(filter->releases.cbegin(), filter->releases.cend(), v.version_type) != filter->releases.cend()) &&
checkMcVersions(filter->versions, v.mcVersion)); // mcVersions} checkMcVersions(filter->versions, { v.mcVersion })); // mcVersions}
} }
void FlamePage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelIndex prev) void FlamePage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelIndex prev)

View File

@ -152,33 +152,26 @@ void ModpackListModel::performPaginatedSearch()
return; return;
} }
} // TODO: Move to standalone API } // TODO: Move to standalone API
auto netJob = makeShared<NetJob>("Modrinth::SearchModpack", APPLICATION->network()); ResourceAPI::SortingMethod sort{};
auto searchAllUrl = QString(BuildConfig.MODRINTH_PROD_URL + sort.name = currentSort;
"/search?" auto searchUrl = ModrinthAPI::getStaticSearchURL({ ModPlatform::ResourceType::MODPACK, nextSearchOffset, currentSearchTerm, sort,
"offset=%1&" m_filter->loaders, m_filter->versions, "", m_filter->categoryIds });
"limit=%2&"
"query=%3&"
"index=%4&"
"facets=[[\"project_type:modpack\"]]")
.arg(nextSearchOffset)
.arg(m_modpacks_per_page)
.arg(currentSearchTerm)
.arg(currentSort);
netJob->addNetAction(Net::ApiDownload::makeByteArray(QUrl(searchAllUrl), m_all_response)); auto netJob = makeShared<NetJob>("Modrinth::SearchModpack", APPLICATION->network());
netJob->addNetAction(Net::ApiDownload::makeByteArray(QUrl(searchUrl.value()), m_allResponse));
QObject::connect(netJob.get(), &NetJob::succeeded, this, [this] { QObject::connect(netJob.get(), &NetJob::succeeded, this, [this] {
QJsonParseError parse_error_all{}; QJsonParseError parseError{};
QJsonDocument doc_all = QJsonDocument::fromJson(*m_all_response, &parse_error_all); QJsonDocument doc = QJsonDocument::fromJson(*m_allResponse, &parseError);
if (parse_error_all.error != QJsonParseError::NoError) { if (parseError.error != QJsonParseError::NoError) {
qWarning() << "Error while parsing JSON response from " << debugName() << " at " << parse_error_all.offset qWarning() << "Error while parsing JSON response from " << debugName() << " at " << parseError.offset
<< " reason: " << parse_error_all.errorString(); << " reason: " << parseError.errorString();
qWarning() << *m_all_response; qWarning() << *m_allResponse;
return; return;
} }
searchRequestFinished(doc_all); searchRequestFinished(doc);
}); });
QObject::connect(netJob.get(), &NetJob::failed, this, &ModpackListModel::searchRequestFailed); QObject::connect(netJob.get(), &NetJob::failed, this, &ModpackListModel::searchRequestFailed);
@ -220,19 +213,23 @@ static auto sortFromIndex(int index) -> QString
} }
} }
void ModpackListModel::searchWithTerm(const QString& term, const int sort) void ModpackListModel::searchWithTerm(const QString& term,
const int sort,
std::shared_ptr<ModFilterWidget::Filter> filter,
bool filterChanged)
{ {
if (sort > 5 || sort < 0) if (sort > 5 || sort < 0)
return; return;
auto sort_str = sortFromIndex(sort); auto sort_str = sortFromIndex(sort);
if (currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort_str) { if (currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort_str && !filterChanged) {
return; return;
} }
currentSearchTerm = term; currentSearchTerm = term;
currentSort = sort_str; currentSort = sort_str;
m_filter = filter;
refresh(); refresh();
} }

View File

@ -71,7 +71,7 @@ class ModpackListModel : public QAbstractListModel {
/* Ask the API for more information */ /* Ask the API for more information */
void fetchMore(const QModelIndex& parent) override; void fetchMore(const QModelIndex& parent) override;
void refresh(); void refresh();
void searchWithTerm(const QString& term, int sort); void searchWithTerm(const QString& term, int sort, std::shared_ptr<ModFilterWidget::Filter> filter, bool filterChanged);
[[nodiscard]] bool hasActiveSearchJob() const { return jobPtr && jobPtr->isRunning(); } [[nodiscard]] bool hasActiveSearchJob() const { return jobPtr && jobPtr->isRunning(); }
[[nodiscard]] Task::Ptr activeSearchJob() { return hasActiveSearchJob() ? jobPtr : nullptr; } [[nodiscard]] Task::Ptr activeSearchJob() { return hasActiveSearchJob() ? jobPtr : nullptr; }
@ -112,12 +112,13 @@ class ModpackListModel : public QAbstractListModel {
QString currentSearchTerm; QString currentSearchTerm;
QString currentSort; QString currentSort;
std::shared_ptr<ModFilterWidget::Filter> m_filter;
int nextSearchOffset = 0; int nextSearchOffset = 0;
enum SearchState { None, CanPossiblyFetchMore, ResetRequested, Finished } searchState = None; enum SearchState { None, CanPossiblyFetchMore, ResetRequested, Finished } searchState = None;
Task::Ptr jobPtr; Task::Ptr jobPtr;
std::shared_ptr<QByteArray> m_all_response = std::make_shared<QByteArray>(); std::shared_ptr<QByteArray> m_allResponse = std::make_shared<QByteArray>();
QByteArray m_specific_response; QByteArray m_specific_response;
int m_modpacks_per_page = 20; int m_modpacks_per_page = 20;

View File

@ -35,6 +35,8 @@
*/ */
#include "ModrinthPage.h" #include "ModrinthPage.h"
#include "Version.h"
#include "modplatform/modrinth/ModrinthAPI.h"
#include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/CustomMessageBox.h"
#include "ui_ModrinthPage.h" #include "ui_ModrinthPage.h"
@ -58,6 +60,7 @@ ModrinthPage::ModrinthPage(NewInstanceDialog* dialog, QWidget* parent)
: QWidget(parent), ui(new Ui::ModrinthPage), dialog(dialog), m_fetch_progress(this, false) : QWidget(parent), ui(new Ui::ModrinthPage), dialog(dialog), m_fetch_progress(this, false)
{ {
ui->setupUi(this); ui->setupUi(this);
createFilterWidget();
ui->searchEdit->installEventFilter(this); ui->searchEdit->installEventFilter(this);
m_model = new Modrinth::ModpackListModel(this); m_model = new Modrinth::ModpackListModel(this);
@ -126,6 +129,16 @@ bool ModrinthPage::eventFilter(QObject* watched, QEvent* event)
return QObject::eventFilter(watched, event); return QObject::eventFilter(watched, event);
} }
bool checkVersionFilters(const Modrinth::ModpackVersion& v, std::shared_ptr<ModFilterWidget::Filter> filter)
{
if (!filter)
return true;
return ((!filter->loaders || !v.loaders || filter->loaders & v.loaders) && // loaders
(filter->releases.empty() || // releases
std::find(filter->releases.cbegin(), filter->releases.cend(), v.version_type) != filter->releases.cend()) &&
checkMcVersions(filter->versions, { v.gameVersion })); // gameVersion}
}
void ModrinthPage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelIndex prev) void ModrinthPage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelIndex prev)
{ {
ui->versionSelectionBox->clear(); ui->versionSelectionBox->clear();
@ -190,7 +203,7 @@ void ModrinthPage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelI
} else } else
updateUI(); updateUI();
if (!current.versionsLoaded) { if (!current.versionsLoaded || m_filterWidget->changed()) {
qDebug() << "Loading modrinth modpack versions"; qDebug() << "Loading modrinth modpack versions";
auto netJob = new NetJob(QString("Modrinth::PackVersions(%1)").arg(current.name), APPLICATION->network()); auto netJob = new NetJob(QString("Modrinth::PackVersions(%1)").arg(current.name), APPLICATION->network());
@ -221,6 +234,16 @@ void ModrinthPage::onSelectionChanged(QModelIndex curr, [[maybe_unused]] QModelI
qDebug() << *response; qDebug() << *response;
qWarning() << "Error while reading modrinth modpack version: " << e.cause(); qWarning() << "Error while reading modrinth modpack version: " << e.cause();
} }
auto pred = [this](const Modrinth::ModpackVersion& v) { return !checkVersionFilters(v, m_filterWidget->getFilter()); };
#if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0)
current.versions.removeIf(pred);
#else
for (auto it = current.versions.begin(); it != current.versions.end();)
if (pred(*it))
it = current.versions.erase(it);
else
++it;
#endif
for (auto version : current.versions) { for (auto version : current.versions) {
auto release_type = version.version_type.isValid() ? QString(" [%1]").arg(version.version_type.toString()) : ""; auto release_type = version.version_type.isValid() ? QString(" [%1]").arg(version.version_type.toString()) : "";
auto mcVersion = !version.gameVersion.isEmpty() && !version.name.contains(version.gameVersion) auto mcVersion = !version.gameVersion.isEmpty() && !version.name.contains(version.gameVersion)
@ -338,7 +361,11 @@ void ModrinthPage::suggestCurrent()
void ModrinthPage::triggerSearch() void ModrinthPage::triggerSearch()
{ {
m_model->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex()); ui->packView->selectionModel()->setCurrentIndex({}, QItemSelectionModel::SelectionFlag::ClearAndSelect);
ui->packView->clearSelection();
ui->packDescription->clear();
ui->versionSelectionBox->clear();
m_model->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex(), m_filterWidget->getFilter(), m_filterWidget->changed());
m_fetch_progress.watch(m_model->activeSearchJob().get()); m_fetch_progress.watch(m_model->activeSearchJob().get());
} }
@ -361,3 +388,25 @@ QString ModrinthPage::getSerachTerm() const
{ {
return ui->searchEdit->text(); return ui->searchEdit->text();
} }
void ModrinthPage::createFilterWidget()
{
auto widget = ModFilterWidget::create(nullptr, false, this);
m_filterWidget.swap(widget);
auto old = ui->splitter->replaceWidget(0, m_filterWidget.get());
// because we replaced the widget we also need to delete it
if (old) {
delete old;
}
connect(ui->filterButton, &QPushButton::clicked, this, [this] { m_filterWidget->setHidden(!m_filterWidget->isHidden()); });
connect(m_filterWidget.get(), &ModFilterWidget::filterChanged, this, &ModrinthPage::triggerSearch);
auto response = std::make_shared<QByteArray>();
m_categoriesTask = ModrinthAPI::getModCategories(response);
QObject::connect(m_categoriesTask.get(), &Task::succeeded, [this, response]() {
auto categories = ModrinthAPI::loadCategories(response, "modpack");
m_filterWidget->setCategories(categories);
});
m_categoriesTask->start();
}

View File

@ -41,6 +41,7 @@
#include "modplatform/modrinth/ModrinthPackManifest.h" #include "modplatform/modrinth/ModrinthPackManifest.h"
#include "ui/pages/modplatform/ModpackProviderBasePage.h" #include "ui/pages/modplatform/ModpackProviderBasePage.h"
#include "ui/widgets/ModFilterWidget.h"
#include "ui/widgets/ProgressWidget.h" #include "ui/widgets/ProgressWidget.h"
#include <QTimer> #include <QTimer>
@ -87,6 +88,7 @@ class ModrinthPage : public QWidget, public ModpackProviderBasePage {
void onSelectionChanged(QModelIndex first, QModelIndex second); void onSelectionChanged(QModelIndex first, QModelIndex second);
void onVersionSelectionChanged(int index); void onVersionSelectionChanged(int index);
void triggerSearch(); void triggerSearch();
void createFilterWidget();
private: private:
Ui::ModrinthPage* ui; Ui::ModrinthPage* ui;
@ -100,4 +102,7 @@ class ModrinthPage : public QWidget, public ModpackProviderBasePage {
// Used to do instant searching with a delay to cache quick changes // Used to do instant searching with a delay to cache quick changes
QTimer m_search_timer; QTimer m_search_timer;
unique_qobject_ptr<ModFilterWidget> m_filterWidget;
Task::Ptr m_categoriesTask;
}; };

View File

@ -12,42 +12,59 @@
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item> <item>
<widget class="QLineEdit" name="searchEdit"> <layout class="QHBoxLayout" name="horizontalLayout">
<property name="placeholderText">
<string>Search and filter ...</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout">
<item> <item>
<widget class="QListView" name="packView"> <widget class="QLineEdit" name="searchEdit">
<property name="horizontalScrollBarPolicy"> <property name="placeholderText">
<enum>Qt::ScrollBarAlwaysOff</enum> <string>Search and filter...</string>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="iconSize">
<size>
<width>48</width>
<height>48</height>
</size>
</property> </property>
</widget> </widget>
</item> </item>
<item> <item>
<widget class="ProjectDescriptionPage" name="packDescription"> <widget class="QPushButton" name="filterButton">
<property name="openExternalLinks"> <property name="text">
<bool>true</bool> <string>Filter</string>
</property>
<property name="openLinks">
<bool>true</bool>
</property> </property>
</widget> </widget>
</item> </item>
</layout> </layout>
</item> </item>
<item>
<widget class="QSplitter" name="splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QWidget" name="widget" native="true"/>
<widget class="QListView" name="packView">
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="iconSize">
<size>
<width>48</width>
<height>48</height>
</size>
</property>
</widget>
<widget class="ProjectDescriptionPage" name="packDescription">
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="openLinks">
<bool>true</bool>
</property>
</widget>
</widget>
</item>
<item> <item>
<layout class="QHBoxLayout"> <layout class="QHBoxLayout">
<item> <item>