[utilities/konsole] src: Add search tab button to the tab bar

Kurt Hindenburg null at kde.org
Wed Dec 4 14:18:07 GMT 2024


Git commit 74c648c0fc842af4746892205330a77377c90cbd by Kurt Hindenburg, on behalf of Troy Hoover.
Committed on 04/12/2024 at 14:18.
Pushed by hindenburg into branch 'master'.

Add search tab button to the tab bar

The button takes user input and sorts through a list of tab names for the tab that matches

SearchTabs pulls from Kate's similar tab sort
function called QuickOpen

FEATURE: 298775
GUI:

M  +3    -0    src/CMakeLists.txt
A  +318  -0    src/searchtabs/SearchTabs.cpp     [License: GPL(v2.0+)]
A  +68   -0    src/searchtabs/SearchTabs.h     [License: GPL(v2.0+)]
A  +69   -0    src/searchtabs/SearchTabsModel.cpp     [License: GPL(v2.0+)]
A  +68   -0    src/searchtabs/SearchTabsModel.h     [License: GPL(v2.0+)]
A  +431  -0    src/searchtabs/kfts_fuzzy_match.h     [License: LGPL(v2.0+)]
M  +115  -8    src/settings/TabBarSettings.ui
M  +8    -0    src/settings/konsole.kcfg
M  +34   -1    src/widgets/ViewContainer.cpp
M  +2    -0    src/widgets/ViewContainer.h

https://invent.kde.org/utilities/konsole/-/commit/74c648c0fc842af4746892205330a77377c90cbd

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 00ac5b30bd..7cc2e7a434 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -200,6 +200,9 @@ set(konsoleprivate_SRCS ${windowadaptors_SRCS}
                         widgets/RenameTabWidget.cpp
                         widgets/TabTitleFormatButton.cpp
 
+                        searchtabs/SearchTabs.cpp
+                        searchtabs/SearchTabsModel.cpp
+
                         terminalDisplay/extras/CompositeWidgetFocusWatcher.cpp
                         terminalDisplay/extras/AutoScrollHandler.cpp
                         terminalDisplay/extras/HighlightScrolledLines.cpp
diff --git a/src/searchtabs/SearchTabs.cpp b/src/searchtabs/SearchTabs.cpp
new file mode 100644
index 0000000000..a2c91add94
--- /dev/null
+++ b/src/searchtabs/SearchTabs.cpp
@@ -0,0 +1,318 @@
+/*
+    SPDX-FileCopyrightText: 2024 Troy Hoover <troytjh98 at gmail.com>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+// Own
+#include "SearchTabs.h"
+#include "kfts_fuzzy_match.h"
+
+// Qt
+#include <QApplication>
+#include <QKeyEvent>
+#include <QModelIndex>
+#include <QSortFilterProxyModel>
+#include <QVBoxLayout>
+
+// KDE
+#include <KLocalizedString>
+
+// Konsole
+#include "KonsoleSettings.h"
+
+using namespace Konsole;
+
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                            Fuzzy Search Model                             */
+/*                                                                           */
+/* ------------------------------------------------------------------------- */
+
+class SearchTabsFilterProxyModel final : public QSortFilterProxyModel
+{
+public:
+    SearchTabsFilterProxyModel(QObject *parent = nullptr)
+        : QSortFilterProxyModel(parent)
+    {
+    }
+
+protected:
+    bool lessThan(const QModelIndex &sourceLeft, const QModelIndex &sourceRight) const override
+    {
+        auto sm = sourceModel();
+
+        const int l = static_cast<SearchTabsModel *>(sm)->idxScore(sourceLeft);
+        const int r = static_cast<SearchTabsModel *>(sm)->idxScore(sourceRight);
+        return l < r;
+    }
+
+    bool filterAcceptsRow(int sourceRow, const QModelIndex &parent) const override
+    {
+        Q_UNUSED(parent)
+
+        if (pattern.isEmpty()) {
+            return true;
+        }
+
+        auto sm = static_cast<SearchTabsModel *>(sourceModel());
+        if (!sm->isValid(sourceRow)) {
+            return false;
+        }
+
+        QStringView tabNameMatchPattern = pattern;
+
+        const QString &name = sm->idxToName(sourceRow);
+
+        int score = 0;
+        bool result;
+        // dont use the QStringView(QString) ctor
+        if (tabNameMatchPattern.isEmpty()) {
+            result = true;
+        } else {
+            result = filterByName(QStringView(name.data(), name.size()), tabNameMatchPattern, score);
+        }
+        //         if (result && pattern == QStringLiteral(""))
+        //             qDebug() << score << ", " << name << "==================== END\n";
+
+        sm->setScoreForIndex(sourceRow, score);
+
+        return result;
+    }
+
+public Q_SLOTS:
+    bool setFilterText(const QString &text)
+    {
+        beginResetModel();
+        pattern = text;
+        endResetModel();
+
+        return true;
+    }
+
+private:
+    static inline bool filterByName(QStringView name, QStringView pattern, int &score)
+    {
+        return kfts::fuzzy_match(pattern, name, score);
+    }
+
+private:
+    QString pattern;
+};
+
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                                Search Tabs                                */
+/*                                                                           */
+/* ------------------------------------------------------------------------- */
+
+SearchTabs::SearchTabs(ViewManager *viewManager)
+    : QFrame(viewManager->activeContainer()->window())
+    , m_viewManager(viewManager)
+{
+    setFrameStyle(QFrame::StyledPanel | QFrame::Sunken);
+    setProperty("_breeze_force_frame", true);
+
+    // handle resizing of MainWindow
+    window()->installEventFilter(this);
+
+    // ensure the components have some proper frame
+    QVBoxLayout *layout = new QVBoxLayout();
+    layout->setSpacing(0);
+    layout->setContentsMargins(QMargins());
+    setLayout(layout);
+
+    // create input line for search query
+    m_inputLine = new QLineEdit(this);
+    m_inputLine->setClearButtonEnabled(true);
+    m_inputLine->addAction(QIcon::fromTheme(QStringLiteral("search")), QLineEdit::LeadingPosition);
+    m_inputLine->setTextMargins(QMargins() + style()->pixelMetric(QStyle::PM_ButtonMargin));
+    m_inputLine->setPlaceholderText(i18nc("@label:textbox", "Search..."));
+    m_inputLine->setToolTip(i18nc("@info:tooltip", "Enter a tab name to search for here"));
+    m_inputLine->setCursor(Qt::IBeamCursor);
+    m_inputLine->setFont(QApplication::font());
+    m_inputLine->setFrame(false);
+    // When the widget focus is set, focus input box instead
+    setFocusProxy(m_inputLine);
+
+    layout->addWidget(m_inputLine);
+
+    m_listView = new QTreeView(this);
+    layout->addWidget(m_listView, 1);
+    m_listView->setProperty("_breeze_borders_sides", QVariant::fromValue(QFlags(Qt::TopEdge)));
+    m_listView->setTextElideMode(Qt::ElideLeft);
+    m_listView->setUniformRowHeights(true);
+
+    // model stores tab information
+    m_model = new SearchTabsModel(this);
+
+    // switch to selected tab
+    connect(m_inputLine, &QLineEdit::returnPressed, this, &SearchTabs::slotReturnPressed);
+    connect(m_listView, &QTreeView::activated, this, &SearchTabs::slotReturnPressed);
+    connect(m_listView, &QTreeView::clicked, this, &SearchTabs::slotReturnPressed); // for single click
+
+    m_inputLine->installEventFilter(this);
+    m_listView->installEventFilter(this);
+    m_listView->setHeaderHidden(true);
+    m_listView->setRootIsDecorated(false);
+    m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    m_listView->setModel(m_model);
+
+    // use fuzzy sort to identify tabs with matching titles
+    connect(m_inputLine, &QLineEdit::textChanged, this, [this](const QString &text) {
+        // initialize the proxy model when there is something to filter
+        bool didFilter = false;
+        if (!m_proxyModel) {
+            m_proxyModel = new SearchTabsFilterProxyModel(this);
+            m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
+            didFilter = m_proxyModel->setFilterText(text);
+            m_proxyModel->setSourceModel(m_model);
+            m_listView->setModel(m_proxyModel);
+        } else {
+            didFilter = m_proxyModel->setFilterText(text);
+        }
+        if (didFilter) {
+            m_listView->viewport()->update();
+            reselectFirst();
+        }
+    });
+
+    setHidden(true);
+
+    // fill stuff
+    updateState();
+}
+
+bool SearchTabs::eventFilter(QObject *obj, QEvent *event)
+{
+    // catch key presses + shortcut overrides to allow to have
+    // ESC as application wide shortcut, too, see bug 409856
+    if (event->type() == QEvent::KeyPress || event->type() == QEvent::ShortcutOverride) {
+        QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
+        if (obj == m_inputLine) {
+            const bool forward2list = (keyEvent->key() == Qt::Key_Up) || (keyEvent->key() == Qt::Key_Down) || (keyEvent->key() == Qt::Key_PageUp)
+                || (keyEvent->key() == Qt::Key_PageDown);
+
+            if (forward2list) {
+                QCoreApplication::sendEvent(m_listView, event);
+                return true;
+            }
+
+        } else if (obj == m_listView) {
+            const bool forward2input = (keyEvent->key() != Qt::Key_Up) && (keyEvent->key() != Qt::Key_Down) && (keyEvent->key() != Qt::Key_PageUp)
+                && (keyEvent->key() != Qt::Key_PageDown) && (keyEvent->key() != Qt::Key_Tab) && (keyEvent->key() != Qt::Key_Backtab);
+
+            if (forward2input) {
+                QCoreApplication::sendEvent(m_inputLine, event);
+                return true;
+            }
+        }
+
+        if (keyEvent->key() == Qt::Key_Escape) {
+            hide();
+            deleteLater();
+            return true;
+        }
+    }
+
+    if (event->type() == QEvent::FocusOut && !(m_inputLine->hasFocus() || m_listView->hasFocus())) {
+        hide();
+        deleteLater();
+        return true;
+    }
+
+    // handle resizing
+    if (window() == obj && event->type() == QEvent::Resize) {
+        updateViewGeometry();
+    }
+
+    return QWidget::eventFilter(obj, event);
+}
+
+void SearchTabs::reselectFirst()
+{
+    int first = 0;
+    const auto *model = m_listView->model();
+
+    if (m_viewManager->viewProperties().size() > 1 && model->rowCount() > 1 && m_inputLine->text().isEmpty()) {
+        first = 1;
+    }
+
+    QModelIndex index = model->index(first, 0);
+    m_listView->setCurrentIndex(index);
+}
+
+void SearchTabs::updateState()
+{
+    m_model->refresh(m_viewManager);
+    reselectFirst();
+
+    updateViewGeometry();
+    show();
+    raise();
+    setFocus();
+}
+
+void SearchTabs::slotReturnPressed()
+{
+    // switch to tab using the unique ViewProperties identifier
+    // (the view identifier is off by 1)
+    const QModelIndex index = m_listView->currentIndex();
+    m_viewManager->setCurrentView(index.data(SearchTabsModel::View).toInt() - 1);
+
+    hide();
+    deleteLater();
+
+    window()->setFocus();
+}
+
+void SearchTabs::updateViewGeometry()
+{
+    // find MainWindow rectangle
+    QRect boundingRect = window()->contentsRect();
+
+    // set search tabs window size
+    static constexpr int minWidth = 125;
+    const int maxWidth = boundingRect.width();
+    const int preferredWidth = maxWidth / 4.8;
+
+    static constexpr int minHeight = 250;
+    const int maxHeight = boundingRect.height();
+    const int preferredHeight = maxHeight / 4;
+
+    const QSize size{qMin(maxWidth, qMax(preferredWidth, minWidth)), qMin(maxHeight, qMax(preferredHeight, minHeight))};
+
+    // resize() doesn't work here, so use setFixedSize() instead
+    setFixedSize(size);
+
+    // set the position just below/above the tab bar
+    int y;
+    int mainWindowHeight = window()->geometry().height();
+    int containerHeight = m_viewManager->activeContainer()->geometry().height();
+
+    // only calculate the tab bar height if it's visible
+    int tabBarHeight = 0;
+    auto isTabBarVisible = m_viewManager->activeContainer()->tabBar()->isVisible();
+    if (isTabBarVisible) {
+        tabBarHeight = m_viewManager->activeContainer()->tabBar()->geometry().height();
+    }
+
+    // set the position above the south tab bar
+    auto tabBarPos = (QTabWidget::TabPosition)KonsoleSettings::tabBarPosition();
+    if (tabBarPos == QTabWidget::South && isTabBarVisible) {
+        y = mainWindowHeight - tabBarHeight - size.height() - 6;
+    }
+    // set the position below the north tab bar
+    // set the position to the top right corner if the tab bar is hidden
+    else {
+        y = mainWindowHeight - containerHeight + tabBarHeight + 6;
+    }
+    boundingRect.setTop(y);
+
+    // set the position to the right of the window
+    int scrollBarWidth = qApp->style()->pixelMetric(QStyle::PM_ScrollBarExtent);
+    int mainWindowWidth = window()->geometry().width();
+    int x = mainWindowWidth - size.width() - scrollBarWidth - 6;
+    const QPoint position{x, y};
+    move(position);
+}
diff --git a/src/searchtabs/SearchTabs.h b/src/searchtabs/SearchTabs.h
new file mode 100644
index 0000000000..c1b96c3a31
--- /dev/null
+++ b/src/searchtabs/SearchTabs.h
@@ -0,0 +1,68 @@
+/*
+    SPDX-FileCopyrightText: 2024 Troy Hoover <troytjh98 at gmail.com>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#pragma once
+
+// Own
+#include "SearchTabsModel.h"
+
+// Qt
+#include <QEvent>
+#include <QFrame>
+#include <QLineEdit>
+#include <QTreeView>
+
+// Konsole
+#include "ViewManager.h"
+#include "widgets/ViewContainer.h"
+
+class SearchTabsFilterProxyModel;
+
+namespace Konsole
+{
+
+class KONSOLEPRIVATE_EXPORT SearchTabs : public QFrame
+{
+public:
+    SearchTabs(ViewManager *viewManager);
+
+    /**
+     * update state
+     * will fill model with current open tabs
+     */
+    void updateState();
+    void updateViewGeometry();
+
+protected:
+    bool eventFilter(QObject *obj, QEvent *event) override;
+
+private:
+    void reselectFirst();
+
+    /**
+     * Return pressed, activate the selected tab
+     * and go back to background
+     */
+    void slotReturnPressed();
+
+private:
+    ViewManager *m_viewManager;
+
+    QLineEdit *m_inputLine;
+    QTreeView *m_listView;
+
+    /**
+     * tab model
+     */
+    SearchTabsModel *m_model = nullptr;
+
+    /**
+     * fuzzy filter model
+     */
+    SearchTabsFilterProxyModel *m_proxyModel = nullptr;
+};
+
+}
diff --git a/src/searchtabs/SearchTabsModel.cpp b/src/searchtabs/SearchTabsModel.cpp
new file mode 100644
index 0000000000..e9f91c9122
--- /dev/null
+++ b/src/searchtabs/SearchTabsModel.cpp
@@ -0,0 +1,69 @@
+/*
+    SPDX-FileCopyrightText: 2024 Troy Hoover <troytjh98 at gmail.com>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+// Own
+#include "SearchTabsModel.h"
+
+// Konsole
+#include "ViewProperties.h"
+
+using namespace Konsole;
+
+SearchTabsModel::SearchTabsModel(QObject *parent)
+    : QAbstractTableModel(parent)
+{
+}
+
+int SearchTabsModel::rowCount(const QModelIndex &parent) const
+{
+    if (parent.isValid()) {
+        return 0;
+    }
+    return (int)m_tabEntries.size();
+}
+
+int SearchTabsModel::columnCount(const QModelIndex &parent) const
+{
+    Q_UNUSED(parent);
+    return 1;
+}
+
+QVariant SearchTabsModel::data(const QModelIndex &idx, int role) const
+{
+    if (!idx.isValid()) {
+        return {};
+    }
+
+    const TabEntry &tab = m_tabEntries.at(idx.row());
+    switch (role) {
+    case Qt::DisplayRole:
+    case Role::Name:
+        return tab.name;
+    case Role::Score:
+        return tab.score;
+    case Role::View:
+        return tab.view;
+    default:
+        return {};
+    }
+
+    return {};
+}
+
+void SearchTabsModel::refresh(ViewManager *viewManager)
+{
+    QList<ViewProperties *> viewProperties = viewManager->viewProperties();
+
+    QVector<TabEntry> tabs;
+    tabs.reserve(viewProperties.size());
+    for (const auto view : std::as_const(viewProperties)) {
+        tabs.push_back(TabEntry{view->title(), view->identifier(), -1});
+    }
+
+    beginResetModel();
+    m_tabEntries = std::move(tabs);
+    endResetModel();
+}
diff --git a/src/searchtabs/SearchTabsModel.h b/src/searchtabs/SearchTabsModel.h
new file mode 100644
index 0000000000..c55dd82fac
--- /dev/null
+++ b/src/searchtabs/SearchTabsModel.h
@@ -0,0 +1,68 @@
+/*
+    SPDX-FileCopyrightText: 2024 Troy Hoover <troytjh98 at gmail.com>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#pragma once
+
+// Qt
+#include <QAbstractTableModel>
+#include <QStringList>
+#include <QVector>
+
+// Konsole
+#include "ViewManager.h"
+
+namespace Konsole
+{
+
+struct TabEntry {
+    QString name;
+    int view;
+    int score = -1;
+};
+
+class SearchTabsModel : public QAbstractTableModel
+{
+public:
+    enum Role {
+        Name = Qt::UserRole + 1,
+        View,
+        Score,
+    };
+    explicit SearchTabsModel(QObject *parent = nullptr);
+
+    int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+    int columnCount(const QModelIndex &parent) const override;
+    QVariant data(const QModelIndex &idx, int role) const override;
+    void refresh(ViewManager *viewManager);
+
+    bool isValid(int row) const
+    {
+        return row >= 0 && row < m_tabEntries.size();
+    }
+
+    void setScoreForIndex(int row, int score)
+    {
+        m_tabEntries[row].score = score;
+    }
+
+    const QString &idxToName(int row) const
+    {
+        return m_tabEntries.at(row).name;
+    }
+
+    int idxScore(const QModelIndex &idx) const
+    {
+        if (!idx.isValid()) {
+            return {};
+        }
+        return m_tabEntries.at(idx.row()).score;
+    }
+
+private:
+    QVector<TabEntry> m_tabEntries;
+};
+
+}
diff --git a/src/searchtabs/kfts_fuzzy_match.h b/src/searchtabs/kfts_fuzzy_match.h
new file mode 100644
index 0000000000..0e608d8d96
--- /dev/null
+++ b/src/searchtabs/kfts_fuzzy_match.h
@@ -0,0 +1,431 @@
+/*
+    SPDX-FileCopyrightText: 2017 Forrest Smith
+    SPDX-FileCopyrightText: 2020 Waqar Ahmed
+
+    SPDX-License-Identifier: LGPL-2.0-or-later
+*/
+
+#pragma once
+
+#include <QDebug>
+#include <QString>
+#include <QTextLayout>
+
+/**
+ * This is based on https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.h
+ * with modifications for Qt
+ *
+ * Dont include this file in a header file, please :)
+ */
+
+namespace kfts
+{
+/**
+ * @brief simple fuzzy matching of chars in @a pattern with chars in @a str sequentially
+ */
+Q_DECL_UNUSED static bool fuzzy_match_simple(const QStringView pattern, const QStringView str);
+
+/**
+ * @brief This should be the main function you should use. @a outscore is the score
+ * of this match and should be used to sort the results later. Without sorting of the
+ * results this function won't be as effective.
+ */
+Q_DECL_UNUSED static bool fuzzy_match(const QStringView pattern, const QStringView str, int &outScore);
+Q_DECL_UNUSED static bool fuzzy_match(const QStringView pattern, const QStringView str, int &outScore, uint8_t *matches);
+
+/**
+ * @brief get string for display in treeview / listview. This should be used from style delegate.
+ * For example: with @a pattern = "kate", @a str = "kateapp" and @htmlTag = "<b>
+ * the output will be <b>k</b><b>a</b><b>t</b><b>e</b>app which will be visible to user as
+ * <b>kate</b>app.
+ *
+ * TODO: improve this so that we don't have to put html tags on every char probably using some kind
+ * of interval container
+ */
+Q_DECL_UNUSED static QString to_fuzzy_matched_display_string(const QStringView pattern, QString &str, const QString &htmlTag, const QString &htmlTagClose);
+Q_DECL_UNUSED static QString
+to_scored_fuzzy_matched_display_string(const QStringView pattern, QString &str, const QString &htmlTag, const QString &htmlTagClose);
+}
+
+namespace kfts
+{
+// Forward declarations for "private" implementation
+namespace fuzzy_internal
+{
+static inline constexpr QChar toLower(QChar c)
+{
+    return c.isLower() ? c : c.toLower();
+}
+
+static bool fuzzy_match_recursive(QStringView::const_iterator pattern,
+                                  QStringView::const_iterator str,
+                                  int &outScore,
+                                  const QStringView::const_iterator strBegin,
+                                  const QStringView::const_iterator strEnd,
+                                  const QStringView::const_iterator patternEnd,
+                                  uint8_t const *srcMatches,
+                                  uint8_t *newMatches,
+                                  int nextMatch,
+                                  int &totalMatches,
+                                  int &recursionCount);
+}
+
+// Public interface
+static bool fuzzy_match_simple(const QStringView pattern, const QStringView str)
+{
+    if (pattern.length() == 1) {
+        return str.contains(pattern, Qt::CaseInsensitive);
+    }
+
+    auto patternIt = pattern.cbegin();
+    for (auto strIt = str.cbegin(); strIt != str.cend() && patternIt != pattern.cend(); ++strIt) {
+        if (strIt->toLower() == patternIt->toLower()) {
+            ++patternIt;
+        }
+    }
+    return patternIt == pattern.cend();
+}
+
+static bool fuzzy_match(const QStringView pattern, const QStringView str, int &outScore)
+{
+    if (pattern.length() == 1) {
+        const int found = str.indexOf(pattern, Qt::CaseInsensitive);
+        if (found >= 0) {
+            outScore = 250 - found;
+            outScore += pattern.at(0) == str.at(found);
+            return true;
+        } else {
+            outScore = 0;
+            return false;
+        }
+    }
+
+    // simple substring matching to flush out non-matching stuff
+    auto patternIt = pattern.cbegin();
+    bool lower = patternIt->isLower();
+    QChar cUp = lower ? patternIt->toUpper() : *patternIt;
+    QChar cLow = lower ? *patternIt : patternIt->toLower();
+    for (auto strIt = str.cbegin(); strIt != str.cend() && patternIt != pattern.cend(); ++strIt) {
+        if (*strIt == cLow || *strIt == cUp) {
+            ++patternIt;
+            lower = patternIt->isLower();
+            cUp = lower ? patternIt->toUpper() : *patternIt;
+            cLow = lower ? *patternIt : patternIt->toLower();
+        }
+    }
+
+    if (patternIt != pattern.cend()) {
+        outScore = 0;
+        return false;
+    }
+
+    uint8_t matches[256];
+    return fuzzy_match(pattern, str, outScore, matches);
+}
+
+static bool fuzzy_match(const QStringView pattern, const QStringView str, int &outScore, uint8_t *matches)
+{
+    int recursionCount = 0;
+
+    auto strIt = str.cbegin();
+    auto patternIt = pattern.cbegin();
+    const auto patternEnd = pattern.cend();
+    const auto strEnd = str.cend();
+    int totalMatches = 0;
+
+    return fuzzy_internal::fuzzy_match_recursive(patternIt, strIt, outScore, strIt, strEnd, patternEnd, nullptr, matches, 0, totalMatches, recursionCount);
+}
+
+// Private implementation
+static bool fuzzy_internal::fuzzy_match_recursive(QStringView::const_iterator pattern,
+                                                  QStringView::const_iterator str,
+                                                  int &outScore,
+                                                  const QStringView::const_iterator strBegin,
+                                                  const QStringView::const_iterator strEnd,
+                                                  const QStringView::const_iterator patternEnd,
+                                                  const uint8_t *srcMatches,
+                                                  uint8_t *matches,
+                                                  int nextMatch,
+                                                  int &totalMatches,
+                                                  int &recursionCount)
+{
+    static constexpr int recursionLimit = 10;
+    // max number of matches allowed, this should be enough
+    static constexpr int maxMatches = 256;
+
+    // Count recursions
+    ++recursionCount;
+    if (recursionCount >= recursionLimit) {
+        return false;
+    }
+
+    // Detect end of strings
+    if (pattern == patternEnd || str == strEnd) {
+        return false;
+    }
+
+    // Recursion params
+    bool recursiveMatch = false;
+    uint8_t bestRecursiveMatches[maxMatches];
+    int bestRecursiveScore = 0;
+
+    // Loop through pattern and str looking for a match
+    bool firstMatch = true;
+    QChar currentPatternChar = toLower(*pattern);
+    // Are we matching in sequence start from start?
+    while (pattern != patternEnd && str != strEnd) {
+        // Found match
+        if (currentPatternChar == toLower(*str)) {
+            // Supplied matches buffer was too short
+            if (nextMatch >= maxMatches) {
+                return false;
+            }
+
+            // "Copy-on-Write" srcMatches into matches
+            if (firstMatch && srcMatches) {
+                memcpy(matches, srcMatches, nextMatch);
+                firstMatch = false;
+            }
+
+            // Recursive call that "skips" this match
+            uint8_t recursiveMatches[maxMatches];
+            int recursiveScore = 0;
+            const auto strNextChar = std::next(str);
+            if (fuzzy_match_recursive(pattern,
+                                      strNextChar,
+                                      recursiveScore,
+                                      strBegin,
+                                      strEnd,
+                                      patternEnd,
+                                      matches,
+                                      recursiveMatches,
+                                      nextMatch,
+                                      totalMatches,
+                                      recursionCount)) {
+                // Pick best recursive score
+                if (!recursiveMatch || recursiveScore > bestRecursiveScore) {
+                    memcpy(bestRecursiveMatches, recursiveMatches, maxMatches);
+                    bestRecursiveScore = recursiveScore;
+                }
+                recursiveMatch = true;
+            }
+
+            // Advance
+            matches[nextMatch++] = (uint8_t)(std::distance(strBegin, str));
+            ++pattern;
+            currentPatternChar = toLower(*pattern);
+        }
+        ++str;
+    }
+
+    // Determine if full pattern was matched
+    const bool matched = pattern == patternEnd;
+
+    // Calculate score
+    if (matched) {
+        static constexpr int firstSepScoreDiff = 3;
+
+        static constexpr int sequentialBonus = 20;
+        static constexpr int separatorBonus = 25; // bonus if match occurs after a separator
+        static constexpr int firstLetterBonus = 15; // bonus if the first letter is matched
+        static constexpr int firstLetterSepMatchBonus = firstLetterBonus - firstSepScoreDiff; // bonus if the first matched letter is camel or separator
+
+        static constexpr int unmatchedLetterPenalty = -1; // penalty for every letter that doesn't matter
+
+        int nonBeginSequenceBonus = 10;
+        // points by which nonBeginSequenceBonus is increment on every matched letter
+        static constexpr int nonBeginSequenceIncrement = 4;
+
+        // Initialize score
+        outScore = 100;
+
+#define debug_algo 0
+#if debug_algo
+#define dbg(...) qDebug(__VA_ARGS__)
+#else
+#define dbg(...)
+#endif
+
+        // Apply unmatched penalty
+        const int unmatched = (int)(std::distance(strBegin, strEnd)) - nextMatch;
+        outScore += unmatchedLetterPenalty * unmatched;
+        dbg("unmatchedLetterPenalty, unmatched count: %d, outScore: %d", unmatched, outScore);
+
+        bool inSeparatorSeq = false;
+        int i = 0;
+        if (matches[i] == 0) {
+            // First letter match has the highest score
+            outScore += firstLetterBonus + separatorBonus;
+            dbg("firstLetterBonus, outScore: %d", outScore);
+            inSeparatorSeq = true;
+        } else {
+            const QChar neighbor = *(strBegin + matches[i] - 1);
+            const QChar curr = *(strBegin + matches[i]);
+            const bool neighborSeparator = neighbor == QLatin1Char('_') || neighbor == QLatin1Char(' ');
+            if (neighborSeparator || (neighbor.isLower() && curr.isUpper())) {
+                // the first letter that got matched was a sepcial char .e., camel or at a separator
+                outScore += firstLetterSepMatchBonus + separatorBonus;
+                dbg("firstLetterSepMatchBonus at %d, letter: %c, outScore: %d", matches[i], curr.toLatin1(), outScore);
+                inSeparatorSeq = true;
+            } else {
+                // nothing
+                nonBeginSequenceBonus += nonBeginSequenceIncrement;
+            }
+            // We didn't match any special positions, apply leading penalty
+            outScore += -(matches[i]);
+            dbg("LeadingPenalty because no first letter match, outScore: %d", outScore);
+        }
+        i++;
+
+        bool allConsecutive = true;
+        // Apply ordering bonuses
+        for (; i < nextMatch; ++i) {
+            const uint8_t currIdx = matches[i];
+            const uint8_t prevIdx = matches[i - 1];
+            // Sequential
+            if (currIdx == (prevIdx + 1)) {
+                if (i == matches[i]) {
+                    // We are in a sequence beginning from first letter
+                    outScore += sequentialBonus;
+                    dbg("sequentialBonus at %d, letter: %c, outScore: %d", matches[i], (strBegin + currIdx)->toLatin1(), outScore);
+                } else if (inSeparatorSeq) {
+                    // we are in a sequnce beginning from a separator like camelHump or underscore
+                    outScore += sequentialBonus - firstSepScoreDiff;
+                    dbg("in separator seq, [sequentialBonus - 5] at %d, letter: %c, outScore: %d", matches[i], (strBegin + currIdx)->toLatin1(), outScore);
+                } else {
+                    // We are in a random sequence
+                    outScore += nonBeginSequenceBonus;
+                    nonBeginSequenceBonus += nonBeginSequenceIncrement;
+                    dbg("nonBeginSequenceBonus at %d, letter: %c, outScore: %d", matches[i], (strBegin + currIdx)->toLatin1(), outScore);
+                }
+            } else {
+                allConsecutive = false;
+
+                // there is a gap between matching chars, apply penalty
+                int penalty = -((currIdx - prevIdx)) - 2;
+                outScore += penalty;
+                inSeparatorSeq = false;
+                nonBeginSequenceBonus = 10;
+                dbg("gap penalty[%d] at %d, letter: %c, outScore: %d", penalty, matches[i], (strBegin + currIdx)->toLatin1(), outScore);
+            }
+
+            // Check for bonuses based on neighbor character value
+            // Camel case
+            const QChar neighbor = *(strBegin + currIdx - 1);
+            const QChar curr = *(strBegin + currIdx);
+            // if camel case bonus, then not snake / separator.
+            // This prevents double bonuses
+            const bool neighborSeparator = neighbor == QLatin1Char('_') || neighbor == QLatin1Char(' ');
+            if (neighborSeparator || (neighbor.isLower() && curr.isUpper())) {
+                outScore += separatorBonus;
+                dbg("separatorBonus at %d, letter: %c, outScore: %d", matches[i], (strBegin + currIdx)->toLatin1(), outScore);
+                inSeparatorSeq = true;
+                continue;
+            }
+        }
+
+        if (allConsecutive && nextMatch >= 4) {
+            outScore *= 2;
+            dbg("allConsecutive double the score, outScore: %d", outScore);
+        }
+    }
+
+    totalMatches = nextMatch;
+    // Return best result
+    if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) {
+        // Recursive score is better than "this"
+        memcpy(matches, bestRecursiveMatches, maxMatches);
+        outScore = bestRecursiveScore;
+        return true;
+    } else if (matched) {
+        // "this" score is better than recursive
+        return true;
+    } else {
+        // no match
+        return false;
+    }
+}
+
+static QString to_fuzzy_matched_display_string(const QStringView pattern, QString &str, const QString &htmlTag, const QString &htmlTagClose)
+{
+    /**
+     * FIXME Don't do so many appends. Instead consider using some interval based solution to wrap a range
+     * of text with the html <tag></tag>
+     */
+    int j = 0;
+    for (int i = 0; i < str.size() && j < pattern.size(); ++i) {
+        if (fuzzy_internal::toLower(str.at(i)) == fuzzy_internal::toLower(pattern.at(j))) {
+            str.replace(i, 1, htmlTag + str.at(i) + htmlTagClose);
+            i += htmlTag.size() + htmlTagClose.size();
+            ++j;
+        }
+    }
+    return str;
+}
+
+static QString to_scored_fuzzy_matched_display_string(const QStringView pattern, QString &str, const QString &htmlTag, const QString &htmlTagClose)
+{
+    if (pattern.isEmpty()) {
+        return str;
+    }
+
+    uint8_t matches[256];
+    int totalMatches = 0;
+    {
+        int score = 0;
+        int recursionCount = 0;
+
+        auto strIt = str.cbegin();
+        auto patternIt = pattern.cbegin();
+        const auto patternEnd = pattern.cend();
+        const auto strEnd = str.cend();
+
+        fuzzy_internal::fuzzy_match_recursive(patternIt, strIt, score, strIt, strEnd, patternEnd, nullptr, matches, 0, totalMatches, recursionCount);
+    }
+
+    int offset = 0;
+    for (int i = 0; i < totalMatches; ++i) {
+        str.insert(matches[i] + offset, htmlTag);
+        offset += htmlTag.size();
+        str.insert(matches[i] + offset + 1, htmlTagClose);
+        offset += htmlTagClose.size();
+    }
+
+    return str;
+}
+
+Q_DECL_UNUSED static QList<QTextLayout::FormatRange>
+get_fuzzy_match_formats(const QStringView pattern, const QStringView str, int offset, const QTextCharFormat &fmt)
+{
+    QList<QTextLayout::FormatRange> ranges;
+    if (pattern.isEmpty()) {
+        return ranges;
+    }
+
+    int totalMatches = 0;
+    int score = 0;
+    int recursionCount = 0;
+
+    auto strIt = str.cbegin();
+    auto patternIt = pattern.cbegin();
+    const auto patternEnd = pattern.cend();
+    const auto strEnd = str.cend();
+
+    uint8_t matches[256];
+    fuzzy_internal::fuzzy_match_recursive(patternIt, strIt, score, strIt, strEnd, patternEnd, nullptr, matches, 0, totalMatches, recursionCount);
+
+    int j = 0;
+    for (int i = 0; i < totalMatches; ++i) {
+        auto matchPos = matches[i];
+        if (!ranges.isEmpty() && matchPos == j + 1) {
+            ranges.last().length++;
+        } else {
+            ranges.append({matchPos + offset, 1, fmt});
+        }
+        j = matchPos;
+    }
+
+    return ranges;
+}
+
+} // namespace kfts
diff --git a/src/settings/TabBarSettings.ui b/src/settings/TabBarSettings.ui
index 3981a743de..13bfaed7f3 100644
--- a/src/settings/TabBarSettings.ui
+++ b/src/settings/TabBarSettings.ui
@@ -7,7 +7,7 @@
     <x>0</x>
     <y>0</y>
     <width>507</width>
-    <height>473</height>
+    <height>618</height>
    </rect>
   </property>
   <layout class="QVBoxLayout" name="verticalLayout">
@@ -226,6 +226,52 @@
           </spacer>
          </item>
          <item row="13" column="0">
+          <widget class="QLabel" name="showSearchTabsButtonLabel">
+           <property name="text">
+            <string>Show Search Tabs button:</string>
+           </property>
+           <property name="alignment">
+            <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
+           </property>
+          </widget>
+         </item>
+         <item row="13" column="1" colspan="2">
+          <widget class="QRadioButton" name="ShowSearchTabsButton">
+           <property name="text">
+            <string>Always</string>
+           </property>
+           <attribute name="buttonGroup">
+            <string notr="true">kcfg_SearchTabsButton</string>
+           </attribute>
+          </widget>
+         </item>
+         <item row="14" column="1" colspan="2">
+          <widget class="QRadioButton" name="HideSearchTabsButton">
+           <property name="text">
+            <string>Never</string>
+           </property>
+           <attribute name="buttonGroup">
+            <string notr="true">kcfg_SearchTabsButton</string>
+           </attribute>
+          </widget>
+         </item>
+         <item row="15" column="1" colspan="2">
+          <spacer>
+           <property name="orientation">
+            <enum>Qt::Orientation::Vertical</enum>
+           </property>
+           <property name="sizeType">
+            <enum>QSizePolicy::Policy::Fixed</enum>
+           </property>
+           <property name="sizeHint" stdset="0">
+            <size>
+             <width>20</width>
+             <height>16</height>
+            </size>
+           </property>
+          </spacer>
+         </item>
+         <item row="16" column="0">
           <widget class="QLabel" name="miscellaneousAppearanceLabel">
            <property name="text">
             <string comment="@item:intext Miscellaneous Options">Miscellaneous:</string>
@@ -235,28 +281,28 @@
            </property>
           </widget>
          </item>
-         <item row="13" column="1" colspan="2">
+         <item row="16" column="1" colspan="2">
           <widget class="QCheckBox" name="kcfg_NewTabButton">
            <property name="text">
             <string>Show 'New Tab' button</string>
            </property>
           </widget>
          </item>
-         <item row="14" column="1" colspan="2">
+         <item row="17" column="1" colspan="2">
           <widget class="QCheckBox" name="kcfg_ExpandTabWidth">
            <property name="text">
             <string>Expand individual tab widths to full window</string>
            </property>
           </widget>
          </item>
-         <item row="15" column="1" colspan="2">
+         <item row="18" column="1" colspan="2">
           <widget class="QCheckBox" name="kcfg_TabBarUseUserStyleSheet">
            <property name="text">
             <string>Use user-defined stylesheet:</string>
            </property>
           </widget>
          </item>
-         <item row="16" column="1">
+         <item row="19" column="1">
           <spacer>
            <property name="orientation">
             <enum>Qt::Orientation::Horizontal</enum>
@@ -272,7 +318,7 @@
            </property>
           </spacer>
          </item>
-         <item row="16" column="2">
+         <item row="19" column="2">
           <widget class="KUrlRequester" name="kcfg_TabBarUserStyleSheetFile">
            <property name="enabled">
             <bool>false</bool>
@@ -522,16 +568,28 @@
   <tabstop>ShowTabBarWhenNeeded</tabstop>
   <tabstop>AlwaysShowTabBar</tabstop>
   <tabstop>AlwaysHideTabBar</tabstop>
-  <tabstop>Bottom</tabstop>
   <tabstop>Top</tabstop>
+  <tabstop>Bottom</tabstop>
+  <tabstop>Left</tabstop>
+  <tabstop>Right</tabstop>
   <tabstop>OnEachTab</tabstop>
   <tabstop>OnTabBar</tabstop>
   <tabstop>None</tabstop>
+  <tabstop>ShowSearchTabsButton</tabstop>
+  <tabstop>HideSearchTabsButton</tabstop>
+  <tabstop>kcfg_NewTabButton</tabstop>
   <tabstop>kcfg_ExpandTabWidth</tabstop>
   <tabstop>kcfg_TabBarUseUserStyleSheet</tabstop>
+  <tabstop>kcfg_TabBarUserStyleSheetFile</tabstop>
+  <tabstop>kcfg_CloseTabOnMiddleMouseButton</tabstop>
   <tabstop>PutNewTabAtTheEnd</tabstop>
   <tabstop>PutNewTabAfterCurrentTab</tabstop>
-  <tabstop>kcfg_CloseTabOnMiddleMouseButton</tabstop>
+  <tabstop>ShowSplitHeaderWhenNeeded</tabstop>
+  <tabstop>AlwaysHideSplitHeader</tabstop>
+  <tabstop>SplitDragHandleSmall</tabstop>
+  <tabstop>SplitDragHandleMedium</tabstop>
+  <tabstop>SplitDragHandleLarge</tabstop>
+  <tabstop>AlwaysShowSplitHeader</tabstop>
  </tabstops>
  <resources/>
  <connections>
@@ -775,9 +833,58 @@
     </hint>
    </hints>
   </connection>
+  <connection>
+   <sender>AlwaysHideTabBar</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>showSearchTabsButtonLabel</receiver>
+   <slot>setDisabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>20</x>
+     <y>20</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>89</x>
+     <y>429</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>AlwaysHideTabBar</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>ShowSearchTabsButton</receiver>
+   <slot>setDisabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>20</x>
+     <y>20</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>20</x>
+     <y>20</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>AlwaysHideTabBar</sender>
+   <signal>toggled(bool)</signal>
+   <receiver>HideSearchTabsButton</receiver>
+   <slot>setDisabled(bool)</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>20</x>
+     <y>20</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>20</x>
+     <y>20</y>
+    </hint>
+   </hints>
+  </connection>
  </connections>
  <buttongroups>
   <buttongroup name="kcfg_SplitViewVisibility"/>
+  <buttongroup name="kcfg_SearchTabsButton"/>
   <buttongroup name="kcfg_CloseTabButton"/>
   <buttongroup name="kcfg_NewTabBehavior"/>
   <buttongroup name="kcfg_SplitDragHandleSize"/>
diff --git a/src/settings/konsole.kcfg b/src/settings/konsole.kcfg
index 82bd09d43c..b71c8c5289 100644
--- a/src/settings/konsole.kcfg
+++ b/src/settings/konsole.kcfg
@@ -155,6 +155,14 @@
       <label>Control the visibility of 'New Tab' button on the tab bar</label>
       <default>false</default>
     </entry>
+    <entry name="SearchTabsButton" type="Enum">
+      <label>Control the visibility of 'Search Tabs' button on the tab bar</label>
+      <choices>
+        <choice name="ShowSearchTabsButton" />
+        <choice name="HideSearchTabsButton" />
+      </choices>
+      <default>ShowSearchTabsButton</default>
+    </entry>
     <entry name="CloseTabButton" type="Enum">
       <label>Control where the "Close tab" button will be displayed</label>
       <choices>
diff --git a/src/widgets/ViewContainer.cpp b/src/widgets/ViewContainer.cpp
index 8a44432a7b..8aae7d3aad 100644
--- a/src/widgets/ViewContainer.cpp
+++ b/src/widgets/ViewContainer.cpp
@@ -9,6 +9,7 @@
 #include "config-konsole.h"
 
 // Qt
+#include <QBoxLayout>
 #include <QFile>
 #include <QKeyEvent>
 #include <QMenu>
@@ -25,6 +26,7 @@
 #include "KonsoleSettings.h"
 #include "ViewProperties.h"
 #include "profile/ProfileList.h"
+#include "searchtabs/SearchTabs.h"
 #include "session/SessionController.h"
 #include "session/SessionManager.h"
 #include "terminalDisplay/TerminalDisplay.h"
@@ -39,6 +41,7 @@ TabbedViewContainer::TabbedViewContainer(ViewManager *connectedViewManager, QWid
     : QTabWidget(parent)
     , _connectedViewManager(connectedViewManager)
     , _newTabButton(new QToolButton(this))
+    , _searchTabsButton(new QToolButton(this))
     , _closeTabButton(new QToolButton(this))
     , _contextMenuTabIndex(-1)
     , _newTabBehavior(PutNewTabAtTheEnd)
@@ -56,6 +59,11 @@ TabbedViewContainer::TabbedViewContainer(ViewManager *connectedViewManager, QWid
     _newTabButton->setToolTip(i18nc("@info:tooltip", "Open a new tab"));
     connect(_newTabButton, &QToolButton::clicked, this, &TabbedViewContainer::newViewRequest);
 
+    _searchTabsButton->setIcon(QIcon::fromTheme(QStringLiteral("quickopen")));
+    _searchTabsButton->setAutoRaise(true);
+    _searchTabsButton->setToolTip(i18nc("@info:tooltip", "Search Tabs"));
+    connect(_searchTabsButton, &QToolButton::clicked, this, &TabbedViewContainer::searchTabs);
+
     _closeTabButton->setIcon(QIcon::fromTheme(QStringLiteral("tab-close")));
     _closeTabButton->setAutoRaise(true);
     _closeTabButton->setToolTip(i18nc("@info:tooltip", "Close this tab"));
@@ -205,9 +213,23 @@ void TabbedViewContainer::konsoleConfigChanged()
     setCornerWidget(KonsoleSettings::newTabButton() ? _newTabButton : nullptr, Qt::TopLeftCorner);
     _newTabButton->setVisible(KonsoleSettings::newTabButton());
 
-    setCornerWidget(KonsoleSettings::closeTabButton() == 1 ? _closeTabButton : nullptr, Qt::TopRightCorner);
+    // Add Layout for right corner tool buttons
+    auto layout = new QHBoxLayout();
+    layout->setStretch(0, 10);
+    layout->setContentsMargins(0, 0, 0, 0);
+    layout->setSpacing(0);
+
+    layout->addWidget(KonsoleSettings::searchTabsButton() == 0 ? _searchTabsButton : nullptr);
+    _searchTabsButton->setVisible(KonsoleSettings::searchTabsButton() == 0);
+
+    layout->addWidget(KonsoleSettings::closeTabButton() == 1 ? _closeTabButton : nullptr);
     _closeTabButton->setVisible(KonsoleSettings::closeTabButton() == 1);
 
+    QWidget *rightCornerWidget = new QWidget();
+    rightCornerWidget->setLayout(layout);
+    setCornerWidget(rightCornerWidget, Qt::TopRightCorner);
+    rightCornerWidget->setVisible(true);
+
     tabBar()->setTabsClosable(KonsoleSettings::closeTabButton() == 0);
 
     tabBar()->setExpanding(KonsoleSettings::expandTabWidth());
@@ -467,6 +489,17 @@ void TabbedViewContainer::renameTab(int index)
     }
 }
 
+void TabbedViewContainer::searchTabs()
+{
+    /**
+     * show tab search and pass focus to it
+     */
+    SearchTabs *searchTabs = new SearchTabs(this->connectedViewManager());
+    setFocusProxy(searchTabs);
+    searchTabs->raise();
+    searchTabs->show();
+}
+
 void TabbedViewContainer::openTabContextMenu(const QPoint &point)
 {
     if (point.isNull()) {
diff --git a/src/widgets/ViewContainer.h b/src/widgets/ViewContainer.h
index 6c6f8d7c60..d6f56c61ef 100644
--- a/src/widgets/ViewContainer.h
+++ b/src/widgets/ViewContainer.h
@@ -117,6 +117,7 @@ public:
     // TODO: Re-enable this later.
     //    void setNewViewMenu(QMenu *menu);
     void renameTab(int index);
+    void searchTabs();
     ViewManager *connectedViewManager();
     void currentTabChanged(int index);
     void closeCurrentTab();
@@ -245,6 +246,7 @@ private:
     ViewManager *_connectedViewManager;
     QMenu *_contextPopupMenu;
     QToolButton *_newTabButton;
+    QToolButton *_searchTabsButton;
     QToolButton *_closeTabButton;
     int _contextMenuTabIndex;
     NewTabBehavior _newTabBehavior;


More information about the kde-doc-english mailing list