/** * LogarithmPlotter - 2D plotter software to make BODE plots, sequences and distribution functions. * Copyright (C) 2021-2024 Ad5001 * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 program. If not, see . */ import QtQuick.Controls import QtQuick import Qt.labs.platform as Native import eu.ad5001.LogarithmPlotter.Popup 1.0 as P import "../js/mathlib.js" as MathLib import "../js/utils.js" as Utils import "../js/objects.js" as Objects import "../js/parsing/parsing.js" as Parsing /*! \qmltype ExpressionEditor \inqmlmodule eu.ad5001.LogarithmPlotter.Setting \brief Setting to edit strings and numbers. \sa EditorDialog, AutocompletionCategory */ Item { id: control height: 30 /*! \qmlsignal ExpressionEditor::changed(var newValue) Emitted when the value of the expression has been changed. The corresponding handler is \c onChanged. */ signal changed(var newValue) /*! \qmlproperty string ExpressionEditor::defValue Default editable expression value of the editor. */ property string defValue /*! \qmlproperty string ExpressionEditor::value Value of the editor. */ property alias value: editor.text /*! \qmlproperty string ExpressionEditor::self Object or context of the expression to be edited. Used to prevent circular dependency. */ property string self: "" /*! \qmlproperty var ExpressionEditor::variables Accepted variables for the expression. */ property var variables: [] /*! \qmlproperty string ExpressionEditor::placeholderText Value of the editor. */ property alias placeholderText: editor.placeholderText /*! \qmlproperty string ExpressionEditor::label Label of the editor. */ property string label /*! \qmlproperty string ExpressionEditor::icon Icon path of the editor. */ property string icon: "" /*! \qmlproperty string ExpressionEditor::openAndCloseMatches Characters that when pressed, should be immediately followed up by their closing character. TODO: Make it configurable. */ readonly property var openAndCloseMatches: { "(": ")", "[": "]", "'": "'", '"': '"' } /*! \qmlproperty string ExpressionEditor::colorSchemes Color schemes of the editor. */ readonly property var colorSchemes: [ { // Breeze Light 'NORMAL': "#1F1C1B", 'VARIABLE': "#0057AE", 'CONSTANT': "#006E28", 'FUNCTION': "#644A9B", 'OPERATOR': "#CA60CA", 'STRING': "#BF0303", 'NUMBER': "#B08000" }, { // Breeze Dark 'NORMAL': "#CFCFC2", 'VARIABLE': "#2980B9", 'CONSTANT': "#27AE60", 'FUNCTION': "#8E44AD", 'OPERATOR': "#A44EA4", 'STRING': "#F44F4F", 'NUMBER': "#F67400" }, { // Solarized 'NORMAL': "#839496", 'VARIABLE': "#B58900", 'CONSTANT': "#859900", 'FUNCTION': "#268BD2", 'OPERATOR': "#859900", 'STRING': "#2AA198", 'NUMBER': "#2AA198" }, { // GitHub Light 'NORMAL': "#24292E", 'VARIABLE': "#D73A49", 'CONSTANT': "#6F42C1", 'FUNCTION': "#6F42C1", 'OPERATOR': "#24292E", 'STRING': "#032F62", 'NUMBER': "#005CC5" }, { // GitHub Dark 'NORMAL': "#E1E4E8", 'VARIABLE': "#F97583", 'CONSTANT': "#B392f0", 'FUNCTION': "#B392f0", 'OPERATOR': "#E1E4E8", 'STRING': "#9ECBFF", 'NUMBER': "#79B8FF" }, { // Nord 'NORMAL': "#D8DEE9", 'VARIABLE': "#81A1C1", 'CONSTANT': "#8FBCBB", 'FUNCTION': "#88C0D0", 'OPERATOR': "#81A1C1", 'STRING': "#A3BE8C", 'NUMBER': "#B48EAD" }, { // Monokai 'NORMAL': "#F8F8F2", 'VARIABLE': "#66D9EF", 'CONSTANT': "#F92672", 'FUNCTION': "#A6E22E", 'OPERATOR': "#F8F8F2", 'STRING': "#E6DB74", 'NUMBER': "#AE81FF" } ] Icon { id: iconLabel anchors.top: parent.top anchors.topMargin: icon == "" ? 0 : 3 source: control.visible && icon != "" ? "../icons/" + control.icon : "" width: height height: icon == "" || !visible ? 0 : 24 color: sysPalette.windowText } Label { id: labelItem anchors.left: iconLabel.right anchors.leftMargin: icon == "" ? 0 : 5 height: parent.height anchors.top: parent.top verticalAlignment: TextInput.AlignVCenter //color: sysPalette.windowText text: visible ? qsTranslate("control", "%1: ").arg(control.label) : "" visible: control.label != "" } Native.MessageDialog { id: parsingErrorDialog title: qsTranslate("expression", "LogarithmPlotter - Parsing error") text: "" function showDialog(propName, propValue, error) { text = qsTranslate("expression", "Error while parsing expression for property %1:\n%2\n\nEvaluated expression: %3").arg(propName).arg(error).arg(propValue) open() } } TextField { id: editor anchors.top: parent.top anchors.left: labelItem.right anchors.leftMargin: 5 width: control.width - (labelItem.visible ? labelItem.width + 5 : 0) - iconLabel.width - 5 height: parent.height verticalAlignment: TextInput.AlignVCenter horizontalAlignment: control.label == "" ? TextInput.AlignLeft : TextInput.AlignHCenter text: control.defValue color: syntaxHighlightingEnabled ? sysPalette.window : sysPalette.windowText focus: true selectByMouse: true property bool autocompleteEnabled: Helper.getSettingBool("autocompletion.enabled") property bool syntaxHighlightingEnabled: Helper.getSettingBool("expression_editor.colorize") property bool autoClosing: Helper.getSettingBool("expression_editor.autoclose") property var tokens: autocompleteEnabled || syntaxHighlightingEnabled ? parent.tokens(text) : [] Keys.priority: Keys.BeforeItem // Required for knowing which key the user presses. onEditingFinished: { if(insertButton.focus || insertPopup.focus) return let value = text if(value != "" && value.toString() != defValue) { let expr = parse(value) if(expr != null) { control.changed(expr) defValue = expr.toEditableString() } } } onActiveFocusChanged: { if(activeFocus && autocompleteEnabled) autocompletePopup.open() else autocompletePopup.close() } cursorDelegate: Rectangle { visible: editor.cursorVisible color: sysPalette.windowText width: editor.cursorRectangle.width } Keys.onUpPressed: function(event) { if(autocompleteEnabled) if(acPopupContent.itemSelected == 0) acPopupContent.itemSelected = acPopupContent.itemCount-1 else acPopupContent.itemSelected = acPopupContent.itemSelected-1 event.accepted = true } Keys.onDownPressed: function(event) { if(autocompleteEnabled) if(acPopupContent.itemSelected == Math.min(acPopupContent.itemCount-1)) acPopupContent.itemSelected = 0 else acPopupContent.itemSelected = acPopupContent.itemSelected+1 event.accepted = true } Keys.onPressed: function(event) { // Autocomplete popup events if(autocompleteEnabled && (event.key == Qt.Key_Enter || event.key == Qt.Key_Return) && acPopupContent.itemCount > 0) { acPopupContent.autocomplete() event.accepted = true } else acPopupContent.itemSelected = 0 if(event.text in openAndCloseMatches && autoClosing) { let start = selectionStart insert(selectionStart, event.text) insert(selectionEnd, openAndCloseMatches[event.text]) cursorPosition = start+1 event.accepted = true } } Text { id: colorizedEditor anchors.fill: editor verticalAlignment: TextInput.AlignVCenter horizontalAlignment: control.label == "" ? TextInput.AlignLeft : TextInput.AlignHCenter textFormat: Text.StyledText text: parent.syntaxHighlightingEnabled ? colorize(parent.tokens) : "" color: sysPalette.windowText visible: parent.syntaxHighlightingEnabled //font.pixelSize: parent.font.pixelSize //opacity: editor.activeFocus ? 0 : 1 } Popup { id: autocompletePopup x: 0 y: parent.height closePolicy: Popup.NoAutoClose width: editor.width height: acPopupContent.height padding: 0 Column { id: acPopupContent width: parent.width readonly property var identifierTokenTypes: [ Parsing.TokenType.VARIABLE, Parsing.TokenType.FUNCTION, Parsing.TokenType.CONSTANT ] property var currentToken: generateTokenInformation(getTokenAt(editor.tokens, editor.cursorPosition)) property var previousToken: generateTokenInformation(getPreviousToken(currentToken.token)) property var previousToken2: generateTokenInformation(getPreviousToken(previousToken.token)) property var previousToken3: generateTokenInformation(getPreviousToken(previousToken2.token)) visible: currentToken.exists // Focus handling. readonly property var lists: [objectPropertiesList, variablesList, constantsList, functionsList, executableObjectsList, objectsList] readonly property int itemCount: objectPropertiesList.model.length + variablesList.model.length + constantsList.model.length + functionsList.model.length + executableObjectsList.model.length + objectsList.model.length property int itemSelected: 0 /*! \qmlmethod var ExpressionEditor::generateTokenInformation(var token) Generates basic information about the given token (existence and type) used in autocompletion). */ function generateTokenInformation(token) { let exists = token != null return { 'token': token, 'exists': exists, 'value': exists ? token.value : null, 'type': exists ? token.type : null, 'startPosition': exists ? token.startPosition : 0, 'dot': exists ? (token.type == Parsing.TokenType.PUNCT && token.value == ".") : false, 'identifier': exists ? identifierTokenTypes.includes(token.type) : false } } /*! \qmlmethod void ExpressionEditor::autocompleteInfoAt(int idx) Returns the autocompletion text information at a given position. The information contains key 'text' (description text), 'autocomplete' (text to insert) and 'cursorFinalOffset' (amount to add to the cursor's position after the end of the autocomplete) */ function autocompleteInfoAt(idx) { if(idx >= itemCount) return "" let startIndex = 0 for(let list of lists) { if(idx < startIndex + list.model.length) return list.model[idx-startIndex] startIndex += list.model.length } } /*! \qmlmethod void ExpressionEditor::autocomplete() Autocompletes with the current selected word. */ function autocomplete() { let autotext = autocompleteInfoAt(itemSelected) let startPos = currentToken.startPosition console.log("Replacing", currentToken.value, "at", startPos, "with", autotext.autocomplete) editor.remove(startPos, startPos+currentToken.value.length) editor.insert(startPos, autotext.autocomplete) editor.cursorPosition = startPos+autotext.autocomplete.length+autotext.cursorFinalOffset } /*! \qmlmethod var ExpressionEditor::getPreviousToken(var token) Returns the token before this one. */ function getPreviousToken(token) { let newToken = getTokenAt(editor.tokens, token.startPosition) if(newToken != null && newToken.type == Parsing.TokenType.WHITESPACE) return getPreviousToken(newToken) return newToken } AutocompletionCategory { id: objectPropertiesList category: qsTr("Object Properties") visbilityCondition: doesObjectExist itemStartIndex: 0 itemSelected: parent.itemSelected property bool isEnteringProperty: ( // Current token is dot. (parent.currentToken.dot && parent.previousToken.identifier && !parent.previousToken2.dot) || // Current token is property identifier (parent.currentToken.identifier && parent.previousToken.dot && parent.previousToken2.identifier && !parent.previousToken3.dot)) property string objectName: isEnteringProperty ? (parent.currentToken.dot ? parent.previousToken.value : parent.previousToken2.value) : "" property bool doesObjectExist: isEnteringProperty && (objectName in Objects.currentObjectsByName) property var objectProperties: doesObjectExist ? Objects.currentObjectsByName[objectName].constructor.properties() : {} categoryItems: Object.keys(objectProperties) autocompleteGenerator: (item) => { let propType = objectProperties[item] return { 'text': item, 'annotation': propType == null ? '' : propType.toString(), 'autocomplete': parent.currentToken.dot ? `.${item} ` : `${item} `, 'cursorFinalOffset': 0 } } baseText: parent.visible && !parent.currentToken.dot ? parent.currentToken.value : "" } AutocompletionCategory { id: variablesList category: qsTr("Variables") visbilityCondition: parent.currentToken.identifier && !parent.previousToken.dot itemStartIndex: objectPropertiesList.model.length itemSelected: parent.itemSelected categoryItems: control.variables autocompleteGenerator: (item) => {return { 'text': item, 'annotation': '', 'autocomplete': item + " ", 'cursorFinalOffset': 0 }} baseText: parent.visible ? parent.currentToken.value : "" } AutocompletionCategory { id: constantsList category: qsTr("Constants") visbilityCondition: parent.currentToken.identifier && !parent.previousToken.dot itemStartIndex: variablesList.itemStartIndex + variablesList.model.length itemSelected: parent.itemSelected categoryItems: Parsing.CONSTANTS_LIST autocompleteGenerator: (item) => {return { 'text': item, 'annotation': Parsing.CONSTANTS[item], 'autocomplete': item + " ", 'cursorFinalOffset': 0 }} baseText: parent.visible ? parent.currentToken.value : "" } AutocompletionCategory { id: functionsList category: qsTr("Functions") visbilityCondition: parent.currentToken.identifier && !parent.previousToken.dot itemStartIndex: constantsList.itemStartIndex + constantsList.model.length itemSelected: parent.itemSelected categoryItems: Parsing.FUNCTIONS_LIST autocompleteGenerator: (item) => {return { 'text': item, 'annotation': Parsing.FUNCTIONS_USAGE[item].join(', '), 'autocomplete': item+'()', 'cursorFinalOffset': -1 }} baseText: parent.visible ? parent.currentToken.value : "" } AutocompletionCategory { id: executableObjectsList category: qsTr("Executable Objects") visbilityCondition: parent.currentToken.identifier && !parent.previousToken.dot itemStartIndex: functionsList.itemStartIndex + functionsList.model.length itemSelected: parent.itemSelected categoryItems: Objects.getObjectsName("ExecutableObject").filter(obj => obj != self) autocompleteGenerator: (item) => {return { 'text': item, 'annotation': Objects.currentObjectsByName[item] == null ? '' : Objects.currentObjectsByName[item].constructor.displayType(), 'autocomplete': item+'()', 'cursorFinalOffset': -1 }} baseText: parent.visible ? parent.currentToken.value : "" } AutocompletionCategory { id: objectsList category: qsTr("Objects") visbilityCondition: parent.currentToken.identifier && !parent.previousToken.dot itemStartIndex: executableObjectsList.itemStartIndex + executableObjectsList.model.length itemSelected: parent.itemSelected categoryItems: Object.keys(Objects.currentObjectsByName).filter(obj => obj != self) autocompleteGenerator: (item) => {return { 'text': item, 'annotation': `${Objects.currentObjectsByName[item].constructor.displayType()}`, 'autocomplete': item+'.', 'cursorFinalOffset': 0 }} baseText: parent.visible ? parent.currentToken.value : "" } } } } Button { id: insertButton text: "α" anchors.right: parent.right anchors.rightMargin: 5 anchors.verticalCenter: parent.verticalCenter width: 20 height: width onClicked: { insertPopup.open() insertPopup.focus = true } } P.InsertCharacter { id: insertPopup x: Math.round((parent.width - width) / 2) y: Math.round((parent.height - height) / 2) category: "expression" onSelected: function(c) { editor.insert(editor.cursorPosition, c) insertPopup.close() focus = false editor.focus = true } } /*! \qmlmethod var ExpressionEditor::parse(string newExpression) Parses the \c newExpression as an expression, checks for errors, shows them if any. Returns the parsed expression if possible, null otherwise.. */ function parse(newExpression) { let expr = null try { expr = new MathLib.Expression(value.toString()) // Check if the expression is valid, throws error otherwise. if(!expr.allRequirementsFullfilled()) { let undefVars = expr.undefinedVariables() if(undefVars.length > 1) throw new Error(qsTranslate('error', 'No object found with names %1.').arg(undefVars.join(', '))) else throw new Error(qsTranslate('error', 'No object found with name %1.').arg(undefVars.join(', '))) } if(expr.requiredObjects().includes(control.self)) throw new Error(qsTranslate('error', 'Object cannot be dependent on itself.')) // Recursive dependencies let dependentOnSelfObjects = expr.requiredObjects().filter( (obj) => Objects.currentObjectsByName[obj].getDependenciesList() .includes(Objects.currentObjectsByName[control.self]) ) if(dependentOnSelfObjects.length == 1) throw new Error(qsTranslate('error', 'Circular dependency detected. Object %1 depends on %2.').arg(dependentOnSelfObjects[0].toString()).arg(control.self)) else if(dependentOnSelfObjects.length > 1) throw new Error(qsTranslate('error', 'Circular dependency detected. Objects %1 depend on %2.').arg(dependentOnSelfObjects.map(obj => obj.toString()).join(', ')).arg(control.self)) //console.log(control.self, propertyName, expr.execute()) return expr } catch(e) { // Error in expression parsingErrorDialog.showDialog(propertyName, newExpression, e.message) return null } } /*! \qmlmethod var ExpressionEditor::tokens(string expressionText) Generates a list of tokens from the given. */ function tokens(text) { let tokenizer = new Parsing.Tokenizer(new Parsing.Input(text), true, false) let tokenList = [] let token while((token = tokenizer.next()) != null) tokenList.push(token) return tokenList } /*! \qmlmethod var ExpressionEditor::getTokenAt(var tokens, int position) Gets the token at the given position within the text. Returns null if out of bounds. */ function getTokenAt(tokenList, position) { let currentPosition = 0 for(let token of tokenList) if(position <= (currentPosition + token.value.length)) return token else currentPosition += token.value.length return null } /*! \qmlmethod var ExpressionEditor::colorize(var tokenList) Creates an HTML colorized string of the given tokens. Returns the colorized and escaped expression if possible, null otherwise.. */ function colorize(tokenList) { let parsedText = "" let scheme = colorSchemes[Helper.getSettingInt("expression_editor.color_scheme")] for(let token of tokenList) { switch(token.type) { case Parsing.TokenType.VARIABLE: parsedText += `${token.value}` break; case Parsing.TokenType.CONSTANT: parsedText += `${token.value}` break; case Parsing.TokenType.FUNCTION: parsedText += `${Utils.escapeHTML(token.value)}` break; case Parsing.TokenType.OPERATOR: parsedText += `${Utils.escapeHTML(token.value)}` break; case Parsing.TokenType.NUMBER: parsedText += `${Utils.escapeHTML(token.value)}` break; case Parsing.TokenType.STRING: parsedText += `${token.limitator}${Utils.escapeHTML(token.value)}${token.limitator}` break; case Parsing.TokenType.WHITESPACE: case Parsing.TokenType.PUNCT: default: parsedText += Utils.escapeHTML(token.value).replace(/ /g, ' ') break; } } return parsedText } }