QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
PlanTreeView.qml
Go to the documentation of this file.
1import QtQuick
2import QtQuick.Controls
3import QtQuick.Layouts
4
5import QGroundControl
6import QGroundControl.Controls
7import QGroundControl.FactControls
8import QGroundControl.PlanView
9
10/// Unified plan tree view showing Mission Items, GeoFence, and Rally Points
11/// as collapsible sections using a real TreeView with type-discriminating delegates.
12TreeView {
13 id: root
14 model: _missionController.visualItemsTree
15 clip: true
16 boundsBehavior: Flickable.StopAtBounds
17 reuseItems: false
18 pointerNavigationEnabled: false
19 selectionBehavior: TableView.SelectionDisabled
20 rowSpacing: 2
21
22 required property var editorMap
23 required property var planMasterController
24
25 signal editingLayerChangeRequested(int layer)
26
27 readonly property bool _createNewPlanMode: planMasterController.showCreateFromTemplate
28
29 on_CreateNewPlanModeChanged: {
30 if (_createNewPlanMode) {
31 var planFileRow = _rowFor(_missionController.planFileGroupIndex)
32 if (!root.isExpanded(planFileRow)) {
33 root.expand(planFileRow)
34 }
35 root.contentY = 0
36 }
37 }
38
39 property var _missionController: planMasterController.missionController
40 property var _geoFenceController: planMasterController.geoFenceController
41 property var _rallyPointController: planMasterController.rallyPointController
42
43 // Helper: convert a persistent model index to the current visual row
44 function _rowFor(modelIndex) { return root.rowAtIndex(modelIndex) }
45
46 // QGCFlickableScrollIndicator expects parent to have indicatorColor (provided by QGCFlickable/QGCListView)
47 property color indicatorColor: qgcPal.text
48
49 QGCPalette { id: qgcPal; colorGroupEnabled: enabled }
50
51 QGCFlickableScrollIndicator { parent: root; orientation: QGCFlickableScrollIndicator.Horizontal }
52 QGCFlickableScrollIndicator { parent: root; orientation: QGCFlickableScrollIndicator.Vertical }
53
54 property int _lastMissionItemCount: 0
55
56 Connections {
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)
66 }
67 var defaultsRow = _rowFor(_missionController.defaultsGroupIndex)
68 if (root.isExpanded(defaultsRow)) {
69 root.collapse(defaultsRow)
70 }
71 }
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
75 }
76 root._lastMissionItemCount = newCount
77 }
78 }
79
80 Connections {
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))
87 } else {
88 // Empty plan: expand Plan Info and Defaults, scroll to top
89 root.expand(_rowFor(_missionController.planFileGroupIndex))
90 root.expand(_rowFor(_missionController.defaultsGroupIndex))
91 root.contentY = 0
92 }
93 root._lastMissionItemCount = _missionController.visualItems ? _missionController.visualItems.count : 0
94 root.editingLayerChangeRequested(PlanEditLayers.layerMission)
95 }
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
100 if (item) {
101 var modelIndex = _missionController.visualItemsTree.indexForObject(item)
102 var row = root.rowAtIndex(modelIndex)
103 if (row >= 0) {
104 root.forceLayout()
105 root.positionViewAtRow(row, TableView.Visible)
106 }
107 }
108 }
109 }
110
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.
113
114 function _layerGroupRow(nodeType) {
115 switch (nodeType) {
116 case "missionGroup": return _rowFor(_missionController.missionGroupIndex)
117 case "fenceGroup": return _rowFor(_missionController.fenceGroupIndex)
118 case "rallyGroup": return _rowFor(_missionController.rallyGroupIndex)
119 }
120 return -1
121 }
122
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)) {
128 root.collapse(row)
129 }
130 }
131 }
132 }
133
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)
138 if (!layerInfo) {
139 return
140 }
141 if (!mainWindow.allowViewSwitch()) {
142 return
143 }
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)
150 }
151 root.forceLayout()
152 root.positionViewAtRow(targetRow, TableView.AlignTop)
153 }
154 }
155
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)) {
160 root.collapse(row)
161 } else {
162 root.expand(row)
163 }
164 root.forceLayout()
165 }
166
167 // Subtitle text shown on group headers, varies by node type
168 function _groupSubtitle(nodeType) {
169 switch (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) : ""
173 default: return ""
174 }
175 }
176
177 // Icon shown on group headers, matches the layer switcher icons
178 function _groupIcon(nodeType) {
179 return PlanEditLayers.infoForNodeType(nodeType)?.icon ?? ""
180 }
181
182 // Coalesces multiple delegate height changes into a single forceLayout() call
183 Timer {
184 id: layoutTimer
185 interval: 0
186 running: false
187 repeat: false
188 onTriggered: root.forceLayout()
189 }
190
191 // Called by MissionItemEditor delegates when their editor height has settled.
192 function _scrollToMissionItem(delegateItem) {
193 root.forceLayout()
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
198 }
199 }
200
201 delegate: Item {
202 id: delegateRoot
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
207 width: root.width
208
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
216
217 readonly property var nodeObject: model.object
218 readonly property string nodeType: model.nodeType
219 readonly property bool separator: model.separator ?? false
220
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"
224
225 onImplicitHeightChanged: layoutTimer.restart()
226
227 readonly property string _qrcBase: "qrc:/qml/QGroundControl/PlanView/"
228
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.
232 Loader {
233 id: loader
234 width: parent.width
235
236 Component.onCompleted: {
237 switch (delegateRoot.nodeType) {
238 case "planFileGroup":
239 case "defaultsGroup":
240 case "missionGroup":
241 case "fenceGroup":
242 case "rallyGroup":
243 case "transformGroup":
244 sourceComponent = groupHeaderComponent
245 break
246 case "planFileInfo":
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
253 })
254 break
255 case "defaultsInfo":
256 setSource(delegateRoot._qrcBase + "MissionDefaultsEditor.qml", {
257 width: Qt.binding(() => delegateRoot.width),
258 missionController: root._missionController,
259 planMasterController: root.planMasterController
260 })
261 break
262 case "missionItem":
263 if (delegateRoot.nodeObject) {
264 setSource(delegateRoot._qrcBase + "MissionItemEditor.qml", {
265 objectName: "planTree_missionItemEditor",
266 width: Qt.binding(() => delegateRoot.width),
267 map: root.editorMap,
268 missionItem: delegateRoot.nodeObject
269 })
270 }
271 break
272 case "fenceEditor":
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
279 })
280 }
281 break
282 case "rallyHeader":
283 if (delegateRoot.nodeObject) {
284 setSource(delegateRoot._qrcBase + "RallyPointEditorHeader.qml", {
285 objectName: "planTree_rallyHeader",
286 width: Qt.binding(() => delegateRoot.width),
287 controller: root._rallyPointController
288 })
289 }
290 break
291 case "rallyItem":
292 if (delegateRoot.nodeObject) {
293 setSource(delegateRoot._qrcBase + "RallyPointItemEditor.qml", {
294 width: Qt.binding(() => delegateRoot.width),
295 rallyPoint: delegateRoot.nodeObject,
296 controller: root._rallyPointController
297 })
298 }
299 break
300 case "transformEditor":
301 setSource(delegateRoot._qrcBase + "TransformEditor.qml", {
302 width: Qt.binding(() => delegateRoot.width),
303 missionController: root._missionController
304 })
305 break
306 }
307 }
308
309 onLoaded: {
310 if (delegateRoot.nodeType === "missionItem" && item) {
311 item.clicked.connect(function() {
312 root._missionController.setCurrentPlanViewSeqNum(delegateRoot.nodeObject.sequenceNumber, false)
313 })
314 item.remove.connect(function() {
315 var viIndex = root._missionController.visualItemIndexForObject(delegateRoot.nodeObject)
316 if (viIndex > 0) {
317 root._missionController.removeVisualItem(viIndex)
318 }
319 })
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)
325 break
326 }
327 }
328 })
329 item.editorExpandedAndLoaded.connect(function() {
330 root._scrollToMissionItem(delegateRoot)
331 })
332 }
333 }
334 }
335
336 Rectangle {
337 id: separatorLine
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
343 height: 1
344 color: qgcPal.groupBorder
345 visible: delegateRoot.separator
346 }
347
348 // ── Group header (Mission Items / GeoFence / Rally Points) ──
349 Component {
350 id: groupHeaderComponent
351
352 Rectangle {
353 objectName: "planTree_" + delegateRoot.nodeType + "Header"
354 width: delegateRoot.width
355 height: ScreenTools.implicitComboBoxHeight + ScreenTools.defaultFontPixelWidth
356 color: qgcPal.windowShade
357
358 RowLayout {
359 id: groupHeaderRow
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
365
366 QGCColoredImage {
367 Layout.alignment: Qt.AlignVCenter
368 Layout.preferredWidth: ScreenTools.defaultFontPixelHeight * 0.75
369 Layout.preferredHeight: Layout.preferredWidth
370 source: "/InstrumentValueIcons/cheveron-right.svg"
371 color: qgcPal.text
372 rotation: delegateRoot.expanded ? 90 : 0
373 }
374
375 QGCLabel {
376 Layout.alignment: Qt.AlignBaseline
377 text: delegateRoot.nodeObject ? delegateRoot.nodeObject.objectName : ""
378 font.bold: true
379 }
380
381 QGCColoredImage {
382 Layout.alignment: Qt.AlignVCenter
383 Layout.preferredWidth: ScreenTools.defaultFontPixelHeight * 0.75
384 Layout.preferredHeight: Layout.preferredWidth
385 source: root._groupIcon(delegateRoot.nodeType)
386 color: qgcPal.text
387 visible: source.toString() !== ""
388 }
389
390 QGCLabel {
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
397 }
398 }
399
400 MouseArea {
401 anchors.fill: parent
402 onClicked: {
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)
408 } else {
409 if (!mainWindow.allowViewSwitch()) {
410 return
411 }
412 root._toggleGroup(delegateRoot.row)
413 }
414 }
415 }
416 }
417 }
418
419 }
420}