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 <mm2pl+gh@kotmisia.pl>
Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
Nerixyz
2026-04-17 14:46:33 +00:00
committed by GitHub
parent d1ee7944db
commit 490bfdac07
14 changed files with 525 additions and 0 deletions
+56
View File
@@ -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" {
+84
View File
@@ -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
+63
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -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
*/
@@ -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 <lauxlib.h>
# include <lua.h>
@@ -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<Channel::Type>(lua);
c2["HTTPMethod"] = lua::createEnumTable<NetworkRequestType>(lua);
c2["EventType"] = lua::createEnumTable<lua::api::EventType>(lua);
@@ -261,6 +266,11 @@ void PluginController::initSol(sol::state_view &lua, Plugin *plugin)
c2["MessageContext"] = lua::createEnumTable<MessageContext>(lua);
c2["LinkType"] =
lua::createEnumTable<lua::api::message::ExposedLinkType>(lua);
c2["SplitContainerNodeType"] =
lua::createEnumTable<SplitContainer::Node::Type>(lua);
c2["WindowType"] = lua::createEnumTable<WindowType>(lua);
c2["windows"] = getApp()->getWindows();
sol::table io = g["io"];
io.set_function(
+25
View File
@@ -10,6 +10,7 @@
# include "util/TypeName.hpp"
# include <QObject>
# include <QPointer>
# include <QString>
# include <QStringBuilder>
# include <QStringList>
@@ -204,4 +205,28 @@ SOL_STACK_FUNCTIONS(QSizeF)
# undef SOL_STACK_FUNCTIONS
namespace sol {
// NOLINTBEGIN(readability-identifier-naming)
template <typename T>
struct unique_usertype_traits<QPointer<T>> {
using type = T;
using actual_type = QPointer<T>;
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
@@ -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<int>(items.size()), 0);
for (size_t idx = 0; idx < items.size(); idx++)
{
tbl[static_cast<int>(idx + 1)] = QPointer(items[idx]);
}
return tbl;
}
/// Wraps a `std::weak_ptr<SplitContainer::Node>` 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<SplitContainer::Node> 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<int>(nodes.size()), 0);
for (size_t idx = 0; idx < nodes.size(); idx++)
{
tbl[static_cast<int>(idx + 1)] = SplitContainerNodeWrap{nodes[idx]};
}
return tbl;
}
bool isValid() const
{
return !this->ptr.expired();
}
std::shared_ptr<SplitContainer::Node> 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<SplitContainerNodeWrap> fromPtr(
SplitContainer::Node *ptr)
{
if (ptr)
{
return SplitContainerNodeWrap(ptr->weak_from_this());
}
return std::nullopt;
}
private:
std::weak_ptr<SplitContainer::Node> ptr;
};
} // namespace
namespace chatterino::lua::api::windowmanager {
void createUserTypes(sol::table &c2)
{
c2.new_usertype<Split>("Split", sol::no_constructor, //
"channel",
sol::readonly_property([](const Split &self) {
return ChannelRef(self.getChannel());
}));
c2.new_usertype<SplitContainerNodeWrap>(
"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>(
"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>(
"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<SplitContainer>(nullptr);
}
return QPointer(
dynamic_cast<SplitContainer *>(self.getPageAt(index)));
});
// Wrapped in a QPointer
c2.new_usertype<Window>("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>(
"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
@@ -0,0 +1,77 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include <sol/forward.hpp>
/**
* @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
+5
View File
@@ -644,6 +644,11 @@ std::set<QString> WindowManager::getVisibleChannelNames() const
return visible;
}
std::span<Window *const> WindowManager::windows() const
{
return this->windows_;
}
void WindowManager::encodeTab(SplitContainer *tab, bool isSelected,
QJsonObject &obj)
{
+3
View File
@@ -15,6 +15,7 @@
#include <memory>
#include <set>
#include <span>
namespace chatterino {
@@ -143,6 +144,8 @@ public:
std::set<QString> getVisibleChannelNames() const;
std::span<Window *const> windows() const;
/// Signals
pajlada::Signals::NoArgSignal gifRepaintRequested;
+3
View File
@@ -20,6 +20,9 @@ class UpdateDialog;
class SplitNotebook;
class Channel;
/**
* @exposeenum c2.WindowType
*/
enum class WindowType { Main, Popup, Attached };
class Window : public BaseWindow
+3
View File
@@ -86,6 +86,9 @@ public:
Node();
Node(Split *_split, Node *_parent);
/**
* @exposeenum c2.SplitContainerNodeType
*/
enum class Type {
EmptyRoot,
Split,
+9
View File
@@ -26,6 +26,7 @@
# include "mocks/TwitchIrcServer.hpp"
# include "NetworkHelpers.hpp"
# include "singletons/Logging.hpp"
# include "singletons/WindowManager.hpp"
# include "Test.hpp"
# include <lauxlib.h>
@@ -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)