diff --git a/libnymea-app-core/libnymea-app-core.h b/libnymea-app-core/libnymea-app-core.h index 64e6b004..3f25507a 100644 --- a/libnymea-app-core/libnymea-app-core.h +++ b/libnymea-app-core/libnymea-app-core.h @@ -68,6 +68,7 @@ #include "configuration/networkmanager.h" #include "types/networkdevices.h" #include "types/networkdevice.h" +#include "scriptsyntaxhighlighter.h" #include @@ -230,6 +231,9 @@ void registerQmlTypes() { qmlRegisterUncreatableType(uri, 1, 0, "NetworkDevice", "Get it from NetworkDevices"); qmlRegisterUncreatableType(uri, 1, 0, "WiredNetworkDevice", "Get it from NetworkDevices"); qmlRegisterUncreatableType(uri, 1, 0, "WirelessNetworkDevice", "Get it from NetworkDevices"); + + qmlRegisterType(uri, 1, 0, "ScriptSyntaxHighlighter"); + qmlRegisterUncreatableType(uri, 1, 0, "CompletionProxyModel", "Get it from ScriptSyntaxHighlighter"); } #endif // LIBNYMEAAPPCORE_H diff --git a/libnymea-app-core/libnymea-app-core.pro b/libnymea-app-core/libnymea-app-core.pro index beccd059..899f0edf 100644 --- a/libnymea-app-core/libnymea-app-core.pro +++ b/libnymea-app-core/libnymea-app-core.pro @@ -15,7 +15,7 @@ include(../nymea-remoteproxy/libnymea-remoteproxyclient/libnymea-remoteproxyclie QT -= gui -QT += network websockets bluetooth charts +QT += network websockets bluetooth charts quick LIBS += -lssl -lcrypto @@ -48,6 +48,7 @@ SOURCES += \ devicediscovery.cpp \ models/packagesfiltermodel.cpp \ models/taglistmodel.cpp \ + scriptsyntaxhighlighter.cpp \ vendorsproxy.cpp \ pluginsproxy.cpp \ interfacesmodel.cpp \ @@ -111,6 +112,7 @@ HEADERS += \ devicediscovery.h \ models/packagesfiltermodel.h \ models/taglistmodel.h \ + scriptsyntaxhighlighter.h \ vendorsproxy.h \ pluginsproxy.h \ interfacesmodel.h \ diff --git a/libnymea-app-core/scriptsyntaxhighlighter.cpp b/libnymea-app-core/scriptsyntaxhighlighter.cpp new file mode 100644 index 00000000..742cfaa4 --- /dev/null +++ b/libnymea-app-core/scriptsyntaxhighlighter.cpp @@ -0,0 +1,342 @@ +#include "scriptsyntaxhighlighter.h" + +#include "engine.h" +#include "devicemanager.h" +#include "devices.h" + +#include +#include +#include + +class ScriptSyntaxHighlighterPrivate: public QSyntaxHighlighter +{ + Q_OBJECT +public: + ScriptSyntaxHighlighterPrivate(QObject *parent); + +protected: + void highlightBlock(const QString &text) override; + +signals: + void contentChanged(const QString &text); + +private: + enum BlockState { + BlockStateInvalid = -1, + BlockStateNone = 0, + BlockStateImport = 1, + BlockStateAction, + BlockstateDeviceId, + }; + struct HighlightingRule + { + QRegExp pattern; + QTextCharFormat format; + }; + QVector highlightingRules; + + QTextCharFormat keywordFormat; + QTextCharFormat propertyFormat; + QTextCharFormat lookupFormat; + QTextCharFormat quotationFormat; + QTextCharFormat itemFormat; + QTextCharFormat cppObjectFormat; +}; + +ScriptSyntaxHighlighter::ScriptSyntaxHighlighter(QObject *parent) : QObject(parent) +{ + m_completionModel = new CompletionModel(this); + m_proxyModel = new CompletionProxyModel(m_completionModel, this); + m_highlighter = new ScriptSyntaxHighlighterPrivate(this); + + m_classes.insert("Action", {"id", "deviceId", "actionTypeId", "actionName"}); +} + +Engine *ScriptSyntaxHighlighter::engine() const +{ + return m_engine; +} + +void ScriptSyntaxHighlighter::setEngine(Engine *engine) +{ + if (m_engine != engine) { + m_engine = engine; + emit engineChanged(); + } +} + +QQuickTextDocument *ScriptSyntaxHighlighter::document() const +{ + return m_document; +} + +void ScriptSyntaxHighlighter::setDocument(QQuickTextDocument *document) +{ + if (m_document != document) { + m_document = document; + m_highlighter->setDocument(m_document->textDocument()); + + connect(document->textDocument(), &QTextDocument::cursorPositionChanged, this, &ScriptSyntaxHighlighter::onCursorPositionChanged); + emit documentChanged(); + } +} + +int ScriptSyntaxHighlighter::cursorPosition() const +{ + return m_currentCursor.position(); +} + +void ScriptSyntaxHighlighter::setCursorPosition(int cursorPosition) +{ + if (m_currentCursor.position() != cursorPosition) { + m_currentCursor.setPosition(cursorPosition); +// emit cursorPositionChanged(); + onCursorPositionChanged(m_currentCursor); + } +} + +CompletionProxyModel *ScriptSyntaxHighlighter::completionModel() const +{ + return m_proxyModel; +} + +void ScriptSyntaxHighlighter::complete(int index) +{ + if (index < 0 || index >= m_proxyModel->rowCount()) { + qWarning() << "Invalid index for completion"; + return; + } + CompletionModel::Entry entry = m_proxyModel->get(index); + QString textToInsert = entry.text; + + if (entry.addTrailingQuote) { + textToInsert.append("\""); + } + if (entry.addComment) { + textToInsert.append(" // " + entry.displayText); + } +// textToInsert.append("\n"); + m_currentCursor.select(QTextCursor::WordUnderCursor); + m_currentCursor.removeSelectedText(); + m_currentCursor.insertText(textToInsert); +} + +void ScriptSyntaxHighlighter::newLine() +{ + QString line = m_currentCursor.block().text(); + QString trimmedLine = line; + trimmedLine.remove(QRegExp("^[ ]+")); + int indent = line.length() - trimmedLine.length(); + + m_currentCursor.insertText(QString("\n").leftJustified(indent + 1, ' ')); + if (m_currentCursor.block().previous().text().endsWith("{")) { + m_document->textDocument()->indentWidth(); + m_currentCursor.insertText(" "); + m_currentCursor.insertText(QString("\n").leftJustified(indent + 1, ' ')); + m_currentCursor.insertText("}"); + m_currentCursor.movePosition(QTextCursor::PreviousBlock, QTextCursor::MoveAnchor, 1); + m_currentCursor.movePosition(QTextCursor::EndOfLine, QTextCursor::MoveAnchor, 1); + emit cursorPositionChanged(); + } +} + +void ScriptSyntaxHighlighter::indent(int from, int to) +{ + QTextCursor tmp = QTextCursor(m_document->textDocument()); + tmp.setPosition(from); + if (from == to) { + tmp.insertText(" "); + } else { + while (tmp.position() < to) { + tmp.insertText(" "); + to += 4; + if (!tmp.movePosition(QTextCursor::NextBlock)) { + break; + } + } + } +} + +void ScriptSyntaxHighlighter::unindent(int from, int to) +{ + QTextCursor tmp = QTextCursor(m_document->textDocument()); + tmp.setPosition(from); + tmp.movePosition(QTextCursor::StartOfLine); + if (from == to) { + if (tmp.block().text().startsWith(" ")) { + tmp.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, 4); + tmp.removeSelectedText(); + } + } else { + // Make sure all selected lines start with 4 empty spaces before we start editing + bool ok = true; + while (tmp.position() < to) { + if (!tmp.block().text().startsWith(" ")) { + ok = false; + break; + } + if (!tmp.movePosition(QTextCursor::NextBlock)) { + ok = false; + break; + } + } + if (ok) { + tmp.setPosition(from); + tmp.movePosition(QTextCursor::StartOfLine); + while (tmp.position() < to) { + tmp.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, 4); + tmp.removeSelectedText(); + to -= 4; + if (!tmp.movePosition(QTextCursor::NextBlock)) { + break; + } + } + } + } +} + +void ScriptSyntaxHighlighter::closeBlock() +{ + m_currentCursor.insertText("}"); + if (m_currentCursor.block().text().trimmed() == "}") { + unindent(m_currentCursor.position(), m_currentCursor.position()); + } +} + +void ScriptSyntaxHighlighter::onCursorPositionChanged(const QTextCursor &cursor) +{ + m_currentCursor = cursor; + QTextCursor word = cursor; + word.select(QTextCursor::WordUnderCursor); + + QString blockText = cursor.block().text(); + m_completionModel->clear(); + m_proxyModel->setFilter(QString()); + if (!m_engine) { + return; + } + QRegExp deviceIdExp(".*deviceId: \"[a-zA-Z0-9-]*"); + if (deviceIdExp.exactMatch(blockText)) { + for (int i = 0; i < m_engine->deviceManager()->devices()->rowCount(); i++) { + Device *dev = m_engine->deviceManager()->devices()->get(i); + m_completionModel->append(CompletionModel::Entry(dev->id().toString(), dev->name(), true, true)); + + } + blockText.remove(QRegExp(".*deviceId: \"")); + m_proxyModel->setFilter(blockText); + return; + } + + QRegExp importExp("imp(o|or)?"); + if (importExp.exactMatch(blockText)) { + m_completionModel->append(CompletionModel::Entry("import ", "import")); + m_proxyModel->setFilter(blockText); + return; + } + + QRegExp importExp2("import [a-zA-Z]*"); + if (importExp2.exactMatch(blockText)) { + m_completionModel->append(CompletionModel::Entry("QtQuick 2.0")); + m_completionModel->append(CompletionModel::Entry("nymea 1.0")); + blockText.remove("import "); + m_proxyModel->setFilter(blockText); + return; + } + + QRegExp expressionStartExp(" *[a-zA-Z0-9]*"); + if (expressionStartExp.exactMatch(blockText)) { + QTextCursor blockStartCursor = m_document->textDocument()->find("{", m_currentCursor, QTextDocument::FindBackward); + QTextCursor blockEndCursor = m_document->textDocument()->find("}", m_currentCursor, QTextDocument::FindBackward); + while (!blockEndCursor.isNull() && blockEndCursor.position() > blockStartCursor.position()) { + blockStartCursor = m_document->textDocument()->find("{", blockStartCursor, QTextDocument::FindBackward); + blockEndCursor = m_document->textDocument()->find("}", blockEndCursor, QTextDocument::FindBackward); + } + QString className = blockStartCursor.block().text(); + className.remove(QRegExp(" *\\{")); + while (className.contains(" ")) { + className.remove(QRegExp(".* ")); + } + qDebug() << "ClassName" << className << m_classes.value(className); + foreach (const QString &s, m_classes.value(className)) { + m_completionModel->append(CompletionModel::Entry(s + ": ", s)); + } + blockText.remove(QRegExp(".* ")); + m_proxyModel->setFilter(blockText); + } + +} + +ScriptSyntaxHighlighterPrivate::ScriptSyntaxHighlighterPrivate(QObject *parent): + QSyntaxHighlighter(parent) +{ + HighlightingRule rule; + + keywordFormat.setForeground(Qt::blue); + + QStringList keywordPatterns; + keywordPatterns << "\\bif\\b" << "\\belse\\b" << "\\breturn\\b"<< "\\bimport\\b" << "\\bsignal\\b" << "\\bproperty\\b"; + foreach (const QString &pattern, keywordPatterns) { + rule.pattern = QRegExp(pattern); + rule.format = keywordFormat; + highlightingRules.append(rule); + } + + propertyFormat.setForeground(Qt::darkRed); + rule.pattern = QRegExp("[A-z]+:"); + rule.format = propertyFormat; + highlightingRules.append(rule); + + lookupFormat.setForeground(Qt::magenta); + //lookupFormat.setBackground(Qt::black); + rule.pattern = QRegExp("\\b[0-9]+\\b"); + rule.format = lookupFormat; + highlightingRules.append(rule); + + quotationFormat.setForeground(Qt::darkGreen); + rule.pattern = QRegExp("\".*\""); + rule.format = quotationFormat; + highlightingRules.append(rule); + rule.pattern = QRegExp("'.*'"); + rule.format = quotationFormat; + highlightingRules.append(rule); + + itemFormat.setForeground(QColor(Qt::red)); + //itemFormat.setFontWeight(QFont::Bold); + rule.pattern = QRegExp("[A-Z][a-z]+ "); + rule.format = itemFormat; + highlightingRules.append(rule); + + cppObjectFormat.setForeground(QColor(Qt::blue).lighter()); + cppObjectFormat.setFontItalic(true); + rule.pattern = QRegExp("_[A-z]+"); + rule.format = cppObjectFormat; + highlightingRules.append(rule); +} + +void ScriptSyntaxHighlighterPrivate::highlightBlock(const QString &text) +{ +// qDebug() << "hightlightBlock called for" << text << previousBlockState() << currentBlock().text(); + + foreach(const HighlightingRule &rule, highlightingRules){ + QRegExp expression(rule.pattern); + int index = expression.indexIn(text); + while (index >= 0) { + int length = expression.matchedLength(); + setFormat(index, length, rule.format); + index = expression.indexIn(text, index + length); + } + } + if (text.trimmed().startsWith("import")) { + setCurrentBlockState(BlockStateImport); + } else if (text.trimmed().startsWith("Action")) { + setCurrentBlockState(BlockStateAction); + } else if (text.trimmed().startsWith("deviceId:")) { + setCurrentBlockState(BlockstateDeviceId); + } else { + setCurrentBlockState(0); + } + + emit contentChanged(text); +} + +#include "scriptsyntaxhighlighter.moc" diff --git a/libnymea-app-core/scriptsyntaxhighlighter.h b/libnymea-app-core/scriptsyntaxhighlighter.h new file mode 100644 index 00000000..84030fc3 --- /dev/null +++ b/libnymea-app-core/scriptsyntaxhighlighter.h @@ -0,0 +1,166 @@ +#ifndef SCRIPTSYNTAXHIGHLIGHTER_H +#define SCRIPTSYNTAXHIGHLIGHTER_H + +#include +#include +#include +#include +#include + +class ScriptSyntaxHighlighterPrivate; +class CompletionModel; +class CompletionProxyModel; +class Engine; + +class ScriptSyntaxHighlighter : public QObject +{ + Q_OBJECT + Q_PROPERTY(Engine* engine READ engine WRITE setEngine NOTIFY engineChanged) + Q_PROPERTY(QQuickTextDocument* document READ document WRITE setDocument NOTIFY documentChanged) + Q_PROPERTY(CompletionProxyModel* completionModel READ completionModel CONSTANT) + Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged) +public: + explicit ScriptSyntaxHighlighter(QObject *parent = nullptr); + + Engine* engine() const; + void setEngine(Engine* engine); + + QQuickTextDocument* document() const; + void setDocument(QQuickTextDocument *document); + + int cursorPosition() const; + void setCursorPosition(int cursorPosition); + + CompletionProxyModel* completionModel() const; + +public slots: + void complete(int index); + void newLine(); + void indent(int from, int to); + void unindent(int from, int to); + void closeBlock(); + +signals: + void documentChanged(); + void engineChanged(); + void cursorPositionChanged(); + +private slots: + void onCursorPositionChanged(const QTextCursor &cursor); + +private: + ScriptSyntaxHighlighterPrivate *m_highlighter = nullptr; + QQuickTextDocument* m_document = nullptr; + CompletionModel* m_completionModel = nullptr; + CompletionProxyModel* m_proxyModel = nullptr; + Engine *m_engine = nullptr; + QTextCursor m_currentCursor; + + QHash m_classes; +}; + + +class CompletionModel: public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) +public: + class Entry { + public: + Entry(const QString &text, const QString &displayText, bool addTrailingQuote = false, bool addComment = false) + : text(text), displayText(displayText), addTrailingQuote(addTrailingQuote), addComment(addComment) {} + Entry(const QString &text): text(text), displayText(text) {} + QString text; + QString displayText; + bool addTrailingQuote = false; + bool addComment = false; + }; + CompletionModel(QObject *parent = nullptr): QAbstractListModel(parent) {} + + int rowCount(const QModelIndex &parent = QModelIndex()) const override { + Q_UNUSED(parent) + return m_list.count(); + } + QHash roleNames() const override { + QHash roles; + roles.insert(Qt::UserRole, "text"); + roles.insert(Qt::DisplayRole, "displayText"); + return roles; + } + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override { + Q_UNUSED(role) + switch (role) { + case Qt::UserRole: + return m_list.at(index.row()).text; + case Qt::DisplayRole: + return m_list.at(index.row()).displayText; + } + return QVariant(); + } + void clear() { + beginResetModel(); + m_list.clear(); + endResetModel(); + emit countChanged(); + } + void append(const Entry &entry) { + beginInsertRows(QModelIndex(), m_list.count(), m_list.count()); + m_list.append(entry); + endInsertRows(); + emit countChanged(); + } + Entry get(int index) { + return m_list.at(index); + } +signals: + void countChanged(); +private: + QList m_list; +}; + +class CompletionProxyModel: public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) + Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged) +public: + CompletionProxyModel(CompletionModel *model, QObject *parent = nullptr): QSortFilterProxyModel(parent), m_model(model) { + setSourceModel(model); + connect(model, &CompletionModel::countChanged, this, &CompletionProxyModel::countChanged); + sort(0); + } + + CompletionModel::Entry get(int index) { + return m_model->get(mapToSource(this->index(index, 0)).row()); + } + + QString filter() const { + return m_filter; + } + void setFilter(const QString &filter) { + if (m_filter != filter) { + m_filter = filter; + emit filterChanged(); + invalidateFilter(); + emit countChanged(); + } + } +protected: + bool filterAcceptsRow(int source_row, const QModelIndex &/*source_parent*/) const override { + if (!m_filter.isEmpty()) { + CompletionModel::Entry entry = m_model->get(source_row); + if (!entry.displayText.startsWith(m_filter) && !entry.text.startsWith(m_filter)) { + return false; + } + } + return true; + } +signals: + void filterChanged(); + void countChanged(); +private: + CompletionModel *m_model = nullptr; + QString m_filter; +}; + +#endif // SCRIPTSYNTAXHIGHLIGHTER_H diff --git a/nymea-app/resources.qrc b/nymea-app/resources.qrc index 9a03a723..584ce20b 100644 --- a/nymea-app/resources.qrc +++ b/nymea-app/resources.qrc @@ -203,5 +203,6 @@ ui/delegates/ThingTile.qml ui/components/TimePicker.qml ui/components/DatePicker.qml + ui/magic/ScriptEditor.qml diff --git a/nymea-app/ui/MagicPage.qml b/nymea-app/ui/MagicPage.qml index 59e84387..f4018e7a 100644 --- a/nymea-app/ui/MagicPage.qml +++ b/nymea-app/ui/MagicPage.qml @@ -10,6 +10,13 @@ Page { text: qsTr("Magic") onBackPressed: pageStack.pop() + HeaderButton { + imageSource: Qt.resolvedUrl("images/magic.svg") + onClicked: { + pageStack.push("magic/ScriptEditor.qml") + } + } + HeaderButton { imageSource: Qt.resolvedUrl("images/add.svg") onClicked: { diff --git a/nymea-app/ui/magic/ScriptEditor.qml b/nymea-app/ui/magic/ScriptEditor.qml new file mode 100644 index 00000000..08e8a4cf --- /dev/null +++ b/nymea-app/ui/magic/ScriptEditor.qml @@ -0,0 +1,137 @@ +import QtQuick 2.0 +import QtQuick.Controls 2.2 +import "../components" +import Nymea 1.0 + +Page { + id: root + + header: NymeaHeader { + text: qsTr("Script editor") + onBackPressed: pageStack.pop() + } + + Rectangle { + color: "white" + anchors.fill: parent + + TextEdit { + id: scriptEdit + anchors.fill: parent + font.family: "Monospace" + Keys.onPressed: { + print("key", event.key) + // Things only to happen when we're not autocompleting + if (!completionBox.visible) { + switch (event.key) { + case Qt.Key_Return: + case Qt.Key_Enter: + syntax.newLine(); + event.accepted = true; + return; + case Qt.Key_Tab: + syntax.indent(selectionStart, selectionEnd); + event.accepted = true; + return; + case Qt.Key_Backtab: + syntax.unindent(selectionStart, selectionEnd); + event.accepted = true; + return; + } + } + + // things to happen in any case + switch (event.key) { + case Qt.Key_BraceRight: + syntax.closeBlock(); + event.accepted = true; + return; + } + + // Things to do only when we're autocompleting + if (completionBox.visible) { + switch (event.key) { + case Qt.Key_Escape: + completionBox.hide(); + event.accepted = true; + break; + case Qt.Key_Down: + completionBox.next(); + event.accepted = true; + break; + case Qt.Key_Up: + completionBox.previous(); + event.accepted = true; + break; + case Qt.Key_Enter: + case Qt.Key_Return: + syntax.complete(completionBox.currentIndex) + event.accepted = true; + break; + } + } + } + + Rectangle { + id: completionBox + border.width: 1 + border.color: "black" + height: syntax.completionModel.count * 30 + width: 200 + x: scriptEdit.cursorRectangle.x + y: scriptEdit.cursorRectangle.y + scriptEdit.cursorRectangle.height + visible: syntax.completionModel.count > 0 && !hidden + property bool hidden: false + Connections { + target: syntax.completionModel + onCountChanged: { + completionBox.hidden = false; + completionBox.currentIndex = 0; + } + } + + property int currentIndex: 0 + function next() { currentIndex = (currentIndex + 1) % syntax.completionModel.count} + function previous() { + currentIndex--; + if (currentIndex < 0) { + currentIndex = syntax.completionModel.count - 1 + } + } + function hide() { + hidden = true; + } + + ListView { + anchors.fill: parent + model: syntax.completionModel + delegate: Rectangle { + height: 30 + width: parent.width + color: index == completionBox.currentIndex ? "blue" : "white" + Label { + text: model.displayText + color: "black" + width: parent.width + elide: Text.ElideRight + } + MouseArea { + anchors.fill: parent + onClicked: { + syntax.complete(index) + } + } + } + } + } + } + } + + ScriptSyntaxHighlighter { + id: syntax + engine: _engine + document: scriptEdit.textDocument + cursorPosition: scriptEdit.cursorPosition + onCursorPositionChanged: scriptEdit.cursorPosition = cursorPosition + } +}