// Copyright (C) 2023 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 #include "extensionmanagerwidget.h" #include "extensionmanagertr.h" #include "extensionsbrowser.h" #include "extensionsmodel.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace Core; using namespace Utils; using namespace StyleHelper; using namespace WelcomePageHelpers; namespace ExtensionManager::Internal { Q_LOGGING_CATEGORY(widgetLog, "qtc.extensionmanager.widget", QtWarningMsg) constexpr TextFormat h5TF {Theme::Token_Text_Default, UiElement::UiElementH5}; constexpr TextFormat h6TF {h5TF.themeColor, UiElement::UiElementH6}; constexpr TextFormat h6CapitalTF {Theme::Token_Text_Muted, UiElement::UiElementH6Capital}; constexpr TextFormat contentTF {Theme::Token_Text_Default, UiElement::UiElementBody2}; static QLabel *sectionTitle(const TextFormat &tf, const QString &title) { QLabel *label = tfLabel(tf, true); label->setText(title); return label; }; static QWidget *toScrollableColumn(QWidget *widget) { widget->setContentsMargins(SpacingTokens::ExVPaddingGapXl, SpacingTokens::ExVPaddingGapXl, SpacingTokens::ExVPaddingGapXl, SpacingTokens::ExVPaddingGapXl); widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Minimum); auto scrollArea = new QScrollArea; scrollArea->setWidget(widget); scrollArea->setWidgetResizable(true); scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scrollArea->setFrameStyle(QFrame::NoFrame); return scrollArea; }; class CollapsingWidget : public QWidget { public: explicit CollapsingWidget(QWidget *parent = nullptr) : QWidget(parent) { setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); } void setWidth(int width) { m_width = width; setVisible(width > 0); updateGeometry(); } QSize sizeHint() const override { return {m_width, 0}; } private: int m_width = 100; }; class HeadingWidget : public QWidget { static constexpr int dividerH = 16; Q_OBJECT public: explicit HeadingWidget(QWidget *parent = nullptr) : QWidget(parent) { m_icon = new QLabel; m_icon->setFixedSize(iconBgSizeBig); static const TextFormat titleTF {Theme::Token_Text_Default, UiElementH4}; static const TextFormat vendorTF {Theme::Token_Text_Accent, UiElementLabelMedium}; static const TextFormat dlTF {Theme::Token_Text_Muted, vendorTF.uiElement}; static const TextFormat detailsTF {Theme::Token_Text_Default, UiElementBody2}; m_title = tfLabel(titleTF); m_vendor = new Button({}, Button::SmallLink); m_vendor->setContentsMargins({}); m_divider = new QLabel; m_divider->setFixedSize(1, dividerH); WelcomePageHelpers::setBackgroundColor(m_divider, dlTF.themeColor); m_dlIcon = new QLabel; const QPixmap dlIcon = Icon({{":/extensionmanager/images/download.png", dlTF.themeColor}}, Icon::Tint).pixmap(); m_dlIcon->setPixmap(dlIcon); m_dlCount = tfLabel(dlTF); m_dlCount->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); m_details = tfLabel(detailsTF); installButton = new Button(Tr::tr("Install..."), Button::MediumPrimary); installButton->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); installButton->hide(); using namespace Layouting; Row { m_icon, Column { m_title, st, Row { m_vendor, Widget { bindTo(&m_dlCountItems), Row { Space(SpacingTokens::HGapXs), m_divider, Space(SpacingTokens::HGapXs), m_dlIcon, Space(SpacingTokens::HGapXxs), m_dlCount, noMargin, spacing(0), }, }, }, st, m_details, spacing(0), }, Column { installButton, st, }, noMargin, spacing(SpacingTokens::ExPaddingGapL), }.attachTo(this); setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Maximum); m_dlCountItems->setVisible(false); connect(installButton, &QAbstractButton::pressed, this, &HeadingWidget::pluginInstallationRequested); connect(m_vendor, &QAbstractButton::pressed, this, [this]() { emit vendorClicked(m_currentVendor); }); update({}); } void update(const QModelIndex ¤t) { if (!current.isValid()) return; m_icon->setPixmap(itemIcon(current, SizeBig)); const QString name = current.data(RoleName).toString(); m_title->setText(name); m_currentVendor = current.data(RoleVendor).toString(); m_vendor->setText(m_currentVendor); const int dlCount = current.data(RoleDownloadCount).toInt(); const bool showDlCount = dlCount > 0; if (showDlCount) m_dlCount->setText(QString::number(dlCount)); m_dlCountItems->setVisible(showDlCount); const auto pluginData = current.data(RolePlugins).value(); if (current.data(RoleItemType).toInt() == ItemTypePack) { const int pluginsCount = pluginData.count(); const QString details = Tr::tr("Pack contains %n plugins.", nullptr, pluginsCount); m_details->setText(details); } else { m_details->setText({}); } const ItemType itemType = current.data(RoleItemType).value(); const bool isPack = itemType == ItemTypePack; const bool isRemotePlugin = !(isPack || pluginSpecForName(name)); installButton->setVisible(isRemotePlugin && !pluginData.empty()); if (installButton->isVisible()) installButton->setToolTip(pluginData.constFirst().second); } signals: void pluginInstallationRequested(); void vendorClicked(const QString &vendor); private: QLabel *m_icon; QLabel *m_title; Button *m_vendor; QLabel *m_divider; QLabel *m_dlIcon; QLabel *m_dlCount; QWidget *m_dlCountItems; QLabel *m_details; QAbstractButton *installButton; QString m_currentVendor; }; class PluginStatusWidget : public QWidget { public: explicit PluginStatusWidget(QWidget *parent = nullptr) : QWidget(parent) { m_label = new InfoLabel; m_checkBox = new QCheckBox(Tr::tr("Load on start")); m_restartButton = new Button(Tr::tr("Restart Now"), Button::MediumPrimary); m_restartButton->setVisible(false); m_pluginView.hide(); using namespace Layouting; Column { m_label, m_checkBox, m_restartButton, }.attachTo(this); connect(m_checkBox, &QCheckBox::clicked, this, [this](bool checked) { ExtensionSystem::PluginSpec *spec = pluginSpecForName(m_pluginName); if (spec == nullptr) return; const bool doIt = m_pluginView.data().setPluginsEnabled({spec}, checked); if (doIt) { m_restartButton->show(); ExtensionSystem::PluginManager::writeSettings(); } else { m_checkBox->setChecked(!checked); } }); connect(m_restartButton, &QAbstractButton::clicked, ICore::instance(), &ICore::restart, Qt::QueuedConnection); update(); } void setPluginName(const QString &name) { m_pluginName = name; update(); } private: void update() { const ExtensionSystem::PluginSpec *spec = pluginSpecForName(m_pluginName); setVisible(spec != nullptr); if (spec == nullptr) return; if (spec->hasError()) { m_label->setType(InfoLabel::Error); m_label->setText(Tr::tr("Error")); } else if (spec->state() == ExtensionSystem::PluginSpec::Running) { m_label->setType(InfoLabel::Ok); m_label->setText(Tr::tr("Loaded")); } else { m_label->setType(InfoLabel::NotOk); m_label->setText(Tr::tr("Not loaded")); } m_checkBox->setChecked(spec->isRequired() || spec->isEnabledBySettings()); m_checkBox->setEnabled(!spec->isRequired()); } InfoLabel *m_label; QCheckBox *m_checkBox; QAbstractButton *m_restartButton; QString m_pluginName; ExtensionSystem::PluginView m_pluginView{this}; }; class TagList : public QWidget { Q_OBJECT public: explicit TagList(QWidget *parent = nullptr) : QWidget(parent) { QHBoxLayout *layout = new QHBoxLayout(this); setLayout(layout); layout->setContentsMargins({}); m_signalMapper = new QSignalMapper(this); connect(m_signalMapper, &QSignalMapper::mappedString, this, &TagList::tagSelected); } void setTags(const QStringList &tags) { if (m_container) { delete m_container; m_container = nullptr; } if (!tags.empty()) { m_container = new QWidget(this); layout()->addWidget(m_container); using namespace Layouting; Flow flow {}; flow.setNoMargins(); flow.setSpacing(SpacingTokens::HGapXs); for (const QString &tag : tags) { QAbstractButton *tagButton = new Button(tag, Button::Tag); connect(tagButton, &QAbstractButton::clicked, m_signalMapper, qOverload<>(&QSignalMapper::map)); m_signalMapper->setMapping(tagButton, tag); flow.addItem(tagButton); } flow.attachTo(m_container); } updateGeometry(); } signals: void tagSelected(const QString &tag); private: QWidget *m_container = nullptr; QSignalMapper *m_signalMapper; }; class ExtensionManagerWidget final : public Core::ResizeSignallingWidget { public: ExtensionManagerWidget(); private: void updateView(const QModelIndex ¤t); void fetchAndInstallPlugin(const QUrl &url); void fetchAndDisplayImage(const QUrl &url); QString m_currentItemName; ExtensionsBrowser *m_extensionBrowser; CollapsingWidget *m_secondaryDescriptionWidget; HeadingWidget *m_headingWidget; QWidget *m_primaryContent; QWidget *m_secondaryContent; QLabel *m_description; QLabel *m_linksTitle; QLabel *m_links; QLabel *m_imageTitle; QLabel *m_image; QBuffer m_imageDataBuffer; QMovie m_imageMovie; QLabel *m_tagsTitle; TagList *m_tags; QLabel *m_platformsTitle; QLabel *m_platforms; QLabel *m_dependenciesTitle; QLabel *m_dependencies; QLabel *m_packExtensionsTitle; QLabel *m_packExtensions; PluginStatusWidget *m_pluginStatus; PluginsData m_currentItemPlugins; Tasking::TaskTreeRunner m_dlTaskTreeRunner; Tasking::TaskTreeRunner m_imgTaskTreeRunner; }; ExtensionManagerWidget::ExtensionManagerWidget() { m_extensionBrowser = new ExtensionsBrowser; auto descriptionColumns = new QWidget; m_secondaryDescriptionWidget = new CollapsingWidget; m_headingWidget = new HeadingWidget; m_description = tfLabel(contentTF, false); m_description->setWordWrap(true); m_linksTitle = sectionTitle(h6CapitalTF, Tr::tr("More information")); m_links = tfLabel(contentTF, false); m_links->setOpenExternalLinks(true); m_imageTitle = sectionTitle(h6CapitalTF, {}); m_image = new QLabel; m_imageMovie.setDevice(&m_imageDataBuffer); using namespace Layouting; auto primary = new QWidget; const auto spL = spacing(SpacingTokens::VPaddingL); Column { m_description, Column { m_linksTitle, m_links, spL }, Column { m_imageTitle, m_image, spL }, st, noMargin, spacing(SpacingTokens::ExVPaddingGapXl), }.attachTo(primary); m_primaryContent = toScrollableColumn(primary); m_tagsTitle = sectionTitle(h6TF, Tr::tr("Tags")); m_tags = new TagList; m_platformsTitle = sectionTitle(h6TF, Tr::tr("Platforms")); m_platforms = tfLabel(contentTF, false); m_dependenciesTitle = sectionTitle(h6TF, Tr::tr("Dependencies")); m_dependencies = tfLabel(contentTF, false); m_packExtensionsTitle = sectionTitle(h6TF, Tr::tr("Extensions in pack")); m_packExtensions = tfLabel(contentTF, false); m_pluginStatus = new PluginStatusWidget; auto secondary = new QWidget; const auto spXxs = spacing(SpacingTokens::VPaddingXxs); Column { sectionTitle(h6CapitalTF, Tr::tr("Extension details")), Column { Column { m_tagsTitle, m_tags, spXxs }, Column { m_platformsTitle, m_platforms, spXxs }, Column { m_dependenciesTitle, m_dependencies, spXxs }, Column { m_packExtensionsTitle, m_packExtensions, spXxs }, spacing(SpacingTokens::VPaddingL), }, st, noMargin, spacing(SpacingTokens::ExVPaddingGapXl), }.attachTo(secondary); m_secondaryContent = toScrollableColumn(secondary); Row { WelcomePageHelpers::createRule(Qt::Vertical), Column { m_secondaryContent, m_pluginStatus, }, noMargin, spacing(0), }.attachTo(m_secondaryDescriptionWidget); Row { WelcomePageHelpers::createRule(Qt::Vertical), Row { Column { Column { m_headingWidget, customMargins(SpacingTokens::ExVPaddingGapXl, SpacingTokens::ExVPaddingGapXl, SpacingTokens::ExVPaddingGapXl, SpacingTokens::ExVPaddingGapXl), }, m_primaryContent, }, }, m_secondaryDescriptionWidget, noMargin, spacing(0), }.attachTo(descriptionColumns); Row { Space(SpacingTokens::ExVPaddingGapXl), m_extensionBrowser, descriptionColumns, noMargin, spacing(0), }.attachTo(this); WelcomePageHelpers::setBackgroundColor(this, Theme::Token_Background_Default); connect(m_extensionBrowser, &ExtensionsBrowser::itemSelected, this, &ExtensionManagerWidget::updateView); connect(this, &ResizeSignallingWidget::resized, this, [this](const QSize &size) { const int intendedBrowserColumnWidth = size.width() - 580; m_extensionBrowser->adjustToWidth(intendedBrowserColumnWidth); const bool secondaryDescriptionVisible = size.width() > 970; const int secondaryDescriptionWidth = secondaryDescriptionVisible ? 264 : 0; m_secondaryDescriptionWidget->setWidth(secondaryDescriptionWidth); }); connect(m_headingWidget, &HeadingWidget::pluginInstallationRequested, this, [this](){ fetchAndInstallPlugin(QUrl::fromUserInput(m_currentItemPlugins.constFirst().second)); }); connect(m_tags, &TagList::tagSelected, m_extensionBrowser, &ExtensionsBrowser::setFilter); connect(m_headingWidget, &HeadingWidget::vendorClicked, m_extensionBrowser, &ExtensionsBrowser::setFilter); updateView({}); } void ExtensionManagerWidget::updateView(const QModelIndex ¤t) { m_headingWidget->update(current); const bool showContent = current.isValid(); m_primaryContent->setVisible(showContent); m_secondaryContent->setVisible(showContent); m_headingWidget->setVisible(showContent); m_pluginStatus->setVisible(showContent); if (!showContent) return; m_currentItemName = current.data().toString(); const bool isPack = current.data(RoleItemType) == ItemTypePack; m_pluginStatus->setPluginName(isPack ? QString() : m_currentItemName); m_currentItemPlugins = current.data(RolePlugins).value(); auto toContentParagraph = [](const QString &text) { const QString pHtml = QString::fromLatin1("

%2

") .arg(contentTF.lineHeight()).arg(text); return pHtml; }; { const TextData textData = current.data(RoleDescriptionText).value(); const bool hasDescription = !textData.isEmpty(); if (hasDescription) { const QString headerCssTemplate = ";margin-top:%1;margin-bottom:%2;padding-top:0;padding-bottom:0;"; const QString h4Css = fontToCssProperties(uiFont(UiElementH4)) + headerCssTemplate.arg(0).arg(SpacingTokens::VGapL); const QString h5Css = fontToCssProperties(uiFont(UiElementH5)) + headerCssTemplate.arg(SpacingTokens::ExVPaddingGapXl) .arg(SpacingTokens::VGapL); QString descriptionHtml; for (const TextData::Type &text : textData) { if (text.second.isEmpty()) continue; const QString paragraph = QString::fromLatin1("
%2
%3") .arg(descriptionHtml.isEmpty() ? h4Css : h5Css) .arg(text.first) .arg(toContentParagraph(text.second.join("
"))); descriptionHtml.append(paragraph); } descriptionHtml.prepend(QString::fromLatin1("") .arg(creatorColor(Theme::Token_Text_Default).name())); descriptionHtml.append(""); m_description->setText(descriptionHtml); } m_description->setVisible(hasDescription); const LinksData linksData = current.data(RoleDescriptionLinks).value(); const bool hasLinks = !linksData.isEmpty(); if (hasLinks) { QString linksHtml; const QStringList links = transform(linksData, [](const LinksData::Type &link) { const QString anchor = link.first.isEmpty() ? link.second : link.first; return QString::fromLatin1(R"(%3 >)") .arg(link.second) .arg(creatorColor(Theme::Token_Text_Accent).name()) .arg(anchor); }); linksHtml = links.join("
"); m_links->setText(toContentParagraph(linksHtml)); } m_linksTitle->setVisible(hasLinks); m_links->setVisible(hasLinks); m_imgTaskTreeRunner.reset(); m_imageMovie.stop(); m_imageDataBuffer.close(); m_image->clear(); const ImagesData imagesData = current.data(RoleDescriptionImages).value(); const bool hasImages = !imagesData.isEmpty(); if (hasImages) { const ImagesData::Type &image = imagesData.constFirst(); // Only show one image m_imageTitle->setText(image.first); fetchAndDisplayImage(image.second); } m_imageTitle->setVisible(hasImages); m_image->setVisible(hasImages); } { const QStringList tags = current.data(RoleTags).toStringList(); m_tags->setTags(tags); const bool hasTags = !tags.isEmpty(); m_tagsTitle->setVisible(hasTags); m_tags->setVisible(hasTags); const QStringList platforms = current.data(RolePlatforms).toStringList(); const bool hasPlatforms = !platforms.isEmpty(); if (hasPlatforms) m_platforms->setText(toContentParagraph(platforms.join("
"))); m_platformsTitle->setVisible(hasPlatforms); m_platforms->setVisible(hasPlatforms); const QStringList dependencies = current.data(RoleDependencies).toStringList(); const bool hasDependencies = !dependencies.isEmpty(); if (hasDependencies) m_dependencies->setText(toContentParagraph(dependencies.join("
"))); m_dependenciesTitle->setVisible(hasDependencies); m_dependencies->setVisible(hasDependencies); const PluginsData plugins = current.data(RolePlugins).value(); const bool hasExtensions = isPack && !plugins.isEmpty(); if (hasExtensions) { const QStringList extensions = transform(plugins, &QPair::first); m_packExtensions->setText(toContentParagraph(extensions.join("
"))); } m_packExtensionsTitle->setVisible(hasExtensions); m_packExtensions->setVisible(hasExtensions); } } void ExtensionManagerWidget::fetchAndInstallPlugin(const QUrl &url) { using namespace Tasking; struct StorageStruct { StorageStruct() { progressDialog.reset(new QProgressDialog( Tr::tr("Downloading..."), Tr::tr("Cancel"), 0, 0, ICore::dialogParent())); progressDialog->setWindowTitle(Tr::tr("Download Extension")); progressDialog->setWindowModality(Qt::ApplicationModal); progressDialog->setFixedSize(progressDialog->sizeHint()); progressDialog->setAutoClose(false); progressDialog->show(); // TODO: Should not be needed. Investigate possible QT_BUG } std::unique_ptr progressDialog; QByteArray packageData; QUrl url; }; Storage storage; const auto onQuerySetup = [url, storage](NetworkQuery &query) { storage->url = url; query.setRequest(QNetworkRequest(url)); query.setNetworkAccessManager(NetworkAccessManager::instance()); }; const auto onQueryDone = [storage](const NetworkQuery &query, DoneWith result) { storage->progressDialog->close(); if (result == DoneWith::Success) { storage->packageData = query.reply()->readAll(); } else { QMessageBox::warning( ICore::dialogParent(), Tr::tr("Download Error"), Tr::tr("Cannot download extension") + "\n\n" + storage->url.toString() + "\n\n" + Tr::tr("Code: %1.").arg(query.reply()->error())); } }; const auto onPluginInstallation = [storage]() { if (storage->packageData.isEmpty()) return; const FilePath source = FilePath::fromUrl(storage->url); TempFileSaver saver(TemporaryDirectory::masterDirectoryPath() + "/XXXXXX" + source.fileName()); saver.write(storage->packageData); if (saver.finalize(ICore::dialogParent())) executePluginInstallWizard(saver.filePath());; }; Group group{ storage, NetworkQueryTask{onQuerySetup, onQueryDone}, onGroupDone(onPluginInstallation), }; m_dlTaskTreeRunner.start(group); } void ExtensionManagerWidget::fetchAndDisplayImage(const QUrl &url) { using namespace Tasking; struct StorageStruct { QByteArray imageData; QUrl url; }; Storage storage; const auto onFetchSetup = [url, storage](NetworkQuery &query) { storage->url = url; query.setRequest(QNetworkRequest(url)); query.setNetworkAccessManager(NetworkAccessManager::instance()); qCDebug(widgetLog).noquote() << "Sending image request:" << url.toDisplayString(); }; const auto onFetchDone = [storage](const NetworkQuery &query, DoneWith result) { qCDebug(widgetLog) << "Got image QNetworkReply:" << query.reply()->error(); if (result == DoneWith::Success) storage->imageData = query.reply()->readAll(); }; const auto onShowImage = [storage, this]() { if (storage->imageData.isEmpty()) return; m_imageDataBuffer.setData(storage->imageData); qCDebug(widgetLog).noquote() << "Image reponse size:" << QLocale::system().formattedDataSize( m_imageDataBuffer.size()); if (!m_imageDataBuffer.open(QIODevice::ReadOnly)) return; QImageReader reader(&m_imageDataBuffer); const bool animated = reader.supportsAnimation(); if (animated) { m_image->setMovie(&m_imageMovie); m_imageMovie.start(); } else { const QPixmap pixmap = QPixmap::fromImage(reader.read()); m_image->setPixmap(pixmap); } qCDebug(widgetLog) << "Image dimensions:" << reader.size(); qCDebug(widgetLog) << "Image is animated:" << animated; }; Group group{ storage, NetworkQueryTask{onFetchSetup, onFetchDone}, onGroupDone(onShowImage), }; m_imgTaskTreeRunner.start(group); } QWidget *createExtensionManagerWidget() { return new ExtensionManagerWidget; } } // ExtensionManager::Internal #include "extensionmanagerwidget.moc"