"""The whole-GUI scale, applied live: one factor for every size spaCR sets (471).
WHAT IT IS. "GUI scale" in Preferences (10 % to 200 %, default 100 %) scales
widget sizes, fixed sizes, margins, spacing, style-sheet sizes (fonts and
paddings included), icons, splitter sizes and figure dpi -- and it does so
while spaCR runs, with no restart.
HOW, WITHOUT TOUCHING 1,420 CALL SITES. Counted on nightly 2026-09-22 across
the 328 modules under ``spacr/qt``: about 1,420 hard-coded geometry calls in
181 files (470 ``setContentsMargins``, 489 ``setSpacing``, 179
``setMinimumWidth/Height``, 67 ``setMaximumWidth/Height``, 74 ``setFixed*``,
30 ``setMinimumSize``, 56 ``QSize``, 64 ``resize``, 6 ``setIconSize``) and
486 ``px`` literals in style-sheet text in 64 files. Routing each by hand is
a change to every screen, and every size added later would have to remember
it. Instead :func:`install_scaling_layer` -- called once from
:func:`spacr.qt.app.launch` before the first widget exists -- replaces the
setters themselves on the Qt classes (``QWidget``'s size setters and
``setStyleSheet``, the layouts' margin and spacing setters, every
``setIconSize``, ``QSplitter.setSizes``). Each replacement
* remembers, on the object, the value the code asked for -- its size at
100 %;
* passes the value times the current scale on to Qt;
* and answers the matching getter with the remembered value, so code that
reads a size back and sets it again (the theme compares its own sheet by
digest, a splitter saves ``sizes()``) keeps working in 100 % units and
never compounds.
A change of scale (:func:`set_gui_scale_live`) walks every live widget and
layout, puts every remembered value back at the new scale, and asks the
windows to lay out again. A widget built later is scaled as it is built.
A splitter's panes keep the room they have on screen -- what shrinks is
what is inside them -- and ``sizes()`` answers in 100 % units, so the sizes
slice B persists survive a change of scale.
HOW IT COMPOSES WITH FONT SCALE. Font scale stays where it is: it writes the
font sizes into the theme's style sheet. That sheet goes through the
``setStyleSheet`` replacement like any other, so a font size ends up as
base x font scale x GUI scale. 50 % GUI at 200 % font is half-size widgets
with text the usual size on screen.
A USER'S OWN ``QT_SCALE_FACTOR`` is not touched and multiplies on the
outside, as Qt always has.
KEEP OR REVERT. Every change of GUI scale or font scale applies at once and
then asks "Keep these settings?" (:class:`KeepOrRevertDialog`), counting down
15 s; the countdown, Esc and closing the dialog all put the old values back.
The dialog is exempt from the scale and sets its own text size, so it is
readable at 10 % GUI and 10 % font alike. ``Ctrl+Alt+0``
(:func:`reset_every_scale`) remains the backup.
WHAT DOES NOT FOLLOW LIVE. Sizes the scale cannot see because they do not
pass through a setter: text and shapes a widget paints itself at fixed pixel
coordinates, pixmaps a widget scales to a number it computed, ``setFont``
with an explicit size, ``move``/``setGeometry`` positions, header section
sizes, a Python ``sizeHint`` override that returns a constant, the Fusion
style's own metrics that the theme sheet does not restate, and pyqtgraph's
axis text. Sizes computed from font metrics are already scaled once by the
font and are scaled again by the setter -- the one way the layer can
over-shrink. The main window's own size is left alone, so the gained room
goes to the content.
"""
from __future__ import annotations
import logging
import math
import re
import weakref
from typing import Callable, List, Optional
LOG = logging.getLogger("spacr.qt.gui_scale")
#: Qt's "no maximum" size, which is never scaled.
QWIDGETSIZE_MAX = 16777215
#: A window carrying this property is drawn at 100 % whatever the scale --
#: the Keep or Revert question is the one that has to stay readable.
EXEMPT = "spacrGuiScaleExempt"
#: Style-sheet properties whose pixel values follow the scale. Borders are
#: left alone: a hairline that rounds to nothing disappears.
_QSS_SIZE = re.compile(
r"(?<![\w-])(font-size|font|padding(?:-(?:top|right|bottom|left))?"
r"|margin(?:-(?:top|right|bottom|left))?|(?:min|max)-(?:width|height)"
r"|width|height|spacing|border(?:-(?:top|bottom)-(?:left|right))?-radius)"
r"(\s*:\s*)([^;{}]*)", re.I)
_PX = re.compile(r"(-?\d+(?:\.\d+)?)px")
_SCALE = 1.0
_INSTALLED = False
_ORIGINAL = {}
_SHEET_CACHE: dict = {}
_LISTENERS: List = []
[docs]
def current_scale() -> float:
"""The GUI scale in force now (1.0 = 100 %)."""
return _SCALE
[docs]
def installed() -> bool:
"""Whether :func:`install_scaling_layer` has run in this process."""
return _INSTALLED
[docs]
def scale_int(value, factor: Optional[float] = None) -> int:
"""``value`` at ``factor``, keeping 0, negatives and Qt's maximum as they are.
:param value: a size in 100 % pixels.
:param factor: the scale; the current one by default.
"""
value = int(value)
factor = _SCALE if factor is None else float(factor)
if value <= 0 or value >= QWIDGETSIZE_MAX or factor == 1.0:
return value
return max(1, int(round(value * factor)))
def _scaled_px(match, factor: float, prop: str = "") -> str:
"""One ``Npx`` at ``factor``, never rounding a non-zero size to nothing.
A radius rounds down, not to nearest. Qt drops every corner of a box
whose two radii add up to more than its side, so a circle or a pill
whose radius is half its size turns square the moment the radius rounds
up past half of the rounded side: 8px of 16px at 70 % is 6 of 11.
:param match: the ``Npx`` match.
:param factor: the scale.
:param prop: the property the value belongs to.
"""
value = float(match.group(1))
if value == 0:
return match.group(0)
size = abs(value) * factor
if prop.lower().endswith("radius"):
scaled = int(math.floor(size + 1e-9)) or 1
else:
scaled = int(round(size)) or 1
return f"{-scaled if value < 0 else scaled}px"
[docs]
def scale_qss_text(text: str, factor: Optional[float] = None) -> str:
"""Scale the sizes in a style sheet, leaving every other byte as it was.
Only the values of size properties (font sizes, paddings, margins,
min/max sizes, widths, heights, spacing, radii) change; selectors,
colours, borders and ``url(...)`` data are untouched.
:param text: the style sheet.
:param factor: the scale; the current one by default.
"""
factor = _SCALE if factor is None else float(factor)
text = "" if text is None else str(text)
if factor == 1.0 or "px" not in text:
return text
key = (text, factor)
hit = _SHEET_CACHE.get(key)
if hit is not None:
return hit
scaled = _QSS_SIZE.sub(
lambda m: m.group(1) + m.group(2)
+ _PX.sub(lambda p: _scaled_px(p, factor, m.group(1)), m.group(3)),
text)
if len(_SHEET_CACHE) > 256:
_SHEET_CACHE.clear()
_SHEET_CACHE[key] = scaled
return scaled
def _exempt(widget) -> bool:
"""Is ``widget`` inside a window drawn at 100 % regardless?"""
try:
window = widget.window()
return bool(window is not None and window.property(EXEMPT))
except (AttributeError, RuntimeError, TypeError):
return False
def _layout_exempt(layout) -> bool:
"""Is ``layout``'s widget inside an exempt window?"""
try:
owner = layout.parentWidget()
except (AttributeError, RuntimeError):
return False
return owner is not None and _exempt(owner)
def _factor_for(widget) -> float:
"""The scale ``widget`` is drawn at: the current one, or 1 if exempt."""
if _SCALE == 1.0:
return 1.0
return 1.0 if _exempt(widget) else _SCALE
def _store(obj, name: str) -> dict:
"""The per-object record of what the code asked for."""
record = getattr(obj, name, None)
if record is None:
record = {}
try:
setattr(obj, name, record)
except (AttributeError, TypeError):
return {}
return record
def _size_args(args):
"""``(w, h)`` from ``(QSize)`` or ``(w, h)``."""
if len(args) == 1:
size = args[0]
return int(size.width()), int(size.height())
return int(args[0]), int(args[1])
def _margin_args(args):
"""``(l, t, r, b)`` from ``(QMargins)`` or four ints."""
if len(args) == 1:
m = args[0]
return int(m.left()), int(m.top()), int(m.right()), int(m.bottom())
return tuple(int(v) for v in args[:4])
_DIMS = {
"minw": ("setMinimumWidth", "minimumWidth"),
"minh": ("setMinimumHeight", "minimumHeight"),
"maxw": ("setMaximumWidth", "maximumWidth"),
"maxh": ("setMaximumHeight", "maximumHeight"),
}
def _is_our_own_value(base, value, factor: float) -> bool:
"""Is ``value`` this layer's own scaled ``base``, measured again?
A widget that re-derives its size from the font it is drawn in -- the
window's close mark re-measures its glyph whenever the sheet changes --
would otherwise hand the shrunken size back as a NEW base, and going
back to 100 % would leave it shrunken. Measured on a 100 -> 50 -> 100
round trip before this guard: the window's close marks came back half
size. A value within 6 % of what this layer itself applied is taken to
be that same size, so the base stands.
:param base: the remembered 100 % value.
:param value: what the caller is setting now.
:param factor: the scale in force.
"""
if base is None or factor == 1.0:
return False
wanted = scale_int(base, factor)
return abs(int(value) - wanted) <= max(1, int(round(wanted * 0.06)))
def _set_dims(widget, **dims) -> None:
"""Remember and apply minimum/maximum dimensions of ``widget``."""
from PySide6.QtWidgets import QWidget
record = _store(widget, "_gs_geo")
applied = _store(widget, "_gs_geo_set")
factor = _factor_for(widget)
for key, value in dims.items():
if not _is_our_own_value(record.get(key), value, factor):
record[key] = int(value)
wanted = scale_int(record[key], factor)
applied[key] = wanted
_ORIGINAL[(QWidget, _DIMS[key][0])](widget, wanted)
def _get_dim(widget, key: str) -> int:
"""A dimension in 100 % units when it is still the one the layer set."""
from PySide6.QtWidgets import QWidget
actual = _ORIGINAL[(QWidget, _DIMS[key][1])](widget)
record = getattr(widget, "_gs_geo", None)
applied = getattr(widget, "_gs_geo_set", None)
if record and key in record and applied and applied.get(key) == actual:
return record[key]
return actual
def _install_widget_setters() -> None:
"""Replace ``QWidget``'s size setters and getters."""
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QMainWindow, QWidget
for name in ("setMinimumWidth", "setMinimumHeight", "setMaximumWidth",
"setMaximumHeight", "minimumWidth", "minimumHeight",
"maximumWidth", "maximumHeight", "resize", "setStyleSheet",
"styleSheet"):
_ORIGINAL[(QWidget, name)] = getattr(QWidget, name)
def setMinimumWidth(self, w): # noqa: N802
"""Record ``w`` at 100 % and apply it at the GUI scale."""
_set_dims(self, minw=w)
def setMinimumHeight(self, h): # noqa: N802
"""Record ``h`` at 100 % and apply it at the GUI scale."""
_set_dims(self, minh=h)
def setMaximumWidth(self, w): # noqa: N802
"""Record ``w`` at 100 % and apply it at the GUI scale."""
_set_dims(self, maxw=w)
def setMaximumHeight(self, h): # noqa: N802
"""Record ``h`` at 100 % and apply it at the GUI scale."""
_set_dims(self, maxh=h)
def setMinimumSize(self, *args): # noqa: N802
"""Record the size at 100 % and apply it at the GUI scale."""
w, h = _size_args(args)
_set_dims(self, minw=w, minh=h)
def setMaximumSize(self, *args): # noqa: N802
"""Record the size at 100 % and apply it at the GUI scale."""
w, h = _size_args(args)
_set_dims(self, maxw=w, maxh=h)
def setFixedWidth(self, w): # noqa: N802
"""Record ``w`` at 100 % and apply it at the GUI scale."""
_set_dims(self, minw=w, maxw=w)
def setFixedHeight(self, h): # noqa: N802
"""Record ``h`` at 100 % and apply it at the GUI scale."""
_set_dims(self, minh=h, maxh=h)
def setFixedSize(self, *args): # noqa: N802
"""Record the size at 100 % and apply it at the GUI scale."""
w, h = _size_args(args)
_set_dims(self, minw=w, maxw=w, minh=h, maxh=h)
def minimumWidth(self): # noqa: N802
"""The minimum width in 100 % units."""
return _get_dim(self, "minw")
def minimumHeight(self): # noqa: N802
"""The minimum height in 100 % units."""
return _get_dim(self, "minh")
def maximumWidth(self): # noqa: N802
"""The maximum width in 100 % units."""
return _get_dim(self, "maxw")
def maximumHeight(self): # noqa: N802
"""The maximum height in 100 % units."""
return _get_dim(self, "maxh")
def minimumSize(self): # noqa: N802
"""The minimum size in 100 % units."""
return QSize(_get_dim(self, "minw"), _get_dim(self, "minh"))
def maximumSize(self): # noqa: N802
"""The maximum size in 100 % units."""
return QSize(_get_dim(self, "maxw"), _get_dim(self, "maxh"))
original_resize = _ORIGINAL[(QWidget, "resize")]
def resize(self, *args):
"""Resize at the GUI scale; the main window keeps its size."""
w, h = _size_args(args)
factor = 1.0 if isinstance(self, QMainWindow) else _factor_for(self)
original_resize(self, scale_int(w, factor), scale_int(h, factor))
original_set_sheet = _ORIGINAL[(QWidget, "setStyleSheet")]
original_sheet = _ORIGINAL[(QWidget, "styleSheet")]
def setStyleSheet(self, text): # noqa: N802
"""Record the sheet at 100 % and apply its sizes at the GUI scale."""
text = "" if text is None else str(text)
record = getattr(self, "_gs_sheet", None)
if record is not None and text == record[1] != record[0]:
text = record[0]
scaled = scale_qss_text(text, _factor_for(self))
try:
self._gs_sheet = (text, scaled)
except (AttributeError, TypeError):
pass
original_set_sheet(self, scaled)
def styleSheet(self): # noqa: N802
"""The sheet as the code set it, at 100 %."""
actual = original_sheet(self)
record = getattr(self, "_gs_sheet", None)
if record is not None and record[1] == actual:
return record[0]
return actual
for fn in (setMinimumWidth, setMinimumHeight, setMaximumWidth,
setMaximumHeight, setMinimumSize, setMaximumSize,
setFixedWidth, setFixedHeight, setFixedSize, minimumWidth,
minimumHeight, maximumWidth, maximumHeight, minimumSize,
maximumSize, resize, setStyleSheet, styleSheet):
setattr(QWidget, fn.__name__, fn)
def _qt_subclasses(base):
"""Every class in QtWidgets that derives from ``base``, ``base`` included.
Read through ``dir`` rather than ``vars``: PySide6 fills a module's
dictionary lazily, so ``vars`` on a fresh import lists three classes and
the patch missed ``QAbstractButton`` -- which is every button's icon.
"""
from PySide6 import QtWidgets
found = []
for name in dir(QtWidgets):
value = getattr(QtWidgets, name, None)
if isinstance(value, type) and issubclass(value, base):
found.append(value)
return found
def _patch_everywhere(base, name: str, make: Callable) -> None:
"""Patch ``name`` on ``base`` and on every Qt subclass binding its own."""
wrappers = set()
for cls in [base] + [c for c in _qt_subclasses(base) if c is not base]:
current = getattr(cls, name, None)
if current is None or current in wrappers:
continue
if cls is not base and current is getattr(base, name):
continue
_ORIGINAL[(cls, name)] = current
wrapper = make(current)
wrapper.__name__ = name
setattr(cls, name, wrapper)
wrappers.add(wrapper)
def _install_layout_setters() -> None:
"""Replace the layouts' margin and spacing setters and getters."""
from PySide6.QtCore import QMargins
from PySide6.QtWidgets import (QBoxLayout, QLayout, QSizePolicy,
QSpacerItem)
def margins_setter(original):
"""The wrapper that records a layout's margins and scales them."""
def setContentsMargins(self, *args): # noqa: N802
"""Record the margins at 100 % and apply them at the GUI scale."""
base = _margin_args(args)
factor = 1.0 if _layout_exempt(self) else _SCALE
wanted = tuple(scale_int(v, factor) for v in base)
try:
self._gs_margins = (base, wanted)
except (AttributeError, TypeError):
pass
original(self, *wanted)
return setContentsMargins
def margins_getter(original):
"""The wrapper that answers a layout's margins at 100 %."""
def contentsMargins(self): # noqa: N802
"""The margins in 100 % units."""
actual = original(self)
record = getattr(self, "_gs_margins", None)
if record is not None and _margin_args((actual,)) == record[1]:
return QMargins(*record[0])
return actual
return contentsMargins
_patch_everywhere(QLayout, "setContentsMargins", margins_setter)
_patch_everywhere(QLayout, "contentsMargins", margins_getter)
def spacing_setter(key):
"""The factory for one spacing setter, by its record key."""
def make(original):
"""The wrapper that records a spacing and scales it."""
def setter(self, value):
"""Record the spacing at 100 % and apply it at the GUI scale."""
factor = 1.0 if _layout_exempt(self) else _SCALE
wanted = scale_int(value, factor)
record = _store(self, "_gs_spacing")
record[key] = (int(value), wanted)
original(self, wanted)
return setter
return make
def spacing_getter(key):
"""The factory for one spacing getter, by its record key."""
def make(original):
"""The wrapper that answers a spacing at 100 %."""
def getter(self):
"""The spacing in 100 % units."""
actual = original(self)
record = getattr(self, "_gs_spacing", None)
if record and key in record and record[key][1] == actual:
return record[key][0]
return actual
return getter
return make
from PySide6.QtWidgets import QFormLayout, QGridLayout
for base, setter, getter, key in (
(QLayout, "setSpacing", "spacing", "sp"),
(QGridLayout, "setHorizontalSpacing", "horizontalSpacing", "hs"),
(QGridLayout, "setVerticalSpacing", "verticalSpacing", "vs"),
(QFormLayout, "setHorizontalSpacing", "horizontalSpacing", "hs"),
(QFormLayout, "setVerticalSpacing", "verticalSpacing", "vs")):
_patch_everywhere(base, setter, spacing_setter(key))
_patch_everywhere(base, getter, spacing_getter(key))
def spacer(self, index, size):
"""A fixed spacer item along the box's direction."""
horizontal = self.direction() in (QBoxLayout.LeftToRight,
QBoxLayout.RightToLeft)
factor = 1.0 if _layout_exempt(self) else _SCALE
wanted = scale_int(size, factor)
if horizontal:
item = QSpacerItem(wanted, 0, QSizePolicy.Fixed,
QSizePolicy.Minimum)
else:
item = QSpacerItem(0, wanted, QSizePolicy.Minimum,
QSizePolicy.Fixed)
spacers = getattr(self, "_gs_spacers", None)
if spacers is None:
spacers = []
try:
self._gs_spacers = spacers
except (AttributeError, TypeError):
pass
spacers.append((item, int(size), horizontal))
if index is None:
self.addItem(item)
else:
self.insertItem(index, item)
def addSpacing(self, size): # noqa: N802
"""Add a spacer recorded at 100 % and drawn at the GUI scale."""
spacer(self, None, size)
def insertSpacing(self, index, size): # noqa: N802
"""Insert a spacer recorded at 100 % and drawn at the GUI scale."""
spacer(self, index, size)
_ORIGINAL[(QBoxLayout, "addSpacing")] = QBoxLayout.addSpacing
_ORIGINAL[(QBoxLayout, "insertSpacing")] = QBoxLayout.insertSpacing
QBoxLayout.addSpacing = addSpacing
QBoxLayout.insertSpacing = insertSpacing
def _install_icon_setters() -> None:
"""Replace every ``setIconSize`` and ``iconSize`` in QtWidgets."""
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QWidget
def setter(original):
"""The wrapper that records an icon size and scales it."""
def setIconSize(self, size): # noqa: N802
"""Record the icon size at 100 % and apply it at the GUI scale."""
base = _size_args((size,))
factor = _factor_for(self)
previous = getattr(self, "_gs_icon", None)
if previous is not None and all(
_is_our_own_value(old, new, factor)
for old, new in zip(previous[0], base)):
base = previous[0]
wanted = tuple(scale_int(v, factor) for v in base)
try:
self._gs_icon = (base, wanted)
except (AttributeError, TypeError):
pass
original(self, QSize(*wanted))
return setIconSize
def getter(original):
"""The wrapper that answers an icon size at 100 %."""
def iconSize(self): # noqa: N802
"""The icon size in 100 % units."""
actual = original(self)
record = getattr(self, "_gs_icon", None)
if record is not None and _size_args((actual,)) == record[1]:
return QSize(*record[0])
return actual
return iconSize
roots = [cls for cls in _qt_subclasses(QWidget)
if getattr(cls, "setIconSize", None) is not None
and not any(getattr(parent, "setIconSize", None) is not None
for parent in cls.__mro__[1:])]
for root in roots:
_patch_everywhere(root, "setIconSize", setter)
_patch_everywhere(root, "iconSize", getter)
def _install_splitter_setters() -> None:
"""Keep splitter sizes in 100 % units, so saved sizes survive a change."""
from PySide6.QtWidgets import QSplitter
original_set = QSplitter.setSizes
original_get = QSplitter.sizes
_ORIGINAL[(QSplitter, "setSizes")] = original_set
_ORIGINAL[(QSplitter, "sizes")] = original_get
def setSizes(self, sizes): # noqa: N802
"""Apply 100 % sizes at the GUI scale, and remember them."""
factor = _factor_for(self)
wanted = [scale_int(v, factor) for v in sizes]
try:
self._gs_sizes = ([int(v) for v in sizes], list(wanted))
except (AttributeError, TypeError):
pass
original_set(self, wanted)
def sizes(self):
"""The sizes in 100 % units."""
factor = _factor_for(self)
actual = original_get(self)
if factor == 1.0:
return actual
return [int(round(v / factor)) if v > 0 else v for v in actual]
QSplitter.setSizes = setSizes
QSplitter.sizes = sizes
def _install_application_sheet() -> None:
"""The application-wide sheet goes through the same size rewrite."""
from PySide6.QtWidgets import QApplication
original_set = QApplication.setStyleSheet
original_get = QApplication.styleSheet
_ORIGINAL[(QApplication, "setStyleSheet")] = original_set
_ORIGINAL[(QApplication, "styleSheet")] = original_get
state = {}
def setStyleSheet(self, text): # noqa: N802
"""Record the sheet at 100 % and apply its sizes at the GUI scale."""
text = "" if text is None else str(text)
scaled = scale_qss_text(text)
state["sheet"] = (text, scaled)
original_set(self, scaled)
def styleSheet(self): # noqa: N802
"""The sheet as the code set it, at 100 %."""
actual = original_get(self)
record = state.get("sheet")
if record is not None and record[1] == actual:
return record[0]
return actual
QApplication.setStyleSheet = setStyleSheet
QApplication.styleSheet = styleSheet
_ORIGINAL["app_state"] = state
[docs]
def install_scaling_layer() -> bool:
"""Put the scaling layer on the Qt classes. Idempotent.
Called from :func:`spacr.qt.app.launch` before the first widget, so every
size spaCR sets is remembered at 100 %. At 100 % every replacement hands
Qt exactly the value it was given, which is why a default session draws
the pixels it drew before the layer existed.
:returns: ``True`` the first time, ``False`` if it was already in place.
"""
global _INSTALLED
if _INSTALLED:
return False
_install_widget_setters()
_install_layout_setters()
_install_icon_setters()
_install_splitter_setters()
_install_application_sheet()
_INSTALLED = True
return True
[docs]
def add_listener(callback: Callable[[float], None]) -> None:
"""Call ``callback(scale)`` after every change of GUI scale.
Held weakly when it is a bound method, so a listener does not keep its
widget alive.
:param callback: called with the new scale.
"""
try:
ref = weakref.WeakMethod(callback)
except TypeError:
ref = (lambda cb=callback: cb)
_LISTENERS.append(ref)
def _reapply_widget(widget, factor: float, previous: float = 1.0) -> None:
"""Put a widget's remembered sizes, sheet and icon back at ``factor``.
:param widget: the widget to re-scale.
:param factor: the scale it is drawn at now.
:param previous: the scale it was drawn at, which is how an icon size
nobody ever set -- matplotlib's toolbar takes the style's 24 px --
is read back into 100 % units the first time the scale moves.
"""
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QWidget
record = getattr(widget, "_gs_geo", None)
if record:
applied = _store(widget, "_gs_geo_set")
for key, value in record.items():
wanted = scale_int(value, factor)
if applied.get(key) != wanted:
applied[key] = wanted
_ORIGINAL[(QWidget, _DIMS[key][0])](widget, wanted)
sheet = getattr(widget, "_gs_sheet", None)
if sheet is not None:
scaled = scale_qss_text(sheet[0], factor)
if scaled != sheet[1]:
widget._gs_sheet = (sheet[0], scaled)
_ORIGINAL[(QWidget, "setStyleSheet")](widget, scaled)
icon = getattr(widget, "_gs_icon", None)
if icon is None and factor != previous:
getter = _original_for(widget, "iconSize")
if getter is not None:
try:
actual = getter(widget)
base = (int(round(actual.width() / previous)),
int(round(actual.height() / previous)))
except (RuntimeError, ZeroDivisionError):
base = None
if base and base[0] > 0 and base[1] > 0:
icon = (base, (0, 0))
try:
widget._gs_icon = icon
except (AttributeError, TypeError):
icon = None
if icon is not None:
wanted = tuple(scale_int(v, factor) for v in icon[0])
if wanted != icon[1]:
setter = _original_for(widget, "setIconSize")
if setter is not None:
widget._gs_icon = (icon[0], wanted)
setter(widget, QSize(*wanted))
def _original_for(obj, name: str):
"""The Qt method ``name`` of ``obj``'s class, before the layer."""
for cls in type(obj).__mro__:
found = _ORIGINAL.get((cls, name))
if found is not None:
return found
return None
def _reapply_layout(layout, factor: float) -> None:
"""Put a layout's remembered margins, spacing and spacers back."""
margins = getattr(layout, "_gs_margins", None)
if margins is not None:
wanted = tuple(scale_int(v, factor) for v in margins[0])
if wanted != margins[1]:
layout._gs_margins = (margins[0], wanted)
setter = _original_for(layout, "setContentsMargins")
if setter is not None:
setter(layout, *wanted)
spacing = getattr(layout, "_gs_spacing", None)
if spacing:
names = {"sp": "setSpacing", "hs": "setHorizontalSpacing",
"vs": "setVerticalSpacing"}
for key, (base, applied) in list(spacing.items()):
wanted = scale_int(base, factor)
if wanted != applied:
setter = _original_for(layout, names[key])
if setter is not None:
setter(layout, wanted)
spacing[key] = (base, wanted)
for item, base, horizontal in getattr(layout, "_gs_spacers", None) or ():
from PySide6.QtWidgets import QSizePolicy
wanted = scale_int(base, factor)
try:
if layout.indexOf(item) < 0:
continue
if horizontal:
item.changeSize(wanted, 0, QSizePolicy.Fixed,
QSizePolicy.Minimum)
else:
item.changeSize(0, wanted, QSizePolicy.Minimum,
QSizePolicy.Fixed)
except RuntimeError:
continue
from PySide6.QtWidgets import QLayout
if not isinstance(layout, QLayout):
return
layout.invalidate()
for child in _child_layouts(layout):
_reapply_layout(child, factor)
def _child_layouts(layout) -> list:
"""The layouts nested directly in ``layout``, without touching its items.
NEVER BY ``itemAt``. Asking a layout for its items gives each one a
Python wrapper that the layout's wrapper then keeps, and a layout item
is not a QObject, so nothing tells the wrapper when Qt deletes the item.
Qt does that all the time: a ``QDialogButtonBox`` throws its items away
and makes new ones whenever its buttons change, and a widget deleted out
of a layout takes its item with it. The wrapper stays registered at the
freed address, and the next object Qt puts there -- a message box's
layout, a widget -- comes back to Python as that ``QWidgetItem``. That
is how a live scale change, which used to wrap every item of every
layout in the application, took a Qt shard down with a segfault and a
``free(): invalid pointer`` (item 43). A nested layout is a QObject child
of the layout it sits in, so ``findChildren`` finds it without wrapping
anything that is not a QObject.
:param layout: the layout to look inside.
:returns: its directly nested layouts.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLayout
try:
found = layout.findChildren(QLayout, "",
Qt.FindChildOption.FindDirectChildrenOnly)
except (RuntimeError, TypeError):
return []
return [child for child in found
if isinstance(child, QLayout) and child is not layout]
def _rescale_canvases(widgets, factor: float) -> int:
"""Set every matplotlib canvas's dpi for the new scale."""
count = 0
for widget in widgets:
if hasattr(widget, "figure") and hasattr(widget, "draw_idle"):
if apply_canvas_dpi(widget, factor):
count += 1
return count
[docs]
def apply_canvas_dpi(canvas, factor: Optional[float] = None) -> bool:
"""Draw a matplotlib canvas at base dpi x GUI scale x its preview scale.
A figure draws in points, so its dpi is what makes a 9 pt label take
more or fewer pixels; scaling the dpi scales every label, dot and line
while the widget keeps the size its layout gives it.
:param canvas: a ``FigureCanvasQTAgg``.
:param factor: the GUI scale; the current one by default.
:returns: ``True`` if the canvas's dpi was set.
"""
figure = getattr(canvas, "figure", None)
if figure is None:
return False
factor = _SCALE if factor is None else float(factor)
if _exempt(canvas):
factor = 1.0
try:
base = getattr(figure, "_spacr_base_dpi", None)
if base is None:
base = float(getattr(figure, "_original_dpi", figure.dpi))
figure._spacr_base_dpi = base
preview = float(getattr(canvas, "_spacr_preview_scale", 1.0) or 1.0)
wanted = base * factor * preview
if abs(float(getattr(figure, "_original_dpi", 0)) - wanted) < 1e-9:
return True
ratio = float(getattr(canvas, "device_pixel_ratio", 1.0) or 1.0)
figure._original_dpi = wanted
figure._set_dpi(wanted * ratio, forward=False)
from PySide6.QtCore import QCoreApplication
from PySide6.QtGui import QResizeEvent
QCoreApplication.sendEvent(
canvas, QResizeEvent(canvas.size(), canvas.size()))
canvas.draw_idle()
return True
except Exception: # noqa: BLE001
LOG.debug("could not rescale a figure canvas", exc_info=True)
return False
[docs]
def set_gui_scale_live(scale: float) -> float:
"""Draw spaCR at ``scale`` now: every live widget, and every one built later.
Does not save the preference; see :func:`spacr.qt.preferences.set_gui_scale`.
:param scale: the factor, 1.0 = 100 %; clamped to 10-200 %.
:returns: the scale applied.
"""
global _SCALE
from PySide6.QtCore import QCoreApplication, QEvent
from PySide6.QtWidgets import QApplication, QSplitter
from .preferences import GUI_SCALE_MAX, GUI_SCALE_MIN
install_scaling_layer()
scale = max(GUI_SCALE_MIN, min(GUI_SCALE_MAX, float(scale)))
old = _SCALE
if abs(scale - old) < 1e-9:
return old
_SCALE = scale
app = QApplication.instance()
if app is None:
return scale
state = _ORIGINAL.get("app_state") or {}
record = state.get("sheet")
if record is not None and record[0]:
scaled = scale_qss_text(record[0], scale)
state["sheet"] = (record[0], scaled)
_ORIGINAL[(QApplication, "setStyleSheet")](app, scaled)
from PySide6.QtWidgets import QLayout, QWidget
widgets = [widget for widget in app.allWidgets()
if isinstance(widget, QWidget)]
for widget in widgets:
try:
factor = 1.0 if _exempt(widget) else scale
_reapply_widget(widget, factor, 1.0 if _exempt(widget) else old)
layout = widget.layout()
if isinstance(layout, QLayout):
_reapply_layout(layout, factor)
if isinstance(widget, QSplitter):
record = getattr(widget, "_gs_sizes", None)
if record is not None:
actual = _ORIGINAL[(QSplitter, "sizes")](widget)
widget._gs_sizes = (
[int(round(v / factor)) if v > 0 else v
for v in actual], list(actual))
except RuntimeError:
continue
_rescale_canvases(widgets, scale)
for top in app.topLevelWidgets():
try:
QCoreApplication.postEvent(top, QEvent(QEvent.LayoutRequest))
top.update()
except RuntimeError:
continue
for ref in list(_LISTENERS):
callback = ref()
if callback is None:
_LISTENERS.remove(ref)
continue
try:
callback(scale)
except Exception: # noqa: BLE001
LOG.debug("a GUI scale listener failed", exc_info=True)
LOG.info("GUI scale %d %%", round(scale * 100))
return scale
[docs]
def apply_saved_gui_scale() -> float:
"""Start at the saved GUI scale; called once the application exists.
:returns: the scale in force.
"""
from .preferences import get_gui_scale
return set_gui_scale_live(get_gui_scale())
KEEP_SECONDS = 15
def _dialog_classes():
"""Build :class:`KeepOrRevertDialog` on first use (keeps import light)."""
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPushButton,
QVBoxLayout)
from .i18n import tr
from .theme import font_px
class KeepOrRevertDialog(QDialog):
""""Keep these settings?" with a countdown that reverts on its own.
Exempt from the GUI scale and sized in its own sheet, so it can be
read at 10 % GUI and 10 % font. Keep accepts; Revert, Esc, closing
the window and the end of the countdown all reject.
:param parent: the window it is centred on.
:param seconds: how long before it reverts by itself.
:param what: the line under the question naming what changed.
"""
def __init__(self, parent=None, seconds: int = KEEP_SECONDS,
what: str = ""):
"""Build the question and start the countdown."""
super().__init__(parent)
self.setProperty(EXEMPT, True)
self.setObjectName("SpacrKeepOrRevert")
self.setWindowTitle(tr("Keep these settings?"))
self._left = max(1, int(seconds))
column = QVBoxLayout(self)
column.setContentsMargins(20, 18, 20, 16)
column.setSpacing(10)
self.question = QLabel(tr("Keep these settings?"), self)
self.question.setObjectName("SpacrKeepQuestion")
column.addWidget(self.question)
if what:
detail = QLabel(what, self)
detail.setObjectName("SpacrKeepDetail")
detail.setWordWrap(True)
column.addWidget(detail)
self.countdown = QLabel(self)
self.countdown.setObjectName("SpacrKeepCountdown")
column.addWidget(self.countdown)
row = QHBoxLayout()
row.addStretch(1)
self.revert_button = QPushButton(tr("Revert"), self)
self.keep_button = QPushButton(tr("Keep"), self)
self.revert_button.clicked.connect(self.reject)
self.keep_button.clicked.connect(self.accept)
row.addWidget(self.revert_button)
row.addWidget(self.keep_button)
column.addLayout(row)
self.keep_button.setDefault(True)
self.keep_button.setFocus(Qt.OtherFocusReason)
title_px = font_px(17, scale=1.0)
body_px = font_px(13, scale=1.0)
self._own_rule = (
"QDialog#SpacrKeepOrRevert QLabel#SpacrKeepQuestion "
f"{{ font-size: {title_px}px; font-weight: 600; }}"
"QDialog#SpacrKeepOrRevert QLabel#SpacrKeepDetail, "
"QDialog#SpacrKeepOrRevert QLabel#SpacrKeepCountdown "
f"{{ font-size: {body_px}px; }}"
"QDialog#SpacrKeepOrRevert QPushButton "
f"{{ font-size: {body_px}px; min-width: 80px; min-height: 26px; "
"padding: 4px 14px; }")
try:
from .theme import set_a_sheeted_widgets_own_rule
set_a_sheeted_widgets_own_rule(self, self._own_rule)
except Exception: # noqa: BLE001
self.setStyleSheet(self._own_rule)
self.setMinimumWidth(360)
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.timeout.connect(self._tick)
self._show_left()
self._timer.start()
def _show_left(self) -> None:
"""Say how long is left."""
self.countdown.setText(tr("Reverting in {seconds} s",
seconds=self._left))
def _tick(self) -> None:
"""One second less; at zero, revert."""
self._left -= 1
if self._left <= 0:
self._timer.stop()
self.reject()
return
self._show_left()
def done(self, result):
"""Stop the countdown on any answer.
:param result: the dialog result.
"""
self._timer.stop()
super().done(result)
return KeepOrRevertDialog
_DIALOG_CLASS = None
[docs]
def keep_or_revert_dialog(parent=None, seconds: int = KEEP_SECONDS,
what: str = ""):
"""A new :class:`KeepOrRevertDialog` (see :func:`_dialog_classes`).
:param parent: the window it is centred on.
:param seconds: how long before it reverts by itself.
:param what: the line naming what changed.
"""
global _DIALOG_CLASS
if _DIALOG_CLASS is None:
_DIALOG_CLASS = _dialog_classes()
return _DIALOG_CLASS(parent, seconds=seconds, what=what)
def _apply_scales(gui: float, font: float) -> None:
"""Save and draw ``gui`` and ``font`` scale now."""
from . import preferences
preferences.set_gui_scale(gui)
preferences.set_font_scale(font)
set_gui_scale_live(gui)
try:
preferences.apply_preferences_to_app()
except Exception: # noqa: BLE001
LOG.debug("could not re-apply the font scale", exc_info=True)
refresh_the_windows()
[docs]
def change_scales(parent=None, *, gui: Optional[float] = None,
font: Optional[float] = None, ask: bool = True,
seconds: int = KEEP_SECONDS,
previous: Optional[tuple] = None,
require_parent: bool = True,
on_done: Optional[Callable[[bool], None]] = None):
"""Apply a new GUI and/or font scale now, then ask whether to keep it.
The question is shown without blocking (``open``, not ``exec``): the
rest of spaCR keeps running under it, and the countdown reverts by
itself if nobody answers.
:param parent: the window the question is centred on.
:param gui: the new GUI scale; unchanged when ``None``.
:param font: the new font scale; unchanged when ``None``.
:param ask: ``False`` keeps without asking.
:param seconds: the countdown before it reverts by itself.
:param previous: ``(gui, font)`` to revert to when the caller has
already applied and saved the new values (the Z + wheel gesture);
nothing is applied again then.
:param require_parent: ask only when there is a window to centre the
question on. ``False`` asks anyway, which is what a test does.
:param on_done: called with ``True`` when kept, ``False`` when reverted.
:returns: the question dialog, or ``None`` when nothing was asked.
"""
from . import preferences
from .i18n import tr
if previous is None:
old_gui, old_font = (preferences.get_gui_scale(),
preferences.get_font_scale())
new_gui = old_gui if gui is None else float(gui)
new_font = old_font if font is None else float(font)
if abs(new_gui - old_gui) < 1e-9 and abs(new_font - old_font) < 1e-9:
return None
_apply_scales(new_gui, new_font)
else:
old_gui, old_font = previous
new_gui = preferences.get_gui_scale() if gui is None else float(gui)
new_font = preferences.get_font_scale() if font is None else float(
font)
if ask and parent is None:
from PySide6.QtWidgets import QApplication
parent = QApplication.activeWindow()
ask = parent is not None or not require_parent
if not ask:
if on_done is not None:
on_done(True)
return None
what = tr("GUI scale {gui}%, font scale {font}%",
gui=int(round(new_gui * 100)), font=int(round(new_font * 100)))
dialog = keep_or_revert_dialog(parent, seconds=seconds, what=what)
def _answered(result) -> None:
"""Keep, or put the old values back."""
from PySide6.QtWidgets import QDialog
kept = result == QDialog.Accepted
if not kept:
_apply_scales(old_gui, old_font)
if on_done is not None:
try:
on_done(kept)
except Exception: # noqa: BLE001
LOG.debug("a scale answer callback failed", exc_info=True)
dialog.deleteLater()
dialog.finished.connect(_answered)
dialog.open()
return dialog
[docs]
def refresh_the_windows() -> int:
"""Rebuild what a style sheet cannot reach: icons, tiles, window chrome.
The same step the Z + wheel gesture ends with. ``MainWindow.refresh_theme``
repaints the marks in the window corner and the Home tiles, which paint
their own pixmaps at a size they compute -- and a pixmap is not a size
the scaling layer ever sees.
:returns: how many windows rebuilt.
"""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
return 0
done = 0
for window in list(app.topLevelWidgets()):
refresh = getattr(window, "refresh_theme", None)
if not callable(refresh):
continue
try:
refresh()
done += 1
except Exception: # noqa: BLE001
LOG.debug("a window would not rebuild after a scale change",
exc_info=True)
return done
[docs]
def reset_every_scale(parent=None) -> bool:
"""Put GUI scale, font scale and every preview scale back to 100 %, now.
The backup way out of a scale too small to read: ``Ctrl+Alt+0``. It
needs no reading and no answer.
:param parent: unused; kept so a shortcut can pass its window.
:returns: ``True``.
"""
_apply_scales(1.0, 1.0)
try:
from .widgets.preview_scale import reset_all_preview_scales
reset_all_preview_scales()
except Exception: # noqa: BLE001
LOG.debug("could not reset the preview scales", exc_info=True)
return True
[docs]
def mend_matplotlib_icons() -> bool:
"""Keep matplotlib's toolbar icons full size below a device ratio of 1.
Matplotlib's toolbar icon engine multiplies the size Qt asks for by the
device pixel ratio, and Qt has already done so; above 1 the two cancel,
below 1 (a user's ``QT_SCALE_FACTOR`` under 1) they compound. Measured
offscreen at a ratio of 0.5: the icons drew 3 px tall instead of 11.
Holding the engine's ratio at 1 or more draws them 11 px tall and
changes nothing at a ratio of 1 or 2. Idempotent.
:returns: ``True`` if the mend is in place.
"""
try:
import matplotlib.backends.backend_qt as backend
except Exception: # noqa: BLE001
return False
engine = getattr(backend, "_IconEngine", None)
original = getattr(engine, "_devicePixelRatio", None)
if engine is None or original is None:
return False
if getattr(engine, "_spacr_mended", False):
return True
def _at_least_one(self):
"""The toolbar's device ratio, never below 1."""
return max(1.0, float(original(self)))
engine._devicePixelRatio = _at_least_one
engine._spacr_mended = True
return True
[docs]
def follow_canvas(canvas) -> bool:
"""Draw a new matplotlib canvas at the current GUI scale.
:param canvas: a ``FigureCanvasQTAgg`` just built.
:returns: ``True`` if its dpi was set.
"""
if _SCALE == 1.0 and float(getattr(canvas, "_spacr_preview_scale",
1.0) or 1.0) == 1.0:
return False
return apply_canvas_dpi(canvas)