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 int _layerMission: 1
28 readonly property int _layerFence: 2
29 readonly property int _layerRally: 3
30 readonly property bool _createNewPlanMode: planMasterController.showCreateFromTemplate
31
32 on_CreateNewPlanModeChanged: {
33 if (_createNewPlanMode) {
34 var planFileRow = _rowFor(_missionController.planFileGroupIndex)
35 if (!root.isExpanded(planFileRow)) {
36 root.expand(planFileRow)
37 }
38 root.contentY = 0
39 }
40 }
41
42 property var _missionController: planMasterController.missionController
43 property var _geoFenceController: planMasterController.geoFenceController
44 property var _rallyPointController: planMasterController.rallyPointController
45
46 // Helper: convert a persistent model index to the current visual row
47 function _rowFor(modelIndex) { return root.rowAtIndex(modelIndex) }
48
49 // QGCFlickableScrollIndicator expects parent to have indicatorColor (provided by QGCFlickable/QGCListView)
50 property color indicatorColor: qgcPal.text
51
52 QGCPalette { id: qgcPal; colorGroupEnabled: enabled }
53
54 QGCFlickableScrollIndicator { parent: root; orientation: QGCFlickableScrollIndicator.Horizontal }
55 QGCFlickableScrollIndicator { parent: root; orientation: QGCFlickableScrollIndicator.Vertical }
56
57 property int _lastMissionItemCount: 0
58
59 Connections {
60 target: root._missionController.visualItems
61 function onCountChanged() {
62 var newCount = _missionController.visualItems ? _missionController.visualItems.count : 0
63 if (newCount > root._lastMissionItemCount) {
64 // First waypoint added — collapse Plan Info and Defaults
65 if (root._lastMissionItemCount <= 1 && newCount > 1) {
66 var planFileRow = _rowFor(_missionController.planFileGroupIndex)
67 if (root.isExpanded(planFileRow)) {
68 root.collapse(planFileRow)
69 }
70 var defaultsRow = _rowFor(_missionController.defaultsGroupIndex)
71 if (root.isExpanded(defaultsRow)) {
72 root.collapse(defaultsRow)
73 }
74 }
75 // Expand mission group and scroll to the new item
76 var missionRow = _rowFor(_missionController.missionGroupIndex)
77 if (!root.isExpanded(missionRow)) {
78 root.expand(missionRow)
79 }
80 // Scroll happens when the editor signals editorExpandedAndLoaded
81 }
82 root._lastMissionItemCount = newCount
83 }
84 }
85
86 Connections {
87 target: root._missionController
88 function onVisualItemsReset() {
89 root.collapseRecursively()
90 if (_missionController.containsItems) {
91 // Non-empty plan: expand mission group
92 root.expand(_rowFor(_missionController.missionGroupIndex))
93 } else {
94 // Empty plan: expand Plan Info and Defaults, scroll to top
95 root.expand(_rowFor(_missionController.planFileGroupIndex))
96 root.expand(_rowFor(_missionController.defaultsGroupIndex))
97 root.contentY = 0
98 }
99 root._lastMissionItemCount = _missionController.visualItems ? _missionController.visualItems.count : 0
100 root.editingLayerChangeRequested(root._layerMission)
101 }
102 function onPlanViewStateChanged() {
103 // Current item changed — bring it on-screen if completely off-screen.
104 // Fine-tuned scroll happens later via editorExpandedAndLoaded.
105 var item = _missionController.currentPlanViewItem
106 if (item) {
107 var modelIndex = _missionController.visualItemsTree.indexForObject(item)
108 var row = root.rowAtIndex(modelIndex)
109 if (row >= 0) {
110 root.forceLayout()
111 root.positionViewAtRow(row, TableView.Visible)
112 }
113 }
114 }
115 }
116
117 // Public API: select a layer and expand its group. Called by the layer tool buttons.
118 function selectLayer(nodeType) {
119 let targetRow = -1
120 switch (nodeType) {
121 case "missionGroup":
122 targetRow = _rowFor(_missionController.missionGroupIndex)
123 editingLayerChangeRequested(_layerMission)
124 break
125 case "fenceGroup":
126 targetRow = _rowFor(_missionController.fenceGroupIndex)
127 editingLayerChangeRequested(_layerFence)
128 break
129 case "rallyGroup":
130 targetRow = _rowFor(_missionController.rallyGroupIndex)
131 editingLayerChangeRequested(_layerRally)
132 break
133 }
134
135 if (targetRow >= 0) {
136 if (!root.isExpanded(targetRow))
137 root.expand(targetRow)
138 root.forceLayout()
139 root.positionViewAtRow(targetRow, TableView.AlignTop)
140 }
141 }
142
143 // Toggle expand/collapse for a group header. Does not affect the editing layer.
144 // Caller is responsible for calling allowViewSwitch() before invoking this.
145 function _toggleGroup(row) {
146 if (root.isExpanded(row)) {
147 root.collapse(row)
148 } else {
149 root.expand(row)
150 }
151 root.forceLayout()
152 }
153
154 // Subtitle text shown on group headers, varies by node type
155 function _groupSubtitle(nodeType) {
156 switch (nodeType) {
157 case "planFileGroup": return planMasterController.currentPlanFileName === "" ? qsTr("<Untitled>") : planMasterController.currentPlanFileName
158 case "missionGroup": return _missionController.visualItems ? (_missionController.visualItems.count - 1) + qsTr(" items") : ""
159 case "rallyGroup": return _rallyPointController.points ? _rallyPointController.points.count + qsTr(" points") : ""
160 default: return ""
161 }
162 }
163
164 // Coalesces multiple delegate height changes into a single forceLayout() call
165 Timer {
166 id: layoutTimer
167 interval: 0
168 running: false
169 repeat: false
170 onTriggered: root.forceLayout()
171 }
172
173 // Called by MissionItemEditor delegates when their editor height has settled.
174 function _scrollToMissionItem(delegateItem) {
175 root.forceLayout()
176 var bottomY = delegateItem.mapToItem(root.contentItem, 0, delegateItem.height).y
177 var neededContentY = bottomY - root.height
178 if (neededContentY > root.contentY) {
179 root.contentY = neededContentY
180 }
181 }
182
183 delegate: Item {
184 id: delegateRoot
185 implicitWidth: root.width
186 implicitHeight: (loader.item ? loader.item.height : 1) + (separatorLine.visible ? separatorLine.height + root.rowSpacing : 0)
187 visible: !root._createNewPlanMode || _visibleInCreateMode
188 height: visible ? implicitHeight : 0
189 width: root.width
190
191 required property TreeView treeView
192 required property bool isTreeNode
193 required property bool expanded
194 required property bool hasChildren
195 required property int depth
196 required property int row
197 required property var model
198
199 readonly property var nodeObject: model.object
200 readonly property string nodeType: model.nodeType
201 readonly property bool separator: model.separator ?? false
202
203 // In create-new-plan mode, only show Plan Info and Defaults groups and their children
204 readonly property bool _visibleInCreateMode: nodeType === "planFileGroup" || nodeType === "planFileInfo"
205 || nodeType === "defaultsGroup" || nodeType === "defaultsInfo"
206
207 onImplicitHeightChanged: layoutTimer.restart()
208
209 readonly property string _qrcBase: "qrc:/qml/QGroundControl/PlanView/"
210
211 // We use setSource() instead of sourceComponent so that required properties
212 // (e.g. missionItem) are injected before internal bindings activate,
213 // preventing "Cannot read property of null" warnings.
214 Loader {
215 id: loader
216 width: parent.width
217
218 Component.onCompleted: {
219 switch (delegateRoot.nodeType) {
220 case "planFileGroup":
221 case "defaultsGroup":
222 case "missionGroup":
223 case "fenceGroup":
224 case "rallyGroup":
225 case "transformGroup":
226 sourceComponent = groupHeaderComponent
227 break
228 case "planFileInfo":
229 setSource(delegateRoot._qrcBase + "PlanInfoEditor.qml", {
230 width: Qt.binding(() => delegateRoot.width),
231 planMasterController: root.planMasterController,
232 missionController: root._missionController,
233 editorMap: root.editorMap
234 })
235 break
236 case "defaultsInfo":
237 setSource(delegateRoot._qrcBase + "MissionDefaultsEditor.qml", {
238 width: Qt.binding(() => delegateRoot.width),
239 missionController: root._missionController,
240 planMasterController: root.planMasterController
241 })
242 break
243 case "missionItem":
244 if (delegateRoot.nodeObject) {
245 setSource(delegateRoot._qrcBase + "MissionItemEditor.qml", {
246 width: Qt.binding(() => delegateRoot.width),
247 map: root.editorMap,
248 missionItem: delegateRoot.nodeObject
249 })
250 }
251 break
252 case "fenceEditor":
253 if (delegateRoot.nodeObject) {
254 setSource(delegateRoot._qrcBase + "GeoFenceEditor.qml", {
255 width: Qt.binding(() => delegateRoot.width),
256 myGeoFenceController: root._geoFenceController,
257 flightMap: root.editorMap
258 })
259 }
260 break
261 case "rallyHeader":
262 if (delegateRoot.nodeObject) {
263 setSource(delegateRoot._qrcBase + "RallyPointEditorHeader.qml", {
264 width: Qt.binding(() => delegateRoot.width),
265 controller: root._rallyPointController
266 })
267 }
268 break
269 case "rallyItem":
270 if (delegateRoot.nodeObject) {
271 setSource(delegateRoot._qrcBase + "RallyPointItemEditor.qml", {
272 width: Qt.binding(() => delegateRoot.width),
273 rallyPoint: delegateRoot.nodeObject,
274 controller: root._rallyPointController
275 })
276 }
277 break
278 case "transformEditor":
279 setSource(delegateRoot._qrcBase + "TransformEditor.qml", {
280 width: Qt.binding(() => delegateRoot.width),
281 missionController: root._missionController
282 })
283 break
284 }
285 }
286
287 onLoaded: {
288 if (delegateRoot.nodeType === "missionItem" && item) {
289 item.clicked.connect(function() {
290 root._missionController.setCurrentPlanViewSeqNum(delegateRoot.nodeObject.sequenceNumber, false)
291 })
292 item.remove.connect(function() {
293 var viIndex = root._missionController.visualItemIndexForObject(delegateRoot.nodeObject)
294 if (viIndex > 0) {
295 root._missionController.removeVisualItem(viIndex)
296 }
297 })
298 item.selectNextNotReadyItem.connect(function() {
299 for (var i = 0; i < root._missionController.visualItems.count; i++) {
300 var vmi = root._missionController.visualItems.get(i)
301 if (vmi.readyForSaveState === VisualMissionItem.NotReadyForSaveData) {
302 root._missionController.setCurrentPlanViewSeqNum(vmi.sequenceNumber, true)
303 break
304 }
305 }
306 })
307 item.editorExpandedAndLoaded.connect(function() {
308 root._scrollToMissionItem(delegateRoot)
309 })
310 }
311 }
312 }
313
314 Rectangle {
315 id: separatorLine
316 anchors.margins: ScreenTools.defaultFontPixelWidth * 0.5
317 anchors.topMargin: root.rowSpacing
318 anchors.top: loader.bottom
319 anchors.left: parent.left
320 anchors.right: parent.right
321 height: 1
322 color: qgcPal.groupBorder
323 visible: delegateRoot.separator
324 }
325
326 // ── Group header (Mission Items / GeoFence / Rally Points) ──
327 Component {
328 id: groupHeaderComponent
329
330 Rectangle {
331 width: delegateRoot.width
332 height: ScreenTools.implicitComboBoxHeight + ScreenTools.defaultFontPixelWidth
333 color: qgcPal.windowShade
334
335 RowLayout {
336 id: groupHeaderRow
337 spacing: ScreenTools.defaultFontPixelWidth * 0.5
338 anchors.verticalCenter: parent.verticalCenter
339 anchors.left: parent.left
340 anchors.right: parent.right
341 anchors.margins: ScreenTools.defaultFontPixelWidth * 0.5
342
343 QGCColoredImage {
344 Layout.alignment: Qt.AlignVCenter
345 Layout.preferredWidth: ScreenTools.defaultFontPixelHeight * 0.75
346 Layout.preferredHeight: Layout.preferredWidth
347 source: "/InstrumentValueIcons/cheveron-right.svg"
348 color: qgcPal.text
349 rotation: delegateRoot.expanded ? 90 : 0
350 }
351
352 QGCLabel {
353 Layout.alignment: Qt.AlignBaseline
354 text: delegateRoot.nodeObject ? delegateRoot.nodeObject.objectName : ""
355 font.bold: true
356 }
357
358 QGCLabel {
359 Layout.alignment: Qt.AlignBaseline
360 Layout.fillWidth: true
361 text: root._groupSubtitle(delegateRoot.nodeType)
362 elide: Text.ElideRight
363 font.pointSize: ScreenTools.smallFontPointSize
364 color: qgcPal.colorGrey
365 }
366 }
367
368 MouseArea {
369 anchors.fill: parent
370 onClicked: {
371 if (!mainWindow.allowViewSwitch()) {
372 return
373 }
374 root._toggleGroup(delegateRoot.row)
375 }
376 }
377 }
378 }
379
380 }
381}