diff --git a/src/server/src/qml/launcher-window.cpp b/src/server/src/qml/launcher-window.cpp index 17d89f46b0..d0ac2210b5 100644 --- a/src/server/src/qml/launcher-window.cpp +++ b/src/server/src/qml/launcher-window.cpp @@ -519,6 +519,16 @@ void LauncherWindow::popToRoot() { emit viewNavigatedBack(); } +void LauncherWindow::removeCurrentFilter() { + auto *nav = m_ctx.navigation.get(); + if (nav->isRootSearch()) return; + + QString const query = nav->searchText(); + nav->popCurrentView(); + nav->setSearchText(query); + emit viewNavigatedBack(); +} + bool LauncherWindow::popOnBackspace() { return m_ctx.services->config()->value().popOnBackspace; } void LauncherWindow::setCompleterValue(int index, const QString &value) { diff --git a/src/server/src/qml/launcher-window.hpp b/src/server/src/qml/launcher-window.hpp index ac88ae58d4..715d5d8e7d 100644 --- a/src/server/src/qml/launcher-window.hpp +++ b/src/server/src/qml/launcher-window.hpp @@ -95,6 +95,8 @@ class LauncherWindow : public QObject { Q_INVOKABLE void handleEscape(); Q_INVOKABLE void goBack(); Q_INVOKABLE void popToRoot(); + // Pop the current scoped view (a filter) but preserve the query text (Shift+Tab). + Q_INVOKABLE void removeCurrentFilter(); Q_INVOKABLE void setCompleterValue(int index, const QString &value); Q_INVOKABLE QRect cursorScreenGeometry() const; Q_INVOKABLE void positionOnCursorScreen(); diff --git a/src/server/src/qml/qml/CommandListView.qml b/src/server/src/qml/qml/CommandListView.qml index 0f622e047a..a0962e8436 100644 --- a/src/server/src/qml/qml/CommandListView.qml +++ b/src/server/src/qml/qml/CommandListView.qml @@ -50,6 +50,10 @@ GenericListView { itemAlias: "" itemIsActive: false itemAccessory: delegateLoader.itemAccessory + quickNumber: { + commandListView.listModel.selectedIndex; + return commandListView.listModel.quickSelectNumber(delegateLoader.index); + } selected: commandListView.currentIndex === delegateLoader.index onClicked: commandListView.currentIndex = delegateLoader.index onActivated: commandListView.itemActivated(delegateLoader.index) diff --git a/src/server/src/qml/qml/ExtensionView.qml b/src/server/src/qml/qml/ExtensionView.qml index 27e3e4d7af..b7418c3dd4 100644 --- a/src/server/src/qml/qml/ExtensionView.qml +++ b/src/server/src/qml/qml/ExtensionView.qml @@ -48,6 +48,17 @@ Item { } return false; } + function moveBy(delta) { + if (contentLoader.item && typeof contentLoader.item.moveBy === "function") { + return contentLoader.item.moveBy(delta); + } + return false; + } + function activateRelative(offset) { + if (contentLoader.item && typeof contentLoader.item.activateRelative === "function") { + return contentLoader.item.activateRelative(offset); + } + } Loader { id: contentLoader @@ -83,6 +94,12 @@ Item { function moveSectionDown() { return listView.moveSectionDown(); } + function moveBy(delta) { + return listView.moveBy(delta); + } + function activateRelative(offset) { + return listView.activateRelative(offset); + } GenericListView { id: listView @@ -133,6 +150,10 @@ Item { itemAlias: "" itemIsActive: false itemAccessory: delegateLoader.itemAccessory + quickNumber: { + listView.listModel.selectedIndex; + return listView.listModel.quickSelectNumber(delegateLoader.index); + } selected: listView.currentIndex === delegateLoader.index onClicked: listView.currentIndex = delegateLoader.index onActivated: listView.itemActivated(delegateLoader.index) diff --git a/src/server/src/qml/qml/GenericListView.qml b/src/server/src/qml/qml/GenericListView.qml index 59dc3253fd..fa7c9dc3c0 100644 --- a/src/server/src/qml/qml/GenericListView.qml +++ b/src/server/src/qml/qml/GenericListView.qml @@ -123,6 +123,46 @@ Item { listView.currentIndex = root.listModel.nextSelectableIndex(-1, 1); } + // Move the selection by `delta` selectable rows (negative = up), clamping at + // the list ends instead of wrapping. Skips section headers. + function moveBy(delta) { + if (delta === 0) + return true; + const step = delta > 0 ? 1 : -1; + let idx = listView.currentIndex; + for (let i = 0; i < Math.abs(delta); i++) { + const next = root.listModel.nextSelectableIndex(idx, step); + // nextSelectableIndex wraps around; treat a wrap as reaching the end. + if (next === idx || (step > 0 && next < idx) || (step < 0 && next > idx)) + break; + idx = next; + } + if (idx !== listView.currentIndex) { + listView.currentIndex = idx; + listView.positionViewAtIndex(sectionScrollTarget(idx, -1), ListView.Contain); + } + return true; + } + + // Activate the item `offset` selectable rows below the current selection + // (offset 0 = current). Clamps to the last item; never wraps. + function activateRelative(offset) { + let idx = listView.currentIndex; + if (idx < 0) + idx = root.listModel.nextSelectableIndex(-1, 1); + if (idx < 0) + return; + for (let i = 0; i < offset; i++) { + const next = root.listModel.nextSelectableIndex(idx, 1); + if (next <= idx) + break; // reached end (no wrap) + idx = next; + } + listView.currentIndex = idx; + root.listModel.setSelectedIndex(idx); + root.listModel.activateSelected(); + } + onListModelChanged: { if (root.autoWireModel && root.listModel && listView.count > 0) { root.selectFirst(); diff --git a/src/server/src/qml/qml/LauncherWindow.qml b/src/server/src/qml/qml/LauncherWindow.qml index 50e5233f35..c02fab904e 100644 --- a/src/server/src/qml/qml/LauncherWindow.qml +++ b/src/server/src/qml/qml/LauncherWindow.qml @@ -311,7 +311,19 @@ Window { Shortcut { sequence: "Escape" enabled: !launcher.alertModel.visible && !actionPanel.open && !footerPanel.open && !launcher.hasOverlay - onActivated: launcher.handleEscape() + onActivated: { + // If the selection isn't on the first item, Escape resets it to the top + // instead of closing; a second Escape (already at top) closes as usual. + const view = commandStack.currentItem; + if (view && view.listModel && typeof view.selectFirst === "function" && typeof view.currentIndex !== "undefined") { + const first = view.listModel.nextSelectableIndex(-1, 1); + if (first >= 0 && view.currentIndex !== first) { + view.selectFirst(); + return; + } + } + launcher.handleEscape(); + } } Shortcut { diff --git a/src/server/src/qml/qml/ListItemDelegate.qml b/src/server/src/qml/qml/ListItemDelegate.qml index 016eed7ebe..d6cf11d099 100644 --- a/src/server/src/qml/qml/ListItemDelegate.qml +++ b/src/server/src/qml/qml/ListItemDelegate.qml @@ -16,6 +16,9 @@ SelectableDelegate { property string filePath: "" property string fileUrl: "" + // 1..5 position from the current selection, for the Alt+N shortcut (0 = hide). + property int quickNumber: 0 + readonly property bool _isDraggable: root.filePath !== "" Component.onCompleted: { @@ -147,5 +150,12 @@ SelectableDelegate { Layout.alignment: Qt.AlignVCenter clip: true } + + TextBadge { + visible: root.quickNumber > 0 + text: root.quickNumber.toString() + contentColor: root.selected ? Theme.listItemSelectionFg : Theme.textMuted + Layout.alignment: Qt.AlignVCenter + } } } diff --git a/src/server/src/qml/qml/RootSearchList.qml b/src/server/src/qml/qml/RootSearchList.qml index 7a6b001919..d27b6379f1 100644 --- a/src/server/src/qml/qml/RootSearchList.qml +++ b/src/server/src/qml/qml/RootSearchList.qml @@ -75,6 +75,10 @@ GenericListView { itemIsActive: delegateLoader.isActive itemAccessory: delegateLoader.accessoryText itemAccessoryColor: delegateLoader.accessoryColor + quickNumber: { + searchListView.listModel.selectedIndex; + return searchListView.listModel.quickSelectNumber(delegateLoader.index); + } selected: searchListView.currentIndex === delegateLoader.index onClicked: searchListView.currentIndex = delegateLoader.index onActivated: searchListView.itemActivated(delegateLoader.index) diff --git a/src/server/src/qml/qml/SearchBar.qml b/src/server/src/qml/qml/SearchBar.qml index e505bbc8ed..7144931c75 100644 --- a/src/server/src/qml/qml/SearchBar.qml +++ b/src/server/src/qml/qml/SearchBar.qml @@ -192,6 +192,46 @@ Item { return false; } + // Alt+1..5 open the Nth result counting from the current selection; + // Alt+q / Alt+w move the selection up / down by 5 rows. + function _handleAltShortcuts(event) { + if ((event.modifiers & Qt.AltModifier) === 0) + return false; + if (event.modifiers & ~(Qt.AltModifier | Qt.KeypadModifier)) + return false; + + const view = commandStack.currentItem; + if (!view) + return false; + + if (launcher.compacted) { + launcher.expand(); + return true; + } + + switch (event.key) { + case Qt.Key_1: + case Qt.Key_2: + case Qt.Key_3: + case Qt.Key_4: + case Qt.Key_5: + if (typeof view.activateRelative === "function") { + view.activateRelative(event.key - Qt.Key_1); + return true; + } + return false; + case Qt.Key_Q: + if (typeof view.moveBy === "function") + return view.moveBy(-5); + return false; + case Qt.Key_W: + if (typeof view.moveBy === "function") + return view.moveBy(5); + return false; + } + return false; + } + function _handleNavigation(event) { const nav = Keyboard.matchNavigation(event.key, event.modifiers); if (nav === 0) @@ -274,11 +314,27 @@ Item { event.accepted = launcher.forwardKey(event.key, event.modifiers); } } + Keys.onTabPressed: event => { + // Apply an alias typed at the end of the query as a scope filter, + // keeping the preceding words as the scoped command's query. + if (searchInput.text.length > 0 && typeof launcher.commandViewHost?.applyTrailingFilter === "function" && launcher.commandViewHost.applyTrailingFilter()) + event.accepted = true; + else + event.accepted = false; + } Keys.onBacktabPressed: event => { - event.accepted = false; + // Remove the current scope filter but keep the query text. + if (!launcher.atRoot) { + launcher.removeCurrentFilter(); + event.accepted = true; + } else { + event.accepted = false; + } } Keys.onPressed: event => { - if (_handleEmacsEditing(event)) { + if (_handleAltShortcuts(event)) { + event.accepted = true; + } else if (_handleEmacsEditing(event)) { event.accepted = true; } else if (_handleNavigation(event)) { event.accepted = true; diff --git a/src/server/src/qml/root-view-host.cpp b/src/server/src/qml/root-view-host.cpp index e938b513bc..665606969c 100644 --- a/src/server/src/qml/root-view-host.cpp +++ b/src/server/src/qml/root-view-host.cpp @@ -43,3 +43,8 @@ bool RootViewHost::tryAliasFastTrack() { if (!m_model) return false; return m_model->tryAliasFastTrack(); } + +bool RootViewHost::applyTrailingFilter() { + if (!m_model) return false; + return m_model->applyTrailingFilter(); +} diff --git a/src/server/src/qml/root-view-host.hpp b/src/server/src/qml/root-view-host.hpp index 705f7ad392..8e38d05bba 100644 --- a/src/server/src/qml/root-view-host.hpp +++ b/src/server/src/qml/root-view-host.hpp @@ -20,6 +20,7 @@ class RootViewHost : public ViewHostBase { QObject *listModel() const; Q_INVOKABLE bool tryAliasFastTrack(); + Q_INVOKABLE bool applyTrailingFilter(); private: RootSearchModel *m_model = nullptr; diff --git a/src/server/src/qml/section-list-model.cpp b/src/server/src/qml/section-list-model.cpp index 87f86a83a7..e20f8edc8d 100644 --- a/src/server/src/qml/section-list-model.cpp +++ b/src/server/src/qml/section-list-model.cpp @@ -190,6 +190,21 @@ int SectionListModel::nextSelectableIndex(int from, int direction) const { return from; } +int SectionListModel::quickSelectNumber(int row) const { + if (row < 0 || m_selectedIndex < 0) return 0; + if (row == m_selectedIndex) return 1; + + int idx = m_selectedIndex; + for (int n = 2; n <= QUICK_SELECT_MAX; ++n) { + int const next = nextSelectableIndex(idx, 1); + if (next <= idx) break; // reached the end (nextSelectableIndex wraps) + idx = next; + if (idx == row) return n; + } + + return 0; +} + int SectionListModel::nextSectionIndex(int from, int direction) const { int const count = static_cast(m_flat.size()); if (count == 0) return from; diff --git a/src/server/src/qml/section-list-model.hpp b/src/server/src/qml/section-list-model.hpp index c7e47df73f..f180817f42 100644 --- a/src/server/src/qml/section-list-model.hpp +++ b/src/server/src/qml/section-list-model.hpp @@ -55,6 +55,11 @@ class SectionListModel : public QAbstractListModel { Q_INVOKABLE int nextSelectableIndex(int from, int direction) const; Q_INVOKABLE int nextSectionIndex(int from, int direction) const; Q_INVOKABLE int scrollTargetIndex(int index, int direction) const; + // 1-based position of `row` among the selectable rows at/below the current + // selection (1 = selected, 2 = next selectable, ...), capped; 0 if not in range. + // Mirrors the Alt+1..5 "open Nth from selection" shortcut for on-screen numbering. + Q_INVOKABLE int quickSelectNumber(int row) const; + static constexpr int QUICK_SELECT_MAX = 5; void refreshActionPanel(); void beforePop();