From 4102ccb80457eea44ea280f0ace2a419602bc34b Mon Sep 17 00:00:00 2001 From: Felix Ernst Date: Mon, 7 Apr 2025 21:09:00 +0000 Subject: Rewrite search integration This huge commit is a nearly complete rewrite of the Dolphin search code. It implements most of the improved Dolphin search UI/UX as designed and discussed in a collaborative effort by Kristen McWilliam, Jin Liu, Andy Betts, Tagwerk, a few others and me. See https://invent.kde.org/system/dolphin/-/issues/46. # Notable changes - A toggle to change the search tool is provided as most contributors deemed that useful in https://invent.kde.org/system/dolphin/-/merge_requests/642#note_985112. - The default search is changed to filenamesearch for maximum reliability. - Removing all search parameters will take users back to the view state prior to starting a search instead of keeping the search results open. - The UI for choosing file types or modification dates has been made more powerful with more granularity and more options. - Most search parameters can be configured from a popup menu which gives us extra space for extra clarity. - Labels and help buttons as well as hyperlinks to settings makes sure the user always knows why some search parameters are unavailable in some contexts. - Chips show important search parameters while the popup is closed. They allow quickly removing filters. - The titles of the search and the input field placeholder message change to make clear whether file names or file contents are searched. - When the user actively switches the search tool, whether content should be searched, or whether to search everywhere, this is preserved for the initial state of the search bar when the user opens it the next time after restarting Dolphin. # Architecture - The new DolphinQuery class is independent of the UI and contains all search parameters modifiable in Dolphin as easy setters and getters. - DolphinQuery objects are also used to update the states of every component in the search UI. There is now a clear separation of UI and search configuration/DolphinQuery. - DolphinQuery is responsible for exporting to and importing from search URLs. - The search UI always reflects the currently configured DolphinQuery no matter if the user changed the UI to change the DolphinQuery or loaded a DolphinQuery/older search URL which then is reflected in the UI. - I tried to simplify all classes and their interaction between each other as much as possible. - I added some tests BUG: 386754 CCBUG: 435119 CCBUG: 458761 BUG: 446387 BUG: 470136 CCBUG: 471556 CCBUG: 475439 CCBUG: 477969 BUG: 480001 BUG: 483578 BUG: 488047 BUG: 488845 BUG: 500103 FIXED-IN: 25.08 --- src/search/selectors/dateselector.cpp | 61 ++++++++ src/search/selectors/dateselector.h | 42 ++++++ src/search/selectors/filetypeselector.cpp | 81 +++++++++++ src/search/selectors/filetypeselector.h | 36 +++++ src/search/selectors/minimumratingselector.cpp | 51 +++++++ src/search/selectors/minimumratingselector.h | 42 ++++++ src/search/selectors/tagsselector.cpp | 189 +++++++++++++++++++++++++ src/search/selectors/tagsselector.h | 45 ++++++ 8 files changed, 547 insertions(+) create mode 100644 src/search/selectors/dateselector.cpp create mode 100644 src/search/selectors/dateselector.h create mode 100644 src/search/selectors/filetypeselector.cpp create mode 100644 src/search/selectors/filetypeselector.h create mode 100644 src/search/selectors/minimumratingselector.cpp create mode 100644 src/search/selectors/minimumratingselector.h create mode 100644 src/search/selectors/tagsselector.cpp create mode 100644 src/search/selectors/tagsselector.h (limited to 'src/search/selectors') diff --git a/src/search/selectors/dateselector.cpp b/src/search/selectors/dateselector.cpp new file mode 100644 index 000000000..2951b1dc4 --- /dev/null +++ b/src/search/selectors/dateselector.cpp @@ -0,0 +1,61 @@ +/* + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "dateselector.h" + +#include "../dolphinquery.h" + +#include +#include +#include +#include + +using namespace Search; + +Search::DateSelector::DateSelector(std::shared_ptr dolphinQuery, QWidget *parent) + : QToolButton{parent} + , UpdatableStateInterface{dolphinQuery} + , m_datePickerPopup{ + new KDatePickerPopup{KDatePickerPopup::NoDate | KDatePickerPopup::DatePicker | KDatePickerPopup::Words, dolphinQuery->modifiedSinceDate(), this}} +{ + setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + setPopupMode(QToolButton::InstantPopup); + + m_datePickerPopup->setDateRange(QDate{}, QDate::currentDate()); + connect(m_datePickerPopup, &KDatePickerPopup::dateChanged, this, [this](const QDate &activatedDate) { + if (activatedDate == m_searchConfiguration->modifiedSinceDate()) { + return; // Already selected. + } + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setModifiedSinceDate(activatedDate); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); + }); + setMenu(m_datePickerPopup); + + updateStateToMatch(std::move(dolphinQuery)); +} + +void DateSelector::removeRestriction() +{ + Q_ASSERT(m_searchConfiguration->modifiedSinceDate().isValid()); + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setModifiedSinceDate(QDate{}); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); +} + +void DateSelector::updateState(const std::shared_ptr &dolphinQuery) +{ + m_datePickerPopup->setDate(dolphinQuery->modifiedSinceDate()); + if (!dolphinQuery->modifiedSinceDate().isValid()) { + setIcon(QIcon{}); // No icon for the empty state + setText(i18nc("@item:inlistbox", "Any Date")); + return; + } + setIcon(QIcon::fromTheme(QStringLiteral("view-calendar"))); + QLocale local; + KFormat formatter(local); + setText(formatter.formatRelativeDate(dolphinQuery->modifiedSinceDate(), QLocale::ShortFormat)); +} diff --git a/src/search/selectors/dateselector.h b/src/search/selectors/dateselector.h new file mode 100644 index 000000000..99cecec06 --- /dev/null +++ b/src/search/selectors/dateselector.h @@ -0,0 +1,42 @@ +/* + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef DATESELECTOR_H +#define DATESELECTOR_H + +#include "../updatablestateinterface.h" + +#include + +class KDatePickerPopup; + +namespace Search +{ + +class DateSelector : public QToolButton, public UpdatableStateInterface +{ + Q_OBJECT + +public: + explicit DateSelector(std::shared_ptr dolphinQuery, QWidget *parent = nullptr); + + /** Causes configurationChanged() to be emitted with a DolphinQuery object that does not contain any restriction settable by this class. */ + void removeRestriction(); + +Q_SIGNALS: + /** Is emitted whenever settings have changed and a new search might be necessary. */ + void configurationChanged(const DolphinQuery &dolphinQuery); + +private: + void updateState(const std::shared_ptr &dolphinQuery) override; + +private: + KDatePickerPopup *m_datePickerPopup = nullptr; +}; + +} + +#endif // DATESELECTOR_H diff --git a/src/search/selectors/filetypeselector.cpp b/src/search/selectors/filetypeselector.cpp new file mode 100644 index 000000000..7852aced2 --- /dev/null +++ b/src/search/selectors/filetypeselector.cpp @@ -0,0 +1,81 @@ +/* + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "filetypeselector.h" + +#include "../dolphinquery.h" + +#include +#include + +using namespace Search; + +FileTypeSelector::FileTypeSelector(std::shared_ptr dolphinQuery, QWidget *parent) + : QComboBox{parent} + , UpdatableStateInterface{dolphinQuery} +{ + for (KFileMetaData::Type::Type type = KFileMetaData::Type::FirstType; type <= KFileMetaData::Type::LastType; type = KFileMetaData::Type::Type(type + 1)) { + switch (type) { + case KFileMetaData::Type::Empty: + addItem(/** No icon for the empty state */ i18nc("@item:inlistbox", "Any Type"), type); + continue; + case KFileMetaData::Type::Archive: + addItem(QIcon::fromTheme(QStringLiteral("package-x-generic")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Audio: + addItem(QIcon::fromTheme(QStringLiteral("audio-x-generic")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Video: + addItem(QIcon::fromTheme(QStringLiteral("video-x-generic")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Image: + addItem(QIcon::fromTheme(QStringLiteral("image-x-generic")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Document: + addItem(QIcon::fromTheme(QStringLiteral("text-x-generic")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Spreadsheet: + addItem(QIcon::fromTheme(QStringLiteral("table")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Presentation: + addItem(QIcon::fromTheme(QStringLiteral("view-presentation")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Text: + addItem(QIcon::fromTheme(QStringLiteral("text-x-generic")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + case KFileMetaData::Type::Folder: + addItem(QIcon::fromTheme(QStringLiteral("inode-directory")), KFileMetaData::TypeInfo{type}.displayName(), type); + continue; + default: + addItem(QIcon(), KFileMetaData::TypeInfo{type}.displayName(), type); + } + } + + connect(this, &QComboBox::activated, this, [this](int activatedIndex) { + auto activatedType = itemData(activatedIndex).value(); + if (activatedType == m_searchConfiguration->fileType()) { + return; // Already selected. + } + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setFileType(activatedType); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); + }); + + updateStateToMatch(std::move(dolphinQuery)); +} + +void Search::FileTypeSelector::removeRestriction() +{ + Q_ASSERT(m_searchConfiguration->fileType() != KFileMetaData::Type::Empty); + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setFileType(KFileMetaData::Type::Empty); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); +} + +void FileTypeSelector::updateState(const std::shared_ptr &dolphinQuery) +{ + setCurrentIndex(findData(dolphinQuery->fileType())); +} diff --git a/src/search/selectors/filetypeselector.h b/src/search/selectors/filetypeselector.h new file mode 100644 index 000000000..bfc827344 --- /dev/null +++ b/src/search/selectors/filetypeselector.h @@ -0,0 +1,36 @@ +/* + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef FILETYPESELECTOR_H +#define FILETYPESELECTOR_H + +#include "../updatablestateinterface.h" + +#include + +namespace Search +{ + +class FileTypeSelector : public QComboBox, public UpdatableStateInterface +{ + Q_OBJECT + +public: + explicit FileTypeSelector(std::shared_ptr dolphinQuery, QWidget *parent = nullptr); + + /** Causes configurationChanged() to be emitted with a DolphinQuery object that does not contain any restriction settable by this class. */ + void removeRestriction(); + +Q_SIGNALS: + /** Is emitted whenever settings have changed and a new search might be necessary. */ + void configurationChanged(const DolphinQuery &dolphinQuery); + +private: + void updateState(const std::shared_ptr &dolphinQuery) override; +}; +} + +#endif // FILETYPESELECTOR_H diff --git a/src/search/selectors/minimumratingselector.cpp b/src/search/selectors/minimumratingselector.cpp new file mode 100644 index 000000000..e46dfd4e0 --- /dev/null +++ b/src/search/selectors/minimumratingselector.cpp @@ -0,0 +1,51 @@ +/* + SPDX-FileCopyrightText: 2012 Peter Penz + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "minimumratingselector.h" + +#include "../dolphinquery.h" + +#include + +using namespace Search; + +MinimumRatingSelector::MinimumRatingSelector(std::shared_ptr dolphinQuery, QWidget *parent) + : QComboBox{parent} + , UpdatableStateInterface{dolphinQuery} +{ + addItem(/** No icon for the empty state */ i18nc("@item:inlistbox", "Any Rating"), 0); + addItem(QIcon::fromTheme(QStringLiteral("starred-symbolic")), i18nc("@item:inlistbox", "1 or more"), 2); + addItem(QIcon::fromTheme(QStringLiteral("starred-symbolic")), i18nc("@item:inlistbox", "2 or more"), 4); + addItem(QIcon::fromTheme(QStringLiteral("starred-symbolic")), i18nc("@item:inlistbox", "3 or more"), 6); + addItem(QIcon::fromTheme(QStringLiteral("starred-symbolic")), i18nc("@item:inlistbox", "4 or more"), 8); + addItem(QIcon::fromTheme(QStringLiteral("starred-symbolic")), i18nc("@item:inlistbox 5 star rating, has a star icon in front", "5"), 10); + + connect(this, &QComboBox::activated, this, [this](int activatedIndex) { + auto activatedMinimumRating = itemData(activatedIndex).value(); + if (activatedMinimumRating == m_searchConfiguration->minimumRating()) { + return; // Already selected. + } + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setMinimumRating(activatedMinimumRating); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); + }); + + updateStateToMatch(std::move(dolphinQuery)); +} + +void MinimumRatingSelector::removeRestriction() +{ + Q_ASSERT(m_searchConfiguration->minimumRating() > 0); + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setMinimumRating(0); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); +} + +void MinimumRatingSelector::updateState(const std::shared_ptr &dolphinQuery) +{ + setCurrentIndex(findData(dolphinQuery->minimumRating())); +} diff --git a/src/search/selectors/minimumratingselector.h b/src/search/selectors/minimumratingselector.h new file mode 100644 index 000000000..02364cd1a --- /dev/null +++ b/src/search/selectors/minimumratingselector.h @@ -0,0 +1,42 @@ +/* + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef MINIMUMRATINGSELECTOR_H +#define MINIMUMRATINGSELECTOR_H + +#include "../updatablestateinterface.h" + +#include + +namespace Search +{ + +/** + * @brief Select the minimum rating search results should have. + * Values <= 0 mean no restriction. 1 is half a star, 2 one full star, etc. 10 is typically the maximum in KDE software. + * Since this box only allows selecting full star ratings, the possible values are 0, 2, 4, 6, 8, 10. + */ +class MinimumRatingSelector : public QComboBox, public UpdatableStateInterface +{ + Q_OBJECT + +public: + explicit MinimumRatingSelector(std::shared_ptr dolphinQuery, QWidget *parent = nullptr); + + /** Causes configurationChanged() to be emitted with a DolphinQuery object that does not contain any restriction settable by this class. */ + void removeRestriction(); + +Q_SIGNALS: + /** Is emitted whenever settings have changed and a new search might be necessary. */ + void configurationChanged(const DolphinQuery &dolphinQuery); + +private: + void updateState(const std::shared_ptr &dolphinQuery) override; +}; + +} + +#endif // MINIMUMRATINGSELECTOR_H diff --git a/src/search/selectors/tagsselector.cpp b/src/search/selectors/tagsselector.cpp new file mode 100644 index 000000000..95d7ff52a --- /dev/null +++ b/src/search/selectors/tagsselector.cpp @@ -0,0 +1,189 @@ +/* + SPDX-FileCopyrightText: 2019 Ismael Asensio + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "tagsselector.h" + +#include "../dolphinquery.h" + +#include +#include +#include + +#include +#include + +using namespace Search; + +namespace +{ +/** + * @brief Provides the list of tags to all TagsSelectors. + * + * This QStringList of tags populates itself. Additional tags the user is actively searching for can be added with addTag() even though we assume that no file + * with such a tag exists if we did not find it automatically. + * @note Use the tagsList() function below instead of constructing TagsList objects yourself. + */ +class TagsList : public QStringList, public QObject +{ +public: + TagsList() + : QStringList{} + { + m_tagsLister->openUrl(QUrl(QStringLiteral("tags:/")), KCoreDirLister::OpenUrlFlag::Reload); + connect(m_tagsLister.get(), &KCoreDirLister::itemsAdded, this, [this](const QUrl &, const KFileItemList &items) { + for (const KFileItem &item : items) { + append(item.text()); + } + removeDuplicates(); + sort(Qt::CaseInsensitive); + }); + }; + + virtual ~TagsList() = default; + + void addTag(const QString &tag) + { + if (contains(tag)) { + return; + } + append(tag); + sort(Qt::CaseInsensitive); + }; + + /** Used to access to the itemsAdded signal so outside users of this class know when items were added. */ + KCoreDirLister *tagsLister() const + { + return m_tagsLister.get(); + }; + +private: + std::unique_ptr m_tagsLister = std::make_unique(); +}; + +/** + * @returns a list of all tags found since the construction of the first TagsSelector object. + * @note Use this function instead of constructing additional TagsList objects. + */ +TagsList *tagsList() +{ + static TagsList *g_tagsList = new TagsList; + return g_tagsList; +} +} + +Search::TagsSelector::TagsSelector(std::shared_ptr dolphinQuery, QWidget *parent) + : QToolButton{parent} + , UpdatableStateInterface{dolphinQuery} +{ + setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + setPopupMode(QToolButton::InstantPopup); + + auto menu = new QMenu{this}; + setMenu(menu); + connect(menu, &QMenu::aboutToShow, this, [this]() { + TagsList *tags = tagsList(); + // The TagsList might not have been updated for a while and new tags might be available. We update now, but this is unfortunately not instant. + // However this selector is connected to the itemsAdded() signal, so we will add any new tags eventually. + tags->tagsLister()->updateDirectory(tags->tagsLister()->url()); + updateMenu(m_searchConfiguration); + }); + + TagsList *tags = tagsList(); + if (tags->isEmpty()) { + // Either there really are no tags or the TagsList has not loaded the tags yet. It only begins loading the first time tagsList() is globally called. + setEnabled(false); + connect( + tags->tagsLister(), + &KCoreDirLister::itemsAdded, + this, + [this]() { + setEnabled(true); + }, + Qt::SingleShotConnection); + } + + connect(tags->tagsLister(), &KCoreDirLister::itemsAdded, this, [this, menu]() { + if (menu->isVisible()) { + updateMenu(m_searchConfiguration); + } + }); + + updateStateToMatch(std::move(dolphinQuery)); +} + +void TagsSelector::removeRestriction() +{ + Q_ASSERT(!m_searchConfiguration->requiredTags().isEmpty()); + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setRequiredTags({}); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); +} + +void TagsSelector::updateMenu(const std::shared_ptr &dolphinQuery) +{ + if (!KProtocolInfo::isKnownProtocol(QStringLiteral("tags"))) { + return; + } + const bool menuWasVisible = menu()->isVisible(); + if (menuWasVisible) { + menu()->hide(); // The menu needs to be hidden now, then updated, and then shown again. + } + // Delete all existing actions in the menu + for (QAction *action : menu()->actions()) { + action->deleteLater(); + } + menu()->clear(); + // Populate the menu + const TagsList *tags = tagsList(); + const bool onlyOneTagExists = tags->count() == 1; + + for (const QString &tag : *tags) { + QAction *tagAction = new QAction{QIcon::fromTheme(QStringLiteral("tag")), tag, menu()}; + tagAction->setCheckable(true); + tagAction->setChecked(dolphinQuery->requiredTags().contains(tag)); + connect(tagAction, &QAction::triggered, this, [this, tag, onlyOneTagExists](bool checked) { + QStringList requiredTags = m_searchConfiguration->requiredTags(); + if (checked == requiredTags.contains(tag)) { + return; // Already selected. + } + if (checked) { + requiredTags.append(tag); + } else { + requiredTags.removeOne(tag); + } + DolphinQuery searchConfigurationCopy = *m_searchConfiguration; + searchConfigurationCopy.setRequiredTags(requiredTags); + Q_EMIT configurationChanged(std::move(searchConfigurationCopy)); + + if (!onlyOneTagExists) { + // Keep the menu open to allow easier tag multi-selection. + menu()->show(); + } + }); + menu()->addAction(tagAction); + } + if (menuWasVisible) { + menu()->show(); + } +} + +void TagsSelector::updateState(const std::shared_ptr &dolphinQuery) +{ + if (dolphinQuery->requiredTags().count()) { + setIcon(QIcon::fromTheme(QStringLiteral("tag"))); + setText(dolphinQuery->requiredTags().join(i18nc("list separator for file tags e.g. all images tagged 'family & party & 2025'", " && "))); + } else { + setIcon(QIcon{}); // No icon for the empty state + setText(i18nc("@action:button Required tags for search results: None", "None")); + } + for (const auto &tag : dolphinQuery->requiredTags()) { + tagsList()->addTag(tag); // We add it just in case this tag is not (or no longer) available on the system. This way the UI always works as expected. + } + if (menu()->isVisible()) { + updateMenu(dolphinQuery); + } +} diff --git a/src/search/selectors/tagsselector.h b/src/search/selectors/tagsselector.h new file mode 100644 index 000000000..386cbb924 --- /dev/null +++ b/src/search/selectors/tagsselector.h @@ -0,0 +1,45 @@ +/* + SPDX-FileCopyrightText: 2019 Ismael Asensio + SPDX-FileCopyrightText: 2025 Felix Ernst + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#ifndef TAGSSELECTOR_H +#define TAGSSELECTOR_H + +#include "../updatablestateinterface.h" + +#include + +namespace Search +{ + +class TagsSelector : public QToolButton, public UpdatableStateInterface +{ + Q_OBJECT + +public: + explicit TagsSelector(std::shared_ptr dolphinQuery, QWidget *parent = nullptr); + + /** Causes configurationChanged() to be emitted with a DolphinQuery object that does not contain any restriction settable by this class. */ + void removeRestriction(); + +Q_SIGNALS: + /** Is emitted whenever settings have changed and a new search might be necessary. */ + void configurationChanged(const DolphinQuery &dolphinQuery); + +private: + /** + * Updates the menu items for the various tags based on @p dolphinQuery and the available tags. + * This method should only be called when the menu is QMenu::aboutToShow() or the menu is currently visible already while this selector's state changes. + * If the menu is open when this method is called, the menu will automatically be reopened to reflect the updated contents. + */ + void updateMenu(const std::shared_ptr &dolphinQuery); + + void updateState(const std::shared_ptr &dolphinQuery) override; +}; + +} + +#endif // TAGSSELECTOR_H -- cgit v1.3