[system/dolphin] src: Allow backward type-ahead navigation with Shift+letter

Méven Car <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 7d4fbef808940b2d12435b48bcdbd634cdf5ed82 by Méven Car.
Committed on 20/07/2026 at 10:36.
Pushed by meven into branch 'master'.

Allow backward type-ahead navigation with Shift+letter

Typing a letter selects the next matching item, but there was no way to
reach a previous match without cycling through the whole list. Make
Shift+letter search backwards instead. The type-ahead search is
case-insensitive, so the capital letter is otherwise redundant and can be
repurposed; Shift-produced punctuation keeps its literal meaning and stays
searchable.

A searchBackwards flag is threaded from the key handler through the
keyboard search manager down to KFileItemModel::indexForKeyboardSearch,
which gains a reverse scan that wraps around.

BUG: 374392

M  +26   -3    src/kitemviews/kfileitemmodel.cpp
M  +1    -1    src/kitemviews/kfileitemmodel.h
M  +21   -6    src/kitemviews/kitemlistcontroller.cpp
M  +1    -1    src/kitemviews/kitemlistcontroller.h
M  +2    -1    src/kitemviews/kitemmodelbase.cpp
M  +3    -1    src/kitemviews/kitemmodelbase.h
M  +4    -3    src/kitemviews/private/kitemlistkeyboardsearchmanager.cpp
M  +7    -5    src/kitemviews/private/kitemlistkeyboardsearchmanager.h
M  +10   -0    src/tests/kfileitemmodeltest.cpp
M  +49   -7    src/tests/kitemlistkeyboardsearchmanagertest.cpp

https://invent.kde.org/system/dolphin/-/commit/7d4fbef808940b2d12435b48bcdbd634cdf5ed82

diff --git a/src/kitemviews/kfileitemmodel.cpp b/src/kitemviews/kfileitemmodel.cpp
index 549217761c..1ca74537d1 100644
--- a/src/kitemviews/kfileitemmodel.cpp
+++ b/src/kitemviews/kfileitemmodel.cpp
@@ -576,17 +576,40 @@ QString removeMarks(const QString &original)
 }
 }
 
-int KFileItemModel::indexForKeyboardSearch(const QString &text, int startFromIndex) const
+int KFileItemModel::indexForKeyboardSearch(const QString &text, int startFromIndex, bool searchBackwards) const
 {
     const auto noMarkText = removeMarks(text);
+    const auto matches = [&noMarkText, this](int i) {
+        return removeMarks(fileItem(i).text()).startsWith(noMarkText, Qt::CaseInsensitive);
+    };
+
+    if (searchBackwards) {
+        // A negative start (searching before the first item) wraps to the end.
+        if (startFromIndex < 0) {
+            startFromIndex = count() - 1;
+        }
+        startFromIndex = qMin(startFromIndex, count() - 1);
+        for (int i = startFromIndex; i >= 0; --i) {
+            if (matches(i)) {
+                return i;
+            }
+        }
+        for (int i = count() - 1; i > startFromIndex; --i) {
+            if (matches(i)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
     startFromIndex = qMax(0, startFromIndex);
     for (int i = startFromIndex; i < count(); ++i) {
-        if (removeMarks(fileItem(i).text()).startsWith(noMarkText, Qt::CaseInsensitive)) {
+        if (matches(i)) {
             return i;
         }
     }
     for (int i = 0; i < startFromIndex; ++i) {
-        if (removeMarks(fileItem(i).text()).startsWith(noMarkText, Qt::CaseInsensitive)) {
+        if (matches(i)) {
             return i;
         }
     }
diff --git a/src/kitemviews/kfileitemmodel.h b/src/kitemviews/kfileitemmodel.h
index 0ac5d92617..9e30486079 100644
--- a/src/kitemviews/kfileitemmodel.h
+++ b/src/kitemviews/kfileitemmodel.h
@@ -105,7 +105,7 @@ public:
 
     QMimeData *createMimeData(const KItemSet &indexes) const override;
 
-    int indexForKeyboardSearch(const QString &text, int startFromIndex = 0) const override;
+    int indexForKeyboardSearch(const QString &text, int startFromIndex = 0, bool searchBackwards = false) const override;
 
     bool supportsDropping(int index) const override;
 
diff --git a/src/kitemviews/kitemlistcontroller.cpp b/src/kitemviews/kitemlistcontroller.cpp
index fdf776efc0..3c450b65fa 100644
--- a/src/kitemviews/kitemlistcontroller.cpp
+++ b/src/kitemviews/kitemlistcontroller.cpp
@@ -471,12 +471,20 @@ bool KItemListController::keyPressEvent(QKeyEvent *event)
             }
         }
         Q_FALLTHROUGH(); // fall through to the default case and add the Space to the current search string.
-    default:
-        m_keyboardManager->addKeys(event->text());
+    default: {
+        // Shift+letter searches backwards. Since the type-ahead search is
+        // case-insensitive the capital letter is redundant for matching, so it
+        // can be repurposed for backward navigation (bug 374392). Other
+        // Shift-produced characters (e.g. punctuation) keep their literal
+        // meaning so they remain searchable.
+        const QString text = event->text();
+        const bool searchBackwards = shiftPressed && text.size() == 1 && text.at(0).isLetter();
+        m_keyboardManager->addKeys(text, searchBackwards);
         // Make sure unconsumed events get propagated up the chain. #302329
         event->ignore();
         return false;
     }
+    }
 
     if (m_selectionManager->currentItem() != index) {
         switch (m_selectionBehavior) {
@@ -515,18 +523,25 @@ bool KItemListController::keyPressEvent(QKeyEvent *event)
     return true;
 }
 
-void KItemListController::slotChangeCurrentItem(const QString &text, bool searchFromNextItem, bool *found)
+void KItemListController::slotChangeCurrentItem(const QString &text, bool searchFromNextItem, bool searchBackwards, bool *found)
 {
     *found = false;
     if (!m_model || m_model->count() == 0) {
         return;
     }
     int index;
-    // In selection mode, always use the current (underlined) item, or the next item, for search start position.
+    // In selection mode, always use the current (underlined) item, or the adjacent item, as the search start position.
     if (m_selectionBehavior == NoSelection || m_selectionMode || m_selectionManager->hasSelection()) {
-        index = m_model->indexForKeyboardSearch(text, searchFromNextItem ? m_selectionManager->currentItem() + 1 : m_selectionManager->currentItem());
+        // When advancing to the next match, step away from the current item in
+        // the search direction: towards the end when searching forward, towards
+        // the beginning when searching backwards.
+        int startFromIndex = m_selectionManager->currentItem();
+        if (searchFromNextItem) {
+            startFromIndex += searchBackwards ? -1 : 1;
+        }
+        index = m_model->indexForKeyboardSearch(text, startFromIndex, searchBackwards);
     } else {
-        index = m_model->indexForKeyboardSearch(text, 0);
+        index = m_model->indexForKeyboardSearch(text, searchBackwards ? m_model->count() - 1 : 0, searchBackwards);
     }
     if (index >= 0) {
         if (m_selectionMode) {
diff --git a/src/kitemviews/kitemlistcontroller.h b/src/kitemviews/kitemlistcontroller.h
index 38a4b60388..f8cfe734ff 100644
--- a/src/kitemviews/kitemlistcontroller.h
+++ b/src/kitemviews/kitemlistcontroller.h
@@ -246,7 +246,7 @@ private Q_SLOTS:
      */
     void slotRubberBandChanged();
 
-    void slotChangeCurrentItem(const QString &text, bool searchFromNextItem, bool *found);
+    void slotChangeCurrentItem(const QString &text, bool searchFromNextItem, bool searchBackwards, bool *found);
 
     void slotAutoActivationTimeout();
 
diff --git a/src/kitemviews/kitemmodelbase.cpp b/src/kitemviews/kitemmodelbase.cpp
index a4f7b0890f..7cff2a9c48 100644
--- a/src/kitemviews/kitemmodelbase.cpp
+++ b/src/kitemviews/kitemmodelbase.cpp
@@ -141,10 +141,11 @@ QMimeData *KItemModelBase::createMimeData(const KItemSet &indexes) const
     return nullptr;
 }
 
-int KItemModelBase::indexForKeyboardSearch(const QString &text, int startFromIndex) const
+int KItemModelBase::indexForKeyboardSearch(const QString &text, int startFromIndex, bool searchBackwards) const
 {
     Q_UNUSED(text)
     Q_UNUSED(startFromIndex)
+    Q_UNUSED(searchBackwards)
     return -1;
 }
 
diff --git a/src/kitemviews/kitemmodelbase.h b/src/kitemviews/kitemmodelbase.h
index 68d1d5d8ae..5d968ea069 100644
--- a/src/kitemviews/kitemmodelbase.h
+++ b/src/kitemviews/kitemmodelbase.h
@@ -147,8 +147,10 @@ public:
      * beginning with string typed in through the keyboard, -1 if not found.
      * @param text              the text which has been typed in through the keyboard
      * @param startFromIndex    the index from which to start searching from
+     * @param searchBackwards   if true, search towards the beginning of the list
+     *                          (wrapping around) instead of towards the end
      */
-    virtual int indexForKeyboardSearch(const QString &text, int startFromIndex = 0) const;
+    virtual int indexForKeyboardSearch(const QString &text, int startFromIndex = 0, bool searchBackwards = false) const;
 
     /**
      * @return True, if the item with the index \a index basically supports dropping.
diff --git a/src/kitemviews/private/kitemlistkeyboardsearchmanager.cpp b/src/kitemviews/private/kitemlistkeyboardsearchmanager.cpp
index 19ce0cf21e..0c53fb9369 100644
--- a/src/kitemviews/private/kitemlistkeyboardsearchmanager.cpp
+++ b/src/kitemviews/private/kitemlistkeyboardsearchmanager.cpp
@@ -29,7 +29,7 @@ bool KItemListKeyboardSearchManager::isSearchAsYouTypeActive() const
     return !m_searchedString.isEmpty() && !m_keyboardInputTime.hasExpired(m_timeout);
 }
 
-void KItemListKeyboardSearchManager::addKeys(const QString &keys)
+void KItemListKeyboardSearchManager::addKeys(const QString &keys, bool searchBackwards)
 {
     if (shouldClearSearchIfInputTimeReached()) {
         m_searchedString.clear();
@@ -57,8 +57,9 @@ void KItemListKeyboardSearchManager::addKeys(const QString &keys)
         //    fall back to rapid navigation using either:
         //    - Last successful search string (for extended searches like "444" -> "4444")
         //    - First character only (original rapid navigation behavior)
+        // TODO: Think about getting rid of the bool parameters of changeCurrentItem()
         bool found = false;
-        Q_EMIT changeCurrentItem(m_searchedString, newSearch, &found);
+        Q_EMIT changeCurrentItem(m_searchedString, newSearch, searchBackwards, &found);
 
         if (found) {
             m_lastSuccessfulSearch = m_searchedString;
@@ -71,7 +72,7 @@ void KItemListKeyboardSearchManager::addKeys(const QString &keys)
                 rapidSearchString = QString(firstChar);
             }
 
-            Q_EMIT changeCurrentItem(rapidSearchString, true, &found);
+            Q_EMIT changeCurrentItem(rapidSearchString, true, searchBackwards, &found);
         }
     }
     m_keyboardInputTime.start();
diff --git a/src/kitemviews/private/kitemlistkeyboardsearchmanager.h b/src/kitemviews/private/kitemlistkeyboardsearchmanager.h
index 4437f97b49..77fb9cb7d4 100644
--- a/src/kitemviews/private/kitemlistkeyboardsearchmanager.h
+++ b/src/kitemviews/private/kitemlistkeyboardsearchmanager.h
@@ -31,9 +31,11 @@ public:
     ~KItemListKeyboardSearchManager() override;
 
     /**
-     * Add \a keys to the text buffer used for searching.
+     * Add \a keys to the text buffer used for searching. If \a searchBackwards
+     * is true, the match before the current item is selected instead of the one
+     * after it (used for Shift+letter backward navigation).
      */
-    void addKeys(const QString &keys);
+    void addKeys(const QString &keys, bool searchBackwards = false);
 
     /**
      * Sets the delay after which the search is cancelled to \a milliseconds.
@@ -63,10 +65,10 @@ Q_SIGNALS:
      * @param searchFromNextItem If true start searching from item next to the
      *                           current item. Otherwise, search from the
      *                           current item.
+     * @param searchBackwards    If true search towards the beginning of the list
+     *                           instead of towards the end.
      */
-    // TODO: Think about getting rid of the bool parameter
-    // (see https://doc.qt.io/archives/qq/qq13-apis.html#thebooleanparametertrap)
-    void changeCurrentItem(const QString &string, bool searchFromNextItem, bool *found);
+    void changeCurrentItem(const QString &string, bool searchFromNextItem, bool searchBackwards, bool *found);
 
 private:
     bool shouldClearSearchIfInputTimeReached();
diff --git a/src/tests/kfileitemmodeltest.cpp b/src/tests/kfileitemmodeltest.cpp
index 95b2f3d095..81540a1f1d 100644
--- a/src/tests/kfileitemmodeltest.cpp
+++ b/src/tests/kfileitemmodeltest.cpp
@@ -1459,6 +1459,16 @@ void KFileItemModelTest::testIndexForKeyboardSearch()
     QCOMPARE(m_model->indexForKeyboardSearch("uu", 0), 10);
     QCOMPARE(m_model->indexForKeyboardSearch("z", 0), 11);
 
+    // Backwards searches (towards the beginning of the list, wrapping around)
+    QCOMPARE(m_model->indexForKeyboardSearch("t", 5, true), 5); // The start index itself matches (Text1)
+    QCOMPARE(m_model->indexForKeyboardSearch("t", 4, true), 4); // Text
+    QCOMPARE(m_model->indexForKeyboardSearch("text1", 6, true), 5); // Skips Text2, finds Text1
+    QCOMPARE(m_model->indexForKeyboardSearch("a", 11, true), 1); // Nearest "a" at or before 11 is aa
+    QCOMPARE(m_model->indexForKeyboardSearch("a", 0, true), 0); // a
+    QCOMPARE(m_model->indexForKeyboardSearch("ž", 0, true), 11); // Wraps past the beginning to Ž
+    QCOMPARE(m_model->indexForKeyboardSearch("a", -1, true), 1); // A negative start wraps to the end
+    QCOMPARE(m_model->indexForKeyboardSearch("b", 5, true), -1); // No match
+
     // TODO: Maybe we should also test keyboard searches in directories which are not sorted by Name?
 }
 
diff --git a/src/tests/kitemlistkeyboardsearchmanagertest.cpp b/src/tests/kitemlistkeyboardsearchmanagertest.cpp
index 601576102b..a9152f84c7 100644
--- a/src/tests/kitemlistkeyboardsearchmanagertest.cpp
+++ b/src/tests/kitemlistkeyboardsearchmanagertest.cpp
@@ -24,6 +24,7 @@ private Q_SLOTS:
     void testAbortedKeyboardSearch();
     void testRepeatedKeyPress();
     void testPressShift();
+    void testBackwardSearch();
 
 private:
     KItemListKeyboardSearchManager m_keyboardSearchManager;
@@ -32,6 +33,7 @@ private:
     void verifySignal(QSignalSpy &spy,
                       const QString &expectedString,
                       bool expectedSearchFromNextItem,
+                      bool expectedSearchBackwards = false,
                       const std::source_location &location = std::source_location::current())
     {
         if (spy.count() != 1) {
@@ -48,10 +50,10 @@ private:
 
         QList<QVariant> arguments = spy.takeFirst();
 
-        if (arguments.size() != 3) {
+        if (arguments.size() != 4) {
             QTest::qFail(QString("Compared values are not the same\n"
                                  "   Actual   (arguments.size()): %1\n"
-                                 "   Expected (3)               : 3")
+                                 "   Expected (4)               : 4")
                              .arg(arguments.size())
                              .toUtf8()
                              .constData(),
@@ -87,7 +89,21 @@ private:
                          location.line());
             return;
         }
-        // Ignore the third parameter (bool* found)
+
+        bool actualSearchBackwards = arguments.at(2).toBool();
+        if (actualSearchBackwards != expectedSearchBackwards) {
+            QTest::qFail(QString("Compared values are not the same\n"
+                                 "   Actual   (arguments.at(2).toBool()): %1\n"
+                                 "   Expected (%2)                      : %2")
+                             .arg(actualSearchBackwards ? "true" : "false")
+                             .arg(expectedSearchBackwards ? "true" : "false")
+                             .toUtf8()
+                             .constData(),
+                         location.file_name(),
+                         location.line());
+            return;
+        }
+        // Ignore the fourth parameter (bool* found)
     }
 };
 
@@ -171,12 +187,12 @@ void KItemListKeyboardSearchManagerTest::testRepeatedKeyPress()
     QCOMPARE(spy.count(), 2);
     // First signal: full string match attempt
     QList<QVariant> arguments = spy.takeFirst();
-    QCOMPARE(arguments.size(), 3);
+    QCOMPARE(arguments.size(), 4);
     QCOMPARE(arguments.at(0).toString(), QString("pp"));
     QCOMPARE(arguments.at(1).toBool(), false);
     // Second signal: rapid navigation fallback
     arguments = spy.takeFirst();
-    QCOMPARE(arguments.size(), 3);
+    QCOMPARE(arguments.size(), 4);
     QCOMPARE(arguments.at(0).toString(), QString("p"));
     QCOMPARE(arguments.at(1).toBool(), true);
 
@@ -185,12 +201,12 @@ void KItemListKeyboardSearchManagerTest::testRepeatedKeyPress()
     QCOMPARE(spy.count(), 2);
     // First signal: full string match attempt
     arguments = spy.takeFirst();
-    QCOMPARE(arguments.size(), 3);
+    QCOMPARE(arguments.size(), 4);
     QCOMPARE(arguments.at(0).toString(), QString("ppp"));
     QCOMPARE(arguments.at(1).toBool(), false);
     // Second signal: rapid navigation fallback
     arguments = spy.takeFirst();
-    QCOMPARE(arguments.size(), 3);
+    QCOMPARE(arguments.size(), 4);
     QCOMPARE(arguments.at(0).toString(), QString("p"));
     QCOMPARE(arguments.at(1).toBool(), true);
 
@@ -224,6 +240,32 @@ void KItemListKeyboardSearchManagerTest::testPressShift()
     verifySignal(spy, "a_b", false);
 }
 
+void KItemListKeyboardSearchManagerTest::testBackwardSearch()
+{
+    QSignalSpy spy(&m_keyboardSearchManager, &KItemListKeyboardSearchManager::changeCurrentItem);
+    QVERIFY(spy.isValid());
+
+    // Shift+letter requests a backward search (bug 374392).
+    m_keyboardSearchManager.addKeys("a", true);
+    verifySignal(spy, "a", true, true);
+
+    // Repeating the key keeps navigating backwards via rapid navigation. The
+    // full-string attempt comes first, then the first-character fallback; both
+    // keep the backward direction.
+    m_keyboardSearchManager.addKeys("a", true);
+    QCOMPARE(spy.count(), 2);
+    QList<QVariant> arguments = spy.takeFirst();
+    QCOMPARE(arguments.size(), 4);
+    QCOMPARE(arguments.at(0).toString(), QString("aa"));
+    QCOMPARE(arguments.at(1).toBool(), false);
+    QCOMPARE(arguments.at(2).toBool(), true);
+    arguments = spy.takeFirst();
+    QCOMPARE(arguments.size(), 4);
+    QCOMPARE(arguments.at(0).toString(), QString("a"));
+    QCOMPARE(arguments.at(1).toBool(), true);
+    QCOMPARE(arguments.at(2).toBool(), true);
+}
+
 QTEST_GUILESS_MAIN(KItemListKeyboardSearchManagerTest)
 
 #include "kitemlistkeyboardsearchmanagertest.moc"
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.