diff --git a/assets/translations/en.json b/assets/translations/en.json index 3e5e21f7c..a7f7f2704 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -59,6 +59,9 @@ }, "launcher": { "search-placeholder": "Search applications...", + "context-menu": { + "open": "Open" + }, "empty": { "type-to-search": "Type to search...", "no-results": "No results found" diff --git a/src/launcher/app_provider.cpp b/src/launcher/app_provider.cpp index 74f443427..a75bf967d 100644 --- a/src/launcher/app_provider.cpp +++ b/src/launcher/app_provider.cpp @@ -336,14 +336,31 @@ bool AppProvider::activate(const LauncherResult& result) { refreshEntriesIfNeeded(); for (const auto& entry : m_entries) { - if (entry.path == result.id) { - std::string token; - if (m_wayland != nullptr && m_wayland->hasXdgActivation()) { - token = m_wayland->requestActivationToken(nullptr); - } - launchCommand(entry.exec, entry.terminal, token); - return true; + if (entry.path != result.id) { + continue; } + + std::string execLine = entry.exec; + if (!result.desktopActionId.empty()) { + const DesktopAction* chosen = nullptr; + for (const auto& action : entry.actions) { + if (action.id == result.desktopActionId) { + chosen = &action; + break; + } + } + if (chosen == nullptr || chosen->exec.empty()) { + return false; + } + execLine = chosen->exec; + } + + std::string token; + if (m_wayland != nullptr && m_wayland->hasXdgActivation()) { + token = m_wayland->requestActivationToken(nullptr); + } + launchCommand(execLine, entry.terminal, token); + return true; } return false; } diff --git a/src/launcher/launcher_provider.h b/src/launcher/launcher_provider.h index 0de322211..b669cc701 100644 --- a/src/launcher/launcher_provider.h +++ b/src/launcher/launcher_provider.h @@ -13,6 +13,8 @@ struct LauncherResult { std::string iconName; std::string iconPath; std::string actionText; + // When launching an application via AppProvider, matches DesktopAction::id (primary Exec leaves this empty). + std::string desktopActionId; double score = 0.0; }; diff --git a/src/shell/launcher/launcher_panel.cpp b/src/shell/launcher/launcher_panel.cpp index abc3f5f43..0c7ef0056 100644 --- a/src/shell/launcher/launcher_panel.cpp +++ b/src/shell/launcher/launcher_panel.cpp @@ -6,9 +6,12 @@ #include "i18n/i18n.h" #include "render/core/async_texture_cache.h" #include "render/core/renderer.h" +#include "render/render_context.h" #include "render/scene/input_area.h" #include "render/scene/node.h" #include "shell/panel/panel_manager.h" +#include "system/desktop_entry.h" +#include "ui/controls/context_menu_popup.h" #include "ui/controls/flex.h" #include "ui/controls/glyph.h" #include "ui/controls/image.h" @@ -19,9 +22,11 @@ #include "ui/palette.h" #include "ui/style.h" #include "util/fuzzy_match.h" +#include "wayland/wayland_connection.h" #include #include +#include #include #include #include @@ -230,12 +235,14 @@ namespace { class LauncherResultAdapter final : public VirtualGridAdapter { public: using ActivateCallback = std::function; + using SecondaryActivateCallback = std::function; LauncherResultAdapter(float scale, AsyncTextureCache* cache) : m_scale(scale), m_cache(cache) {} void setResults(const std::vector* results) { m_results = results; } void setRenderer(Renderer* renderer) { m_renderer = renderer; } void setOnActivate(ActivateCallback callback) { m_onActivate = std::move(callback); } + void setOnSecondaryActivate(SecondaryActivateCallback callback) { m_onSecondaryActivate = std::move(callback); } [[nodiscard]] std::size_t itemCount() const override { return m_results == nullptr ? 0u : m_results->size(); } @@ -257,12 +264,19 @@ public: } } + void onSecondaryActivate(std::size_t index, float anchorX, float anchorY) override { + if (m_onSecondaryActivate) { + m_onSecondaryActivate(index, anchorX, anchorY); + } + } + private: float m_scale; AsyncTextureCache* m_cache = nullptr; Renderer* m_renderer = nullptr; const std::vector* m_results = nullptr; ActivateCallback m_onActivate; + SecondaryActivateCallback m_onSecondaryActivate; }; LauncherPanel::LauncherPanel(ConfigService* config, AsyncTextureCache* asyncTextures) @@ -308,6 +322,8 @@ void LauncherPanel::create() { m_adapter = std::make_unique(scale, m_asyncTextures); m_adapter->setResults(&m_results); m_adapter->setOnActivate([this](std::size_t index) { activateAt(index); }); + m_adapter->setOnSecondaryActivate( + [this](std::size_t index, float ax, float ay) { openAppActionsMenu(index, ax, ay); }); auto grid = std::make_unique(); grid->setColumns(1); @@ -370,6 +386,10 @@ void LauncherPanel::onOpen(std::string_view context) { } void LauncherPanel::onClose() { + if (m_actionsMenu != nullptr && m_actionsMenu->isOpen()) { + m_actionsMenu->close(); + } + if (m_asyncTextures != nullptr) { DeferredCall::callLater([asyncTextures = m_asyncTextures]() { asyncTextures->trimUnused(0); }); } @@ -530,6 +550,106 @@ void LauncherPanel::applyEmptyState() { } } +void LauncherPanel::openAppActionsMenu(std::size_t index, float anchorX, float anchorY) { + if (index >= m_results.size()) { + return; + } + const LauncherResult& base = m_results[index]; + + const DesktopEntry* match = nullptr; + for (const auto& e : desktopEntries()) { + if (e.path == base.id) { + match = &e; + break; + } + } + if (match == nullptr || match->actions.empty()) { + return; + } + + WaylandConnection* wl = PanelManager::instance().wayland(); + RenderContext* rc = PanelManager::instance().renderContext(); + if (wl == nullptr || rc == nullptr) { + return; + } + + const auto parentCtx = PanelManager::instance().fallbackPopupParentContext(); + if (!parentCtx.has_value()) { + return; + } + + if (m_actionsMenu == nullptr) { + m_actionsMenu = std::make_unique(*wl, *rc); + } + + std::vector actionsCopy = match->actions; + + std::vector entries; + entries.reserve(actionsCopy.size() + 1); + entries.push_back(ContextMenuControlEntry{ + .id = -1, + .label = i18n::tr("launcher.context-menu.open"), + .enabled = true, + .separator = false, + .hasSubmenu = false, + }); + for (std::int32_t i = 0; i < static_cast(actionsCopy.size()); ++i) { + entries.push_back(ContextMenuControlEntry{ + .id = i, + .label = actionsCopy[static_cast(i)].name, + .enabled = true, + .separator = false, + .hasSubmenu = false, + }); + } + + const float scale = contentScale(); + constexpr float kMenuWidth = 240.0f; + const float menuWidth = kMenuWidth * scale; + + PanelManager::instance().beginAttachedPopup(parentCtx->surface); + PanelManager::instance().setActivePopup(m_actionsMenu.get()); + + m_actionsMenu->setOnDismissed([parentSurface = parentCtx->surface]() { + PanelManager::instance().clearActivePopup(); + PanelManager::instance().endAttachedPopup(parentSurface); + }); + + m_actionsMenu->setOnActivate( + [this, base, actionsCopy = std::move(actionsCopy)](const ContextMenuControlEntry& entry) { + LauncherResult result = base; + result.desktopActionId.clear(); + if (entry.id >= 0 && entry.id < static_cast(actionsCopy.size())) { + result.desktopActionId = actionsCopy[static_cast(entry.id)].id; + } else if (entry.id != -1) { + return; + } + + for (auto& provider : m_providers) { + if (provider->name() != std::string_view(result.providerName)) { + continue; + } + if (!provider->activate(result)) { + return; + } + if (provider->trackUsage()) { + m_usageTracker.record(provider->name(), result.id); + } + PanelManager::instance().closePanel(); + return; + } + }); + + const float inset = std::round(std::max(4.0f, Style::spaceXs * scale)); + const std::int32_t ax = static_cast(std::round(anchorX - inset)); + const std::int32_t ay = static_cast(std::round(anchorY - inset)); + const std::int32_t aw = static_cast(std::round(inset * 2.0f)); + const std::int32_t ah = static_cast(std::round(inset * 2.0f)); + + m_actionsMenu->open(std::move(entries), menuWidth, 12, ax, ay, std::max(1, aw), std::max(1, ah), + parentCtx->layerSurface, parentCtx->output); +} + void LauncherPanel::activateAt(std::size_t index) { if (index >= m_results.size()) { return; diff --git a/src/shell/launcher/launcher_panel.h b/src/shell/launcher/launcher_panel.h index e8e163b97..75099fc32 100644 --- a/src/shell/launcher/launcher_panel.h +++ b/src/shell/launcher/launcher_panel.h @@ -10,6 +10,7 @@ #include #include +class ContextMenuPopup; class Flex; class Glyph; class Image; @@ -53,6 +54,7 @@ private: void activateSelected(); bool handleKeyEvent(std::uint32_t sym, std::uint32_t modifiers); void applyEmptyState(); + void openAppActionsMenu(std::size_t index, float anchorX, float anchorY); std::vector> m_providers; std::vector m_results; @@ -70,4 +72,5 @@ private: std::size_t m_selectedIndex = 0; ConfigService* m_config = nullptr; AsyncTextureCache* m_asyncTextures = nullptr; + std::unique_ptr m_actionsMenu; }; diff --git a/src/ui/controls/virtual_grid_view.cpp b/src/ui/controls/virtual_grid_view.cpp index 17fbe1749..287530d99 100644 --- a/src/ui/controls/virtual_grid_view.cpp +++ b/src/ui/controls/virtual_grid_view.cpp @@ -60,14 +60,19 @@ VirtualGridView::VirtualGridView() { auto inputArea = std::make_unique(); inputArea->setZIndex(50); + inputArea->setAcceptedButtons(InputArea::buttonMask({BTN_LEFT, BTN_RIGHT})); inputArea->setOnEnter([this](const InputArea::PointerData& data) { onPointerEnter(data.localX, data.localY); }); inputArea->setOnMotion([this](const InputArea::PointerData& data) { onPointerMotion(data.localX, data.localY); }); inputArea->setOnLeave([this]() { onPointerLeave(); }); inputArea->setOnPress([this](const InputArea::PointerData& data) { - if (!data.pressed || data.button != BTN_LEFT) { + if (!data.pressed) { return; } - onPointerPress(data.localX, data.localY); + if (data.button == BTN_LEFT) { + onPointerPress(data.localX, data.localY); + } else if (data.button == BTN_RIGHT) { + onSecondaryPointerPress(data.localX, data.localY); + } }); m_inputArea = static_cast(m_canvas->addChild(std::move(inputArea))); } @@ -389,6 +394,22 @@ void VirtualGridView::onPointerPress(float localX, float localY) { } } +void VirtualGridView::onSecondaryPointerPress(float localX, float localY) { + const auto idx = indexAt(localX, localY); + if (!idx.has_value()) { + return; + } + setSelectedIndex(idx); + if (m_adapter != nullptr) { + float wx = 0.0f; + float wy = 0.0f; + Node::absolutePosition(m_inputArea, wx, wy); + wx += localX; + wy += localY; + m_adapter->onSecondaryActivate(*idx, wx, wy); + } +} + std::optional VirtualGridView::indexAt(float localX, float localY) const noexcept { if (m_layoutColumns == 0 || m_cellWidth <= 0.0f || m_cellHeightResolved <= 0.0f || m_itemCount == 0) { return std::nullopt; diff --git a/src/ui/controls/virtual_grid_view.h b/src/ui/controls/virtual_grid_view.h index 1993c624a..e0c7e503d 100644 --- a/src/ui/controls/virtual_grid_view.h +++ b/src/ui/controls/virtual_grid_view.h @@ -40,6 +40,10 @@ public: // Optional: respond to an activation gesture (click). The grid still // updates its own selection state and fires onSelectionChanged. virtual void onActivate(std::size_t /*index*/) {} + + // Optional: secondary button press (e.g. context menu). Anchor coordinates are in the panel scene graph + // (surface-local). + virtual void onSecondaryActivate(std::size_t /*index*/, float /*anchorX*/, float /*anchorY*/) {} }; class VirtualGridView : public Flex { @@ -83,6 +87,7 @@ private: void onPointerMotion(float localX, float localY); void onPointerLeave(); void onPointerPress(float localX, float localY); + void onSecondaryPointerPress(float localX, float localY); [[nodiscard]] std::optional indexAt(float localX, float localY) const noexcept; ScrollView* m_scroll = nullptr;