From d012e5cdee44b5e85a6fafcd198e71656f979518 Mon Sep 17 00:00:00 2001 From: Tiago Miguel Date: Tue, 18 Aug 2026 10:52:41 +0100 Subject: [PATCH] feat: add favorites reorder and keyboard shortcuts in root search Add support for reordering favorites and keyboard-first actions in root search and launcher flows. This brings a Raycast-inspired workflow improvement to make day-to-day navigation and favorites management faster. --- .../root-search/root-search-actions.cpp | 23 +++++++++++- .../root-search/root-search-actions.hpp | 28 ++++++++++++++- src/server/src/extend/model-deser.cpp | 1 + .../src/internal/keyboard/keybind-manager.cpp | 7 ++++ src/server/src/internal/keyboard/keybind.hpp | 1 + src/server/src/qml/launcher-window.cpp | 17 ++++++++- src/server/src/qml/launcher-window.hpp | 7 ++++ src/server/src/qml/qml/ListItemDelegate.qml | 35 +++++++++++++++++-- src/server/src/qml/qml/RootSearchList.qml | 3 +- src/server/src/qml/root-search-model.cpp | 16 +++++++++ src/server/src/qml/root-search-model.hpp | 1 + src/server/src/qml/root-search-sources.cpp | 3 ++ src/server/src/qml/root-search-sources.hpp | 2 ++ src/server/src/qml/root-view-host.cpp | 5 +++ src/server/src/qml/section-list-model.cpp | 14 ++++++++ src/server/src/qml/section-list-model.hpp | 1 + .../root-item-manager/root-item-manager.cpp | 26 ++++++++++++++ .../root-item-manager/root-item-manager.hpp | 3 ++ 18 files changed, 187 insertions(+), 6 deletions(-) diff --git a/src/server/src/actions/root-search/root-search-actions.cpp b/src/server/src/actions/root-search/root-search-actions.cpp index 4ef462d278..33fe06250b 100644 --- a/src/server/src/actions/root-search/root-search-actions.cpp +++ b/src/server/src/actions/root-search/root-search-actions.cpp @@ -1,5 +1,6 @@ #include "actions/root-search/root-search-actions.hpp" #include "qml/alias-form-view-host.hpp" +#include "keyboard/keybind.hpp" #include "ui/action-pannel/shortcut-recorder-panel-view.hpp" #include "ui/image/url.hpp" #include "service-registry.hpp" @@ -81,7 +82,27 @@ void ToggleItemAsFavorite::execute(ApplicationContext *ctx) { }; ToggleItemAsFavorite::ToggleItemAsFavorite(const EntrypointId &id, bool currentValue) - : m_id(id), m_value(currentValue) {} + : m_id(id), m_value(currentValue) { + setShortcut(Keybind::FavoriteAction); +} + +void MoveFavoriteUpAction::execute(ApplicationContext *ctx) { + ctx->services->rootItemManager()->moveFavoriteUp(m_id); +} + +MoveFavoriteUpAction::MoveFavoriteUpAction(const EntrypointId &id) + : AbstractAction(tr("Move up in favorites"), ImageURL::builtin(BuiltinIcon::ArrowUp)), m_id(id) { + setShortcut(Keybind::MoveUpAction); +} + +void MoveFavoriteDownAction::execute(ApplicationContext *ctx) { + ctx->services->rootItemManager()->moveFavoriteDown(m_id); +} + +MoveFavoriteDownAction::MoveFavoriteDownAction(const EntrypointId &id) + : AbstractAction(tr("Move down in favorites"), ImageURL::builtin(BuiltinIcon::ArrowDown)), m_id(id) { + setShortcut(Keybind::MoveDownAction); +} void DisableItemAction::execute(ApplicationContext *ctx) { auto alert = new CallbackAlertWidget(); diff --git a/src/server/src/actions/root-search/root-search-actions.hpp b/src/server/src/actions/root-search/root-search-actions.hpp index cbdbc69440..fbca7d9741 100644 --- a/src/server/src/actions/root-search/root-search-actions.hpp +++ b/src/server/src/actions/root-search/root-search-actions.hpp @@ -50,6 +50,28 @@ class ToggleItemAsFavorite : public AbstractAction { ToggleItemAsFavorite(const EntrypointId &id, bool currentValue); }; +class MoveFavoriteUpAction : public AbstractAction { + Q_DECLARE_TR_FUNCTIONS(MoveFavoriteUpAction) + + EntrypointId m_id; + + void execute(ApplicationContext *ctx) override; + +public: + MoveFavoriteUpAction(const EntrypointId &id); +}; + +class MoveFavoriteDownAction : public AbstractAction { + Q_DECLARE_TR_FUNCTIONS(MoveFavoriteDownAction) + + EntrypointId m_id; + + void execute(ApplicationContext *ctx) override; + +public: + MoveFavoriteDownAction(const EntrypointId &id); +}; + class OpenItemPreferencesAction : public AbstractAction { public: OpenItemPreferencesAction(const EntrypointId &id) : m_id(id) {} @@ -138,10 +160,14 @@ class RootSearchActionGenerator { disable->setShortcut(Keybind::RemoveAction); std::vector actions; - actions.reserve(8); + actions.reserve(metadata.favorite ? 10 : 8); actions.emplace_back(copyDeeplink); actions.emplace_back(resetRanking); actions.emplace_back(markAsFavorite); + if (metadata.favorite) { + actions.emplace_back(new MoveFavoriteUpAction(id)); + actions.emplace_back(new MoveFavoriteDownAction(id)); + } actions.emplace_back(setAlias); if (platform::supports(platform::Capability::GlobalShortcuts)) { auto setGlobalShortcut = diff --git a/src/server/src/extend/model-deser.cpp b/src/server/src/extend/model-deser.cpp index cbf0d407eb..ba9aaa7363 100644 --- a/src/server/src/extend/model-deser.cpp +++ b/src/server/src/extend/model-deser.cpp @@ -562,6 +562,7 @@ static const std::unordered_map NAMED_SHORTCUTS = { {"open", Keybind::OpenAction}, {"open-with", Keybind::OpenAction}, {"pin", Keybind::PinAction}, + {"favorite", Keybind::FavoriteAction}, {"refresh", Keybind::RefreshAction}, {"remove", Keybind::RemoveAction}, {"remove-all", Keybind::DangerousRemoveAction}, diff --git a/src/server/src/internal/keyboard/keybind-manager.cpp b/src/server/src/internal/keyboard/keybind-manager.cpp index f55cece065..af11dbcfdb 100644 --- a/src/server/src/internal/keyboard/keybind-manager.cpp +++ b/src/server/src/internal/keyboard/keybind-manager.cpp @@ -106,6 +106,13 @@ static const std::unordered_map &keybindInfos() { .icon = "pin", .dflt = Keyboard::Shortcut(Qt::Key_P, Qt::ControlModifier | Qt::ShiftModifier) }}, + {Keybind::FavoriteAction, KeybindInfo{ + .id = "action.favorite", + .name = QCoreApplication::translate("keybind-manager", "Favorite Action"), + .description = QCoreApplication::translate("keybind-manager", "Can be used by actions that can add or remove the selected item from favorites"), + .icon = "star", + .dflt = Keyboard::Shortcut(Qt::Key_F, Qt::ControlModifier | Qt::ShiftModifier) + }}, {Keybind::RemoveAction, KeybindInfo{ .id = "action.remove", .name = QCoreApplication::translate("keybind-manager", "Remove Action"), diff --git a/src/server/src/internal/keyboard/keybind.hpp b/src/server/src/internal/keyboard/keybind.hpp index c7c8573f22..dc390139bf 100644 --- a/src/server/src/internal/keyboard/keybind.hpp +++ b/src/server/src/internal/keyboard/keybind.hpp @@ -16,6 +16,7 @@ enum class Keybind : uint8_t { PasteAction, NewAction, PinAction, + FavoriteAction, RemoveAction, DangerousRemoveAction, EditAction, diff --git a/src/server/src/qml/launcher-window.cpp b/src/server/src/qml/launcher-window.cpp index 532bce88e7..db04378682 100644 --- a/src/server/src/qml/launcher-window.cpp +++ b/src/server/src/qml/launcher-window.cpp @@ -359,13 +359,15 @@ bool LauncherWindow::eventFilter(QObject *obj, QEvent *event) { m_ctx.navigation->closeWindow(); } - else if (event->type() == QEvent::KeyPress) { + else if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { auto *ke = static_cast(event); // NOLINT // KeypadModifier marks key origin, not user intent; strip it so numpad // arrows compare equal to main-keyboard arrows downstream. if (ke->modifiers().testFlag(Qt::KeypadModifier)) { ke->setModifiers(ke->modifiers() & ~Qt::KeypadModifier); } + syncCommandHeld(ke); + if (event->type() == QEvent::KeyRelease) { return QObject::eventFilter(obj, event); } // the current view host gets first pick at any key press, unless a component // that owns the keyboard (overlay, alert, action panel) is up. const bool viewOwnsInput = @@ -409,6 +411,7 @@ void LauncherWindow::handleVisibilityChanged(bool visible) { if (!isLayerShellActive()) { Wayland::XdgActivation::activateWindow(m_window); } #endif } else { + setCommandHeld(false); LauncherWindowPlatform::suppressHeldKeyReleases(); m_window->hide(); updateWindowTitle(); @@ -583,6 +586,18 @@ bool LauncherWindow::forwardKey(int key, int modifiers) { return false; } +void LauncherWindow::setCommandHeld(bool held) { + if (m_commandHeld == held) return; + m_commandHeld = held; + emit commandHeldChanged(); +} + +void LauncherWindow::syncCommandHeld(const QKeyEvent *event) { + bool held = event->modifiers().testFlag(Qt::ControlModifier); + if (event->key() == Qt::Key_Control) { held = event->type() == QEvent::KeyPress; } + setCommandHeld(held); +} + void LauncherWindow::goBack() { m_ctx.navigation->goBack(); emit viewNavigatedBack(); diff --git a/src/server/src/qml/launcher-window.hpp b/src/server/src/qml/launcher-window.hpp index d637b7b01c..9e9bfd72a2 100644 --- a/src/server/src/qml/launcher-window.hpp +++ b/src/server/src/qml/launcher-window.hpp @@ -21,6 +21,7 @@ class ViewHostBase; class QQuickWindow; class BaseView; class DialogContentWidget; +class QKeyEvent; class LauncherWindow : public QObject { Q_OBJECT @@ -54,6 +55,7 @@ class LauncherWindow : public QObject { Q_PROPERTY(int lsLayer READ lsLayer NOTIFY lsChanged) Q_PROPERTY(int lsKeyboardInteractivity READ lsKeyboardInteractivity NOTIFY lsChanged) Q_PROPERTY(bool canPositionWindow READ canPositionWindow CONSTANT) + Q_PROPERTY(bool commandHeld READ commandHeld NOTIFY commandHeldChanged) public: explicit LauncherWindow(ApplicationContext &ctx, QObject *parent = nullptr); @@ -89,6 +91,7 @@ class LauncherWindow : public QObject { int lsLayer() const { return m_lsLayer; } int lsKeyboardInteractivity() const { return m_lsKeyboardInteractivity; } static bool canPositionWindow(); + bool commandHeld() const { return m_commandHeld; } Q_INVOKABLE void expand(); Q_INVOKABLE void forwardSearchText(const QString &text); @@ -126,6 +129,7 @@ class LauncherWindow : public QObject { void windowSizeOverrideChanged(); void overlayChanged(); void lsChanged(); + void commandHeldChanged(); private: bool eventFilter(QObject *obj, QEvent *event) override; @@ -141,6 +145,8 @@ class LauncherWindow : public QObject { void setExclusiveFocus(bool exclusive); void updateLayerShellProps(); void buildFooterMenu(); + void setCommandHeld(bool held); + void syncCommandHeld(const QKeyEvent *event); ApplicationContext &m_ctx; ActionPanelController *m_actionPanel; @@ -194,6 +200,7 @@ class LauncherWindow : public QObject { int m_lsLayer = 2; // LayerShellQt::Window::LayerTop int m_lsKeyboardInteractivity = 2; // LayerShellQt::Window::KeyboardInteractivityOnDemand bool m_hasCompleter = false; + bool m_commandHeld = false; QVariantList m_completerArgs; QString m_completerIcon; QVariantList m_completerValues; diff --git a/src/server/src/qml/qml/ListItemDelegate.qml b/src/server/src/qml/qml/ListItemDelegate.qml index 6adde9ba6c..6420d43375 100644 --- a/src/server/src/qml/qml/ListItemDelegate.qml +++ b/src/server/src/qml/qml/ListItemDelegate.qml @@ -10,10 +10,13 @@ SelectableDelegate { required property string itemIconSource required property string itemAlias property var itemShortcutTokens: [] + property bool overlayShortcutTokens: false required property bool itemIsActive property var itemAccessory: [] property string itemAccessoryColor: "" + readonly property bool _overlayShortcutActive: overlayShortcutTokens && itemShortcutTokens.length > 0 + RowLayout { anchors.fill: parent anchors.leftMargin: 12 @@ -52,7 +55,8 @@ SelectableDelegate { readonly property real spacing: 6 readonly property real shortcutLeadingSpace: 8 - readonly property real aliasSpace: (aliasBadge.visible ? aliasBadge.width + spacing : 0) + (shortcutBadge.visible ? shortcutBadge.width + spacing + shortcutLeadingSpace : 0) + readonly property bool inlineShortcutVisible: !root.overlayShortcutTokens && shortcutBadge.visible + readonly property real aliasSpace: (aliasBadge.visible ? aliasBadge.width + spacing : 0) + (inlineShortcutVisible ? shortcutBadge.width + spacing + shortcutLeadingSpace : 0) readonly property real availableForText: width - aliasSpace readonly property real subtitleReserved: subtitleText.visible ? Math.min(subtitleText.implicitWidth + spacing, availableForText * 0.5) : 0 @@ -93,7 +97,7 @@ SelectableDelegate { ShortcutBadge { id: shortcutBadge - visible: root.itemShortcutTokens.length > 0 + visible: !root.overlayShortcutTokens && root.itemShortcutTokens.length > 0 anchors.left: aliasBadge.visible ? aliasBadge.right : (subtitleText.visible ? subtitleText.right : titleText.right) anchors.leftMargin: visible ? textRow.spacing + textRow.shortcutLeadingSpace : 0 anchors.verticalCenter: parent.verticalCenter @@ -102,6 +106,7 @@ SelectableDelegate { } ListAccessoryRow { + id: accessoryRow accessories: { if (root.itemAccessory instanceof Array) return root.itemAccessory; @@ -120,6 +125,32 @@ SelectableDelegate { Layout.maximumWidth: implicitWidth Layout.alignment: Qt.AlignVCenter clip: true + opacity: root._overlayShortcutActive ? 0 : 1 + + Behavior on opacity { + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + } + } + + ShortcutBadge { + id: overlayShortcutBadge + visible: opacity > 0 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + z: 2 + tokens: root.itemShortcutTokens + opacity: root._overlayShortcutActive ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } } } } diff --git a/src/server/src/qml/qml/RootSearchList.qml b/src/server/src/qml/qml/RootSearchList.qml index 974e3ca85a..757c1b4513 100644 --- a/src/server/src/qml/qml/RootSearchList.qml +++ b/src/server/src/qml/qml/RootSearchList.qml @@ -72,7 +72,8 @@ GenericListView { itemSubtitle: delegateLoader.subtitle itemIconSource: delegateLoader.iconSource itemAlias: delegateLoader.alias - itemShortcutTokens: delegateLoader.shortcutTokens + overlayShortcutTokens: delegateLoader.itemType === "favorite" + itemShortcutTokens: (delegateLoader.itemType === "favorite" && !launcher.commandHeld) ? [] : delegateLoader.shortcutTokens itemIsActive: delegateLoader.isActive itemAccessory: delegateLoader.accessoryText itemAccessoryColor: delegateLoader.accessoryColor diff --git a/src/server/src/qml/root-search-model.cpp b/src/server/src/qml/root-search-model.cpp index aa3ee495cb..50249ecb2a 100644 --- a/src/server/src/qml/root-search-model.cpp +++ b/src/server/src/qml/root-search-model.cpp @@ -208,6 +208,22 @@ void RootSearchModel::setSelectedIndex(int index) { } } +bool RootSearchModel::activateFavorite(int index) { + if (index < 0 || std::cmp_greater_equal(index, m_favoritesSource->count())) return false; + + int sourceIdx = -1; + int itemIdx = -1; + for (int row = 0; row < rowCount(); ++row) { + if (!dataItemAt(row, sourceIdx, itemIdx)) continue; + if (sources()[sourceIdx] != m_favoritesSource || itemIdx != index) continue; + setSelectedIndex(row); + activateSelected(); + return true; + } + + return false; +} + const RootItem *RootSearchModel::selectedRootItem() const { int sourceIdx = -1; int itemIdx = -1; diff --git a/src/server/src/qml/root-search-model.hpp b/src/server/src/qml/root-search-model.hpp index 1b1f648b2c..da49d9a4b2 100644 --- a/src/server/src/qml/root-search-model.hpp +++ b/src/server/src/qml/root-search-model.hpp @@ -29,6 +29,7 @@ class RootSearchModel : public SectionListModel { void setSelectedIndex(int index) override; const RootItem *selectedRootItem() const; + bool activateFavorite(int index); private: void refresh(); diff --git a/src/server/src/qml/root-search-sources.cpp b/src/server/src/qml/root-search-sources.cpp index ac3d14cdbf..899ac610a5 100644 --- a/src/server/src/qml/root-search-sources.cpp +++ b/src/server/src/qml/root-search-sources.cpp @@ -288,6 +288,9 @@ QVariant RootFavoritesSection::customData(int i, int role) const { return QString::fromStdString(meta.alias.value_or("")); } case ShortcutTokens: + if (i < QUICK_OPEN_COUNT) { + return Keyboard::Shortcut(static_cast(Qt::Key_1 + i), Qt::ControlModifier).toDisplayTokens(); + } return shortcutTokensFor(m_manager->itemMetadata(m_items[i]->uniqueId())); case IsActive: return m_items[i]->isActive(); diff --git a/src/server/src/qml/root-search-sources.hpp b/src/server/src/qml/root-search-sources.hpp index a3f1755c11..5b6283e421 100644 --- a/src/server/src/qml/root-search-sources.hpp +++ b/src/server/src/qml/root-search-sources.hpp @@ -152,6 +152,8 @@ class RootNewsSection : public SectionSource { class RootFavoritesSection : public RootItemSection { public: + static constexpr int QUICK_OPEN_COUNT = 9; + explicit RootFavoritesSection(RootItemManager *mgr) : m_manager(mgr) {} QString sectionName() const override { diff --git a/src/server/src/qml/root-view-host.cpp b/src/server/src/qml/root-view-host.cpp index e59a22e214..65cfb81467 100644 --- a/src/server/src/qml/root-view-host.cpp +++ b/src/server/src/qml/root-view-host.cpp @@ -91,6 +91,11 @@ bool RootViewHost::inputFilter(QKeyEvent *event) { auto &nav = context()->navigation; auto &cfg = context()->services->config()->value(); + if (!event->isAutoRepeat() && event->modifiers() == Qt::ControlModifier && event->key() >= Qt::Key_1 && + event->key() < Qt::Key_1 + RootFavoritesSection::QUICK_OPEN_COUNT) { + return m_model->activateFavorite(event->key() - Qt::Key_1); + } + if (!event->modifiers() && event->key() == Qt::Key_Space) { return tryAliasFastTrack(); } // wrapped navigation is incompatible with overriding key up, so we disable history in that case diff --git a/src/server/src/qml/section-list-model.cpp b/src/server/src/qml/section-list-model.cpp index a87a14d1c6..c5989d459f 100644 --- a/src/server/src/qml/section-list-model.cpp +++ b/src/server/src/qml/section-list-model.cpp @@ -341,6 +341,8 @@ void SectionListModel::rebuildFlatList() { if (newCount == 0) { m_selectedIndex = -1; + } else if (int restored = indexOfItemId(m_lastSelectedItemId); restored >= 0) { + m_selectedIndex = restored; } else if (m_selectedIndex >= newCount) { m_selectedIndex = nextSelectableIndex(newCount, -1); } else if (m_selectedIndex >= 0 && m_flat[m_selectedIndex].kind == FlatItem::SectionHeader) { @@ -349,3 +351,15 @@ void SectionListModel::rebuildFlatList() { if (m_selectedIndex != prevSelected) emit selectedIndexChanged(); } + +int SectionListModel::indexOfItemId(const QString &id) const { + if (id.isEmpty()) return -1; + + for (int i = 0; std::cmp_less(i, m_flat.size()); ++i) { + if (m_flat[i].kind != FlatItem::DataItem) continue; + auto *source = m_sources[m_flat[i].sourceIdx]; + if (source->itemId(m_flat[i].itemIdx) == id) return i; + } + + return -1; +} diff --git a/src/server/src/qml/section-list-model.hpp b/src/server/src/qml/section-list-model.hpp index 544d050cbb..7925434e11 100644 --- a/src/server/src/qml/section-list-model.hpp +++ b/src/server/src/qml/section-list-model.hpp @@ -79,6 +79,7 @@ class SectionListModel : public QAbstractListModel { void rebuildFlatList(); void rebuildCustomRoleDefaults(); + int indexOfItemId(const QString &id) const; ViewScope m_scope; std::vector m_sources; diff --git a/src/server/src/services/root-item-manager/root-item-manager.cpp b/src/server/src/services/root-item-manager/root-item-manager.cpp index 90d393fb47..18ef68504f 100644 --- a/src/server/src/services/root-item-manager/root-item-manager.cpp +++ b/src/server/src/services/root-item-manager/root-item-manager.cpp @@ -489,6 +489,32 @@ bool RootItemManager::setItemAsFavorite(const EntrypointId &itemId, bool value) return true; } +bool RootItemManager::moveFavoriteDown(const EntrypointId &id) { + auto favorites = m_cfg.value().favorites; + auto it = std::ranges::find(favorites, std::string{id}); + + if (it == favorites.end() || it + 1 == favorites.end()) return false; + + std::iter_swap(it, it + 1); + m_cfg.mergeWithUser({.favorites = favorites}); + emit favoriteOrderChanged(id); + + return true; +} + +bool RootItemManager::moveFavoriteUp(const EntrypointId &id) { + auto favorites = m_cfg.value().favorites; + auto it = std::ranges::find(favorites, std::string{id}); + + if (it == favorites.end() || it == favorites.begin()) return false; + + std::iter_swap(it, it - 1); + m_cfg.mergeWithUser({.favorites = favorites}); + emit favoriteOrderChanged(id); + + return true; +} + std::vector> RootItemManager::queryFavorites(std::optional limit) { return getFromSerializedEntrypointIds(m_cfg.value().favorites); } diff --git a/src/server/src/services/root-item-manager/root-item-manager.hpp b/src/server/src/services/root-item-manager/root-item-manager.hpp index 1645c9a2a4..38d6aacf69 100644 --- a/src/server/src/services/root-item-manager/root-item-manager.hpp +++ b/src/server/src/services/root-item-manager/root-item-manager.hpp @@ -216,6 +216,7 @@ class RootItemManager : public QObject { void itemsChanged() const; void itemRankingReset(const EntrypointId &id) const; void itemFavoriteChanged(const EntrypointId &id, bool favorite) const; + void favoriteOrderChanged(const EntrypointId &id) const; void fallbackEnabled(const EntrypointId &id) const; void fallbackOrderChanged(const EntrypointId &id) const; void fallbackDisabled(const EntrypointId &id) const; @@ -295,6 +296,8 @@ class RootItemManager : public QObject { bool moveFallbackUp(const EntrypointId &id); bool enableFallback(const EntrypointId &id); std::vector> queryFavorites(std::optional limit = {}); + bool moveFavoriteDown(const EntrypointId &id); + bool moveFavoriteUp(const EntrypointId &id); bool resetRanking(const EntrypointId &id); bool registerVisit(const EntrypointId &id); SearchHistory &searchHistory() { return m_searchHistory; }