From 59047704ae0ea84d47cd4b3fea0f12663b1450aa Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 25 Nov 2019 12:26:55 +0100 Subject: [PATCH] More work on the editor --- .../scripting/codecompletion.cpp | 353 ++++++++++++++---- libnymea-app-core/scripting/codecompletion.h | 43 ++- .../scripting/completionmodel.cpp | 24 +- libnymea-app-core/scripting/completionmodel.h | 11 +- libnymea-app-core/scriptmanager.cpp | 57 ++- libnymea-app-core/scriptmanager.h | 13 +- libnymea-common/types/scripts.cpp | 18 + libnymea-common/types/scripts.h | 1 + nymea-app/images.qrc | 1 + nymea-app/ui/components/NymeaHeader.qml | 1 + nymea-app/ui/images/save.svg | 170 +++++++++ nymea-app/ui/magic/ScriptEditor.qml | 116 ++++-- nymea-app/ui/magic/ScriptsPage.qml | 4 +- .../ui/magic/scripting/CompletionBox.qml | 88 ++++- 14 files changed, 749 insertions(+), 151 deletions(-) create mode 100644 nymea-app/ui/images/save.svg diff --git a/libnymea-app-core/scripting/codecompletion.cpp b/libnymea-app-core/scripting/codecompletion.cpp index 71eab950..5176f568 100644 --- a/libnymea-app-core/scripting/codecompletion.cpp +++ b/libnymea-app-core/scripting/codecompletion.cpp @@ -1,6 +1,5 @@ #include "codecompletion.h" -#include "completionmodel.h" #include "engine.h" #include @@ -11,13 +10,26 @@ CodeCompletion::CodeCompletion(QObject *parent): QObject(parent) { - registerType("Item"); - m_classes.insert("DeviceAction", {"id", "deviceId", "actionTypeId", "actionName"}); - m_classes.insert("DeviceState", {"id", "deviceId", "stateTypeId", "stateName", "value", "onValueChanged"}); - m_classes.insert("DeviceEvent", {"id", "deviceId", "eventTypeId", "eventName", "onTriggered"}); - m_classes.insert("Timer", {"id", "interval", "running", "repeat", "onTriggered"}); + m_classes.insert("Item", ClassInfo("Item", {"id"})); + m_classes.insert("DeviceAction", ClassInfo("DeviceAction", {"id", "deviceId", "actionTypeId", "actionName"}, {"execute"})); + m_classes.insert("DeviceState", ClassInfo("DeviceState", {"id", "deviceId", "stateTypeId", "stateName", "value"}, {}, {"onValueChanged"})); + m_classes.insert("DeviceEvent", ClassInfo("DeviceEvent", {"id", "deviceId", "eventTypeId", "eventName"}, {}, {"onTriggered"})); + m_classes.insert("Timer", ClassInfo("Timer", {"id", "interval", "running", "repeat"}, {"start", "stop"}, {"onTriggered"})); + + m_attachedClasses.insert("Component", ClassInfo("Component", {}, {}, {"onCompleted", "onDestruction", "onDestroyed"})); m_genericSyntax.insert("property", "property "); + m_genericSyntax.insert("function", "function "); + + m_genericJsSyntax.insert("for", "for"); + m_genericJsSyntax.insert("var", "var"); + m_genericJsSyntax.insert("while", "while "); + m_genericJsSyntax.insert("do", "do "); + m_genericJsSyntax.insert("if", "if "); + m_genericJsSyntax.insert("else", "else "); + + m_jsClasses.insert("console", ClassInfo("console", {}, {"log", "warn"})); + m_jsClasses.insert("JSON", ClassInfo("JSON", {}, {"stringify", "parse", "hasOwnProperty", "isPrototypeOf", "toString", "valueOf", "toLocaleString", "propertyIsEnumerable"})); m_model = new CompletionModel(this); m_proxy = new CompletionProxyModel(m_model, this); @@ -51,7 +63,6 @@ void CodeCompletion::setDocument(QQuickTextDocument *document) emit cursorPositionChanged(); connect(m_document->textDocument(), &QTextDocument::cursorPositionChanged, this, [this](const QTextCursor &cursor){ - qDebug() << "text cursor changed" << cursor.position(); m_cursor = cursor; update(); }); @@ -65,7 +76,6 @@ int CodeCompletion::cursorPosition() const void CodeCompletion::setCursorPosition(int position) { - qDebug() << "setCursorPos" << position << m_cursor.position(); // This is a bit tricky: As our cursor works on the same textDocument as the view, // our cursor will already have the position set to the new one by the time we // receive the update from the View when the document is changed. @@ -104,7 +114,9 @@ void CodeCompletion::update() } lastUpdatePos = m_cursor.position(); - QString blockText = m_cursor.block().text(); + QTextCursor tmp = m_cursor; + tmp.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor); + QString blockText = tmp.selectedText(); QList entries; @@ -112,8 +124,7 @@ void CodeCompletion::update() if (deviceIdExp.exactMatch(blockText)) { for (int i = 0; i < m_engine->deviceManager()->devices()->rowCount(); i++) { Device *dev = m_engine->deviceManager()->devices()->get(i); - entries.append(CompletionModel::Entry(dev->id().toString(), dev->name(), true, true)); - + entries.append(CompletionModel::Entry(dev->id().toString() + "\" // " + dev->name(), dev->name(), "thing", dev->deviceClass()->interfaces().join(","))); } blockText.remove(QRegExp(".*deviceId: \"")); m_model->update(entries); @@ -137,7 +148,7 @@ void CodeCompletion::update() for (int i = 0; i < device->deviceClass()->stateTypes()->rowCount(); i++) { StateType *stateType = device->deviceClass()->stateTypes()->get(i); - entries.append(CompletionModel::Entry(stateType->id(), stateType->name(), true, true)); + entries.append(CompletionModel::Entry(stateType->id() + "\" // " + stateType->name(), stateType->name(), "stateType")); } blockText.remove(QRegExp(".*stateTypeId: \"")); m_model->update(entries); @@ -161,7 +172,7 @@ void CodeCompletion::update() for (int i = 0; i < device->deviceClass()->stateTypes()->rowCount(); i++) { StateType *stateType = device->deviceClass()->stateTypes()->get(i); - entries.append(CompletionModel::Entry(stateType->name(), stateType->name(), true, false)); + entries.append(CompletionModel::Entry(stateType->name() + "\"", stateType->name(), "stateType")); } blockText.remove(QRegExp(".*stateName: \"")); m_model->update(entries); @@ -185,7 +196,7 @@ void CodeCompletion::update() for (int i = 0; i < device->deviceClass()->actionTypes()->rowCount(); i++) { ActionType *actionType = device->deviceClass()->actionTypes()->get(i); - entries.append(CompletionModel::Entry(actionType->id(), actionType->name(), true, true)); + entries.append(CompletionModel::Entry(actionType->id() + "\" // " + actionType->name(), actionType->name(), "actionType")); } blockText.remove(QRegExp(".*actionTypeId: \"")); m_model->update(entries); @@ -209,7 +220,7 @@ void CodeCompletion::update() for (int i = 0; i < device->deviceClass()->actionTypes()->rowCount(); i++) { ActionType *actionType = device->deviceClass()->actionTypes()->get(i); - entries.append(CompletionModel::Entry(actionType->name(), actionType->name(), true, false)); + entries.append(CompletionModel::Entry(actionType->name() + "\"", actionType->name(), "actionType")); } blockText.remove(QRegExp(".*actionName: \"")); m_model->update(entries); @@ -232,7 +243,7 @@ void CodeCompletion::update() for (int i = 0; i < device->deviceClass()->eventTypes()->rowCount(); i++) { EventType *eventType = device->deviceClass()->eventTypes()->get(i); - entries.append(CompletionModel::Entry(eventType->id(), eventType->name(), true, true)); + entries.append(CompletionModel::Entry(eventType->id() + "\" // " + eventType->name(), eventType->name(), "eventType")); } blockText.remove(QRegExp(".*eventTypeId: \"")); m_model->update(entries); @@ -255,7 +266,7 @@ void CodeCompletion::update() for (int i = 0; i < device->deviceClass()->eventTypes()->rowCount(); i++) { EventType *eventType = device->deviceClass()->eventTypes()->get(i); - entries.append(CompletionModel::Entry(eventType->name(), eventType->name(), true, false)); + entries.append(CompletionModel::Entry(eventType->name() + "\"", eventType->name(), "eventType")); } blockText.remove(QRegExp(".*eventName: \"")); m_model->update(entries); @@ -265,7 +276,7 @@ void CodeCompletion::update() QRegExp importExp("imp(o|or)?"); if (importExp.exactMatch(blockText)) { - entries.append(CompletionModel::Entry("import ", "import")); + entries.append(CompletionModel::Entry("import ", "import", "keyword", "")); m_model->update(entries); m_proxy->setFilter(blockText); return; @@ -281,7 +292,7 @@ void CodeCompletion::update() return; } - QRegExp rValueExp(" *[a-zA-Z0-0]+:[ a-zA-Z0-0]*"); + QRegExp rValueExp(" *[\\.a-zA-Z0-0]+:[ a-zA-Z0-0]*"); if (rValueExp.exactMatch(blockText)) { QTextCursor tmp = m_cursor; tmp.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor); @@ -297,55 +308,121 @@ void CodeCompletion::update() } qDebug() << "rValue" << previousWord << word; - // Find all ids in the doc - tmp = QTextCursor(m_document->textDocument()); - while (!tmp.atEnd()) { - tmp.movePosition(QTextCursor::StartOfWord, QTextCursor::MoveAnchor); - tmp.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); - QString word = tmp.selectedText(); - if (word == "id") { - tmp.movePosition(QTextCursor::NextWord, QTextCursor::MoveAnchor); - tmp.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); - QString idName = tmp.selectedText(); - entries.append(CompletionModel::Entry(idName, idName)); - } - tmp.movePosition(QTextCursor::NextWord); + entries.append(getIds()); + foreach (const QString &s, m_jsClasses.keys()) { + entries.append(CompletionModel::Entry(s, s, "type")); } + foreach (const QString &s, m_attachedClasses.keys()) { + entries.append(CompletionModel::Entry(s, s, "type")); + } + m_model->update(entries); m_proxy->setFilter(word); return; } + QRegExp dotExp(".*[a-zA-Z0-9]+\\.[a-zA-Z0-9]*"); + if (dotExp.exactMatch(blockText)) { + QString id = blockText; + id.remove(QRegExp(".* ")).remove(QRegExp("\\.[a-zA-Z0-9]*")); + QString type = getIdTypes().value(id); + qDebug() << "dot expression:" << id << type; + // Classes + foreach (const QString &property, m_classes.value(type).properties) { + entries.append(CompletionModel::Entry(property, property, "property")); + } + foreach (const QString &method, m_classes.value(type).methods) { + entries.append(CompletionModel::Entry(method + "(", method, "method", "", ")")); + } + // Attached classes/properties + foreach (const QString &property, m_attachedClasses.value(id).properties) { + entries.append(CompletionModel::Entry(property, property, "property")); + } + foreach (const QString &method, m_attachedClasses.value(id).methods) { + entries.append(CompletionModel::Entry(method + "(", method, "method", "", ")")); + } + foreach (const QString &event, m_attachedClasses.value(id).events) { + entries.append(CompletionModel::Entry(event + ": ", event, "event")); + } + // JS global objects + foreach (const QString &property, m_jsClasses.value(id).properties) { + entries.append(CompletionModel::Entry(property, property, "property")); + } + foreach (const QString &method, m_jsClasses.value(id).methods) { + entries.append(CompletionModel::Entry(method + "(", method, "method", "", ")")); + } + m_model->update(entries); + m_proxy->setFilter(blockText.remove(QRegExp(".*\\."))); + return; + } + + // Are we in a JS block? + int pos = m_cursor.position(); + BlockInfo jsBlock = getBlockInfo(pos); + bool isImperative = jsBlock.name.endsWith(":") || jsBlock.name.endsWith("()"); + bool atStart = false; + while (!isImperative && jsBlock.valid && !atStart) { + qDebug() << "is imperative block?" << isImperative << jsBlock.name << "blockText" << blockText; + BlockInfo tmp = getBlockInfo(jsBlock.start - 1); + if (tmp.valid) { + jsBlock = tmp; + isImperative = jsBlock.name.endsWith(":") || jsBlock.name.endsWith("()"); + } else { + atStart = true; + } + } + if (isImperative) { + // Starting a new expression? + QRegExp newExpressionExp("(.*; [a-zA-Z0-9]*| *[a-zA-Z0-9]*)"); + if (newExpressionExp.exactMatch(blockText)) { + // Add generic qml syntax + foreach (const QString &s, m_genericJsSyntax.keys()) { + entries.append(CompletionModel::Entry(m_genericJsSyntax.value(s), s, "keyword", "")); + } + // Add js global objects + foreach (const QString &s, m_jsClasses.keys()) { + entries.append(CompletionModel::Entry(s, s, "type")); + } + + entries.append(getIds()); + } + + m_model->update(entries); + m_proxy->setFilter(blockText.remove(QRegExp(".* "))); + return; + } + QRegExp lValueStartExp(" *[a-zA-Z0-9]*"); if (lValueStartExp.exactMatch(blockText)) { - qDebug() << "matching"; - QTextCursor blockStartCursor = m_document->textDocument()->find("{", m_cursor, QTextDocument::FindBackward); - QTextCursor blockEndCursor = m_document->textDocument()->find("}", m_cursor, 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(".* ")); - } + BlockInfo blockInfo = getBlockInfo(m_cursor.position()); // If we're inside a class, add properties - if (!className.isEmpty()) { - foreach (const QString &s, m_classes.value(className)) { - entries.append(CompletionModel::Entry(s + ": ", s)); + qDebug() << "Block name" << blockInfo.name; + + if (!blockInfo.name.isEmpty()) { + foreach (const QString &s, m_classes.value(blockInfo.name).properties) { + if (!blockInfo.properties.contains(s)) { + entries.append(CompletionModel::Entry(s + ": ", s, "property")); + } + } + foreach (const QString &s, m_classes.value(blockInfo.name).events) { + if (!blockInfo.properties.contains(s)) { + entries.append(CompletionModel::Entry(s + ": ", s, "event")); + } } } - // Always append class names foreach (const QString &s, m_classes.keys()) { - entries.append(CompletionModel::Entry(s + " {", s)); + entries.append(CompletionModel::Entry(s + " {", s, "type", "", "}")); + } + // Always append attached class names + foreach (const QString &s, m_attachedClasses.keys()) { + entries.append(CompletionModel::Entry(s, s, "type")); } - // Add generic syntax + // Add generic qml syntax foreach (const QString &s, m_genericSyntax.keys()) { - entries.append(CompletionModel::Entry(m_genericSyntax.value(s), s)); + entries.append(CompletionModel::Entry(m_genericSyntax.value(s), s, "keyword", "")); } m_model->update(entries); @@ -355,11 +432,12 @@ void CodeCompletion::update() return; } + m_model->update({}); m_proxy->setFilter(QString()); } -CodeCompletion::BlockInfo CodeCompletion::getBlockInfo(int position) +CodeCompletion::BlockInfo CodeCompletion::getBlockInfo(int position) const { BlockInfo info; @@ -374,6 +452,10 @@ CodeCompletion::BlockInfo CodeCompletion::getBlockInfo(int position) return info; } + info.start = blockStart.position(); + info.end = m_document->textDocument()->find("}", position).position(); + info.valid = true; + qDebug() << "Block strats at" << blockStart.position(); info.name = blockStart.block().text(); @@ -382,13 +464,25 @@ CodeCompletion::BlockInfo CodeCompletion::getBlockInfo(int position) info.name.remove(QRegExp(".* ")); } - qDebug() << "Block name:" << info.name; - - while (blockStart.position() < position) { - qDebug() << "current pos:" << blockStart.position() << blockStart.block().text(); - QTextCursor tmp = m_document->textDocument()->find("\n", blockStart); + int childBlocks = 0; + while (!blockStart.isNull() && blockStart.position() < position) { + QTextCursor tmp = m_document->textDocument()->find(QRegExp("[{}\n]"), blockStart); + if (tmp.selectedText() == "{") { + blockStart = tmp; + childBlocks++; + continue; + } + if (tmp.selectedText() == "}") { + blockStart = tmp; + childBlocks--; + continue; + } + // \n + if (childBlocks > 0) { // Skip all stuff in child blocks + blockStart = tmp; + continue; + } foreach (const QString &statement, blockStart.block().text().split(";")) { - qDebug() << "statement:" << statement; QStringList parts = statement.split(":"); if (parts.length() != 2) { continue; @@ -398,12 +492,88 @@ CodeCompletion::BlockInfo CodeCompletion::getBlockInfo(int position) qDebug() << "inserting:" << propName << "->" << propValue; info.properties.insert(propName, propValue); } - blockStart.movePosition(QTextCursor::NextBlock); + blockStart = tmp; } return info; } +QList CodeCompletion::getIds() const +{ + // Find all ids in the doc + QList entries; + QTextCursor tmp = QTextCursor(m_document->textDocument()); + while (!tmp.atEnd()) { + tmp.movePosition(QTextCursor::StartOfWord, QTextCursor::MoveAnchor); + tmp.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + QString word = tmp.selectedText(); + if (word == "id") { + tmp.movePosition(QTextCursor::NextWord, QTextCursor::MoveAnchor); + tmp.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + QString idName = tmp.selectedText(); + entries.append(CompletionModel::Entry(idName, idName, "id", "")); + } + tmp.movePosition(QTextCursor::NextWord); + } + return entries; +} + +QHash CodeCompletion::getIdTypes() const +{ + QHash ret; + QTextCursor tmp = QTextCursor(m_document->textDocument()); + while (!tmp.atEnd()) { + tmp.movePosition(QTextCursor::StartOfWord, QTextCursor::MoveAnchor); + tmp.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + QString word = tmp.selectedText(); + if (word == "id") { + tmp.movePosition(QTextCursor::NextWord, QTextCursor::MoveAnchor); + tmp.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + QString idName = tmp.selectedText(); + BlockInfo info = getBlockInfo(tmp.position()); + if (!info.name.isEmpty()) { + ret.insert(idName, info.name); + } + } + tmp.movePosition(QTextCursor::NextWord); + } + return ret; +} + +int CodeCompletion::openingBlocksBefore(int position) const +{ + int opening = 0; + int closing = 0; + QTextCursor tmp = m_cursor; + tmp.setPosition(position); + do { + tmp = m_document->textDocument()->find(QRegExp("[{}]"), tmp, QTextDocument::FindBackward); + if (tmp.selectedText() == "{") + opening++; + if (tmp.selectedText() == "}") + closing++; + } while (!tmp.isNull()); + + return opening - closing; +} + +int CodeCompletion::closingBlocksAfter(int position) const +{ + int opening = 0; + int closing = 0; + QTextCursor tmp = m_cursor; + tmp.setPosition(position); + do { + tmp = m_document->textDocument()->find(QRegExp("[{}]"), tmp); + if (tmp.selectedText() == "{") + opening++; + if (tmp.selectedText() == "}") + closing++; + } while (!tmp.isNull()); + + return closing - opening; +} + void CodeCompletion::complete(int index) { if (index < 0 || index >= m_proxy->rowCount()) { @@ -411,27 +581,26 @@ void CodeCompletion::complete(int index) return; } CompletionModel::Entry entry = m_proxy->get(index); - QString textToInsert = entry.text; - if (entry.addTrailingQuote) { - textToInsert.append("\""); - } - if (entry.addComment) { - textToInsert.append(" // " + entry.displayText); - } -// textToInsert.append("\n"); m_cursor.select(QTextCursor::WordUnderCursor); m_cursor.removeSelectedText(); - m_cursor.insertText(textToInsert); - if (textToInsert.endsWith("{")) { - insertAfterCursor("}"); - } + qDebug() << "inserting:" << entry.text; + m_cursor.insertText(entry.text); + + qDebug() << "inserting after cursor:" << entry.trailingText; + insertAfterCursor(entry.trailingText); } void CodeCompletion::newLine() { qDebug() << "Newline" << m_cursor.position(); QString line = m_cursor.block().text(); + + if (line.endsWith("{") && openingBlocksBefore(m_cursor.position()) > closingBlocksAfter(m_cursor.position())) { + m_cursor.insertText("}"); + m_cursor.movePosition(QTextCursor::PreviousCharacter); + } + QString trimmedLine = line; trimmedLine.remove(QRegExp("^[ ]+")); int indent = line.length() - trimmedLine.length(); @@ -511,23 +680,43 @@ void CodeCompletion::closeBlock() } } +void CodeCompletion::insertBeforeCursor(const QString &text) +{ + m_cursor.insertText(text); +} + void CodeCompletion::insertAfterCursor(const QString &text) { m_cursor.insertText(text); - m_cursor.movePosition(QTextCursor::PreviousCharacter); + m_cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::MoveAnchor, text.length()); emit cursorPositionChanged(); } -template -void CodeCompletion::registerType(const QString &qmlName) +void CodeCompletion::moveCursor(CodeCompletion::MoveOperation moveOperation, int count) { - QMetaObject metaObject = T::staticMetaObject; - QStringList properties; - for (int i = 0; i < metaObject.propertyCount(); i++) { - qDebug() << "Adding prop" << metaObject.property(i).name() << metaObject.property(i).type(); - if (metaObject.property(i).isWritable()) { - properties.append(metaObject.property(i).name()); - } + switch (moveOperation) { + case MoveOperationPreviousLine: + m_cursor.movePosition(QTextCursor::PreviousBlock, QTextCursor::MoveAnchor, count); + emit cursorPositionChanged(); + return; + case MoveOperationNextLine: + m_cursor.movePosition(QTextCursor::NextBlock, QTextCursor::MoveAnchor, count); + emit cursorPositionChanged(); + return; + case MoveOperationPreviousWord: { + // We're not using the cursors next/previos word because we want camelCase word fragments + QTextCursor tmp = m_document->textDocument()->find(QRegExp("[A-Z\\.:\"'\\(\\)\\[\\]^ ]"), m_cursor.position() - 1, QTextDocument::FindBackward); + qWarning() << "found at" << tmp.position() << "starting at" << m_cursor.position(); + m_cursor.setPosition(tmp.position()); + emit cursorPositionChanged(); + return; + } + case MoveOperationNextWord: { + // We're not using the cursors next/previos word because we want camelCase word fragments + QTextCursor tmp = m_document->textDocument()->find(QRegExp("[A-Z\\.:\"'\\(\\)\\[\\]$ ]"), m_cursor.position() + 1); + m_cursor.setPosition(tmp.position() - 1); + emit cursorPositionChanged(); + return; + } } - m_classes.insert(qmlName, properties); } diff --git a/libnymea-app-core/scripting/codecompletion.h b/libnymea-app-core/scripting/codecompletion.h index d984b369..59749c4f 100644 --- a/libnymea-app-core/scripting/codecompletion.h +++ b/libnymea-app-core/scripting/codecompletion.h @@ -6,9 +6,9 @@ #include #include +#include "completionmodel.h" + class Engine; -class CompletionModel; -class CompletionProxyModel; class CodeCompletion: public QObject { @@ -20,6 +20,14 @@ class CodeCompletion: public QObject Q_PROPERTY(QString currentWord READ currentWord NOTIFY currentWordChanged) public: + enum MoveOperation { + MoveOperationPreviousLine, + MoveOperationNextLine, + MoveOperationPreviousWord, + MoveOperationNextWord, + }; + Q_ENUM(MoveOperation) + CodeCompletion(QObject *parent = nullptr); Engine* engine() const; @@ -43,8 +51,11 @@ public slots: void indent(int from, int to); void unindent(int from, int to); void closeBlock(); + void insertBeforeCursor(const QString &text); void insertAfterCursor(const QString &text); + void moveCursor(MoveOperation moveOperation, int count = 1); + signals: void engineChanged(); void documentChanged(); @@ -52,14 +63,31 @@ signals: void currentWordChanged(); private: - struct BlockInfo { + class BlockInfo { + public: + bool valid = false; QString name; QHash properties; + int start = -1; + int end = -1; }; - BlockInfo getBlockInfo(int postition); + class ClassInfo { + public: + ClassInfo(const QString &name = QString(), const QStringList &properties = QStringList(), const QStringList &methods = QStringList(), const QStringList &events = QStringList()): + name(name), properties(properties), methods(methods), events(events) {} + QString name; + QStringList properties; + QStringList methods; + QStringList events; + }; - template void registerType(const QString &qmlName); + BlockInfo getBlockInfo(int postition) const; + QList getIds() const; + QHash getIdTypes() const; + + int openingBlocksBefore(int position) const; + int closingBlocksAfter(int position) const; private: Engine *m_engine = nullptr; @@ -69,8 +97,11 @@ private: QTextCursor m_cursor; - QHash m_classes; + QHash m_classes; + QHash m_attachedClasses; + QHash m_jsClasses; QHash m_genericSyntax; + QHash m_genericJsSyntax; }; diff --git a/libnymea-app-core/scripting/completionmodel.cpp b/libnymea-app-core/scripting/completionmodel.cpp index 7fd9493d..0bf401b1 100644 --- a/libnymea-app-core/scripting/completionmodel.cpp +++ b/libnymea-app-core/scripting/completionmodel.cpp @@ -18,6 +18,8 @@ QHash CompletionModel::roleNames() const QHash roles; roles.insert(Qt::UserRole, "text"); roles.insert(Qt::DisplayRole, "displayText"); + roles.insert(Qt::DecorationRole, "decoration"); + roles.insert(Qt::DecorationPropertyRole, "decorationProperty"); return roles; } @@ -28,6 +30,10 @@ QVariant CompletionModel::data(const QModelIndex &index, int role) const return m_list.at(index.row()).text; case Qt::DisplayRole: return m_list.at(index.row()).displayText; + case Qt::DecorationRole: + return m_list.at(index.row()).decoration; + case Qt::DecorationPropertyRole: + return m_list.at(index.row()).decorationProperty; } return QVariant(); } @@ -72,7 +78,6 @@ QString CompletionProxyModel::filter() const void CompletionProxyModel::setFilter(const QString &filter) { if (m_filter != filter) { - qDebug() << "Setting filter" << filter; m_filter = filter; emit filterChanged(); invalidateFilter(); @@ -90,3 +95,20 @@ bool CompletionProxyModel::filterAcceptsRow(int source_row, const QModelIndex &) } return true; } + +bool CompletionProxyModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const +{ + CompletionModel::Entry left = m_model->get(source_left.row()); + CompletionModel::Entry right = m_model->get(source_right.row()); + + static QStringList ordering = {"property", "method", "event", "type", "keyword" }; + + int leftOrder = ordering.indexOf(left.decoration); + int rightOrder = ordering.indexOf(right.decoration); + + if (leftOrder != rightOrder) { + return leftOrder < rightOrder; + } + + return left.displayText < right.displayText; +} diff --git a/libnymea-app-core/scripting/completionmodel.h b/libnymea-app-core/scripting/completionmodel.h index af45f03a..b9fb6f4f 100644 --- a/libnymea-app-core/scripting/completionmodel.h +++ b/libnymea-app-core/scripting/completionmodel.h @@ -11,13 +11,14 @@ class CompletionModel: public QAbstractListModel 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, const QString &displayText, const QString &decoration, const QString &decorationProperty = QString(), const QString &trailingText = QString()) + : text(text), displayText(displayText), decoration(decoration), decorationProperty(decorationProperty), trailingText(trailingText) {} Entry(const QString &text): text(text), displayText(text) {} QString text; QString displayText; - bool addTrailingQuote = false; - bool addComment = false; + QString decoration; + QString decorationProperty; + QString trailingText; }; CompletionModel(QObject *parent = nullptr); @@ -52,6 +53,8 @@ public: protected: bool filterAcceptsRow(int source_row, const QModelIndex &/*source_parent*/) const override; + bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; + signals: void countChanged(); void filterChanged(); diff --git a/libnymea-app-core/scriptmanager.cpp b/libnymea-app-core/scriptmanager.cpp index 02083285..c3a21990 100644 --- a/libnymea-app-core/scriptmanager.cpp +++ b/libnymea-app-core/scriptmanager.cpp @@ -28,20 +28,27 @@ Scripts *ScriptManager::scripts() const return m_scripts; } -int ScriptManager::addScript(const QString &content) +int ScriptManager::addScript(const QString &name, const QString &content) { QVariantMap params; - params.insert("name", "Test"); + params.insert("name", name); params.insert("content", content); return m_client->sendCommand("Scripts.AddScript", params, this, "onScriptAdded"); } +int ScriptManager::renameScript(const QUuid &id, const QString &name) +{ + QVariantMap params; + params.insert("id", id); + params.insert("name", name); + return m_client->sendCommand("Scripts.EditScript", params, this, "onScriptRenamed"); +} + int ScriptManager::editScript(const QUuid &id, const QString &content) { QVariantMap params; params.insert("id", id); params.insert("content", content); - qDebug() << "Calling EditScript" << content; return m_client->sendCommand("Scripts.EditScript", params, this, "onScriptEdited"); } @@ -61,29 +68,24 @@ int ScriptManager::fetchScript(const QUuid &id) void ScriptManager::onScriptsFetched(const QVariantMap ¶ms) { - qDebug() << "scripts fetched" << params; foreach (const QVariant &variant, params.value("params").toMap().value("scripts").toList()) { - qDebug() << "script" << variant.toMap().value("id").toUuid(); QUuid id = variant.toMap().value("id").toUuid(); Script *script = new Script(id); script->setName(variant.toMap().value("name").toString()); m_scripts->addScript(script); - qDebug() << "Script added"; } } void ScriptManager::onScriptFetched(const QVariantMap ¶ms) { - qDebug() << "Script fetched" << params; - emit scriptFetched(params.value("id").toInt(), + emit fetchScriptReply(params.value("id").toInt(), params.value("params").toMap().value("scriptError").toString(), params.value("params").toMap().value("content").toString()); } void ScriptManager::onScriptAdded(const QVariantMap ¶ms) { - qDebug() << "Script added" << params; - emit scriptAdded(params.value("id").toInt(), + emit addScriptReply(params.value("id").toInt(), params.value("params").toMap().value("scriptError").toString(), params.value("params").toMap().value("script").toMap().value("id").toUuid(), params.value("params").toMap().value("errors").toStringList()); @@ -92,25 +94,50 @@ void ScriptManager::onScriptAdded(const QVariantMap ¶ms) void ScriptManager::onScriptEdited(const QVariantMap ¶ms) { - qDebug() << "Script edited" << params; -// emit scriptAdded(params.value("id").toInt(), params.value("script").toMap().value("id").toUuid()); - emit scriptEdited(params.value("id").toInt(), + emit editScriptReply(params.value("id").toInt(), params.value("params").toMap().value("scriptError").toString(), params.value("params").toMap().value("errors").toStringList()); } +void ScriptManager::onScriptRenamed(const QVariantMap ¶ms) +{ + emit renameScriptReply(params.value("id").toInt(), params.value("params").toMap().value("scriptError").toString()); +} + void ScriptManager::onScriptRemoved(const QVariantMap ¶ms) { - emit scriptRemoved(params.value("id").toInt(), params.value("params").toMap().value("scriptError").toString()); + emit removeScriptReply(params.value("id").toInt(), params.value("params").toMap().value("scriptError").toString()); } void ScriptManager::onNotificationReceived(const QVariantMap ¶ms) { - qDebug() << "noticication" << params; + qDebug() << "noticication" << params.value("notification").toString(); if (params.value("notification").toString() == "Scripts.ScriptLogMessage") { emit scriptMessage(params.value("params").toMap().value("scriptId").toUuid(), params.value("params").toMap().value("type").toString(), params.value("params").toMap().value("message").toString()); } + + else if (params.value("notification").toString() == "Scripts.ScriptAdded") { + QVariantMap scriptMap = params.value("params").toMap().value("script").toMap(); + Script *script = new Script(scriptMap.value("id").toUuid()); + script->setName(scriptMap.value("name").toString()); + m_scripts->addScript(script); + } + + else if (params.value("notification").toString() == "Scripts.ScriptRemoved") { + QUuid id = params.value("params").toMap().value("scriptId").toUuid(); + m_scripts->removeScript(id); + } + + else if (params.value("notification").toString() == "Scripts.ScriptChanged") { + QUuid id = params.value("params").toMap().value("scriptId").toUuid(); + QString name = params.value("params").toMap().value("name").toString(); + m_scripts->getScript(id)->setName(name); + } + + else { + qWarning() << "Unhandled notification" << params.value("notification").toString(); + } } diff --git a/libnymea-app-core/scriptmanager.h b/libnymea-app-core/scriptmanager.h index 0d9f8bda..cd195b80 100644 --- a/libnymea-app-core/scriptmanager.h +++ b/libnymea-app-core/scriptmanager.h @@ -22,16 +22,18 @@ public: Scripts *scripts() const; public slots: - int addScript(const QString &content); + int addScript(const QString &name, const QString &content); + int renameScript(const QUuid &id, const QString &name); int editScript(const QUuid &id, const QString &content); int removeScript(const QUuid &id); int fetchScript(const QUuid &id); signals: - void scriptAdded(int id, const QString &scriptError, const QUuid &scriptId, const QStringList &errors); - void scriptEdited(int id, const QString &scriptError, const QStringList &errors); - void scriptRemoved(int id, const QString &scriptError); - void scriptFetched(int id, const QString &scriptError, const QString &content); + void addScriptReply(int id, const QString &scriptError, const QUuid &scriptId, const QStringList &errors); + void editScriptReply(int id, const QString &scriptError, const QStringList &errors); + void renameScriptReply(int id, const QString &scriptError); + void removeScriptReply(int id, const QString &scriptError); + void fetchScriptReply(int id, const QString &scriptError, const QString &content); void scriptMessage(const QUuid &scriptId, const QString &type, const QString &message); @@ -40,6 +42,7 @@ private slots: void onScriptFetched(const QVariantMap ¶ms); void onScriptAdded(const QVariantMap ¶ms); void onScriptEdited(const QVariantMap ¶ms); + void onScriptRenamed(const QVariantMap ¶ms); void onScriptRemoved(const QVariantMap ¶ms); void onNotificationReceived(const QVariantMap ¶ms); diff --git a/libnymea-common/types/scripts.cpp b/libnymea-common/types/scripts.cpp index e5cfbdff..d7460d21 100644 --- a/libnymea-common/types/scripts.cpp +++ b/libnymea-common/types/scripts.cpp @@ -49,6 +49,24 @@ void Scripts::addScript(Script *script) m_list.append(script); endInsertRows(); emit countChanged(); + + connect(script, &Script::nameChanged, this, [this, script](){ + int idx = m_list.indexOf(script); + if (idx < 0) return; + emit dataChanged(index(idx), index(idx), {RoleName}); + }); +} + +void Scripts::removeScript(const QUuid &id) +{ + for (int i = 0; i < m_list.count(); i++) { + if (m_list.at(i)->id() == id) { + beginRemoveRows(QModelIndex(), i, i); + m_list.takeAt(i)->deleteLater(); + endRemoveRows(); + return; + } + } } Script* Scripts::get(int index) const diff --git a/libnymea-common/types/scripts.h b/libnymea-common/types/scripts.h index 93264493..1c19defd 100644 --- a/libnymea-common/types/scripts.h +++ b/libnymea-common/types/scripts.h @@ -24,6 +24,7 @@ public: void clear(); void addScript(Script *script); + void removeScript(const QUuid &id); Q_INVOKABLE Script *get(int index) const; Q_INVOKABLE Script *getScript(const QUuid &scriptId); diff --git a/nymea-app/images.qrc b/nymea-app/images.qrc index 64c5895d..3455ce76 100644 --- a/nymea-app/images.qrc +++ b/nymea-app/images.qrc @@ -214,5 +214,6 @@ ui/images/browser/MediaBrowserIconDeezer.svg ui/images/view-grid-symbolic.svg ui/images/script.svg + ui/images/save.svg diff --git a/nymea-app/ui/components/NymeaHeader.qml b/nymea-app/ui/components/NymeaHeader.qml index 77a69633..8000571a 100644 --- a/nymea-app/ui/components/NymeaHeader.qml +++ b/nymea-app/ui/components/NymeaHeader.qml @@ -58,6 +58,7 @@ Item { font.pixelSize: app.mediumFont elide: Text.ElideRight text: root.text + visible: text.length > 0 color: app.headerForegroundColor } } diff --git a/nymea-app/ui/images/save.svg b/nymea-app/ui/images/save.svg new file mode 100644 index 00000000..86bb28c8 --- /dev/null +++ b/nymea-app/ui/images/save.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/nymea-app/ui/magic/ScriptEditor.qml b/nymea-app/ui/magic/ScriptEditor.qml index 18d6de15..8a8e62d6 100644 --- a/nymea-app/ui/magic/ScriptEditor.qml +++ b/nymea-app/ui/magic/ScriptEditor.qml @@ -1,4 +1,4 @@ -import QtQuick 2.0 +import QtQuick 2.4 import QtQuick.Controls 2.2 import Nymea 1.0 import QtQuick.Layouts 1.2 @@ -12,16 +12,15 @@ Page { property alias scriptId: d.scriptId Component.onCompleted: { - if (scriptId !== undefined) { + if (scriptId !== undefined) {; d.callId = engine.scriptManager.fetchScript(scriptId); } else { scriptEdit.text = "import QtQuick 2.0\nimport nymea 1.0\n\nItem {\n \n}\n" - d.callId = engine.scriptManager.addScript(scriptEdit.text); } } header: NymeaHeader { - text: qsTr("Script editor") + onBackPressed: { if (scriptEdit.text == d.oldContent) { pageStack.pop() @@ -37,17 +36,34 @@ Page { pageStack.pop(); }); popup.open(); + } + TextField { + id: nameTextField + Layout.fillWidth: true + text: d.script ? d.script.name : "" + placeholderText: qsTr("Script name") } HeaderButton { - imageSource: "../images/media-playback-start.svg" + imageSource: "../images/save.svg" + enabled: d.script.name !== nameTextField.text || d.oldContent !== scriptEdit.text + color: enabled ? app.accentColor : keyColor + hoverEnabled: true + ToolTip.text: qsTr("Deploy script") + ToolTip.visible: hovered onClicked: { if (!d.scriptId) { - d.callId = engine.scriptManager.addScript(scriptEdit.text) + d.callId = engine.scriptManager.addScript(nameTextField.text, scriptEdit.text); } else { - print("editing script", d.scriptId, scriptEdit.text) - d.callId = engine.scriptManager.editScript(d.scriptId, scriptEdit.text) + print("editing script", d.scriptId) + if (d.script.name != nameTextField.text) { + engine.scriptManager.renameScript(d.scriptId, nameTextField.text) + } + if (d.oldContent != scriptEdit.text) { + d.callId = engine.scriptManager.editScript(d.scriptId, scriptEdit.text) + print("called edit", d.callId) + } } } } @@ -55,32 +71,50 @@ Page { QtObject { id: d - property int callId + property int callId: -1 property var scriptId property string oldContent + + property Script script: engine.scriptManager.scripts.getScript(d.scriptId) + } + + FontMetrics { + id: fontMetrics + font: scriptEdit.font } Connections { target: engine.scriptManager - onScriptAdded: { + onAddScriptReply: { if (id == d.callId) { + d.callId = -1; if (scriptError == "ScriptErrorNoError") { d.scriptId = scriptId; } errorModel.update(errors); } } - onScriptEdited: { + onEditScriptReply: { + print("edit reply", id, d.callId) if (id == d.callId) { + d.oldContent = scriptEdit.text; + d.callId = -1; errorModel.update(errors) } } - onScriptFetched: { + onFetchScriptReply: { if (id == d.callId && scriptError == "ScriptErrorNoError") { + d.callId = -1; scriptEdit.text = content; d.oldContent = content; } } + onRenameScriptReply: { + if (id == d.callId) { + d.callId = -1; + } + } + onScriptMessage: { if (scriptId !== d.scriptId) { return; @@ -91,6 +125,7 @@ Page { // TODO: Make this a SplitView when we can use Qt 5.13 ColumnLayout { + id: content anchors.fill: parent Flickable { @@ -98,6 +133,7 @@ Page { Layout.fillHeight: true Layout.fillWidth: true clip: true + interactive: !completionBox.visible boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar { policy: ScrollBar.AlwaysOn } @@ -140,6 +176,21 @@ Page { completionBox.show(); return; } + break; + case Qt.Key_PageUp: + var oldSelectionStart = scriptEdit.selectionStart; + completion.moveCursor(CodeCompletion.MoveOperationPreviousLine, scriptFlickable.height / (fontMetrics.lineSpacing + 2)); + if (event.modifiers & Qt.ShiftModifier) { + scriptEdit.select(oldSelectionStart, scriptEdit.cursorPosition) + } + return; + case Qt.Key_PageDown: + var oldSelectionStart = scriptEdit.selectionStart; + completion.moveCursor(CodeCompletion.MoveOperationNextLine, scriptFlickable.height / (fontMetrics.lineSpacing + 2)); + if (event.modifiers & Qt.ShiftModifier) { + scriptEdit.select(oldSelectionStart, scriptEdit.cursorPosition) + } + return; } } @@ -161,7 +212,11 @@ Page { completion.unindent(selectionStart, selectionEnd); event.accepted = true; return; - + case Qt.Key_Period: + completion.insertBeforeCursor("."); + completionBox.show(); + event.accepted = true; + return; } // Things to do only when we're autocompleting @@ -188,15 +243,6 @@ Page { } } } - - CompletionBox { - id: completionBox - model: completion.model - textArea: scriptEdit - onComplete: { - completion.complete(index) - } - } } } @@ -284,10 +330,29 @@ Page { } } } - } } + CompletionBox { + id: completionBox + property var editorPosition: scriptFlickable.mapToItem(root, 0, 0) + property int scrollOffsetX: scriptFlickable.contentX + scriptFlickable.originX + property int scrollOffsetY: scriptFlickable.contentY + scriptFlickable.originY + property int cursorXOnPage: scriptEdit.cursorRectangle.x + editorPosition.x - scrollOffsetX + property int cursorYOnPage: scriptEdit.cursorRectangle.y + editorPosition.y - scrollOffsetY + property int cursorHeight: scriptEdit.cursorRectangle.height + x: cursorXOnPage - Math.max(0, cursorXOnPage + width - root.width) + y: cursorYOnPage + cursorHeight + height < content.height ? + cursorYOnPage + cursorHeight + : cursorYOnPage - height + + model: completion.model + textArea: scriptEdit + font: scriptEdit.font + onComplete: { + completion.complete(index) + } + } ScriptSyntaxHighlighter { id: syntax @@ -302,4 +367,9 @@ Page { cursorPosition: scriptEdit.cursorPosition onCursorPositionChanged: scriptEdit.cursorPosition = cursorPosition } + + BusyOverlay { + shown: d.callId != -1 + } + } diff --git a/nymea-app/ui/magic/ScriptsPage.qml b/nymea-app/ui/magic/ScriptsPage.qml index bfc9a9e0..4ae185ec 100644 --- a/nymea-app/ui/magic/ScriptsPage.qml +++ b/nymea-app/ui/magic/ScriptsPage.qml @@ -24,7 +24,7 @@ Page { Connections { target: engine.scriptManager - onScriptRemoved: { + onRemovScriptReply: { if (id == d.pendingAction) { d.pendingAction = -1; } @@ -37,7 +37,7 @@ Page { delegate: NymeaListItemDelegate { width: parent.width text: model.name - subText: model.id + iconName: "../images/script.svg" canDelete: true onClicked: { pageStack.push("ScriptEditor.qml", {scriptId: model.id}); diff --git a/nymea-app/ui/magic/scripting/CompletionBox.qml b/nymea-app/ui/magic/scripting/CompletionBox.qml index 481a95cb..3809b594 100644 --- a/nymea-app/ui/magic/scripting/CompletionBox.qml +++ b/nymea-app/ui/magic/scripting/CompletionBox.qml @@ -1,6 +1,8 @@ import QtQuick 2.2 import QtQuick.Controls 2.2 import Nymea 1.0 +import QtQuick.Layouts 1.2 +import "../../components" Rectangle { id: root @@ -9,8 +11,6 @@ Rectangle { color: app.backgroundColor height: (Math.min(model.count, 10) * d.entryHeight) + (border.width * 2) width: 200 - x: textArea.cursorRectangle.x - y: textArea.cursorRectangle.y + textArea.cursorRectangle.height visible: model.count > 0 && !d.hidden && (model.filter.length >= 3 || d.manuallyInvoked) @@ -46,8 +46,10 @@ Rectangle { } function show() { - d.hidden = false; - d.manuallyInvoked = true; + if (root.model.count > 1) { + d.hidden = false; + d.manuallyInvoked = true; + } } function hide() { @@ -65,13 +67,14 @@ Rectangle { QtObject { id: d - property int entryHeight: dummyLabel.font.pixelSize + 4 + property int entryHeight: dummyLabel.font.pixelSize + 6 property int currentIndex: 0 property bool hidden: false property bool manuallyInvoked: false } + ListView { id: listView anchors.fill: parent @@ -86,15 +89,74 @@ Rectangle { height: d.entryHeight width: parent.width color: index == root.currentIndex ? app.accentColor : "transparent" - Label { - anchors.verticalCenter: parent.verticalCenter - anchors { left: parent.left; right: parent.right; margins: 4} - text: model.displayText - color: app.foregroundColor - width: parent.width - elide: Text.ElideRight - font: root.font + RowLayout { + anchors.fill: parent + spacing: 0 + + Item { + Layout.preferredHeight: d.entryHeight + Layout.preferredWidth: height + Layout.alignment: Qt.AlignVCenter + Rectangle { + anchors.centerIn: parent + height: root.font.pixelSize * .6 + width: height + border.width: 1 + border.color: "black" + visible: !entryIcon.visible + color: { + switch (model.decoration) { + case "type": + return "#55fc49"; + case "keyword": + return "yellow"; + case "property": + return "#ff5555"; + case "method": + return "blue"; + case "event": + return "magenta"; + case "id": + return "turquise"; + default: + return "transparent"; + } + } + } + ColorIcon { + id: entryIcon + height: root.font.pixelSize + width: height + anchors.centerIn: parent + visible: name != "" + color: root.currentIndex == index ? app.backgroundColor : app.accentColor + name: { + switch (model.decoration) { + case "thing": + return app.interfacesToIcon(model.decorationProperty.split(",")) + case "eventType": + return Qt.resolvedUrl("../../images/event.svg") + case "stateType": + return Qt.resolvedUrl("../../images/state.svg") + case "actionType": + return Qt.resolvedUrl("../../images/action.svg") + } + return "" + } + } + } + + Label { + anchors.verticalCenter: parent.verticalCenter + Layout.fillWidth: true + text: model.displayText + color: app.foregroundColor + width: parent.width + elide: Text.ElideRight + font: root.font + } } + MouseArea { anchors.fill: parent onClicked: {