"""One scale slider per live preview, scaling that preview alone (item 471).
The whole-GUI scale (:mod:`spacr.qt.gui_scale`) is fixed at startup and
applies everywhere. A preview wants its own: a Mask preview on a laptop
may need to shrink its controls to give the images room, while the settings
beside it stay readable. Every preview -- Mask, Measure, Plaque, Timelapse,
Motility and the image-UMAP views -- installs the same control through
:func:`install_preview_scale`, so there is one implementation of it.
WHAT IT SCALES, LIVE, WITHOUT A RESTART. Everything inside the preview that
spaCR sized in pixels:
* style-sheet sizes -- font sizes, paddings, margins, minimum and maximum
widths and heights, radii -- both the ones the preview inherits from the
application sheet and the ones its widgets set on themselves. Border
widths are left alone, so a 1 px rule stays a rule;
* minimum, maximum and fixed widget sizes set in code;
* layout margins and spacing;
* button icon sizes;
* and, through :meth:`PreviewScaler.add_hook`, what only the panel knows
how to redraw: Measure's crop thumbnails, a matplotlib figure's dots and
labels (:func:`scale_figure_canvas`).
Each value's own base is remembered on the widget, so scaling is always
from the size the code asked for and never compounds. A size the code sets
again later becomes the new base. At 100 % nothing is touched at all -- no
sheet, no size, no property -- so a preview at its default scale is the
preview it was before this module existed.
The slider itself is exempt: at 10 % the thing that brings the preview back
must still be there to grab, and double-clicking its value returns to 100 %.
``Ctrl+Alt+0`` resets every preview (:func:`reset_all_preview_scales`).
A WINDOW OPENED FROM THE PREVIEW IS NOT THE PREVIEW (item 522). A dialog
parented to the panel is a window of its own, yet Qt cascades the panel's
scaled sheet into it, and the walk found it among the panel's children: at
150 % a settings dialog's buttons were 59 px tall, not the 40 px of every
other dialog. Such a window and everything in it are kept at 100 %, and the
window's own sheet re-states, at 100 %, the sizes it would otherwise inherit
scaled -- set as it is polished, so before it is first laid out.
Each preview remembers its own scale, under ``prefs/preview_scale/<name>``.
"""
from __future__ import annotations
import logging
import re
import weakref
from typing import Callable, List, Optional
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtWidgets import (QAbstractButton, QFormLayout, QGridLayout,
QHBoxLayout, QLabel, QLayout, QSlider,
QWidget)
LOG = logging.getLogger("spacr.qt.preview_scale")
PREVIEW_SCALE_MIN = 0.10
PREVIEW_SCALE_MAX = 2.00
DEFAULT_PREVIEW_SCALE = 1.0
_KEY = "prefs/preview_scale/{name}"
#: Qt's "no maximum", which is never scaled.
_QWIDGETSIZE_MAX = 16777215
#: Style-sheet properties whose pixel values follow the scale. Border widths
#: are deliberately absent: a hairline that rounds to zero disappears.
SCALED_QSS_PROPERTIES = frozenset({
"font-size", "font",
"padding", "padding-top", "padding-right", "padding-bottom",
"padding-left",
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
"min-width", "min-height", "max-width", "max-height",
"width", "height", "spacing", "border-radius",
"border-top-left-radius", "border-top-right-radius",
"border-bottom-left-radius", "border-bottom-right-radius",
})
PREVIEW_SCALE_TIP = (
"Scale this preview's controls, text and pictures, from 10 % to 200 %, "
"without changing the rest of spaCR. It applies straight away and is "
"remembered for this preview. Double-click the percentage to go back "
"to 100 %; Ctrl+Alt+0 resets every scale.")
_EXEMPT = "spacrPreviewScaleExempt"
_P_SHEET_BASE = "spacrPsSheetBase"
_P_SHEET_SET = "spacrPsSheetSet"
_P_MIN_BASE = "spacrPsMinBase"
_P_MIN_SET = "spacrPsMinSet"
_P_MAX_BASE = "spacrPsMaxBase"
_P_MAX_SET = "spacrPsMaxSet"
_P_ICON_BASE = "spacrPsIconBase"
_P_ICON_SET = "spacrPsIconSet"
_P_MARGIN_BASE = "spacrPsMarginBase"
_P_MARGIN_SET = "spacrPsMarginSet"
_P_SPACING_BASE = "spacrPsSpacingBase"
_P_SPACING_SET = "spacrPsSpacingSet"
_COMMENT = re.compile(r"/\*.*?\*/", re.S)
_RULE = re.compile(r"([^{}]+)\{([^{}]*)\}")
_PX = re.compile(r"(-?\d+(?:\.\d+)?)px")
_SCALERS: "weakref.WeakSet[PreviewScaler]" = weakref.WeakSet()
[docs]
def clamp_preview_scale(value) -> float:
"""``value`` as a float inside the slider's bounds (1.0 if unreadable).
:param value: anything a stored preference may hold.
"""
try:
value = float(value)
except (TypeError, ValueError):
return DEFAULT_PREVIEW_SCALE
return max(PREVIEW_SCALE_MIN, min(PREVIEW_SCALE_MAX, value))
[docs]
def get_preview_scale(name: str) -> float:
"""The saved scale of the preview called ``name``.
:param name: the preview's key, e.g. ``"mask"``.
"""
from ..preferences import _settings
try:
raw = _settings().value(_KEY.format(name=name),
DEFAULT_PREVIEW_SCALE)
except Exception: # noqa: BLE001
raw = DEFAULT_PREVIEW_SCALE
return clamp_preview_scale(raw)
[docs]
def set_preview_scale(name: str, scale: float) -> None:
"""Remember ``scale`` for the preview called ``name``.
:param name: the preview's key, e.g. ``"mask"``.
:param scale: the factor, 1.0 = 100 %; clamped to 10-200 %.
"""
from ..preferences import _settings
_settings().setValue(_KEY.format(name=name), clamp_preview_scale(scale))
def _scaled_px(match, factor: float, prop: str = "") -> str:
"""One ``Npx`` scaled, never rounding a non-zero size to nothing.
A radius rounds down, as in :func:`spacr.qt.gui_scale._scaled_px`, so a
circle's radius never outgrows half of its rounded side.
"""
from ..gui_scale import _scaled_px as scaled_px
return scaled_px(match, factor, prop)
[docs]
def scale_qss(text: str, factor: float, *, sizes_only: bool = False) -> str:
"""Scale the pixel sizes in a style sheet by ``factor``.
:param text: the QSS.
:param factor: the scale; 1.0 returns ``text`` unchanged (unless
``sizes_only``).
:param sizes_only: keep only the declarations that were scaled, in rules
that had any. That is what a preview's own sheet is built from: the
sizes of every rule it inherits, re-stated at its scale, and nothing
about colour -- so a theme change needs no rebuild of it.
:returns: the scaled QSS.
"""
text = str(text or "")
if not sizes_only and abs(factor - 1.0) < 1e-9:
return text
text = _COMMENT.sub("", text)
out: List[str] = []
for match in _RULE.finditer(text):
selector, body = match.group(1).strip(), match.group(2)
kept = []
changed = False
for declaration in body.split(";"):
if ":" not in declaration:
if declaration.strip() and not sizes_only:
kept.append(declaration.strip())
continue
name, value = declaration.split(":", 1)
prop = name.strip().lower()
if prop in SCALED_QSS_PROPERTIES and "px" in value:
value = _PX.sub(lambda m: _scaled_px(m, factor, prop), value)
changed = True
kept.append(f"{name.strip()}: {value.strip()}")
elif not sizes_only:
kept.append(f"{name.strip()}: {value.strip()}")
if sizes_only and not changed:
continue
if not selector:
out.append("; ".join(kept))
else:
out.append(f"{selector} {{ {'; '.join(kept)} }}")
if not out and not _RULE.search(text) and not sizes_only:
return _scale_bare_declarations(text, factor)
return "\n".join(out)
def _scale_bare_declarations(text: str, factor: float) -> str:
"""Scale bare declarations such as a font size and a text color."""
parts = []
for declaration in text.split(";"):
if ":" not in declaration:
if declaration.strip():
parts.append(declaration.strip())
continue
name, value = declaration.split(":", 1)
prop = name.strip().lower()
if prop in SCALED_QSS_PROPERTIES:
value = _PX.sub(lambda m: _scaled_px(m, factor, prop), value)
parts.append(f"{name.strip()}: {value.strip()}")
return "; ".join(parts)
def _pair(text) -> Optional[tuple]:
"""``"w,h"`` from a property back into ints."""
if text is None:
return None
try:
return tuple(int(v) for v in str(text).split(","))
except ValueError:
return None
def _text(values) -> str:
"""Ints into the ``"a,b"`` form kept on a property."""
return ",".join(str(int(v)) for v in values)
def _scale_int(value: int, factor: float) -> int:
"""A size scaled, keeping zero at zero and a non-zero size above it."""
if value <= 0:
return value
return max(1, int(round(value * factor)))
def _base_of(obj, base_key: str, set_key: str, current) -> tuple:
"""The value the code asked for: remembered, unless it was set again."""
base = _pair(obj.property(base_key))
applied = _pair(obj.property(set_key))
current = tuple(current)
if base is None or (applied is not None and current != applied):
base = current
obj.setProperty(base_key, _text(base))
return base
def _forget(obj, *keys) -> None:
"""Drop the remembered bases, so a widget at 100 % carries nothing."""
for key in keys:
if obj.property(key) is not None:
obj.setProperty(key, None)
[docs]
class PreviewScaler(QObject):
"""Scales one preview's subtree, and keeps it scaled as it grows.
:param root: the preview panel.
:param name: the key its scale is saved under.
:param scale: the starting scale; the saved one by default.
"""
def __init__(self, root: QWidget, name: str,
scale: Optional[float] = None):
"""Remember the preview and apply its saved scale once it is built."""
super().__init__(root)
self._root = root
self._name = str(name)
self._scale = clamp_preview_scale(
get_preview_scale(self._name) if scale is None else scale)
self._hooks: List[Callable[[float], None]] = []
self._applying = False
self._root_sheet_key = None
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(60)
self._timer.timeout.connect(self.apply)
root.installEventFilter(self)
_SCALERS.add(self)
if abs(self._scale - 1.0) > 1e-9:
QTimer.singleShot(0, self.apply)
@property
[docs]
def name(self) -> str:
"""The key this preview's scale is saved under."""
return self._name
[docs]
def scale(self) -> float:
"""The preview's current scale."""
return self._scale
[docs]
def add_hook(self, hook: Callable[[float], None]) -> None:
"""Call ``hook(scale)`` after every change, and once now if scaled.
For what a panel draws itself -- a thumbnail size, a figure's dpi --
which no style sheet or size constraint reaches.
:param hook: called with the new scale; an exception it raises is
logged, never passed on.
"""
self._hooks.append(hook)
if abs(self._scale - 1.0) > 1e-9:
self._run_hook(hook)
def _run_hook(self, hook) -> None:
"""Run one hook, never letting it take the preview down."""
try:
hook(self._scale)
except Exception: # noqa: BLE001
LOG.debug("a preview scale hook failed", exc_info=True)
[docs]
def set_scale(self, scale: float, *, persist: bool = True) -> float:
"""Scale the preview to ``scale`` now, and remember it.
:param scale: the factor, 1.0 = 100 %.
:param persist: save it for this preview's next opening.
:returns: the scale applied, after clamping.
"""
scale = clamp_preview_scale(scale)
changed = abs(scale - self._scale) > 1e-9
self._scale = scale
if persist:
try:
set_preview_scale(self._name, scale)
except Exception: # noqa: BLE001
LOG.debug("could not save the preview scale", exc_info=True)
self.apply()
if changed:
for hook in list(self._hooks):
self._run_hook(hook)
return scale
[docs]
def refresh(self) -> None:
"""Rebuild after the application sheet changed (Zoom, theme)."""
self._root_sheet_key = None
self.apply()
[docs]
def eventFilter(self, watched, event): # noqa: N802
"""Scale what joins the preview later, and sheets set on it later.
Read through ``getattr``: a garbage collection that breaks a cycle
through this object clears its attributes while Qt still delivers
it events, and a filter that raised then would put an error in the
event loop for a preview that is already going away.
:param watched: the widget the event is for.
:param event: the event; never consumed.
"""
scale = getattr(self, "_scale", 1.0)
timer = getattr(self, "_timer", None)
if (timer is not None and not getattr(self, "_applying", True)
and abs(scale - 1.0) > 1e-9):
kind = event.type()
if kind == QEvent.ChildPolished:
self._window_polished(event.child(), scale)
elif kind in (QEvent.ChildAdded, QEvent.StyleChange,
QEvent.Show):
try:
timer.start()
except RuntimeError:
pass
return False
def _exempt(self, widget) -> bool:
"""Is ``widget`` the scale control, or inside it?"""
while widget is not None and widget is not self._root:
try:
if widget.property(_EXEMPT):
return True
widget = widget.parentWidget()
except RuntimeError:
return False
return False
def _window_of(self, widget):
"""The outermost window between ``widget`` and the preview, if any.
A dialog parented to the panel, or to anything in it, is its own
window: it and its contents are not the preview's to scale.
"""
window = None
while widget is not None and widget is not self._root:
try:
if widget.isWindow():
window = widget
widget = widget.parentWidget()
except RuntimeError:
return window
return window
def _window_polished(self, child, scale: float) -> None:
"""Re-state a new window's sizes before it is first laid out.
Its parent reports it polished from inside ``show()``, before the
window takes its size; waiting for the next :meth:`apply` would lay
it out once at the preview's scale.
"""
try:
if (not isinstance(child, QWidget) or not child.isWindow()
or self._window_of(child) is not child):
return
except RuntimeError:
return
self._applying = True
try:
self._unscale_window(child, scale)
child.installEventFilter(self)
except RuntimeError:
pass
finally:
self._applying = False
def _unscale_window(self, window, factor: float) -> None:
"""Give a window under the preview the sizes of every other window.
Its own sheet is the preview's cascade re-stated at 100 %, then the
sheet the window set on itself, which still wins where they differ.
At 100 % the window's own sheet is given back untouched.
"""
current = window.styleSheet() or ""
base = window.property(_P_SHEET_BASE)
applied = window.property(_P_SHEET_SET)
if base is None or (applied is not None and current != applied):
base = current
if abs(factor - 1.0) < 1e-9:
if applied is not None and current != base:
window.setStyleSheet(base)
_forget(window, _P_SHEET_BASE, _P_SHEET_SET)
return
chain = []
widget = window.parentWidget()
while widget is not None:
chain.append(widget)
if widget is self._root:
break
widget = widget.parentWidget()
parts = [scale_qss(self._inherited_sheet(), 1.0, sizes_only=True)]
for widget in reversed(chain):
own = widget.property(_P_SHEET_BASE)
if own is None:
own = widget.styleSheet() or ""
if own:
parts.append(scale_qss(own, 1.0, sizes_only=True))
if base:
parts.append(base)
wanted = "\n".join(part for part in parts if part)
window.setProperty(_P_SHEET_BASE, base)
window.setProperty(_P_SHEET_SET, wanted)
if current != wanted:
window.setStyleSheet(wanted)
[docs]
def apply(self) -> None:
"""Put the current scale on every widget of the preview."""
if self._applying:
return
try:
root = self._root
root.objectName()
except RuntimeError:
return
self._applying = True
factor = self._scale
try:
widgets = [root] + list(root.findChildren(QWidget))
windows = []
for widget in widgets:
if widget is not root and self._exempt(widget):
continue
window = self._window_of(widget)
own = factor if window is None else 1.0
if widget is window:
windows.append(widget)
elif widget is not root:
self._scale_own_sheet(widget, own)
self._scale_geometry(widget, own)
layout = widget.layout()
if isinstance(layout, QLayout):
self._scale_layout(layout, own)
if widget is root:
continue
if (abs(factor - 1.0) > 1e-9
and (window is None or widget is window)):
widget.installEventFilter(self)
else:
widget.removeEventFilter(self)
self._scale_root_sheet(factor)
for window in windows:
self._unscale_window(window, factor)
self._pin_the_control_text(factor)
except RuntimeError:
pass
finally:
self._applying = False
def _controls(self):
"""The scale controls that sit inside this preview's own subtree."""
return self._root.findChildren(PreviewScaleControl)
def _pin_the_control_text(self, factor: float) -> None:
"""Keep the slider itself at its unscaled size whatever the scale.
The preview's own sheet reaches every child, the slider included, so
the slider is given a sheet of its own that re-states the sizes it
inherits at 100 %. A widget's own sheet beats its parent's.
"""
controls = self._controls()
if not controls:
return
restated = ""
if abs(factor - 1.0) > 1e-9:
restated = scale_qss(self._inherited_sheet(), 1.0,
sizes_only=True)
for control in controls:
if (control.styleSheet() or "") != restated:
control.setStyleSheet(restated)
def _scale_own_sheet(self, widget, factor: float) -> None:
"""Scale the sizes in a widget's own ``setStyleSheet``."""
current = widget.styleSheet() or ""
base = widget.property(_P_SHEET_BASE)
applied = widget.property(_P_SHEET_SET)
if base is None or (applied is not None and current != applied):
if not current:
if base is not None:
_forget(widget, _P_SHEET_BASE, _P_SHEET_SET)
return
base = current
if abs(factor - 1.0) < 1e-9:
if current != base:
widget.setStyleSheet(base)
_forget(widget, _P_SHEET_BASE, _P_SHEET_SET)
return
widget.setProperty(_P_SHEET_BASE, base)
scaled = scale_qss(base, factor)
widget.setProperty(_P_SHEET_SET, scaled)
if current != scaled:
widget.setStyleSheet(scaled)
def _inherited_sheet(self) -> str:
"""Every sheet the preview inherits, outermost first."""
from PySide6.QtWidgets import QApplication
from ..theme import (_WINDOW_SHEET_BASE_LEN, _LOCAL_WIDGET_QSS_ATTRIBUTE,
window_stylesheet)
chain = []
widget = self._root.parentWidget()
while widget is not None:
text = widget.styleSheet() or ""
base_len = widget.property(_WINDOW_SHEET_BASE_LEN)
if isinstance(base_len, int) and 0 < base_len <= len(text):
text = text[base_len:]
if text:
chain.append(text)
widget = widget.parentWidget()
app = QApplication.instance()
window_sheet = window_stylesheet(app) or (
app.styleSheet() if app is not None else "")
suffix = ""
for ancestor in _ancestors(self._root):
suffix = getattr(ancestor, _LOCAL_WIDGET_QSS_ATTRIBUTE, "") or suffix
return "\n".join([window_sheet or ""] + list(reversed(chain))
+ ([suffix] if suffix else []))
def _scale_root_sheet(self, factor: float) -> None:
"""Re-state the inherited sizes at this scale, on the preview itself."""
root = self._root
current = root.styleSheet() or ""
base = root.property(_P_SHEET_BASE)
applied = root.property(_P_SHEET_SET)
if base is None or (applied is not None and current != applied):
base = current
if abs(factor - 1.0) < 1e-9:
if root.property(_P_SHEET_SET) is not None and current != base:
root.setStyleSheet(base)
if not base:
for widget in [root] + root.findChildren(QWidget):
style = widget.style()
style.unpolish(widget)
style.polish(widget)
QWidget.updateGeometry(widget)
QWidget.update(widget)
_forget(root, _P_SHEET_BASE, _P_SHEET_SET)
self._root_sheet_key = None
return
inherited = self._inherited_sheet()
key = (hash(inherited), factor, base)
if key == self._root_sheet_key and current == applied:
return
scaled = (scale_qss(inherited, factor, sizes_only=True) + "\n"
+ scale_qss(base, factor))
root.setProperty(_P_SHEET_BASE, base)
root.setProperty(_P_SHEET_SET, scaled)
self._root_sheet_key = key
if current != scaled:
root.setStyleSheet(scaled)
def _scale_geometry(self, widget, factor: float) -> None:
"""Scale the minimum, maximum and icon sizes the code set."""
size = widget.minimumSize()
current = (size.width(), size.height())
if current != (0, 0) or widget.property(_P_MIN_BASE) is not None:
base = _base_of(widget, _P_MIN_BASE, _P_MIN_SET, current)
if abs(factor - 1.0) < 1e-9:
if current != base:
widget.setMinimumSize(*base)
_forget(widget, _P_MIN_BASE, _P_MIN_SET)
else:
wanted = tuple(_scale_int(v, factor) for v in base)
widget.setProperty(_P_MIN_SET, _text(wanted))
if current != wanted:
widget.setMinimumSize(*wanted)
size = widget.maximumSize()
current = (size.width(), size.height())
if (current != (_QWIDGETSIZE_MAX, _QWIDGETSIZE_MAX)
or widget.property(_P_MAX_BASE) is not None):
base = _base_of(widget, _P_MAX_BASE, _P_MAX_SET, current)
if abs(factor - 1.0) < 1e-9:
if current != base:
widget.setMaximumSize(*base)
_forget(widget, _P_MAX_BASE, _P_MAX_SET)
else:
wanted = tuple(v if v >= _QWIDGETSIZE_MAX
else _scale_int(v, factor) for v in base)
widget.setProperty(_P_MAX_SET, _text(wanted))
if current != wanted:
widget.setMaximumSize(*wanted)
if isinstance(widget, QAbstractButton) and not widget.icon().isNull():
size = widget.iconSize()
current = (size.width(), size.height())
base = _base_of(widget, _P_ICON_BASE, _P_ICON_SET, current)
if abs(factor - 1.0) < 1e-9:
if current != base:
widget.setIconSize(_qsize(base))
_forget(widget, _P_ICON_BASE, _P_ICON_SET)
else:
wanted = tuple(_scale_int(v, factor) for v in base)
widget.setProperty(_P_ICON_SET, _text(wanted))
if current != wanted:
widget.setIconSize(_qsize(wanted))
def _scale_layout(self, layout: QLayout, factor: float) -> None:
"""Scale a layout's margins and spacing, and its nested layouts'."""
margins = layout.contentsMargins()
current = (margins.left(), margins.top(), margins.right(),
margins.bottom())
if current != (0, 0, 0, 0) or layout.property(_P_MARGIN_BASE):
base = _base_of(layout, _P_MARGIN_BASE, _P_MARGIN_SET, current)
if abs(factor - 1.0) < 1e-9:
if current != base:
layout.setContentsMargins(*base)
_forget(layout, _P_MARGIN_BASE, _P_MARGIN_SET)
else:
wanted = tuple(_scale_int(v, factor) for v in base)
layout.setProperty(_P_MARGIN_SET, _text(wanted))
if current != wanted:
layout.setContentsMargins(*wanted)
if isinstance(layout, (QGridLayout, QFormLayout)):
current = (layout.horizontalSpacing(), layout.verticalSpacing())
else:
current = (layout.spacing(), layout.spacing())
if (min(current) >= 0 and current != (0, 0)) or layout.property(
_P_SPACING_BASE):
base = _base_of(layout, _P_SPACING_BASE, _P_SPACING_SET, current)
wanted = base if abs(factor - 1.0) < 1e-9 else tuple(
_scale_int(v, factor) for v in base)
if abs(factor - 1.0) < 1e-9:
_forget(layout, _P_SPACING_BASE, _P_SPACING_SET)
else:
layout.setProperty(_P_SPACING_SET, _text(wanted))
if current != wanted and min(wanted) >= 0:
if isinstance(layout, (QGridLayout, QFormLayout)):
layout.setHorizontalSpacing(wanted[0])
layout.setVerticalSpacing(wanted[1])
else:
layout.setSpacing(wanted[0])
from ..gui_scale import _child_layouts
for child in _child_layouts(layout):
self._scale_layout(child, factor)
def _qsize(pair):
"""A ``QSize`` from a pair of ints."""
from PySide6.QtCore import QSize
return QSize(int(pair[0]), int(pair[1]))
def _ancestors(widget):
"""``widget``'s parents, nearest first."""
parent = widget.parentWidget()
while parent is not None:
yield parent
parent = parent.parentWidget()
[docs]
class PreviewScaleControl(QWidget):
"""The slider and its percentage: compact, and exempt from its own scale.
:param scaler: the preview's scaler.
:param parent: owning widget.
"""
def __init__(self, scaler: PreviewScaler, parent=None):
"""Build the slider at the preview's saved scale."""
super().__init__(parent)
from ..i18n import tr
self._scaler = scaler
self.setObjectName("PreviewScaleControl")
self.setProperty(_EXEMPT, True)
row = QHBoxLayout(self)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
self.slider = QSlider(Qt.Horizontal, self)
self.slider.setObjectName("PreviewScaleSlider")
self.slider.setRange(int(round(PREVIEW_SCALE_MIN * 100)),
int(round(PREVIEW_SCALE_MAX * 100)))
self.slider.setSingleStep(5)
self.slider.setPageStep(25)
self.slider.setFixedWidth(72)
self.slider.setValue(int(round(scaler.scale() * 100)))
self.value = QLabel(self)
self.value.setObjectName("PreviewScaleValue")
self.value.setProperty("i18nSkipText", True)
self._show(self.slider.value())
tip = tr(PREVIEW_SCALE_TIP)
for widget in (self, self.slider, self.value):
widget.setToolTip(tip)
row.addWidget(self.slider)
row.addWidget(self.value)
self.slider.valueChanged.connect(self._show)
self.slider.sliderReleased.connect(self._commit)
self.slider.valueChanged.connect(self._commit_unless_dragging)
self.value.installEventFilter(self)
def _show(self, value: int) -> None:
"""Show the slider's value as a percentage."""
self.value.setText(f"{int(value)}%")
def _commit_unless_dragging(self, _value: int) -> None:
"""Apply keyboard and click steps at once; a drag applies on release."""
if not self.slider.isSliderDown():
self._commit()
def _commit(self) -> None:
"""Scale the preview to what the slider says."""
self._scaler.set_scale(self.slider.value() / 100.0)
[docs]
def set_percent(self, percent: int) -> None:
"""Move the slider (and so the preview) to ``percent``.
:param percent: the scale in percent, 10 to 200.
"""
self.slider.setValue(int(percent))
self._commit()
[docs]
def eventFilter(self, watched, event): # noqa: N802
"""Double-clicking the percentage goes back to 100 %.
:param watched: the widget the event is for.
:param event: the event; a double-click on the value is consumed.
"""
if (watched is getattr(self, "value", None)
and event.type() == QEvent.MouseButtonDblClick):
self.set_percent(100)
return True
return False
def _card_of(panel):
"""The preview card around ``panel``: the nearest ancestor with a title row."""
for ancestor in _ancestors(panel):
if callable(getattr(ancestor, "add_title_action", None)):
return ancestor
return None
class _PlacesTheControl(QObject):
"""Puts the control in its row the first time the panel is shown.
WHY AT THE SHOW. A preview's toolbar row is already as full as the
narrowest laptop allows, and a slider added to it raises the whole
column's minimum width -- measured, the settings column lost 68 px at
100 %, which is exactly what "100 % looks as it does today" forbids. The
preview card's title row, beside Refresh, has the room; but a panel is
built before it is put in its card, so where the control goes can only
be decided once the panel is on screen.
"""
def __init__(self, panel, control, layout, index, stretch_before,
prefer_card):
"""Remember where the control could go, and wait for the show."""
super().__init__(panel)
self._panel = panel
self._control = control
self._layout = layout
self._index = index
self._stretch_before = stretch_before
self._prefer_card = prefer_card
panel.installEventFilter(self)
def eventFilter(self, watched, event): # noqa: N802
"""Place the control on the panel's first show.
:param watched: the widget the event is for.
:param event: the event; never consumed.
"""
panel = getattr(self, "_panel", None)
if panel is not None and watched is panel and (
event.type() == QEvent.Show):
panel.removeEventFilter(self)
self.place()
return False
def place(self) -> str:
"""Put the control in the card's title row, else show it in its row.
It already sits, hidden, in the row it was installed into -- a
hidden widget takes no room and adds nothing to the row's minimum --
so without a card showing it is all there is to do.
:returns: ``"card"``, ``"row"`` or ``""`` for where it went.
"""
control = self._control
card = _card_of(self._panel) if self._prefer_card else None
where = ""
if card is not None:
if self._layout is not None:
self._layout.removeWidget(control)
row = getattr(card, "_title_row", None)
refresh = getattr(card, "_refresh_button", None)
if row is not None and refresh is not None and row.indexOf(
refresh) >= 0:
control.setParent(card)
row.insertWidget(row.indexOf(refresh), control)
else:
card.add_title_action(control)
where = "card"
elif self._layout is not None:
where = "row"
if where:
control.show()
host = card if card is not None else self._panel
top = host.layout() if host is not None else None
if top is not None:
top.activate()
return where
[docs]
def install_preview_scale(panel: QWidget, name: str, layout=None, *,
index: Optional[int] = None,
stretch_before: bool = False,
prefer_card: bool = True
) -> PreviewScaleControl:
"""Give ``panel`` its own scale slider, saved under ``name``.
:param panel: the preview panel whose contents the slider scales.
:param name: the key its scale is saved under, e.g. ``"mask"``.
:param layout: the row to put the slider in when the panel is not in a
preview card; ``None`` leaves it to the caller.
:param index: where in ``layout`` to insert it; the end by default.
:param stretch_before: put a stretch in front of it, for a row whose
last item does not already push it right.
:param prefer_card: put it in the enclosing card's title row, beside
Refresh, when there is one (see :class:`_PlacesTheControl`).
:returns: the control. Its scaler is ``control.scaler`` and on
``panel.preview_scaler``; ``control.placer.place()`` places it now.
"""
scaler = PreviewScaler(panel, name)
control = PreviewScaleControl(scaler, panel)
control.hide()
control.scaler = scaler
scaler.control = control
panel.preview_scaler = scaler
if layout is not None:
if index is None:
if stretch_before:
layout.addStretch(1)
layout.addWidget(control)
else:
layout.insertWidget(index, control)
control.hide()
control.placer = _PlacesTheControl(panel, control, layout, index,
stretch_before, prefer_card)
return control
[docs]
def refresh_all_preview_scales() -> int:
"""Rebuild every scaled preview; called after Preferences re-styles.
:returns: how many previews were scaled and so rebuilt.
"""
count = 0
for scaler in list(_SCALERS):
try:
if abs(scaler.scale() - 1.0) > 1e-9:
scaler.refresh()
count += 1
except RuntimeError:
continue
return count
[docs]
def reset_all_preview_scales() -> int:
"""Put every live preview back to 100 %, and forget the saved scales.
:returns: how many live previews were reset.
"""
count = 0
for scaler in list(_SCALERS):
try:
scaler.set_scale(1.0)
count += 1
except RuntimeError:
continue
try:
from ..preferences import _settings
store = _settings()
real = getattr(store, "_real", store)
real.beginGroup("prefs/preview_scale")
keys = list(real.childKeys())
real.endGroup()
for key in keys:
store.setValue(f"prefs/preview_scale/{key}", 1.0)
except Exception: # noqa: BLE001
LOG.debug("could not reset the saved preview scales", exc_info=True)
for scaler in list(_SCALERS):
control = getattr(scaler, "control", None)
if control is None:
continue
try:
control.slider.blockSignals(True)
control.slider.setValue(100)
control.slider.blockSignals(False)
control._show(100)
except RuntimeError:
continue
return count