diff --git a/CHANGELOG.md b/CHANGELOG.md
index 750f7ae4..7cbb6332 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
- Minor: Added cached emotes fallback when fetching from a provider fails. (#6125, #6229)
- Minor: Add an option for the reduced opacity of message history. (#6121)
- Minor: Make paused chat indicator more visible, and fix its zoom behavior. (#6123)
+- Minor: Added interactive REPL for plugins. (#6120)
- Minor: Added WebSocket API for plugins. (#6076, #6186)
- Minor: Allow for themes to set transparent values for window background on Linux. (#6137)
- Minor: Popup overlay now only draws an outline when being interacted with. (#6140)
diff --git a/resources/buttons/reloadDark.svg b/resources/buttons/reloadDark.svg
new file mode 100644
index 00000000..96410e00
--- /dev/null
+++ b/resources/buttons/reloadDark.svg
@@ -0,0 +1 @@
+
diff --git a/resources/buttons/reloadLight.svg b/resources/buttons/reloadLight.svg
new file mode 100644
index 00000000..22c494cb
--- /dev/null
+++ b/resources/buttons/reloadLight.svg
@@ -0,0 +1 @@
+
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index d3b1e3bd..8c43c4f7 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -594,6 +594,8 @@ set(SOURCE_FILES
widgets/Notebook.hpp
widgets/OverlayWindow.cpp
widgets/OverlayWindow.hpp
+ widgets/PluginRepl.cpp
+ widgets/PluginRepl.hpp
widgets/Scrollbar.cpp
widgets/Scrollbar.hpp
widgets/TooltipEntryWidget.cpp
diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp
index 7ddc3ed2..6e64b6c7 100644
--- a/src/controllers/plugins/LuaAPI.cpp
+++ b/src/controllers/plugins/LuaAPI.cpp
@@ -31,19 +31,6 @@
namespace {
using namespace chatterino;
-void logHelper(lua_State *L, Plugin *pl, QDebug stream,
- const sol::variadic_args &args)
-{
- stream.noquote();
- stream << "[" + pl->id + ":" + pl->meta.name + "]";
- for (const auto &arg : args)
- {
- stream << lua::toString(L, arg.stack_index());
- // Remove this from our stack
- lua_pop(L, 1);
- }
-}
-
QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl)
{
auto base =
@@ -101,7 +88,7 @@ void c2_log(ThisPluginState L, LogLevel lvl, sol::variadic_args args)
lua::StackGuard guard(L);
{
QDebug stream = qdebugStreamForLogLevel(lvl);
- logHelper(L, L.plugin(), stream, args);
+ L.plugin()->log(L.state(), lvl, std::move(stream), args);
}
}
@@ -261,7 +248,7 @@ void g_print(ThisPluginState L, sol::variadic_args args)
(QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE,
QT_MESSAGELOG_FUNC, chatterinoLua().categoryName())
.debug());
- logHelper(L, L.plugin(), stream, args);
+ L.plugin()->log(L.state(), LogLevel::Info, std::move(stream), args);
}
void package_loadlib(sol::variadic_args args)
diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp
index 2ad1bdc4..0dda2943 100644
--- a/src/controllers/plugins/Plugin.cpp
+++ b/src/controllers/plugins/Plugin.cpp
@@ -218,6 +218,8 @@ std::unordered_set Plugin::listRegisteredCommands()
Plugin::~Plugin()
{
+ this->onUnloaded();
+
for (auto *timer : this->activeTimeouts)
{
QObject::disconnect(timer, nullptr, nullptr, nullptr);
@@ -293,6 +295,35 @@ bool Plugin::hasHTTPPermissionFor(const QUrl &url)
});
}
+void Plugin::log(lua_State *L, lua::api::LogLevel level, QDebug stream,
+ const sol::variadic_args &args)
+{
+ stream.noquote();
+ stream << "[" + this->id + ":" + this->meta.name + "]";
+ QString fullMessage;
+ for (const auto &arg : args)
+ {
+ auto s = lua::toString(L, arg.stack_index());
+ stream << s;
+
+ if (!fullMessage.isEmpty())
+ {
+ fullMessage.append(' ');
+ }
+ fullMessage.append(s);
+
+ // Remove this from our stack
+ lua_pop(L, 1);
+ }
+
+ this->onLog(level, fullMessage);
+}
+
+sol::state_view Plugin::state()
+{
+ return {this->state_};
+}
+
bool Plugin::hasNetworkPermission() const
{
return std::ranges::any_of(this->meta.permissions, [](const auto &p) {
diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp
index 1d7bbed1..82f7a25b 100644
--- a/src/controllers/plugins/Plugin.hpp
+++ b/src/controllers/plugins/Plugin.hpp
@@ -6,6 +6,7 @@
# include "controllers/plugins/LuaUtilities.hpp"
# include "controllers/plugins/PluginPermission.hpp"
+# include
# include
# include
# include
@@ -21,6 +22,10 @@
struct lua_State;
class QTimer;
+namespace chatterino::lua::api {
+enum class LogLevel;
+} // namespace chatterino::lua::api
+
namespace chatterino {
struct PluginMeta {
@@ -137,12 +142,20 @@ public:
bool hasHTTPPermissionFor(const QUrl &url);
bool hasNetworkPermission() const;
+ void log(lua_State *L, lua::api::LogLevel level, QDebug stream,
+ const sol::variadic_args &args);
+
+ sol::state_view state();
+
std::map callbacks;
// In-flight HTTP Requests
// This is a lifetime hack to ensure they get deleted with the plugin. This relies on the Plugin getting deleted on reload!
std::vector> httpRequests;
+ boost::signals2::signal onUnloaded;
+ boost::signals2::signal onLog;
+
private:
QDir loadDirectory_;
lua_State *state_;
diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp
index ab0784ad..baa40b47 100644
--- a/src/controllers/plugins/PluginController.cpp
+++ b/src/controllers/plugins/PluginController.cpp
@@ -278,6 +278,8 @@ void PluginController::load(const QFileInfo &index, const QDir &pluginDir,
}
temp->dataDirectory().mkpath(".");
+ // make sure we capture log messages during load
+ this->onPluginLoaded(temp);
qCDebug(chatterinoLua) << "Running lua file:" << index;
int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str());
if (err != 0)
diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp
index 06fe5a1a..5095fdb9 100644
--- a/src/controllers/plugins/PluginController.hpp
+++ b/src/controllers/plugins/PluginController.hpp
@@ -6,6 +6,7 @@
# include "controllers/commands/CommandContext.hpp"
# include "controllers/plugins/Plugin.hpp"
+# include
# include
# include
# include
@@ -63,6 +64,8 @@ public:
WebSocketPool &webSocketPool();
+ boost::signals2::signal onPluginLoaded;
+
private:
void loadPlugins();
void load(const QFileInfo &index, const QDir &pluginDir,
diff --git a/src/controllers/plugins/SolTypes.cpp b/src/controllers/plugins/SolTypes.cpp
index 053fe328..71e7dd4a 100644
--- a/src/controllers/plugins/SolTypes.cpp
+++ b/src/controllers/plugins/SolTypes.cpp
@@ -3,9 +3,11 @@
# include "Application.hpp"
# include "common/QLogging.hpp"
+# include "controllers/plugins/LuaAPI.hpp"
# include "controllers/plugins/PluginController.hpp"
# include
+# include
# include
namespace chatterino::lua {
@@ -27,9 +29,10 @@ Plugin *ThisPluginState::plugin()
void logError(Plugin *plugin, QStringView context, const QString &msg)
{
+ QString fullMessage = context % u" - " % msg;
qCWarning(chatterinoLua).noquote()
- << "[" + plugin->id + ":" + plugin->meta.name + "]" << context << "-"
- << msg;
+ << "[" + plugin->id + ":" + plugin->meta.name + "]" << fullMessage;
+ plugin->onLog(api::LogLevel::Warning, fullMessage);
}
} // namespace chatterino::lua
diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp
index 6568d47c..8aaf7a01 100644
--- a/src/singletons/Settings.hpp
+++ b/src/singletons/Settings.hpp
@@ -680,6 +680,15 @@ public:
BoolSetting showSendButton = {"/ui/showSendButton", false};
+ struct {
+ // this isn't shown in the UI
+ BoolSetting enabled = {"/plugins/repl/enabled", false};
+ // An empty string implies the default monospace font
+ QStringSetting fontFamily = {"/plugins/repl/fontFamily", {}};
+ QStringSetting fontStyle = {"/plugins/repl/fontStyle", "Regular"};
+ IntSetting fontSize = {"/plugins/repl/fontSize", 10};
+ } pluginRepl;
+
// Similarity
BoolSetting similarityEnabled = {"/similarity/similarityEnabled", false};
BoolSetting colorSimilarDisabled = {"/similarity/colorSimilarDisabled",
diff --git a/src/widgets/PluginRepl.cpp b/src/widgets/PluginRepl.cpp
new file mode 100644
index 00000000..a31c14f7
--- /dev/null
+++ b/src/widgets/PluginRepl.cpp
@@ -0,0 +1,769 @@
+#include "widgets/PluginRepl.hpp"
+
+#ifdef CHATTERINO_HAVE_PLUGINS
+
+# include "Application.hpp"
+# include "controllers/plugins/LuaAPI.hpp"
+# include "controllers/plugins/LuaUtilities.hpp"
+# include "controllers/plugins/Plugin.hpp"
+# include "controllers/plugins/PluginController.hpp"
+# include "controllers/plugins/SolTypes.hpp"
+# include "singletons/Resources.hpp"
+# include "singletons/Settings.hpp"
+# include "singletons/Theme.hpp"
+# include "widgets/buttons/PixmapButton.hpp"
+# include "widgets/buttons/SvgButton.hpp"
+
+# include
+# include
+# include
+# include
+# include
+# include
+# include
+
+namespace {
+
+using namespace Qt::StringLiterals;
+using namespace chatterino;
+
+class HistoricTextEdit : public QTextEdit
+{
+ Q_OBJECT
+
+public:
+ HistoricTextEdit(QWidget *parent = nullptr);
+
+ QSize sizeHint() const override
+ {
+ return this->minimumSizeHint();
+ }
+
+ QSize minimumSizeHint() const override
+ {
+ auto margins = this->contentsMargins();
+
+ auto h = margins.top() + this->fontMetrics().height() +
+ margins.bottom() + 15;
+ return {0, h};
+ }
+
+Q_SIGNALS:
+ void onSend(const QString & /* text */);
+
+protected:
+ bool event(QEvent *e) override;
+
+private:
+ /// If the item wouldn't change, std::nullopt is returned
+ std::optional nextHistoryItem(qsizetype diff);
+
+ QStringList history;
+ qsizetype historyIdx = 0;
+ QString lastUnfinishedInput;
+};
+
+HistoricTextEdit::HistoricTextEdit(QWidget *parent)
+ : QTextEdit(parent)
+{
+}
+
+bool HistoricTextEdit::event(QEvent *event)
+{
+ if (event->type() != QEvent::KeyPress)
+ {
+ return QTextEdit::event(event);
+ }
+
+ auto *e = dynamic_cast(event);
+ if (!e)
+ {
+ return false;
+ }
+
+ if (e->keyCombination() == QKeyCombination(Qt::Key_Up))
+ {
+ e->accept();
+ auto cursor = this->textCursor();
+ if (cursor.movePosition(QTextCursor::Up))
+ {
+ this->setTextCursor(cursor);
+ }
+ else
+ {
+ auto next = this->nextHistoryItem(-1);
+ if (next)
+ {
+ this->setPlainText(*std::move(next));
+ this->moveCursor(QTextCursor::End);
+ }
+ }
+ return true;
+ }
+ if (e->keyCombination() == QKeyCombination(Qt::Key_Down))
+ {
+ e->accept();
+ auto cursor = this->textCursor();
+ if (cursor.movePosition(QTextCursor::Down))
+ {
+ this->setTextCursor(cursor);
+ }
+ else
+ {
+ auto next = this->nextHistoryItem(1);
+ if (next)
+ {
+ this->setPlainText(*std::move(next));
+ this->moveCursor(QTextCursor::End);
+ }
+ }
+ return true;
+ }
+ if (e->keyCombination() == QKeyCombination(Qt::Key_Return))
+ {
+ auto text = this->toPlainText().trimmed();
+ if (!text.isEmpty())
+ {
+ e->accept();
+ if (this->history.empty() || this->history.back() != text)
+ {
+ this->history.append(text);
+ }
+ this->historyIdx = this->history.size();
+ this->setPlainText({});
+ this->lastUnfinishedInput = {};
+ this->onSend(text);
+ return true;
+ }
+ }
+
+ return QTextEdit::event(event);
+}
+
+std::optional HistoricTextEdit::nextHistoryItem(qsizetype diff)
+{
+ if (this->history.empty())
+ {
+ return {};
+ }
+ bool wasUnfinishedInput = this->historyIdx >= this->history.size();
+
+ auto nextIdx =
+ std::clamp(this->historyIdx + diff, 0LL, this->history.size());
+ if (nextIdx == this->historyIdx)
+ {
+ return {}; // nothing changed
+ }
+ this->historyIdx = nextIdx;
+
+ if (this->historyIdx >= this->history.size())
+ {
+ return this->lastUnfinishedInput;
+ }
+ if (wasUnfinishedInput)
+ {
+ this->lastUnfinishedInput = this->toPlainText();
+ }
+ return this->history[this->historyIdx];
+}
+
+sol::optional tryAsStringAndPop(lua_State *L)
+{
+ auto s = sol::stack::get>(L);
+ auto qs = s.map([](auto sv) {
+ return QString::fromUtf8(sv.data(), sv.size());
+ });
+ lua_pop(L, 1);
+ return qs;
+}
+
+/// This stringifies a value without recursing.
+///
+/// It's similar to luaL_tolstring with a two minor differences:
+/// - `__tostring` is not executed - this can potentially error because it might
+/// be called without a `self` argument (e.g. in `c2.Channel`).
+/// - The address for tables, threads, etc. isn't shown.
+QString stringifyValue(lua_State *L, int idx)
+{
+ switch (lua_type(L, idx))
+ {
+ case LUA_TNONE:
+ return u"(none)"_s;
+ case LUA_TNIL:
+ return u"nil"_s;
+ case LUA_TSTRING: {
+ // Safety: We know this is a string
+ auto sv = sol::stack::unqualified_get(L, idx);
+ return QString::fromUtf8(sv.data(),
+ static_cast(sv.size()));
+ }
+ case LUA_TNUMBER: {
+ if (lua_isinteger(L, idx) != 0)
+ {
+ auto v = static_cast(lua_tointeger(L, idx));
+ return QString::number(v);
+ }
+ auto v = static_cast(lua_tonumber(L, idx));
+ return QString::number(v);
+ }
+ case LUA_TBOOLEAN: {
+ if (lua_toboolean(L, idx) != 0)
+ {
+ return u"true"_s;
+ }
+ return u"false"_s;
+ }
+ case LUA_TTHREAD:
+ case LUA_TFUNCTION:
+ case LUA_TUSERDATA:
+ case LUA_TLIGHTUSERDATA:
+ case LUA_TTABLE:
+ default: {
+ int tt = luaL_getmetafield(L, idx, "__name");
+ if (tt != LUA_TNIL)
+ {
+ auto name = tryAsStringAndPop(L);
+ if (name)
+ {
+ return *std::move(name);
+ }
+ }
+
+ return QString::fromUtf8(luaL_typename(L, idx));
+ }
+ }
+}
+
+QString stringifyValue(sol::stack_proxy it)
+{
+ return stringifyValue(it.lua_state(), it.stack_index());
+}
+
+QString stringifyValue(const sol::object &obj)
+{
+ static_assert(!sol::is_stack_based>());
+ // XXX: in many cases the value is already on the stack
+ obj.push();
+ auto s = stringifyValue(obj.lua_state(), -1);
+ obj.pop();
+ return s;
+}
+
+enum class ExpandFlag : uint8_t {
+ None = 0,
+ TryUsertypeStorage = 1 << 0,
+};
+using ExpandFlags = FlagsEnum;
+
+void stringify(sol::stack_proxy it, QString &s, size_t maxItems = 10,
+ ExpandFlags flags = {})
+{
+ lua::StackGuard g(it.lua_state());
+
+ auto typ = it.get_type();
+ switch (typ)
+ {
+ case sol::type::userdata: {
+ auto meta = lua_getmetatable(it.lua_state(), it.stack_index());
+ if (meta == 0)
+ {
+ s.append(u"userdata");
+ }
+
+ auto table =
+ sol::stack::unqualified_get(it.lua_state());
+
+ s.append(u"userdata{");
+ size_t n = 0;
+ for (const auto &inner : table)
+ {
+ if (n != 0)
+ {
+ s.append(u", ");
+ }
+ s.append(stringifyValue(inner.first));
+
+ n++;
+ if (n >= maxItems)
+ {
+ s.append(u", ...");
+ break;
+ }
+ }
+ s.append('}');
+
+ lua_pop(it.lua_state(), 1); // metatable
+ return;
+ }
+ case sol::type::table: {
+ auto tbl = it.get>();
+ if (!tbl)
+ {
+ s.append(stringifyValue(it));
+ return;
+ }
+ s.append(u"{");
+ size_t n = 0;
+
+ auto meta = lua_getmetatable(it.lua_state(), it.stack_index());
+ if (meta != 0)
+ {
+ s.append(u"(metatable): "_s);
+ stringify({it.lua_state(), -1}, s, maxItems / 2,
+ flags | ExpandFlag::TryUsertypeStorage);
+ n++;
+ lua_pop(it.lua_state(), 1); // metatable
+ }
+
+ if (flags.has(ExpandFlag::TryUsertypeStorage))
+ {
+ auto uts = sol::u_detail::maybe_get_usertype_storage_base(
+ it.lua_state(), it.stack_index());
+ if (uts)
+ {
+ for (const auto &[key, _] : uts->string_keys)
+ {
+ if (n == 0)
+ {
+ s.append('[');
+ }
+ else
+ {
+ s.append(u", ["_s);
+ }
+# if QT_VERSION < QT_VERSION_CHECK(6, 5, 0)
+ s.append(QString::fromUtf8(
+ key.data(), static_cast(key.size())));
+# else
+ s.append(QUtf8StringView(key));
+# endif
+ s.append("] = ");
+ n++;
+ }
+ }
+ }
+
+ for (const auto &inner : *tbl)
+ {
+ if (n == 0)
+ {
+ s.append('[');
+ }
+ else
+ {
+ s.append(u", ["_s);
+ }
+
+ s.append(stringifyValue(inner.first));
+ s.append("] = ");
+ s.append(stringifyValue(inner.second));
+
+ n++;
+ if (n >= maxItems)
+ {
+ s.append(u", ..."_s);
+ break;
+ }
+ }
+ s.append('}');
+ return;
+ }
+ case sol::type::none:
+ case sol::type::lua_nil:
+ case sol::type::string:
+ case sol::type::number:
+ case sol::type::thread:
+ case sol::type::boolean:
+ case sol::type::function:
+ case sol::type::lightuserdata:
+ case sol::type::poly:
+ default:
+ s.append(stringifyValue(it));
+ }
+}
+
+} // namespace
+
+namespace chatterino {
+
+PluginRepl::PluginRepl(QString id, QWidget *parent)
+ : BaseWindow(
+ {
+ BaseWindow::EnableCustomFrame,
+ BaseWindow::DisableCustomScaling,
+ BaseWindow::DisableLayoutSave,
+ },
+ parent)
+ , id(std::move(id))
+ , isPinned(getSettings()->windowTopMost)
+{
+ this->setAttribute(Qt::WA_DeleteOnClose);
+ this->resize(600, 300);
+
+ this->setWindowTitle(this->id + u" - Plugin REPL"_s);
+
+ auto *root = new QVBoxLayout(this->getLayoutContainer());
+
+ // top row
+ {
+ auto *top = new QHBoxLayout;
+ this->ui.clear = new SvgButton(
+ {
+ .dark = u":/buttons/cancel.svg"_s,
+ .light = u":/buttons/cancelDark.svg"_s,
+ },
+ nullptr, {3, 3});
+ this->ui.clear->setScaleIndependentSize({18, 18});
+ this->ui.clear->setToolTip(u"Clear Output"_s);
+ QObject::connect(this->ui.clear, &Button::leftClicked, this, [this] {
+ this->ui.output->clear();
+ });
+
+ this->ui.reload = new SvgButton(
+ {
+ .dark = u":/buttons/reloadLight.svg"_s,
+ .light = u":/buttons/reloadDark.svg"_s,
+ },
+ nullptr, {3, 3});
+ this->ui.reload->setScaleIndependentSize({18, 18});
+ this->ui.reload->setToolTip(u"Reload"_s);
+ QObject::connect(this->ui.reload, &Button::leftClicked, this, [this] {
+ if (!this->plugin)
+ {
+ this->log(lua::api::LogLevel::Critical, "Plugin not loaded.");
+ return;
+ }
+ this->log({}, u"Reloading..."_s);
+ bool result = getApp()->getPlugins()->reload(this->id);
+ if (result)
+ {
+ this->log({}, u"Reloaded."_s);
+ }
+ else
+ {
+ this->log(lua::api::LogLevel::Critical, u"Failed to reload."_s);
+ }
+ });
+
+ this->ui.pin = new PixmapButton;
+ this->ui.pin->setScaleIndependentSize({18, 18});
+ this->ui.pin->setToolTip(u"Pin Window"_s);
+ QObject::connect(this->ui.pin, &Button::leftClicked, this, [this] {
+ this->isPinned = !this->isPinned;
+ this->updatePinned();
+ });
+
+ top->addStretch(1);
+ top->addWidget(this->ui.clear);
+ top->addWidget(this->ui.reload);
+ top->addWidget(this->ui.pin);
+
+ root->addLayout(top);
+ }
+
+ auto *splitter = new QSplitter(Qt::Vertical, this);
+ splitter->setChildrenCollapsible(false);
+ root->addWidget(splitter, 1);
+
+ this->ui.output = new QTextEdit;
+ splitter->addWidget(this->ui.output);
+ this->ui.output->setUndoRedoEnabled(false);
+ this->ui.output->setReadOnly(true);
+ this->ui.output->setAcceptRichText(false);
+ auto *input = new HistoricTextEdit(this);
+ this->ui.input = input;
+ QObject::connect(input, &HistoricTextEdit::onSend, this,
+ &PluginRepl::tryRun);
+ splitter->addWidget(this->ui.input);
+ this->ui.input->setPlaceholderText(u"Type something..."_s);
+ this->ui.input->setAcceptRichText(false);
+ this->ui.input->setFocus();
+
+ this->pluginLoadedConn =
+ getApp()->getPlugins()->onPluginLoaded.connect([this](Plugin *plug) {
+ if (plug->id == this->id)
+ {
+ this->setPlugin(plug);
+ }
+ });
+
+ this->updatePinned();
+ this->themeChangedEvent();
+ this->tryUpdate();
+}
+
+void PluginRepl::themeChangedEvent()
+{
+ this->updateFont();
+ if (getTheme()->isLightTheme())
+ {
+ this->charFormats.self.setForeground(QColor(0x505050));
+ this->charFormats.warning.setForeground(QColor(0x715100));
+ this->blockFormats.warning.setBackground(QColor(0xfffbd6));
+ this->charFormats.error.setForeground(QColor(0xa4000f));
+ this->blockFormats.error.setBackground(QColor(0xfdf2f5));
+ }
+ else
+ {
+ this->charFormats.self.setForeground(QColor(0xa0a0a0));
+ this->charFormats.warning.setForeground(QColor(0xfce2a1));
+ this->blockFormats.warning.setBackground(QColor(0x42381f));
+ this->charFormats.error.setForeground(QColor(0xffb3d2));
+ this->blockFormats.error.setBackground(QColor(0x4b2f36));
+ }
+
+ if (this->isPinned)
+ {
+ this->ui.pin->setPixmap(getResources().buttons.pinEnabled);
+ }
+ else
+ {
+ this->ui.pin->setPixmap(getTheme()->buttons.pin);
+ }
+
+ auto pal = this->palette();
+ pal.setColor(QPalette::Window,
+ getTheme()->tabs.selected.backgrounds.regular);
+ pal.setColor(QPalette::Base, getTheme()->splits.background);
+ pal.setColor(QPalette::Text, getTheme()->window.text);
+
+ this->ui.output->setPalette(pal);
+
+ pal.setColor(QPalette::Base, getTheme()->splits.input.background);
+ this->ui.input->setPalette(pal);
+}
+
+void PluginRepl::tryRun(QString code)
+{
+ if (!this->plugin)
+ {
+ this->log(lua::api::LogLevel::Critical, "Plugin not loaded.");
+ return;
+ }
+
+ this->log({}, u"> "_s + code);
+
+ bool addedReturn = false;
+ size_t maxItems = 10;
+
+ if (code.startsWith('!'))
+ {
+ maxItems = std::numeric_limits::max();
+ auto v = QStringView(code).sliced(1).trimmed();
+ auto nChars = v.constData() - code.constData();
+ code.remove(0, nChars);
+ }
+
+ if (!code.startsWith(u"return "))
+ {
+ code.prepend(u"return ");
+ addedReturn = true;
+ }
+
+ sol::protected_function_result res;
+ try
+ {
+ std::string u8code = code.toStdString();
+ std::string chunkName = "";
+ auto result =
+ this->plugin->state().load(u8code, chunkName, sol::load_mode::text);
+
+ if (!result.valid() && addedReturn)
+ {
+ u8code = u8code.substr(7); // "return "
+ result = this->plugin->state().load(u8code, chunkName,
+ sol::load_mode::text);
+ }
+
+ if (!result.valid())
+ {
+ auto err = result.get();
+ this->log(lua::api::LogLevel::Critical, err.what());
+ return;
+ }
+
+ auto fn = result.get>();
+ if (!fn)
+ {
+ this->log(lua::api::LogLevel::Critical,
+ u"Code didn't result in a callable function."_s);
+ return;
+ }
+
+ sol::protected_function_result res = (*fn)();
+ this->logResult(res, {
+ .maxItems = maxItems,
+ });
+ }
+ catch (const sol::error &err)
+ {
+ this->log(lua::api::LogLevel::Critical, err.what());
+ }
+}
+
+void PluginRepl::logResult(const sol::protected_function_result &res,
+ const LogOptions &opts)
+{
+ if (!res.valid())
+ {
+ sol::error err = res;
+ this->log(lua::api::LogLevel::Critical, err.what());
+ return;
+ }
+ if (res.return_count() == 0)
+ {
+ return;
+ }
+
+ QString msg;
+ for (auto it : res)
+ {
+ if (!msg.isEmpty())
+ {
+ msg.append(' ');
+ }
+ stringify(it, msg, opts.maxItems);
+ }
+ this->log(lua::api::LogLevel::Info, msg);
+}
+
+void PluginRepl::log(std::optional level,
+ const QString &text)
+{
+ auto [charFmt, blockFmt] = [&] {
+ if (!level)
+ {
+ return std::tie(this->charFormats.self, this->blockFormats.self);
+ }
+
+ switch (*level)
+ {
+ case lua::api::LogLevel::Debug:
+ return std::tie(this->charFormats.debug,
+ this->blockFormats.debug);
+ case lua::api::LogLevel::Warning:
+ return std::tie(this->charFormats.warning,
+ this->blockFormats.warning);
+ case lua::api::LogLevel::Critical:
+ return std::tie(this->charFormats.error,
+ this->blockFormats.error);
+ case lua::api::LogLevel::Info:
+ default:
+ return std::tie(this->charFormats.info,
+ this->blockFormats.info);
+ }
+ }();
+
+ auto *bar = this->ui.output->verticalScrollBar();
+ bool atBottom = bar != nullptr && bar->value() == bar->maximum();
+ auto cursor = this->ui.output->textCursor();
+ cursor.movePosition(QTextCursor::End);
+ if (this->ui.output->document()->isEmpty())
+ {
+ cursor.setBlockFormat(blockFmt);
+ cursor.setBlockCharFormat(charFmt);
+ }
+ else
+ {
+ cursor.insertBlock(blockFmt, charFmt);
+ }
+ cursor.insertText(text);
+
+ if (atBottom)
+ {
+ bar->setValue(bar->maximum());
+ }
+}
+
+void PluginRepl::tryUpdate()
+{
+ auto it = getApp()->getPlugins()->plugins().find(this->id);
+ if (it == getApp()->getPlugins()->plugins().end())
+ {
+ return;
+ }
+ if (!PluginController::isPluginEnabled(this->id))
+ {
+ return;
+ }
+ this->setPlugin(it->second.get());
+}
+
+void PluginRepl::setPlugin(Plugin *plugin)
+{
+ this->plugin = plugin;
+
+ if (!plugin)
+ {
+ this->pluginDestroyConn.release();
+ this->pluginLogConn.release();
+ return;
+ }
+
+ this->pluginDestroyConn = this->plugin->onUnloaded.connect([this] {
+ this->setPlugin(nullptr);
+ this->log({}, u"Unloaded."_s);
+ });
+ this->pluginLogConn =
+ this->plugin->onLog.connect([this](auto level, const auto &text) {
+ this->log(level, text);
+ });
+
+ this->log({}, u"Loaded."_s);
+}
+
+QFont PluginRepl::currentFont()
+{
+ auto family = getSettings()->pluginRepl.fontFamily.getValue();
+ auto style = getSettings()->pluginRepl.fontStyle.getValue();
+ auto fontSize = getSettings()->pluginRepl.fontSize.getValue();
+ if (family.isEmpty())
+ {
+ auto font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
+ font.setStyleName(style);
+ font.setPointSize(fontSize);
+ return font;
+ }
+
+ return QFontDatabase::font(family, style, fontSize);
+}
+
+void PluginRepl::updateFont()
+{
+ this->font = PluginRepl::currentFont();
+ this->ui.input->setFont(this->font);
+ this->ui.output->setFont(this->font);
+
+ // Set the tab width to be at least 4 spaces.
+ //
+ // setTabStopDistance:
+ // > Do not set a value less than the horizontalAdvance() of the
+ // > QChar::VisualTabCharacter character, otherwise the tab-character will
+ // > be drawn incompletely.
+ QFontMetricsF metrics(this->font);
+ auto tabCharWidth = metrics.horizontalAdvance(QChar::VisualTabCharacter);
+ auto spaceWidth = metrics.horizontalAdvance(QChar::Space);
+ auto tabDistance = std::max(tabCharWidth, spaceWidth * 4.F);
+
+ this->ui.input->setTabStopDistance(tabDistance);
+ this->ui.output->setTabStopDistance(tabDistance);
+}
+
+void PluginRepl::updatePinned()
+{
+ this->setTopMost(this->isPinned);
+ if (this->isPinned)
+ {
+ this->ui.pin->setPixmap(getResources().buttons.pinEnabled);
+ }
+ else
+ {
+ this->ui.pin->setPixmap(getTheme()->buttons.pin);
+ }
+}
+
+} // namespace chatterino
+
+# include "PluginRepl.moc"
+
+#endif
diff --git a/src/widgets/PluginRepl.hpp b/src/widgets/PluginRepl.hpp
new file mode 100644
index 00000000..4d45956c
--- /dev/null
+++ b/src/widgets/PluginRepl.hpp
@@ -0,0 +1,91 @@
+#pragma once
+
+#ifdef CHATTERINO_HAVE_PLUGINS
+# include "widgets/BaseWindow.hpp"
+
+# include
+# include
+# include
+# include
+# include
+
+class QTextEdit;
+class QTextCharFormat;
+class QTextBlockFormat;
+
+namespace chatterino::lua::api {
+enum class LogLevel;
+} // namespace chatterino::lua::api
+
+namespace chatterino {
+
+class Plugin;
+class SvgButton;
+class PixmapButton;
+
+class PluginRepl : public BaseWindow
+{
+public:
+ PluginRepl(QString id, QWidget *parent = nullptr);
+
+ static QFont currentFont();
+
+protected:
+ void themeChangedEvent() override;
+
+private:
+ struct LogOptions {
+ /// Maximum number of items to show in tables.
+ size_t maxItems = 10;
+ };
+
+ void tryRun(QString code);
+ void logResult(const sol::protected_function_result &res,
+ const LogOptions &opts);
+
+ void log(std::optional level, const QString &text);
+
+ void tryUpdate();
+ void setPlugin(Plugin *plugin);
+
+ void updateFont();
+ void updatePinned();
+
+ QString id;
+ Plugin *plugin = nullptr;
+
+ boost::signals2::scoped_connection pluginDestroyConn;
+ boost::signals2::scoped_connection pluginLogConn;
+ boost::signals2::scoped_connection pluginLoadedConn;
+
+ bool isPinned = false;
+
+ struct {
+ QTextEdit *input = nullptr;
+ QTextEdit *output = nullptr;
+ SvgButton *clear = nullptr;
+ SvgButton *reload = nullptr;
+ PixmapButton *pin = nullptr;
+ } ui;
+
+ struct {
+ QTextCharFormat self;
+ QTextCharFormat debug;
+ QTextCharFormat info;
+ QTextCharFormat warning;
+ QTextCharFormat error;
+ } charFormats;
+ struct {
+ QTextBlockFormat self;
+ QTextBlockFormat debug;
+ QTextBlockFormat info;
+ QTextBlockFormat warning;
+ QTextBlockFormat error;
+ } blockFormats;
+
+ QFont font;
+};
+
+} // namespace chatterino
+
+#endif
diff --git a/src/widgets/settingspages/PluginsPage.cpp b/src/widgets/settingspages/PluginsPage.cpp
index 5d87e3ff..d0a9a01f 100644
--- a/src/widgets/settingspages/PluginsPage.cpp
+++ b/src/widgets/settingspages/PluginsPage.cpp
@@ -3,12 +3,15 @@
# include "Application.hpp"
# include "common/Args.hpp"
+# include "controllers/accounts/AccountController.hpp"
# include "controllers/plugins/PluginController.hpp"
# include "singletons/Paths.hpp"
# include "singletons/Settings.hpp"
# include "util/Helpers.hpp"
# include "util/LayoutCreator.hpp"
# include "util/RemoveScrollAreaBackground.hpp"
+# include "widgets/PluginRepl.hpp"
+# include "widgets/settingspages/SettingWidget.hpp"
# include
# include
@@ -78,6 +81,17 @@ PluginsPage::PluginsPage()
"enable and disable them.");
groupLayout->addRow(disabledLabel);
}
+
+ if (getSettings()->pluginRepl.enabled)
+ {
+ groupLayout->addRow(SettingWidget::fontButton(
+ "REPL font", getSettings()->pluginRepl.fontFamily,
+ &PluginRepl::currentFont, [](const QFont &font) {
+ getSettings()->pluginRepl.fontFamily = font.family();
+ getSettings()->pluginRepl.fontSize = font.pointSize();
+ getSettings()->pluginRepl.fontStyle = font.styleName();
+ }));
+ }
}
this->rebuildContent();
@@ -231,6 +245,16 @@ void PluginsPage::rebuildContent()
{
reloadButton->setEnabled(false);
}
+
+ if (getSettings()->pluginRepl.enabled)
+ {
+ auto *replButton = new QPushButton("Open REPL", this->dataFrame_);
+ QObject::connect(replButton, &QPushButton::clicked, [id]() {
+ auto *repl = new PluginRepl(id);
+ repl->show();
+ });
+ pluginEntry->addRow(replButton);
+ }
}
}
diff --git a/src/widgets/settingspages/SettingWidget.cpp b/src/widgets/settingspages/SettingWidget.cpp
index 92e3ac1f..a53462ff 100644
--- a/src/widgets/settingspages/SettingWidget.cpp
+++ b/src/widgets/settingspages/SettingWidget.cpp
@@ -10,6 +10,7 @@
#include
#include
+#include
#include
#include
#include
@@ -393,6 +394,45 @@ SettingWidget *SettingWidget::lineEdit(const QString &label,
return widget;
}
+SettingWidget *SettingWidget::fontButton(const QString &label,
+ QStringSetting &familySetting,
+ std::function currentFont,
+ std::function onChange)
+{
+ auto *widget = new SettingWidget(label);
+
+ auto *lbl = new QLabel(label + ":");
+
+ auto *button = new QPushButton(currentFont().family());
+
+ widget->hLayout->addWidget(lbl);
+ widget->hLayout->addStretch(1);
+ widget->hLayout->addWidget(button);
+
+ familySetting.connect(
+ [button, currentFont](const auto &) {
+ button->setText(currentFont().family());
+ },
+ widget->managedConnections);
+
+ QObject::connect(button, &QPushButton::clicked,
+ [widget, currentFont{std::move(currentFont)},
+ onChange{std::move(onChange)}]() {
+ bool ok = false;
+ auto font =
+ QFontDialog::getFont(&ok, currentFont(), widget);
+ if (ok)
+ {
+ onChange(font);
+ }
+ });
+
+ widget->actionWidget = button;
+ widget->label = lbl;
+
+ return widget;
+}
+
SettingWidget *SettingWidget::setTooltip(QString tooltip)
{
assert(!tooltip.isEmpty());
diff --git a/src/widgets/settingspages/SettingWidget.hpp b/src/widgets/settingspages/SettingWidget.hpp
index 6bd2b6ba..74a96833 100644
--- a/src/widgets/settingspages/SettingWidget.hpp
+++ b/src/widgets/settingspages/SettingWidget.hpp
@@ -23,7 +23,7 @@ namespace chatterino {
class GeneralPageView;
-class SettingWidget : QWidget
+class SettingWidget : public QWidget
{
Q_OBJECT
@@ -66,6 +66,11 @@ public:
QStringSetting &setting,
const QString &placeholderText = {});
+ static SettingWidget *fontButton(const QString &label,
+ QStringSetting &familySetting,
+ std::function currentFont,
+ std::function onChange);
+
SettingWidget *setTooltip(QString tooltip);
SettingWidget *setDescription(const QString &text);