"""Hold Z and turn the wheel to resize the interface's text, live.
THE LIVE ZOOM GESTURE, and the measurement that shaped it.
The request came with a condition -- "only if possible to do fast without
lag" -- so the first question was what a notch costs. On one 1440x900
MainWindow with 847 widgets:
stylesheet() rebuild 1.4 ms
app.setStyleSheet(...) + repolish 587.5 ms <- Preferences
repolish the visible screen only 107 ms
QApplication.setFont(...) 14 ms
Only the last one is live, and it moves nothing: the application sheet
carries 49 hardcoded ``font-size: <N>px`` declarations, and a QSS font-size
beats the inherited application font.
THE ESCAPE THE INSTRUCTION PROPOSED DOES NOT EXIST. Part 2 of 378 assumed
the 49 declarations could be rewritten in ``em`` or ``%`` so that one
``setFont`` moved everything. Qt does not implement either for
``font-size``: ``QCss`` accepts only ``pt``, ``px`` and the CSS size
keywords, and silently drops any other unit, so ``font-size: 2em`` leaves
the widget at the inherited size. Measured on this PySide6, and
pinned by ``test_qt_still_has_no_relative_font_size``, which fails the day
Qt gains the unit and makes the simpler design available.
WHAT IS LIVE INSTEAD. An explicit ``QWidget.setFont`` *does* beat the
application sheet's ``font-size`` -- it is the same escape hatch that lets a
per-widget sheet win -- and it costs one font assignment and one relayout
rather than a global unpolish/repolish. So a notch snapshots each visible
widget's font once, then re-scales every widget from that snapshot by the
ratio the wheel has travelled. The role hierarchy survives because each
widget is scaled from its own baseline: a 22 px title and a 13 px caption
stay in proportion without this module knowing which is which.
WHAT IS NOT LIVE, AND WHY THAT WAS ACCEPTED. Every size the font scale pins
from Python -- row heights, column widths, icon sizes, tile geometry, all of
:func:`spacr.qt.preferences.scaled_px` -- moves only when the stylesheet is
rebuilt, and that is the 587 ms number. The choice made:
"Text live, spacing on release." Text follows the wheel; the spacing around
it catches up in one step when the wheel stops or Z comes up. It is a
deliberate compromise, not an oversight, and it is why the settle exists.
AND THE ICONS DID NOT MOVE AT ALL, which was NOT part of that compromise.
The spacing caught up at the settle; the icons never caught up, because an
icon size is neither a stylesheet value nor a ``scaled_px`` call that
anything re-ran -- it is a widget PROPERTY written once when the widget was
built, so a scale that grew every caption left every glyph beside those
captions exactly where it was. The settle now re-derives them, through
:func:`spacr.qt.preferences._rescale_icon_sizes`, inside the same
``apply_preferences_to_app`` step that rebuilds the sheet: 5.5 ms for the
2,122 widgets of a window with two modules open, against the 395-877 ms
that step costs when the scale really changes. Nothing was added to the
live half.
"""
from __future__ import annotations
import logging
import re
from typing import Optional
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QWidget
from .gil_priority import _watch_application_events
LOG = logging.getLogger(__name__)
#: What one wheel notch is worth, as a fraction of 100 %.
#:
#: 5 % is the slider's own granularity in Preferences, so the gesture and
#: the control cannot disagree about which sizes are reachable.
FONT_SCALE_STEP = 0.05
#: A wheel notch, in eighths of a degree. Qt's own unit for one detent.
_NOTCH = 120.0
#: How long the wheel has to be still before the spacing catches up, in ms.
#:
#: The settle is the expensive half of this gesture (see the module
#: docstring), so it may not run between two notches of one flick. 220 ms is
#: comfortably longer than the ~50 ms gap of a fast scroll and short enough
#: that a user who has stopped does not think the gesture is unfinished.
_SETTLE_MS = 220
#: The smallest text this may produce, in px: one, because
#: ``QFont.setPixelSize`` refuses zero. It is Qt's floor and the same one
#: :func:`spacr.qt.theme.font_px` keeps, so the live pass and the settled
#: stylesheet agree at the bottom of the range. The Zoom floor itself is
#: :data:`spacr.qt.preferences.FONT_SCALE_MIN`; a readability floor here
#: would stop the wheel's text shrinking before the scale does.
_MIN_PX = 1
#: The point-size floor for the few widgets whose font is not set in pixels:
#: one pixel at Qt's 96 DPI logical resolution. ``setPointSizeF`` refuses
#: zero, and a positive size under this resolves to a 0 px font that draws
#: nothing.
_MIN_PT = 72.0 / 96.0
_FILTER_ATTRIBUTE = "_spacr_live_zoom_filter"
def _alive(widget) -> bool:
"""Is this widget's C++ side still there?
A gesture holds a list of widgets across event-loop turns, and anything
on it can be deleted while the wheel is still turning -- a tooltip, a
dialog the user closed, a screen that rebuilt itself. Touching the
Python wrapper afterwards raises ``RuntimeError`` from shiboken.
"""
try:
from shiboken6 import isValid
return bool(isValid(widget))
except Exception: # noqa: BLE001
try:
widget.objectName()
return True
except RuntimeError:
return False
def _scaled_font(base: QFont, ratio: float,
current: QFont) -> Optional[QFont]:
"""Return ``base`` grown by ``ratio``, or None when nothing would move.
Sized from ``base`` and compared against ``current``, which are not the
same question. Sizing from the font now on the widget compounds the
rounding -- twenty notches of ``round(px * 1.05)`` drift far enough that
letting go visibly jumps -- while comparing against ``base`` would skip
the notch that puts a widget BACK to the size it started at, and leave
it stranded one step above every neighbour on the way down.
The comparison earns its place: a 5 % step on an 11 px caption rounds to
the same pixel about half the time, and assigning a font that resolves
to the size the widget already has still invalidates its layout.
:param base: the widget's font when the gesture began.
:param ratio: how far the wheel has travelled, as a multiplier.
:param current: the font on the widget right now.
"""
px = base.pixelSize()
if px > 0:
target = max(_MIN_PX, int(round(px * ratio)))
if target == current.pixelSize():
return None
font = QFont(base)
font.setPixelSize(target)
return font
points = base.pointSizeF()
if points > 0:
target = max(_MIN_PT, points * ratio)
if abs(target - current.pointSizeF()) < 0.01:
return None
font = QFont(base)
font.setPointSizeF(target)
return font
return None
_ZOOM_KINDS = frozenset((QEvent.KeyPress, QEvent.KeyRelease, QEvent.Wheel,
QEvent.WindowDeactivate))
[docs]
class LiveZoomFilter(QObject):
"""Application-wide filter that turns Z + wheel into a live font scale.
Installed on the QApplication because the gesture has to work on every
screen -- a per-screen handler works in some places and not others,
which is the complaint that produced 315's warning about application
filters. That warning is about COST, so the body is two integer
comparisons for an event it does not want, and the wheel branch is not
even reached unless Z is down. Measured at 1.2 us per uninteresting
event on this machine, which is the Python call itself rather than
anything this does inside it.
"""
def __init__(self, parent: Optional[QObject] = None) -> None:
"""Create the filter that scales the interface while the key is held.
The starting scale and the widgets' original fonts are recorded so the
gesture can be undone exactly, including which of them carried a font of
their own rather than inheriting one -- restoring an inherited font as
an explicit one would pin it against every later theme change.
:param parent: parent object, or ``None``.
"""
super().__init__(parent)
self._held = False
#: The scale the gesture started from, and the one on screen now.
self._base_scale = 1.0
self._live_scale = 1.0
#: (widget, the font it had, whether that font was its OWN) for
#: everything the gesture may move, plus the subset it did move.
self._baseline: list[tuple[object, QFont, bool]] = []
self._touched: set = set()
#: The window the wheel was over, for the settle's rebuild.
self._window = None
self._settle_timer = QTimer(self)
self._settle_timer.setSingleShot(True)
self._settle_timer.setInterval(_SETTLE_MS)
self._settle_timer.timeout.connect(
lambda: self.settle(released=False))
[docs]
def eventFilter(self, watched, event): # noqa: N802 - Qt naming
"""Watch the widgets this filter is installed on.
:param watched: the object the event is for.
:param event: the event.
:returns: True to stop the event going further.
"""
kind = event.type()
if kind == QEvent.KeyPress:
if self._is_the_key(event) and not event.isAutoRepeat():
self._arm()
elif kind == QEvent.KeyRelease:
if self._held and self._is_the_key(event) \
and not event.isAutoRepeat():
self.settle()
elif self._held:
if kind == QEvent.Wheel:
return self._wheeled(watched, event)
if kind == QEvent.WindowDeactivate:
self.settle()
return False
@staticmethod
def _is_the_key(event) -> bool:
"""Is this the Z the gesture is armed by?
Z, not Ctrl: Ctrl+wheel is already canvas zoom on the mask editor
and the fractal, and a gesture that means two things on one screen
means neither. Ctrl+Z is undo everywhere, so a Z carrying a
command modifier is somebody else's shortcut.
"""
if event.key() != Qt.Key_Z:
return False
blocking = (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)
return not (event.modifiers() & blocking)
def _arm(self) -> None:
"""Start listening for the wheel.
NO EXEMPTION FOR A FOCUSED TEXT FIELD, though the first version had
one -- somebody typing a plate name is pressing the same key. It was
removed because it disabled the gesture on exactly the screens that
want it: a settings form is mostly fields, one of them always has
the focus, and a gesture that silently does nothing there is a worse
failure than the one it prevented. Nothing happens on the key alone
-- it is never consumed, so the letter is still typed -- so a misfire
needs the user to hold Z down AND turn the wheel, which typing does
not do.
MAKE MASKS TAKES Z AND WINS THERE. That screen binds a `QShortcut`
for its Zoom tool, and Qt dispatches shortcuts in `processKeyEvent`
BEFORE application event filters, so this filter never sees the key
press on Make Masks at all. Measured: armed without that
shortcut, not armed with it. So it is mutual exclusion rather than
coexistence, and the gesture is simply unavailable on that one
screen -- which is the right way round, because Zoom is the tool
somebody opened Make Masks to use.
"""
self._held = True
def _wheeled(self, watched, event) -> bool:
"""Take one wheel event for the gesture. Always consumes it.
Consumed even when the scale is already at a bound, and even for a
purely horizontal wheel: while Z is held the wheel belongs to this
gesture, and a list that scrolled under the pointer at 200 % would
look like the gesture had leaked.
"""
from .preferences import FONT_SCALE_MAX, FONT_SCALE_MIN
event.accept()
notches = event.angleDelta().y() / _NOTCH
if notches:
if not self._baseline:
self._begin(watched)
target = self._live_scale + FONT_SCALE_STEP * notches
target = max(FONT_SCALE_MIN, min(FONT_SCALE_MAX, target))
target = round(target, 4)
if target != self._live_scale:
self._live_scale = target
self._apply()
self._settle_timer.start()
return True
def _begin(self, watched) -> None:
"""Snapshot the font of everything on screen.
Visible widgets only. A hidden stack page is most of a mature
session's widget count, nothing there can be seen mid-gesture, and
the settle rebuilds the stylesheet for all of them anyway -- so
scaling them live would be paying the whole cost of the gesture for
none of its benefit.
"""
from .preferences import get_font_scale
app = QApplication.instance()
if app is None:
return
self._base_scale = get_font_scale()
self._live_scale = self._base_scale
self._baseline = [(w, QFont(w.font()), w.testAttribute(Qt.WA_SetFont))
for w in app.allWidgets() if w.isVisible()]
self._touched = set()
try:
self._window = watched.window() if hasattr(watched, "window") \
else None
except Exception: # noqa: BLE001
self._window = None
def _apply(self) -> None:
"""Re-scale every snapshotted widget from its own baseline font.
The whole live half of the gesture is this loop: ~10 ms for the 204
visible widgets of a MainWindow on this machine, against 1285 ms to
set the stylesheet on the same window (measured
offscreen; the numbers on real hardware are 587 ms for
the sheet). See :func:`_scaled_font` for why each widget is sized
from its baseline rather than from what it is wearing.
"""
if not self._base_scale:
return
ratio = self._live_scale / self._base_scale
for widget, base, _own in self._baseline:
try:
font = _scaled_font(base, ratio, widget.font())
if font is None:
continue
widget.setFont(font)
self._touched.add(widget)
except RuntimeError:
continue
self._announce()
def _announce(self) -> None:
"""Say the size in the status bar while the wheel is turning.
THE READOUT 378 DEFERRED, added 2026-09-09. It was written and
removed once: the wheel has no detents a user can count, so a number
is genuinely useful, but it is one more English sentence and every
user-facing sentence here is a row in ten translation catalogs that a
ratchet test audits. It was left out rather than regenerate catalogs
that other work was editing at the same time. That work is finished
and the catalogs are stable, which is the condition 378 named --
"STILL WORTH DOING, with its catalog row, whenever the catalogs are
next rebuilt".
ROUNDED, NOT TRUNCATED, and that is not cosmetic: 378 records that
four notches down from 1.0 is 0.7999999999999998, which truncates to
79 % and reads as a bug in the gesture rather than in the print.
A window with no status bar is ordinary -- a dialog, a screensaver --
so this is best-effort and never raises into the event filter that
calls it.
"""
window = self._window
if window is None or not _alive(window):
return
status = getattr(window, "statusBar", None)
if not callable(status):
return
from .i18n import tr
try:
status().showMessage(
tr("Text size {percent} %").format(
percent=int(round(self._live_scale * 100))),
_SETTLE_MS * 2)
except (RuntimeError, AttributeError):
return
@staticmethod
def _ask_to_keep(window, scale: float, before: float) -> None:
"""Ask whether to keep the font scale the wheel chose (item 471).
Every change of font or GUI scale asks, so a scale that turned out
unreadable goes back by itself. Asked after the gesture, never per
notch.
:param window: the window the question is centred on.
:param scale: the font scale now in force.
:param before: the font scale to go back to.
"""
try:
from .gui_scale import change_scales, current_scale
change_scales(window, font=scale,
previous=(current_scale(), before))
except Exception: # noqa: BLE001
LOG.debug("could not ask to keep the font scale", exc_info=True)
[docs]
def settle(self, released: bool = True) -> None:
"""End the gesture: persist the scale and let the spacing catch up.
THE EXPENSIVE HALF, ON PURPOSE. Everything :func:`scaled_px` pins is
rebuilt here, in one 587 ms step, rather than twenty times a second
while the wheel turns -- which is the compromise
chosen over a gesture that stutters. Called when the wheel has been
still for :data:`_SETTLE_MS`, when Z comes up, and when the window
loses focus with Z still down.
THE ICONS CATCH UP HERE TOO, and here only. They ride inside
``apply_preferences_to_app`` rather than being resized per notch,
because the condition on this gesture was that it be fast without
lag and the live half is the half that has to answer for it. The
sweep is 5.5 ms on 2,122 widgets, so it COULD have gone in the live
half -- it is here because the icons would then have grown inside
spacing that had not, which is the mismatch that made the icons
look wrong in the first place.
The QSettings write happens here too, for the same reason: twenty
writes a second to a settings file is not free.
:param released: whether Z is now up. False when the wheel merely
went quiet: the user is still holding the key and may keep
scrolling, and disarming under them would make the second half
of one gesture scroll the list. The next notch simply starts a
fresh gesture from the scale this one just saved.
"""
self._settle_timer.stop()
if released:
self._held = False
if not self._baseline:
self._window = None
return
for widget, base, own in self._baseline:
try:
if widget in self._touched:
widget.setFont(base if own else QFont())
except RuntimeError:
continue
self._baseline = []
self._touched = set()
scale, self._live_scale = self._live_scale, 1.0
window, self._window = self._window, None
from .preferences import apply_preferences_to_app, set_font_scale
if scale != self._base_scale:
set_font_scale(scale)
try:
apply_preferences_to_app(QApplication.instance())
except Exception: # noqa: BLE001
LOG.exception("could not apply the font scale the wheel chose")
if scale != self._base_scale:
self._ask_to_keep(window, scale, self._base_scale)
if window is not None and _alive(window):
refresh = getattr(window, "refresh_theme", None)
if callable(refresh):
try:
refresh()
except Exception: # noqa: BLE001
LOG.debug("a window would not rebuild after a live zoom",
exc_info=True)
[docs]
def install_live_zoom(app=None) -> Optional[LiveZoomFilter]:
"""Install the Z + wheel font gesture on a running application.
Idempotent: the filter is retained on the application object, so a
second call returns the one already installed rather than stacking a
second filter onto every event in the process.
:param app: optional QApplication; falls back to the running instance.
:returns: the filter, or None when there is no application to hold it.
"""
app = app or QApplication.instance()
if app is None:
return None
existing = getattr(app, _FILTER_ATTRIBUTE, None)
if existing is not None:
return existing
parent = app if isinstance(app, QObject) else None
live_zoom = LiveZoomFilter(parent)
_watch_application_events(app, live_zoom, _ZOOM_KINDS)
setattr(app, _FILTER_ATTRIBUTE, live_zoom)
return live_zoom
#: One Ctrl + wheel notch over a right-hand column, as a fraction of 100 %.
#: Twice the Z gesture's step: this one moves a column, not the interface,
#: and a console the user is squinting at should get there in a few notches.
COLUMN_TEXT_STEP = 0.10
#: How long the wheel is still before the size is written down, in ms.
_COLUMN_SAVE_MS = 400
_COLUMN_FILTER_ATTRIBUTE = "_spacr_column_text_filter"
#: The event kinds the column filter looks at, looked up once: the filter
#: sees every event in the process. Measured per event: 0.3 us with no
#: filter, 3.3 us with the enum members looked up inside the filter, 1.4 us
#: with them looked up here.
_WHEEL = QEvent.Wheel
_KEYS = frozenset((QEvent.ShortcutOverride, QEvent.KeyPress))
_RESTYLE_ON = frozenset((QEvent.StyleChange, QEvent.Show))
_COLUMN_KINDS = frozenset((_WHEEL,)) | _KEYS | _RESTYLE_ON
_COMMENT = re.compile(r"/\*.*?\*/", re.S)
_RULE = re.compile(r"([^{}]+)\{([^{}]*)\}")
_FONT_SIZE = re.compile(r"font-size\s*:\s*([0-9]*\.?[0-9]+)\s*(px|pt)", re.I)
[docs]
def font_size_rules(sheet: str) -> list:
"""Every ``(selector, size, unit)`` in ``sheet`` that sets a font size.
:param sheet: Qt style sheet text.
"""
rules = []
for selector, body in _RULE.findall(_COMMENT.sub("", str(sheet or ""))):
found = _FONT_SIZE.search(body)
selector = selector.strip()
if found and selector:
rules.append((selector, float(found.group(1)),
found.group(2).lower()))
return rules
[docs]
def scaled_font_sheet(sheet: str, ratio: float) -> str:
"""The font sizes of ``sheet``, and nothing else, multiplied by ``ratio``.
Set on a container, this re-declares every size the window's sheet gives
the widgets inside it: a container's sheet beats an ancestor's whatever
the specificity, so the whole hierarchy of sizes -- a 15 px card title
over 12 px captions -- moves together and stays in proportion, and
widgets built later inside the container follow without being visited.
:param sheet: the sheet the container inherits.
:param ratio: the multiplier; 1.0 gives the same sizes back.
"""
lines = []
for selector, size, unit in font_size_rules(sheet):
if unit == "px":
value = f"{max(_MIN_PX, int(round(size * ratio)))}px"
else:
value = f"{max(_MIN_PT, round(size * ratio, 2))}pt"
lines.append(f"{selector} {{ font-size: {value}; }}")
return "\n".join(lines)
[docs]
class ColumnTextScale(QObject):
"""Ctrl + wheel over a module screen's right-hand column sizes its text.
Holding Ctrl and turning the wheel over any panel in that column makes
its text larger or smaller.
ONE SIZE FOR EVERY COLUMN, persisted in
:func:`spacr.qt.preferences.get_runtime_text_scale`. It multiplies the
size the rest of the interface has, so the whole-GUI scale (471) and the
Z gesture (378) still move the column with everything else.
A STYLE SHEET ON THE COLUMN, NOT ``setFont``. The Z gesture's
``setFont`` is undone by the next repolish (measured: a label set to
20 px is back at its sheet's 13 px after ``unpolish``/``polish``), and
the column's widgets repolish every time a card folds or a run starts.
:func:`scaled_font_sheet` re-declares the inherited sizes on the column
itself, which only its descendants see. A widget that sizes its own text
from Python and so outranks any sheet -- the console's entries -- takes
the size through an ``apply_column_text_scale(scale)`` method instead.
AN APPLICATION FILTER, because the console and every scroll area in the
column accept the wheel before a parent could see it. It yields to:
* the Z gesture while Z is held -- Z + wheel is 378's;
* any widget between the pointer and the column that handles the wheel
in Python -- the plaque and live-preview canvases zoom on Ctrl + wheel
and a figure canvas has its own scroll -- which keeps the gesture to
the text panels;
* everything outside a registered column.
Ctrl+0 with the pointer over a column whose text is not at 100 % puts it
back; at 100 % the key is left to Go home, which it otherwise is.
"""
def __init__(self, parent: Optional[QObject] = None) -> None:
"""Create the filter with the stored size and no columns yet."""
super().__init__(parent)
self._roots: list = []
self._digests: dict = {}
self._own: dict = {}
self._swallow_key = False
try:
from .preferences import get_runtime_text_scale
self._scale = float(get_runtime_text_scale())
except Exception: # noqa: BLE001
self._scale = 1.0
self._restyle_timer = QTimer(self)
self._restyle_timer.setSingleShot(True)
self._restyle_timer.setInterval(0)
self._restyle_timer.timeout.connect(self.restyle_all)
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(_COLUMN_SAVE_MS)
self._save_timer.timeout.connect(self._save)
[docs]
def scale(self) -> float:
"""The columns' text size, 1.0 for the interface's own."""
return self._scale
[docs]
def roots(self) -> list:
"""The registered columns that are still alive."""
self._roots = [r for r in self._roots if _alive(r)]
return list(self._roots)
[docs]
def register(self, root) -> None:
"""Make ``root`` a column whose text Ctrl + wheel sizes.
:param root: the container; everything inside it follows.
"""
if root is None or any(root is r for r in self.roots()):
return
self._roots.append(root)
self._own[id(root)] = str(root.styleSheet() or "")
self.restyle(root)
[docs]
def set_scale(self, scale: float, *, remember: bool = True) -> float:
"""Give every column the text size ``scale``, within its bounds.
:param scale: 1.0 for the interface's own size.
:param remember: store it, once the wheel is still.
:returns: the size applied.
"""
from .preferences import (RUNTIME_TEXT_SCALE_MAX,
RUNTIME_TEXT_SCALE_MIN)
scale = round(max(RUNTIME_TEXT_SCALE_MIN,
min(RUNTIME_TEXT_SCALE_MAX, float(scale))), 4)
if scale != self._scale:
self._scale = scale
self._restyle_timer.start()
if remember:
self._save_timer.start()
return scale
[docs]
def reset(self) -> float:
"""Put the columns' text back to the interface's size, now."""
self.set_scale(1.0, remember=False)
self._save()
self.restyle_all()
return self._scale
def _save(self) -> None:
"""Write the size down."""
self._save_timer.stop()
try:
from .preferences import set_runtime_text_scale
set_runtime_text_scale(self._scale)
except Exception: # noqa: BLE001
LOG.debug("could not store the column text size", exc_info=True)
@staticmethod
def _inherited_sheet(root) -> str:
"""The sheets ``root`` inherits, farthest first."""
sheets = []
widget = root.parentWidget()
while widget is not None:
text = widget.styleSheet()
if text:
sheets.append(str(text))
widget = widget.parentWidget()
app = QApplication.instance()
if app is not None and app.styleSheet():
sheets.append(str(app.styleSheet()))
return "\n".join(reversed(sheets))
[docs]
def restyle(self, root) -> bool:
"""Re-declare ``root``'s inherited sizes at the present scale.
Idempotent: nothing is set unless the scale or an inherited sheet
changed since the last time, which is what lets this run on every
``StyleChange`` the column receives -- setting the column's own sheet
sends it one. A column never scaled wears no sheet of its own; one
that was scaled keeps declaring its sizes at 100 %, because taking a
font rule away does not give Qt's widgets their old font back
(measured: 23 of the Measure column's 58 widgets kept the larger
size when the sheet was emptied).
:param root: a registered column.
:returns: whether the column's sheet was replaced.
"""
if not _alive(root):
return False
key = id(root)
declare = self._scale != 1.0 or key in self._digests
inherited = self._inherited_sheet(root) if declare else ""
digest = (self._scale, hash(inherited))
if self._digests.get(key, digest if not declare else None) == digest:
return False
self._digests[key] = digest
own = self._own.get(key, "")
sheet = f"{own}\n{scaled_font_sheet(inherited, self._scale)}"
if str(root.styleSheet() or "") != sheet:
root.setStyleSheet(sheet)
for widget in root.findChildren(QWidget):
hook = getattr(widget, "apply_column_text_scale", None)
if callable(hook):
try:
hook(self._scale)
except Exception: # noqa: BLE001
LOG.debug("a column widget refused its text size",
exc_info=True)
QTimer.singleShot(0, lambda: self._refit(root))
return True
@staticmethod
def _refit(root) -> None:
"""Let the column's splitters re-measure the panes the text resized.
A row of buttons that wraps needs another line when its captions
grow, and a splitter keeps a fixed pane at the height it had until
told to look again.
"""
if not _alive(root):
return
for splitter in root.findChildren(QWidget):
rebalance = getattr(splitter, "rebalance", None)
if (callable(rebalance) and hasattr(splitter, "pane")
and getattr(splitter, "_laid_out", False)):
try:
rebalance(refit=True)
except Exception: # noqa: BLE001
LOG.debug("a column splitter would not refit",
exc_info=True)
[docs]
def restyle_all(self) -> int:
"""Restyle every column; returns how many sheets were replaced."""
self._restyle_timer.stop()
return sum(1 for root in self.roots() if self.restyle(root))
[docs]
def root_of(self, widget):
"""The registered column ``widget`` is inside, or None.
:param widget: any object; only a widget can be inside a column.
"""
if not self._roots or not isinstance(widget, QWidget):
return None
while widget is not None:
for root in self._roots:
if widget is root:
return root
widget = widget.parentWidget()
return None
@staticmethod
def _handles_its_own_wheel(widget, root) -> bool:
"""Whether something between ``widget`` and ``root`` owns the wheel.
A class that defines ``wheelEvent`` in Python (a canvas that zooms,
a matplotlib figure) is doing something of its own with it; Qt's
own scroll areas and text views are not, and are what the column's
text lives in.
"""
while widget is not None and widget is not root:
for klass in type(widget).__mro__:
if "wheelEvent" in vars(klass):
if not str(klass.__module__).startswith(
("PySide6", "shiboken6")):
return True
break
widget = widget.parentWidget()
return False
@staticmethod
def _z_is_held() -> bool:
"""Whether the Z gesture has the wheel."""
app = QApplication.instance()
live = getattr(app, _FILTER_ATTRIBUTE, None) if app else None
return bool(getattr(live, "_held", False))
def _column_under_pointer(self, watched):
"""The column under the pointer, else the one ``watched`` is in."""
from PySide6.QtGui import QCursor
try:
root = self.root_of(QApplication.widgetAt(QCursor.pos()))
except Exception: # noqa: BLE001
root = None
return root or self.root_of(watched)
[docs]
def eventFilter(self, watched, event): # noqa: N802 - Qt naming
"""Take Ctrl + wheel and Ctrl+0 over a column; restyle on a change.
:param watched: the object the event is for.
:param event: the event.
:returns: True when the event was the gesture's.
"""
kind = event.type()
if kind not in _COLUMN_KINDS or not self._roots:
return False
if kind == _WHEEL:
if event.modifiers() & Qt.ControlModifier:
return self._wheeled(watched, event)
return False
if kind in _KEYS:
if event.key() == Qt.Key_0:
return self._keyed(watched, event, kind)
return False
if kind in _RESTYLE_ON:
for root in self._roots:
if watched is root:
self._restyle_timer.start()
break
return False
def _wheeled(self, watched, event) -> bool:
"""One Ctrl + wheel event; consumed when it sized a column."""
blocking = Qt.AltModifier | Qt.MetaModifier | Qt.ShiftModifier
if event.modifiers() & blocking or self._z_is_held():
return False
root = self.root_of(watched)
if root is None or self._handles_its_own_wheel(watched, root):
return False
delta = event.angleDelta().y() or event.pixelDelta().y()
event.accept()
if delta:
self.set_scale(self._scale + COLUMN_TEXT_STEP * delta / _NOTCH)
return True
def _keyed(self, watched, event, kind) -> bool:
"""Ctrl+0 over a column with its text resized puts it back."""
if event.modifiers() != Qt.ControlModifier:
return False
if kind == QEvent.KeyPress:
swallow, self._swallow_key = self._swallow_key, False
if swallow:
event.accept()
return True
if self._scale == 1.0:
return False
if self._column_under_pointer(watched) is None:
return False
self.reset()
event.accept()
self._swallow_key = kind == QEvent.ShortcutOverride
return True
[docs]
def install_column_text_scale(app=None) -> Optional[ColumnTextScale]:
"""Install the Ctrl + wheel column gesture on the application, once.
:param app: optional QApplication; falls back to the running instance.
:returns: the filter, or None when there is no application to hold it.
"""
app = app or QApplication.instance()
if app is None:
return None
existing = getattr(app, _COLUMN_FILTER_ATTRIBUTE, None)
if existing is not None:
return existing
column_text = ColumnTextScale(app if isinstance(app, QObject) else None)
_watch_application_events(app, column_text, _COLUMN_KINDS)
setattr(app, _COLUMN_FILTER_ATTRIBUTE, column_text)
return column_text
[docs]
def register_text_column(root) -> Optional[ColumnTextScale]:
"""Let Ctrl + wheel over ``root`` size the text inside it.
:param root: a module screen's right-hand column.
:returns: the filter, or None without an application.
"""
column_text = install_column_text_scale()
if column_text is not None:
column_text.register(root)
return column_text