/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright 2013 - 2020, nymea GmbH * Contact: contact@nymea.io * * This file is part of nymea. * This project including source code and documentation is protected by * copyright law, and remains the property of nymea GmbH. All rights, including * reproduction, publication, editing and translation, are reserved. The use of * this project is subject to the terms of a license agreement to be concluded * with nymea GmbH in accordance with the terms of use of nymea GmbH, available * under https://nymea.io/license * * GNU General Public License Usage * Alternatively, this project may be redistributed and/or modified under the * terms of the GNU General Public License as published by the Free Software * Foundation, GNU version 3. This project is distributed in the hope that it * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with * this project. If not, see . * * For any further details and any questions please contact us under * contact@nymea.io or see our FAQ/Licensing Information on * https://nymea.io/license/faq * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ import QtQuick 2.9 import QtQuick.Controls 2.2 import Nymea 1.0 import QtQuick.Layouts 1.2 import QtQuick.Controls.Material 2.1 import Qt.labs.settings 1.0 import "../components" import "scripting" Page { id: root property alias scriptId: d.scriptId Component.onCompleted: { 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" } if ((Qt.platform.os == "android" || Qt.platform.os == "ios") && !editorSettings.popupWasShown) { var component = Qt.createComponent(Qt.resolvedUrl("../components/NymeaDialog.qml")); var infoPopup = component.createObject(root, { title: qsTr("Did you know..."), headerIcon: "../images/info.svg", text: qsTr("nymea:app is available for all kinds of devices. In order to edit scripts we recommend to use nymea:app on your personal computer or connect a keyboard to your tablet.") }) infoPopup.open(); editorSettings.popupWasShown = true } } Settings { id: editorSettings property bool popupWasShown: false property int preferredPaneHeight: Math.min(200, root.height / 4) } header: NymeaHeader { onBackPressed: { if (scriptEdit.text == d.oldContent) { pageStack.pop() return; } var comp = Qt.createComponent("../components/NymeaDialog.qml"); var popup = comp.createObject(root, { title: qsTr("Unsaved changes"), text: qsTr("There are unsaved changes in the script. Do you want to discard the changes?"), standardButtons: Dialog.Yes | Dialog.No }) popup.onAccepted.connect(function() { pageStack.pop(); }); popup.open(); } TextField { id: nameTextField Layout.fillWidth: true text: d.script ? d.script.name : "" placeholderText: qsTr("Script name") } HeaderButton { imageSource: "../images/question.svg" text: qsTr("Help") onClicked: { Qt.openUrlExternally("https://nymea.io/documentation/users/usage/scripting") } } HeaderButton { imageSource: "../images/save.svg" enabled: d.script && d.script.name !== nameTextField.text || d.oldContent !== scriptEdit.text color: enabled ? Style.accentColor : Style.iconColor hoverEnabled: true ToolTip.text: qsTr("Deploy script") ToolTip.visible: hovered onClicked: { if (!d.scriptId) { d.callId = engine.scriptManager.addScript(nameTextField.text, scriptEdit.text); } else { 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) } } } } } QtObject { id: d 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 } ScriptAutoSaver { id: autoSaver scriptId: d.scriptId liveContent: scriptEdit.text } Connections { target: engine.scriptManager onAddScriptReply: { print("Add reply", status) deployReply(id, status, errors) if (status == ScriptManager.ScriptErrorNoError) { d.scriptId = scriptId; } } onEditScriptReply: { print("Edit reply", status) deployReply(id, status, errors) } function deployReply(id, status, errors) { if (id === d.callId) { d.callId = -1; if (status === ScriptManager.ScriptErrorNoError) { d.oldContent = scriptEdit.text; infoPane.hide(); errorPane.hide(); } else if (status === ScriptManager.ScriptErrorInvalidScript) { errorPane.show(); } errorModel.update(errors) } } onFetchScriptReply: { if (id == d.callId && status == ScriptManager.ScriptErrorNoError) { d.callId = -1; d.oldContent = content; if (autoSaver.cachedContent.length > 0 && autoSaver.cachedContent !== content) { console.log("autosaved version available!"); scriptEdit.text = autoSaver.cachedContent; infoPane.show(); } else { scriptEdit.text = content; } autoSaver.active = true; } } onRenameScriptReply: { if (id == d.callId) { d.callId = -1; } } onScriptMessage: { print("scriptMessage:", scriptId, d.scriptId) if (scriptId !== d.scriptId) { return; } var str = "".arg(type == "ScriptMessageTypeWarning" ? Style.accentColor : Style.foregroundColor) + message + "" consoleOutput.append(str) } } // TODO: Make this a SplitView when we can use Qt 5.13 ColumnLayout { id: content spacing: 0 anchors.fill: parent InfoPane { id: infoPane Layout.fillWidth: true text: qsTr("An autosaved version of this script has been loaded. Deploy to store this version or reload to restore the deployed version.") buttonText: qsTr("Reload") z: 1 onButtonClicked: { scriptEdit.text = d.oldContent infoPane.hide(); errorPane.hide(); errorModel.update([]) } } InfoPane { id: errorPane Layout.fillWidth: true color: "red" text: qsTr("The script has not been deployed because it contains errors.") } Flickable { id: scriptFlickable Layout.fillHeight: true Layout.fillWidth: true clip: true interactive: !completionBox.visible boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar { policy: ScrollBar.AlwaysOn } ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AlwaysOn } LineNumbers { id: lineNumbers textArea: scriptEdit } TextArea.flickable: TextArea { id: scriptEdit leftPadding: lineNumbers.width + 2 rightPadding: 20 bottomPadding: 28 inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase font.family: "Monospace" font.pixelSize: app.extraSmallFont selectByMouse: true selectByKeyboard: true onCursorPositionChanged: { if (completionBox.visible) { completion.update(); } } function controlPressed(event) { return event.modifiers & Qt.ControlModifier || event.modifiers & Qt.MetaModifier } function shiftPressed(event) { return event.modifiers & Qt.ShiftModifier } Keys.onPressed: { // print("key", event.key, "Completion box visible:", completionBox.visible) // Things to happen only when we're not autocompleting if (!completionBox.visible) { switch (event.key) { case Qt.Key_Return: case Qt.Key_Enter: completion.newLine(); event.accepted = true; return; case Qt.Key_Space: if (!completionBox.visible && controlPressed(event)) { completion.update(); 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; } } // things to happen in any case switch (event.key) { case Qt.Key_BraceLeft: completion.insertAfterCursor("}"); return; case Qt.Key_BraceRight: completion.closeBlock(); event.accepted = true; return; case Qt.Key_Tab: completion.indent(selectionStart, selectionEnd); event.accepted = true; return; case Qt.Key_Backtab: completion.unindent(selectionStart, selectionEnd); event.accepted = true; return; case Qt.Key_Period: completion.insertBeforeCursor("."); completionBox.show(); event.accepted = true; return; case Qt.Key_Plus: if (controlPressed(event)) { scriptEdit.font.pixelSize++; event.accepted = true; return; } break; case Qt.Key_ZoomIn: scriptEdit.font.pixelSize++; event.accepted = true; break; case Qt.Key_hyphen: case Qt.Key_Minus: if (controlPressed(event)) { scriptEdit.font.pixelSize--; event.accepted = true; return; } break; case Qt.Key_ZoomOut: scriptEdit.font.pixelSize--; event.accepted = true; break; case Qt.Key_Slash: if (controlPressed(event)) { completion.toggleComment(selectionStart, selectionEnd); event.accepted = true; return; } break; } // 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: completion.complete(completionBox.currentIndex) completionBox.hide(); event.accepted = true; break; } } } } } MouseArea { Layout.fillWidth: true Layout.preferredHeight: app.margins / 4 property int offset: 0 enabled: editorPane.shown onPressed: offset = height - mouseY cursorShape: enabled ? Qt.SplitVCursor : Qt.ArrowCursor onMouseYChanged: { var newSize = content.height - mapToItem(content, mouseX, mouseY).y - offset editorSettings.preferredPaneHeight = Math.min(Math.max(editorPane.collapsedHeight + 50, newSize), root.height - infoPane.height) } } EditorPane { id: editorPane Layout.fillWidth: true Layout.preferredHeight: shown ? editorSettings.preferredPaneHeight : collapsedHeight ScrollView { id: errorsPane anchors { fill: parent; margins: app.margins / 2 } property string title: qsTr("Errors") property bool clearEnabled: errorModel.count > 0 signal raise() function clear() { errorModel.clear(); } ListView { id: errorListView model: ListModel { id: errorModel property var errorLines: [] function update(errors) { clear(); var newErrorLines = [] errors.forEach( function(error) { var parts = error.split(":") var line = parseInt(parts.shift()); var col = parseInt(parts.shift()); var message = parts.join(":").trim(); append({line: line, column: col, message: message}) newErrorLines.push(line); }) errorLines = newErrorLines; if (errorModel.count > 0) { errorsPane.raise(); } } function getError(lineNumber) { print("getting error for line", lineNumber, errorModel.count) for (var i = 0; i < errorModel.count; i++) { var entry = get(i); print("i:", i, entry.message, entry.line) if (entry.line === lineNumber) { return entry; } } } } delegate: Label { id: errorDelegate width: parent.width text: model.line + ":" + model.column + ": " + model.message font: scriptEdit.font MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: { if (mouse.button == Qt.LeftButton) { scriptEdit.forceActiveFocus() completion.moveCursor(CodeCompletion.MoveOperationAbsoluteLine, model.line) } else { print("rmb") var popup = rmbMenuComponent.createObject(errorDelegate, {x: mouseX}) popup.copy.connect(function() { PlatformHelper.toClipBoard(errorDelegate.text) }) popup.copyAll.connect(function() { var text = []; for (var i = 0; i < errorModel.count; i++) { var line = errorModel.get(i) text.push(line.line + ":" + line.column + ": " + line.message) } PlatformHelper.toClipBoard(text.join("\n")) }) popup.open() } } } } } } ScrollView { id: consolePane anchors {fill: parent; margins: app.margins/ 2 } property string title: qsTr("Console") property bool clearEnabled: false signal raise() function clear() { consoleOutput.text = ""; clearEnabled = false; } TextArea { id: consoleOutput onTextChanged: { consolePane.raise(); print("text:", text) consolePane.clearEnabled = true } selectByMouse: true font: scriptEdit.font textFormat: Qt.RichText } } } } 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 document: scriptEdit.textDocument backgroundColor: Style.backgroundColor } CodeCompletion { id: completion engine: _engine document: scriptEdit.textDocument cursorPosition: scriptEdit.cursorPosition onCursorPositionChanged: scriptEdit.cursorPosition = cursorPosition onHint: completionBox.show() onSelect: scriptEdit.select(from, to) } BusyOverlay { shown: d.callId != -1 } Component { id: rmbMenuComponent Menu { signal copy() signal copyAll() MenuItem { text: qsTr("Copy") onClicked: copy() } MenuItem { text: qsTr("Copy all") onClicked: copyAll() } } } }