From c9d5504fa5e08c10f7470b2a4721c27f73d60a4a Mon Sep 17 00:00:00 2001 From: Alessandro Portale Date: Mon, 8 Jul 2024 11:07:21 +0200 Subject: [PATCH] ExtensionManager: Add filtering and sorting As per UI design spec, this change inserts two widgets between the search field and the extensions list. One for filtering, one for sorting. The widgets are derived from QComboBox, with custom paintEvent. The selected filter/sort options are applied to the custom SortFilterProxyModel which is inserted between the "search" proxy model and the view. Fixes: QTCREATORBUG-31179 Change-Id: Ia7768fa4f31b5bf5682918e724a3a299e851eb46 Reviewed-by: hjk --- .../extensionmanager/extensionmanager.qrc | 4 + .../extensionmanager/extensionsbrowser.cpp | 251 +++++++++++++++--- .../extensionmanager/images/filter.png | Bin 0 -> 155 bytes .../extensionmanager/images/filter@2x.png | Bin 0 -> 235 bytes src/plugins/extensionmanager/images/sort.png | Bin 0 -> 164 bytes .../extensionmanager/images/sort@2x.png | Bin 0 -> 266 bytes src/tools/icons/qtcreatoricons.svg | 38 +++ 7 files changed, 263 insertions(+), 30 deletions(-) create mode 100644 src/plugins/extensionmanager/images/filter.png create mode 100644 src/plugins/extensionmanager/images/filter@2x.png create mode 100644 src/plugins/extensionmanager/images/sort.png create mode 100644 src/plugins/extensionmanager/images/sort@2x.png diff --git a/src/plugins/extensionmanager/extensionmanager.qrc b/src/plugins/extensionmanager/extensionmanager.qrc index c156db17e7d..c0325bbb20b 100644 --- a/src/plugins/extensionmanager/extensionmanager.qrc +++ b/src/plugins/extensionmanager/extensionmanager.qrc @@ -8,11 +8,15 @@ images/extensionbig@2x.png images/extensionsmall.png images/extensionsmall@2x.png + images/filter.png + images/filter@2x.png images/mode_extensionmanager_mask.png images/mode_extensionmanager_mask@2x.png images/packbig.png images/packbig@2x.png images/packsmall.png images/packsmall@2x.png + images/sort.png + images/sort@2x.png diff --git a/src/plugins/extensionmanager/extensionsbrowser.cpp b/src/plugins/extensionmanager/extensionsbrowser.cpp index 99c13fabdba..ebeb7de4622 100644 --- a/src/plugins/extensionmanager/extensionsbrowser.cpp +++ b/src/plugins/extensionmanager/extensionsbrowser.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -59,6 +60,96 @@ constexpr int gapSize = HGapL; constexpr int itemWidth = 330; constexpr int cellWidth = itemWidth + gapSize; +class OptionChooser : public QComboBox +{ +public: + OptionChooser(const FilePath &iconMask, const QString &textTemplate, QWidget *parent = nullptr) + : QComboBox(parent) + , m_iconDefault(Icon({{iconMask, m_colorDefault}}, Icon::Tint).icon()) + , m_iconActive(Icon({{iconMask, m_colorActive}}, Icon::Tint).icon()) + , m_textTemplate(textTemplate) + { + setMouseTracking(true); + connect(this, &QComboBox::currentIndexChanged, this, &QWidget::updateGeometry); + } + +protected: + void paintEvent([[maybe_unused]] QPaintEvent *event) override + { + // +------------+------+---------+---------------+------------+ + // | | | | (VPaddingXs) | | + // | | | +---------------+ | + // |(HPaddingXs)|(icon)|(HGapXxs)||(HPaddingXs)| + // | | | +---------------+ | + // | | | | (VPaddingXs) | | + // +------------+------+---------+---------------+------------+ + + const bool active = currentIndex() > 0; + const bool hover = underMouse(); + const TextFormat &tF = (active || hover) ? m_itemActiveTf : m_itemDefaultTf; + + const QRect iconRect(HPaddingXs, 0, m_iconSize.width(), height()); + const int textX = iconRect.right() + 1 + HGapXxs; + const QRect textRect(textX, VPaddingXs, + width() - HPaddingXs - textX, tF.lineHeight()); + + QPainter p(this); + (active ? m_iconActive : m_iconDefault).paint(&p, iconRect); + p.setPen(tF.color()); + p.setFont(tF.font()); + const QString elidedText = p.fontMetrics().elidedText(currentFormattedText(), + Qt::ElideRight, + textRect.width() + HPaddingXs); + p.drawText(textRect, tF.drawTextFlags, elidedText); + } + + void enterEvent(QEnterEvent *event) override + { + QComboBox::enterEvent(event); + update(); + } + + void leaveEvent(QEvent *event) override + { + QComboBox::leaveEvent(event); + update(); + } + +private: + QSize sizeHint() const override + { + const QFontMetrics fm(m_itemDefaultTf.font()); + const int textWidth = fm.horizontalAdvance(currentFormattedText()); + const int width = + HPaddingXs + + m_iconSize.width() + + HGapXxs + + textWidth + + HPaddingXs; + const int height = + VPaddingXs + + m_itemDefaultTf.lineHeight() + + VPaddingXs; + return {width, height}; + } + + QString currentFormattedText() const + { + return m_textTemplate.arg(currentText()); + } + + constexpr static Theme::Color m_colorDefault = Theme::Token_Text_Muted; + constexpr static Theme::Color m_colorActive = Theme::Token_Text_Default; + constexpr static QSize m_iconSize{16, 16}; + constexpr static TextFormat m_itemDefaultTf + {m_colorDefault, UiElement::UiElementLabelMedium}; + constexpr static TextFormat m_itemActiveTf + {m_colorActive, m_itemDefaultTf.uiElement}; + const QIcon m_iconDefault; + const QIcon m_iconActive; + const QString m_textTemplate; +}; + static QString extensionStateDisplayString(ExtensionState state) { switch (state) { @@ -280,38 +371,117 @@ public: class SortFilterProxyModel : public QSortFilterProxyModel { public: - SortFilterProxyModel(QObject *parent = nullptr); + struct SortOption { + const QString displayName; + const Role role; + const Qt::SortOrder order = Qt::AscendingOrder; + }; + + struct FilterOption { + const QString displayName; + const std::function indexAcceptedFunc; + }; + + SortFilterProxyModel(QObject *parent = nullptr) + : QSortFilterProxyModel(parent) + { + setSortCaseSensitivity(Qt::CaseInsensitive); + } + + static const QList &sortOptions() + { + static const QList options = { + {Tr::tr("Name"), RoleName}, + {Tr::tr("Vendor"), RoleVendor}, + {Tr::tr("Popularity"), RoleDownloadCount, Qt::DescendingOrder}, + }; + return options; + } + + void setSortOption(int index) + { + QTC_ASSERT(index < sortOptions().count(), index = 0); + m_sortOptionIndex = index; + const SortOption &option = sortOptions().at(index); + + // Ensure some order for cases with insufficient data, e.g. RoleDownloadCount + setSortRole(RoleName); + sort(0); + if (option.role == RoleName) + return; // Already sorted. + + setSortRole(option.role); + sort(0, option.order); + } + + static const QList &filterOptions() + { + static const QList options = { + { + Tr::tr("All"), + []([[maybe_unused]] const QModelIndex &index) { + return true; + }, + }, + { + Tr::tr("Extension packs"), + [](const QModelIndex &index) { + return index.data(RoleItemType).value() == ItemTypePack; + }, + }, + { + Tr::tr("Individual extensions"), + [](const QModelIndex &index) { + return index.data(RoleItemType).value() == ItemTypeExtension; + }, + }, + }; + return options; + } + + void setFilterOption(int index) + { + QTC_ASSERT(index < filterOptions().count(), index = 0); + beginResetModel(); + m_filterOptionIndex = index; + endResetModel(); + } protected: - bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; + bool lessThan(const QModelIndex &left, const QModelIndex &right) const override + { + const SortOption &option = sortOptions().at(m_sortOptionIndex); + const ItemType leftType = left.data(RoleItemType).value(); + const ItemType rightType = right.data(RoleItemType).value(); + if (leftType != rightType) + return option.order == Qt::AscendingOrder ? leftType < rightType + : leftType > rightType; + + return QSortFilterProxyModel::lessThan(left, right); + } + + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override + { + const QModelIndex index = sourceModel()->index(source_row, 0, source_parent); + return filterOptions().at(m_filterOptionIndex).indexAcceptedFunc(index); + } + + int m_filterOptionIndex = 0; + int m_sortOptionIndex = 0; }; -SortFilterProxyModel::SortFilterProxyModel(QObject *parent) - : QSortFilterProxyModel(parent) -{ -} - -bool SortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const -{ - const ItemType leftType = left.data(RoleItemType).value(); - const ItemType rightType = right.data(RoleItemType).value(); - if (leftType != rightType) - return leftType < rightType; - - const QString leftName = left.data(RoleName).toString(); - const QString rightName = right.data(RoleName).toString(); - return leftName < rightName; -} - class ExtensionsBrowserPrivate { public: bool dataFetched = false; ExtensionsModel *model; QLineEdit *searchBox; + OptionChooser *filterChooser; + OptionChooser *sortChooser; QListView *extensionsView; QItemSelectionModel *selectionModel = nullptr; - SortFilterProxyModel *filterProxyModel; + QSortFilterProxyModel *searchProxyModel; + SortFilterProxyModel *sortFilterProxyModel; int columnsCount = 2; Tasking::TaskTreeRunner taskTreeRunner; SpinnerSolution::Spinner *m_spinner; @@ -333,11 +503,22 @@ ExtensionsBrowser::ExtensionsBrowser(QWidget *parent) d->model = new ExtensionsModel(this); - d->filterProxyModel = new SortFilterProxyModel(this); - d->filterProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - d->filterProxyModel->setFilterRole(RoleSearchText); - d->filterProxyModel->setSortRole(RoleItemType); - d->filterProxyModel->setSourceModel(d->model); + d->searchProxyModel = new QSortFilterProxyModel(this); + d->searchProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + d->searchProxyModel->setFilterRole(RoleSearchText); + d->searchProxyModel->setSourceModel(d->model); + + d->sortFilterProxyModel = new SortFilterProxyModel(this); + d->sortFilterProxyModel->setSourceModel(d->searchProxyModel); + + d->filterChooser = new OptionChooser(":/extensionmanager/images/filter.png", + Tr::tr("Filter by: %1")); + d->filterChooser->addItems(Utils::transform(SortFilterProxyModel::filterOptions(), + &SortFilterProxyModel::FilterOption::displayName)); + + d->sortChooser = new OptionChooser(":/extensionmanager/images/sort.png", Tr::tr("Sort by: %1")); + d->sortChooser->addItems(Utils::transform(SortFilterProxyModel::sortOptions(), + &SortFilterProxyModel::SortOption::displayName)); d->extensionsView = new QListView; d->extensionsView->setFrameStyle(QFrame::NoFrame); @@ -346,7 +527,7 @@ ExtensionsBrowser::ExtensionsBrowser(QWidget *parent) d->extensionsView->setSelectionMode(QListView::SingleSelection); d->extensionsView->setUniformItemSizes(true); d->extensionsView->setViewMode(QListView::IconMode); - d->extensionsView->setModel(d->filterProxyModel); + d->extensionsView->setModel(d->sortFilterProxyModel); d->extensionsView->setMouseTracking(true); using namespace Layouting; @@ -360,7 +541,13 @@ ExtensionsBrowser::ExtensionsBrowser(QWidget *parent) spacing(gapSize), customMargins(0, VPaddingM, extraListViewWidth() + gapSize, VPaddingM), }, - Space(ExPaddingGapL), + Row { + d->filterChooser, + Space(HGapS), + d->sortChooser, + st, + customMargins(0, 0, extraListViewWidth() + gapSize, 0), + }, d->extensionsView, noMargin, spacing(0), }.attachTo(this); @@ -374,10 +561,10 @@ ExtensionsBrowser::ExtensionsBrowser(QWidget *parent) d->m_spinner->hide(); auto updateModel = [this] { - d->filterProxyModel->sort(0); + d->sortFilterProxyModel->sort(0); if (d->selectionModel == nullptr) { - d->selectionModel = new QItemSelectionModel(d->filterProxyModel, + d->selectionModel = new QItemSelectionModel(d->sortFilterProxyModel, d->extensionsView); d->extensionsView->setSelectionModel(d->selectionModel); connect(d->extensionsView->selectionModel(), &QItemSelectionModel::currentChanged, @@ -387,7 +574,11 @@ ExtensionsBrowser::ExtensionsBrowser(QWidget *parent) connect(PluginManager::instance(), &PluginManager::pluginsChanged, this, updateModel); connect(d->searchBox, &QLineEdit::textChanged, - d->filterProxyModel, &QSortFilterProxyModel::setFilterWildcard); + d->searchProxyModel, &QSortFilterProxyModel::setFilterWildcard); + connect(d->sortChooser, &OptionChooser::currentIndexChanged, + d->sortFilterProxyModel, &SortFilterProxyModel::setSortOption); + connect(d->filterChooser, &OptionChooser::currentIndexChanged, + d->sortFilterProxyModel, &SortFilterProxyModel::setFilterOption); } ExtensionsBrowser::~ExtensionsBrowser() diff --git a/src/plugins/extensionmanager/images/filter.png b/src/plugins/extensionmanager/images/filter.png new file mode 100644 index 0000000000000000000000000000000000000000..52c93ebcabd184f9c916e72e1b9363d50e3a40da GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4h9AW2CEqh_A)RqBzd|xhDc0J{?X4SVZa*K zX>g$7g!YE6)`CXY0Np&EjSfa_2S3V(Jg_rAeC~g>KLhpvB+Xb6- znlI+7WS?j7wP}9Dx8Oj%6H`wHQ%`UptAzUEN$QPcjelF{r5}E*D#9KN5 literal 0 HcmV?d00001 diff --git a/src/plugins/extensionmanager/images/sort.png b/src/plugins/extensionmanager/images/sort.png new file mode 100644 index 0000000000000000000000000000000000000000..eb98b5fd56674ea44539fadbc90ad016deb0d2f5 GIT binary patch literal 164 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd7G?$phPQVgfdr%jd_r6q7#J8C8TtA7ZES3& zPMtb^`t;44H*edv?f?J(+r(BGF)%Q=c)B=-a6~7+XkcPfY2%)9QR&6SM4luIqmG6n zl2NRZEXoc&YJzO;yaJ1Dn-{KSI^y6~;JCwRhue(6V+n^9r|nX>$;V*x$T0J1`0pqN P1_lOCS3j3^P6s} literal 0 HcmV?d00001 diff --git a/src/plugins/extensionmanager/images/sort@2x.png b/src/plugins/extensionmanager/images/sort@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8a82201052b416e88cb71f4e8109c5e54d1b5f69 GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I7G?$phQ^Te;|vT8S^+*Gt_%ze%*@OzEG$w| zQqt1Wix)3mvSi7M6)RS)TD5xh>NRWDtXsEk^XAR}|Nn2{J|M=xz%bj>#W6%9cbK{Jn0_MTde2Rt)_AC_zP`$B-fhS1jQd|1b>ly + + + + + + + + +