From a9473c38aa7a4cafacb8ebc482b663bd5c9a48e4 Mon Sep 17 00:00:00 2001 From: Michel Boyer de la Giroday Date: Fri, 23 Aug 2024 06:41:17 +0200 Subject: [PATCH] feat: improve search in document (#25) Fixes tasks: QtUiDocument QtTsDocument RCDocument text view RCDocument central view RCDocument left tree --- src/gui/CMakeLists.txt | 2 + src/gui/findwidget.cpp | 43 ++++++++++++++ src/gui/findwidget.h | 9 +++ src/gui/findwidget.ui | 4 +- src/gui/highlightsearchdelegate.cpp | 83 +++++++++++++++++++++++++++ src/gui/highlightsearchdelegate.h | 34 +++++++++++ src/gui/mainwindow.cpp | 26 ++++++++- src/gui/mainwindow.h | 1 + src/gui/qttsview.cpp | 89 ++++++++++++++++++++++++++++- src/gui/qttsview.h | 26 +++++++-- src/gui/qtuiview.cpp | 89 ++++++++++++++++++++++++++++- src/gui/qtuiview.h | 22 +++++-- 12 files changed, 409 insertions(+), 19 deletions(-) create mode 100644 src/gui/highlightsearchdelegate.cpp create mode 100644 src/gui/highlightsearchdelegate.h diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 73a96ef1..648f6497 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -28,6 +28,8 @@ set(PROJECT_SOURCES gui_constants.h guisettings.h guisettings.cpp + highlightsearchdelegate.h + highlightsearchdelegate.cpp historypanel.h historypanel.cpp imageview.h diff --git a/src/gui/findwidget.cpp b/src/gui/findwidget.cpp index 0cc4b8a1..bd936e6d 100644 --- a/src/gui/findwidget.cpp +++ b/src/gui/findwidget.cpp @@ -11,8 +11,12 @@ #include "findwidget.h" #include "core/logger.h" #include "core/project.h" +#include "core/qttsdocument.h" +#include "core/qtuidocument.h" #include "core/textdocument.h" #include "guisettings.h" +#include "qttsview.h" +#include "qtuiview.h" #include "ui_findwidget.h" #include @@ -86,11 +90,35 @@ QString FindWidget::findString() void FindWidget::findNext() { + auto qtUiDocument = qobject_cast(Core::Project::instance()->currentDocument()); + if (qtUiDocument) { + Q_EMIT findRequested(ui->findEdit->text(), Core::TextDocument::NoFindFlags); + return; + } + + auto qtTsDocument = qobject_cast(Core::Project::instance()->currentDocument()); + if (qtTsDocument) { + Q_EMIT findRequested(ui->findEdit->text(), Core::TextDocument::NoFindFlags); // default is FindForward + return; + } + find(findFlags()); } void FindWidget::findPrevious() { + auto qtUiDocument = qobject_cast(Core::Project::instance()->currentDocument()); + if (qtUiDocument) { + Q_EMIT findRequested(ui->findEdit->text(), Core::TextDocument::FindBackward); + return; + } + + auto qtTsDocument = qobject_cast(Core::Project::instance()->currentDocument()); + if (qtTsDocument) { + Q_EMIT findRequested(ui->findEdit->text(), Core::TextDocument::FindBackward); + return; + } + find(findFlags() | Core::TextDocument::FindBackward); } @@ -153,4 +181,19 @@ void FindWidget::replace(bool onlyOne) } } +void FindWidget::hideEvent(QHideEvent *event) +{ + Q_EMIT findWidgetClosed(); + QWidget::hideEvent(event); +} + +void FindWidget::showReplaceFeature(bool show) +{ + // We don't want to use a wrapping frame here due to layouting issues... + ui->replaceWithLabel->setVisible(show); + ui->replaceEdit->setVisible(show); + ui->replaceAllbutton->setVisible(show); + ui->replaceButton->setVisible(show); +} + } // namespace Gui diff --git a/src/gui/findwidget.h b/src/gui/findwidget.h index b06ab622..5cd77424 100644 --- a/src/gui/findwidget.h +++ b/src/gui/findwidget.h @@ -31,6 +31,15 @@ class FindWidget : public QWidget void open(); + void showReplaceFeature(bool show = true); + +signals: + void findRequested(const QString &text, int options); + void findWidgetClosed(); + +protected: + void hideEvent(QHideEvent *event) override; + private: int findFlags() const; QString findString(); diff --git a/src/gui/findwidget.ui b/src/gui/findwidget.ui index 91de74d9..fd5dc055 100644 --- a/src/gui/findwidget.ui +++ b/src/gui/findwidget.ui @@ -117,14 +117,14 @@ - + Replace with: - + Find: diff --git a/src/gui/highlightsearchdelegate.cpp b/src/gui/highlightsearchdelegate.cpp new file mode 100644 index 00000000..eedb241d --- /dev/null +++ b/src/gui/highlightsearchdelegate.cpp @@ -0,0 +1,83 @@ +/* + This file is part of Knut. + + SPDX-FileCopyrightText: 2024 Klarälvdalens Datakonsult AB, a KDAB Group company + + SPDX-License-Identifier: GPL-3.0-only + + Contact KDAB at for commercial licensing options. +*/ + +#include "highlightsearchdelegate.h" +#include + +namespace Gui { + +HighlightSearchDelegate::HighlightSearchDelegate(QObject *parent) + : QItemDelegate(parent) +{ +} + +void HighlightSearchDelegate::setSearchText(const QString &searchText, int offset) +{ + m_searchText = searchText; + m_offset = offset; +} + +void HighlightSearchDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + QString text = index.data().toString(); + if (m_searchText.isEmpty() || !text.contains(m_searchText, Qt::CaseInsensitive)) { + QItemDelegate::paint(painter, option, index); + return; + } + + // Check for multiline strings + QStringList multilineStrings = text.split("\n", Qt::SkipEmptyParts); + bool isMultiline = multilineStrings.count() > 1; + if (isMultiline) { + // Make it a one line text for the search highlight, + // otherwise the painting is error prone (needs too much manipulation). + // That does not impact the model itself. + text = text.replace("\n", " - "); + } + // Find the substring to highlight + const QRegularExpression regex(m_searchText, QRegularExpression::CaseInsensitiveOption); + int start = regex.match(text).capturedStart(); + int end = start + regex.match(text).capturedLength(); + + // Calculate the drawing positions. + // The TreeViews or TableView display its Items excentred in relation to the left side of the cell. + // Correct the x position accordingly using the caller offset value. + int x = option.rect.left() + m_offset; + int y = option.rect.top(); + int height = option.rect.height(); + int width = painter->fontMetrics().horizontalAdvance(text.left(start)); + + // Paint the seach result string 'bold'. + QFont highlightFont = option.font; + highlightFont.setBold(true); + + painter->save(); + // Make sure we don't paint over the selected item highlighted background. + if (option.state & QStyle::State_Selected) { + // Set the background color to the selection color + painter->fillRect(option.rect, option.palette.brush(QPalette::Active, QPalette::Highlight)); + } + + painter->drawText(x, y, width, height, option.displayAlignment, text.left(start)); + // Paint the searched string with bold font. + painter->setFont(highlightFont); + x += width; + width = painter->fontMetrics().horizontalAdvance(text.mid(start, end - start)); + painter->drawText(x, y, width, height, option.displayAlignment, text.mid(start, end - start)); + // Reset the painter font to original font. + painter->setFont(option.font); + x += width; + width = painter->fontMetrics().horizontalAdvance(text.right(text.length() - end)); + painter->drawText(x, y, width, height, option.displayAlignment, text.right(text.length() - end)); + painter->restore(); +} + +} // namespace Gui diff --git a/src/gui/highlightsearchdelegate.h b/src/gui/highlightsearchdelegate.h new file mode 100644 index 00000000..b10a01dc --- /dev/null +++ b/src/gui/highlightsearchdelegate.h @@ -0,0 +1,34 @@ +/* + This file is part of Knut. + + SPDX-FileCopyrightText: 2024 Klarälvdalens Datakonsult AB, a KDAB Group company + + SPDX-License-Identifier: GPL-3.0-only + + Contact KDAB at for commercial licensing options. +*/ + +#pragma once + +#include + +namespace Gui { + +class HighlightSearchDelegate : public QItemDelegate +{ + Q_OBJECT + +public: + explicit HighlightSearchDelegate(QObject *parent = nullptr); + + void setSearchText(const QString &searchText, int offset = 0); + +protected: + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + QString m_searchText; + int m_offset; +}; + +} // namespace Gui diff --git a/src/gui/mainwindow.cpp b/src/gui/mainwindow.cpp index 80199034..41c35905 100644 --- a/src/gui/mainwindow.cpp +++ b/src/gui/mainwindow.cpp @@ -572,12 +572,17 @@ void MainWindow::updateActions() ui->actionCloseDocument->setEnabled(document != nullptr); + auto *qtTsDocument = qobject_cast(document); + auto *qtUiDocument = qobject_cast(document); auto *textDocument = qobject_cast(document); + + const bool enableFindWidgetFeatures = + (qtTsDocument != nullptr || qtUiDocument != nullptr || textDocument != nullptr); ui->actionSelectAll->setEnabled(textDocument != nullptr); - ui->actionFind->setEnabled(textDocument != nullptr); + ui->actionFind->setEnabled(enableFindWidgetFeatures); ui->actionReplace->setEnabled(textDocument != nullptr); - ui->actionFindNext->setEnabled(textDocument != nullptr); - ui->actionFindPrevious->setEnabled(textDocument != nullptr); + ui->actionFindNext->setEnabled(enableFindWidgetFeatures); + ui->actionFindPrevious->setEnabled(enableFindWidgetFeatures); ui->actionDeleteLine->setEnabled(textDocument != nullptr); ui->actionUndo->setEnabled(textDocument != nullptr); ui->actionRedo->setEnabled(textDocument != nullptr); @@ -615,6 +620,18 @@ void MainWindow::updateActions() ui->actionCreateUi->setEnabled(rcEnabled); } +void MainWindow::updateFindWidgetDisplay() +{ + // Handle the 'FindWidget' display depending on the document type. + auto document = Core::Project::instance()->currentDocument(); + + auto *qtTsDocument = qobject_cast(document); + auto *qtUiDocument = qobject_cast(document); + // Hide the replace widgets. + const bool hideReplaceFeature = (qtTsDocument != nullptr || qtUiDocument != nullptr); + ui->findWidget->showReplaceFeature(!hideReplaceFeature); +} + void MainWindow::updateScriptActions() { ui->actionStartRecordingScript->setEnabled(!m_historyPanel->isRecording()); @@ -694,6 +711,7 @@ QWidget *MainWindow::widgetForDocument(Core::Document *document) } case Core::Document::Type::QtUi: { auto uiview = new QtUiView(); + uiview->setFindWidget(ui->findWidget); uiview->setUiDocument(qobject_cast(document)); return uiview; } @@ -715,6 +733,7 @@ QWidget *MainWindow::widgetForDocument(Core::Document *document) } case Core::Document::Type::QtTs: { auto tsView = new QtTsView(); + tsView->setFindWidget(ui->findWidget); tsView->setTsDocument(qobject_cast(document)); return tsView; } @@ -792,6 +811,7 @@ void MainWindow::changeCurrentDocument() const QModelIndex &index = m_fileModel->index(fileName); m_projectView->setCurrentIndex(index); updateActions(); + updateFindWidgetDisplay(); } } // namespace Gui diff --git a/src/gui/mainwindow.h b/src/gui/mainwindow.h index 1089d08a..0032de1a 100644 --- a/src/gui/mainwindow.h +++ b/src/gui/mainwindow.h @@ -101,6 +101,7 @@ class MainWindow : public QMainWindow void updateActions(); void updateScriptActions(); + void updateFindWidgetDisplay(); void initProject(const QString &path); void openDocument(const QModelIndex &index); diff --git a/src/gui/qttsview.cpp b/src/gui/qttsview.cpp index 60adefad..15ed474c 100644 --- a/src/gui/qttsview.cpp +++ b/src/gui/qttsview.cpp @@ -9,7 +9,8 @@ */ #include "qttsview.h" -#include "core/qttsdocument.h" +#include "findwidget.h" +#include "highlightsearchdelegate.h" #include #include @@ -129,6 +130,8 @@ QtTsView::QtTsView(QWidget *parent) auto mainWidgetLayout = new QVBoxLayout(this); m_tableView->verticalHeader()->hide(); m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); + // Set the delegate + m_tableView->setItemDelegate(new HighlightSearchDelegate(this)); mainWidgetLayout->addWidget(m_tableView); mainWidgetLayout->addWidget(m_searchLineEdit); m_searchLineEdit->setPlaceholderText(tr("Filter...")); @@ -147,12 +150,27 @@ void QtTsView::setTsDocument(Core::QtTsDocument *document) m_document->disconnect(this); m_document = document; - if (m_document) + if (m_document) { connect(m_document, &Core::QtTsDocument::fileUpdated, this, &QtTsView::updateView); - + } updateView(); } +void QtTsView::setFindWidget(FindWidget *findWidget) +{ + Q_ASSERT(findWidget); + + // Connections + connect(findWidget, &FindWidget::findRequested, this, &QtTsView::search); + + auto updateDisplay = [this]() { + search(""); // Reset delegate highlighting + m_tableView->viewport()->update(); + m_tableView->clearSelection(); + }; + connect(findWidget, &FindWidget::findWidgetClosed, this, updateDisplay); +} + void QtTsView::updateView() { m_contentProxyModel->setSourceModel(nullptr); @@ -170,6 +188,71 @@ void QtTsView::updateView() m_tableView->horizontalHeader()->setStretchLastSection(true); } +void QtTsView::search(const QString &searchText, int options) +{ + // Retrieve the text margin for the text inside a cell depending on the style. + QStyle *widgetStyle = style(); + // Ref: See textMargin - QItemDelegate::drawDisplay. + const int textMargin = widgetStyle->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; + + // Search only in case the search was not already processed (inclusive empty string search). + if (searchText != m_currentSearchText || !m_initialSearchProcessed) { + // Colorize search hits (This gives an overview over the search result) + // textMargin: Displays the highlighted data indented with the style text margin value. + static_cast(m_tableView->itemDelegate())->setSearchText(searchText, textMargin); + // Update display. + m_tableView->viewport()->update(); + // Search... + m_currentSearchResult = searchModel(searchText, m_tableView->model()); + // Reset the search index. + m_currentSearchIndex = 0; + m_initialSearchProcessed = true; + } else { // Search was already processed. Handle options (Backward, Forward) + if (!m_currentSearchResult.isEmpty()) { + int index; + switch (options) { + case Core::TextDocument::FindBackward: { + index = m_currentSearchIndex - 1; + // if it is the first match continue with the last match. + index = (index >= 0) ? index : m_currentSearchResult.count() - 1; + break; + } + default: { // Default is FindForward + index = m_currentSearchIndex + 1; + // if it is the last match continue with the first match. + index = (index < m_currentSearchResult.count()) ? index : 0; + break; + } + } + // Update the current search index + m_currentSearchIndex = index; + } + } + // Update the current search text + m_currentSearchText = searchText; + // Select (highlight) the cell that matched, as the current index. + if (!m_currentSearchResult.isEmpty()) { + m_tableView->selectionModel()->setCurrentIndex(m_currentSearchResult[m_currentSearchIndex], + QItemSelectionModel::SelectCurrent); + } +} + +QModelIndexList QtTsView::searchModel(const QString &searchText, const QAbstractItemModel *model) const +{ + QModelIndexList searchResults; + for (int row = 0; row < model->rowCount(); ++row) { + for (int column = 0; column < model->columnCount(); ++column) { + const QModelIndex index = model->index(row, column); + const QString data = model->data(index).toString(); + if ((searchText.isEmpty() && data == searchText) + || (!searchText.isEmpty() && data.contains(searchText, Qt::CaseInsensitive))) { + searchResults.append(index); + } + } + } + return searchResults; +} + void QtTsProxy::setFilterText(const QString &str) { m_filterText = str; diff --git a/src/gui/qttsview.h b/src/gui/qttsview.h index acc5b8a9..fe3ee15a 100644 --- a/src/gui/qttsview.h +++ b/src/gui/qttsview.h @@ -10,31 +10,47 @@ #pragma once +#include "core/qttsdocument.h" +#include "core/textdocument.h" + #include #include + class QTableView; class QLineEdit; -namespace Core { -class QtTsDocument; -} namespace Gui { - +class FindWidget; class QtTsProxy; + class QtTsView : public QWidget { Q_OBJECT + public: explicit QtTsView(QWidget *parent = nullptr); void setTsDocument(Core::QtTsDocument *document); + void setFindWidget(FindWidget *findWidget); + +public slots: + // Accessible from QML + void search(const QString &searchText, int option = Core::TextDocument::NoFindFlags); private: + QModelIndexList searchModel(const QString &searchText, const QAbstractItemModel *model) const; void updateView(); + QTableView *const m_tableView; QLineEdit *const m_searchLineEdit; Core::QtTsDocument *m_document = nullptr; QtTsProxy *const m_contentProxyModel; QAbstractItemModel *m_contentModel = nullptr; + // Search feature + QModelIndexList m_currentSearchResult; + QString m_currentSearchText; + int m_currentSearchIndex = 0; + bool m_initialSearchProcessed = false; }; -} + +} // namespace Gui diff --git a/src/gui/qtuiview.cpp b/src/gui/qtuiview.cpp index c6e0cd68..1fa938a8 100644 --- a/src/gui/qtuiview.cpp +++ b/src/gui/qtuiview.cpp @@ -10,6 +10,8 @@ #include "qtuiview.h" #include "core/qtuidocument.h" +#include "findwidget.h" +#include "highlightsearchdelegate.h" #include #include @@ -110,6 +112,74 @@ QtUiView::QtUiView(QWidget *parent) m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); m_previewArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_previewArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + + // Set the delegate + m_tableView->setItemDelegate(new HighlightSearchDelegate(this)); +} + +void QtUiView::search(const QString &searchText, int options) +{ + // Retrieve the left margin for the text inside a cell depending on the style. + QStyle *widgetStyle = style(); + // Ref: See textMargin - QItemDelegate::drawDisplay. + const int textMargin = widgetStyle->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; + + // Search only in case the search was not already processed (inclusive empty string search). + if (searchText != m_currentSearchText || !m_initialSearchProcessed) { + // Colorize search hits (This gives an overview over the search result) + // textMargin: Displays the highlighted data indented with the style left text margin value. + static_cast(m_tableView->itemDelegate())->setSearchText(searchText, textMargin); + // Update display. + m_tableView->viewport()->update(); + // Search... + m_currentSearchResult = searchModel(searchText, m_tableView->model()); + // Reset the search index. + m_currentSearchIndex = 0; + m_initialSearchProcessed = true; + } else { // Search was already processed. Handle options (Backward, Forward) + if (!m_currentSearchResult.isEmpty()) { + int index; + switch (options) { + case Core::TextDocument::FindBackward: { + index = m_currentSearchIndex - 1; + // if it is the first match continue with the last match. + index = (index >= 0) ? index : m_currentSearchResult.count() - 1; + break; + } + default: { // Default is FindForward + index = m_currentSearchIndex + 1; + // if it is last match continue with the first match. + index = (index < m_currentSearchResult.count()) ? index : 0; + break; + } + } + // Update the current search index + m_currentSearchIndex = index; + } + } + // Update the current search text + m_currentSearchText = searchText; + // Select (highlight) the cell that matched, as the current index. + if (!m_currentSearchResult.isEmpty()) { + m_tableView->selectionModel()->setCurrentIndex(m_currentSearchResult[m_currentSearchIndex], + QItemSelectionModel::SelectCurrent); + } +} + +QModelIndexList QtUiView::searchModel(const QString &searchText, const QAbstractItemModel *model) const +{ + QModelIndexList searchResults; + for (int row = 0; row < model->rowCount(); ++row) { + for (int column = 0; column < model->columnCount(); ++column) { + const QModelIndex index = model->index(row, column); + const QString data = model->data(index).toString(); + if ((searchText.isEmpty() && data == searchText) + || (!searchText.isEmpty() && data.contains(searchText, Qt::CaseInsensitive))) { + searchResults.append(index); + } + } + } + return searchResults; } void QtUiView::setUiDocument(Core::QtUiDocument *document) @@ -120,12 +190,27 @@ void QtUiView::setUiDocument(Core::QtUiDocument *document) m_document->disconnect(this); m_document = document; - if (m_document) + if (m_document) { connect(m_document, &Core::QtUiDocument::fileUpdated, this, &QtUiView::updateView); - + } updateView(); } +void QtUiView::setFindWidget(FindWidget *findWidget) +{ + Q_ASSERT(findWidget); + + connect(findWidget, &FindWidget::findRequested, this, &QtUiView::search); + + // Update display. + auto updateDisplay = [this]() { + search(""); // Reset delegate highlighting + m_tableView->viewport()->update(); + m_tableView->clearSelection(); + }; + connect(findWidget, &FindWidget::findWidgetClosed, this, updateDisplay); +} + void QtUiView::updateView() { delete m_tableView->model(); diff --git a/src/gui/qtuiview.h b/src/gui/qtuiview.h index 2e326c19..f5ff77c3 100644 --- a/src/gui/qtuiview.h +++ b/src/gui/qtuiview.h @@ -10,33 +10,47 @@ #pragma once +#include "core/qtuidocument.h" +#include "core/textdocument.h" + +#include #include class QTableView; class QMdiArea; class QMdiSubWindow; -namespace Core { -class QtUiDocument; -} - namespace Gui { +class FindWidget; + class QtUiView : public QSplitter { Q_OBJECT + public: explicit QtUiView(QWidget *parent = nullptr); + void setFindWidget(FindWidget *findWidget); void setUiDocument(Core::QtUiDocument *document); +public slots: + // Accessible from QML + void search(const QString &searchText, int option = Core::TextDocument::NoFindFlags); + private: + QModelIndexList searchModel(const QString &searchText, const QAbstractItemModel *model) const; void updateView(); QTableView *const m_tableView; QMdiArea *const m_previewArea; Core::QtUiDocument *m_document = nullptr; QMdiSubWindow *m_previewWindow = nullptr; + // Search feature + QModelIndexList m_currentSearchResult; + QString m_currentSearchText; + int m_currentSearchIndex = 0; + bool m_initialSearchProcessed = false; }; } // namespace Gui