feat: add REPL for plugins (#6120)

This can be enabled by setting the `.plugins.repl.enabled` setting to true


Close Chatterino and make a backup of your settings.json before attempting any modifications.
```json5
{
  "plugins": {
    "repl": {
      "enabled": true,
    },
  },
}
```
This commit is contained in:
nerix
2025-06-28 16:04:08 +02:00
committed by GitHub
parent 9e59fb1a5f
commit b6623cff88
16 changed files with 1000 additions and 18 deletions
+1
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg"><path d="M12 4.5a7.5 7.5 0 11-7.419 6.392c.067-.454-.265-.892-.724-.892a.749.749 0 00-.752.623A9 9 0 106 5.292V4.25a.75.75 0 00-1.5 0v3c0 .414.336.75.75.75h3a.75.75 0 000-1.5H6.9a7.473 7.473 0 015.1-2z"/></svg>

After

Width:  |  Height:  |  Size: 274 B

+1
View File
@@ -0,0 +1 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" fill="#fff"><path d="M12 4.5a7.5 7.5 0 11-7.419 6.392c.067-.454-.265-.892-.724-.892a.749.749 0 00-.752.623A9 9 0 106 5.292V4.25a.75.75 0 00-1.5 0v3c0 .414.336.75.75.75h3a.75.75 0 000-1.5H6.9a7.473 7.473 0 015.1-2z"/></svg>

After

Width:  |  Height:  |  Size: 286 B

+2
View File
@@ -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
+2 -15
View File
@@ -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)
+31
View File
@@ -218,6 +218,8 @@ std::unordered_set<QString> 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) {
+13
View File
@@ -6,6 +6,7 @@
# include "controllers/plugins/LuaUtilities.hpp"
# include "controllers/plugins/PluginPermission.hpp"
# include <boost/signals2/signal.hpp>
# include <QDir>
# include <QString>
# include <QUrl>
@@ -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<lua::api::EventType, sol::protected_function> 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<std::shared_ptr<lua::api::HTTPRequest>> httpRequests;
boost::signals2::signal<void()> onUnloaded;
boost::signals2::signal<void(lua::api::LogLevel, const QString &)> onLog;
private:
QDir loadDirectory_;
lua_State *state_;
@@ -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)
@@ -6,6 +6,7 @@
# include "controllers/commands/CommandContext.hpp"
# include "controllers/plugins/Plugin.hpp"
# include <boost/signals2/signal.hpp>
# include <QDir>
# include <QFileInfo>
# include <QJsonArray>
@@ -63,6 +64,8 @@ public:
WebSocketPool &webSocketPool();
boost::signals2::signal<void(Plugin *)> onPluginLoaded;
private:
void loadPlugins();
void load(const QFileInfo &index, const QDir &pluginDir,
+5 -2
View File
@@ -3,9 +3,11 @@
# include "Application.hpp"
# include "common/QLogging.hpp"
# include "controllers/plugins/LuaAPI.hpp"
# include "controllers/plugins/PluginController.hpp"
# include <QObject>
# include <QStringBuilder>
# include <sol/thread.hpp>
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
+9
View File
@@ -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",
+769
View File
@@ -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 <QBoxLayout>
# include <QFontDatabase>
# include <QScrollBar>
# include <QSplitter>
# include <QTextBlock>
# include <QTextEdit>
# include <sol/sol.hpp>
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<QString> 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<QKeyEvent *>(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<QString> 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<QString> tryAsStringAndPop(lua_State *L)
{
auto s = sol::stack::get<sol::optional<std::string_view>>(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<std::string_view>(L, idx);
return QString::fromUtf8(sv.data(),
static_cast<qsizetype>(sv.size()));
}
case LUA_TNUMBER: {
if (lua_isinteger(L, idx) != 0)
{
auto v = static_cast<LUAI_UACINT>(lua_tointeger(L, idx));
return QString::number(v);
}
auto v = static_cast<LUAI_UACNUMBER>(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<std::remove_cvref_t<decltype(obj)>>());
// 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<ExpandFlag>;
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<sol::table>(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<sol::optional<sol::table>>();
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<qsizetype>(key.size())));
# else
s.append(QUtf8StringView(key));
# endif
s.append("] = <dyn>");
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<size_t>::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 = "<inline code>";
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<sol::error>();
this->log(lua::api::LogLevel::Critical, err.what());
return;
}
auto fn = result.get<sol::optional<sol::protected_function>>();
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<lua::api::LogLevel> 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
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#ifdef CHATTERINO_HAVE_PLUGINS
# include "widgets/BaseWindow.hpp"
# include <boost/signals2/connection.hpp>
# include <QString>
# include <QTextBlockFormat>
# include <QTextCharFormat>
# include <sol/forward.hpp>
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<lua::api::LogLevel> 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
+24
View File
@@ -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 <QCheckBox>
# include <QFormLayout>
@@ -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);
}
}
}
@@ -10,6 +10,7 @@
#include <QBoxLayout>
#include <QCheckBox>
#include <QFontDialog>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
@@ -393,6 +394,45 @@ SettingWidget *SettingWidget::lineEdit(const QString &label,
return widget;
}
SettingWidget *SettingWidget::fontButton(const QString &label,
QStringSetting &familySetting,
std::function<QFont()> currentFont,
std::function<void(QFont)> 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());
+6 -1
View File
@@ -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<QFont()> currentFont,
std::function<void(QFont)> onChange);
SettingWidget *setTooltip(QString tooltip);
SettingWidget *setDescription(const QString &text);