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

Kurt Hindenburg <[email protected]>
Newsgroups gmane.comp.kde.cvs,gmane.comp.kde.doc
Message-ID <[email protected]>
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 <[email protected]>
+
+    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 <[email protected]>
+
+    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 <[email protected]>
+
+    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 <[email protected]>
+
+    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;
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.