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.
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.