6import QGroundControl.Controls
7import QGroundControl.FactControls
8import QGroundControl.PlanView
10/// Unified plan tree view showing Mission Items, GeoFence, and Rally Points
11/// as collapsible sections using a real TreeView with type-discriminating delegates.
14 model: _missionController.visualItemsTree
16 boundsBehavior: Flickable.StopAtBounds
18 pointerNavigationEnabled: false
19 selectionBehavior: TableView.SelectionDisabled
22 required property var editorMap
23 required property var planMasterController
25 signal editingLayerChangeRequested(int layer)
27 readonly property bool _createNewPlanMode: planMasterController.showCreateFromTemplate
29 on_CreateNewPlanModeChanged: {
30 if (_createNewPlanMode) {
31 var planFileRow = _rowFor(_missionController.planFileGroupIndex)
32 if (!root.isExpanded(planFileRow)) {
33 root.expand(planFileRow)
39 property var _missionController: planMasterController.missionController
40 property var _geoFenceController: planMasterController.geoFenceController
41 property var _rallyPointController: planMasterController.rallyPointController
43 // Helper: convert a persistent model index to the current visual row
44 function _rowFor(modelIndex) { return root.rowAtIndex(modelIndex) }
46 // QGCFlickableScrollIndicator expects parent to have indicatorColor (provided by QGCFlickable/QGCListView)
47 property color indicatorColor: qgcPal.text
49 QGCPalette { id: qgcPal; colorGroupEnabled: enabled }
51 QGCFlickableScrollIndicator { parent: root; orientation: QGCFlickableScrollIndicator.Horizontal }
52 QGCFlickableScrollIndicator { parent: root; orientation: QGCFlickableScrollIndicator.Vertical }
54 property int _lastMissionItemCount: 0
57 target: root._missionController.visualItems
58 function onCountChanged() {
59 var newCount = _missionController.visualItems ? _missionController.visualItems.count : 0
60 if (newCount > root._lastMissionItemCount) {
61 // First waypoint added — collapse Plan Info and Defaults
62 if (root._lastMissionItemCount <= 1 && newCount > 1) {
63 var planFileRow = _rowFor(_missionController.planFileGroupIndex)
64 if (root.isExpanded(planFileRow)) {
65 root.collapse(planFileRow)
67 var defaultsRow = _rowFor(_missionController.defaultsGroupIndex)
68 if (root.isExpanded(defaultsRow)) {
69 root.collapse(defaultsRow)
72 // Expand mission group (making Mission the active layer) and scroll to the new item
73 root.selectLayer("missionGroup")
74 // Scroll happens when the editor signals editorExpandedAndLoaded
76 root._lastMissionItemCount = newCount
81 target: root._missionController
82 function onVisualItemsReset() {
83 root.collapseRecursively()
84 if (_missionController.containsItems) {
85 // Non-empty plan: expand mission group
86 root.expand(_rowFor(_missionController.missionGroupIndex))
88 // Empty plan: expand Plan Info and Defaults, scroll to top
89 root.expand(_rowFor(_missionController.planFileGroupIndex))
90 root.expand(_rowFor(_missionController.defaultsGroupIndex))
93 root._lastMissionItemCount = _missionController.visualItems ? _missionController.visualItems.count : 0
94 root.editingLayerChangeRequested(PlanEditLayers.layerMission)
96 function onPlanViewStateChanged() {
97 // Current item changed — bring it on-screen if completely off-screen.
98 // Fine-tuned scroll happens later via editorExpandedAndLoaded.
99 var item = _missionController.currentPlanViewItem
101 var modelIndex = _missionController.visualItemsTree.indexForObject(item)
102 var row = root.rowAtIndex(modelIndex)
105 root.positionViewAtRow(row, TableView.Visible)
111 // The three groups which correspond to map editing layers. These are exclusive:
112 // expanding one collapses the others and makes it the active editing layer.
114 function _layerGroupRow(nodeType) {
116 case "missionGroup": return _rowFor(_missionController.missionGroupIndex)
117 case "fenceGroup": return _rowFor(_missionController.fenceGroupIndex)
118 case "rallyGroup": return _rowFor(_missionController.rallyGroupIndex)
123 function _collapseOtherLayerGroups(nodeType) {
124 for (var layerInfo of PlanEditLayers.layerInfos) {
125 if (layerInfo.nodeType !== nodeType) {
126 var row = _layerGroupRow(layerInfo.nodeType)
127 if (row >= 0 && root.isExpanded(row)) {
134 // Public API: make a layer group the active editing layer and expand it,
135 // collapsing the other layer groups. Called by the layer tool buttons and group headers.
136 function selectLayer(nodeType) {
137 var layerInfo = PlanEditLayers.infoForNodeType(nodeType)
141 if (!mainWindow.allowViewSwitch()) {
144 editingLayerChangeRequested(layerInfo.layer)
145 _collapseOtherLayerGroups(nodeType)
146 var targetRow = _layerGroupRow(nodeType)
147 if (targetRow >= 0) {
148 if (!root.isExpanded(targetRow)) {
149 root.expand(targetRow)
152 root.positionViewAtRow(targetRow, TableView.AlignTop)
156 // Toggle expand/collapse for a non-layer group header (Plan Info, Defaults, Transform).
157 // Caller is responsible for calling allowViewSwitch() before invoking this.
158 function _toggleGroup(row) {
159 if (root.isExpanded(row)) {
167 // Subtitle text shown on group headers, varies by node type
168 function _groupSubtitle(nodeType) {
170 case "planFileGroup": return planMasterController.currentPlanFileName === "" ? qsTr("<Untitled>") : planMasterController.currentPlanFileName
171 case "missionGroup": return _missionController.visualItems ? qsTr("%1 items").arg(_missionController.visualItems.count - 1) : ""
172 case "rallyGroup": return _rallyPointController.points ? qsTr("%1 points").arg(_rallyPointController.points.count) : ""
177 // Icon shown on group headers, matches the layer switcher icons
178 function _groupIcon(nodeType) {
179 return PlanEditLayers.infoForNodeType(nodeType)?.icon ?? ""
182 // Coalesces multiple delegate height changes into a single forceLayout() call
188 onTriggered: root.forceLayout()
191 // Called by MissionItemEditor delegates when their editor height has settled.
192 function _scrollToMissionItem(delegateItem) {
194 var bottomY = delegateItem.mapToItem(root.contentItem, 0, delegateItem.height).y
195 var neededContentY = bottomY - root.height
196 if (neededContentY > root.contentY) {
197 root.contentY = neededContentY
203 implicitWidth: root.width
204 implicitHeight: (loader.item ? loader.item.height : 1) + (separatorLine.visible ? separatorLine.height + root.rowSpacing : 0)
205 enabled: !root._createNewPlanMode || _enabledInCreateMode
206 opacity: enabled ? 1 : root.editorMap._nonInteractiveOpacity
209 required property TreeView treeView
210 required property bool isTreeNode
211 required property bool expanded
212 required property bool hasChildren
213 required property int depth
214 required property int row
215 required property var model
217 readonly property var nodeObject: model.object
218 readonly property string nodeType: model.nodeType
219 readonly property bool separator: model.separator ?? false
221 // In create-new-plan mode, only the Plan Info and Defaults groups and their children are enabled
222 readonly property bool _enabledInCreateMode: nodeType === "planFileGroup" || nodeType === "planFileInfo"
223 || nodeType === "defaultsGroup" || nodeType === "defaultsInfo"
225 onImplicitHeightChanged: layoutTimer.restart()
227 readonly property string _qrcBase: "qrc:/qml/QGroundControl/PlanView/"
229 // We use setSource() instead of sourceComponent so that required properties
230 // (e.g. missionItem) are injected before internal bindings activate,
231 // preventing "Cannot read property of null" warnings.
236 Component.onCompleted: {
237 switch (delegateRoot.nodeType) {
238 case "planFileGroup":
239 case "defaultsGroup":
243 case "transformGroup":
244 sourceComponent = groupHeaderComponent
247 setSource(delegateRoot._qrcBase + "PlanInfoEditor.qml", {
248 objectName: "planTree_planFileInfo",
249 width: Qt.binding(() => delegateRoot.width),
250 planMasterController: root.planMasterController,
251 missionController: root._missionController,
252 editorMap: root.editorMap
256 setSource(delegateRoot._qrcBase + "MissionDefaultsEditor.qml", {
257 width: Qt.binding(() => delegateRoot.width),
258 missionController: root._missionController,
259 planMasterController: root.planMasterController
263 if (delegateRoot.nodeObject) {
264 setSource(delegateRoot._qrcBase + "MissionItemEditor.qml", {
265 objectName: "planTree_missionItemEditor",
266 width: Qt.binding(() => delegateRoot.width),
268 missionItem: delegateRoot.nodeObject
273 if (delegateRoot.nodeObject) {
274 setSource(delegateRoot._qrcBase + "GeoFenceEditor.qml", {
275 objectName: "planTree_fenceEditor",
276 width: Qt.binding(() => delegateRoot.width),
277 myGeoFenceController: root._geoFenceController,
278 flightMap: root.editorMap
283 if (delegateRoot.nodeObject) {
284 setSource(delegateRoot._qrcBase + "RallyPointEditorHeader.qml", {
285 objectName: "planTree_rallyHeader",
286 width: Qt.binding(() => delegateRoot.width),
287 controller: root._rallyPointController
292 if (delegateRoot.nodeObject) {
293 setSource(delegateRoot._qrcBase + "RallyPointItemEditor.qml", {
294 width: Qt.binding(() => delegateRoot.width),
295 rallyPoint: delegateRoot.nodeObject,
296 controller: root._rallyPointController
300 case "transformEditor":
301 setSource(delegateRoot._qrcBase + "TransformEditor.qml", {
302 width: Qt.binding(() => delegateRoot.width),
303 missionController: root._missionController
310 if (delegateRoot.nodeType === "missionItem" && item) {
311 item.clicked.connect(function() {
312 root._missionController.setCurrentPlanViewSeqNum(delegateRoot.nodeObject.sequenceNumber, false)
314 item.remove.connect(function() {
315 var viIndex = root._missionController.visualItemIndexForObject(delegateRoot.nodeObject)
317 root._missionController.removeVisualItem(viIndex)
320 item.selectNextNotReadyItem.connect(function() {
321 for (var i = 0; i < root._missionController.visualItems.count; i++) {
322 var vmi = root._missionController.visualItems.get(i)
323 if (vmi.readyForSaveState === VisualMissionItem.NotReadyForSaveData) {
324 root._missionController.setCurrentPlanViewSeqNum(vmi.sequenceNumber, true)
329 item.editorExpandedAndLoaded.connect(function() {
330 root._scrollToMissionItem(delegateRoot)
338 anchors.margins: ScreenTools.defaultFontPixelWidth * 0.5
339 anchors.topMargin: root.rowSpacing
340 anchors.top: loader.bottom
341 anchors.left: parent.left
342 anchors.right: parent.right
344 color: qgcPal.groupBorder
345 visible: delegateRoot.separator
348 // ── Group header (Mission Items / GeoFence / Rally Points) ──
350 id: groupHeaderComponent
353 objectName: "planTree_" + delegateRoot.nodeType + "Header"
354 width: delegateRoot.width
355 height: ScreenTools.implicitComboBoxHeight + ScreenTools.defaultFontPixelWidth
356 color: qgcPal.windowShade
360 spacing: ScreenTools.defaultFontPixelWidth * 0.5
361 anchors.verticalCenter: parent.verticalCenter
362 anchors.left: parent.left
363 anchors.right: parent.right
364 anchors.margins: ScreenTools.defaultFontPixelWidth * 0.5
367 Layout.alignment: Qt.AlignVCenter
368 Layout.preferredWidth: ScreenTools.defaultFontPixelHeight * 0.75
369 Layout.preferredHeight: Layout.preferredWidth
370 source: "/InstrumentValueIcons/cheveron-right.svg"
372 rotation: delegateRoot.expanded ? 90 : 0
376 Layout.alignment: Qt.AlignBaseline
377 text: delegateRoot.nodeObject ? delegateRoot.nodeObject.objectName : ""
382 Layout.alignment: Qt.AlignVCenter
383 Layout.preferredWidth: ScreenTools.defaultFontPixelHeight * 0.75
384 Layout.preferredHeight: Layout.preferredWidth
385 source: root._groupIcon(delegateRoot.nodeType)
387 visible: source.toString() !== ""
391 Layout.alignment: Qt.AlignBaseline
392 Layout.fillWidth: true
393 text: root._groupSubtitle(delegateRoot.nodeType)
394 elide: Text.ElideRight
395 font.pointSize: ScreenTools.smallFontPointSize
396 color: qgcPal.colorGrey
403 if (PlanEditLayers.infoForNodeType(delegateRoot.nodeType)) {
404 // Layer groups cannot be collapsed by clicking their header. The active
405 // editing layer always has its group expanded. selectLayer() handles
406 // the allowViewSwitch() guard.
407 root.selectLayer(delegateRoot.nodeType)
409 if (!mainWindow.allowViewSwitch()) {
412 root._toggleGroup(delegateRoot.row)