diff --git a/src/Plugins/Tools/Signals/CMakeLists.txt b/src/Plugins/Tools/Signals/CMakeLists.txt index 8328020ed..6349d4156 100644 --- a/src/Plugins/Tools/Signals/CMakeLists.txt +++ b/src/Plugins/Tools/Signals/CMakeLists.txt @@ -1,3 +1,23 @@ -apx_plugin(SRCS "*.qml") +set(QRC_QML + ColorChoiceFact.qml + ColorChooser.qml + FilterBase.qml + FilterFact.qml + FilterKalman.qml + FilterRegistry.qml + FilterRunningAverage.qml + MenuItem.qml + MenuPage.qml + MenuSet.qml + MenuSets.qml + PageButton.qml + SignalButton.qml + Signals.qml + SignalsModel.qml + SignalsPlugin.qml + SignalsView.qml +) -apx_qrc(${MODULE} PREFIX ${MODULE} SRCS "*.qml") +apx_plugin(SRCS ${QRC_QML}) + +apx_qrc(${MODULE} PREFIX ${MODULE} SRCS ${QRC_QML}) diff --git a/src/Plugins/Tools/Signals/ColorChoiceFact.qml b/src/Plugins/Tools/Signals/ColorChoiceFact.qml new file mode 100644 index 000000000..615ed02ed --- /dev/null +++ b/src/Plugins/Tools/Signals/ColorChoiceFact.qml @@ -0,0 +1,42 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: colorFact + + property bool isColorChoice: true + property var itemOwner: null + property string colorValue: "" + + flags: Fact.CloseOnTrigger + opts: ({ + "editor": Qt.resolvedUrl("ColorChooser.qml") + }) + + onTriggered: { + if (itemOwner && typeof itemOwner.setColorValue === "function") + itemOwner.setColorValue(colorValue) + } +} diff --git a/src/Plugins/Tools/Signals/ColorChooser.qml b/src/Plugins/Tools/Signals/ColorChooser.qml new file mode 100644 index 000000000..47dca8139 --- /dev/null +++ b/src/Plugins/Tools/Signals/ColorChooser.qml @@ -0,0 +1,59 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls.Material + +Rectangle { + id: editor + + readonly property string colorText: fact && fact.colorValue !== undefined + ? String(fact.colorValue).trim().toUpperCase() + : (fact && fact.itemOwner && fact.itemOwner.colorValue !== undefined + ? String(fact.itemOwner.colorValue).trim().toUpperCase() + : "") + readonly property bool selected: fact && fact.itemOwner + && fact.itemOwner.colorValue === colorText + + implicitHeight: factButton.height * 0.6 + implicitWidth: factButton.height * 1.8 + radius: height / 5 + border.width: selected ? 2 : 1 + border.color: selected ? "white" : "#66000000" + color: colorText !== "" ? colorText : "transparent" + + Rectangle { + anchors.fill: parent + anchors.margins: 3 + visible: colorText === "" + radius: parent.radius - 1 + color: "transparent" + border.width: 1 + border.color: Material.hintTextColor + + Text { + anchors.centerIn: parent + text: qsTr("A") + font: apx.font_narrow(Math.max(10, parent.height * 0.55)) + color: Material.hintTextColor + } + } +} diff --git a/src/Plugins/Tools/Signals/FilterBase.qml b/src/Plugins/Tools/Signals/FilterBase.qml new file mode 100644 index 000000000..bf3a0b7f2 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterBase.qml @@ -0,0 +1,99 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: filterSettingsFact + + property var data: ({}) + property string filterType: "" + property string filterTitle: "" + property string detailsText: "" + property real outputValue: 0 + + flags: (Fact.Group | Fact.Section) + title: filterTitle + descr: detailsText + + Component.onCompleted: load(data) + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback + } + + function clamp(value, minValue, maxValue) + { + return Math.max(minValue, Math.min(maxValue, value)) + } + + function normalizedInput(inputValue) + { + return asNumber(inputValue, 0) + } + + function passThrough(inputValue) + { + outputValue = normalizedInput(inputValue) + return outputValue + } + + function load(filterData) + { + var source = filterData && typeof filterData === "object" ? filterData : {} + loadParameters(source) + reset() + } + + function save() + { + var filterData = saveParameters() + if (!filterData || filterData instanceof Array || typeof filterData !== "object") + filterData = {} + + filterData.type = filterType + return filterData + } + + function loadParameters(filterData) + { + } + + function saveParameters() + { + return ({}) + } + + function reset() + { + outputValue = 0 + } + + // Each concrete filter updates its own state and returns the latest output. + function update(inputValue) + { + return passThrough(inputValue) + } +} diff --git a/src/Plugins/Tools/Signals/FilterFact.qml b/src/Plugins/Tools/Signals/FilterFact.qml new file mode 100644 index 000000000..0caf97f75 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterFact.qml @@ -0,0 +1,227 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: filterFact + + property var data: ({}) + property var filterRegistry: FilterRegistry {} + property bool isFilterItem: true + property bool loading: false + property string filterType: filterRegistry.defaultType() + + flags: (Fact.Group | Fact.Bool) + icon: "tune" + value: true + title: filterRegistry.titleForType(filterType) + descr: filterDescription() + + signal removeTriggered() + + readonly property bool filterEnabled: value !== false + readonly property var currentFilterFact: filterBody.size > 0 ? filterBody.child(0) : null + + Component.onCompleted: load(data) + + function itemEditor() + { + for (var parent = filterFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveAll === "function" && parent.newItem !== undefined) + return parent + } + + return null + } + + function canSaveAll() + { + var owner = itemEditor() + return owner && !owner.newItem + } + + function saveAll() + { + var owner = itemEditor() + if (owner) + owner.saveAll() + } + + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + return component.createObject(parent, properties) + } + + console.log(component.errorString()) + return null + } + + function selectedTypeText() + { + if (typeFact.text !== undefined && typeFact.text !== null) + return String(typeFact.text).trim() + + if (typeFact.value !== undefined && typeFact.value !== null) + return String(typeFact.value).trim() + + return "" + } + + function filterDescription() + { + var details = currentFilterFact && currentFilterFact.descr !== undefined ? String(currentFilterFact.descr) : "" + if (filterEnabled) + return details + + return details !== "" ? qsTr("Off") + ", " + details : qsTr("Off") + } + + function createFilterBody(filterData) + { + var info = filterRegistry.typeInfo(filterType) + if (!info) + return null + + filterBody.deleteChildren() + return createFact(filterBody, info.source, { + "data": filterData + }) + } + + function setFilterType(type, filterData) + { + var normalizedType = filterRegistry.valueForType(type) + var normalizedData = filterRegistry.normalizeFilter(filterData) + + filterType = normalizedType + createFilterBody(normalizedData ? normalizedData : filterRegistry.defaultFilter(normalizedType)) + } + + function load(filterData) + { + var filter = filterRegistry.normalizeFilter(filterData) + if (!filter) + filter = filterRegistry.defaultFilter(filterRegistry.defaultType()) + + loading = true + value = filter.enabled !== false + filterType = filter.type + typeFact.value = filterRegistry.titleForType(filter.type) + createFilterBody(filter) + reset() + loading = false + } + + function save() + { + var filter = currentFilterFact && typeof currentFilterFact.save === "function" + ? currentFilterFact.save() + : filterRegistry.defaultFilter(filterType) + if (!filter) + return null + + filter.enabled = filterEnabled + filter.type = filterType + return filterRegistry.normalizeFilter(filter) + } + + function reset() + { + if (currentFilterFact && typeof currentFilterFact.reset === "function") + currentFilterFact.reset() + } + + // The wrapper controls enable/type while the loaded child owns the actual math. + function update(inputValue) + { + var input = Number(inputValue) + if (!isFinite(input)) + input = 0 + + if (!filterEnabled) + return input + + if (currentFilterFact && typeof currentFilterFact.update === "function") + return currentFilterFact.update(input) + + return input + } + + onValueChanged: { + if (!loading) + reset() + } + + Fact { + id: typeFact + name: "type" + title: qsTr("Type") + descr: qsTr("Filter algorithm") + flags: Fact.Enum + enumStrings: filterRegistry.typeTitles() + value: filterRegistry.titleForType(filterRegistry.defaultType()) + onValueChanged: { + if (filterFact.loading) + return + + var selectedType = filterRegistry.valueForType(filterFact.selectedTypeText()) + if (selectedType === filterFact.filterType) + return + + filterFact.setFilterType(selectedType, filterRegistry.defaultFilter(selectedType)) + filterFact.reset() + } + } + + Fact { + id: filterBody + title: qsTr("Settings") + descr: qsTr("Parameters for the selected filter type") + flags: (Fact.Group | Fact.Section) + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + visible: filterFact.canSaveAll() + icon: "check-circle" + onTriggered: filterFact.saveAll() + } + + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove filter") + descr: qsTr("Delete this filter from the stack") + icon: "delete" + onTriggered: { + filterFact.removeTriggered() + filterFact.deleteFact() + } + } +} diff --git a/src/Plugins/Tools/Signals/FilterKalman.qml b/src/Plugins/Tools/Signals/FilterKalman.qml new file mode 100644 index 000000000..dd9d2ba19 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterKalman.qml @@ -0,0 +1,103 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +FilterBase { + id: filterFact + + property real defaultR: 0.1 + property real defaultQ: 0.001 + property bool initialized: false + property real stateEstimate: 0 + property real covariance: 0.1 + readonly property real measurementNoise: Math.max(0.0, asNumber(rFact.value, defaultR)) + readonly property real processNoise: Math.max(0.0, asNumber(qFact.value, defaultQ)) + + filterType: "kalman_smp" + filterTitle: qsTr("Simple Kalman") + detailsText: qsTr("R") + ": " + Number(measurementNoise) + + ", " + qsTr("Q") + ": " + Number(processNoise) + + function loadParameters(filterData) + { + rFact.value = Math.max(0.0, asNumber(filterData.r, defaultR)) + qFact.value = Math.max(0.0, asNumber(filterData.q, defaultQ)) + } + + function saveParameters() + { + return { + "r": measurementNoise, + "q": processNoise + } + } + + function reset() + { + initialized = false + stateEstimate = 0 + covariance = 0.1 + outputValue = 0 + } + + function update(inputValue) + { + var input = normalizedInput(inputValue) + if (!filterEnabled) + return passThrough(input) + + if (!initialized) { + stateEstimate = input + covariance = 0.1 + initialized = true + } else { + covariance = covariance + processNoise + + var denominator = covariance + measurementNoise + var gain = denominator > 0 ? covariance / denominator : 0 + + stateEstimate = stateEstimate + gain * (input - stateEstimate) + covariance = (1.0 - gain) * covariance + } + + outputValue = stateEstimate + return outputValue + } + + Fact { + id: rFact + name: "r" + title: qsTr("R") + descr: qsTr("Measurement noise") + flags: Fact.Float + value: defaultR + } + + Fact { + id: qFact + name: "q" + title: qsTr("Q") + descr: qsTr("Process noise") + flags: Fact.Float + value: defaultQ + } +} diff --git a/src/Plugins/Tools/Signals/FilterRegistry.qml b/src/Plugins/Tools/Signals/FilterRegistry.qml new file mode 100644 index 000000000..56809cf05 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterRegistry.qml @@ -0,0 +1,156 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQml + +QtObject { + id: registry + + // To add a filter: create a dedicated Filter*.qml component and register it here. + readonly property var typeOptions: [ + { + "title": qsTr("Running average"), + "value": "running_avg", + "source": Qt.resolvedUrl("FilterRunningAverage.qml"), + "defaults": { + "type": "running_avg", + "enabled": true, + "coef": 0.2 + } + }, + { + "title": qsTr("Simple Kalman"), + "value": "kalman_smp", + "source": Qt.resolvedUrl("FilterKalman.qml"), + "defaults": { + "type": "kalman_smp", + "enabled": true, + "r": 0.1, + "q": 0.001 + } + } + ] + + function cloneValue(value) + { + if (value === undefined || value === null) + return value + + return JSON.parse(JSON.stringify(value)) + } + + function asString(value, fallback) + { + if (fallback === undefined) + fallback = "" + + if (value === undefined || value === null) + return fallback + + return String(value) + } + + function typeInfo(type) + { + var text = asString(type, "") + + for (var i = 0; i < typeOptions.length; ++i) { + if (typeOptions[i].value === text || typeOptions[i].title === text) + return typeOptions[i] + } + + return null + } + + function titleForType(type) + { + var info = typeInfo(type) + return info ? info.title : asString(type, "") + } + + function componentSource(type) + { + var info = typeInfo(type) + return info ? info.source : "" + } + + function typeTitles() + { + var values = [] + + for (var i = 0; i < typeOptions.length; ++i) + values.push(typeOptions[i].title) + + return values + } + + function valueForType(type) + { + var info = typeInfo(type) + return info ? info.value : defaultType() + } + + function defaultType() + { + return typeOptions.length > 0 ? typeOptions[0].value : "" + } + + function defaultFilter(type) + { + var info = typeInfo(type) + return info ? cloneValue(info.defaults) : null + } + + function normalizeFilter(filterData) + { + if (!filterData || filterData instanceof Array || typeof filterData !== "object") + return null + + var source = cloneValue(filterData) + var filter = defaultFilter(source.type) + + if (!filter) + return null + + for (var key in source) + filter[key] = source[key] + + filter.type = defaultFilter(source.type).type + if (filter.enabled === undefined) + filter.enabled = true + + return filter + } + + function normalizeFilters(filtersData) + { + var source = filtersData instanceof Array ? filtersData : [] + var list = [] + + for (var i = 0; i < source.length; ++i) { + var filter = normalizeFilter(source[i]) + if (filter) + list.push(filter) + } + + return list + } +} diff --git a/src/Plugins/Tools/Signals/FilterRunningAverage.qml b/src/Plugins/Tools/Signals/FilterRunningAverage.qml new file mode 100644 index 000000000..e78980b51 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterRunningAverage.qml @@ -0,0 +1,80 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +FilterBase { + id: filterFact + + property real defaultCoefficient: 0.2 + property bool initialized: false + property real averageValue: 0 + readonly property real coefficient: clamp(asNumber(coefFact.value, defaultCoefficient), 0.0, 1.0) + + filterType: "running_avg" + filterTitle: qsTr("Running average") + detailsText: qsTr("Coef") + ": " + Number(coefficient).toFixed(2) + + function loadParameters(filterData) + { + coefFact.value = clamp(asNumber(filterData.coef, defaultCoefficient), 0.0, 1.0) + } + + function saveParameters() + { + return { + "coef": coefficient + } + } + + function reset() + { + initialized = false + averageValue = 0 + outputValue = 0 + } + + function update(inputValue) + { + var input = normalizedInput(inputValue) + if (!filterEnabled) + return passThrough(input) + + if (!initialized) { + averageValue = input + initialized = true + } else { + averageValue = averageValue * (1.0 - coefficient) + input * coefficient + } + + outputValue = averageValue + return outputValue + } + + Fact { + id: coefFact + name: "coef" + title: qsTr("Coefficient") + descr: qsTr("Running average blend coefficient") + flags: Fact.Float + value: defaultCoefficient + } +} diff --git a/src/Plugins/Tools/Signals/MenuColor.qml b/src/Plugins/Tools/Signals/MenuColor.qml new file mode 100644 index 000000000..4985ed648 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuColor.qml @@ -0,0 +1,47 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Apx.Common + +TextButton { + Layout.fillHeight: true + checkable: true + ButtonGroup.group: buttonGroup + + property var values: [] + onActivated: signals.facts=Qt.binding(function(){return values}) + + toolTip: getToolTip(values) + + function getToolTip(facts) + { + var s=[] + for(var i=0;i"+fact.descr+"") + } + return s.join("
") + } +} diff --git a/src/Plugins/Tools/Signals/MenuItem.qml b/src/Plugins/Tools/Signals/MenuItem.qml new file mode 100644 index 000000000..9e71daf44 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuItem.qml @@ -0,0 +1,585 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls.Material + +import APX.Facts + +Fact { + id: itemFact + + property bool newItem: false + property var data: ({}) + property var signalsModel: null + property var setFact: null + property var pageFact: null + + property string colorValue: "" + property var filtersData: [] + property string saveError: "" + property var filterRegistry: FilterRegistry {} + readonly property var colorBaseLabels: [ + qsTr("Red"), + qsTr("Pink"), + qsTr("Purple"), + qsTr("Deep Purple"), + qsTr("Indigo"), + qsTr("Blue"), + qsTr("Light Blue"), + qsTr("Cyan"), + qsTr("Teal"), + qsTr("Green"), + qsTr("Orange"), + qsTr("Blue Grey") + ] + readonly property var colorBaseValues: [ + Material.Red, + Material.Pink, + Material.Purple, + Material.DeepPurple, + Material.Indigo, + Material.Blue, + Material.LightBlue, + Material.Cyan, + Material.Teal, + Material.Green, + Material.Orange, + Material.BlueGrey + ] + readonly property var colorShadeLabels: [ + qsTr("300"), + qsTr("500"), + qsTr("700"), + qsTr("900") + ] + readonly property var colorShadeValues: [ + Material.Shade300, + Material.Shade500, + Material.Shade700, + Material.Shade900 + ] + + flags: Fact.Group + title: newItem ? qsTr("Add new item") : bindText() + descr: itemDescription() + + signal addTriggered() + signal removeTriggered() + + Component.onCompleted: { + load() + rebuildColorChoices() + } + + function rootEditor() + { + for (var parent = itemFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveSettings === "function") + return parent + } + + return null + } + + function saveAll() + { + var root = rootEditor() + if (root) + root.saveSettings() + } + + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child + } + + console.log(component.errorString()) + return null + } + + function cloneValue(value) + { + if (value === undefined || value === null) + return value + return JSON.parse(JSON.stringify(value)) + } + + function normalizeColorValue(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + if (text === "") + return "" + return text.toUpperCase() + } + + function normalizeBindValue(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function factTextValue(factObject) + { + if (!factObject) + return "" + + if (factObject.text !== undefined && factObject.text !== null) + return String(factObject.text).trim() + + if (factObject.value !== undefined && factObject.value !== null) + return String(factObject.value).trim() + + return "" + } + + function bindText() + { + return normalizeBindValue(factTextValue(mBind)) + } + + function saveTarget() + { + return normalizeBindValue(factTextValue(mSaveFact)) + } + + function colorValueCurrent() + { + return colorValue + } + + function warningText() + { + return factTextValue(mWarning) + } + + function colorSummary() + { + return colorValue !== "" ? colorValue : qsTr("Auto") + } + + function filterSummary(filterData) + { + var filter = filterData || {} + var label = filterRegistry.titleForType(filter.type) + if (filter.enabled === false) + label += " (" + qsTr("off") + ")" + return label + } + + function liveFilterSummary(filterFact) + { + if (!filterFact) + return "" + + return filterFact.descr !== "" ? filterFact.title + ": " + filterFact.descr : filterFact.title + } + + function clearFilterFacts() + { + if (!mFiltersGroup) + return + + for (var i = mFiltersGroup.size - 1; i >= 0; --i) { + var child = mFiltersGroup.child(i) + if (child && child.isFilterItem) + child.deleteFact() + } + } + + function createFilterFact(filterData) + { + var child = createFact(mFiltersGroup, "FilterFact.qml", { + "data": filterData, + "filterRegistry": itemFact.filterRegistry + }) + if (!child) + return null + + return child + } + + function exportFilters() + { + var list = [] + + var liveFilters = filterFacts() + for (var i = 0; i < liveFilters.length; ++i) { + var child = liveFilters[i] + if (!child || typeof child.save !== "function") + continue + + var filterData = child.save() + if (filterData) + list.push(filterRegistry.normalizeFilter(filterData)) + } + + return list + } + + function filterFacts() + { + var list = [] + + if (!mFiltersGroup) + return list + + for (var i = 0; i < mFiltersGroup.size; ++i) { + var child = mFiltersGroup.child(i) + if (child && child.isFilterItem) + list.push(child) + } + + return list + } + + function currentFilters() + { + var live = exportFilters() + if (live.length > 0) + return live + return filterRegistry.normalizeFilters(cloneValue(filtersData)) + } + + function rebuildFilters() + { + clearFilterFacts() + + var source = filterRegistry.normalizeFilters(cloneValue(filtersData)) + for (var i = 0; i < source.length; ++i) + createFilterFact(source[i]) + } + + function filtersSummary() + { + var liveFilters = filterFacts() + if (liveFilters.length > 0) { + var liveParts = [] + for (var i = 0; i < liveFilters.length; ++i) + liveParts.push(liveFilterSummary(liveFilters[i])) + return liveParts.join(", ") + } + + var list = currentFilters() + if (!(list instanceof Array) || list.length <= 0) + return qsTr("None") + + var parts = [] + for (var i = 0; i < list.length; ++i) + parts.push(filterSummary(list[i])) + return parts.join(", ") + } + + // The item owns the live filter chain so the chart only provides a raw sample. + function updateFilters(inputValue) + { + var output = Number(inputValue) + if (!isFinite(output)) + output = 0 + + var liveFilters = filterFacts() + for (var i = 0; i < liveFilters.length; ++i) { + var filterFact = liveFilters[i] + if (!filterFact || typeof filterFact.update !== "function") + continue + + output = filterFact.update(output) + } + + return output + } + + function setColorValue(value) + { + colorValue = normalizeColorValue(value) + } + + function clearColorChoices() + { + if (!mColorPage) + return + + for (var i = mColorPage.size - 1; i >= 0; --i) { + var child = mColorPage.child(i) + if (child && child.isColorChoice) + child.deleteFact() + } + } + + function rebuildColorChoices() + { + clearColorChoices() + + createFact(mColorPage, "ColorChoiceFact.qml", { + "itemOwner": itemFact, + "title": qsTr("Auto"), + "descr": qsTr("Use automatic series color"), + "colorValue": "", + "section": "", + "value": qsTr("Auto") + }) + + for (var shadeIndex = 0; shadeIndex < colorShadeValues.length; ++shadeIndex) { + var shadeLabel = colorShadeLabels[shadeIndex] + for (var colorIndex = 0; colorIndex < colorBaseValues.length; ++colorIndex) { + var colorCode = Material.color(colorBaseValues[colorIndex], + colorShadeValues[shadeIndex]).toString().toUpperCase() + createFact(mColorPage, "ColorChoiceFact.qml", { + "itemOwner": itemFact, + "title": colorBaseLabels[colorIndex], + "descr": colorCode, + "colorValue": colorCode, + "section": shadeLabel, + "value": colorCode + }) + } + } + } + + function setFiltersData(value) + { + filtersData = filterRegistry.normalizeFilters(cloneValue(value)) + rebuildFilters() + } + + function addFilter() + { + return createFilterFact(filterRegistry.defaultFilter(filterRegistry.defaultType())) + } + + function validationError() + { + var target = saveTarget() + if (target === "") + return "" + + if (!target.startsWith("sns.scr.")) + return qsTr("Save target must start with sns.scr.") + + if (itemFact.pageFact && typeof itemFact.pageFact.isSaveTargetUsedLocal === "function" + && itemFact.pageFact.isSaveTargetUsedLocal(target, itemFact)) + return qsTr("Save target is already used in this page.") + + if (itemFact.setFact && typeof itemFact.setFact.isSaveTargetUsed === "function" + && itemFact.setFact.isSaveTargetUsed(target, itemFact)) + return qsTr("Save target is already used in this set.") + + return "" + } + + function refreshValidation() + { + saveError = validationError() + } + + function canSave() + { + return bindText() !== "" && validationError() === "" + } + + function load() + { + mBind.value = normalizeBindValue(data && data.bind !== undefined ? data.bind : "") + colorValue = normalizeColorValue(data ? data.color : "") + filtersData = filterRegistry.normalizeFilters(cloneValue(data && data.filters instanceof Array + ? data.filters + : [])) + rebuildFilters() + mWarning.value = data && data.warning !== undefined ? data.warning : "" + mSaveFact.value = normalizeBindValue(data && data.save !== undefined ? data.save : null) + refreshValidation() + } + + function save() + { + if (!canSave()) + return null + + var item = { + "bind": bindText(), + "filters": exportFilters() + } + + if (colorValue !== "") + item.color = colorValue + if (factTextValue(mWarning) !== "") + item.warning = factTextValue(mWarning) + if (saveTarget() !== "") + item.save = saveTarget() + + return item + } + + function itemDescription() + { + var details = [] + if (colorValue !== "") + details.push(qsTr("Color") + ": " + colorValue) + if (filtersSummary() !== qsTr("None")) + details.push(qsTr("Filters") + ": " + filtersSummary()) + if (factTextValue(mWarning) !== "") + details.push(qsTr("Warning") + ": " + factTextValue(mWarning)) + if (saveTarget() !== "") + details.push(qsTr("Save") + ": " + saveTarget()) + if (saveError !== "") + details.push(saveError) + + return details.join(", ") + } + + Fact { + id: mFact + title: qsTr("Binding") + descr: qsTr("Pick a mandala fact") + flags: Fact.Int + units: "mandala" + onTextChanged: { + if (value) + mBind.setValue(itemFact.normalizeBindValue(text)) + } + } + + Fact { + id: mBind + name: "bind" + title: qsTr("Expression") + descr: qsTr("Mandala path or JavaScript expression") + flags: Fact.Text + } + + Fact { + id: mColorPage + title: qsTr("Color") + descr: qsTr("Series color override") + flags: Fact.Group + value: itemFact.colorSummary() + opts: ({ + "editor": Qt.resolvedUrl("ColorChooser.qml") + }) + property var itemOwner: itemFact + } + + Fact { + id: mFiltersGroup + title: qsTr("Filters") + descr: itemFact.filtersSummary() + // icon: "tune" + flags: (Fact.Group | Fact.DragChildren) + + Fact { + flags: Fact.Action + title: qsTr("Add filter") + descr: qsTr("Append a new filter to the stack") + icon: "plus-circle" + onTriggered: itemFact.addFilter() + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + visible: !itemFact.newItem + icon: "check-circle" + onTriggered: itemFact.saveAll() + } + } + + Fact { + id: mWarning + name: "warning" + title: qsTr("Warning") + descr: qsTr("Expression that raises a page warning") + flags: Fact.Text + } + + Fact { + id: mSaveFact + name: "save" + title: qsTr("Save target") + descr: qsTr("Pick a mandala fact under sns.scr") + flags: Fact.Int + units: "mandala" + onTextChanged: { + itemFact.refreshValidation() + if (itemFact.setFact && typeof itemFact.setFact.refreshSaveWarnings === "function") + itemFact.setFact.refreshSaveWarnings() + } + } + + Fact { + title: qsTr("Save target status") + descr: qsTr("Fix this before saving") + visible: itemFact.saveError !== "" + icon: "alert-circle" + value: itemFact.saveError + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + visible: !itemFact.newItem + icon: "check-circle" + onTriggered: itemFact.saveAll() + } + + Fact { + flags: (Fact.Action | Fact.Apply | Fact.ShowDisabled) + title: qsTr("Add") + descr: qsTr("Add this item") + enabled: itemFact.newItem && itemFact.canSave() + icon: "plus-circle" + onTriggered: { + itemFact.menuBack() + itemFact.addTriggered() + } + } + + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + descr: qsTr("Remove this item") + visible: !itemFact.newItem + icon: "delete" + onTriggered: { + var ownerSet = itemFact.setFact + itemFact.removeTriggered() + itemFact.deleteFact() + if (ownerSet && typeof ownerSet.refreshSaveWarnings === "function") + ownerSet.refreshSaveWarnings() + } + } +} diff --git a/src/Plugins/Tools/Signals/MenuPage.qml b/src/Plugins/Tools/Signals/MenuPage.qml new file mode 100644 index 000000000..54b418101 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuPage.qml @@ -0,0 +1,414 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts +import "." + +Fact { + id: pageFact + + property bool newItem: false + property var data: ({}) + property var signalsModel: null + property var setFact: null + property real speedValue: 1.0 + + property alias itemsFact: pageItems + + flags: (Fact.Group | Fact.FlatModel) + title: newItem ? qsTr("Add new page") : pageName() + descr: newItem ? qsTr("Create a new chart page") : pageDescription() + + signal addTriggered() + signal removeTriggered() + + Component.onCompleted: { + load() + } + + function rootEditor() + { + for (var parent = pageFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveSettings === "function") + return parent + } + + return null + } + + function saveAll() + { + var root = rootEditor() + if (root) + root.saveSettings() + } + + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child + } + + console.log(component.errorString()) + return null + } + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback + } + + function defaultTitle() + { + if (signalsModel && typeof signalsModel.defaultPageTitle === "function") + return pageFact.signalsModel.defaultPageTitle(Math.max(pageFact.num, 0)) + return qsTr("Page") + " " + (Math.max(pageFact.num, 0) + 1) + } + + function pageNameFromBind(bind) + { + var text = String(bind).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (simplePath.test(text)) { + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + } + + if (text === "") + return "" + + var parts = text.split(".") + var token = parts.length > 0 ? parts[parts.length - 1] : text + if (token.length <= 0) + return "" + + return token.charAt(0).toUpperCase() + } + + function pageName() + { + var text = mName.text.trim() + if (text !== "") + return text + + if (pageItems.size > 0) { + var firstItem = pageItems.child(0) + if (firstItem && typeof firstItem.bindText === "function") { + var guessed = pageNameFromBind(firstItem.bindText()) + if (guessed !== "") + return guessed + } + } + + return pageFact.defaultTitle() + } + + function currentSpeedValue() + { + return speedValue + } + + function setSpeedValue(value) + { + var nextSpeed = signalsModel && typeof signalsModel.normalizeSpeed === "function" + ? signalsModel.normalizeSpeed(value) + : asNumber(value, 1.0) + if (nextSpeed === speedValue) + return + + speedValue = nextSpeed + } + + function isPinned() + { + return !!mPin.value + } + + function itemFacts() + { + var items = [] + + for (var i = 0; i < pageItems.size; ++i) + items.push(pageItems.child(i)) + + return items + } + + function speedText() + { + if (signalsModel && typeof signalsModel.formatSpeed === "function") + return signalsModel.formatSpeed(speedValue) + + var text = String(speedValue) + if (text.endsWith(".0")) + text = text.slice(0, -2) + return text + "x" + } + + function speedOptions() + { + var factors = signalsModel && signalsModel.speedFactors instanceof Array + ? signalsModel.speedFactors + : [0.2, 0.5, 1.0, 2.0, 4.0] + var values = [] + + for (var i = 0; i < factors.length; ++i) { + if (signalsModel && typeof signalsModel.formatSpeed === "function") + values.push(signalsModel.formatSpeed(factors[i])) + else { + var text = String(factors[i]) + if (text.endsWith(".0")) + text = text.slice(0, -2) + values.push(text + "x") + } + } + + return values + } + + function speedValueFromText(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var factors = signalsModel && signalsModel.speedFactors instanceof Array + ? signalsModel.speedFactors + : [0.2, 0.5, 1.0, 2.0, 4.0] + + for (var i = 0; i < factors.length; ++i) { + var optionText = signalsModel && typeof signalsModel.formatSpeed === "function" + ? signalsModel.formatSpeed(factors[i]) + : String(factors[i]).replace(/\.0$/, "") + "x" + if (optionText === text) + return Number(factors[i]) + } + + if (signalsModel && typeof signalsModel.normalizeSpeed === "function") + return signalsModel.normalizeSpeed(text) + + return 1.0 + } + + function load() + { + mName.value = data && data.name !== undefined ? data.name : "" + mPin.value = data && data.pin !== undefined ? data.pin : false + speedValue = signalsModel && typeof signalsModel.normalizeSpeed === "function" + ? signalsModel.normalizeSpeed(data ? data.speed : undefined) + : asNumber(data ? data.speed : undefined, 1.0) + updateItems() + } + + function updateItems() + { + pageItems.deleteChildren() + + var items = data && data.items instanceof Array ? data.items : [] + for (var i = 0; i < items.length; ++i) + createItem(items[i]) + } + + function maybeAdoptNameFromBind(bind) + { + if (mName.text.trim() !== "") + return + + var guessed = pageNameFromBind(bind) + if (guessed !== "") + mName.setValue(guessed) + } + + function createItem(itemData) + { + var child = createFact(pageItems, "MenuItem.qml", { + "data": itemData, + "signalsModel": signalsModel, + "setFact": setFact, + "pageFact": pageFact + }) + if (!child) + return null + + if (itemData && itemData.bind !== undefined) + maybeAdoptNameFromBind(itemData.bind) + + if (setFact && typeof setFact.refreshSaveWarnings === "function") + setFact.refreshSaveWarnings() + + return child + } + + function isSaveTargetUsedLocal(saveTarget, skipItem) + { + var target = String(saveTarget).trim() + if (target === "") + return false + + for (var i = 0; i < pageItems.size; ++i) { + var itemEditor = pageItems.child(i) + if (!itemEditor || itemEditor === skipItem) + continue + if (typeof itemEditor.saveTarget === "function" + && itemEditor.saveTarget() === target) + return true + } + + return false + } + + function save() + { + var items = [] + for (var i = 0; i < pageItems.size; ++i) { + var itemEditor = pageItems.child(i) + var itemData = itemEditor.save() + if (itemData === null) + return null + items.push(itemData) + } + + return { + "name": pageName(), + "pin": mPin.value, + "speed": speedValue, + "items": items + } + } + + function pageDescription() + { + var parts = [] + if (mPin.value) + parts.push(qsTr("Pinned")) + + parts.push(qsTr("Speed") + ": " + speedText()) + + var items = [] + for (var i = 0; i < pageItems.size; ++i) + items.push(pageItems.child(i).title) + if (items.length > 0) + parts.push(items.join(", ")) + + return parts.join(", ") + } + + Fact { + id: mName + title: qsTr("Page name") + descr: qsTr("Tab label for this page") + flags: Fact.Text + icon: "rename-box" + } + + Fact { + id: mPin + title: qsTr("Pinned") + descr: qsTr("Show this page in the stacked pinned layout") + flags: Fact.Bool + icon: "pin" + } + + Fact { + id: mSpeed + title: qsTr("Speed") + descr: qsTr("Default chart speed for this page") + flags: Fact.Enum + icon: "play-speed" + enumStrings: pageFact.speedOptions() + value: pageFact.speedText() + onValueChanged: { + var selectedText = value === undefined || value === null ? text : value + var selectedSpeed = pageFact.speedValueFromText(selectedText) + if (selectedSpeed === pageFact.speedValue) + return + + pageFact.speedValue = selectedSpeed + } + } + + MenuItem { + title: qsTr("Add new item") + icon: "plus-circle" + newItem: true + visible: !pageFact.newItem + signalsModel: pageFact.signalsModel + setFact: pageFact.setFact + pageFact: pageFact + onAddTriggered: { + var itemData = save() + if (itemData) + pageFact.createItem(itemData) + } + } + + Fact { + id: pageItems + title: qsTr("Items") + visible: !pageFact.newItem + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + visible: !pageFact.newItem + icon: "check-circle" + onTriggered: pageFact.saveAll() + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Add") + descr: qsTr("Add this page") + enabled: newItem + icon: "plus-circle" + onTriggered: { + pageFact.menuBack() + addTriggered() + } + } + + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + descr: qsTr("Remove this page") + visible: !newItem + icon: "delete" + onTriggered: { + var ownerSet = setFact + if (ownerSet && ownerSet.stateChanged) + ownerSet.stateChanged() + removeTriggered() + pageFact.deleteFact() + if (ownerSet && typeof ownerSet.refreshSaveWarnings === "function") + ownerSet.refreshSaveWarnings() + } + } +} diff --git a/src/Plugins/Tools/Signals/MenuSet.qml b/src/Plugins/Tools/Signals/MenuSet.qml new file mode 100644 index 000000000..89a1093a0 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuSet.qml @@ -0,0 +1,252 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts +import "." + +Fact { + id: setFact + + property var signalsModel: null + property var data: ({}) + + flags: (Fact.Group | Fact.FlatModel) + title: setTitleText() + descr: pagesSummary() + + signal selected(var num) + signal stateChanged() + + Component.onCompleted: { + load() + refreshSaveWarnings() + } + + function rootEditor() + { + for (var parent = setFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveSettings === "function") + return parent + } + + return null + } + + function saveAll() + { + var root = rootEditor() + if (root) + root.saveSettings() + } + + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child + } + + console.log(component.errorString()) + return null + } + + function defaultTitle() + { + if (signalsModel && typeof signalsModel.defaultSetTitle === "function") + return setFact.signalsModel.defaultSetTitle(Math.max(setFact.num, 0)) + return qsTr("Set") + " " + (Math.max(setFact.num, 0) + 1) + } + + function pageFacts() + { + var pages = [] + + for (var i = 0; i < setPages.size; ++i) + pages.push(setPages.child(i)) + + return pages + } + + function load() + { + setTitle.value = data && data.title !== undefined ? data.title : defaultTitle() + updatePages() + } + + function setTitleText() + { + return setTitle.text.trim() !== "" ? setTitle.text.trim() : setFact.defaultTitle() + } + + function save() + { + refreshSaveWarnings() + + var pages = [] + for (var i = 0; i < setPages.size; ++i) { + var pageEditor = setPages.child(i) + var pageData = pageEditor.save() + if (pageData === null) + return null + pages.push(pageData) + } + + return { + "title": setTitleText(), + "pages": pages + } + } + + function updatePages() + { + setPages.deleteChildren() + + var pages = data && data.pages instanceof Array ? data.pages : [] + for (var i = 0; i < pages.length; ++i) + createPage(pages[i]) + } + + function createPage(pageData) + { + var child = createFact(setPages, "MenuPage.qml", { + "data": pageData, + "signalsModel": setFact.signalsModel, + "setFact": setFact + }) + if (!child) + return null + return child + } + + function refreshSaveWarnings() + { + for (var i = 0; i < setPages.size; ++i) { + var pageEditor = setPages.child(i) + if (!pageEditor || !pageEditor.itemsFact) + continue + + for (var j = 0; j < pageEditor.itemsFact.size; ++j) { + var itemEditor = pageEditor.itemsFact.child(j) + if (itemEditor && typeof itemEditor.refreshValidation === "function") + itemEditor.refreshValidation() + } + } + } + + function isSaveTargetUsed(saveTarget, skipItem) + { + var target = String(saveTarget).trim() + if (target === "") + return false + + for (var i = 0; i < setPages.size; ++i) { + var pageEditor = setPages.child(i) + if (!pageEditor || !pageEditor.itemsFact) + continue + + for (var j = 0; j < pageEditor.itemsFact.size; ++j) { + var itemEditor = pageEditor.itemsFact.child(j) + if (!itemEditor || itemEditor === skipItem) + continue + if (typeof itemEditor.saveTarget === "function" + && itemEditor.saveTarget() === target) + return true + } + } + + return false + } + + function pagesSummary() + { + var pages = [] + for (var i = 0; i < setPages.size; ++i) + pages.push(setPages.child(i).title) + return pages.join(", ") + } + + Fact { + id: setTitle + title: qsTr("Set name") + descr: qsTr("Saved chart configuration name") + flags: Fact.Text + icon: "rename-box" + } + + MenuPage { + title: qsTr("Add new page") + icon: "plus-circle" + newItem: true + signalsModel: setFact.signalsModel + setFact: setFact + onAddTriggered: { + var pageData = save() + if (pageData) { + setFact.createPage(pageData) + setFact.stateChanged() + } + } + } + + Fact { + id: setPages + title: qsTr("Pages") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + } + + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove set") + descr: qsTr("Delete this chart set") + icon: "delete" + onTriggered: { + if (setFact.active) + selected(0) + setFact.stateChanged() + setFact.deleteFact() + } + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + icon: "check-circle" + onTriggered: setFact.saveAll() + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Select") + descr: qsTr("Make this set active") + visible: !setFact.active + icon: "check-circle" + onTriggered: { + setFact.menuBack() + setFact.selected(setFact.num) + } + } +} diff --git a/src/Plugins/Tools/Signals/MenuSets.qml b/src/Plugins/Tools/Signals/MenuSets.qml new file mode 100644 index 000000000..b292267ee --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuSets.qml @@ -0,0 +1,294 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: setsFact + + property var signalsModel: null + property bool destroyOnClose: true + property string defaultDescr: qsTr("Chart configuration editor") + property bool loading: false + + name: setsFact.signalsModel && setsFact.signalsModel.settingsName ? setsFact.signalsModel.settingsName : "signals" + flags: (Fact.Group | Fact.FlatModel) + title: qsTr("Signals") + descr: setsFact.defaultDescr + icon: "poll" + + signal accepted() + signal stateChanged() + + Component.onCompleted: { + loadSettings() + } + + function close() + { + if (!setsFact.destroyOnClose) { + setsFact.menuBack() + return + } + + setsList.deleteChildren() + setsFact.menuBack() + } + + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child + } + + console.log(component.errorString()) + return null + } + + function createSet(setData) + { + var child = createFact(setsList, "MenuSet.qml", { + "data": setData, + "signalsModel": setsFact.signalsModel + }) + if (!child) + return null + + child.selected.connect(select) + child.stateChanged.connect(markChanged) + return child + } + + function activeSetIndex() + { + for (var i = 0; i < setsList.size; ++i) { + if (setsList.child(i).active) + return i + } + + return setsList.size > 0 ? 0 : -1 + } + + function activeSetFact() + { + var index = activeSetIndex() + return index >= 0 && index < setsList.size ? setsList.child(index) : null + } + + function activePages() + { + var activeSet = activeSetFact() + return activeSet && typeof activeSet.pageFacts === "function" ? activeSet.pageFacts() : [] + } + + function activeSetTitle() + { + var activeSet = activeSetFact() + return activeSet ? activeSet.title : "" + } + + function exportSettings() + { + var settings = { + "active": { + "signals": 0 + }, + "sets": [] + } + + for (var i = 0; i < setsList.size; ++i) { + var setEditor = setsList.child(i) + var setData = setEditor.save() + if (setData === null) + return null + + settings.sets.push(setData) + if (setEditor.active) + settings.active.signals = i + } + + if (settings.sets.length <= 0) { + var defaults = defaultSettings() + settings.sets = defaults.sets instanceof Array ? defaults.sets : [] + settings.active.signals = defaults.active ? defaults.active.signals : 0 + } + + return settings + } + + function markChanged() + { + if (loading) + return + + stateChanged() + } + + function loadTree(settings) + { + loading = true + setsFact.descr = setsFact.defaultDescr + setsList.deleteChildren() + + var sets = settings && settings.sets instanceof Array ? settings.sets : [] + var currentSetIdx = settings && settings.active ? settings.active.signals : 0 + + for (var i = 0; i < sets.length; ++i) + setsFact.createSet(sets[i]) + + setsFact.select(currentSetIdx) + loading = false + markChanged() + } + + function defaultSettings() + { + if (setsFact.signalsModel && typeof setsFact.signalsModel.createDefaultSettings === "function") + return setsFact.signalsModel.createDefaultSettings() + + return { + "active": { + "signals": 0 + }, + "sets": [] + } + } + + function loadSettings() + { + var settings = setsFact.defaultSettings() + if (setsFact.signalsModel) { + if (!setsFact.signalsModel.loaded && typeof setsFact.signalsModel.loadSettings === "function") + setsFact.signalsModel.loadSettings() + if (typeof setsFact.signalsModel.exportSettings === "function") + settings = setsFact.signalsModel.exportSettings() + } + + loadTree(settings) + } + + function saveSettings() + { + setsFact.descr = setsFact.defaultDescr + + var settings = exportSettings() + if (settings === null) { + setsFact.descr = qsTr("Fix editor errors before saving") + return + } + + if (setsFact.signalsModel && typeof setsFact.signalsModel.saveSettings === "function") + setsFact.signalsModel.saveSettings(settings) + + setsFact.accepted() + } + + function resetToDefaults() + { + var settings = exportSettings() + if (settings === null) + return + + var defaults = defaultSettings() + var defaultSet = defaults.sets instanceof Array && defaults.sets.length > 0 + ? defaults.sets[0] + : null + if (!defaultSet) + return + + var targetIndex = -1 + for (var i = 0; i < settings.sets.length; ++i) { + var title = String(settings.sets[i].title === undefined ? "" : settings.sets[i].title).trim().toLowerCase() + if (title === "default") { + targetIndex = i + break + } + } + + if (targetIndex < 0) { + settings.sets.unshift(defaultSet) + targetIndex = 0 + } else { + settings.sets[targetIndex] = defaultSet + } + + settings.active.signals = targetIndex + loadTree(settings) + } + + function select(num) + { + for (var i = 0; i < setsList.size; ++i) { + var setEditor = setsList.child(i) + setEditor.active = setEditor.num === num + } + + markChanged() + } + + Fact { + id: setsList + title: qsTr("Sets") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + } + + Fact { + title: qsTr("Add set") + descr: qsTr("Create a new chart set") + flags: Fact.Action + icon: "plus-circle" + onTriggered: { + var setTitle = setsFact.signalsModel && typeof setsFact.signalsModel.defaultSetTitle === "function" + ? setsFact.signalsModel.defaultSetTitle(setsList.size) + : qsTr("Set") + " " + (setsList.size + 1) + var child = setsFact.createSet({ + "title": setTitle, + "pages": [] + }) + if (child) { + child.trigger() + markChanged() + } + } + } + + Fact { + title: qsTr("Reset to defaults") + descr: qsTr("Replace the editor contents with the built-in default set") + flags: Fact.Action + icon: "restore" + onTriggered: setsFact.resetToDefaults() + } + + Fact { + title: qsTr("Save") + descr: qsTr("Save chart sets") + flags: (Fact.Action | Fact.Apply) + icon: "check-circle" + onTriggered: setsFact.saveSettings() + } +} diff --git a/src/Plugins/Tools/Signals/PageButton.qml b/src/Plugins/Tools/Signals/PageButton.qml new file mode 100644 index 000000000..871e29b1c --- /dev/null +++ b/src/Plugins/Tools/Signals/PageButton.qml @@ -0,0 +1,72 @@ +pragma ComponentBehavior: Bound + +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls.Material + +import Apx.Common + +ValueButton { + id: control + + property bool selected: false + property bool pinned: false + property bool pageWarning: false + + hoverEnabled: true + checkable: true + checked: selected + active: selected + + showValue: false + alerts: false + warning: pageWarning + normalColor: pinned ? "#4a4a22" : "#202020" + textBold: selected + textScale: 0.62 + minimumWidth: Math.max(28, height * 1.15) + maximumWidth: Math.max(minimumWidth, height * 3.0) + toolTipItem.delay: 500 + + onTriggered: checked = true + + Rectangle { + anchors.fill: parent + radius: 4 + color: "transparent" + border.width: control.pinned ? 1 : 0 + border.color: control.pageWarning + ? Material.color(Material.Orange) + : Material.color(Material.BlueGrey) + } + + MaterialIcon { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 2 + visible: control.pinned + name: "pin" + size: Math.max(8, control.height * 0.34) + color: Material.color(Material.LightBlue) + } +} diff --git a/src/Plugins/Tools/Signals/README.md b/src/Plugins/Tools/Signals/README.md index fe4294aa8..9532af2a7 100644 --- a/src/Plugins/Tools/Signals/README.md +++ b/src/Plugins/Tools/Signals/README.md @@ -4,4 +4,63 @@ page: plugins # Signals -QML widget to show live chart of defined UAV physical values for easy tuning. +Configurable telemetry chart plugin for APX GCS. + +## Current behavior + +- Saved configurations live in `signals.json` as sets of pages and items. +- The bottom tab row uses `SignalButton`: single click switches pages, and double-click or long-press opens that page editor. +- The `+` button opens the full editor for sets, pages, items, colors, filters, and save targets. +- Pinned pages are rendered as stacked charts above the main chart inside the same widget. +- Page tabs highlight active warnings and their tooltips include the page items plus any warning messages. +- Chart overlays use `apx.font_narrow`: + - top-right page name on every chart + - speed label directly below the page name when the page speed is not `1x` + - bottom-right active set name on the main chart +- Page speed is persisted per page with the allowed values `0.2x`, `0.5x`, `1x`, `2x`, `4x`. +- Speed can be changed in two places: + - in the page editor as an enum-like selector + - by clicking the chart itself, which cycles the page speed + +## Item model + +Each chart item stores: + +- `bind` +- `color` +- `filters` +- `warning` +- `save` + +Notes: + +- Item labels are always derived from `bind`. +- Simple mandala picks are normalized to bare paths such as `est.att.roll` instead of `mandala.est.att.roll.value`. +- `save` targets use the same normalization and must stay under `sns.scr.*`. + +## Filters + +Filters are edited inline from the item editor, without a separate custom filter page. + +Current filter types: + +- `running_avg` (`Running average`) +- `kalman_smp` (`Simple Kalman`) + +The filter stack is ordered and draggable. A single `Add filter` action appends a generic filter row that owns: + +- an enable toggle +- a filter `type` selector +- a dynamically loaded settings section for the selected filter type + +The concrete math and parameter fields live in dedicated settings facts such as `FilterRunningAverage.qml` and `FilterKalman.qml`, loaded through `FilterFact.qml`. The rendered series value and the optional `save` output both use the filtered result. + +## Persistence + +`SignalsModel.qml` owns persistence and legacy migration. + +- New format: `{ active: { signals }, sets: [...] }` +- Legacy format accepted on load: `{ page, signalas }` +- Missing or empty settings regenerate the built-in default set + +There is no hard page limit in the current editor. diff --git a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md new file mode 100644 index 000000000..681b750ef --- /dev/null +++ b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md @@ -0,0 +1,166 @@ +# Signals Refactor Notes + +Current implementation snapshot for `src/Plugins/Tools/Signals/`. + +## Runtime architecture + +- `SignalsPlugin.qml` + - Registers the plugin and mounts `Signals {}`. +- `Signals.qml` + - Hosts `SignalsModel`. + - Renders original-style `SignalButton` page tabs. + - Opens the full editor from the `+` button. + - Renders pinned charts above the main chart. + - Cycles page speed when the user clicks a chart. + - Shows chart overlays with `apx.font_narrow`: + - top-right page title on all charts + - speed label below the page title only when speed is not `1x` + - bottom-right active set label on the main chart +- `SignalsView.qml` + - Owns the chart renderer. + - Builds series from page items. + - Reads raw samples from live page items. + - Delegates filter execution to each item's `updateFilters()` chain. + - Writes filtered values to `sns.scr.*` save targets. + - Preserves `cmd.*` desaturated styling and grow-only Y-axis behavior. +- `SignalButton.qml` + - Remains the active tab-button implementation. + - Single click switches pages; double-click / long-press opens that page editor. + - Surfaces warning state through tab color and tooltip content. + +## Persistence and model + +- `SignalsModel.qml` + - Owns `signals.json` load/save. + - Persists `{ active: { signals }, sets: [...] }`. + - Migrates legacy `{ page, signalas }` on load. + - Regenerates the built-in default set when settings are missing or empty. + - Exposes page-speed helpers and page mutation helpers. + +Current page/item schema: + +```json +{ + "title": "default", + "pages": [ + { + "name": "R", + "pin": false, + "speed": 1.0, + "items": [ + { + "bind": "est.att.roll", + "color": "", + "filters": [], + "warning": "", + "save": "" + } + ] + } + ] +} +``` + +Notes: + +- There is no hard page limit in the current editor. +- Simple mandala paths are normalized to bare paths such as `est.att.roll`. +- The same normalization is applied to `save` targets. + +## Editor tree + +- `MenuSets.qml` + - Root sets editor. + - Add/remove/reorder/select sets. + - Reset to defaults. + - Save the full chart configuration. +- `MenuSet.qml` + - One set editor. + - Set name, page list, add/remove pages, save action. + - No page-limit enforcement. +- `MenuPage.qml` + - One page editor. + - Page name, pin toggle, editable enum-like speed selector, item list. +- `MenuItem.qml` + - One item editor. + - No `title` field. + - No `act` field. + - Fields: binding, color, filters, warning, save target. + - Nested save actions stay visible while editing. +- `ColorChooser.qml` + - 12x4 Material palette plus hex input. +- `FilterFact.qml` + - Generic filter row with enable toggle, type selector, save/remove actions, and a dynamic settings section. +- `FilterBase.qml` + - Shared base for the type-specific settings Fact loaded inside `FilterFact.qml`. +- `FilterRunningAverage.qml`, `FilterKalman.qml` + - Dedicated filter settings Facts; each file owns its own editor fields and runtime math. +- `FilterRegistry.qml` + - Filter defaults, titles, type normalization, and component lookup. + +## Filter implementation + +Current filter types: + +- `running_avg` +- `kalman_smp` + +Current pattern: + +- Filters are generic `FilterFact.qml` rows inside the item’s `Filters` group. +- One generic `Add filter` action appends a new wrapper row with the registry default type. +- Each filter is ordered and draggable. +- Each filter has an enable toggle on the row itself. +- Each filter row owns the enable/type shell and swaps the loaded settings Fact when the type changes. +- Each loaded settings Fact owns the runtime state and `update()` implementation. +- `MenuItem.qml` applies the live filter chain; `SignalsView.qml` only provides raw samples. + +## Current UX decisions + +- `+` opens the full Signals editor, not a quick-bind field. +- Single click on a tab switches pages. +- Double-click or long-press on a tab opens that page editor. +- Pin actions remain in the page editor; there is no tab pin control. +- Pinned pages are controlled from the page editor. +- Page-tab tooltips list item labels and active warning messages. +- Warning state colors the affected tab. +- Speed is per page. +- The dedicated bottom speed button was removed. +- Clicking a chart cycles that page’s speed. +- The speed label is only shown when the page speed is not `1x`. +- Save targets must stay under `sns.scr.*` and are deduplicated across the active set. +- Only the warning expression is kept. There is no separate alarm field. + +## File status + +| File | Current role | Status | +| --- | --- | --- | +| `Signals.qml` | Runtime shell and overlays | active | +| `SignalsModel.qml` | persistence and runtime helpers | active | +| `SignalsView.qml` | chart renderer and filter execution | active | +| `SignalButton.qml` | page tabs | active | +| `MenuSets.qml` | sets editor root | active | +| `MenuSet.qml` | set editor | active | +| `MenuPage.qml` | page editor | active | +| `MenuItem.qml` | item editor | active | +| `ColorChooser.qml` | color editor page | active | +| `FilterFact.qml` | generic filter shell | active | +| `FilterBase.qml` | shared base for filter settings facts | active | +| `FilterRunningAverage.qml` | running average settings + runtime | active | +| `FilterKalman.qml` | Kalman settings + runtime | active | +| `FilterRegistry.qml` | filter metadata and normalization | active | +| `PageButton.qml` | old experiment from an earlier tab rewrite | currently unused | + +## Documentation sync + +The markdown files should reflect these current decisions: + +- no page limit +- no bottom speed button +- chart click cycles speed +- speed label sits below the page title and is hidden at `1x` +- `SignalButton.qml` remains the active tab path +- tab double-click / long-press opens the page editor +- page tabs surface warning state and warning text +- item schema is `bind/color/filters/warning/save` +- filters use a generic wrapper row with a type selector that swaps dedicated settings facts diff --git a/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md b/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md new file mode 100644 index 000000000..85c2eaf31 --- /dev/null +++ b/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md @@ -0,0 +1,294 @@ +# VSCode Copilot Agent Prompt — Signals Plugin Upgrade + +Paste this entire prompt into your Copilot chat (Agent mode) inside the `apx-gcs` repo working tree. It describes the full feature set you will add to the existing `Signals` plugin, the reference code you should study, and concrete implementation rules. + +**Starting point:** the `main` branch of `uavos/apx-gcs`. You are extending the existing `Signals` plugin in-place. There is no other branch, plugin, or prior contribution you need to consult — everything needed is described below. + +**Important: do not run any git operations.** No `git add`, `git commit`, `git checkout`, `git branch`, `git mv`, `git rm`, `git rebase`, `git push`, `git stash`, `git restore`. No creating branches. No opening pull requests. Only modify the working tree. The human will handle all version control afterwards. + +--- + +## Context for the agent + +You are working in the **uavos/apx-gcs** repository (APX Ground Control Station, Qt 6 / QML / C++). The `Signals` plugin at `src/Plugins/Tools/Signals/` is a telemetry chart widget. Today it is hardcoded — fixed tab buttons `R / P / Y / Axy / Az / G / Pt / Ctr / RC / Usr / +` with fixed mandala bindings, a single global speed setting, no per-series filtering, no multiple configurations. It works, but every vehicle / airframe / mission needs its own chart setup, and operators currently have no way to save or switch those configurations. + +Your job is to turn `Signals` into a configurable plugin with the same ergonomic model the `Numbers` plugin already uses elsewhere in the codebase: **sets** of **pages** of **items**, fully user-editable, persisted to JSON. On top of that, each item gets an ordered **filter stack** (e.g. running-average then Kalman) so operators can smooth noisy telemetry before plotting and, optionally, write the filtered signal back into the mandala for use by other plugins. + +The end result: one plugin, one widget, no hardcoded buttons, no new dependencies, fully backward-compatible with any existing `signals.json` on disk. + +### Design philosophy + +- **Mirror Numbers.** The sets/pages/items editor pattern, the JSON shape, the Fact-tree wiring — all of it should look and feel identical to `Numbers`. Operators already know how Numbers works; Signals should feel like the same tool applied to charts instead of readouts. +- **Filters are plain Facts, not a custom editor page.** Model the filter stack like `datalink/ports` — an ordered, reorderable list of child Fact groups with add/remove actions, a bool on/off switch on each filter group row, and child parameter Facts shown/hidden from the selected filter `type`. +- **QML only.** No C++ changes. The plugin is QML today and will stay QML. + +### Clarifications from implementation review + +- **Keep the page tabs visually and behaviorally the same as the original `Signals` plugin.** Tabs only switch pages. Do not overload them with edit affordances, pin actions, long-press behavior, or extra icons. +- **The `+` button opens the full Signals editor** (sets/pages/items), same entry-point concept as `Numbers`. There is no separate sets-editor button. +- **Items do not have `title` or `act` fields.** Series labels are always derived from `bind`. +- **Normalize simple mandala paths.** If the user picks `mandala.est.att.roll.value`, store and display `est.att.roll`. Apply the same rule to `save` targets. +- **The save target behaves like the binding picker.** Use a mandala-typed Fact selector, not a duplicate free-text “save path” row. +- **Pinned charts are stacked above the main chart inside the same widget.** Pinned charts show only a top-right page-name label. The main chart shows a top-right page-name label and a bottom-right set-name label. All these overlay labels use `apx.font_narrow`. +- **There is no hard page limit.** Do not cap the number of pages per set in the editor or model. +- **There is no bottom speed button.** Clicking a chart cycles that page's speed. The speed label is shown directly below the page title and only when the page speed is not `1x`. +- **Save must be reachable from nested editor pages.** A user drilling into set/page/item/color/filter pages should still have a visible `Save` action without backing all the way out. + +--- + +## Repository files you MUST read before writing any code + +Open each of these in the editor and understand them. The two blocks below are your entire study set. + +### The Signals plugin (what you will modify) + +- `src/Plugins/Tools/Signals/SignalsPlugin.qml` — AppPlugin registration. +- `src/Plugins/Tools/Signals/Signals.qml` — top-level widget: page tabs, `+` editor button, chart-click speed control, pinned-chart stack, main-chart page/set labels, and the chart area. This is the file most affected by the refactor. +- `src/Plugins/Tools/Signals/SignalsView.qml` — QtCharts renderer. Handles series creation, series colors (including the desaturated rendering for `cmd.*` facts), axis auto-rescale, speed-factor timing. +- `src/Plugins/Tools/Signals/SignalButton.qml` — the authoritative tab/toggle button. Keep the page-tab interaction and visual behavior aligned with this original component. +- `src/Plugins/Tools/Signals/FilterRegistry.qml` — filter-type registry, defaults, normalization helpers. +- `src/Plugins/Tools/Signals/FilterFact.qml` — the plain Fact-based filter row used in the filter stack. +- `src/Plugins/Tools/Signals/CMakeLists.txt` — resource list; every new QML file you add must be listed here under `QRC_QML`. + +### The Numbers plugin (the canonical pattern to mirror) + +- `src/main/qml/Apx/Controls/numbers/NumbersModel.qml` — loads `numbers.json`, builds `NumbersItem` objects, exposes `edit()` → `NumbersMenuPopup`. This is the template for the new `SignalsModel.qml`. +- `src/main/qml/Apx/Controls/numbers/NumbersMenu.qml` — Fact-tree sets editor with `loadSettings()`/`saveSettings()`, "Add set" action, `select(num)` radio behavior, and the `json.active[settingsName]` active-index preservation logic. Template for the new `MenuSets.qml`. +- `src/main/qml/Apx/Controls/numbers/NumbersMenuSet.qml` — per-set editor with `setTitle`, a `NumbersMenuNumber` child template, and a draggable children list. Template for the new `MenuSet.qml`. +- `src/main/qml/Apx/Controls/numbers/NumbersMenuNumber.qml` — per-item editor exposing `bind`, `title`, `prec`, `warn`, `alarm`, `act`. Template for the new `MenuItem.qml`, but Signals drops `title`, `alarm`, and `act`; you will add `color`, `filters`, and `save` instead. +- `src/main/qml/Apx/Controls/numbers/NumbersMenuPopup.qml`, `NumbersItem.qml`, `NumbersBar.qml`, `NumbersBox.qml` — supporting components. Skim them; reuse their patterns rather than inventing new ones. + +### Shared components + +- `src/main/qml/Apx/Common/` — skim `TextButton`, `IconButton`, `ValueButton`, `FactButton`, `Style`. Reuse these; do not invent new primitives. +- Find the `datalink/ports` implementation (search under `src/Plugins/Protocols` or `src/main/qml/Apx/Menu` for `ports`). Read it carefully — it's the model for the per-item filter list: ordered, draggable, typed sub-Facts, each with an enable toggle. Note how it handles add/remove/reorder and how the typed subtree is rendered. + +--- + +## Terminology (use these exact names in code, JSON, and UI) + +- **Set** — top-level saved group, same concept as a Numbers set. One set = one complete chart configuration, typically tied to a specific system or airframe. Multiple sets can exist (e.g. `default`, `HAPS`, `R22`, …); exactly one is active at a time. +- **Page** — a named page inside a set. Each page is one tab in the widget's tab bar. Replaces today's hardcoded `R / P / Y / …` buttons. +- **Item** — one plotted variable on a page. An item has: expression (`bind`), `color`, an ordered `filters` list, an optional `warning` expression, and an optional `save` target (a mandala variable under `sns.scr.*`). The displayed series name is always derived from `bind`. +- **Filter** — one processing node inside an item's filter stack. A filter has a `type` (from an enum), an `enabled` toggle, a position in the stack (user-reorderable), and its own typed parameter subtree. Supported types at launch: `running_avg`, `kalman_smp`. + +--- + +## Functional spec — what the upgraded `Signals` plugin must do + +### 1. Sets editor (mirror Numbers exactly) + +- Provide a Numbers-style sets editor reachable from the widget's `+` button (see §3 for the exact UI). Users can add / rename / delete / reorder sets; exactly one set is active at any time. +- Persist to a single JSON file in `application.prefs` named `signals.json`, using the same `{ active, sets }` structure as `numbers.json`. `active.signals` is the index of the active set. See the JSON schema section further down for the full shape. +- On first run (no `signals.json` on disk), auto-generate a **default set** that reproduces today's hardcoded Signals configuration verbatim: pages `R`, `P`, `Y`, `Axy`, `Az`, `G`, `Pt`, `Ctr`, `RC`, `Usr` with the same mandala bindings each of those hardcoded buttons currently wires up. Read the existing `Signals.qml` to extract those bindings exactly — this is how you preserve behavior for existing users. +- Provide a "Reset to defaults" action in the sets editor that regenerates the default set even if other sets already exist. The default set must never be silently deleted when the user has zero sets — in that case, auto-recreate it. +- When the active set changes, pages and items must be rebuilt from that set's contents. + +### 2. Pages inside a set + +- Each set holds an ordered list of pages with no hard editor cap. +- A page has: `name` (string, user-editable; suggested default when creating a new page = uppercase first character of the first item's bind, e.g. `est.att.roll` → `R`), `pin` (bool, optional — see §6), `speed` (float, per-page — see §7), and an `items` array. +- Tab buttons at the bottom of the widget show the page `name`. On hover, each tab shows a tooltip listing the page name and each item's derived label (`bind`) + color swatch, so operators can see at a glance what's on a page without switching to it. +- A warning raised by any item on a page (see §4) must visually highlight that page's tab button using the same `ValueButton.alerts` style used elsewhere, and the tooltip should include the warning message. + +### 3. Page bar layout (replaces today's hardcoded button row) + +The bottom bar and chart overlays should behave like this: + +- **Bottom bar, left / center:** the page tabs, one per page of the active set. Keep them behaving like the original Signals plugin: clicking a tab only switches the chart page. +- **Bottom bar, right:** a `+` icon button (`IconButton { iconName: "plus" }`) that opens the full Signals editor (sets/pages/items). +- **Main chart, top-right:** the active page name, with the speed label directly below it when the page speed is not `1x`. +- **Main chart, bottom-right:** the active set name. +- **Pinned charts, top-right:** the pinned page name, with the speed label directly below it when the page speed is not `1x`. +- **Chart click:** clicking a chart cycles that page's speed. + +There is no separate sets-editor button and no clickable set-name badge. The old `+` tab with its inline text-input `bind` shortcut is removed entirely. + +### 4. Items inside a page + +- Each item has these fields: + - `bind` — JS expression over the mandala, required (e.g. `est.att.roll`, `cmd.rc.roll`, or a computed expression). If the user chooses a simple mandala fact from the selector, store/display it as `xxx.yyy.zzz`, not `mandala.xxx.yyy.zzz.value`. + - `color` — hex string, optional. If empty, fall back to the palette algorithm today's `Signals.qml` uses: `Material.color(Material.Blue + i*2)` where `i` is the item index within the page. For `cmd.*` bindings keep the desaturated / lightened series rendering already in `SignalsView.addFactSeries`. + - `filters` — ordered list of filter instances, can be empty. See §5. + - `warning` — optional JS expression. When it evaluates truthy, the owning page tab is highlighted and the message is surfaced in the tab tooltip. + - `save` — optional mandala variable name. Must begin with `sns.scr.` — reject anything else with a user-visible warning in the editor. This field behaves like the binding picker: it is a mandala-typed Fact selector and stores the normalized bare path. +- The per-item editor is built on the `NumbersMenuNumber` structure, but Signals does **not** expose `title` or `act`. Add `color` (via the color chooser — see §8), `filters` (inline fact children — see §5), and `save` (mandala-picker field with the `sns.scr.` guard and the dedup check described in §9). Do not add a duplicate free-text “save path” row. + +### 5. Filter stack (ordered list of filters per item) + +- Each item owns an ordered list of filter instances. The list can be empty (no filtering). Multiple filters run in sequence: telemetry tick → filter 0 → filter 1 → … → rendered value (and, if `save` is set, written back to the mandala). +- Model the list on `datalink/ports`: a `Filters` group Fact whose children are the filters, each draggable to reorder, each with an `enabled` toggle on the filter group row itself, and each with a typed parameter subtree decided by its `type` enum. +- Filter types at launch: + - **`running_avg`** — single-pole running average with coefficient `coef ∈ [0, 1]`. Update rule: `out = out * (1 - coef) + in * coef`. On the first sample, initialize `out = in`. Reasonable default: `coef = 0.2`; precision / slider granularity should match `NumbersMenuNumber.prec` conventions. + - **`kalman_smp`** — simple 1-D Kalman smoother. Parameters: `r` (measurement noise, default `0.1`) and `q` (process noise, default `0.001`). State and covariance are internal; on the first sample, seed state from the raw value and covariance to `0.1`. Standard scalar Kalman update on each subsequent sample. +- Do **not** build a separate custom filter editor page. Use plain Facts only. A filter row is a `Fact.Group | Fact.Bool` subtree with normal add/remove actions. The `type` Fact is enum-like, and the parameter Facts (`coef`, `r`, `q`, …) are ordinary child Facts shown/hidden from the selected `type`. +- The architecture must still make adding a third filter type trivial: update the filter-type registry and add the parameter subtree / runtime logic for the new type in the same plain-Fact pattern. + +### 6. Pinned pages (stacked view) + +- Each page has an optional `pin` boolean. When **no** pages are pinned, the widget behaves as a classic single-chart tabbed view: clicking a tab swaps the chart. +- When **one or more** pages are pinned, the widget switches to a stacked layout: every pinned page renders its own chart view, vertically stacked in a `ColumnLayout`, all visible simultaneously. Non-pinned pages remain accessible via their tabs and appear in the single remaining tabbed slot below (or above) the pinned stack — pick one placement and be consistent. +- Pinned charts must not grow extra controls. Each pinned chart shows only the chart plus a small top-right page title label. +- The main chart also shows a top-right page title label, and additionally a bottom-right active-set label. +- Use `apx.font_narrow` for all these chart overlay labels. +- Pinning / unpinning is a page-level toggle in the page editor. Do not add pin actions to the page tabs. + +### 7. Speed control (per page, persisted) + +- Each page has its own `speed` value, a direct float factor from the set `[0.2, 0.5, 1, 2, 4]`. This factor controls the chart timing (x-axis tick rate) — same meaning as today's speed control, just with a wider, explicit value set so long-term recording at `0.2` / `0.5` is possible. +- The page editor exposes speed as an enabled enum-like selector for the page's default speed. +- Clicking a chart cycles that page to the next speed value in the list (wrapping from `4` back to `0.2`). +- The speed label is rendered as `{value}x` (e.g. `0.5x`, `1x`, `2x`) and is shown directly below the page title only when the page speed is not `1x`. +- The current speed is persisted per page inside `signals.json` and restored on reload. Changing speed on one page does not affect any other page. +- In the pinned-stack layout, each pinned page's chart uses its own stored speed, and clicking a pinned chart cycles only that pinned page. + +### 8. Colors + +- Default item colors follow the palette algorithm already in `Signals.qml`: `Material.color(Material.Blue + i*2)` with `i` = item index on its page. +- Users may override per item via a color chooser. Implement the chooser as a compact 12×4 palette grid (48 Material colors) plus a hex text input for custom values. The color chooser opens from the item editor's color field. +- For `cmd.*` bindings, keep the existing desaturated / lightened rendering from `SignalsView.addFactSeries` — that rendering distinguishes commanded values from estimated values at a glance. + +### 9. Save-to-mandala + +- When an item has a non-empty `save`, the item's **filtered output** (post-filter-stack) is written to the named mandala variable on every tick, using the existing pattern: `apx.fleet.current.mandala.fact(fname, true).setRawValueLocal(value)`. +- The editor must enforce that `save` begins with `sns.scr.` (the `scr` = "scripting" namespace reserved for plugin-produced values). Anything else is rejected with a user-visible warning message. +- The editor must also dedup: within the active set (across all pages, all items), no two items may share the same `save` target. If the user tries to save to a name already used elsewhere, show a warning and block the save. +- The save selector must normalize simple mandala picks the same way `bind` does: `mandala.sns.scr.foo.value` is stored/displayed as `sns.scr.foo`. + +### 10. Chart rendering + +- Keep one chart renderer: the current `SignalsView.qml`. Adapt it to accept its data from the active set's active page (or, in pinned-stack mode, to be instanced per pinned page) rather than from hardcoded `SignalButton` children. +- Required adaptations: + - Rebuild series when the active page / set changes, when items are added / removed / reordered, or when an item's `bind` or `color` changes. Use a `resetEnable` / dirty-flag pattern so rebuilds happen exactly once per user action. + - Support live color edits: editing an item's color updates that series' pen without a full rebuild (an `updateSeriesColor(index, color)` method on the chart view). + - Support the `[0.2, 0.5, 1, 2, 4]` speed factor array per-page (see §7). + - Keep the existing auto-rescale-grow behavior (axis grows, never shrinks during a recording session). + - For items with a non-empty filter stack, render the **filtered** value on the chart, not the raw one. The raw stream is only visible if the user explicitly adds a second item with the same `bind` and an empty filter list. + - Preserve the original page-switch workflow driven by `SignalButton.qml`. + - Overlay labels are owned by `Signals.qml`: top-right page label on every chart, bottom-right set label on the main chart, all using `apx.font_narrow`. + +--- + +## Non-functional requirements + +1. **No C++ changes.** Everything is QML, under `src/Plugins/Tools/Signals/` and (where you need a truly shared primitive that doesn't already exist) `src/main/qml/Apx/Common/`. If you feel a C++ change is unavoidable, stop and ask first. +2. **Backward-compatible migration.** Existing `signals.json` files may use older shapes. Specifically, any legacy file with top-level keys `page` (string) and `signalas` (array — note the typo on the old key, read it as-is) must be migrated silently on first load: wrap the legacy list into a single set named `default` with a single page whose `name` is the old `page` value (or `page 1` if missing), and whose `items` are the legacy list entries mapped field-for-field. Always write the new key `signals` (correctly spelled) on save. Never lose user data. Never show a migration dialog — the migration is invisible to the user. +3. **Localize every user-visible string with `qsTr()`.** Include the English source text inline. Match the style of the existing Signals and Numbers QML for consistency with existing translations. +4. **QML style.** 4-space indent, no tabs. Match surrounding file style. Keep `import` order consistent with sibling files. Copy the LGPL-2.1 header from `SignalsPlugin.qml` onto every new file you create. +5. **CMakeLists.** Every new QML file must be listed under `QRC_QML` in `src/Plugins/Tools/Signals/CMakeLists.txt`. Every deleted file must be removed from that list. +6. **No new third-party dependencies.** Only Qt modules already used by the project: `QtQuick`, `QtQuick.Controls`, `QtQuick.Layouts`, `QtCharts`, `QtQml.Models`, `Apx.Common`, `Apx.Controls`, `APX.Facts`, `APX.Fleet`, `APX.Mandala`. +7. **`qmllint` clean.** Every new and modified QML file must pass `qmllint` with no warnings. Report any warnings you cannot silence in your progress summary. +8. **Simulator smoke test after the upgrade.** Build the project, run GCS against the built-in simulator, and verify: (a) a fresh profile auto-generates the default set with all old Signals pages populated; (b) create a second set with two pages (one pinned, one not) and a handful of items, including at least one item with a two-filter stack `running_avg → kalman_smp` and one item with `save = sns.scr.`; (c) click each chart to set a different page speed; (d) restart the app; (e) confirm all of it persisted exactly; (f) confirm the `sns.scr.*` variable is receiving filtered values visible to another plugin (e.g. Numbers). + +--- + +## JSON schema for `signals.json` + +```json +{ + "active": { + "signals": 0 + }, + "sets": [ + { + "title": "default", + "pages": [ + { + "name": "attitude", + "pin": false, + "speed": 1.0, + "items": [ + { + "bind": "est.att.roll", + "color": "#2196F3", + "filters": [ + { "type": "running_avg", "enabled": true, "coef": 0.2 }, + { "type": "kalman_smp", "enabled": false, "r": 0.1, "q": 0.001 } + ], + "warning": "", + "save": "" + } + ] + }, + { + "name": "power", + "pin": true, + "speed": 0.5, + "items": [ + { "bind": "est.pwr.vbat", "color": "#FFC107", "filters": [], "save": "sns.scr.vbat_f" } + ] + } + ] + } + ] +} +``` + +- `active.signals` is the index into `sets` of the currently active set (mirrors the `active.numbers` convention in `numbers.json`). +- Every field other than `bind` is optional; readers must tolerate missing keys and default them sensibly. +- Legacy files with top-level `page` (string) and `signalas` (array) are migrated silently on load — see non-functional requirement 2. + +--- + +## Step-by-step plan the agent should follow + +Work through these in order. Do not skip the inventory and design steps. **Do not commit anything.** Just modify files in the working tree. After each numbered step, produce a short progress summary (files added / modified / deleted + a one-sentence description) and stop for review — the human will decide when to commit. + +1. **Inventory.** Read every file in `src/Plugins/Tools/Signals/` and `src/main/qml/Apx/Controls/numbers/` in full. Locate and read the `datalink/ports` implementation (search under `src/Plugins/Protocols` and `src/main/qml/Apx/Menu`). Create a short `REFACTOR_NOTES.md` inside `src/Plugins/Tools/Signals/` listing, for each existing Signals file: what it does today, what will happen to it (keep / modify / delete), and which Numbers file you will model the replacement on. This doc is working scratch — keep it updated as you go, delete it in the final cleanup step. + +2. **Design sketch.** Before writing any QML, in `REFACTOR_NOTES.md`, sketch the new Fact tree for Signals: `Signals (plugin) → Sets (list) → Set → Pages (list) → Page → Items (list) → Item → Filters (list) → Filter`. Write down exactly which new QML file implements each level, which existing Numbers file it is modeled on, and which existing Signals file it replaces. Confirm the structure matches the Numbers pattern (`NumbersModel` → sets → items, with the same `active.` persistence convention). + +3. **Data model first.** Create `src/Plugins/Tools/Signals/SignalsModel.qml`, modeled on `NumbersModel.qml`. It owns the Fact tree (`active`, `sets`), the persistence (`loadSettings()` / `saveSettings()` reading and writing `signals.json` under `application.prefs`), and the legacy migration logic for old `{ page, signalas }` files. At this point you have no UI yet — only the model. Write a small comment block documenting the JSON schema and the migration. Verify load / save against a synthetic `signals.json` in the simulator before moving on. + +4. **Default-set factory.** Inside `SignalsModel.qml` (or a sibling helper file), implement the default-set generator that is invoked when `signals.json` is missing or has zero sets. The default set must exactly reproduce today's hardcoded Signals configuration: one page per hardcoded button (`R`, `P`, `Y`, `Axy`, `Az`, `G`, `Pt`, `Ctr`, `RC`, `Usr`), each page populated with the same mandala bindings today's `Signals.qml` wires into those buttons. Extract those bindings from the current `Signals.qml` source — don't guess. Each default page starts with `pin = false`, `speed = 1.0`, and an empty `filters` list on every item. + +5. **Sets / page / item editors.** Create the editor QML files: + - `MenuSets.qml` — modeled on `NumbersMenu.qml`. Lists all sets, supports add / rename / delete / reorder, "Reset to defaults" action, persists `active.signals`. + - `MenuSet.qml` — modeled on `NumbersMenuSet.qml`. Per-set editor: `title`, draggable pages list, "Add page" action, no page cap. + - `MenuPage.qml` — per-page editor: `name`, `pin` toggle, `speed` (enabled enum-like selector), draggable items list, "Add item" action. + - `MenuItem.qml` — modeled on `NumbersMenuNumber.qml`, but without `title` / `act`; extend it with `color` (opens color chooser), inline `filters`, and `save` (mandala picker with `sns.scr.` guard and dedup check). + - `ColorChooser.qml` — 12×4 palette grid plus hex text input. + - Ensure nested editor pages expose a visible `Save` action so the user can save while editing a set, page, item, color, or filter. + +6. **Filter stack.** Implement the filter list: + - `FilterRegistry.qml` — filter types, titles, defaults, normalization, and registry helpers. + - `FilterFact.qml` — one plain Fact-based filter row: `Fact.Group | Fact.Bool`, enum-like `type`, child parameter Facts shown/hidden from `type`, `reset()`, `step(in)`, and a remove action. + - The item editor owns a `Filters` group Fact that holds `FilterFact` children, supports drag reorder, and exposes an `Add filter` action. + - Wire the filter chain into the chart pipeline: the chart series for an item with filters renders post-filter values. The filters are reset whenever the item is edited or the series is rebuilt. + +7. **Widget shell.** Rewrite `Signals.qml` (and / or split it into a container + tab bar + chart area) so it renders the active set: + - Bottom bar: original-style `SignalButton` page tabs from `sets[active].pages` and the `+` icon button on the right (opens the full editor). + - Chart area: single `SignalsView` in the no-pins case; `ColumnLayout` of `SignalsView` instances in the pinned-pages-exist case. + - Chart click: reads / writes the clicked page's `speed`; cycles through `[0.2, 0.5, 1, 2, 4]`. + - Page-tab warning state: per-item `warning` expressions are evaluated and the owning tab is highlighted when truthy. + - Overlay labels: top-right page label plus conditional speed label on the main chart, top-right page label plus conditional speed label on each pinned chart, bottom-right active-set label on the main chart. Use `apx.font_narrow`. + +8. **Chart renderer adaptation.** Modify `SignalsView.qml` to take its series definitions from a page model (list of items with `bind` / `color` / filter chain) rather than from hardcoded `SignalButton` children. Add `updateSeriesColor(index, color)` for live color edits. Wire per-page `speed`. Preserve the existing `cmd.*` desaturated rendering and the auto-rescale-grow behavior. Support the save-to-mandala write on every tick for items with a non-empty `save`. + +9. **Cleanup.** Keep `SignalButton.qml` as the tab interaction/style baseline. If `PageButton.qml` or other experimental tab components were added during refactor attempts and end up unused, remove them. Remove `REFACTOR_NOTES.md`. Update `src/Plugins/Tools/Signals/CMakeLists.txt`: add every new QML file, remove every deleted file. Refresh the plugin `README.md` (if present; create one if not) with a short description of the new capabilities and a pointer to the JSON schema. + +10. **Build + smoke.** `cmake --build` the project. Fix any QML warnings from `qmllint`. Run the simulator smoke test described in non-functional requirement 8. If any step fails, fix it and rerun. Do not hand off with a broken build. + +11. **Hand-off.** Stop. Do not create a branch, do not commit, do not open a PR. Produce a final summary containing: (a) the complete list of files added, modified, and deleted; (b) a suggested PR title and description the human can paste when they open the PR themselves (suggested title: "Signals plugin: sets / pages / items editor with filter stack"); (c) a brief screenshot / gif checklist the human should attach to the PR from the simulator smoke test. + +--- + +## Open questions to flag + +If any of these becomes a real blocker while coding, stop and ask before guessing. Otherwise default as specified. + +1. **Speed scope.** The spec says speed is per page. If during implementation it becomes clear operators would prefer one speed per set, flag it. Default: per page. +2. **Legacy `signalas` typo.** Accept the old key on load, always write `signals` on save, no user-visible migration notice. Default: silent. +3. **Free-text bind shortcut.** Today's `+` tab has an inline text-input `bind` shortcut. The full Signals editor replaces it. If, during smoke testing, it turns out the quick inline entry is actually useful for operators, flag it as a small follow-up — don't add it back unprompted. Default: remove. + +--- + +## Constraints on your behavior + +- **No new third-party dependencies.** Only Qt modules already referenced by the project. +- **No unrelated file changes.** Exception: if you notice an obvious typo (a comment misspelling, a stale TODO) *in a file you are already editing for this task*, fix it inline. Do not go typo-hunting through the rest of the codebase. +- **No C++ signature changes** anywhere — not in `PluginInterface`, `Fact`, `AppRoot`, or any other C++ file. This is a QML-only task. +- **Ask only on real conflicts.** Do not ask for permission for obvious style, naming, or micro-layout choices — pick the one that matches the surrounding code. +- **Terse progress updates.** After each of the 11 steps, report: files touched, approximate lines added / removed, one-sentence summary of what now works. No essays, no recaps of the prompt. +- **No git operations, at all.** No `git add`, `git commit`, `git checkout`, `git branch`, `git mv`, `git rm`, `git rebase`, `git cherry-pick`, `git push`, `git stash`, `git restore`. Renames are plain filesystem copy-then-delete. Deletions are plain `rm`. The only acceptable git usage is **read-only inspection** (`git log`, `git diff`) to orient yourself in the repo. +- **`qmllint` clean, step by step.** Run `qmllint` on every new or modified QML file as you go, not just at the end. Report warnings in the step summary. Do not worry about committing — the human handles that. diff --git a/src/Plugins/Tools/Signals/SignalButton.qml b/src/Plugins/Tools/Signals/SignalButton.qml index 4985ed648..b94978747 100644 --- a/src/Plugins/Tools/Signals/SignalButton.qml +++ b/src/Plugins/Tools/Signals/SignalButton.qml @@ -22,6 +22,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Controls.Material import Apx.Common @@ -31,16 +32,32 @@ TextButton { ButtonGroup.group: buttonGroup property var values: [] - onActivated: signals.facts=Qt.binding(function(){return values}) + property string pageToolTip: "" + property bool pageWarning: false - toolTip: getToolTip(values) + signal editTriggered() + + textColor: pageWarning ? Material.color(Material.Orange) + : Material.primaryTextColor + + onDoubleClicked: editTriggered() + onPressAndHold: editTriggered() + + toolTip: pageToolTip !== "" ? pageToolTip : getToolTip(values) function getToolTip(facts) { var s=[] for(var i=0;i"+fact.descr+"") + var color = fact && fact.color ? fact.color + : (fact && fact.opts ? fact.opts.color : undefined) + var label = fact && fact.title ? fact.title + : (fact && fact.descr ? fact.descr : fact.bind) + if(color) + s.push(""+label+"") + else + s.push(label) } return s.join("
") } diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index b79e19bbb..15c608948 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + /* * APX Autopilot project * @@ -26,7 +28,7 @@ import QtQuick.Controls.Material import QtCore import Apx.Common - +import Apx.Menu Rectangle { id: control @@ -35,80 +37,691 @@ Rectangle { implicitWidth: layout.implicitWidth border.width: 0 - color: "#000" + color: "#000000" + + property string selectedPage: "" + property var selectedPageFact: null + property bool pageStateRefreshPending: false + property var pageStates: [] + property var globalUi: ui + property var globalApx: apx + property var prefsStore: application.prefs + property real uiScale: control.globalUi ? control.globalUi.scale : 1 + + readonly property var pages: setsFact.activePages() + readonly property var pinnedPages: { + var list = [] + var source = setsFact.activePages() + + for (var i = 0; i < source.length; ++i) { + if (control.pagePinned(source[i])) + list.push(source[i]) + } + + return list + } + readonly property int activePageIndex: checkedPageIndex() + readonly property var activePage: pageAt(activePageIndex) + readonly property real pinnedChartHeight: Math.max(44 * uiScale, + Math.min(96 * uiScale, + (control.height - bottomArea.implicitHeight) + / Math.max(pinnedPages.length + 1, 2))) + + SignalsModel { + id: signalsModel + prefsAdapter: control.prefsStore + } + + MenuSets { + id: setsFact + signalsModel: signalsModel + destroyOnClose: false + } + + Connections { + target: setsFact + + function onStateChanged() + { + control.handleModelChanged() + } + } + + Component { + id: factPopupComponent + + FactMenuPopup { + pinned: true + } + } + + Settings { + category: "signals" + property alias page: control.selectedPage + } + + Connections { + target: control.globalApx ? control.globalApx.fleet.current.mandala : null + + function onTelemetryDecoded() + { + control.schedulePageStateRefresh() + } + } + + Component.onCompleted: handleModelChanged() + + function createEditorPopup(pos) + { + var popupParent = control.globalUi && control.globalUi.window ? control.globalUi.window + : control + + return factPopupComponent.createObject(popupParent, { + "pos": pos + }) + } + + function createPopupFact(popup, url, properties) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status !== Component.Ready) { + console.log(component.errorString()) + popup.destroy() + return null + } + + var fact = component.createObject(popup, properties) + component.destroy() + + if (!fact) { + popup.destroy() + return null + } + + popup.showFact(fact) + popup.open() + return fact + } + + function openSetsEditor() + { + var popup = createEditorPopup(Qt.point(0.76, 0.08)) + if (!popup) + return + + popup.showFact(setsFact) + popup.open() + } + + function openPageEditor(page) + { + if (!page) + return + + selectedPageFact = page + selectedPage = pageTitle(page, pageIndexFor(page)) + + var popup = createEditorPopup(Qt.point(0.76, 0.08)) + if (!popup) + return + + popup.showFact(page) + popup.open() + } + + function pageAt(index) + { + return index >= 0 && index < pages.length ? pages[index] : null + } + + function checkedPageIndex() + { + var button = buttonGroup.checkedButton + if (!button) + return -1 + + var index = button["pageIndex"] + return index === undefined ? -1 : Number(index) + } + + function pageName(index) + { + var page = pageAt(index) + if (page && typeof page.pageName === "function") + return String(page.pageName()) + if (page && page.name) + return String(page.name) + return signalsModel.defaultPageTitle(Math.max(index, 0)) + } + + function pageFacts(index) + { + var page = pageAt(index) + if (page && typeof page.itemFacts === "function") + return page.itemFacts() + return page && page.items instanceof Array ? page.items : [] + } + + function pageTitle(page, fallbackIndex) + { + if (page && typeof page.pageName === "function") + return String(page.pageName()) + if (page && page.name) + return String(page.name) + return signalsModel.defaultPageTitle(Math.max(fallbackIndex, 0)) + } + + function activeSetTitle() + { + if (typeof setsFact.activeSetTitle === "function") + return String(setsFact.activeSetTitle()) + return "" + } + + function pagePinned(page) + { + if (!page) + return false + if (typeof page.isPinned === "function") + return page.isPinned() + return !!page.pin + } + + function pageIndexFor(page) + { + for (var i = 0; i < pages.length; ++i) { + if (pages[i] === page) + return i + } + + return -1 + } + + function pinnedPageIndex(pinnedIndex) + { + var match = 0 + + for (var i = 0; i < pages.length; ++i) { + if (!control.pagePinned(pages[i])) + continue + + if (match === pinnedIndex) + return i + + match++ + } + + return -1 + } + + function overlayFont(pixelSize) + { + var size = Math.max(9, pixelSize) + if (control.globalApx && typeof control.globalApx.font_narrow === "function") + return control.globalApx.font_narrow(size) + return Qt.font({"pixelSize": size}) + } + + function pageSpeedValue(page) + { + if (!page) + return 1.0 + + if (typeof page.currentSpeedValue === "function") + return page.currentSpeedValue() + + if (signalsModel && typeof signalsModel.normalizeSpeed === "function") + return signalsModel.normalizeSpeed(page.speed) + + return Number(page.speed) + } + + function pageSpeedText(page) + { + var speedValue = pageSpeedValue(page) + if (speedValue === 1.0) + return "" + + if (signalsModel && typeof signalsModel.formatSpeed === "function") + return signalsModel.formatSpeed(speedValue) + + var text = String(speedValue) + if (text.endsWith(".0")) + text = text.slice(0, -2) + return text + "x" + } + + function normalizeBindText(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function itemBind(item) + { + if (!item || item.bind === undefined) + return item && typeof item.bindText === "function" ? item.bindText() : "" + + return normalizeBindText(item.bind) + } + + function defaultItemColor(index) + { + return Material.color(Material.Blue + index * 2) + } + + function itemColor(item, index) + { + if (item && typeof item.colorValueCurrent === "function" + && String(item.colorValueCurrent()).trim() !== "") + return item.colorValueCurrent() + + if (item && item.color !== undefined && String(item.color).trim() !== "") + return item.color + return defaultItemColor(index) + } + + function itemTitle(item, index) + { + if (item && item.title !== undefined && String(item.title) !== "") + return String(item.title) + if (item) + return itemBind(item) + return qsTr("Item") + " " + (index + 1) + } + + function itemWarningText(item) + { + if (!item) + return "" + if (typeof item.warningText === "function") + return item.warningText() + return item.warning !== undefined ? String(item.warning) : "" + } + + function escapeHtml(text) + { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + } + + function evaluateBinding(bind) + { + try { + return eval(normalizeBindText(bind)) + } catch (error) { + return NaN + } + } + + function evaluateCondition(expression, value, item, page) + { + if (!expression || String(expression).trim() === "") + return false + + var rawValue = value + var bind = itemBind(item) + var title = itemTitle(item, 0) + var pageNameValue = page ? pageTitle(page, 0) : "" + + try { + return !!eval(expression) + } catch (error) { + return false + } + } + + function evaluatePageState(index) + { + var page = pageAt(index) + var state = { + "warning": false, + "messages": [], + "toolTip": "" + } + + if (!page) + return state + + var lines = ["" + escapeHtml(pageName(index)) + ""] + var items = pageFacts(index) + + for (var i = 0; i < items.length; ++i) { + var item = items[i] + var title = itemTitle(item, i) + lines.push("\u25A0 " + + escapeHtml(title)) + + var value = evaluateBinding(itemBind(item)) + var warningExpr = itemWarningText(item) + if (warningExpr !== "" && evaluateCondition(warningExpr, value, item, page)) { + state.warning = true + state.messages.push(qsTr("Warning") + ": " + title + " (" + warningExpr + ")") + } + } + + if (state.messages.length > 0) { + lines.push("
" + escapeHtml(qsTr("Alerts")) + "") + for (var j = 0; j < state.messages.length; ++j) + lines.push(escapeHtml(state.messages[j])) + } + + state.toolTip = lines.join("
") + return state + } + + function refreshPageStates() + { + var states = [] + for (var i = 0; i < pages.length; ++i) + states.push(evaluatePageState(i)) + pageStates = states + } + + function schedulePageStateRefresh() + { + if (pageStateRefreshPending) + return + + pageStateRefreshPending = true + Qt.callLater(function() { + pageStateRefreshPending = false + refreshPageStates() + }) + } + + function pageState(index) + { + return index >= 0 && index < pageStates.length + ? pageStates[index] + : { + "warning": false, + "messages": [], + "toolTip": "" + } + } + + function selectSavedPage() + { + if (pages.length <= 0) { + selectedPageFact = null + selectedPage = "" + signals.facts = [] + return + } + + if (selectedPageFact && pageIndexFor(selectedPageFact) >= 0) { + selectedPage = pageTitle(selectedPageFact, pageIndexFor(selectedPageFact)) + return + } + + selectedPageFact = null + + if (selectedPage !== "") { + for (var i = 0; i < pages.length; ++i) { + if (pageName(i) === selectedPage) { + selectedPageFact = pageAt(i) + return + } + } + } + + selectedPageFact = pageAt(0) + selectedPage = pageName(0) + } + + function handleModelChanged() + { + Qt.callLater(function() { + selectSavedPage() + Qt.callLater(function() { + selectSavedPage() + schedulePageStateRefresh() + }) + }) + } + + function cyclePageSpeed() + { + if (!activePage) + return + + if (typeof activePage.setSpeedValue === "function") { + activePage.setSpeedValue(signalsModel.nextSpeedValue(pageSpeedValue(activePage))) + return + } + + signalsModel.setPageSpeed(activePageIndex, + signalsModel.nextSpeedValue(activePage.speed)) + } + + function cycleSpeedForPage(page) + { + var index = pageIndexFor(page) + if (index < 0) + return + + if (typeof page.setSpeedValue === "function") { + page.setSpeedValue(signalsModel.nextSpeedValue(pageSpeedValue(page))) + return + } + + signalsModel.setPageSpeed(index, + signalsModel.nextSpeedValue(page.speed)) + } + + function cycleSpeedForPinnedPage(pinnedIndex, page) + { + var index = pinnedPageIndex(pinnedIndex) + if (index < 0 && page) + index = pageIndexFor(page) + if (index < 0) + return + + var targetPage = pageAt(index) + if (!targetPage) + return + + if (typeof targetPage.setSpeedValue === "function") { + targetPage.setSpeedValue(signalsModel.nextSpeedValue(pageSpeedValue(targetPage))) + return + } + + signalsModel.setPageSpeed(index, + signalsModel.nextSpeedValue(targetPage.speed)) + } ColumnLayout { id: layout anchors.fill: parent - spacing: 0 + spacing: 2 * control.uiScale + + Repeater { + model: control.pinnedPages - SignalsView { - id: signals - facts: [] + delegate: Item { + id: pinnedChartPane + + required property var modelData + required property int index + + Layout.fillWidth: true + Layout.preferredHeight: control.pinnedChartHeight + Layout.minimumHeight: 36 * control.uiScale + clip: true + + SignalsView { + anchors.fill: parent + uiContext: control.globalUi + apxContext: control.globalApx + facts: pinnedChartPane.modelData && pinnedChartPane.modelData.items instanceof Array + ? pinnedChartPane.modelData.items + : control.pageFacts(control.pageIndexFor(pinnedChartPane.modelData)) + speed: signalsModel.speedIndex(control.pageSpeedValue(pinnedChartPane.modelData)) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: control.cycleSpeedForPinnedPage(pinnedChartPane.index, + pinnedChartPane.modelData) + } + + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 * control.uiScale + anchors.rightMargin: 4 * control.uiScale + radius: 2 * control.uiScale + color: "#A0000000" + visible: titleLabel.text !== "" + + implicitWidth: titleColumn.implicitWidth + 10 * control.uiScale + implicitHeight: titleColumn.implicitHeight + 4 * control.uiScale + + Column { + id: titleColumn + anchors.centerIn: parent + spacing: 1 * control.uiScale + + Label { + id: titleLabel + anchors.horizontalCenter: parent.horizontalCenter + text: control.pageTitle(pinnedChartPane.modelData, pinnedChartPane.index) + color: "#FFFFFF" + font: control.overlayFont(10 * control.uiScale) + } + + Label { + id: titleSpeedLabel + anchors.horizontalCenter: parent.horizontalCenter + visible: text !== "" + text: control.pageSpeedText(pinnedChartPane.modelData) + color: "#CCCCCC" + font: control.overlayFont(8 * control.uiScale) + } + } + } + } + } + + Item { + id: mainChartArea Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 20 - Layout.preferredHeight: 130*ui.scale - } + Layout.preferredHeight: 130 * control.uiScale + clip: true - TextInput { - id: textInput - - Layout.fillWidth: true - Layout.minimumHeight: Style.fontSize + SignalsView { + id: signals + anchors.fill: parent + uiContext: control.globalUi + apxContext: control.globalApx + facts: [] + speed: control.activePage + ? signalsModel.speedIndex(control.pageSpeedValue(control.activePage)) + : signalsModel.speedIndex(1.0) + } - // clip: true - // focus: true - visible: false + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: control.cyclePageSpeed() + } - horizontalAlignment: Text.AlignRight - verticalAlignment: Text.AlignVCenter + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 * control.uiScale + anchors.rightMargin: 4 * control.uiScale + radius: 2 * control.uiScale + color: "#A0000000" + visible: mainTitleLabel.text !== "" - font: apx.font_narrow(Style.fontSize) + implicitWidth: mainTitleColumn.implicitWidth + 10 * control.uiScale + implicitHeight: mainTitleColumn.implicitHeight + 4 * control.uiScale - color: activeFocus?Material.color(Material.Yellow):Material.primaryTextColor - text: "est.air.airspeed" + Column { + id: mainTitleColumn + anchors.centerIn: parent + spacing: 1 * control.uiScale - activeFocusOnTab: true - selectByMouse: true + Label { + id: mainTitleLabel + anchors.horizontalCenter: parent.horizontalCenter + text: control.activePage ? control.pageTitle(control.activePage, control.activePageIndex) : "" + color: "#FFFFFF" + font: control.overlayFont(10 * control.uiScale) + } - onEditingFinished: { - updateFacts() - } - onActiveFocusChanged: { - if(activeFocus)selectAll(); + Label { + id: mainSpeedLabel + anchors.horizontalCenter: parent.horizontalCenter + visible: text !== "" + text: control.pageSpeedText(control.activePage) + color: "#CCCCCC" + font: control.overlayFont(8 * control.uiScale) + } + } } - onVisibleChanged: if(visible)forceActiveFocus() - Component.onCompleted: updateFacts() - - property var facts: [] - function updateFacts() - { - var flist=[] - var list=textInput.text.split(',') - for(var i=0;i + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQml + +QtObject { + id: model + + /* + * signals.json schema: + * { + * "active": { "signals": 0 }, + * "sets": [ + * { + * "title": "default", + * "pages": [ + * { + * "name": "R", + * "pin": false, + * "speed": 1.0, + * "items": [ + * { + * "bind": "est.att.roll", + * "color": "", + * "filters": [], + * "warning": "", + * "save": "" + * } + * ] + * } + * ] + * } + * ] + * } + * + * Legacy migration: + * { "page": "R", "signalas": [ ... ] } becomes one set titled "default" + * with one page named from "page" (or "page 1" when missing). Legacy item + * fields are copied forward and normalized into the new item schema. + */ + + property string settingsFileName: "signals.json" + property string settingsName: "signals" + property bool autoLoad: true + + // Provided by the host widget or a test harness. + property var prefsAdapter: null + + property var speedFactors: [0.2, 0.5, 1.0, 2.0, 4.0] + property var active: ({signals: 0}) + property int activeSignals: -1 + property var sets: [] + property bool loaded: false + property var filterRegistry: FilterRegistry {} + + readonly property bool hasSets: sets.length > 0 + readonly property var activeSet: hasSets ? sets[activeSignals] : null + readonly property var pages: activeSet && (activeSet.pages instanceof Array) ? activeSet.pages : [] + readonly property var pinnedPages: filterPages(true) + readonly property var unpinnedPages: filterPages(false) + + signal settingsLoaded(var settings) + signal settingsSaved(var settings) + + Component.onCompleted: { + if (autoLoad) + loadSettings() + } + + function settingsStore() + { + return prefsAdapter + } + + function defaultSetTitle(index) + { + return qsTr("Set") + " " + (index + 1) + } + + function defaultPageTitle(index) + { + return qsTr("Page") + " " + (index + 1) + } + + function legacyDefaultPageTitle() + { + return qsTr("page 1") + } + + function createDefaultItem(bind) + { + return { + "bind": bind, + "color": "", + "filters": [], + "warning": "", + "save": "" + } + } + + function createDefaultPage(name, bindings) + { + var page = { + "name": name, + "pin": false, + "speed": 1.0, + "items": [] + } + + for (var i = 0; i < bindings.length; ++i) + page.items.push(createDefaultItem(bindings[i])) + + return page + } + + function createDefaultSet() + { + var specs = [ + { + "name": "R", + "bindings": ["cmd.att.roll", "est.att.roll"] + }, + { + "name": "P", + "bindings": ["cmd.att.pitch", "est.att.pitch"] + }, + { + "name": "Y", + "bindings": ["cmd.pos.bearing", "cmd.att.yaw", "est.att.yaw"] + }, + { + "name": "Axy", + "bindings": ["est.acc.x", "est.acc.y"] + }, + { + "name": "Az", + "bindings": ["est.acc.z"] + }, + { + "name": "G", + "bindings": ["est.gyro.x", "est.gyro.y", "est.gyro.z"] + }, + { + "name": "Pt", + "bindings": ["est.pos.altitude", "est.pos.vspeed", "est.air.airspeed"] + }, + { + "name": "Ctr", + "bindings": [ + "ctr.att.ail", + "ctr.att.elv", + "ctr.att.rud", + "ctr.eng.thr", + "ctr.eng.prop", + "ctr.str.rud" + ] + }, + { + "name": "RC", + "bindings": ["cmd.rc.roll", "cmd.rc.pitch", "cmd.rc.thr", "cmd.rc.yaw"] + }, + { + "name": "Usr", + "bindings": [ + "est.usr.u1", + "est.usr.u2", + "est.usr.u3", + "est.usr.u4", + "est.usr.u5", + "est.usr.u6" + ] + } + ] + var set = { + "title": "default", + "pages": [] + } + + for (var i = 0; i < specs.length; ++i) + set.pages.push(createDefaultPage(specs[i].name, specs[i].bindings)) + + return set + } + + function createDefaultSettings() + { + return { + "active": { + "signals": 0 + }, + "sets": [createDefaultSet()] + } + } + + function copyValue(value) + { + if (value === undefined || value === null) + return value + + return JSON.parse(JSON.stringify(value)) + } + + function asObject(value) + { + if (!value || value instanceof Array || typeof value !== "object") + return {} + + return value + } + + function asArray(value) + { + return value instanceof Array ? value : [] + } + + function asString(value, fallback) + { + if (fallback === undefined) + fallback = "" + + if (value === undefined || value === null) + return fallback + + return String(value) + } + + function asBool(value, fallback) + { + if (value === undefined || value === null) + return fallback + + return !!value + } + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback + } + + function clamp(value, minValue, maxValue) + { + return Math.max(minValue, Math.min(maxValue, value)) + } + + function normalizeSpeed(value) + { + var number = asNumber(value, 1.0) + + for (var i = 0; i < speedFactors.length; ++i) { + if (speedFactors[i] === number) + return number + } + + return 1.0 + } + + function speedIndex(value) + { + var speedValue = normalizeSpeed(value) + + for (var i = 0; i < speedFactors.length; ++i) { + if (speedFactors[i] === speedValue) + return i + } + + return 2 + } + + function nextSpeedValue(value) + { + var index = speedIndex(value) + return speedFactors[(index + 1) % speedFactors.length] + } + + function formatSpeed(value) + { + var text = String(normalizeSpeed(value)) + if (text.endsWith(".0")) + text = text.slice(0, -2) + return text + "x" + } + + function normalizeActiveIndex(index, count) + { + var number = Math.floor(asNumber(index, 0)) + + if (count <= 0) + return 0 + + if (number < 0 || number >= count) + return 0 + + return number + } + + function parseSettingsText(text) + { + if (!text) + return {} + + try { + return JSON.parse(text) + } catch (error) { + console.warn("SignalsModel: invalid " + settingsFileName + ": " + error) + } + + return {} + } + + function isLegacySettings(json) + { + if (!json || typeof json !== "object") + return false + + if (json.sets instanceof Array) + return false + + return json.signalas instanceof Array || json.page !== undefined + } + + function normalizeFilter(filterData) + { + return filterRegistry.normalizeFilter(filterData) + } + + function normalizeFilters(filtersData) + { + return filterRegistry.normalizeFilters(filtersData) + } + + function normalizeBind(value) + { + var text = asString(value, "").trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function normalizeItem(itemData) + { + var source = asObject(itemData) + var item = copyValue(source) || {} + + item.bind = normalizeBind(source.bind !== undefined ? source.bind : source.name) + if (!item.bind) + return null + + item.color = asString(source.color, "") + item.filters = normalizeFilters(source.filters) + item.warning = asString(source.warning !== undefined ? source.warning : source.warn, "") + item.save = asString(source.save, "") + + delete item.title + delete item.act + delete item.warn + delete item.name + delete item.descr + delete item.value + + if (item.opts && item.color === "") + item.color = asString(item.opts.color, "") + + delete item.opts + + return item + } + + function normalizeItems(itemsData) + { + var list = [] + var source = asArray(itemsData) + + for (var i = 0; i < source.length; ++i) { + var item = normalizeItem(source[i]) + if (item) + list.push(item) + } + + return list + } + + function normalizePage(pageData, index) + { + var source = asObject(pageData) + var page = copyValue(source) || {} + + page.name = asString(source.name, defaultPageTitle(index)) + page.pin = asBool(source.pin, false) + page.speed = normalizeSpeed(source.speed) + page.items = normalizeItems(source.items) + + return page + } + + function normalizePages(pagesData) + { + var list = [] + var source = asArray(pagesData) + + for (var i = 0; i < source.length; ++i) + list.push(normalizePage(source[i], i)) + + return list + } + + function normalizeSet(setData, index) + { + var source = asObject(setData) + var set = copyValue(source) || {} + + set.title = asString(source.title, defaultSetTitle(index)) + set.pages = normalizePages(source.pages) + + return set + } + + function normalizeSets(setsData) + { + var list = [] + var source = asArray(setsData) + + for (var i = 0; i < source.length; ++i) + list.push(normalizeSet(source[i], i)) + + return list + } + + function normalizeLegacySettings(json) + { + var source = asObject(json) + + return { + "active": { + "signals": 0 + }, + "sets": [ + { + "title": "default", + "pages": [ + { + "name": asString(source.page, legacyDefaultPageTitle()), + "pin": false, + "speed": 1.0, + "items": normalizeItems(source.signalas) + } + ] + } + ] + } + } + + function normalizeSettings(json) + { + var root = isLegacySettings(json) ? normalizeLegacySettings(json) : asObject(json) + var normalizedSets = normalizeSets(root.sets) + + if (normalizedSets.length <= 0) + normalizedSets = normalizeSets([createDefaultSet()]) + + var normalized = { + "active": { + "signals": 0 + }, + "sets": normalizedSets + } + + normalized.active.signals = normalizeActiveIndex(asObject(root.active).signals, + normalized.sets.length) + return normalized + } + + function assignSettings(normalized) + { + sets = normalized.sets + active = normalized.active + activeSignals = normalized.active.signals + } + + function applySettings(settings) + { + var normalized = normalizeSettings(settings) + + assignSettings(normalized) + loaded = true + + return exportSettings() + } + + function exportSettings() + { + return normalizeSettings({ + "active": { + "signals": hasSets ? activeSignals : 0 + }, + "sets": sets + }) + } + + function loadSettings() + { + var store = settingsStore() + var text = "" + var settings = createDefaultSettings() + + if (store && typeof store.loadFile === "function") { + text = store.loadFile(settingsFileName) + if (text) + settings = parseSettingsText(text) + } + + var exported = applySettings(settings) + settingsLoaded(exported) + return exported + } + + function saveSettings(settings) + { + if (settings !== undefined) + applySettings(settings) + + var exported = exportSettings() + var store = settingsStore() + + if (store && typeof store.saveFile === "function") + store.saveFile(settingsFileName, JSON.stringify(exported, " ", 2)) + + settingsSaved(exported) + return exported + } + + function setActiveSignals(index) + { + assignSettings(normalizeSettings({ + "active": { + "signals": index + }, + "sets": sets + })) + } + + function setSets(list) + { + assignSettings(normalizeSettings({ + "active": { + "signals": activeSignals + }, + "sets": list + })) + } + + function mutateActiveSet(mutator) + { + if (!hasSets || typeof mutator !== "function") + return exportSettings() + + var settings = exportSettings() + var setIndex = normalizeActiveIndex(settings.active.signals, settings.sets.length) + var setData = setIndex >= 0 && setIndex < settings.sets.length ? settings.sets[setIndex] : null + if (!setData) + return settings + + mutator(setData) + return saveSettings(settings) + } + + function pageAt(index) + { + return index >= 0 && index < pages.length ? pages[index] : null + } + + function setPageSpeed(index, speedValue) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages[index].speed = normalizeSpeed(speedValue) + }) + } + + function togglePagePin(index) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages[index].pin = !setData.pages[index].pin + }) + } + + function updatePage(index, pageData) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages[index] = normalizePage(pageData, index) + }) + } + + function removePage(index) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages.splice(index, 1) + if (setData.pages.length <= 0) { + setData.pages.push(normalizePage({ + "name": defaultPageTitle(0), + "pin": false, + "speed": 1.0, + "items": [] + }, 0)) + } + }) + } + + function addItemToPage(pageIndex, itemData) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || pageIndex < 0 || pageIndex >= setData.pages.length) + return + + var item = normalizeItem(itemData) + if (!item) + return + + setData.pages[pageIndex].items.push(item) + }) + } + + function isSaveTargetUsedInActiveSet(saveTarget, skipPageIndex, skipItemIndex) + { + var target = asString(saveTarget, "").trim() + if (target === "") + return false + + if (skipPageIndex === undefined) + skipPageIndex = -1 + if (skipItemIndex === undefined) + skipItemIndex = -1 + + for (var i = 0; i < pages.length; ++i) { + if (i === skipPageIndex && skipItemIndex < 0) + continue + + var page = pages[i] + var items = page && page.items instanceof Array ? page.items : [] + for (var j = 0; j < items.length; ++j) { + if (i === skipPageIndex && j === skipItemIndex) + continue + + var item = items[j] + if (asString(item ? item.save : "", "").trim() === target) + return true + } + } + + return false + } + + function filterPages(pinned) + { + var list = [] + + for (var i = 0; i < pages.length; ++i) { + var page = pages[i] + if (!!page.pin === pinned) + list.push(page) + } + + return list + } +} diff --git a/src/Plugins/Tools/Signals/SignalsPlugin.qml b/src/Plugins/Tools/Signals/SignalsPlugin.qml index 39305b7c4..27d3c54e1 100755 --- a/src/Plugins/Tools/Signals/SignalsPlugin.qml +++ b/src/Plugins/Tools/Signals/SignalsPlugin.qml @@ -31,7 +31,9 @@ AppPlugin { descr: qsTr("Realtime chart") icon: "poll" - sourceComponent: Signals { } + sourceComponent: Signals { + implicitWidth: Style.buttonSize * 15 + } uiComponent: "main" onConfigure: { ui.main.add(plugin, GroundControl.Layout.Main) diff --git a/src/Plugins/Tools/Signals/SignalsView.qml b/src/Plugins/Tools/Signals/SignalsView.qml index b1b5bd04a..70c20f8c9 100644 --- a/src/Plugins/Tools/Signals/SignalsView.qml +++ b/src/Plugins/Tools/Signals/SignalsView.qml @@ -21,38 +21,224 @@ */ import QtQuick import QtCharts -import QtQuick.Controls +import QtQuick.Controls.Material import QtQml Item { id: chartItem - //clip: true + property var facts: [] + property var uiContext: ui + property var apxContext: apx property bool openGL: false //apx.settings.graphics.opengl.value - property bool smoothLines: ui.smooth - + property bool smoothLines: uiContext ? uiContext.smooth : false property real speed: 0 - property real lineWidth: ui.antialiasing?1.5:1 - property real lineWidthCmd: ui.antialiasing?2.1:2 + property real lineWidth: uiContext && uiContext.antialiasing ? 1.5 : 1 + property real lineWidthCmd: uiContext && uiContext.antialiasing ? 2.1 : 2 - property var speedFactor: [ 1, 2, 4, 0.5, 0.2 ] - property real speedFactorValue: speed<0?speedFactor[0]:speed>=speedFactor.length?speedFactor[speedFactor.length-1]:speedFactor[speed] + property var speedFactor: [0.2, 0.5, 1, 2, 4] + property real speedFactorValue: speed < 0 + ? speedFactor[0] + : speed >= speedFactor.length + ? speedFactor[speedFactor.length - 1] + : speedFactor[speed] - onFactsChanged: { - chartView.reset() + QtObject { + id: colorProbe + + property color value: "white" } + onFactsChanged: chartView.reset() + Connections { - target: apx.fleet.current.mandala - function onTelemetryDecoded(){ chartView.appendData() } + target: chartItem.apxContext ? chartItem.apxContext.fleet.current.mandala : null + + function onTelemetryDecoded() + { + chartView.appendData() + } + } + + function normalizeBindText(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function itemBind(fact) + { + if (!fact) + return "" + + if (typeof fact.bindText === "function") + return normalizeBindText(fact.bindText()) + + if (fact.bind !== undefined && fact.bind !== null && String(fact.bind) !== "") + return normalizeBindText(fact.bind) + if (fact.name !== undefined && fact.name !== null) + return normalizeBindText(fact.name) + + return "" + } + + function itemTitle(fact, index) + { + if (fact && fact.title !== undefined && String(fact.title) !== "") + return String(fact.title) + + var bind = itemBind(fact) + if (bind !== "") + return bind + + return qsTr("Signal") + " " + (index + 1) + } + + function defaultSeriesColor(index) + { + return Material.color(Material.Blue + index * 2) + } + + function resolvedColor(value, fallback) + { + var source = value + if (source === undefined || source === null || source === "") + source = fallback + + colorProbe.value = source + return colorProbe.value + } + + function itemColor(fact, index) + { + if (fact) { + if (typeof fact.colorValueCurrent === "function" + && String(fact.colorValueCurrent()) !== "") + return resolvedColor(fact.colorValueCurrent(), defaultSeriesColor(index)) + + if (fact.color !== undefined && fact.color !== null && String(fact.color) !== "") + return resolvedColor(fact.color, defaultSeriesColor(index)) + + if (fact.opts && fact.opts.color) + return resolvedColor(fact.opts.color, defaultSeriesColor(index)) + } + + return resolvedColor("", defaultSeriesColor(index)) + } + + function itemSaveTarget(fact) + { + if (!fact) + return "" + + if (typeof fact.saveTarget === "function") + return fact.saveTarget() + + if (fact.save === undefined || fact.save === null || typeof fact.save === "function") + return "" + + return String(fact.save).trim() + } + + function evaluateValue(fact, bind) + { + if (fact && typeof fact.bindText !== "function" && fact.value !== undefined) + return fact.value + + if (!bind) + return NaN + + try { + return eval(normalizeBindText(bind)) + } catch (error) { + return NaN + } + } + + function filteredItemValue(fact, value) + { + if (fact && typeof fact.updateFilters === "function") + return fact.updateFilters(value) + + return value + } + + function writeSavedValue(fact, value) + { + var saveTarget = itemSaveTarget(fact) + if (saveTarget === "" || !chartItem.apxContext) + return + + var saveFact = chartItem.apxContext.fleet.current.mandala.fact(saveTarget, true) + if (saveFact) + saveFact.setRawValueLocal(value) + } + + function isCommandBind(bind) + { + return bind.indexOf("cmd.") === 0 || bind.indexOf("cmd") === 0 + } + + function applySeriesStyle(series, fact, index) + { + if (!series || !fact) + return + + var bind = itemBind(fact) + var color = itemColor(fact, index) + + if (isCommandBind(bind)) { + series.width = Qt.binding(function() { + return lineWidthCmd + }) + series.color = Qt.hsla(color.hslHue, + color.hslSaturation / 2, + color.hslLightness * 1.2, + 1) + } else { + series.width = Qt.binding(function() { + return lineWidth + }) + series.color = color + } + } + + function syncSeries(series, fact, index) + { + if (!series || !fact) + return + + var title = itemTitle(fact, index) + if (series.name !== title) + series.name = title + + applySeriesStyle(series, fact, index) + } + + function updateSeriesColor(index) + { + if (index < 0 || index >= chartItem.facts.length || index >= chartView.count) + return + + syncSeries(chartView.series(index), chartItem.facts[index], index) } ChartView { id: chartView - antialiasing: ui.antialiasing + antialiasing: chartItem.uiContext && chartItem.uiContext.antialiasing legend.visible: false margins.top: 0 margins.left: 0 @@ -65,26 +251,33 @@ Item { anchors.bottomMargin: margin anchors.leftMargin: margin anchors.rightMargin: margin - //onPlotAreaChanged: margin=-plotArea.y/3 plotAreaColor: "black" backgroundColor: "black" backgroundRoundness: 0 dropShadowEnabled: false - property int samples: Math.min(1000,Math.max(25,width/(3*speedFactorValue))) + property int samples: Math.min(1000, + Math.max(25, width / (3 * chartItem.speedFactorValue))) property int time: 0 property bool dataExist: false ValueAxis { id: axisX + property real t: chartView.time - Behavior on t { enabled: ui.smooth && chartView.dataExist; NumberAnimation {duration: 500; } } - min: t-chartView.samples+20 + + Behavior on t { + enabled: chartItem.uiContext && chartItem.uiContext.smooth && chartView.dataExist + + NumberAnimation { + duration: 500 + } + } + + min: t - chartView.samples + 20 max: t - //min: -chartView.samples //t-chartView.samples+20 - //max: 0 //t visible: false gridVisible: false labelsVisible: false @@ -92,17 +285,18 @@ Item { shadesVisible: false titleVisible: false } + ValueAxis { id: axisY + min: -0 max: 0 tickCount: 4 labelsColor: "white" - labelsFont.pixelSize: Qt.application.font.pixelSize * 0.7 - gridLineColor: "#555" + labelsFont.pixelSize: 8 + gridLineColor: "#555555" } - property real dataPadding: 0.05 property real dataPaddingZero: 0.05 property var sdata: [] @@ -110,103 +304,109 @@ Item { function reset() { - chartView.removeAllSeries(); - chartView.sdata=[] - chartView.time=0 - axisY.min=-dataPaddingZero - axisY.max=dataPaddingZero - axisY.tickCount=4 + chartView.removeAllSeries() + chartView.sdata = [] + chartView.time = 0 + chartView.dataExist = false + chartView.timeRescale = 0 + axisY.min = -dataPaddingZero + axisY.max = dataPaddingZero + axisY.tickCount = 4 axisY.applyNiceNumbers() - speed=0 } function appendData() { - var t=time+1; - var v=0 - var fact={} - for(var i=0;i21){ - timeRescale=t - var d=sdata.length-samples*facts.length - if(d>0)sdata.splice(0,d) - var p=apx.seriesBounds(sdata) - var min=p.x-dataPadding - var max=p.y+dataPadding - if(min==max){ - min-=dataPaddingZero - max+=dataPaddingZero + var t = time + 1 + + for (var i = 0; i < chartItem.facts.length; ++i) + appendDataValue(chartItem.facts[i], t, i) + + if ((t - timeRescale) > 21) { + timeRescale = t + var d = sdata.length - samples * chartItem.facts.length + if (d > 0) + sdata.splice(0, d) + var p = chartItem.apxContext ? chartItem.apxContext.seriesBounds(sdata) + : Qt.point(0, 0) + var min = p.x - dataPadding + var max = p.y + dataPadding + if (min === max) { + min -= dataPaddingZero + max += dataPaddingZero } - var bmod=false - if(axisY.minmax){ - axisY.max=max - bmod=true + if (axisY.max > max) { + axisY.max = max + bmod = true } - if(bmod){ - axisY.tickCount=4 + if (bmod) { + axisY.tickCount = 4 axisY.applyNiceNumbers() } } - time=t - dataExist=true + time = t + dataExist = true } - function appendDataValue(fact, t, i){ - if(i>=chartView.count)addFactSeries(fact) - var s=chartView.series(i) + function appendDataValue(fact, t, i) + { + if (i >= chartView.count) + addFactSeries(fact, i) + + var series = chartView.series(i) + chartItem.syncSeries(series, fact, i) + + var value = chartItem.evaluateValue(fact, chartItem.itemBind(fact)) + + if (!isFinite(value)) + value = 0 + + value = chartItem.filteredItemValue(fact, value) + + if (!isFinite(value)) + value = 0 - var value=fact.value!=undefined?fact.value:eval(fact.name) + chartItem.writeSavedValue(fact, value) - if(!isFinite(value))value=0 - s.append(t,value); + series.append(t, value) sdata.push(value) - //instant rescale - grow - if(axisY.maxvalue){ - axisY.min=value-dataPadding; - } - //remove old - var cnt=samples - if(s.count>cnt) s.removePoints(0,s.count-cnt) + if (axisY.max < value) + axisY.max = value + dataPadding + if (axisY.min > value) + axisY.min = value - dataPadding + var cnt = samples + if (series.count > cnt) + series.removePoints(0, series.count - cnt) } - function addFactSeries(fact) + function addFactSeries(fact, index) { - var s = chartView.createSeries(ui.antialiasing?ChartView.SeriesTypeLine:ChartView.SeriesTypeLine,fact.title,axisX, axisY) - s.useOpenGL = Qt.binding(function(){return openGL}) - s.capStyle=Qt.RoundCap - //s.opacity=0.7 - - var color = fact.opts.color - if(!color) color = Qt.rgba(1,1,1,1) - - if(fact.name.startsWith("cmd")){ - s.width=Qt.binding(function(){return lineWidthCmd}) - s.color=Qt.hsla(color.hslHue, color.hslSaturation/2, color.hslLightness*1.2, 1) - }else{ - s.width=Qt.binding(function(){return lineWidth}) - s.color=color - } - return s + var series = chartView.createSeries(chartItem.uiContext + && chartItem.uiContext.antialiasing + ? ChartView.SeriesTypeLine + : ChartView.SeriesTypeLine, + chartItem.itemTitle(fact, index), + axisX, + axisY) + series.useOpenGL = Qt.binding(function() { + return openGL + }) + series.capStyle = Qt.RoundCap + chartItem.applySeriesStyle(series, fact, index) + return series } - } - function changeSpeed() { - if((speed+1)=0){ return s } @@ -112,8 +112,23 @@ ColumnLayout { return "FactMenuPageList.qml" } + function applyPageContext() + { + if(!pageLoader.item) + return + + if(pageLoader.item.hasOwnProperty("fact")) + pageLoader.item.fact = fact + if(pageLoader.item.hasOwnProperty("menuPage")) + pageLoader.item.menuPage = menuPage + } + function factButtonTriggered(fact) { + if(fact && fact.dataType === Fact.Apply && pageLoader.item + && typeof pageLoader.item.applyPendingChanges === "function") + pageLoader.item.applyPendingChanges() + if(factMenu) factMenu.factButtonTriggered(fact) } @@ -134,8 +149,8 @@ ColumnLayout { property alias model: repeater.model Repeater { id: repeater - model: fact.actionsModel - delegate: Loader{ + model: fact ? fact.actionsModel : null + delegate: Loader { active: modelData && modelData.visible && ((modelData.options&Fact.ShowDisabled)?true:modelData.enabled) visible: active sourceComponent: Component { diff --git a/src/main/qml/Apx/Menu/FactMenuPopup.qml b/src/main/qml/Apx/Menu/FactMenuPopup.qml index a497a769c..2e60dfdce 100644 --- a/src/main/qml/Apx/Menu/FactMenuPopup.qml +++ b/src/main/qml/Apx/Menu/FactMenuPopup.qml @@ -96,7 +96,8 @@ Popup { else popup.raise() } Connections { - target: factMenu.fact + target: factMenu.fact ? factMenu.fact : null + ignoreUnknownSignals: true function onProgressChanged(){ popup.pinned=true } } onStackEmpty: popup.close()