From 514ff0b42ca83fa890e052efb09f29439056a7b4 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Sun, 9 Aug 2026 12:32:03 +0200 Subject: [PATCH] feat(plugins): Add signal for context menus (#6961) This PR adds the ability for plugins to add actions to context menus of ChannelViews. They can add actions, submenus and separators. I added the signal to the `WindowManager` to allow plugins to listen to these events in any channel. Here's an example plugin: ```lua c2.windows:on_channelview_context_menu_requested(function(args) args.menu:add_action("Testing", function() args.channel:add_system_message("Clicked!") end) args.menu:insert_action(1, "First!", function() local msg = "text='" .. args.message.message_text .. "'" if args.message_element then msg = msg .. " element=" .. args.message_element.type end if args.split then msg = msg .. " split={channel=" .. args.split.channel:get_name() .. "}" end args.channel:add_system_message(msg) end) local sub = args.menu:add_menu("Submenu") sub:add_action("An action", function() end) sub:add_separator() sub:add_action("Second one", function() end) end) ``` Reviewed-by: Mm2PL Reviewed-by: pajlada --- docs/chatterino.d.ts | 25 ++ docs/lua-meta/globals.lua | 54 ++++ docs/wip-plugins.md | 48 ++++ src/CMakeLists.txt | 2 + src/controllers/plugins/LuaAPI.hpp | 1 + src/controllers/plugins/Plugin.cpp | 6 + src/controllers/plugins/PluginController.cpp | 2 + src/controllers/plugins/SignalCallback.hpp | 5 + src/controllers/plugins/api/Menu.cpp | 102 +++++++ src/controllers/plugins/api/Menu.hpp | 56 ++++ src/controllers/plugins/api/Message.cpp | 256 ++++++++---------- src/controllers/plugins/api/Message.hpp | 52 ++++ src/controllers/plugins/api/WindowManager.cpp | 58 +++- src/controllers/plugins/api/WindowManager.hpp | 12 + src/singletons/WindowManager.hpp | 8 + src/widgets/helper/ChannelView.cpp | 33 ++- src/widgets/helper/ChannelView.hpp | 2 + 17 files changed, 571 insertions(+), 151 deletions(-) create mode 100644 src/controllers/plugins/api/Menu.cpp create mode 100644 src/controllers/plugins/api/Menu.hpp diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index 31bbeda7d..bdf9b5d33 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -655,11 +655,23 @@ declare namespace c2 { type: WindowType; } + interface ChannelViewContextMenuRequestedArgs { + split?: Split; + message: Message; + message_element: MessageElement; + channel?: Channel; + menu: Menu; + } + class WindowManager { main_window: Window; last_selected_window: Window; all(): Window[]; + + on_channelview_context_menu_requested( + cb: (args: ChannelViewContextMenuRequestedArgs) => void + ): ConnectionHandle; } var windows: WindowManager; @@ -682,6 +694,19 @@ declare namespace c2 { to_local(): DateTime; to_utc(): DateTime; } + + class Menu { + add_action(text: string, cb: () => void): void; + insert_action( + before: string | number, + text: string, + cb: () => void + ): void; + add_menu(text: string): Menu; + insert_menu(before: string | number, text: string): Menu; + add_separator(): void; + insert_separator(before: string | number): void; + } } declare module "chatterino.json" { diff --git a/docs/lua-meta/globals.lua b/docs/lua-meta/globals.lua index 19180e368..da3bf1b75 100644 --- a/docs/lua-meta/globals.lua +++ b/docs/lua-meta/globals.lua @@ -537,6 +537,48 @@ c2.ImageSet = {} function c2.ImageSet.new(image1, image2, image3) end -- End src/controllers/plugins/api/Images.hpp +-- Begin src/controllers/plugins/api/Menu.hpp + + +---A generic menu used for context menus. +---@class c2.Menu +c2.Menu = {} + +---Appends a new action to the menu. +---@param text string +---@param cb fun() +function c2.Menu:add_action(text, cb) end + +---Inserts an action named `text` before `before`. If `before` is not found, +---the action is inserted at the end. `before` can either be a name or a +---one-based index. +---@param before string|integer A name or index of an action. +---@param text string +---@param cb fun() +function c2.Menu:insert_action(before, text, cb) end + +---Appends a new Menu with `title` to the menu. +---@param title string +---@return c2.Menu +function c2.Menu:add_menu(title) end + +---Inserts a new Menu named `title` before `before`. If `before` is not found, +---the menu is inserted at the end. `before` can either be a name or a one-based +---index. +---@param before string|integer A name or index of an action. +---@param title string +function c2.Menu:insert_menu(before, title) end + +---Appends a new separator. +function c2.Menu:add_separator() end + +---Inserts a new separator before `before`. If `before` is not found, +---the separator is inserted at the end. `before` can either be a name or a +---one-based index. +---@param before string|integer A name or index of an action. +function c2.Menu:insert_separator(before) end +-- End src/controllers/plugins/api/Menu.hpp + -- Begin src/controllers/plugins/api/Message.hpp @@ -1006,6 +1048,18 @@ c2.WindowManager = {} ---@return c2.Window[] windows function c2.WindowManager:all() end +---@class ChannelViewContextMenuRequestedArgs +---@field split? c2.Split The split holding the channel view. This is `nil` if the view is not inside a split. +---@field message c2.Message The clicked message. +---@field message_element? MessageElement The clicked message element. +---@field channel? c2.Channel The channel shown in the view. Note that this might be a virtual channel (e.g. in a search popup or usercard). +---@field menu c2.Menu The context menu. Add your actions here. + +---Registers an event handler for context menus in ChannelViews. +---@param cb fun(args: ChannelViewContextMenuRequestedArgs) +---@return c2.ConnectionHandle +function c2.WindowManager:on_channelview_context_menu_requested(cb) end + ---@type c2.WindowManager c2.windows = ... -- End src/controllers/plugins/api/WindowManager.hpp diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 0cd01483f..3d258f8f1 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -977,6 +977,21 @@ GraphViz output: Get all open windows. +##### `WindowManager:on_channelview_context_menu_requested(cb)` + +Registers an event handler for context menus in ChannelViews. + +When a context menu is requested, `cb` is passed a table with the following +fields: + +- `split?` ([`Split`](#split)) The split holding the channel view. This is `nil` + if the view is not inside a split. +- `message` ([`Message`](#message)) The clicked message. +- `message_element?` (`MessageElement`) The clicked message element. +- `channel?` ([`Channel`](#channel)) The channel shown in the view. Note that + this might be a virtual channel (e.g. in a search popup or usercard). +- `menu` ([`Menu`](#qmenu)) The context menu. Add your actions here. + #### `c2.windows` The global [`WindowManager`](#windowmanager). @@ -1046,6 +1061,39 @@ a local time but `1970-01-01T00:00:00Z` is not. Returns a copy of this datetime converted to UTC. +#### `Menu` + +A generic menu used for context menus. + +##### `Menu:add_action(text, cb)` + +Appends a new action to the menu. + +##### `Menu:insert_action(before, text, cb)` + +Inserts an action named `text` before `before`. If `before` is not found, the +action is inserted at the end. `before` can either be a name or a one-based +index. + +##### `Menu:add_menu(title)` + +Appends a new Menu with `title` to the menu. Returns the new menu. + +##### `Menu:insert_menu(before, text)` + +Inserts a new Menu named `title` before `before`. If `before` is not found, +the menu is inserted at the end. `before` can either be a name or a one-based +index. Returns the new menu. + +##### `Menu:add_separator()` + +Appends a new separator. + +##### `Menu:insert_separator(before)` + +Inserts a new separator before `before`. If `before` is not found, the separator +is inserted at the end. `before` can either be a name or a one-based index. + ### Input/Output API These functions are wrappers for Lua's I/O library. Functions on file pointer diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 79a3f295c..2b4e16871 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -281,6 +281,8 @@ set(SOURCE_FILES controllers/plugins/api/JSONParse.hpp controllers/plugins/api/JSONStringify.cpp controllers/plugins/api/JSONStringify.hpp + controllers/plugins/api/Menu.cpp + controllers/plugins/api/Menu.hpp controllers/plugins/api/Message.cpp controllers/plugins/api/Message.hpp controllers/plugins/api/WebSocket.cpp diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index e7a52831e..b72ea2464 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -99,6 +99,7 @@ sol::table toTable(lua_State *L, const CompletionEvent &ev); * @includefile controllers/plugins/api/HTTPResponse.hpp * @includefile controllers/plugins/api/HTTPRequest.hpp * @includefile controllers/plugins/api/Images.hpp + * @includefile controllers/plugins/api/Menu.hpp * @includefile controllers/plugins/api/Message.hpp * @includefile controllers/plugins/api/WebSocket.hpp * @includefile controllers/plugins/api/WindowManager.hpp diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp index ad332f50c..64659af09 100644 --- a/src/controllers/plugins/Plugin.cpp +++ b/src/controllers/plugins/Plugin.cpp @@ -83,6 +83,12 @@ Plugin::~Plugin() "This must be empty or destructor of sol::protected_function would " "explode malloc structures later"); } + +lua::PluginWeakRef Plugin::weakRef() const +{ + return this->selfRef_.weak(); +} + int Plugin::addTimeout(QTimer *timer) { this->activeTimeouts.push_back(timer); diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 2da2607ab..3b0e39c65 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -21,6 +21,7 @@ # include "controllers/plugins/api/Images.hpp" # include "controllers/plugins/api/IOWrapper.hpp" # include "controllers/plugins/api/JSON.hpp" +# include "controllers/plugins/api/Menu.hpp" # include "controllers/plugins/api/Message.hpp" # include "controllers/plugins/api/WebSocket.hpp" # include "controllers/plugins/api/WindowManager.hpp" @@ -259,6 +260,7 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin) lua::api::createAccounts(c2); lua::api::windowmanager::createUserTypes(c2); lua::api::datetime::createUserTypes(c2); + lua::api::menu::createUserType(c2); c2["ChannelType"] = lua::createEnumTable(lua); c2["HTTPMethod"] = lua::createEnumTable(lua); c2["EventType"] = lua::createEnumTable(lua); diff --git a/src/controllers/plugins/SignalCallback.hpp b/src/controllers/plugins/SignalCallback.hpp index d260edbe7..ca5eb803a 100644 --- a/src/controllers/plugins/SignalCallback.hpp +++ b/src/controllers/plugins/SignalCallback.hpp @@ -53,6 +53,11 @@ struct SignalCallback { } } + PluginWeakRef owner() const + { + return this->pluginRef; + } + void operator()(auto &&...args) const { assertInGuiThread(); diff --git a/src/controllers/plugins/api/Menu.cpp b/src/controllers/plugins/api/Menu.cpp new file mode 100644 index 000000000..9d0a65eaa --- /dev/null +++ b/src/controllers/plugins/api/Menu.cpp @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Contributors to Chatterino +// +// SPDX-License-Identifier: MIT + +#include "controllers/plugins/api/Menu.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "controllers/plugins/Plugin.hpp" +# include "controllers/plugins/SignalCallback.hpp" +# include "controllers/plugins/SolTypes.hpp" + +# include + +namespace { + +QAction *findAction(const QMenu &menu, const QString &name) +{ + const auto actions = menu.actions(); + for (QAction *action : actions) // NOLINT(misc-const-correctness) + { + if (action->text() == name) + { + return action; + } + } + return nullptr; +} + +QAction *findAction(const QMenu &menu, int n) +{ + n -= 1; // Take one-based indices + + const auto actions = menu.actions(); + if (n < 0 || n >= actions.size()) + { + return nullptr; + } + return menu.actions().at(n); +} + +QAction *findAction(const QMenu &menu, const std::variant &spec) +{ + return std::visit( + [&](auto &&it) { + return findAction(menu, it); + }, + spec); +} + +} // namespace + +namespace chatterino::lua::api::menu { + +void createUserType(sol::table &c2) +{ + c2.new_usertype( + "Menu", sol::no_constructor, // + + "add_action", + [](QMenu &menu, const QString &name, ThisPluginState state, + sol::main_protected_function cb) { + // NOLINTNEXTLINE(clazy-connect-3arg-lambda) + menu.addAction( + name, SignalCallback(state.plugin()->weakRef(), std::move(cb))); + }, + "insert_action", + [](QMenu &menu, const std::variant &before, + const QString &name, ThisPluginState state, + sol::main_protected_function cb) { + auto *act = new QAction(name, &menu); + QObject::connect( + act, &QAction::triggered, + SignalCallback(state.plugin()->weakRef(), std::move(cb))); + menu.insertAction(findAction(menu, before), act); + }, + + "add_menu", + [](QMenu &menu, const QString &title) { + return QPointer(menu.addMenu(title)); + }, + "insert_menu", + [](QMenu &menu, const std::variant &before, + const QString &title) { + return QPointer(menu.insertMenu(findAction(menu, before), + new QMenu(title, &menu))); + }, + + "add_separator", + [](QMenu &menu) { + menu.addSeparator(); + }, + "insert_separator", + [](QMenu &menu, const std::variant &before) { + menu.insertSeparator(findAction(menu, before)); + } // + ); +} + +} // namespace chatterino::lua::api::menu + +#endif diff --git a/src/controllers/plugins/api/Menu.hpp b/src/controllers/plugins/api/Menu.hpp new file mode 100644 index 000000000..24a1245ef --- /dev/null +++ b/src/controllers/plugins/api/Menu.hpp @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 Contributors to Chatterino +// +// SPDX-License-Identifier: MIT + +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include + +namespace chatterino::lua::api::menu { + +/* @lua-fragment +---A generic menu used for context menus. +---@class c2.Menu +c2.Menu = {} + +---Appends a new action to the menu. +---@param text string +---@param cb fun() +function c2.Menu:add_action(text, cb) end + +---Inserts an action named `text` before `before`. If `before` is not found, +---the action is inserted at the end. `before` can either be a name or a +---one-based index. +---@param before string|integer A name or index of an action. +---@param text string +---@param cb fun() +function c2.Menu:insert_action(before, text, cb) end + +---Appends a new Menu with `title` to the menu. +---@param title string +---@return c2.Menu +function c2.Menu:add_menu(title) end + +---Inserts a new Menu named `title` before `before`. If `before` is not found, +---the menu is inserted at the end. `before` can either be a name or a one-based +---index. +---@param before string|integer A name or index of an action. +---@param title string +function c2.Menu:insert_menu(before, title) end + +---Appends a new separator. +function c2.Menu:add_separator() end + +---Inserts a new separator before `before`. If `before` is not found, +---the separator is inserted at the end. `before` can either be a name or a +---one-based index. +---@param before string|integer A name or index of an action. +function c2.Menu:insert_separator(before) end +*/ + +/// Creates the c2.Menu user type +void createUserType(sol::table &c2); + +} // namespace chatterino::lua::api::menu + +#endif diff --git a/src/controllers/plugins/api/Message.cpp b/src/controllers/plugins/api/Message.cpp index 2ec2a11b8..e4abdf94e 100644 --- a/src/controllers/plugins/api/Message.cpp +++ b/src/controllers/plugins/api/Message.cpp @@ -275,158 +275,134 @@ std::shared_ptr messageFromTable(const sol::table &tbl); namespace chatterino::lua::api::message { -struct ElementRef { - ElementRef() = default; - ElementRef(std::shared_ptr msg, size_t index) - : msg(std::move(msg)) - , index(index) +MessageElement *ElementRef::element() const +{ + if (!this->msg || this->index >= this->msg->elements.size()) { + return nullptr; } + checkWritable(this->msg.get()); + return this->msg->elements[this->index].get(); +} - MessageElement *element() const +const MessageElement *ElementRef::constElement() const +{ + if (!this->msg || this->index >= this->msg->elements.size()) { - if (!this->msg || this->index >= this->msg->elements.size()) + return nullptr; + } + return this->msg->elements[this->index].get(); +} + +MessageElement &ElementRef::ref() const +{ + auto *el = this->element(); + if (!el) + { + throw std::runtime_error("Element does not exist or expired"); + } + return *el; +} + +const MessageElement &ElementRef::cref() const +{ + const auto *el = this->constElement(); + if (!el) + { + throw std::runtime_error("Element does not exist or expired"); + } + return *el; +} + +template +sol::optional ElementRef::as() const +{ + // using ref() to error if the reference is invalid + auto *el = dynamic_cast(&this->ref()); + if (!el) + { + return sol::nullopt; + } + return *el; +} + +template +sol::optional ElementRef::asConst() const +{ + // using cref() to error if the reference is invalid + const auto *el = dynamic_cast(&this->cref()); + if (!el) + { + return sol::nullopt; + } + return *el; +} + +template +bool ElementRef::is() const +{ + return dynamic_cast(&this->cref()) != nullptr; +} + +/// Visit this element by dynamic casting +template +auto ElementRef::visit(auto &&...cb) const +{ + static_assert(sizeof...(T) == sizeof...(cb) && sizeof...(T) > 0); + + // infer the returned type inside the optional + using Cb0 = std::tuple_element_t<0, std::tuple>; + using T0 = std::tuple_element_t<0, std::tuple>; + using TReturn = std::invoke_result_t; + + return this->visitOne(std::forward(cb)...); +} + +bool ElementRef::operator==(const ElementRef &rhs) const +{ + return this->msg.get() == rhs.msg.get() && this->index == rhs.index; +} + +template +decltype(auto) ElementRef::maybeConstElement() const +{ + if constexpr (Const) + { + return this->constElement(); + } + else + { + return this->element(); + } +} + +template +auto ElementRef::visitOne(auto &&cb, auto &&...rest) const + -> std::conditional_t, void, sol::optional> +{ + auto *el = dynamic_cast(this->maybeConstElement>()); + if (!el) + { + if constexpr (sizeof...(rest) == 0) { - return nullptr; - } - checkWritable(this->msg.get()); - return this->msg->elements[this->index].get(); - } - - const MessageElement *constElement() const - { - if (!this->msg || this->index >= this->msg->elements.size()) - { - return nullptr; - } - return this->msg->elements[this->index].get(); - } - - MessageElement &ref() const - { - auto *el = this->element(); - if (!el) - { - throw std::runtime_error("Element does not exist or expired"); - } - return *el; - } - - const MessageElement &cref() const - { - const auto *el = this->constElement(); - if (!el) - { - throw std::runtime_error("Element does not exist or expired"); - } - return *el; - } - - /// Cast this element to `T`. Otherwise nullopt is returned. - /// Use `.map()` to access the content. - template - sol::optional as() const - { - // using ref() to error if the reference is invalid - auto *el = dynamic_cast(&this->ref()); - if (!el) - { - return sol::nullopt; - } - return *el; - } - - /// Cast this element to `const T`. Otherwise nullopt is returned. - /// Use `.map()` to access the content. - template - sol::optional asConst() const - { - // using cref() to error if the reference is invalid - const auto *el = dynamic_cast(&this->cref()); - if (!el) - { - return sol::nullopt; - } - return *el; - } - - template - bool is() const - { - return dynamic_cast(&this->cref()) != nullptr; - } - - /// Visit this element by dynamic casting - template - auto visit(auto &&...cb) const - { - static_assert(sizeof...(T) == sizeof...(cb) && sizeof...(T) > 0); - - // infer the returned type inside the optional - using Cb0 = std::tuple_element_t<0, std::tuple>; - using T0 = std::tuple_element_t<0, std::tuple>; - using TReturn = std::invoke_result_t; - - return this->visitOne(std::forward(cb)...); - } - - bool operator==(const ElementRef &rhs) const - { - return this->msg.get() == rhs.msg.get() && this->index == rhs.index; - } - - std::shared_ptr msg; - size_t index = 0; - -private: - template - decltype(auto) maybeConstElement() const - { - if constexpr (Const) - { - return this->constElement(); - } - else - { - return this->element(); - } - } - - /// Run one callback - /// - /// This is called recursively. - /// If the callback returns something, we return an `optional` otherwise - /// we return `void`. - template - auto visitOne(auto &&cb, auto &&...rest) const - -> std::conditional_t, void, - sol::optional> - { - auto *el = - dynamic_cast(this->maybeConstElement>()); - if (!el) - { - if constexpr (sizeof...(rest) == 0) + if constexpr (std::is_void_v< + std::invoke_result_t>) { - if constexpr (std::is_void_v< - std::invoke_result_t>) - { - return; - } - else - { - return sol::nullopt; - } + return; } else { - return this->visitOne( - std::forward(rest)...); + return sol::nullopt; } } - return std::invoke(cb, *el); + else + { + return this->visitOne( + std::forward(rest)...); + } } -}; + return std::invoke(cb, *el); +} struct ElementIterator { using difference_type = std::ptrdiff_t; diff --git a/src/controllers/plugins/api/Message.hpp b/src/controllers/plugins/api/Message.hpp index 901cc250d..c81b9b250 100644 --- a/src/controllers/plugins/api/Message.hpp +++ b/src/controllers/plugins/api/Message.hpp @@ -245,6 +245,58 @@ enum class ExposedLinkType : std::uint8_t { * @includefile common/enums/MessageContext.hpp */ +struct ElementRef { + ElementRef() = default; + ElementRef(std::shared_ptr msg, size_t index) + : msg(std::move(msg)) + , index(index) + { + } + + MessageElement *element() const; + + const MessageElement *constElement() const; + + MessageElement &ref() const; + const MessageElement &cref() const; + + /// Cast this element to `T`. Otherwise nullopt is returned. + /// Use `.map()` to access the content. + template + sol::optional as() const; + + /// Cast this element to `const T`. Otherwise nullopt is returned. + /// Use `.map()` to access the content. + template + sol::optional asConst() const; + + template + bool is() const; + + /// Visit this element by dynamic casting + template + auto visit(auto &&...cb) const; + + bool operator==(const ElementRef &rhs) const; + + std::shared_ptr msg; + size_t index = 0; + +private: + template + decltype(auto) maybeConstElement() const; + + /// Run one callback + /// + /// This is called recursively. + /// If the callback returns something, we return an `optional` otherwise + /// we return `void`. + template + auto visitOne(auto &&cb, auto &&...rest) const + -> std::conditional_t, void, + sol::optional>; +}; + /// Creates the c2.Message user type void createUserType(sol::table &c2); diff --git a/src/controllers/plugins/api/WindowManager.cpp b/src/controllers/plugins/api/WindowManager.cpp index 124733d04..f684c378d 100644 --- a/src/controllers/plugins/api/WindowManager.cpp +++ b/src/controllers/plugins/api/WindowManager.cpp @@ -7,9 +7,15 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/api/Message.hpp" +# include "controllers/plugins/Plugin.hpp" +# include "controllers/plugins/SignalCallback.hpp" # include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep +# include "messages/layouts/MessageLayout.hpp" +# include "messages/layouts/MessageLayoutElement.hpp" # include "singletons/WindowManager.hpp" # include "util/WeakPtrHelpers.hpp" +# include "widgets/helper/ChannelView.hpp" # include "widgets/Notebook.hpp" # include "widgets/splits/Split.hpp" # include "widgets/Window.hpp" @@ -178,8 +184,58 @@ void createUserTypes(sol::table &c2) sol::readonly_property([](const WindowManager &self) { return QPointer(self.getLastSelectedWindow()); }), - "all", [](const WindowManager &self, sol::this_state state) { + "all", + [](const WindowManager &self, sol::this_state state) { return qPointerWrapped(self.windows(), state); + }, + "on_channelview_context_menu_requested", + [](WindowManager &self, ThisPluginState state, + sol::main_protected_function cb) { + return state.plugin()->connections.managedConnect( + self.channelViewContextMenuRequested, + [cb = SignalCallback(state.plugin()->weakRef(), std::move(cb))]( + const ChannelView &view, const MessageLayout &layout, + const MessageLayoutElement *element, QMenu &menu) { + auto pluginRef = cb.owner().strong(); + if (!pluginRef) + { + return; + } + + std::optional ref; + + auto msg = std::const_pointer_cast( + layout.getMessagePtr()); + if (element) + { + // Find the index of `el` in the message to create an + // `ElementRef`. + auto *el = &element->getCreator(); + for (size_t i = 0; i < msg->elements.size(); ++i) + { + if (msg->elements[i].get() == el) + { + ref.emplace(msg, i); + break; + } + } + } + + std::optional maybeChan; + if (auto underlyingChan = view.underlyingChannel()) + { + maybeChan.emplace(std::move(underlyingChan)); + } + + auto tbl = pluginRef.plugin()->state().create_table_with( + "split", QPointer(view.findParentSplit()), // + "channel", std::move(maybeChan), // + "menu", QPointer(&menu), // + "message", std::move(msg), // + "message_element", std::move(ref) // + ); + cb(std::move(tbl)); + }); }); } diff --git a/src/controllers/plugins/api/WindowManager.hpp b/src/controllers/plugins/api/WindowManager.hpp index 7e4e00ade..e9f894728 100644 --- a/src/controllers/plugins/api/WindowManager.hpp +++ b/src/controllers/plugins/api/WindowManager.hpp @@ -64,6 +64,18 @@ c2.WindowManager = {} ---@return c2.Window[] windows function c2.WindowManager:all() end +---@class ChannelViewContextMenuRequestedArgs +---@field split? c2.Split The split holding the channel view. This is `nil` if the view is not inside a split. +---@field message c2.Message The clicked message. +---@field message_element? MessageElement The clicked message element. +---@field channel? c2.Channel The channel shown in the view. Note that this might be a virtual channel (e.g. in a search popup or usercard). +---@field menu c2.Menu The context menu. Add your actions here. + +---Registers an event handler for context menus in ChannelViews. +---@param cb fun(args: ChannelViewContextMenuRequestedArgs) +---@return c2.ConnectionHandle +function c2.WindowManager:on_channelview_context_menu_requested(cb) end + ---@type c2.WindowManager c2.windows = ... */ diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 5befad266..bee19f8a4 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -37,6 +37,8 @@ class Channel; using ChannelPtr = std::shared_ptr; struct Message; using MessagePtr = std::shared_ptr; +class MessageLayout; +class MessageLayoutElement; class WindowLayout; class Theme; class Fonts; @@ -170,6 +172,12 @@ public: pajlada::Signals::Signal selectSplitContainer; pajlada::Signals::Signal scrollToMessageSignal; + /// This is invoked when a context menu for a message is requested in any + /// ChannelView. It's primarily used by plugins to add items. + pajlada::Signals::Signal + channelViewContextMenuRequested; + private: // Load window layout from the window-layout.json file WindowLayout loadWindowLayoutFromFile() const; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 47b4a5758..4bdc6266a 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -464,6 +464,23 @@ Scrollbar *ChannelView::scrollbar() return this->scrollBar_; } +Split *ChannelView::findParentSplit() const +{ + auto *split = dynamic_cast(this->parentWidget()); + + if (split) + { + return split; + } + + auto *searchPopup = dynamic_cast(this->parentWidget()); + if (!searchPopup) + { + return nullptr; + } + return dynamic_cast(searchPopup->parentWidget()); +} + bool ChannelView::pausable() const { return this->pausable_; @@ -1408,16 +1425,7 @@ MessageElementFlags ChannelView::getFlags() const MessageElementFlags flags = app->getWindows()->getWordFlags(); - auto *split = dynamic_cast(this->parentWidget()); - - if (split == nullptr) - { - auto *searchPopup = dynamic_cast(this->parentWidget()); - if (searchPopup != nullptr) - { - split = dynamic_cast(searchPopup->parentWidget()); - } - } + auto *split = this->findParentSplit(); if (split != nullptr) { @@ -2631,6 +2639,11 @@ void ChannelView::addContextMenuItems( this->messageMenuCreated.invoke(menu, hoveredElement); + menu->addSeparator(); + + getApp()->getWindows()->channelViewContextMenuRequested.invoke( + *this, *layout, hoveredElement, *menu); + menu->popup(QCursor::pos()); menu->raise(); } diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 395833fc4..e561eba94 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -223,6 +223,8 @@ public: Scrollbar *scrollbar(); + Split *findParentSplit() const; + using ChannelViewID = std::size_t; /// /// \brief Get the ID of this ChannelView