Source code for spacr.qt.widgets.section

"""
Section — a collapsible group with a header row (chevron + title) and
a QFormLayout body that expands/collapses on click. Used by settings
screens to group related fields; every section is collapsed by
default so users see one row per category instead of a wall of
controls.

A HEADING CAN ALSO SAY WHOSE SETTINGS THESE ARE. A module folded into
another one keeps its settings and loses its tile, and where the fold
made those settings a category rather than a page there is no button
left to carry the picture the user learned the module by. It goes on
the heading instead — :meth:`Section.set_source_app`, which draws the
module's own icon at the trailing end of the header row.
"""
from __future__ import annotations

import re
from typing import Optional, Union

from functools import partial

from PySide6.QtCore import QCoreApplication, QEvent, QObject, QSize, Qt, QTimer, Signal
from PySide6.QtWidgets import (
    QApplication,
    QFormLayout,
    QFrame,
    QHBoxLayout,
    QLabel,
    QScrollArea,
    QSizePolicy,
    QToolButton,
    QVBoxLayout,
    QWidget,
)

from ..i18n import tr
from ..theme import ALPHA_MARK, SPACING, STAGE_LABEL, STAGE_NOTE

#: Edge of the module mark drawn beside a category heading, in logical px.
#:
#: Smaller than the 20 px a fold button carries, because this one sits
#: inside a 34 px header next to text rather than alone on a 34 px plate.
SOURCE_ICON_PX = 18

#: The objectName the mark carries, so a screen (or a test) can find it.
SOURCE_ICON_NAME = "SectionSourceIcon"

#: Floor for a category header's height, in logical px.
#:
#: A FLOOR AND NOT A HEIGHT. It is what a header gets when its own size hint
#: is smaller than a comfortable pointer target; a header whose font asks for
#: more keeps what it asks for. See :meth:`Section._sync_header_minimum` for
#: what happened while this was the header's flat minimum.
SECTION_HEADER_MIN_PX = 34


#: How many rounds of posted ``LayoutRequest`` to drain before painting.
#:
#: Servicing one layout posts the next, so a single pass is never enough.
#: Four is past the deepest chain measured on the settings panel.
LAYOUT_FLUSHES = 4

#: Dynamic property reference-counting nested toggles on one scroll area.
#:
#: Opening a nested heading opens its ancestors too, so several sections can
#: be inside their own toggle at once. Only the OUTERMOST one owns the
#: confinement, or the inner levels would each release it early. Kept on the
#: scroll area itself rather than in a module dict keyed on ``id()``, which
#: goes wrong the moment a scroll area is collected and its id reused.
SETTLING_DEPTH = "spacrSectionSettlingDepth"


[docs] def scroll_host(widget): """The :class:`QScrollArea` ``widget`` scrolls inside, or ``None``. The confinement belongs on the scroll area and not on the section, because it is the SCROLL AREA's own size hint changing -- when its scrollbar arrives -- that carries the relayout past the settings column and into the splitter and the window. :param widget: the section being toggled. :returns: the enclosing scroll area, or None when the section is mounted without one (a dialog, a test). """ node = widget.parentWidget() while node is not None: if isinstance(node, QScrollArea): return node node = node.parentWidget() return None
class _BodyBackOnShow(QObject): """Puts an open category's detached body back when the category shows. Installed on a category only while its body is away, so no other widget's events reach it. ``Show`` is delivered before anything is painted, whichever way the category was revealed -- shown itself, or by a parent -- and the body is visible again in the same pass. """ _show = QEvent.Type.Show def eventFilter(self, watched, event): # noqa: N802 """Bring an open category's body back as the category appears.""" if (event.type() == self._show and isinstance(watched, Section) and watched._expanded and watched._detached_at is not None): watched._attach_body() return False #: The one filter every category with a body away carries. _BACK_ON_SHOW: Optional[_BodyBackOnShow] = None def _back_on_show() -> _BodyBackOnShow: """The shared :class:`_BodyBackOnShow`, made on first use.""" global _BACK_ON_SHOW if _BACK_ON_SHOW is None: _BACK_ON_SHOW = _BodyBackOnShow(QCoreApplication.instance()) return _BACK_ON_SHOW def _logical_parent(widget): """``widget``'s parent on the settings form, across a detached body. The parent widget, except for the body of a category that is holding it detached (see :meth:`Section._detach_body_while_hidden`): that body has no parent widget while it waits, and its category is where it belongs. :param widget: any widget. :returns: the parent it has on the form, or ``None``. """ parent = widget.parentWidget() if parent is None: owner = getattr(widget, "_spacr_detached_from", None) if owner is not None: return owner return parent def _sections_below(widget) -> list: """Every :class:`Section` below ``widget``, across detached bodies. ``findChildren(Section)`` for a widget that is not a category; :meth:`Section._nested_sections` for one that is. """ nested = getattr(widget, "_nested_sections", None) if callable(nested): return nested() found = list(widget.findChildren(Section)) for member in list(found): found.extend(child for child in member._nested_sections() if child not in found) return found
[docs] def module_mark(key: str): """Return the specific icon for a folded module, if available. Generic fallback artwork is not returned because it does not identify the source module. :param key: Folded module registry key. """ from .. import iconset try: has_art = iconset.bundled_icon_path(key) is not None glyphs = getattr(iconset, "_NAME_TO_GLYPH", {}) or {} if not has_art and key not in glyphs: return None from ..app import _icon_for_app mark = _icon_for_app(key) except Exception: # noqa: BLE001 return None if mark is None or mark.isNull(): return None return mark
[docs] class Section(QFrame): """Collapsible section with an animated chevron header + form body. :param title: the heading text, which is also what the hover strip looks this section's help up by. :param parent: parent widget. :param expanded: whether it opens unfolded. Sections start CLOSED by default because a screen that opens every one of them is a wall of settings before the user has chosen anything. """ toggled = Signal(bool) def __init__(self, title: str, parent=None, expanded: bool = False): """Build one collapsible settings category. The title is kept as written and uppercased only on the way to the button, because the translation catalogue is keyed on the written name -- looking up an already-uppercased caption finds nothing and leaves the header in English. The ``&`` in a name like "Plate Layout & Controls" is escaped for display, since a ``QToolButton`` would otherwise read it as a mnemonic and swallow it. The form's field-growth policy is named explicitly rather than left to the platform style: a style answering ``FieldsStayAtSizeHint`` -- valid, and what one reporter's platform chose -- gave a field 108 px in a section that Fusion gives 1,115. :param title: the category name, as written. :param parent: parent widget, or ``None``. :param expanded: open the category immediately. """ super().__init__(parent) self.setObjectName("SectionCard") self._expanded = False self._maturity = "stable" self._hint = "" self._row_widgets = [] #: The folded module these settings came from, once one claims #: them, and the mark that says so. Empty on a category the host #: module wrote itself, which is most of them. self._source_app = "" self._source_mark = None #: Where the body goes back when it is detached, and who hears that #: it did. See :meth:`_detach_body_while_hidden`. self._detached_at: Optional[int] = None self._body_came_back = None #: Builds the body's rows the first time the category is opened, #: for a category whose rows wait for that; see #: ``AppScreen._build_a_waiting_heading``. ``None`` otherwise. self._spacr_build_body = None outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) self._header = QToolButton(self) self._header.setObjectName("SectionHeader") self._title_source = str(title) self._title = self._title_source.upper() self._header.setProperty("i18nSkipText", True) self._header.retranslate_dynamic_content = self._refresh_header_text self._refresh_header_text() self._header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self._header.setArrowType(Qt.RightArrow) self._header.setCheckable(True) self._header.setChecked(False) self._header.setCursor(Qt.PointingHandCursor) self._header.installEventFilter(self) self._header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self._sync_header_minimum() self._header.clicked.connect(self._on_toggle) outer.addWidget(self._header) self._body = QWidget(self) self._body.setObjectName("SectionBody") self._form = QFormLayout(self._body) self._form.setFieldGrowthPolicy( QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self._form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) self._form.setFormAlignment(Qt.AlignTop) self._form.setContentsMargins(SPACING["md"], SPACING["md"], SPACING["md"], SPACING["md"]) self._form.setHorizontalSpacing(SPACING["md"]) self._form.setVerticalSpacing(SPACING["sm"]) self._body.setVisible(False) outer.addWidget(self._body) self._seal_body() if expanded: self.set_expanded(True) def _detach_body_while_hidden(self) -> int: """Take a body nobody can see out of the page until it is needed. A module screen is styled widget by widget, and Qt styles every widget under the page when the stylesheet lands on it -- hidden or not. On Classify, Mask and Measure most of a settings form is in categories that are collapsed, or hidden by the Essentials view or the maturity preference: 533 of Classify's widgets, 392 of Mask's, 303 of Measure's. A body taken out of the page before the sheet lands costs nothing then; :meth:`_attach_body` puts it back the moment it could be seen, and it is styled at that moment instead. NOTHING IS REBUILT. The body keeps every row, caption and control it had, and the controls are still the ones the settings model reads and writes, so the run collects every value, and recipes and imports still set them. The body remembers its category, so :func:`_logical_parent`, :meth:`_nested_sections` and :meth:`_holds` answer as they would with it in place, for the code that walks the form rather than the model. A body that is away when its category is destroyed goes with it, as it would have as a child. :returns: how many widgets left the page; 0 when the body can be seen, or is already out. """ if self._detached_at is not None: return 0 if not (self.isHidden() or self._body.isHidden()): return 0 layout = self.layout() index = layout.indexOf(self._body) if index < 0: return 0 count = len(self._body.findChildren(QWidget)) + 1 layout.removeWidget(self._body) self._body._spacr_detached_from = self self._body.setParent(None) self._body.setVisible(False) self.destroyed.connect(self._body.deleteLater) self.installEventFilter(_back_on_show()) self._detached_at = index return count def _body_is_detached(self) -> bool: """Whether the body is out of the page, waiting to be seen.""" return self._detached_at is not None def _attach_body(self) -> bool: """Put a detached body back where it was, before it can be seen. Called on expanding, and on the category's ``Show`` while it is open (see :class:`_BodyBackOnShow`), so the body is in place, styled and laid out in the same pass that shows it. Whoever set :attr:`_body_came_back` is then told, which is how a screen runs the language pass the body missed while it was away. :returns: ``True`` when this call put it back. """ index = self._detached_at if index is None: return False self._detached_at = None body = self._body try: self.destroyed.disconnect(body.deleteLater) except (RuntimeError, TypeError): pass if _BACK_ON_SHOW is not None: self.removeEventFilter(_BACK_ON_SHOW) body.setParent(self) body._spacr_detached_from = None self.layout().insertWidget(index, body) body.setVisible(self._expanded) heard = self._body_came_back if callable(heard): heard(self) return True def _nested_sections(self) -> list: """Every category below this one, including in a detached body. What ``findChildren(Section)`` answers with every body in place. """ found = [] seen = {id(self)} pending = [self._body] if self._detached_at is not None else [] pending.append(self) visited = set() while pending: root = pending.pop() if id(root) in visited: continue visited.add(id(root)) for member in root.findChildren(Section): if id(member) not in seen: seen.add(id(member)) found.append(member) if member._detached_at is not None: pending.append(member._body) return found def _holds(self, widget) -> bool: """Whether ``widget`` is on this category's form, attached or not.""" node = widget while node is not None: if node is self: return True node = _logical_parent(node) return False
[docs] def add_row( self, label: Union[str, QWidget], widget: QWidget, info_widget: Optional[QWidget] = None, wrap_label: bool = False, ) -> None: """Add a labeled row, optionally with an information-link icon. :param label: text or widget shown on the row's label side. :param widget: setting control shown on the row's field side. :param wrap_label: build the ``SettingLabelWithInfo`` host even with no info widget to put in it. The host is what right-aligns the label against its field; it used to arrive only as a side effect of there being a dot, so removing the dot from the settings form left every label left-aligned and turned half of each row's width into page showing through rather than category surface. ``_row_widgets`` still records the caller's label, not the host, so anything reading rows back gets the ``QLabel`` it passed in. """ form_label = label if info_widget is not None or wrap_label: form_label = QWidget(self._body) form_label.setObjectName("SettingLabelWithInfo") label_row = QHBoxLayout(form_label) label_row.setContentsMargins(0, 0, 0, 0) label_row.setSpacing(SPACING["xs"]) label_row.addStretch(1) if isinstance(label, QWidget): label_row.addWidget(label) else: label_row.addWidget(QLabel(str(label), form_label)) if info_widget is not None: label_row.addWidget(info_widget) self._form.addRow(form_label, widget) self._row_widgets.append((label, widget)) self._apply_maturity(label, setting=True) self._apply_maturity(widget, setting=True)
[docs] def add_prose_row(self, label: Union[str, QWidget], widget: QWidget, *, at_top: bool = False) -> None: """A labelled row that is NOT a setting. The difference from :meth:`add_row` is `_row_widgets`, for the reason :meth:`add_prose` gives: every entry there is taken to BE a labelled setting by the module smoke test, which asserts each field carries a ``settingKey`` and that its label holds linked API help. A row of Download buttons is neither. It still goes through the section's own QFormLayout, so its label sits in the same column and at the same right-aligned edge as every setting above and below it -- which is the point: a row of buttons floating in the middle of the section reads as unrelated to the settings it acts on, and one aligned with them reads as part of the same form. :param label: the row label: text (shown elided, with the full text as tooltip) or a ready-made widget. :param widget: the widget placed in the field column of the row. """ form_label = QWidget(self._body) form_label.setObjectName("SettingLabelWithInfo") label_row = QHBoxLayout(form_label) label_row.setContentsMargins(0, 0, 0, 0) label_row.setSpacing(SPACING["xs"]) label_row.addStretch(1) if isinstance(label, QWidget): label_row.addWidget(label) else: from .eliding import ElidingLabel text = str(label) prose = ElidingLabel(text, form_label) prose.setToolTip(text) label_row.addWidget(prose) if at_top: self._form.insertRow(0, form_label, widget) else: self._form.addRow(form_label, widget)
[docs] def add_widget(self, widget: QWidget) -> None: """Add a full-width (label-less) widget to the section's form body. :param widget: the widget added as a full-width row, coloured by the section's maturity. """ self._form.addRow(widget) self._row_widgets.append((None, widget)) self._apply_maturity(widget, setting=True)
[docs] def add_prose(self, widget: QWidget, *, at_top: bool = False) -> None: """Add full-width content that is NOT a setting row. :param widget: prose or other non-setting content to add. :param at_top: put it above the section's controls rather than below. THE DIFFERENCE FROM :meth:`add_widget` IS `_row_widgets`, and it is the whole reason this exists. Every entry there is taken to BE a labelled setting row by ``tests/qt/test_all_module_smoke.py::_setting_row_contract``, which asserts each field carries a ``settingKey`` and that its label is a QLabel holding linked API help. A prose box is neither a setting nor labelled, so registering it there would either fail that contract or force a fake ``settingKey`` onto a non-setting -- which would then be pushed into the tooltip and API-documentation machinery. `add_widget` has the same signature and the opposite bookkeeping, and had no caller in the GUI, so nothing had yet exposed the conflation. """ if at_top: self._form.insertRow(0, widget) else: self._form.addRow(widget) self._apply_maturity(widget, setting=True)
[docs] def title(self) -> str: """Return the section's header text, un-escaped.""" return self._title
[docs] def header(self) -> QToolButton: """Return the clickable header button (chevron + category title). Public because a screen needs a precise hover target for the *category* it represents: the section itself covers the whole form once expanded, so filtering events on it would report the category while the pointer is over one of its settings. """ return self._header
[docs] def set_source_app(self, key: str, name: str = "") -> bool: """Associate this category with a folded module and display its icon. The icon is drawn separately from the header button so the collapse chevron and pointer target remain unchanged. :param key: Folded module registry key. :param name: Accessible module name. If empty, ``key`` is used. :returns: ``True`` if a module-specific icon was displayed. """ key = str(key or "") self._source_app = key for target in (self, self._header): target.setProperty("settingsSourceApp", key) mark = module_mark(key) if key else None if mark is None: if self._source_mark is not None: self._source_mark.setVisible(False) return False badge = self._source_mark if badge is None: badge = QLabel(self._header) badge.setObjectName(SOURCE_ICON_NAME) badge.setAttribute(Qt.WA_TransparentForMouseEvents, True) badge.setFixedSize(SOURCE_ICON_PX, SOURCE_ICON_PX) self._source_row().addWidget(badge, 0, Qt.AlignVCenter) self._source_mark = badge badge.setPixmap(mark.pixmap(QSize(SOURCE_ICON_PX, SOURCE_ICON_PX), badge.devicePixelRatioF())) badge.setAccessibleName(str(name or key)) badge.setVisible(True) return True
[docs] def source_app(self) -> str: """Return the folded module associated with this category.""" return self._source_app
[docs] def source_mark(self) -> Optional[QWidget]: """Return the visible source-icon label, or ``None``.""" mark = self._source_mark if mark is None or not mark.isVisibleTo(self._header): return None return mark
def _source_row(self): """The header's own layout, made on demand for the module mark. Built only when a mark actually arrives, so every category that has none is the widget it always was. The right margin matches the header's stylesheet padding, so the mark lines up with the text that starts on the other side of it. """ row = self._header.layout() if row is None: row = QHBoxLayout(self._header) row.setContentsMargins(0, 0, SPACING["md"], 0) row.setSpacing(0) row.addStretch(1) return row
[docs] def set_hint(self, text: str) -> None: """Attach a hover tooltip to the section's header. The tooltip appears when the user hovers the header, whether the section is currently expanded or collapsed — same UX as every other Qt tooltip. :param text: tooltip text (plain or HTML; empty clears it). """ self._hint = text or "" self._refresh_tooltip()
[docs] def set_maturity(self, stage: str) -> None: """Colour this section and every setting row by maturity stage. ``stable``/``beta``/``alpha`` use the exact hues shown in Home's maturity legend. Unknown values deliberately fall back to stable. :param stage: maturity stage, ``'stable'``, ``'beta'`` or ``'alpha'`` (case-insensitive); empty or unknown values are treated as ``'stable'``. """ stage = str(stage or "stable").lower() if stage not in STAGE_LABEL: stage = "stable" self._maturity = stage for target in (self, self._header, self._body): self._apply_maturity(target) for label, widget in self._row_widgets: self._apply_maturity(label, setting=True) self._apply_maturity(widget, setting=True) self.setAccessibleDescription( f"{STAGE_LABEL[stage]} maturity settings section" ) self._refresh_header_text() self._refresh_tooltip()
[docs] def maturity(self) -> str: """Return ``stable``, ``beta`` or ``alpha`` for this section.""" return self._maturity
[docs] def set_expanded(self, on: bool) -> None: """Expand or collapse the section body programmatically. :param on: ``True`` to expand the body, ``False`` to collapse it. """ self._header.setChecked(on) self._on_toggle(on)
[docs] def is_expanded(self) -> bool: """Return True when the section body is currently visible.""" return self._expanded
def _apply_maturity(self, widget, *, setting: bool = False) -> None: """Stamp the maturity on a widget and repolish it so the style follows. :param widget: the widget to stamp; anything else is ignored. :param setting: stamp the per-setting property rather than the category's. """ if not isinstance(widget, QWidget): return prop = "settingMaturity" if setting else "maturity" widget.setProperty(prop, self._maturity) style = widget.style() style.unpolish(widget) style.polish(widget) def _refresh_header_text(self, language: Optional[str] = None) -> None: """Rebuild the header caption from its translated parts. The caption is composed -- the category name, plus a badge for beta or an ``α`` for alpha -- so the generic language pass would look up the finished line as one key and never find it. That pass is kept off the button and the caption rebuilt here whenever the language changes. An alpha heading reads "CONFLUENCY α": the name upper-cased and the mark kept lower-case, because an upper-cased ``α`` is the Greek capital, which is indistinguishable from a Latin A. A spelled-out "(Alpha)" in the name is dropped in favour of the mark, and the heading's colour (the theme's alpha ink) says the rest. :param language: the language to build for; ``None`` uses the current one. """ source = self._title_source.strip() text = tr(source, language).strip() if source.endswith(ALPHA_MARK) and text == source: text = self._translated_alpha_name(source, language) if self._maturity == "alpha" or text.endswith(ALPHA_MARK): base = text[:-len(ALPHA_MARK)] if text.endswith(ALPHA_MARK) else text base = base.upper().strip() stage = tr(STAGE_LABEL["alpha"], language).upper() for badge in dict.fromkeys((stage, STAGE_LABEL["alpha"].upper())): base = re.sub( rf"\s*(?:\(\s*{re.escape(badge)}\s*\)|{re.escape(badge)})\s*$", "", base, flags=re.IGNORECASE, ).strip() text = f"{base} {ALPHA_MARK}" if base else ALPHA_MARK self._header.setText(text.replace("&", "&&")) return text = text.upper() if self._maturity != "stable": stage = tr(STAGE_LABEL[self._maturity], language).upper() for badge in dict.fromkeys( (stage, STAGE_LABEL[self._maturity].upper())): text = re.sub( rf"\s*(?:\(\s*{re.escape(badge)}\s*\)|{re.escape(badge)})\s*$", "", text, flags=re.IGNORECASE, ).strip() text = f"{text} · {stage}" if text else f"· {stage}" text = text.replace("&", "&&") self._header.setText(text) @staticmethod def _translated_alpha_name(source: str, language: Optional[str]) -> str: """``"Cloud α"`` in ``language`` from the rows its name already has. An alpha category's catalog row may not exist yet, but the plain name ("Cloud") or the name it had before the mark replaced "(Alpha)" ("Confluency (Alpha)") usually does; either is used, with the badge dropped, before falling back to the English title. :param source: the English title, ending with the alpha mark. :param language: the language to translate into; ``None`` is the current one. :returns: the translated name followed by the mark. """ base = source[:-len(ALPHA_MARK)].strip() for candidate in (base, f"{base} (Alpha)"): translated = tr(candidate, language).strip() if translated != candidate: translated = re.sub(r"\s*[(\uff08][^()\uff08\uff09]*[)\uff09]\s*$", "", translated).strip() \ if candidate.endswith("(Alpha)") else translated return f"{translated} {ALPHA_MARK}" return source
[docs] def eventFilter(self, watched, event): # noqa: N802 """Swallow the header's tooltip request; pass everything else on. The header's own minimum is re-taken here on the two events that can change what it needs -- a new font, and a new style -- because both arrive after the button is built. See :meth:`_sync_header_minimum`. :param watched: the object the event is for. :param event: the event. :returns: True to stop a tooltip from being shown. """ if watched is getattr(self, "_header", None): if event.type() == QEvent.ToolTip: return True if event.type() in (QEvent.FontChange, QEvent.StyleChange, QEvent.Polish): self._sync_header_minimum() return super().eventFilter(watched, event)
def _seal_body(self) -> None: """Let Qt know the body is opaque, when the card really is opaque. MEASURED on the mask screen, 3840x2160, font_scale 2, blobs backdrop at 12 fps: the animation reaches 34.3% of the window and 0.0% of a section body -- the card's own fill covers every pixel of it. The body and its sixty-odd fields were repainting 12 times a second anyway, because the card's fill comes from the stylesheet and a QSS background is not something ``QWidgetRepaintManager`` can subtract damage against. See :func:`spacr.qt.theme.seal_surface` for the pair of measurements that establishes that. Sealing the BODY rather than the card on purpose: the card's rect includes its ``margin-bottom``, which is the gutter between two categories and is where the backdrop legitimately shows through, so the card cannot promise its whole rect. The body can. The seal undoes itself when ``surface`` is translucent at the user's page-opacity setting, which is the case the user asked for on the dock and the panels. """ try: from ..theme import seal_surface seal_surface(self._body, role="surface") except Exception: # noqa: BLE001 pass
[docs] def changeEvent(self, event): """Re-seal the body when the stylesheet or the palette is swapped. A theme change or a move of the page-opacity slider re-renders ``surface``; the seal is a palette brush this widget owns, so nothing else re-computes it. Only ``StyleChange`` is answered -- setting the body's palette posts ``PaletteChange`` back here, and answering that one would be a loop. :param event: the change event; it is passed to the base class, and a ``QEvent.StyleChange`` also re-seals the body. """ super().changeEvent(event) if event.type() == QEvent.StyleChange: self._seal_body()
def _sync_header_minimum(self) -> None: """Keep the header from being squeezed below the height it needs. `QSizePolicy.Fixed` is not the last word on how short a widget may be made: `qSmartMinSize` computes a minimum from the policy and the hints, and then an explicitly set `minimumHeight` REPLACES it, downwards as readily as upwards. So the flat `setMinimumHeight(34)` this used to carry did not raise a floor, it granted the layout permission to shrink the header to 34 px. That permission is taken up on every expand. Showing a section body makes the settings column taller than the widget the scroll area has given it -- the scroll area only resizes that widget on the NEXT pass -- so the first pass distributes too little height and shrinks every category header to its stated minimum. MEASURED on the mask screen: at font_scale 1 the headers want 36 px and drop to 34, which is why this went unseen for so long; at font_scale 2 they want 52 and drop to 34, and 11 of the 19 headers on the panel took an 18 px dip and came back one layout pass later. That is 22 of the 175 resize events an expand costs, and a window repaint landing inside the ~2 ms both passes take draws every heading short. 34 stays as a floor for the opposite case -- a hint smaller than a comfortable pointer target, which is what the constant was for. Re-taken on font and style changes because the hint is the polished button's, and at construction the stylesheet has not been applied. """ wanted = max(SECTION_HEADER_MIN_PX, self._header.sizeHint().height()) if self._header.minimumHeight() != wanted: self._header.setMinimumHeight(wanted) def _refresh_tooltip(self) -> None: """Rebuild the header tooltip, adding the caution text off stable. Stable is the normal case and keeps its curated tooltip byte for byte; beta and alpha need the note, because their colour carries information the old tooltip did not. """ note = ( STAGE_NOTE.get(self._maturity, "") if self._maturity != "stable" else "" ) parts = [part for part in (self._hint, note) if part] self._header.setToolTip("\n\n".join(parts)) def _pre_resolve_the_scrollbar(self, scroll) -> None: """Decide the scrollbar BEFORE the body moves, not during. Today the bar arrives *during* the open, which narrows the viewport by its own width in the very frame the content jumps hundreds of pixels down. Two perpendicular discontinuities in one frame is what reads as a shudder rather than as a resize -- and the narrower viewport re-elides every label, which is a second full layout pass. Resolved up front, the horizontal step happens once, before anything moves vertically, and the labels are elided once at the final width. :param scroll: the enclosing scroll area. """ content = scroll.widget() if content is None: return if self._expanded: delta = self._body.sizeHint().height() else: delta = -self._body.height() will_scroll = (content.height() + delta) > scroll.viewport().height() scroll.setVerticalScrollBarPolicy( Qt.ScrollBarAlwaysOn if will_scroll else Qt.ScrollBarAlwaysOff) def _on_toggle(self, checked: bool) -> None: """Open or close the body, in ONE painted frame rather than two. WHAT THE USER SEES WITHOUT THIS. Qt services a layout in whatever order the posted events arrive, and here they arrive in two rounds: the section's own chain, and then -- after the first round has already been PAINTED -- a second ``LayoutRequest`` for the scroll area and the splitter. The intermediate result of round one is on screen for about one vsync. That is the flicker, literally: a wrong frame, briefly shown. Reported as "the opening of module settings categories has a flicker as well and is not smoothe". So updates are held while every posted layout request is drained synchronously, and the window is only asked to paint once the geometry has stopped moving. WHERE THE GUARD GOES DECIDES WHETHER IT HELPS. Held on the top-level window, re-enabling calls ``update()`` on the whole 3840x2160 surface and every targeted rectangle Qt worked out is thrown away for one full-window repaint. Held on the scroll area, the forced repaint is the settings column alone and the splitter and backdrop above it are never touched. ``setUpdatesEnabled`` is restored in a ``finally``: an exception between the two calls would leave the whole column unable to repaint, which is a frozen panel rather than a cosmetic bug. :param checked: whether the header is now open. """ self._expanded = bool(checked) self._header.setArrowType( Qt.DownArrow if self._expanded else Qt.RightArrow) if self._expanded and self._detached_at is not None: self._attach_body() if self._expanded and self._spacr_build_body is not None: build, self._spacr_build_body = self._spacr_build_body, None build() scroll = scroll_host(self) if scroll is None: self._body.setVisible(self._expanded) self.toggled.emit(self._expanded) try: from .ambient import field_ripple_for_widget except ImportError: return QTimer.singleShot(0, partial( field_ripple_for_widget, self, edge="bottom")) return depth = int(scroll.property(SETTLING_DEPTH) or 0) outermost = depth == 0 scroll.setProperty(SETTLING_DEPTH, depth + 1) saved_policy = None try: if outermost: saved_policy = scroll.verticalScrollBarPolicy() self._pre_resolve_the_scrollbar(scroll) scroll.setUpdatesEnabled(False) self._body.setVisible(self._expanded) self.toggled.emit(self._expanded) finally: scroll.setProperty( SETTLING_DEPTH, int(scroll.property(SETTLING_DEPTH) or 1) - 1) if outermost: try: for _ in range(LAYOUT_FLUSHES): QApplication.sendPostedEvents( None, QEvent.LayoutRequest) finally: if saved_policy is not None: scroll.setVerticalScrollBarPolicy(saved_policy) scroll.setUpdatesEnabled(True) try: from .ambient import field_ripple_for_widget except ImportError: return QTimer.singleShot(0, partial( field_ripple_for_widget, self, edge="bottom"))