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;
