Skip to content

Windows build: layout grouping fix + style manager fix + nested groups - #1

Open
vicquick wants to merge 14 commits into
masterfrom
integration/win-build
Open

Windows build: layout grouping fix + style manager fix + nested groups#1
vicquick wants to merge 14 commits into
masterfrom
integration/win-build

Conversation

@vicquick

Copy link
Copy Markdown
Owner

Integration branch for local Windows builds. Not for upstream.

Contains on top of upstream master:

Opened only to trigger the Windows Qt6 build workflow so the artifact can be downloaded.

Known: the nested-groups commits add public methods to QgsLayoutItemGroup, QgsLayoutModel and QgsLayoutViewToolSelect without regenerating the SIP bindings, so the Code Layout check will fail. Does not affect the Windows build.

vicquick and others added 14 commits August 26, 2026 12:39
styleClicked() was connected to QAbstractItemView::clicked with a
direct connection. When the user clicks a saved style in the Layer
Styling panel's Style Manager tab, styleClicked() calls
setCurrentStyle() synchronously from inside
QListView::mouseReleaseEvent().

setCurrentStyle() emits currentStyleChanged(), which triggers
currentStyleChanged() → mStyleList->setCurrentIndex(). This modifies
the view's selection model while mouseReleaseEvent still holds a
QPersistentModelIndex (d->pressedIndex). On Qt 6, the invalidated
persistent index causes a use-after-free when mouseReleaseEvent
continues after the clicked() handler returns.

On Windows: access violation (0xbaadf00d = freed heap).
On Linux: SIGSEGV → SIGABRT.

The crash does NOT happen when switching styles via:
- Right-click layer → Styles submenu (QAction::triggered fires from
  the menu event loop, outside any view event handler)
- Layer Properties dialog (QComboBox::currentIndexChanged, also
  outside a view event handler)

Fix: change the clicked → styleClicked connection to
Qt::QueuedConnection, so styleClicked() runs on the next event loop
iteration — after mouseReleaseEvent has fully completed and released
d->pressedIndex.

Assisted-by: Claude (Anthropic)
…te rows

QgsLayoutItemsListView::updateSelection() was connected to
QItemSelectionModel::selectionChanged with a direct connection. When
the source QgsLayoutModel begins a row insertion (for example when
creating a new group via QgsLayoutView::groupSelectedItems ->
addLayoutItem -> rebuildZList -> beginInsertRows) and there are
persistent indices held by the items list view's selection model,
those indices shift and QItemSelectionModel emits selectionChanged
synchronously, from inside the still-open beginInsertRows transaction.

updateSelection() then runs while the source model is mid-transaction
and calls setSelected() on layout items, which emits dataChanged() on
the source model. Emitting dataChanged from inside a
beginInsertRows/endInsertRows bracket is a QAbstractItemModel contract
violation.

The observable consequences differed by Qt version:

- Qt 6.8: QTreeView::dataChanged eagerly touches the selection model,
  which re-emits selectionChanged, re-enters updateSelection(), and
  the cycle recurses until the stack overflows.

- Qt 6.9+: The mid-transaction dataChanged corrupts
  QSortFilterProxyModel's internal source-to-proxy mapping cache. The
  newly inserted row ends up with two proxy rows mapping to the same
  source row, so the Items list panel visibly shows a duplicate entry
  for the newly created group. Closing and reopening the layout
  rebuilds the proxy from scratch and the duplicate disappears.

Both symptoms share the same root cause: updateSelection() running
synchronously inside an open source-model transaction. Making the
selectionChanged -> updateSelection connection a Qt::QueuedConnection
defers the slot to the next event loop iteration, by which time
endInsertRows() has fired and the proxy is in a consistent state.

The mUpdatingSelection re-entry guard is also added to
updateSelection()'s early return, matching the guard that
onItemFocused() already uses. With Qt::QueuedConnection this is
defense in depth, but it is cheap and protects against future
refactors that might reintroduce a synchronous path into the slot.

Fixes qgis#61702

Assisted-by: Claude (Anthropic)
- QgsLayoutModel: implement parent(), restructure index()/rowCount()
  so child items of a QgsLayoutItemGroup appear nested under their
  group instead of flat at the root. Two helpers, topLevelItemsInScene()
  and childItemsInScene(group), filter the existing scene-order list
  by parentGroup() membership. The root sentinel at top-level row 0
  is preserved (proxy filters hide it from the user-facing panel).
- QgsLayoutModel::indexForItem(): walk up parentGroup() chain to
  build the hierarchical index, instead of the flat row+1 lookup.
- QgsLayoutItemsListView: setIndentation(16), setRootIsDecorated(true),
  setAnimated(true) so the QTreeView actually renders the hierarchy
  with disclosure arrows now that the model exposes it.

Drag-and-drop reorder still rejects non-root parents pending
per-group local z-stack support in QgsLayoutItemGroup.

Assisted-by: Claude Opus 4.7
- QgsLayoutItemGroup gains reorderItemUp/Down/ToTop/ToBottom that
  mutate the group's mItems QList (which now has documented semantics:
  index 0 = topmost member, last index = bottommost). Persistence to
  ComposerItemGroupElement was already iteration-order based, so the
  local z-stack survives save/reload with no schema change.
- QgsLayoutModel::childItemsInScene now defers to QgsLayoutItemGroup::items()
  for ordering, so reorder methods are immediately reflected in the
  layout items panel tree.
- QgsLayout::groupItems sorts incoming selection by current global
  zValue (descending) before populating the new group, so the visually
  topmost selected item becomes the top of the group's local stack —
  matching the user's mental model when grouping a stack of items.

Assisted-by: Claude Opus 4.7
QgsLayoutViewToolSelect:
- The existing CTRL stack-cycle now also skips the "promote to top-level
  group" step, letting users click straight onto a nested member instead
  of always grabbing the enclosing group. Mirrors Adobe Illustrator's
  Group Selection / Direct Selection semantics with a single modifier.
- Double-click on a group (or on any item inside one) enters isolation
  mode: every other layout item is dimmed to 25 % opacity so editing
  happens visually scoped to the group. Pages stay full opacity.
- Esc, double-click on empty space, or tool deactivation restores
  every dimmed item to its original opacity. Original opacities are
  cached in mDimmedItems so a user-set opacity is preserved.
- mIsolatedGroup tracked via QPointer so a deleted group is harmless.

Assisted-by: Claude Opus 4.7
QgsLayoutItemsListView::updateSelection used to walk parentGroup() up
to the outermost group and add it to the layout selection alongside the
clicked child. That was useful when the panel was the only way to grab
the group at all, but now that the canvas tool handles the click → group
promotion (and Ctrl-click handles drill-in), the panel becomes the
precise / direct selection surface — Adobe Layers panel semantics.

A click on a child row now selects exactly that child. Group rows still
work the same: clicking the group row selects the group.

Assisted-by: Claude Opus 4.7
The inline accessor returned QgsLayoutItemGroup* from a
QPointer<QgsLayoutItemGroup> member, which requires the complete
QgsLayoutItemGroup type at every translation unit that includes the
header. The header only forward-declares the class to keep its
include surface minimal, so a translation unit that included the
selection tool header without also pulling in qgslayoutitemgroup.h
failed to compile (qgslayoutdesignerdialog.cpp was the first such
unit hit during a clean build).

Move the body to the .cpp so the QPointer-to-pointer conversion is
instantiated where the full type is already available.

Assisted-by: Claude Opus 4.7
Three follow-up issues from the first round of nested-grouping
testing in the layout designer:

1. Visibility column overflow under nesting
   QTreeView puts the disclosure arrow + indent in column 0 by
   default, which is the visibility checkbox column. The fixed-width
   visibility column was getting eaten by the indent on nested rows.
   Switch tree position to the name column so vis / lock stay flush
   left at their fixed widths regardless of depth.

2. Last grouped member missing + ungroup wipes other rows
   QgsLayout::groupItems and ungroupItems mutate parentGroup() on
   members directly without telling QAbstractItemModel about the
   parent change. With the items model now hierarchical via
   parent() / index() / rowCount(), these mutations leave the tree
   reading stale state — a member appears under top-level when it
   should be under the new group, or vice versa. Easiest correct
   fix: emit a modelReset around the operation. Added
   QgsLayoutModel::emitModelReset() (a tiny public wrapper around
   the protected begin/endResetModel) and call it from groupItems
   and ungroupItems after the structural change.

3. New groups stay collapsed
   After modelReset the tree forgets expansion state. Wire
   expandAll() to modelReset so every group is visible by default —
   mirrors layer tree / browser tree behavior elsewhere in QGIS.

Assisted-by: Claude Opus 4.7
Replace setTreePosition() (which moved the disclosure arrow into the
name column) with a depth-aware width adjustment on the visibility
column itself. The disclosure arrow now lives in the visibility
column where it belongs visually (the Adobe-like nested look the
user actually wants), and the column auto-grows by one indentation
step per nesting level so the checkbox always has clear space.

Recomputed on every modelReset (i.e. every group/ungroup) via
adjustVisibilityColumnWidth(); base width when no groups exist is
the original 4 'x' character widths, unchanged from upstream.

Assisted-by: Claude Opus 4.7
It was placed next to the other hierarchy helpers in the private
section, but unlike them it is called from QgsLayout::groupItems and
ungroupItems which sit outside the class — the build failure on
qgslayout.cpp was the access check. The other helpers stay private
because they are only invoked from QgsLayoutModel members.

Assisted-by: Claude Opus 4.7
QgsLayoutItemsListViewModel::filterAcceptsRow used to drop sourceRow 0
unconditionally to hide the source model's top-level paper / null
sentinel. With the source model now hierarchical, sourceRow 0 of a
group parent is a real item — the topmost member — and was being
silently dropped from the panel. Made the check parent-aware so only
the root sentinel is hidden.

Also widen the visibility column to base * 2 + depth * indentation
when any group exists, so the disclosure arrow plus indented checkbox
have actual room at deeper nesting levels.

Assisted-by: Claude Opus 4.7
Wires QgsMessageLog::logMessage("LayoutPerf", Info) on:
- QgsLayout::groupItems / ungroupItems — total wall time, plus
  the split between mUndoStack work and the post-op
  emitModelReset.
- QgsLayoutModel::emitModelReset — wall time and pre-reset
  top-level + scene counts so we can see how big the model is.
- QgsLayoutItemsListView modelReset slot — expandAll vs total
  panel work after a reset.
- QgsLayoutItemsListView::adjustVisibilityColumnWidth — depth
  walk vs total column resize.

User can filter the QGIS Message Log panel to "LayoutPerf"
to see numbers per group/ungroup operation. No behavior change.

Assisted-by: Claude Opus 4.7
LayoutPerf logging used both without their headers; build failed
on qgslayout.cpp.o.

Assisted-by: Claude Opus 4.7
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

🍎 arm64-osx-dynamic-release — ❌ 1 port(s) failed

Failed: arrow

📦 arrow

from stdout-arm64-osx-dynamic-release.log

CMake Error at scripts/cmake/vcpkg_execute_required_process.cmake:127 (message):
    Command failed: /Users/runner/.vcpkg/buildtrees/arrow/arm64-osx-dynamic-release-venv/bin/python setup.py build_ext --build-type=release --cmake-generator Ninja --rpath @loader_path/../../../ install --prefix /Users/runner/.vcpkg/packages/arrow_arm64-osx-dynamic-release
    Working Directory: /Users/runner/.vcpkg/buildtrees/arrow/src/e-arrow-23-9829f06184.clean/python
    Error code: 1
    See logs for more information:
      /Users/runner/.vcpkg/buildtrees/arrow/python-build-arm64-osx-dynamic-release-out.log
      /Users/runner/.vcpkg/buildtrees/arrow/python-build-arm64-osx-dynamic-release-err.log

from python-build-arm64-osx-dynamic-release-out.log (key errors)

FAILED: [code=1] CMakeFiles/_parquet_encryption.dir/_parquet_encryption.cpp.o
/Users/runner/.vcpkg/buildtrees/arrow/src/e-arrow-23-9829f06184.clean/python/build/temp.macosx-15.7-arm64-cpython-312/_parquet_encryption.cpp:17184:77: error: no matching function for call to 'move'
1 error generated.
ninja: build stopped: subcommand failed.

📦 Download failing-port-logs-arm64-osx-dynamic-release artifact · 📑 Download full build-logs-arm64-osx-dynamic-release artifact · 🔧 Workflow run · 📌 Commit 4bc45bc

🪟 Windows Qt6 builds

Download Windows Qt6 builds of this PR for testing.
To execute locally, unzip the downloaded zip file and run bin\qgis-bin.exe in the extracted directory.
You might be prompted by Windows Defender click "Run anyway"
(Built from commit 4bc45bc)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant