"""
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"
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_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 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"))