From 490bfdac0787fef275bdc478c37fcf6560e7c792 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Fri, 17 Apr 2026 16:46:33 +0200 Subject: [PATCH] feat(plugins): add readonly window layout access (#6687) This implements the second part for https://github.com/Chatterino/chatterino2/issues/5978 - read-only access to the window layout. This allows plugins to query the open channels and how they're presented. Reviewed-by: Mm2PL Reviewed-by: pajlada --- docs/chatterino.d.ts | 56 ++++++ docs/lua-meta/globals.lua | 84 ++++++++ docs/wip-plugins.md | 63 ++++++ src/CMakeLists.txt | 2 + src/controllers/plugins/LuaAPI.hpp | 1 + src/controllers/plugins/PluginController.cpp | 10 + src/controllers/plugins/SolTypes.hpp | 25 +++ src/controllers/plugins/api/WindowManager.cpp | 184 ++++++++++++++++++ src/controllers/plugins/api/WindowManager.hpp | 77 ++++++++ src/singletons/WindowManager.cpp | 5 + src/singletons/WindowManager.hpp | 3 + src/widgets/Window.hpp | 3 + src/widgets/splits/SplitContainer.hpp | 3 + tests/src/Plugins.cpp | 9 + 14 files changed, 525 insertions(+) create mode 100644 src/controllers/plugins/api/WindowManager.cpp create mode 100644 src/controllers/plugins/api/WindowManager.hpp diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index 8211d3e3b..88dff92b5 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -606,6 +606,62 @@ declare namespace c2 { ) => ImageSet; } var ImageSet: ImageSetConstructor; + + class Split implements IWeakResource { + is_valid(): boolean; + channel: Channel; + } + + enum SplitContainerNodeType { + EmptyRoot, + Split, + VerticalContainer, + HorizontalContainer, + } + + class SplitContainerNode { + type: SplitContainerNodeType; + split?: Split; + parent?: SplitContainerNode; + horizontal_flex: number; + vertical_flex: number; + + children(): SplitContainerNode[]; + } + + class SplitContainer { + selected_split: Split; + base_node: SplitContainerNode; + + splits(): Split[]; + } + + class SplitNotebook { + selected_page?: SplitContainer; + page_count: number; + + page_at(i: number): SplitContainer | null; + } + + enum WindowType { + Main, + Popup, + Attached, + } + + class Window { + notebook: SplitNotebook; + type: WindowType; + } + + class WindowManager { + main_window: Window; + last_selected_window: Window; + + all(): Window[]; + } + + var windows: WindowManager; } declare module "chatterino.json" { diff --git a/docs/lua-meta/globals.lua b/docs/lua-meta/globals.lua index 9157df06b..2d028a1c2 100644 --- a/docs/lua-meta/globals.lua +++ b/docs/lua-meta/globals.lua @@ -849,6 +849,90 @@ function c2.WebSocket:send_binary(data) end -- End src/controllers/plugins/api/WebSocket.hpp +-- Begin src/controllers/plugins/api/WindowManager.hpp + +-- Begin src/widgets/splits/SplitContainer.hpp + +---@enum c2.SplitContainerNodeType +c2.SplitContainerNodeType = { + EmptyRoot = {}, ---@type c2.SplitContainerNodeType.EmptyRoot + Split = {}, ---@type c2.SplitContainerNodeType.Split + VerticalContainer = {}, ---@type c2.SplitContainerNodeType.VerticalContainer + HorizontalContainer = {}, ---@type c2.SplitContainerNodeType.HorizontalContainer +} + +-- End src/widgets/splits/SplitContainer.hpp + +-- Begin src/widgets/Window.hpp + +---@enum c2.WindowType +c2.WindowType = { + Main = {}, ---@type c2.WindowType.Main + Popup = {}, ---@type c2.WindowType.Popup + Attached = {}, ---@type c2.WindowType.Attached +} + +-- End src/widgets/Window.hpp + + + +---@class c2.Split +---@field channel c2.Channel The channel open in this split (might be empty) +c2.Split = {} + +---@class c2.SplitContainerNode A node in a split container +---@field type c2.SplitContainerNodeType The type of this node +---@field split c2.Split|nil The split contained in this code (if this is a split node) +---@field parent c2.SplitContainerNode|nil The parent node +---@field horizontal_flex number The amount of horizontal space this split takes +---@field vertical_flex number The amount of vertical space this split takes +c2.SplitContainerNode = {} + +---Get all children of this node. +---@return c2.SplitContainerNode[] children +function c2.SplitContainerNode:children() end + +---Is this handle still valid? +---@return boolean +function c2.SplitContainerNode:is_valid() end + +---@class c2.SplitContainer A container with potentially multiple splits +---@field selected_split c2.Split The currently selected split. +---@field base_node c2.SplitContainerNode The top level node. +c2.SplitContainer = {} + +---Get all splits contained in this container +---@return c2.Split[] splits +function c2.SplitContainer:splits() end + +---@class c2.SplitNotebook +---@field selected_page c2.SplitContainer|nil The currently selected page. +---@field page_count integer The number of pages/tabs. +c2.SplitNotebook = {} + +---Get the notebook page at a specific index. +---@param i integer The zero based index of the page. +---@return c2.SplitContainer|nil page The page contained at the specified index (zero based). +function c2.SplitNotebook:page_at(i) end + +---@class c2.Window +---@field notebook c2.SplitNotebook The notebook of this window. +---@field type c2.WindowType The type of this window. +c2.Window = {} + +---@class c2.WindowManager +---@field main_window c2.Window The main window. +---@field last_selected_window c2.Window The last selected window (or the main window if none were selected last). +c2.WindowManager = {} + +---Get all open windows. +---@return c2.Window[] windows +function c2.WindowManager:all() end + +---@type c2.WindowManager +c2.windows = ... +-- End src/controllers/plugins/api/WindowManager.hpp + -- Begin src/common/network/NetworkCommon.hpp ---@enum c2.HTTPMethod diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 3e720ef12..d9306b075 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -894,6 +894,69 @@ All arguments accept an [`Image`](#image) or a `string` (URL). Requires the [network permission](#permissions). +#### `Split` + +A split. See [Anatomy of a Chatterino window](https://wiki.chatterino.com/Glossary/#anatomy-of-a-chatterino-window). +This holds a `channel` ([Channel](#channel)) that's open in this split. + +#### `SplitContainerNode` + +A node in a split container. It has the following fields: + +- `type` (`SplitContainerNodeType`) The type of this node +- `split` ([`Split`](#split)?) The split contained in this code (if this is a split node) +- `parent` ([`SplitContainerNode`](#splitcontainernode)?) The parent node +- `horizontal_flex` (`number`) The amount of horizontal space this split takes +- `vertical_flex` (`number`) The amount of vertical space this split takes + +##### `SplitContainerNode:children()` + +Get all children ([`SplitContainerNode`](#splitcontainernode)) of this node. + +#### `SplitContainer` + +A container with potentially multiple splits. It has the following fields: + +- `selected_split` ([`Split`](#split)) The currently selected split. +- `base_node` ([`SplitContainerNode`](#splitcontainernode)) The top level node. + +##### `SplitContainer:splits()` + +Get all splits ([`Split`](#split)) contained in this container. + +#### `SplitNotebook` + +- `selected_page` ([`SplitContainer`](#splitcontainer)?) The currently selected page. +- `page_count` (`integer`) The number of pages/tabs. + +##### `SplitNotebook:page_at(i)` + +Get the notebook page at a specific index. +`i` is the zero based index of the page. +Returns the page ([`SplitContainer`](#splitcontainer)) contained at the specified index. + +#### `Window` + +It has the following fields: + +- `notebook` ([`SplitNotebook`](#splitnotebook)) The notebook of this window. +- `type` (`WindowType`) The type of this window. + +#### `WindowManager` + +It has the following fields: + +- `main_window` ([`Window`](#window)) The main window. +- `last_selected_window` ([`Window`](#window)) The last selected window (or the main window if none were selected last). + +##### `WindowManager:all()` + +Get all open windows. + +#### `c2.windows` + +The global [`WindowManager`](#windowmanager). + ### 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 6af3678c6..42b9e4a49 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -283,6 +283,8 @@ set(SOURCE_FILES controllers/plugins/api/Message.hpp controllers/plugins/api/WebSocket.cpp controllers/plugins/api/WebSocket.hpp + controllers/plugins/api/WindowManager.cpp + controllers/plugins/api/WindowManager.hpp controllers/plugins/ConnectionManager.cpp controllers/plugins/ConnectionManager.hpp controllers/plugins/LuaAPI.cpp diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index 37506e9f2..0d6cac342 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -100,6 +100,7 @@ sol::table toTable(lua_State *L, const CompletionEvent &ev); * @includefile controllers/plugins/api/Images.hpp * @includefile controllers/plugins/api/Message.hpp * @includefile controllers/plugins/api/WebSocket.hpp + * @includefile controllers/plugins/api/WindowManager.hpp * @includefile common/network/NetworkCommon.hpp */ diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index f45fcc905..5ed2fe599 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -22,6 +22,7 @@ # include "controllers/plugins/api/JSON.hpp" # include "controllers/plugins/api/Message.hpp" # include "controllers/plugins/api/WebSocket.hpp" +# include "controllers/plugins/api/WindowManager.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/SolTypes.hpp" @@ -29,6 +30,9 @@ # include "messages/MessageElement.hpp" # include "singletons/Paths.hpp" # include "singletons/Settings.hpp" +# include "singletons/WindowManager.hpp" +# include "widgets/splits/SplitContainer.hpp" +# include "widgets/Window.hpp" # include # include @@ -250,6 +254,7 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin) lua::api::message::createUserType(c2); lua::api::images::createUserTypes(c2); lua::api::createAccounts(c2); + lua::api::windowmanager::createUserTypes(c2); c2["ChannelType"] = lua::createEnumTable(lua); c2["HTTPMethod"] = lua::createEnumTable(lua); c2["EventType"] = lua::createEnumTable(lua); @@ -261,6 +266,11 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin) c2["MessageContext"] = lua::createEnumTable(lua); c2["LinkType"] = lua::createEnumTable(lua); + c2["SplitContainerNodeType"] = + lua::createEnumTable(lua); + c2["WindowType"] = lua::createEnumTable(lua); + + c2["windows"] = getApp()->getWindows(); sol::table io = g["io"]; io.set_function( diff --git a/src/controllers/plugins/SolTypes.hpp b/src/controllers/plugins/SolTypes.hpp index 80cb8615b..510737063 100644 --- a/src/controllers/plugins/SolTypes.hpp +++ b/src/controllers/plugins/SolTypes.hpp @@ -10,6 +10,7 @@ # include "util/TypeName.hpp" # include +# include # include # include # include @@ -204,4 +205,28 @@ SOL_STACK_FUNCTIONS(QSizeF) # undef SOL_STACK_FUNCTIONS +namespace sol { + +// NOLINTBEGIN(readability-identifier-naming) +template +struct unique_usertype_traits> { + using type = T; + using actual_type = QPointer; + + static const bool value = true; + + static bool is_null(const actual_type &ptr) + { + return ptr.isNull(); + } + + static type *get(const actual_type &ptr) + { + return ptr.get(); + } +}; +// NOLINTEND(readability-identifier-naming) + +} // namespace sol + #endif diff --git a/src/controllers/plugins/api/WindowManager.cpp b/src/controllers/plugins/api/WindowManager.cpp new file mode 100644 index 000000000..03a40e845 --- /dev/null +++ b/src/controllers/plugins/api/WindowManager.cpp @@ -0,0 +1,184 @@ +#include "controllers/plugins/api/WindowManager.hpp" + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep +# include "singletons/WindowManager.hpp" +# include "util/WeakPtrHelpers.hpp" +# include "widgets/Notebook.hpp" +# include "widgets/splits/Split.hpp" +# include "widgets/Window.hpp" + +namespace { + +using namespace chatterino; + +/// Create a table with all items from `items` wrapped in a `QPointer`. +sol::table qPointerWrapped(const auto &items, sol::this_state state) +{ + auto tbl = + sol::state_view(state).create_table(static_cast(items.size()), 0); + + for (size_t idx = 0; idx < items.size(); idx++) + { + tbl[static_cast(idx + 1)] = QPointer(items[idx]); + } + return tbl; +} + +/// Wraps a `std::weak_ptr` and adds convenience functions +/// and an `operator==`. +/// +/// In Lua, all nodes have this type instead of a raw `weak_ptr`. In Chatterino, +/// nodes are `std::shared_ptr`s and often nodes are passed around as raw +/// pointers. +struct SplitContainerNodeWrap { + SplitContainerNodeWrap(std::weak_ptr ptr) + : ptr(std::move(ptr)) + { + } + + sol::table children(sol::this_state state) const + { + const auto &nodes = this->strong()->getChildren(); + auto tbl = sol::state_view(state).create_table( + static_cast(nodes.size()), 0); + + for (size_t idx = 0; idx < nodes.size(); idx++) + { + tbl[static_cast(idx + 1)] = SplitContainerNodeWrap{nodes[idx]}; + } + return tbl; + } + + bool isValid() const + { + return !this->ptr.expired(); + } + + std::shared_ptr strong() const + { + auto locked = this->ptr.lock(); + if (locked) + { + return locked; + } + throw std::runtime_error("Split container node does not exist anymore"); + } + + bool operator==(const SplitContainerNodeWrap &other) const + { + return weakOwnerEquals(this->ptr, other.ptr); + } + + static std::optional fromPtr( + SplitContainer::Node *ptr) + { + if (ptr) + { + return SplitContainerNodeWrap(ptr->weak_from_this()); + } + return std::nullopt; + } + +private: + std::weak_ptr ptr; +}; + +} // namespace + +namespace chatterino::lua::api::windowmanager { + +void createUserTypes(sol::table &c2) +{ + c2.new_usertype("Split", sol::no_constructor, // + "channel", + sol::readonly_property([](const Split &self) { + return ChannelRef(self.getChannel()); + })); + + c2.new_usertype( + "SplitContainerNode", sol::no_constructor, // + "is_valid", &SplitContainerNodeWrap::isValid, // + "type", sol::readonly_property([](const SplitContainerNodeWrap &self) { + return self.strong()->getType(); + }), + "split", sol::readonly_property([](const SplitContainerNodeWrap &self) { + return QPointer(self.strong()->getSplit()); + }), + "parent", + sol::readonly_property([](const SplitContainerNodeWrap &self) { + return SplitContainerNodeWrap::fromPtr(self.strong()->getParent()); + }), + "horizontal_flex", + sol::readonly_property([](const SplitContainerNodeWrap &self) { + return self.strong()->getHorizontalFlex(); + }), + "vertical_flex", + sol::readonly_property([](const SplitContainerNodeWrap &self) { + return self.strong()->getVerticalFlex(); + }), + "children", &SplitContainerNodeWrap::children); + + // Wrapped in a QPointer + c2.new_usertype( + "SplitContainer", sol::no_constructor, // + "selected_split", + sol::property( + [](const SplitContainer &self) { + return QPointer(self.getSelectedSplit()); + }, + [](SplitContainer &self, Split &split) { + self.setSelected(&split); + }), + "base_node", sol::readonly_property([](SplitContainer &self) { + return SplitContainerNodeWrap::fromPtr(self.getBaseNode()); + }), // + "splits", [](SplitContainer &self, sol::this_state state) { + return qPointerWrapped(self.getSplits(), state); + }); + + // Wrapped in a QPointer + c2.new_usertype( + "SplitNotebook", sol::no_constructor, // + "selected_page", sol::readonly_property([](SplitNotebook &self) { + return QPointer(self.getSelectedPage()); + }), + "page_count", sol::readonly_property(&SplitNotebook::getPageCount), + "page_at", [](const SplitNotebook &self, int index) { + if (index < 0 || index >= self.getPageCount()) + { + return QPointer(nullptr); + } + return QPointer( + dynamic_cast(self.getPageAt(index))); + }); + + // Wrapped in a QPointer + c2.new_usertype("Window", sol::no_constructor, // + "notebook", + sol::readonly_property([](Window &self) { + return QPointer(&self.getNotebook()); + }), + "type", sol::readonly_property([](Window &self) { + return self.getType(); + })); + + c2.new_usertype( + "WindowManager", sol::no_constructor, // + "main_window", sol::readonly_property([](WindowManager &self) { + return QPointer(&self.getMainWindow()); + }), + "last_selected_window", + sol::readonly_property([](const WindowManager &self) { + return QPointer(self.getLastSelectedWindow()); + }), + "all", [](const WindowManager &self, sol::this_state state) { + return qPointerWrapped(self.windows(), state); + }); +} + +} // namespace chatterino::lua::api::windowmanager + +#endif diff --git a/src/controllers/plugins/api/WindowManager.hpp b/src/controllers/plugins/api/WindowManager.hpp new file mode 100644 index 000000000..7e4e00ade --- /dev/null +++ b/src/controllers/plugins/api/WindowManager.hpp @@ -0,0 +1,77 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include + +/** + * @includefile widgets/splits/SplitContainer.hpp + * @includefile widgets/Window.hpp + */ + +/* @lua-fragment + +---@class c2.Split +---@field channel c2.Channel The channel open in this split (might be empty) +c2.Split = {} + +---@class c2.SplitContainerNode A node in a split container +---@field type c2.SplitContainerNodeType The type of this node +---@field split c2.Split|nil The split contained in this code (if this is a split node) +---@field parent c2.SplitContainerNode|nil The parent node +---@field horizontal_flex number The amount of horizontal space this split takes +---@field vertical_flex number The amount of vertical space this split takes +c2.SplitContainerNode = {} + +---Get all children of this node. +---@return c2.SplitContainerNode[] children +function c2.SplitContainerNode:children() end + +---Is this handle still valid? +---@return boolean +function c2.SplitContainerNode:is_valid() end + +---@class c2.SplitContainer A container with potentially multiple splits +---@field selected_split c2.Split The currently selected split. +---@field base_node c2.SplitContainerNode The top level node. +c2.SplitContainer = {} + +---Get all splits contained in this container +---@return c2.Split[] splits +function c2.SplitContainer:splits() end + +---@class c2.SplitNotebook +---@field selected_page c2.SplitContainer|nil The currently selected page. +---@field page_count integer The number of pages/tabs. +c2.SplitNotebook = {} + +---Get the notebook page at a specific index. +---@param i integer The zero based index of the page. +---@return c2.SplitContainer|nil page The page contained at the specified index (zero based). +function c2.SplitNotebook:page_at(i) end + +---@class c2.Window +---@field notebook c2.SplitNotebook The notebook of this window. +---@field type c2.WindowType The type of this window. +c2.Window = {} + +---@class c2.WindowManager +---@field main_window c2.Window The main window. +---@field last_selected_window c2.Window The last selected window (or the main window if none were selected last). +c2.WindowManager = {} + +---Get all open windows. +---@return c2.Window[] windows +function c2.WindowManager:all() end + +---@type c2.WindowManager +c2.windows = ... +*/ + +namespace chatterino::lua::api::windowmanager { + +void createUserTypes(sol::table &c2); + +} // namespace chatterino::lua::api::windowmanager + +#endif diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index 43d298d47..c5a300892 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -644,6 +644,11 @@ std::set WindowManager::getVisibleChannelNames() const return visible; } +std::span WindowManager::windows() const +{ + return this->windows_; +} + void WindowManager::encodeTab(SplitContainer *tab, bool isSelected, QJsonObject &obj) { diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 5bf44c5a8..53126dcae 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -15,6 +15,7 @@ #include #include +#include namespace chatterino { @@ -143,6 +144,8 @@ public: std::set getVisibleChannelNames() const; + std::span windows() const; + /// Signals pajlada::Signals::NoArgSignal gifRepaintRequested; diff --git a/src/widgets/Window.hpp b/src/widgets/Window.hpp index 04989524b..3fc4c2ee2 100644 --- a/src/widgets/Window.hpp +++ b/src/widgets/Window.hpp @@ -20,6 +20,9 @@ class UpdateDialog; class SplitNotebook; class Channel; +/** + * @exposeenum c2.WindowType + */ enum class WindowType { Main, Popup, Attached }; class Window : public BaseWindow diff --git a/src/widgets/splits/SplitContainer.hpp b/src/widgets/splits/SplitContainer.hpp index 6d86e60d4..1896c2238 100644 --- a/src/widgets/splits/SplitContainer.hpp +++ b/src/widgets/splits/SplitContainer.hpp @@ -86,6 +86,9 @@ public: Node(); Node(Split *_split, Node *_parent); + /** + * @exposeenum c2.SplitContainerNodeType + */ enum class Type { EmptyRoot, Split, diff --git a/tests/src/Plugins.cpp b/tests/src/Plugins.cpp index 2b07fa4e0..5fda7bde7 100644 --- a/tests/src/Plugins.cpp +++ b/tests/src/Plugins.cpp @@ -26,6 +26,7 @@ # include "mocks/TwitchIrcServer.hpp" # include "NetworkHelpers.hpp" # include "singletons/Logging.hpp" +# include "singletons/WindowManager.hpp" # include "Test.hpp" # include @@ -96,6 +97,8 @@ public: : mock::BaseApplication(TEST_SETTINGS) , plugins(this->paths_) , commands(this->paths_) + , windows(this->args, this->paths_, this->settings, this->theme, + this->fonts) { } @@ -129,6 +132,11 @@ public: return &this->accounts; } + WindowManager *getWindows() override + { + return &this->windows; + } + PluginController plugins; mock::Logging logging; CommandController commands; @@ -136,6 +144,7 @@ public: MockTwitch twitch; AccountController accounts; mock::Helix helix; + WindowManager windows; }; QDir luaTestBaseDir(const QString &category)