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);