QtAds — Hurdles and Solutions
[[[appendices.qt-ads]]]
Overview
[[[appendices.qt-ads#overview]]]
Engineering notes on pyside6-qtads behavior that took real time to work through, so a future
encounter with the same corner of QtAds doesn't have to re-derive it from scratch.
1. FocusHighlighting and nested CDockManagers
[[[appendices.qt-ads#focus-highlighting]]]
Symptom: with CDockManager.eConfigFlag.FocusHighlighting on, focusing one dock also
highlights an unrelated dock in a different CDockManager — reproduced in rehuco-agent's
dock-in-dock-in-dock shell, which has three separate CDockManager instances sharing one native
top-level window (MainWindow's own, DocumentsDock's, and each per-document DocumentWidget's
own — [[plugins#toolkit-surfaces]], [[plugins#viewer-editor-both]]): focusing a document tab in
the outer DocumentsDock also lit up that document's own inner viewer/editor split, and vice
versa. Confirmed via live logging on focusedDockWidgetChanged, not just visually. Not fixable at
the application level without abandoning the "documents tabbed together in one window" UX (making
each DocumentWidget a genuine separate top-level window, e.g. Qt.Window or an MDI subwindow,
would isolate it — a much bigger change than warranted for a cosmetic feature); the fix is to
avoid FocusHighlighting entirely — see [[appendices.qt-ads#current-changed-alternative]].
1.1 Root cause: a shared QWindow property plus global signals
[[[appendices.qt-ads#cross-manager-contamination]]]
CDockFocusController::updateDockWidgetFocus() stores the newly-focused dock on the shared
native QWindow:
if (Window)
{
Window->setProperty(FocusedDockWidgetProperty, QVariant::fromValue(QPointer<CDockWidget>(DockWidget)));
}
Every CDockFocusController instance — one gets constructed per CDockManager, so three in this
app with one document open, one more per additionally-open document — connects to the same
global Qt signals in its constructor:
connect(QApplication::instance(), SIGNAL(focusChanged(QWidget*, QWidget*)),
this, SLOT(onApplicationFocusChanged(QWidget*, QWidget*)));
connect(QApplication::instance(), SIGNAL(focusWindowChanged(QWindow*)),
this, SLOT(onFocusWindowChanged(QWindow*)));
So focusing any dock, in any manager, writes it into the shared window property, and every
other manager's own controller reads that same shared value and calls updateDockWidgetFocus()
on it too — using its own independent bookkeeping, regardless of whether that dock is even one of
its own. A QMainWindow subclass embedded as a child widget (e.g. DocumentWidget, itself a
QMainWindow, but placed via dock.setWidget(...) rather than shown as a genuine top-level
window) does not get its own QWindow: QWidget.window() always resolves to the nearest true
top-level ancestor, and in a dock-in-dock-in-dock shell there is exactly one such ancestor for the
whole app. Giving each nested manager a "separate" CDockFocusController would change nothing —
they'd all still share the one real window's property, since scoping follows the OS-level window,
not the CDockManager/QMainWindow instance asking.
1.2 Compounding gotcha: silent no-op without the flag, deferred signal stacking
[[[appendices.qt-ads#requires-focushighlighting]]]
setDockWidgetFocused()/focusedDockWidgetChanged are silent no-ops without the flag at all (the
whole CDockFocusController only gets constructed when it's set, and only if set before the
first CDockManager in the process is built). Even with the flag on, setDockWidgetFocused()
does not synchronously emit focusedDockWidgetChanged for a dock that isn't visible yet (e.g.
during session restore, before MainWindow.show()) — updateDockWidgetFocus() defers the signal
until the dock's own visibilityChanged(true) fires. Calling it repeatedly on several invisible
docks in a row (once per reopened document, say) stacks up multiple deferred completions that all
fire in a burst, in whatever order Qt happens to deliver them, once the window is finally shown —
not necessarily the order they were queued in. Style updates themselves are not deferred (they
run synchronously, unconditionally, before the visibility check), so this specifically corrupts
signal-driven bookkeeping (e.g. "which dock is current"), not paint state — a red herring that
cost real time before [[appendices.qt-ads#cross-manager-contamination]]'s shared-window-property
cause was found.
1.3 Fix: track focus via CDockAreaWidget.currentChanged instead
[[[appendices.qt-ads#current-changed-alternative]]]
Don't use FocusHighlighting at all when nested CDockManagers share a window. Track whichever
tab/dock matters using a signal that isn't gated by it instead: CDockAreaWidget::currentChanged(int
index) is a plain tab-bar signal — ordinary Qt tab-widget behavior, not routed through the
shared-window mechanism [[appendices.qt-ads#cross-manager-contamination]] describes. Fires whenever
the current (selected) tab within one
specific area changes, regardless of real Qt keyboard focus. Connect it once per distinct
CDockAreaWidget (CDockManager.addDockWidget(...) returns the area a dock ended up in —
multiple docks tabbed into the same area share one connection, so guard against connecting
twice), then resolve the tab index back to a dock via area.dockWidget(index). This is what
DocumentsDock uses to track which document is "current" for the window title and
session-focus-save, with no cross-manager coupling at all.
1.4 Confirm a binding exists before trusting a stub or the C++ header alone
[[[appendices.qt-ads#verify-bindings-live]]]
pyside6-qtads ships no official typings (see [[appendices.code-conventions]]'s notes on this
repo's own local stub, typings/PySide6QtAds/__init__.pyi) — a method visible in the upstream
C++ header is not guaranteed to be exposed to Python, and the local stub can also simply be
missing an entry that is bound. Before adding a new stub entry (or relying on one already
there), check the live binding directly:
import PySide6QtAds as QtAds
print("currentChanged" in dir(QtAds.CDockAreaWidget))
print("dockWidget" in dir(QtAds.CDockAreaWidget))
This caught CDockAreaWidget.dockWidget/.currentIndex/.currentChanged all being genuinely
bound (confirmed against the upstream
Qt-Advanced-Docking-System's
DockAreaWidget.h Q_SIGNALS:/public method declarations too) despite not yet being in the
stub — safe to add, once both checks agree. More generally: the upstream C++ source itself — not
just the Python bindings or this repo's own typings stub — is often the only way to actually find
a root cause once a QtAds behavior stops matching what the Python-side API surface alone suggests
it should do; clone the repo locally and read the relevant .cpp/.h directly
([[appendices.qt-ads#cross-manager-contamination]] came from doing exactly that).
2. The full signal set a "current dock" tracker needs
[[[appendices.qt-ads#current-dock-signals]]]
CDockAreaWidget.currentChanged ([[appendices.qt-ads#current-changed-alternative]]) is necessary
but not sufficient — it only fires
when a tab index within one area changes, and several ways of making a dock "current" don't do
that. QtAdsFocusTracker (borco_pyside.qtads) is the reusable home for the whole set, each part
added only after confirming empirically that the others miss its case:
CDockAreaWidget.currentChanged— ordinary tab-bar switching within a shared (tabbed) area.- the area's tabs-menu
QMenu.triggered— picking an area's already-current lone tab from its dropdown never changescurrentChanged's index, so it fires nothing. - the dock's own tab-label
clicked(tab_label(dock)→ theCElidingLabelnameddockWidgetTabLabel) — a dock alone in its area is always index 0, so clicking its tab never changescurrentChangedeither. QApplication.focusChanged— real keyboard focus moving into a different, already-visible split area changes no area's current-tab index at all. Walk upparentWidget()from the newly focused widget to find the enclosing trackedCDockWidget.CDockWidget.viewToggled— a dock hidden/shown by itstoggleViewActionfires none of the above. Show → make it current. Hiding the current dock: Qt moves keyboard focus to a neighbor synchronously, beforeviewToggledfires (verified:focusWidget()already points at the sibling inside theviewToggled(False)slot), sofocusChangedhas already re-selected that real neighbor and the toggle handler is reached only when nothing tracked took focus — clear then, rather than fabricate a current dock no focus points at. Guard the whole handler withCDockManager.isRestoringState():restoreStatefiresviewToggledfor every reconstructed dock, which would fight the explicit re-selection in [[appendices.qt-ads#restore-current-split]].
Deliberately not the tab title: current-ness is shown by a dynamic tracked_focus QSS
property + highlight styling on the tab (a FocusHighlighting-free equivalent of QtAds' own
focused property), so callers own their titles outright and there is no marker-vs-dirty-suffix
conflict from two writers touching one windowTitle.
Register a dock with the tracker before placing it (#398): addDockWidget itself makes the dock current, so the
tracker emits from inside that call, and a dock registered afterwards misses its own first selection.
3. restoreState doesn't restore which split area was current
[[[appendices.qt-ads#restore-current-split]]]
CDockManager.saveState/restoreState records only the current tab within each area, never
which of several split areas held focus. So docks tabbed together restore their current tab
fine, but a viewer/editor split into two areas always comes back current on whichever dock was
adopted first (the viewer), losing the real selection. Persist it yourself: save the current
dock's objectName() alongside the manager state, and after restoreState (which re-registers
every dock by name) re-select it with CDockManager.findDockWidget(name) +
set_current_dock — this is QtAdsFocusTracker.save_state/restore_state. Note restoreState
rebuilds every affected CDockAreaWidget from scratch, orphaning currentChanged connections
made before it — re-track areas on stateRestored — and fires viewToggled/currentChanged for
the reconstructed docks during the call, hence the isRestoringState() guard in
[[appendices.qt-ads#current-dock-signals]].
4. Recoloring a tab close button: the icon is stylesheet-governed, not code-set
[[[appendices.qt-ads#tab-close-button]]]
Symptom: the [x] close-button icon goes invisible against a themed/highlighted tab, and
color: in QSS can't tint it. Root cause (found by reading QtAds' DockWidgetTab.cpp and its
bundled default.css): the close button's icon comes from the default stylesheet's
#tabCloseButton { qproperty-icon: url(:/ads/images/close-button.svg); qproperty-iconSize: 16px; }
rule, which wins over both the C++ internal::setButtonIcon and
CDockManager.iconProvider().registerCustomIcon(TabCloseIcon, …) at polish time — so neither
code path can change the icon in the installed build, and a url() SVG icon ignores QSS color:
regardless — and all four of 5.0's sheets carry that rule, so the workaround holds whichever is live
(but see [[appendices.qt-ads#stylesheet-reload]]: QtAds replaces the sheet when the palette flips,
so the override has to be re-applied, not merely applied once).
What does follow the palette is text: render the close mark as the button's
text (a glyph — ideally from a bundled icon font, e.g. Phosphor, loaded via
QFontDatabase.addApplicationFont, since a system-font glyph's metrics vary per platform), color
it with color: palette(...), and hide the real icon with qproperty-iconSize: 0px. Two timing
traps: (1) do the icon-size zeroing in QSS, because QtAds re-polishes the button on every tab
activation and would re-apply the 16px size otherwise; (2) set the glyph's font/size and a square
setFixedSize in Python on a deferred (QTimer.singleShot(0)) tick, because QtAds re-sets the
button after emitting dockWidgetAdded (and after a restore) for the tab it then makes active,
overwriting an eager restyle. TabCloseButtonIsToolButton aside, the button is a QPushButton
named tabCloseButton, reachable via dock.tabWidget().findChild(QAbstractButton, "tabCloseButton").
4.1 Why the override is allowed to win: specificity first, source order only as the tiebreak
[[[appendices.qt-ads#qss-cascade]]]
The zeroing rule beats QtAds' 16px for one reason and one reason only: it is appended after it.
Both are plain #tabCloseButton id selectors, so they tie on specificity, and Qt implements CSS2.1
cascading — a tie is broken by source order, last one wins. Verified directly rather than taken from
the docs, since the whole workaround rests on it:
| Stylesheet | Wins |
|---|---|
#target { red } then #target { green } |
green — last |
#target { green } then #target { red } |
red — last, symmetric |
#target { green } then QLabel { red } |
green — the id, despite being first |
QLabel { red } then #target { green } |
green — the id again |
#tabCloseButton { 16px } then #tabCloseButton { 0px } |
iconSize == 0 |
The middle two matter as much as the rest: order never overrides specificity, it only settles ties.
So the append order in QtAdsFocusTracker is load-bearing, not cosmetic — read QtAds' sheet,
concatenate ours after it. Prepending would silently hand the last word back to 16px and
restore the doubled close mark, which is also why the re-append following every
setColorSchemeMode reload has to put the block at the end again
([[appendices.qt-ads#stylesheet-reload]]).
The tracked_focus highlight is not in this position: ads--CDockWidgetTab[tracked_focus="true"]
adds an attribute selector on top of the type selector, outranking QtAds' plain
ads--CDockWidgetTab rules on specificity, so it would win in either order. Only the close-button
override depends on being last.
5. A fully custom tab widget crashes when routed through CDockComponentsFactory
[[[appendices.qt-ads#custom-tab-widget]]]
Question (spike #60): can QtAds's tab widget be replaced wholesale with a custom widget, rather than styling/appending to the bundled one (as in [[appendices.qt-ads#tab-close-button]])? Finding: partially — the sanctioned extension point exists and is bound, but returning a Python-subclassed tab through it crashes; a different, code-only path covers the actual need instead.
5.1 The extension point is bound, and works for the title bar/tab bar
[[[appendices.qt-ads#components-factory-bound]]]
QtAds's own extension point for this is CDockComponentsFactory (DockComponentsFactory.h): a global
factory whose create... virtuals (createDockWidgetTab, createDockAreaTitleBar,
createDockAreaTabBar, createDockWidgetSideTab) build every dock/tab/title-bar widget instance,
replaceable wholesale via CDockComponentsFactory.setFactory(...). pyside6-qtads's bindings.xml
does bind it (object-type, all four virtuals), with inject-code on setFactory specifically to keep
a Python override callable afterwards — confirmed live too (setFactory/createDockWidgetTab both
present in dir(QtAds.CDockComponentsFactory), per [[appendices.qt-ads#verify-bindings-live]]'s rule of
never trusting the header alone). Overriding createDockAreaTitleBar/createDockAreaTabBar alone, each
returning a plain (non-subclassed) base-class instance from Python, works end-to-end through
addDockWidget with no crash.
5.2 But a Python-subclassed CDockWidgetTab segfaults on insertion
[[[appendices.qt-ads#custom-tab-segfault]]]
Symptom: overriding createDockWidgetTab to return a Python subclass of CDockWidgetTab — even
an empty one whose __init__ does nothing but call super().__init__() — builds the tab object fine
(the override runs and returns successfully during CDockWidget() construction itself, before any dock
area exists), then segfaults later inside addDockWidget/addDockWidgetTabToArea, the moment
QtAds' C++ side tries to insert that tab into its area's tab bar. Isolated to a 5-line repro; not
present when createDockWidgetTab is left at its default, or overridden to return a vanilla
(non-subclassed) CDockWidgetTab. Root cause not chased past this point (out of scope for a spike):
bindings.xml marks CDockAreaTabBar::insertTab(int, ads::CDockWidgetTab*)'s tab argument parent
action="add" — ordinary Qt-parent reparenting for a C++-constructed pointer, but likely mishandled by
shiboken's shell/ownership machinery when the pointer instead originates from a Python virtual-function
return. Not an application-level mistake to work around: a fully custom Python-defined tab class routed
through the sanctioned factory hook is not viable in the current binding.
5.3 Working alternative: extend the default tab's own layout post-construction
[[[appendices.qt-ads#tab-layout-insert]]]
Skip the factory/subclass path entirely. After addDockWidget()/addDockWidgetTabToArea(),
dock.tabWidget() still returns the ordinary C++-built CDockWidgetTab; inserting an extra widget
straight into its own layout() works with no crash and needs no CDockComponentsFactory registration
at all. The default layout is, left to right: dockWidgetTabLabel (title, stretch 1) → spacing →
tabCloseButton → trailing spacing (DockWidgetTabPrivate::createLayout()) — confirmed by walking
layout().itemAt(i) after construction. layout().insertWidget(1, widget) lands a new widget between
the title and the close button; insertWidget(0, widget) lands it ahead of the title. Being a real
child QWidget rather than a stylesheet-driven icon (contrast [[appendices.qt-ads#tab-close-button]]),
it takes an ordinary clicked signal connection with none of that section's icon-recoloring workarounds.
6. Customizing or disabling the per-area tabs menu
[[[appendices.qt-ads#tabs-menu]]]
Question (spike #60 follow-up): the "tabs menu" — the dropdown behind TitleBarButtonTabsMenu,
shown once an area's tabs overflow/elide — lists every tab in that area, built straight from each tab's
text()/icon()/toolTip(). Since DocumentsDock.__update_dock_title bakes the dirty/locked marker
into CDockWidget.windowTitle()
(documents_dock.py:29),
that marker shows up in this menu too, with no separate state to key a color off. Three questions:
can the menu's contents be changed (e.g. two-line entries instead of a tooltip)? Can the whole menu be
replaced with a custom widget? Can it be disabled for one CDockManager only (the outer documents
dock) while staying on for others (the per-document editor/viewer splits, #61's actual replacement)?
6.1 Contents are rebuildable — no subclassing needed
[[[appendices.qt-ads#tabs-menu-rebuild]]]
CDockAreaTitleBar::onTabsMenuAboutToShow() is a private, non-virtual slot — there's no override
hook for it (contrast buildContextMenu, which is virtual and public). But the button and its menu
are both reachable through public API: titleBar.button(QtAds.TitleBarButtonTabsMenu).menu(). QtAds
connects its own rebuild to that menu's aboutToShow in the title bar's constructor, which runs before
application code can reach the button at all — so any later menu.aboutToShow.connect(...) from Python
fires after QtAds' own rebuild on every open, letting a handler menu.clear() and repopulate freely
without racing it. Confirmed: replacing each plain QAction with a QWidgetAction wrapping a small
QWidget (bold name label over a full-path label) renders correctly — real two-line entries, no
tooltip-on-hover needed. Click-to-switch still works for free: QtAds' own onTabsMenuActionTriggered
just reads action->data().toInt() as the tab index, so a replacement action only needs setData(i) to
keep that behavior.
6.2 The button/menu itself has no factory hook — it's hardcoded
[[[appendices.qt-ads#tabs-menu-no-factory]]]
Unlike the tab widget ([[appendices.qt-ads#components-factory-bound]]), TabsMenuButton is built
directly in DockAreaTitleBarPrivate::createLayout(), not routed through CDockComponentsFactory at
all. There is nothing to subclass or replace wholesale here, safely or otherwise — augmenting the
existing menu's contents (§6.1) is the only lever, matching the same "extend after construction" shape
as [[appendices.qt-ads#tab-layout-insert]].
6.3 Disabling it for one manager only needs reactive re-hiding, not just a flag
[[[appendices.qt-ads#tabs-menu-per-manager]]]
DockAreaHasTabsMenuButton (eConfigFlag) is a CDockManager static — shared process-wide across
every manager, same category as [[appendices.qt-ads#requires-focushighlighting]]. Turning it off is
all-or-nothing; it cannot single out one manager (e.g. the outer documents dock, per #61) while leaving
others (per-document editor/viewer splits) on.
A genuinely per-manager alternative exists: CDockManager.dockAreaCreated(area) fires only for areas
created under that specific manager instance — confirmed an outer manager's connection never fired for
an untouched inner manager's area. But naively hiding the button synchronously from that signal doesn't
stick: DockContainerWidgetPrivate::onVisibleDockAreaCountChanged() unconditionally forces the tabs-menu
button back to visible whenever a container's visible-area-count transitions to/from exactly 1 (the
sole/"top-level" area case), on both add and remove, regardless of any flag -- confirmed empirically
(a hide made during dockAreaCreated reverted itself for the first/sole area, and later removing a
second/split area reverted the first area's hide too). Fix: defer the hide with
QTimer.singleShot(0, ...) — the same "let QtAds' own synchronous bookkeeping finish first" shape as
[[appendices.qt-ads#tab-close-button]]'s icon-restyle timing trap — and re-apply it from both
dockAreaCreated (new areas) and dockWidgetRemoved (re-hide every remaining area, since removal can
also flip the count through 1). With both hooked and deferred, an outer manager's button stayed hidden
through add, split, and remove, while an untouched inner manager kept the global default.
7. pyside6-qtads's vendored libxkbcommon crashes Qt on Linux mouse motion
[[[appendices.qt-ads#libxkbcommon-race]]]
Symptom: on Linux (confirmed under WSLg's Wayland platform plugin), the app builds and shows
fine, then segfaults the instant the mouse enters any window — a gdb backtrace shows the crash
inside atom_intern()/xkb_state_mod_name_is_active(), called from Qt's own (official)
QXkbCommon::modifiers() during QWaylandInputDevice::Pointer::pointer_motion.
Root cause: the pyside6-qtads wheel vendors its own private copy of libxkbcommon under
pyside6_qtads.libs/. PySide6QtAds.so is a normal Python C extension, loaded with global symbol
visibility, so importing it adds its private libxkbcommon to the process's global symbol scope.
Linux's dynamic linker resolves an unversioned symbol name (atom_intern, etc.) by searching that
global scope in load order and using the first match — for every caller, regardless of which
literal file that caller's own dependency list names. If import PySide6QtAds runs before Qt's own
libxkbcommon.so.0 (the system copy libQt6Gui.so.6 itself directly depends on) has been loaded,
QtAds' copy wins that race and Qt's real keyboard/pointer-modifier handling gets silently rebound to
it — an ABI-incompatible build it was never tested against.
Fix: import anything from PySide6 proper before import PySide6QtAds anywhere in the process
(confirmed with gdb: constructing a QApplication first is enough, the crash disappears).
rehuco_agent/app.py is the app's actual entry point for this — its own import PySide6QtAds as
QtAds is placed after its PySide6.Qt* imports, marked # isort: skip so ruff's import
sorter (which otherwise sorts plain import statements before from ... import ones, undoing the
order) leaves it alone. No other file needs the same treatment: Python caches modules, so only the
first import PySide6QtAds anywhere in the process matters, and app.py is always that first
one on the real startup path.
Filed upstream: mborgerson/pyside6_qtads#123.
Resolved upstream in 5.0.0.2 (released 2026-08-04): the manylinux wheel no longer vendors
libxkbcommon at all (ci: Don't vendor libxkbcommon in manylinux wheels), so the race this section
describes cannot happen on a wheel built from that fix onward — confirmed via the diff between the
v5.0.0 and v5.0.0.2 tags on mborgerson/pyside6_qtads. This also drops a duplicate copy of
libxkbcommon (and whatever it pulls in) from what the Linux AppImage build has to bundle, on top of
closing the crash. pyside6-qtads's floor is >=5.0.0.2 for exactly this, alongside the submodule
fix below. app.py's import order is left as-is regardless — there's no harm in it, and it costs
nothing to keep protecting a build resolved below the floor.
8. Every CDockManager carries QtAds' default stylesheet — nesting pays for it per level
[[[appendices.qt-ads#per-manager-stylesheet]]]
Symptom: switching between open document tabs is visibly slow, on every switch and not just the first — 77.8 ms in rehuco-agent's dock-in-dock-in-dock shell with three documents open, measured offscreen (#234).
Root cause: CDockManager's constructor applies QtAds' bundled default.css (9 359 characters)
to itself, so an app that nests managers — one outer, one per open document — holds one copy per
level, and Qt re-evaluates all of them against the whole subtree on every repolish, which a tab
activation triggers. What costs is each additional stylesheet-carrying ancestor, near enough
regardless of what that sheet contains (see the table below).
Fix: let exactly one widget in the nest carry the QSS. QSS cascades, so an ancestor's sheet
already styles every nested manager's chrome, and a nested manager's own copy buys nothing —
setStyleSheet("") on it is visually free (verified: full-window grabs before and after are
pixel-identical in both light and dark themes) and halves the switch: 77.8 ms → 40.7 ms.
QtAdsFocusTracker's stylesheet_host parameter is the seam — given a host, the tracker appends its
tracked-focus rules there (once, however many trackers share it) and clears its own manager's sheet.
Measured alternatives, each a fresh process over the same three documents:
| Where the styling lives | Sheet | Switch |
|---|---|---|
| One copy per manager, five managers | 9 852 × 5 | 77.8 ms |
| The outermost manager only — the fix | 9 852 | 40.7 ms |
Outermost manager, QtAds' default.css only (no focus rules) |
9 359 | 36.2 ms |
Outermost manager, the focus rules only (no default.css) |
492 | 36.2 ms |
The whole sheet hoisted to QApplication instead |
9 852 | 52.7 ms |
| No stylesheet anywhere (chrome unstyled — not shippable) | 0 | 11.4 ms |
Three things worth keeping from that table. The cost is a step, not a slope: 492 characters and
9 359 characters on one manager both measure 36.2 ms against 11.4 ms for nothing at all, so ~25 ms is
the price of that subtree having any stylesheet-carrying ancestor, and only the last ~4 ms scales
with what is in it. That is why hoisting is worth 37 ms and trimming the surviving sheet is worth
almost nothing. QApplication is not the same fix: an application sheet is evaluated against
every widget in the process, where a manager's reaches only its own subtree. And what the surviving
sheet buys is the current-tab highlight, the close-button glyph
([[appendices.qt-ads#tab-close-button]]) and the dock borders — dropping it entirely is the 11.4 ms
row, and would mean marking the current tab without QSS at all.
A floated dock keeps its chrome: a stylesheet reaches a child widget even when that child is a
top-level window (verified with a Qt.Tool QLabel parented to a styled widget — it renders
styled), and QtAds parents its floating containers to the manager they came from.
9. QtAds replaces its own stylesheet at runtime — pin the colour scheme to own the timing
[[[appendices.qt-ads#stylesheet-reload]]]
Symptom: after a light↔dark switch, no tab reads as current any more and every tab shows QtAds'
[x] icon and the glyph drawn as the button's text — two close marks per tab
(#227, surfaced by #234).
Root cause: ADS 5.0 added dark-mode support with CDockManager::ColorSchemeMode::FollowPalette
as the default, and re-runs its own loadStylesheet() from CDockManager::eventFilter on an
ApplicationPaletteChange — calling setStyleSheet(...), which replaces. Everything appended to
that sheet is gone, including [[appendices.qt-ads#current-changed-alternative]]'s tracked_focus
highlight and §4's qproperty-iconSize: 0px. Measured in a real window (light → dark):
| manager stylesheet | appended rules | |
|---|---|---|
| before the flip | 9 852 | present |
| after the flip | 9 478 | gone |
Four sheets ship, not one — default.css (9 359), default_dark.css (9 478), and the two
default_linux* variants — which is why the length changes rather than merely the content.
Re-appending afterwards cannot be timed against it. Qt fires ApplicationPaletteChange several
times per switch and QtAds reloads off a later one than a coalescing notifier
(ApplicationPaletteChangeNotifier) emits for, so a re-append from that signal is simply overwritten
again — confirmed: the rules were still gone afterwards. Reacting to the carrier's own StyleChange
does work, but an event filter on a dock manager was rejected as too costly.
Fix: pin the mode. setColorSchemeMode(Light|Dark) stops the event-driven reload entirely, and
reloads once, synchronously, at the call — so the re-append that follows it is deterministic.
QtAdsFocusTracker pins on construction and re-pins on every palette change, choosing by
CDockManager.isApplicationPaletteDark() (QtAds' own test), then re-applies its rules. Verified in a
real window: after the flip the manager carries the dark sheet plus the appended rules (9 971),
the current tab is still highlighted, and every close button still reports a zeroed icon size.
Two traps worth keeping. setColorSchemeMode reloads exactly when the call changes the effective
dark-ness (verified: re-pinning the same scheme leaves an appended marker untouched; pinning the
other one wipes it) — but since the caller can't cheaply know whether a given call flips it, the
re-append follows every call unconditionally (it no-ops when the rules survived), and it must land
at the end of the reloaded sheet ([[appendices.qt-ads#qss-cascade]]).
And the reload hands a nested manager back a full default sheet, undoing the one-sheet-per-nest
arrangement of [[appendices.qt-ads#per-manager-stylesheet]]; re-clearing it belongs in the same step.
eConfigFlag.DisableStylesheet would be the blunter lever — QtAds applies no sheet at all and the
app owns styling outright. It exists in upstream ADS 5.0 but was not bound in pyside6-qtads
5.0.0 (dir(CDockManager.eConfigFlag) did not list it, the
[[appendices.qt-ads#verify-bindings-live]] check applied to a flag an issue body had recorded as
available) -- and is bound as of 5.0.0.2 (confirmed the same way against the installed wheel).
Not adopted: pinning the mode already solves the problem this section is about, and switching levers
now would trade a working, tested fix for an untested one on the strength of a binding gap that has
since closed.
10. Auto-hide (pinning): what the flags carry, and what a feature flag cannot do
[[[appendices.qt-ads#auto-hide-flags]]]
Question (#279): QtAds can pin a dock into one of a container's four sidebars — a tab on the border, sliding out over the layout and collapsing again. Turning that on for the main window's own docks raised three things the API surface alone answers wrongly.
DefaultAutoHideConfig is bound, and already carries most of what a caller would add. It is not
listed by dir(CDockManager.eAutoHideFlag) but is in its __members__ and resolves as an
attribute, both on the flag enum and promoted onto CDockManager itself — the
[[appendices.qt-ads#verify-bindings-live]] check applied to a preset rather than a method. Its value
is 1283: AutoHideFeatureEnabled | DockAreaHasAutoHideButton | AutoHideHasMinimizeButton |
AutoHideCloseOnOutsideMouseClick. So a caller spelling out "collapse on an outside click" is
restating the default, and only AutoHideShowOnMouseOver (hover to peek) would be a genuine addition —
which the app does not set: its 500 ms delay timer holds a raw CAutoHideTab* (QtAds 5.0.0,
DockContainerWidget.cpp:155) that nothing clears when the tab is deleted, so unpinning a dock the mouse
just entered or left fires the timer into freed memory and crashes (#492, confirmed under page heap).
The flags are CDockManager statics, like eConfigFlag — set before the first manager, shared
by every manager in the process, nested ones included. Set after one exists they still reach areas
created later (verified), which is what lets a test turn pinning on for its own duration. A bare-QtAds probe
needs them too (#491): without setAutoHideConfigFlags(DefaultAutoHideConfig) every pin silently does nothing, and
the probe "shows" no signals.
10.1 DockWidgetPinnable gates neither the button nor the pin
[[[appendices.qt-ads#pinnable-is-not-a-lever]]]
Symptom: clearing DockWidgetPinnable on a dock changes nothing. Its area still shows the pin
button, and CDockWidget.setAutoHide(True) on it still pins it into a sidebar — verified directly
against the installed binding, on a dock whose feature mask deliberately omitted the flag.
What shows the button is the DockAreaHasAutoHideButton config flag alone. So "pinning, but only
for the outer window" cannot be expressed per dock: it needs the per-manager reactive re-hiding of
[[appendices.qt-ads#tabs-menu-per-manager]], which is what QtAdsAutoHideButtonSuppressor
(borco_pyside.qtads) packages.
The feature is not useless, though — it gates the other two ways in. Upstream's
FloatingDragPreview.cpp offers the sidebar drop zones only when the dragged content is pinnable
(if (isContentPinnable()) AllowedContainerAreas |= AutoHideDockAreas), and DockAreaTitleBar.cpp
enables the title bar's "Pin Group" context-menu action only for a pinnable area — while it creates the
button on the config flag alone, which is the asymmetry measured above. So a nested manager whose docks
simply never carry DockWidgetPinnable (every sub-dock in rehuco-agent) is closed to the drag and the
menu for free, and the suppressor only has to close the button.
And that section's signal pair is not enough. It names dockAreaCreated and dockWidgetRemoved,
which is what a container's visible-area count moving through one needs for adds and removals — but a
reveal moves it too. A shell whose sub-docks start hidden (rehuco-agent's Tasks dock, #276) got its
pin buttons back on the first toggleView(True), measured. The suppressor therefore also hooks
dockAreaViewToggled, dockWidgetAdded and stateRestored, each through the same zero-delay
QTimer.
10.2 An icon-only sidebar needs icons the app may not have
[[[appendices.qt-ads#auto-hide-icon-only]]]
AutoHideSideBarsIconOnly drops each sidebar tab's title in favour of the dock's own icon —
CDockWidget.setIcon, not the icon on whatever QAction a toolbar uses to toggle that dock. An app
whose dock icons live only on its actions (as rehuco-agent's did) gets sidebar tabs with no glyph and
no text. Left off for that reason; a rotated title says what the dock is with nothing to set up.
10.3 A pinned dock reads as open, and stays pinned through its toggle action
[[[appendices.qt-ads#auto-hide-toggle-view]]]
The reading a View menu needs turns out to need no special case at all (verified in a real window,
shown and unshown alike):
isClosed() |
toggleViewAction().isChecked() |
isAutoHide() |
|
|---|---|---|---|
| pinned | False |
True |
True |
| toggled off while pinned | True |
False |
True |
| toggled on again | False |
True |
True |
So a pinned dock is put away, not closed, and the toggle keeps it pinned across a hide and a show —
it comes back as a sidebar tab, slid out, rather than re-docked into the layout. A menu or toolbar
action mirroring toggleViewAction's checked state is therefore already correct for pinned docks.
(A minimal manager built outside an application shell was seen to un-pin on the second toggle instead. That is not what the app does, and the app is what was measured here — worth knowing only so a probe disagreeing with a real window is not mistaken for a regression.)
10.4 Nothing writes back where a dock was actually pinned
[[[appendices.qt-ads#auto-hide-preferred-side]]]
CDockWidget.setPreferredAutoHideSideBarLocation is the only thing a dock area's pin button reads,
and QtAds never updates it. A drag dropped on a particular border goes through
CDockManager.addAutoHideDockWidget(location, dock), which pins the dock to the named side and leaves
the preference alone; so does a restoreState() bringing a pinned dock back. Re-setting the preference,
for its part, leaves an already-pinned dock where it is — it decides the next pin, not the current one.
So a dock does not remember where the user put it. Drag one onto the right sidebar, unpin it, press its pin button: it goes back to whatever the preference still says. Reproduced end to end before it was believed, since the API reads as though the preference tracked the dock.
The fix is one signal. CDockManager.autoHideWidgetCreated fires with the CAutoHideDockContainer
QtAds has just built, and that container names both its dockWidget() and the sideBarLocation() it
landed in. It fires on every route into a sidebar — verified for a drop, the pin button,
setAutoHide(True, location), a drag of an already-pinned dock straight from one sidebar to another
(a second container, no unpin in between), and a layout restore. Writing that side straight back with
setPreferredAutoHideSideBarLocation is the whole of it, and it leaves no second copy of the side to
fall out of step: the dock's own preference is the memory.
borco_pyside.qtads.QtAdsPinSideHandler packages that, plus persistence per dock.
SideBarLocation is ordered Top=0, Left=1, Right=2, Bottom=3, None=4 — SideBarNone is the
member an unpinned dock's autoHideLocation() reports, and it is the enum's last value, not its
first. Worth stating because the zero value being a real side (Top) makes a falsy-means-unset reading
silently wrong.
Pin state — which docks, and which sidebar — is part of the saved layout's pinned list
([[appendices.qt-ads#structural-layout]]), so a dock left pinned needs no persistence of its own. A dock left unpinned is the case the layout cannot cover: the blob records no
pin, and so says nothing about where the next one should go — which is why the remembered side is
stored separately, and why a dock nobody has ever pinned stores nothing at all rather than the
current default (a stored default would read as a choice the user made, and outlive a later change to
the constant).
10.5 The sidebars need no application QSS
[[[appendices.qt-ads#auto-hide-styling]]]
Both shipped sheets already style the new chrome — default.css and default_dark.css each carry 6
ads--CAutoHideSideBar selectors and 27 ads--CAutoHideTab ones — and
[[appendices.qt-ads#stylesheet-reload]]'s pinned colour scheme is what loads the right one of the two.
So pinning adds no unstyled element and needed no rule of its own.
Worth knowing for the next visual check: a theme switch cannot be observed under
QT_QPA_PLATFORM=offscreen. Neither QStyleHints.setColorScheme nor driving the app's own
ThemeModel moves it — colorScheme() stays Unknown and both grabs come back pixel-identical — so
the light/dark reading of any new chrome has to be done on a real platform plugin. (The offscreen
plugin also renders every glyph as tofu here, which a grab taken for layout can ignore and one taken
for legibility cannot.)
10.6 restoreState abandons the floating container a pinned dock came out of
[[[appendices.qt-ads#auto-hide-abandoned-float]]]
A dock placed with addDockWidgetFloating and then moved by a restoreState() whose blob puts it in an
auto-hide sidebar leaves its old CFloatingDockContainer alive. On the owning window's first show it
appears as a blank window — 640x480, the never-laid-out QWidget default the dock was floated at — and
vanishes a moment later when the real window paints over it. A blob placing the same dock in a dock area,
or in a floating window of its own, is reconciled correctly; the sidebar is the one restore path that is
not.
Why QtAds' own guard misses it. CDockManager::showEvent shows every floating widget it still holds
as uninitialised, skipping those with no open dock areas. This one reports hasOpenDockAreas() == True
with dockAreaCount() == 1: a stale area, not an empty container. Both numbers are worth recording,
because the guard reads as though it should have caught this and the reason it does not is exactly that
the container is not empty.
The manager has already dropped it, so dockManager().floatingWidgets() can be blind to precisely
the thing under test. Any probe or assertion has to go through findChildren(CFloatingDockContainer) on
the owning window — which is also how MainWindow.hide_to_tray reaches floating docks, for the same
reason.
The fix is placement, not cleanup. Dock every dock into an area up front, so no floating container
exists when restoreState runs and there is nothing to abandon; a floating container should only ever
come from the user tearing a dock out, or from restoreState rebuilding one the user saved. rehuco's
Settings dock used to reach floating as a fallback for a layout that did not restore, which is what
306 first had to make safe and #307 then removed outright. Deleting the container afterwards would be
chasing one instance of a general rule: nothing guarantees QtAds retires a container a restore emptied.
Placing the dock somewhere is not optional — an unplaced dock is never registered with the manager,
and restoreState silently skips it.
Two traps come with floating a dock programmatically, both of the same class as 10.3's "a pinned
dock reads as open" — QtAds state that does not survive an operation one would expect it to.
addDockWidgetFloating:
- reopens a closed dock it moves (
isClosed()goesTrue→False), so a caller that has already decided the dock should be closed has to close it again afterwards. Doing so is enough: the guard above then suppresses the container on show, since a closed dock leaves no open area behind. - sizes the new container from the dock's current size —
640x480for a dock that was never placed, but a degenerate100x15for one tabbed into a never-shown window, and it survives the show at about100x40even with content whoseminimumSizeis larger. Resizing the dock before the call is what gives it a usable window — but to its content widget'ssizeHint(), not its own, which undershoots it (432x289against the content's438x341, measured on the same dock);minimumSizeHint()is a useless60x40either way.
And while a dock is closed, isFloating() returns False even inside a floating container — it wants
the container to have an open top-level dock widget. Read floatingDockContainer() is not None
instead, or a test of the closed-and-floating case asserts the opposite of what it means.
10.7 A floating window dropped on a sidebar pins the nested managers' docks inside it
[[[appendices.qt-ads#recursive-sidebar-drop]]]
Question (#491): a browser sub-dock — a dock of the Browsers dock's nested manager — turned up pinned on the main window's sidebar, beside an empty Browsers. Nothing in the app pins it.
QtAds does, through a recursive search. Dropping a floating window onto a sidebar runs
DockContainerWidgetPrivate::dropIntoAutoHideSideBar (QtAds 5.0.0, DockContainerWidget.cpp:607), which
collects the window's areas with findChildren<CDockAreaWidget*>(…, Qt::FindChildrenRecursively). The search
reaches through the Browsers dock into its nested manager, and every dock of every area found is pinned into
the target sidebar by createAndSetupAutoHideContainer (:1571), which re-homes it
(setDockManager) when the target belongs to another manager. No DockWidgetPinnable check is made on this
path ([[appendices.qt-ads#pinnable-is-not-a-lever]]). The nested manager is never told: its
dockWidgetsMap() still lists the dock, and no signal fires on it. dropIntoContainer (:479) and
dropIntoSection (:674) search the same recursive way.
What is observable is the target manager's autoHideWidgetCreated, which fires for the stolen dock as
for any other pin (measured). Unpinning the dock (setAutoHide(False)) and then addDockWidget on the nested
manager puts it back — owner and container both the nested manager again (measured). QtAdsPinGuard does
exactly that on every manager, deferred a turn so QtAds' drop finishes walking the window first; on a manager
that never pins it also unpins any of its own docks a border drop pinned there. And QtAdsLayout restores a
nested manager's pinned entries into its main container (pins=False), so a layout saved while a dock sat
stolen does not grow the nested manager a sidebar of its own on the next start. The same blindness to nesting runs
upward too ([[appendices.qt-ads#nested-emptied-float]]).
10.8 Emptying a nested manager deletes the floating window above it
[[[appendices.qt-ads#nested-emptied-float]]]
Question (#493): with the Root Catalog dock floating over the Browsers and Log docks, creating or switching a catalog deleted the whole floating window — all three docks, their toggle actions with them, until a restart.
QtAds retires the wrong window. When an area loses its last dock and its container is left with no area,
CDockAreaWidget::removeDockWidget (QtAds 5.0.0, DockAreaWidget.cpp:603) calls finishDropOperation() on
DockContainer->floatingWidget(): the window is hidden, its container lets go of every area, and it is
deleteLater()ed and unregistered. That is meant for the container's own floating window, the one a drag just
emptied. But floatingWidget() is findParent<CFloatingDockContainer*>, the nearest floating window above the
container, the same walk §14 met in QtAdsLayout.save. So a nested manager emptied inside a dock of an outer floating
window deletes the outer window, and every dock in it goes with the next turn of the event loop (measured, bare
QtAds and the app alike). It is the removal that does it: the Browsers dock lets every browser go as a catalog closes,
before the next catalog's are built. Nothing undoes finishDropOperation() afterwards, and nothing signals it. It is
the upward twin of 10.7's downward search.
Every nested manager in the app empties somewhere. The Browsers dock empties as a catalog closes and on the last
browser's [x]. The Documents dock empties as its last document closes. A document's sub-docks empty in teardown,
which a preview moving to a document another holder already has runs on the host it keeps. Tasks never removes a dock.
None of their docks is DockWidgetFloatable, but all are DockWidgetMovable, and a drag empties one too: dragging
a manager's only dock onto its own container's edge goes through dropWidget and the drag-preview drops
(moveToNewSection, dropIntoSection, dropIntoContainer), which take the dock off its old area through
CDockAreaWidget::removeDockWidget itself. That area removal runs the same check, and no manager removal signal
(dockWidgetAboutToBeRemoved, dockWidgetRemoved) is emitted on that path (measured: a guard listening there lost the
window). QtAdsLayout's moves always leave the seed's area standing.
The fix keeps the count above zero, from inside the removal. CDockContainerWidget::removeDockArea emits
dockAreasRemoved as its last step, and CDockAreaWidget::removeDockWidget tests dockAreaCount() == 0 only after
that call returns, so a slot on the signal runs on every path, drags included, and in time.
borco_pyside.qtads.QtAdsEmptyingGuard, on every nested manager, listens there: when the container has just lost its
last area and a floating window is above it, it parks a ballast dock in an area of its own, closed, so the count is
one again by the time QtAds looks. Its closed area is hidden and takes no room. The next dock unparks it, whether added
through the manager (dockWidgetAdded) or dropped back in (dockAreasAdded), into an area of its own or the
ballast's. Park and unpark are both synchronous: QtAdsLayout.restore reads the areas and splitter counts straight
after each add, and a ballast left standing a turn longer would cost a restored splitter its sizes. The ballast goes in
and out through CDockContainerWidget.addDockWidget / removeDockWidget called on the manager, which skip the
manager's registry, so dockWidgetsMap() never lists it (measured) and a layout save, the tab walks and the app's own
dock lists never see it. With no floating window above, nothing is parked: QtAds leaves the manager empty, which is
harmless there.
addDockWidgetFloating returns a wrapper of the window that was measured to go invalid while the window lives on, the
Qt-made-wrapper trap of §14's last bullet. Find the window again through a dock's floatingDockContainer() rather
than holding the wrapper.
11. restoreState puts a floating dock on screen before its owner exists
[[[appendices.qt-ads#restore-shows-floating]]]
CDockManager.addDockWidgetFloating and CDockManager.restoreState disagree about a floating
container's first show, and only the first of them is careful. addDockWidgetFloating checks whether
the manager is visible and, when it is not, parks the new container in QtAds' own
uninitialised-floating-widgets list for CDockManager::showEvent to show later. restoreState does
not: a blob describing a dock as floating and open has its container shown the moment the blob is
applied. A window that restores its layout during construction — which it must, since restoring after
the first show visibly resettles a layout the user is already looking at — therefore puts that dialog
on screen, alone, a moment before itself.
Hiding it again on the next line is not a fix. The native window is created, mapped and painted
synchronously inside that show(): measured on a real platform plugin, a Paint for the container
arrives between its Show and the following Hide, with no turn of the event loop in between. So the
hide shortens the flash instead of removing it — and under QT_QPA_PLATFORM=offscreen the paint never
happens at all, so an offscreen probe reports the problem as already solved. This is one to measure on
a real plugin or not at all.
What removes it is WA_DontShowOnScreen. QWidgetPrivate::show_sys — the step that creates and
maps the native window — returns early for a widget carrying that attribute, and Qt delivers
QEvent::Show to the widget before calling it. An application-wide event filter that sets the
attribute while handling that event therefore leaves the container "shown" as far as Qt's bookkeeping
is concerned, with nothing ever presented; the container's Paint before the owning window's
disappears. borco_pyside.qtads.QtAdsFloatingShowGuard packages that, armed for the duration of a
window's construction.
Releasing them again is where this bites back. A held container is visible to Qt but unmapped, so an
ordinary show() on it does nothing — it has to be hidden first, then cleared of the attribute,
then shown, in that order. Hiding one that is already hidden was seen to crash the process
(segfault, not an exception), so exactly one hide has to happen — the guard's release does it, gated
on the container still being visible, and hands the containers back for the caller's own
"show these once I am up" list.
And it has to be armed around the owner's show() as well, not only its construction.
addDockWidgetFloating on an unshown manager parks the container, and CDockManager::showEvent
then shows it — but that event fires while the owning top-level is still showing its children,
before its own native window maps, so a parked container's Show and Paint still land ahead of
the window's. Measured on a real plugin with a refused layout blob and a dialog saved open, back when
the Settings dock still floated as a fallback (#306, removed by #307): the one path that floated an
open dock under an unshown manager. Guarding the show() catches that too, and the guard stays armed
there because nothing but the absence of such a caller keeps the path closed.
Releasing them after the window is not the end of it either: on Windows a released container comes
up empty (#308). Its window is owned by the main window, so it stacks above it from the moment it
is shown, and no raise or foreground forcing of the owner ever gets between the two — measured on the
real plugin, the z-order never inverts. What the eye sees as the dock "arriving a beat late" is its
first paint: the main window paints synchronously inside its own show(), but Qt exposes a top-level
shown after it only from a WM_PAINT, which the queue generates once nothing else is pending — so the
dock sits above the window as an empty surface, the window showing through, until startup's posted
events drain, and only then fills in. QWidget.repaint() is dropped on a not-yet-exposed window;
UpdateWindow on its handle sends the WM_PAINT there and then, which is what
borco_pyside.platforms.windows.window_painting.paint_now does right after each release.
And a painted dock still fades in behind the window. The Desktop Window Manager animates every
top-level's appearance from its own ShowWindow, so the container's fade starts the ~35 ms its show
comes after the window's and, captured frame by frame, it is a translucent ghost over an already-opaque
window for those frames — the same "behind and empty, then brought on top" the deferred paint gave,
shorter. DWMWA_TRANSITIONS_FORCEDISABLED set on each window around its show
(borco_pyside.platforms.windows.window_transitions.open_transition_disabled) puts both in the first
frame either reaches, at the price of the fade-in itself; cleared again after the show, every later
transition stays the desktop's.
12. A per-tab maximize is a userland hide/show, and every capture has to undo it
[[[appendices.qt-ads#area-maximize]]]
QtAds has no "maximize this dock" (#341): closeOtherAreas() closes the siblings — their docks read
closed and their toggle actions un-check — and the showMaximized family is plain QWidget window
state. Upstream's own answer (githubuser0xFFFF/Qt-Advanced-Docking-System#194) is the userland
hide/show, and that is what borco_pyside.qtads.QtAdsMaximizeHandler does: the maximized dock is
brought to the front of its area, the area's other tabs are hidden (tabWidget().setVisible(False) —
the docks stay open; QtAds neither re-shows such a tab on a resize or current change nor lists it in
the tabs menu, measured), setVisible(False) on every sibling CDockAreaWidget of the same container
grows that area to the whole container, and showing exactly those tabs and areas puts them back. The
neighbouring tabs go too so that the maximized dock is the only thing on screen: left in the tab bar,
one of them could be "maximized" with nothing visible happening, and its restore would then put back
everything the first maximize hid. The container is the scope, not the manager — CDockManager.openedDockAreas() covers the main
container only, so a dock in a floating window is reached through area.dockContainer().openedDockAreas()
and fills that window, leaving the main one alone. Everything below was measured offscreen against the
installed binding.
Hiding an area is signal-silent. CDockAreaWidget.setVisible(False) and back emit nothing at all:
no dockAreaViewToggled on the manager, no viewToggled on any of the area's docks, no
dockAreasAdded/dockAreasRemoved. toggleView is the route that emits. So no dock reads closed,
nothing else in the app reacts, and openedDockAreas() (which filters on isHidden()) simply stops
listing the hidden ones — which is also why the handler needs no re-entrancy guard against its own
hides, and why a document's viewToggled-driven size stash never sees them.
A capture taken while maximized is poisoned. saveState() serialises the hidden panes' splitter
Sizes at zero (800 0 for a two-pane split), and restoring that blob later gives a sliver, not the
layout the user had. Every layout capture in the app — a document's session and default-layout blobs,
the documents area's, the task queue's, the window's close-time outer state — therefore runs inside the
handler's unmaximized() block, which shows the hidden areas back for the duration and hides them
again after, with the button and the maximize itself left as they were. Two details make the block
honest: the splitter only re-divides on a LayoutRequest, which Qt posts, so a capture in the same
call would still read zeros — QCoreApplication.sendPostedEvents(None, QEvent.Type.LayoutRequest)
delivers them synchronously, with no turn of the event loop and nothing else let through; and every
splitter's sizes under the container are recorded as the dock is maximized and re-applied after each
show, since a hidden pane's size is the splitter's own bookkeeping rather than a promise.
Exit first, then the change. Any structural change on the manager — an area added or removed (a
dock dragged out or docked in), a dock added or removed, a dock or an area shown or hidden by a toggle
(viewToggled, dockAreaViewToggled) — restores synchronously from the signal, so whatever the app
does in reaction sees the un-maximized layout. A dock toggled inside a hidden sibling fires
dockAreaViewToggled(area, False) and can leave that area with no open docks; showing such an area
back puts an empty box on screen (measured), so the restore skips any area whose
openDockWidgetsCount() is zero. A layout restore rebuilds every area from scratch: the handler forgets
its bookkeeping on stateRestored, while the tabs — and the buttons on them — survive, the same objects
reparented ([[appendices.qt-ads#restore-current-split]]); a refused blob touches nothing, so a standing
maximize correctly survives one. One consumer still needs its own call: DocumentSubDocks.__on_view_toggled
is connected as each dock is built, before the handler could be, so it calls restore() itself ahead of
its size stash rather than trusting slot order.
The button is the app's, inserted into the tab beside QtAds' own close button. A Python-subclassed
tab through the factory crashes ([[appendices.qt-ads#custom-tab-widget]]), so a plain QPushButton — the
close button's own kind — goes into the tab's own layout after the title's own spacing, with a spacing
of the same width inserted after it — title · gap · maximize · gap · close, the shape
[[appendices.qt-ads#tab-layout-insert]] established.
It is drawn exactly as QtAdsFocusTracker draws the close button: a glyph as text, squared to the
tab's height (Phosphor's arrows-out at rest and arrows-in while maximized, in this app), so the
tracker's ads--CDockWidgetTab[tracked_focus="true"] #tabMaximizeButton { color: … } rule keeps it
legible on the current tab's highlight. Its rest, hover and pressed look are QtAds' own
#tabCloseButton rules — which differ between the light and dark sheets — copied with the selector
renamed at the moment the tracker appends its block (QtAdsFocusTracker.tab_maximize_button_stylesheet),
minus the close icon's qproperty- lines and negative padding; re-derived on every re-apply, so a theme
flip reads the sheet the pinned mode now calls for. And the tracker re-polishes the button with the tab's
other children as current-ness moves — a descendant rule takes only on the re-polished descendant
itself, and a button inserted while its tab was current would otherwise keep the highlight's white on a
tab no longer highlighted (measured). The buttons are re-walked from dockWidgetsMap() on
dockWidgetAdded and stateRestored, the deferred way QtAdsAutoHideButtonSuppressor walks its areas.
13. The dock registry is keyed by the name a dock was added under
[[[appendices.qt-ads#dock-registry-keys]]]
CDockManager keeps every dock in a by-name registry — dockWidgetsMap(), the one findDockWidget and
restoreState look docks up in — and fills it in addDockWidget from the dock's objectName() at that
moment. Nothing re-keys it afterwards, and removeDockWidget drops the entry under the name the dock has
now. Measured against the installed binding: add a dock as path-a, rename it path-a-renamed, remove
it, and the registry still lists path-a; findDockWidget("path-a") finds the renamed dock, while
findDockWidget("path-a-renamed") finds nothing.
Once that dock is deleted, the stale entry is a dangling pointer, and every later reader of the registry
converts it (#364). That is QtAdsMaximizeHandler's deferred button walk, run on the next dockWidgetAdded:
depending on what the freed memory holds by then, it either sees a bare QObject ('QObject' object has no
attribute 'tabWidget') or dies natively inside dockWidgetsMap() itself (_purecall, an access violation).
Neither is reachable by a Python guard — the crash is in the conversion, before any Python sees the dock.
The app hit it because a document's dock resyncs its objectName() to the document's path, which a rename
on disk changes: close that document, open another, and the walk reads the renamed one's freed dock.
The fix is at the removal, since a rename can't re-key: there is no key-only removal, and removing and
re-adding the dock to re-register it would tear down its area whenever it stands alone there.
borco_pyside.qtads.remove_dock_widget finds the key the dock is registered under, gives the dock that
name for the removeDockWidget call, and puts its current name back after — so the entry goes with the
dock. Any dock whose objectName() can change after it is added must leave its manager that way.
Either way, a removed dock comes out parentless and is deleted with deleteLater(): freed on the spot —
which is what Python dropping its last reference does to a removed dock it owns — it goes ahead of the
events the removal posted, and the next processEvents was measured to crash or hang.
14. Layouts are saved as a structural tree, not QtAds' blob
[[[appendices.qt-ads#structural-layout]]]
Every dock manager in the app saves its layout through borco_pyside.qtads.QtAdsLayout. It does not use
CDockManager.saveState(), because that blob is all-or-nothing (#102):
restoreStateleaves a dock the blob does not name in no area at all, silently.saveStatewrites only docks that sit in an area.
So one bad save lost a dock for good: every later session restored it as missing and saved it as missing. No version guard catches this, because it happens within one layout version. That is how the Browsers dock vanished from the outer layout, and why a floating window holding two browsers came back empty.
The format is a JSON-able dict, so it fits the JSON state files and a CBOR envelope alike:
{"format": 1,
"main": Node,
"floating": [{"geometry": [x, y, w, h], "root": Node}],
"pinned": [{"side": "left|right|top|bottom", "docks": [Dock], "size": int}],
"unplaced": [Dock]}
Node = {"split": "h"|"v", "sizes": [int], "children": [Node]} | {"area": [Dock], "current": name}
Dock = {"name": objectName, "closed": bool, "state": base64?}
Each dock's entry carries its content's own state through the caller's hook: the Log dock's filters, the
Tasks dock's nested layout, a document dock's StatefulWidgets, a browser's BrowserState. That way, a dock the
layout drops takes nothing else with it. QtAds' config flags are not part of the format. A tree is
normalized on save and on restore alike: QtAds roots every container in a splitter that often holds one
child, and can nest a splitter in one that runs the same way. The tree keeps neither shape.
Save starts from the docks, not from floatingWidgets()/dockContainers(): each dock's area names its
container. A floating window the manager lost track of is still saved; one such case was seen in #488, cause
unproven. The manager's own container is told apart by identity, not by floatingWidget(). That call
answers the nearest floating window above a container, so a nested manager inside a floated outer dock
(Tasks) reports the outer window as its own (measured), the walk that has QtAds delete that outer window when the
nested manager empties ([[appendices.qt-ads#nested-emptied-float]]).
Restore degrades per node:
- A malformed node is dropped.
- A dock the tree names that the manager lacks is skipped. A caller whose layout defines its docks (the browsers) builds it from the entry's state instead.
- A dock the manager has that the tree does not name goes to the caller's default place.
The rest restores either way. Anything that is not a dict of format 1, an old blob among them, restores nothing. Old blobs are not migrated; the built layout is kept once.
No placeholder docks. QtAds has no public way to make an empty area, and Scrutiny Debugger's rewrite of
the same thing inserts throwaway docks to build the tree. That approach needs a bare processEvents() before
removing them, or DockAreaTabBarPrivate::updateTabs() crashes. Here every manager builds its docks before it
restores, and the tree is pruned to the docks that exist before anything moves. So every area the restore
builds has a real dock to seed it.
Build order:
- A splitter first places one seed area per child: each child's first dock,
Right/Bottomof the previous seed. - Only then does each child expand from its seed, so every insertion targets a lone area. Beside an area whose splitter runs the other way, QtAds wraps the two in a new splitter; beside one that runs the same way, the new area joins it.
- Tabs go in with
Center. - Docks are opened or closed, and each area's current tab is set.
- Splitter sizes are applied last, once the whole tree exists, because every insertion resets its splitter's sizes. A splitter that also holds an area the tree does not name keeps QtAds' sizes.
- The restore ends by emitting
stateRestored, which the helpers above re-track on.
Three traps the build has to step around (measured):
- A closed dock must be opened before it is moved. QtAds moves a closed dock as an open one with its tab
still hidden. The dock reports itself open, so no later
toggleView(True)shows the tab. A restored document came back with tab-less areas, and a Content Images dock that never loaded, since it loads onviewToggled(True). - A floating dock must be docked before it is pinned, as in §10.6.
- Never read a splitter's panes with
QSplitter.widget(i)through a temporary wrapper. PySide registers whatwidget(i)returns as a child of the splitter's Python wrapper. When that wrapper is freed, shiboken's_destroyParentInfoinvalidates every Qt-made wrapper below each pane, while the objects live on. Python-made wrappers stay valid. The symptom was "already deleted" on a document toolbar'sQWidgetActionat the next type-dock rebuild.shiboken6.dump(wrapper)lists a wrapper's registered children.children()withindexOf()registers nothing, and skipsQSplitterHandle, whichindexOf()also answers for.
QtAdsFloatingShowGuard (§11) keeps no container wrapper for a related reason. A container the restore shows
in the middle of a QtAds call had the wrapper the guard's filter saw invalidated before the call returned. The
guard finds its containers again at release by the WA_DontShowOnScreen it set.
15. A floating window's title, and a lone dock's tab
[[[appendices.qt-ads#floating-titles]]]
Three QtAds behaviours decide what a floating dock window is called and whether it shows a tab (#488):
setConfigFlagsreplaces QtAds' defaults; it does not add to them.FloatingContainerHasWidgetTitleis one of those defaults, so an app that sets its own flags drops it unless it lists it again. Without it every floating window takesCDockManager.floatingContainersTitle(), which falls back to the application's display name and then to its application name -- argv[0], "python" under a dev run. With it, a window holding one area takes that area's current tab's title, and a window of several areas takesfloatingContainersTitle(). The app sets the display name ("rehuco"); Windows then appends it to any top-level title that does not already end with it, which is how one dock reads "Settings - rehuco".- A dock alone in a floating window has its title bar hidden -- unless it has title-bar actions. QtAds keeps
the bar so the actions stay reachable, and the bar carries the dock's tab, which names the window a second
time. Hiding the tab widget alone (
tabWidget().setVisible(False)) keeps the bar and its buttons; QtAds neither re-shows a hidden tab on a resize or a current change, and shows it again itself when the dock is inserted into another area (measured).CDockWidget.isFloating()is true exactly while a dock is alone in a floating window, not for one sharing its window with another open dock. - No one signal marks a dock entering or leaving that state (measured):
topLevelChangedfires as another dock joins or leaves its window, but not as the dock itself re-docks or floats out, which reparent it (QEvent.ParentChange).QtAdsLoneTabHider(borco_pyside.qtads) watches both, decides a turn of the event loop later, and shows again only a tab it hid, so the tabs a maximize hides (§12) stay hidden.
16. Who owns a dock once it is closed or floated
[[[appendices.qt-ads#dock-ownership]]]
- A closed dock has no Qt parent (#345): anything that finds a dock by walking up from a widget, or reads its
owner through
parent(), gets nothing while the dock is closed. rehuco's document sub-docks are not floatable. - The window
addDockWidgetFloatingreturns can be deleted under its caller (#493): QtAds retires a floating container when its last area empties, so the wrapper held from that call goes stale. Find the window again through a dock'sfloatingDockContainer()each time it is needed ([[appendices.qt-ads#nested-emptied-float]]).