570 lines
22 KiB
QML
570 lines
22 KiB
QML
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
|
*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*
|
|
* 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 = "<font color=\"%1\">".arg(type == "ScriptMessageTypeWarning" ? Style.accentColor : Style.foregroundColor) + message + "</font>"
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|