Repository: qgis/QGIS
Pull request #60600: Fix drag and drop for stacked diagrams (follow-up)
Author: login: gacarrillor
Labels: name: GUI/UX
description: Related to QGIS application GUI or User Experience
name: Bug
description: Either a bug report, or a bug fix. Let's hope for the latter!
name: Diagrams
name: backport queued_ltr_backports
description: Queued Backports
Created: 2025-02-13T20:19:07Z | Merged: 2025-03-21T08:14:28Z

Follow-up #60278.

This PR adjusts issues and improves consistency with drag and drop behavior in other parts of QGIS. See #60596 for screencasts of the buggy behavior in the current master.

Namely, this PR:

1. **Replaces the drop indicator**, switching from a box to a horizontal line (which expands to the whole row). The rationale is that stacked diagrams don't handle (yet) a parent-child hierarchy (i.e., one cannot have nested stacked diagrams in the GUI), but act instead as a flat list. This is consistent with drag and drop for other flat lists in QGIS (e.g., classification symbol lists or graduated symbol lists).

2. Fixes an issue that moves the dropped item to the end of the list (i.e., an **append** operation), even if the desired position was, e.g., between other items.

3. Fixes an issue that makes it possible to obtain **copies** of the dragged object.

4. Enables **moving multiple items** and positioning them in the expected way (e.g., if a diagram A is below a diagram B before the drag, it should continue being below B once dropped).

5. Minor fixes: Allow dropping items **below all other items** (i.e., in the blank area), as well as in the whole row span (i.e., also for columns > 0). 

6. **Clear leftover current index** in the view after the drag'n'drop. This is how other flat lists behave, e.g., Graduated and Classification symbology for vector layers.

Screencast of the result:

https://github.com/user-attachments/assets/e32e2180-344f-4cf7-8906-1707677eaf40

Fix #60596 

Linked issue #60596: (Still) Unable to reorder subdiagrams in stacked diagrams dialog
### What is the bug or the crash?

Follow up #59505 (@DelazJ)

Using a QGIS master on top of https://github.com/qgis/QGIS/commit/2027519a153, there are some inconsistencies in the drag and drop behavior that prevent users from reordering subdiagrams. For instance:

 + Most of the times, the dropped subdiagram will not stay in the desired position, but rather be appended (i.e., place in the last position of the list):

    https://github.com/user-attachments/assets/213c3c67-ff8d-4049-84d2-207232c2b2ff

 + It's possible to obtain unwanted copies of dragged & dropped items in a reproducible way:

    https://github.com/user-attachments/assets/e56c98d0-f9de-40e2-9f9e-7e9240dd99fb


### Steps to reproduce the issue

1. Add vector data.
2. Go to `Layer Styles` --> `Diagrams` and choose `Stacked Diagram`.
3. Create a couple of sub diagrams.
4. Attempt to reorder them.

### Versions

<table width='100%' align='center'><tr><td>QGIS version</td><td>3.41.0-Master</td></tr><tr><td>QGIS code revision</td><td><a href="https://github.com/qgis/QGIS/commit/2027519a153">2027519a153</a></td></tr><tr><td colspan="2">&nbsp;</td></tr><tr><td colspan="2"><b>Libraries</b></td></tr><tr><td>Qt version</td><td>5.15.13</td></tr><tr><td>Python version</td><td>3.12.3</td></tr><tr><td>GDAL/OGR version</td><td>3.8.4</td></tr><tr><td>PROJ version</td><td>9.4.0</td></tr><tr><td>EPSG Registry database version</td><td>v11.004 (2024-02-24)</td></tr><tr><td>GEOS version</td><td>3.12.1-CAPI-1.18.1</td></tr><tr><td>SQLite version</td><td>3.45.1</td></tr><tr><td>PostgreSQL client version</td><td>unknown</td></tr><tr><td>SpatiaLite version</td><td>5.1.0</td></td></tr><tr><td>QWT version</td><td>6.1.4</td></tr><tr><td>QScintilla2 version</td><td>2.14.1</td></tr><tr><td>OS version</td><td>Ubuntu 24.04.1 LTS</td></tr><tr><td colspan="2"><i>This copy of QGIS writes debugging output.</i></td></tr><tr><td colspan="2">&nbsp;</td></tr><tr><td colspan="2"><b>Active Python plugins</b></td></tr><tr><td>loadthemall</td><td>3.4</td></tr><tr><td>mapswipetool_plugin</td><td>1.3</td></tr><tr><td>AppendFeaturesToLayer</td><td>2.0.0</td></tr><tr><td>linking_relation_editor</td><td>v1.4.0</td></tr><tr><td>QgisModelBaker</td><td>v7.10.6</td></tr><tr><td>quick_map_services</td><td>0.19.36</td></tr><tr><td>qfieldsync</td><td>v4.12.0</td></tr><tr><td>french_locator_filter</td><td>1.1.1</td></tr><tr><td>pzp</td><td>0.0.3</td></tr><tr><td>swiss_locator</td><td>4.0.0</td></tr><tr><td>pg_service_parser</td><td>0.6.0</td></tr><tr><td>firstaid</td><td>3.2.0</td></tr><tr><td>InvisibleLayersAndGroups</td><td>2.2</td></tr><tr><td>db_manager</td><td>0.1.20</td></tr><tr><td>processing</td><td>2.12.99</td></tr><tr><td>MetaSearch</td><td>0.3.6</td></tr><tr><td>grassprovider</td><td>2.12.99</td></tr></table>

### Supported QGIS version

- [x] I'm running a supported QGIS version according to [the roadmap](https://www.qgis.org/en/site/getinvolved/development/roadmap.html#release-schedule).

### New profile

- [x] I tried with a new [QGIS profile](https://docs.qgis.org/latest/en/docs/user_manual/introduction/qgis_configuration.html#working-with-user-profiles)

### Additional context

Tested on Ubuntu 24.04 LTS and Windows 11.

Diff:
diff --git a/src/gui/vector/qgsstackeddiagramproperties.cpp b/src/gui/vector/qgsstackeddiagramproperties.cpp
index f927045686d4..f37511653859 100644
--- a/src/gui/vector/qgsstackeddiagramproperties.cpp
+++ b/src/gui/vector/qgsstackeddiagramproperties.cpp
@@ -63,10 +63,14 @@ QgsStackedDiagramProperties::QgsStackedDiagramProperties( QgsVectorLayer *layer,
   mModel = new QgsStackedDiagramPropertiesModel();
   mSubDiagramsView->setModel( mModel );
 
+  mSubDiagramsView->setStyle( new QgsStackedDiagramsViewStyle( mSubDiagramsView ) );
+
   connect( mModel, &QAbstractItemModel::dataChanged, this, &QgsStackedDiagramProperties::widgetChanged );
   connect( mModel, &QAbstractItemModel::rowsInserted, this, &QgsStackedDiagramProperties::widgetChanged );
   connect( mModel, &QAbstractItemModel::rowsRemoved, this, &QgsStackedDiagramProperties::widgetChanged );
 
+  connect( mModel, &QgsStackedDiagramPropertiesModel::subDiagramsMoved, this, &QgsStackedDiagramProperties::clearCurrentIndex );
+
   syncToLayer();
 }
 
@@ -86,7 +90,7 @@ void QgsStackedDiagramProperties::addSubDiagramRenderer()
     const QModelIndex currentIndex = mSubDiagramsView->selectionModel()->currentIndex();
     mModel->insertSubDiagram( currentIndex.row() + 1, renderer.release() );
     const QModelIndex newIndex = mModel->index( currentIndex.row() + 1, 0 );
-    mSubDiagramsView->selectionModel()->setCurrentIndex( newIndex, QItemSelectionModel::ClearAndSelect );
+    mSubDiagramsView->selectionModel()->setCurrentIndex( newIndex, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows );
   }
   else
   {
@@ -101,7 +105,7 @@ void QgsStackedDiagramProperties::appendSubDiagramRenderer( QgsDiagramRenderer *
   const int rows = mModel->rowCount();
   mModel->insertSubDiagram( rows, dr ); // Transfers ownership
   const QModelIndex newIndex = mModel->index( rows, 0 );
-  mSubDiagramsView->selectionModel()->setCurrentIndex( newIndex, QItemSelectionModel::ClearAndSelect );
+  mSubDiagramsView->selectionModel()->setCurrentIndex( newIndex, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows );
 }
 
 void QgsStackedDiagramProperties::editSubDiagramRenderer()
@@ -169,6 +173,11 @@ void QgsStackedDiagramProperties::removeSubDiagramRenderer()
   mSubDiagramsView->selectionModel()->clear();
 }
 
+void QgsStackedDiagramProperties::clearCurrentIndex()
+{
+  mSubDiagramsView->selectionModel()->clearCurrentIndex();
+}
+
 void QgsStackedDiagramProperties::syncToLayer()
 {
   const QgsDiagramRenderer *dr = mLayer->diagramRenderer();
@@ -197,6 +206,8 @@ void QgsStackedDiagramProperties::syncToLayer()
 
     const QgsDiagramLayerSettings *dls = mLayer->diagramLayerSettings();
     mModel->updateDiagramLayerSettings( *dls );
+
+    mSubDiagramsView->selectionModel()->clear();
   }
 }
 
@@ -372,12 +383,16 @@ QgsStackedDiagramPropertiesModel::~QgsStackedDiagramPropertiesModel()
 
 Qt::ItemFlags QgsStackedDiagramPropertiesModel::flags( const QModelIndex &index ) const
 {
-  const Qt::ItemFlag checkable = ( index.column() == 0 ? Qt::ItemIsUserCheckable : Qt::NoItemFlags );
+  // Flat list, to ease drop handling valid indexes are not dropEnabled
+  if ( !index.isValid() )
+    return Qt::ItemIsDropEnabled;
 
-  // allow drop only at first column
-  const Qt::ItemFlag drop = ( index.column() == 0 ? Qt::ItemIsDropEnabled : Qt::NoItemFlags );
+  Qt::ItemFlags flags = Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled;
 
-  return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | drop | checkable;
+  if ( index.column() == 0 )
+    flags |= Qt::ItemIsUserCheckable;
+
+  return flags;
 }
 
 Qt::DropActions QgsStackedDiagramPropertiesModel::supportedDropActions() const
@@ -399,7 +414,11 @@ QMimeData *QgsStackedDiagramPropertiesModel::mimeData( const QModelIndexList &in
 
   QDataStream stream( &encodedData, QIODevice::WriteOnly );
 
-  for ( const QModelIndex &index : indexes )
+  // Sort indexes since their order reflects selection order
+  QModelIndexList sortedIndexes = indexes;
+  std::sort( sortedIndexes.begin(), sortedIndexes.end() );
+
+  for ( const QModelIndex &index : std::as_const( sortedIndexes ) )
   {
     // each item consists of several columns - let's add it with just first one
     if ( !index.isValid() || index.column() != 0 )
@@ -423,6 +442,7 @@ QMimeData *QgsStackedDiagramPropertiesModel::mimeData( const QModelIndexList &in
 bool QgsStackedDiagramPropertiesModel::dropMimeData( const QMimeData *data, Qt::DropAction action, int row, int column, const QModelIndex &parent )
 {
   Q_UNUSED( column )
+  Q_UNUSED( parent )
 
   if ( action == Qt::IgnoreAction )
     return true;
@@ -430,17 +450,14 @@ bool QgsStackedDiagramPropertiesModel::dropMimeData( const QMimeData *data, Qt::
   if ( !data->hasFormat( QStringLiteral( "application/vnd.text.list" ) ) )
     return false;
 
-  if ( parent.column() > 0 )
-    return false;
-
   QByteArray encodedData = data->data( QStringLiteral( "application/vnd.text.list" ) );
   QDataStream stream( &encodedData, QIODevice::ReadOnly );
   int rows = 0;
 
+  // the item was dropped at parent, we may decide where to put the items
   if ( row == -1 )
   {
-    // the item was dropped at a parent - we may decide where to put the items - let's append them
-    row = rowCount( parent );
+    row = mRenderers.count(); // let's append them (e.g., drop in empty space)
   }
 
   while ( !stream.atEnd() )
@@ -480,6 +497,7 @@ bool QgsStackedDiagramPropertiesModel::dropMimeData( const QMimeData *data, Qt::
     }
   }
 
+  emit subDiagramsMoved(); // Let views know they can clean some things up
   return true;
 }
 
@@ -681,3 +699,24 @@ QgsDiagramLayerSettings QgsStackedDiagramPropertiesModel::diagramLayerSettings()
 {
   return mDiagramLayerSettings;
 }
+
+// ------------------------------ View style --------------------------------
+QgsStackedDiagramsViewStyle::QgsStackedDiagramsViewStyle( QWidget *parent )
+  : QgsProxyStyle( parent )
+{}
+
+void QgsStackedDiagramsViewStyle::drawPrimitive( PrimitiveElement element, const QStyleOption *option, QPainter *painter, const QWidget *widget ) const
+{
+  if ( element == QStyle::PE_IndicatorItemViewItemDrop && !option->rect.isNull() )
+  {
+    QStyleOption opt( *option );
+    opt.rect.setLeft( 0 );
+    // draw always as line above, because we move item to that index
+    opt.rect.setHeight( 0 );
+    if ( widget )
+      opt.rect.setRight( widget->width() );
+    QProxyStyle::drawPrimitive( element, &opt, painter, widget );
+    return;
+  }
+  QProxyStyle::drawPrimitive( element, option, painter, widget );
+}
diff --git a/src/gui/vector/qgsstackeddiagramproperties.h b/src/gui/vector/qgsstackeddiagramproperties.h
index 5f9fe54f7233..39970c3626ca 100644
--- a/src/gui/vector/qgsstackeddiagramproperties.h
+++ b/src/gui/vector/qgsstackeddiagramproperties.h
@@ -24,6 +24,7 @@
 #include "qgis_gui.h"
 #include "qgsdiagramrenderer.h"
 #include "ui_qgsstackeddiagrampropertiesbase.h"
+#include "qgsproxystyle.h"
 
 #include <QWidget>
 #include <QDialog>
@@ -88,11 +89,34 @@ class GUI_EXPORT QgsStackedDiagramPropertiesModel : public QAbstractTableModel
      */
     void updateDiagramLayerSettings( QgsDiagramLayerSettings dls );
 
+  signals:
+    //! Informs views that subdiagrams were moved in the model.
+    void subDiagramsMoved();
+
   protected:
     QList<QgsDiagramRenderer *> mRenderers;
     QgsDiagramLayerSettings mDiagramLayerSettings;
 };
 
+/**
+ * \ingroup gui
+ * \brief View style which shows drop indicator line between items
+ *
+ * \since QGIS 3.40.6
+ */
+class QgsStackedDiagramsViewStyle : public QgsProxyStyle
+{
+    Q_OBJECT
+
+  public:
+    /**
+    * Constructor for QgsStackedDiagramsViewStyle
+    * \param parent parent object
+    */
+    explicit QgsStackedDiagramsViewStyle( QWidget *parent );
+
+    void drawPrimitive( PrimitiveElement element, const QStyleOption *option, QPainter *painter, const QWidget *widget = nullptr ) const override;
+};
 
 /**
  * \ingroup gui
@@ -118,6 +142,13 @@ class GUI_EXPORT QgsStackedDiagramProperties : public QgsPanelWidget, private Ui
   public slots:
     void apply();
 
+    /**
+     * Clears current item from the view.
+     *
+     * \since QGIS 3.40.6
+     */
+    void clearCurrentIndex();
+
   private slots:
 
     /**


Reviews:
user: login: nirvn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T14:39:21Z
user: login: gacarrillor
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-26T15:05:11Z
user: login: gacarrillor
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T13:55:38Z
user: login: gacarrillor
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T14:06:19Z
user: login: nirvn
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T02:07:34Z

Review comments:
diff_hunk: @@ -1505,6 +1505,7 @@
     ],
     "QgsStackedDiagramProperties": [
         "apply()",
+        "rowsMoved()",
path: tests/code_layout/acceptable_missing_doc.py
user: login: nirvn
body: Can you add documentation instead of expanding the missing doc list? 
created_at: 2025-02-26T14:05:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -404,23 +411,15 @@ QMimeData *QgsStackedDiagramPropertiesModel::mimeData( const QModelIndexList &in
 
   QDataStream stream( &encodedData, QIODevice::WriteOnly );
 
-  for ( const QModelIndex &index : indexes )
+  // Create list of rows
+  const auto constIndexes = indexes;
+  for ( const QModelIndex &index : constIndexes )
   {
-    // each item consists of several columns - let's add it with just first one
     if ( !index.isValid() || index.column() != 0 )
       continue;
 
-    if ( QgsDiagramRenderer *diagram = mRenderers.at( index.row() ) )
-    {
-      QDomDocument doc;
-
-      QDomElement rootElem = doc.createElement( QStringLiteral( "diagram_mime" ) );
-      diagram->writeXml( rootElem, doc, QgsReadWriteContext() );
-      doc.appendChild( rootElem );
-      stream << doc.toString( -1 );
-    }
+    stream << index.row();
path: src/gui/vector/qgsstackeddiagramproperties.cpp
user: login: nirvn
body: This is not ideal, we're abusing mimeData to pass on a simple row index integer, instead of copying the actual diagram. This means we won't be able to do stuff like right-click -> copy diagram to then paste as new diagram, etc.

What's the rational behind this move?
created_at: 2025-02-26T14:09:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -404,23 +411,15 @@ QMimeData *QgsStackedDiagramPropertiesModel::mimeData( const QModelIndexList &in
 
   QDataStream stream( &encodedData, QIODevice::WriteOnly );
 
-  for ( const QModelIndex &index : indexes )
+  // Create list of rows
+  const auto constIndexes = indexes;
+  for ( const QModelIndex &index : constIndexes )
   {
-    // each item consists of several columns - let's add it with just first one
     if ( !index.isValid() || index.column() != 0 )
       continue;
 
-    if ( QgsDiagramRenderer *diagram = mRenderers.at( index.row() ) )
-    {
-      QDomDocument doc;
-
-      QDomElement rootElem = doc.createElement( QStringLiteral( "diagram_mime" ) );
-      diagram->writeXml( rootElem, doc, QgsReadWriteContext() );
-      doc.appendChild( rootElem );
-      stream << doc.toString( -1 );
-    }
+    stream << index.row();
path: src/gui/vector/qgsstackeddiagramproperties.cpp
user: login: gacarrillor
body: Understood, I'll go back to the proper mimeData content while keeping the fixes this PR introduces.

BTW, I took graduated and classification symbology for vector layers, as well as classification from point clouds as a basis. They seem to follow another approach with respect to copy&paste, but I agree we should keep the actual diagram's XML instead, thanks.
created_at: 2025-02-26T15:05:11Z
author_association: MEMBER
diff_hunk: @@ -1505,6 +1505,7 @@
     ],
     "QgsStackedDiagramProperties": [
         "apply()",
+        "rowsMoved()",
path: tests/code_layout/acceptable_missing_doc.py
user: login: gacarrillor
body: Method documented
created_at: 2025-03-07T13:55:38Z
author_association: MEMBER
diff_hunk: @@ -404,23 +411,15 @@ QMimeData *QgsStackedDiagramPropertiesModel::mimeData( const QModelIndexList &in
 
   QDataStream stream( &encodedData, QIODevice::WriteOnly );
 
-  for ( const QModelIndex &index : indexes )
+  // Create list of rows
+  const auto constIndexes = indexes;
+  for ( const QModelIndex &index : constIndexes )
   {
-    // each item consists of several columns - let's add it with just first one
     if ( !index.isValid() || index.column() != 0 )
       continue;
 
-    if ( QgsDiagramRenderer *diagram = mRenderers.at( index.row() ) )
-    {
-      QDomDocument doc;
-
-      QDomElement rootElem = doc.createElement( QStringLiteral( "diagram_mime" ) );
-      diagram->writeXml( rootElem, doc, QgsReadWriteContext() );
-      doc.appendChild( rootElem );
-      stream << doc.toString( -1 );
-    }
+    stream << index.row();
path: src/gui/vector/qgsstackeddiagramproperties.cpp
user: login: gacarrillor
body: Addressed, I rebased to keep a clean commit history.

Now we have the fixes and still will be able to support the copying of diagrams.
Thanks for the suggestion.
created_at: 2025-03-07T14:06:19Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-02-13T21:52:50Z
body: <!-- !!## stickymarker ##!! -->

<!-- ====startsection mingw64-->
### 🪟 Windows builds
Download [Windows builds of this PR for testing](https://github.com/qgis/QGIS/actions/runs/13896277758/artifacts/2763773599).
Debug symbols for this build are available [here](https://github.com/qgis/QGIS/actions/runs/13896277758/artifacts/2763774042).
*(Built from commit 5faf09470b1a5efb9f7a1f347d10a70fbc5165b8)*

<!-- ====endsection mingw64-->

<!-- ====startsection mingw64-qt6-->
### 🪟 Windows Qt6 builds
Download [Windows Qt6 builds of this PR for testing](https://github.com/qgis/QGIS/actions/runs/13896277807/artifacts/2763607006).
*(Built from commit 5faf09470b1a5efb9f7a1f347d10a70fbc5165b8)*

<!-- ====endsection mingw64-qt6-->

author_association: CONTRIBUTOR
user: login: gacarrillor
created_at: 2025-02-14T10:53:08Z
body: (Unrelated PyQgsWFSProvider test failure)
author_association: MEMBER
user: login: gacarrillor
created_at: 2025-02-20T17:32:04Z
body: (Rebased)
author_association: MEMBER
user: login: gacarrillor
created_at: 2025-03-07T14:10:14Z
body: Thanks for the review @nirvn, I've addressed the comments to keep the fixes while keeping copy&paste eventual addition still possible (which, by the way I'd be happy to add in another PR).
author_association: MEMBER
user: login: gacarrillor
created_at: 2025-03-08T12:53:45Z
body: The last commit clears the current item from the view. As you can see in this screencast, the current item is a leftover, since it sticks to the dragged item's index, showing no valuable information to users.


https://github.com/user-attachments/assets/9702d18b-617a-4827-a24d-f4f0d5f51a2f

Additionally, this clearing brings some harmonization with other flat lists after a drag'n'drop, e.g., Graduated and Classification ones for vector layer symbology.


author_association: MEMBER
user: login: gacarrillor
created_at: 2025-03-10T09:33:57Z
body: (Rebased to build on top of working CI)
author_association: MEMBER