/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* 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: "qrc:/icons/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: "qrc:/icons/question.svg"
text: qsTr("Help")
onClicked: {
Qt.openUrlExternally("https://nymea.io/documentation/users/usage/scripting")
}
}
HeaderButton {
imageSource: "qrc:/icons/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()
}
}
}
}