"""Fade form-field chrome to the right while keeping text fully opaque.
The fill and outline follow a cubic transparency ramp defined by
:func:`spacr.qt.theme.field_fade_alpha`: the left edge uses the theme token's
own alpha, the midpoint remains 87.5% opaque, and the right edge is fully
transparent. Field chrome is independent of the page-opacity preference and
the effect is enabled by default through the field-fade preference.
A registered stylesheet makes each supported editor's background and border
transparent while reserving its one-pixel border geometry. An application-
wide paint-event filter then draws the ramped fill and outline before Qt draws
the editor's text, selection, and cursor. Installing one filter on
``QApplication`` covers fields created or rebuilt after startup without
per-screen registration. Non-paint events pass through without repaint work.
Embedded line editors, item-view cell editors, multiline text widgets, and
widgets carrying :data:`OPT_OUT_PROPERTY` are excluded. Higher-specificity
ID-based styles can intentionally keep an opaque fill and hide the ramp.
"""
from __future__ import annotations
import logging
import math
import weakref
from typing import Optional
from PySide6.QtCore import QEvent, QObject, QRectF, Qt
from PySide6.QtGui import (QBrush, QColor, QImage, QLinearGradient, QPainter,
QPen, QPixmap)
from PySide6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QApplication,
QComboBox,
QLineEdit,
)
from ..gil_priority import (_stop_watching_application_events,
_watch_application_events)
from ..theme import (
FIELD_FADE_STOPS,
field_chrome,
field_fade_alpha,
_forget_painting_theme,
_painting_theme,
register_widget_qss,
)
LOG = logging.getLogger(__name__)
#: The widget types that count as "a field". Single-line value editors,
#: which is what a settings form is made of. ``QAbstractSpinBox`` covers
#: ``QSpinBox``, ``QDoubleSpinBox`` and the date/time editors.
#:
#: ``QPlainTextEdit``/``QTextEdit`` are pointedly NOT here: the console,
#: the AI chat transcript and the log panes are those types, and a
#: multi-line body of text that dissolves mid-paragraph is a different
#: (and unasked-for) thing from a value box that trails off past its end.
FIELD_TYPES = (QLineEdit, QComboBox, QAbstractSpinBox)
#: Dynamic property a widget can set to ``True`` to keep the plain look
#: even while the preference is on. Nothing in spaCR sets it yet; it
#: exists so a widget that paints its own background has a way out that
#: is not "edit this module".
OPT_OUT_PROPERTY = "spacrNoFieldFade"
#: Width of the painted outline, in logical pixels. Matches the ``1px``
#: the stylesheet reserves, so turning the fade on or off never reflows
#: a form.
FIELD_BORDER_PX = 1.0
_filter: Optional["_FieldFadeFilter"] = None
_enabled: Optional[bool] = None
[docs]
def field_fade_enabled() -> bool:
"""Whether fields fade. Cached — this is read on every paint event.
Building a ``QSettings`` per paint would put a file-format lookup in
the middle of the render loop. The cache is dropped by
:func:`invalidate_field_fade`, which
:func:`spacr.qt.preferences.set_field_fade_enabled` and
:func:`spacr.qt.preferences.apply_preferences_to_app` both call, so
the two can never disagree about what is on screen.
"""
global _enabled
if _enabled is None:
try:
from ..preferences import get_field_fade_enabled
_enabled = bool(get_field_fade_enabled())
except Exception:
_enabled = True
return _enabled
[docs]
def invalidate_field_fade() -> None:
"""Forget the cached preference so the next read hits ``QSettings``."""
global _enabled
_enabled = None
_forget_painting_theme()
_CHROME.clear()
_recall_chrome()
[docs]
def fades(widget) -> bool:
"""Whether ``widget`` is a field this effect should paint.
Two exclusions, both of them about what the widget *is* rather than
what class it belongs to:
* The ``QLineEdit`` a spin box or an editable combo box embeds. That
inner editor is a field by type but not by appearance: its
container already ramps, and a second ramp inside the first would
put a seam down the middle of one control.
* An item view's in-place cell editor. It is a temporary widget laid
over a row of data, not a form field with space to its right, so a
transparent trailing half would show the cell it is covering and
read as a rendering fault rather than as a design.
:param widget: the widget to test; only the field types in ``FIELD_TYPES``
that have not set the opt-out property can qualify.
"""
if not isinstance(widget, FIELD_TYPES):
return False
if widget.property(OPT_OUT_PROPERTY):
return False
parent = widget.parentWidget()
if parent is None:
return True
if isinstance(widget, QLineEdit) and isinstance(
parent, (QAbstractSpinBox, QComboBox)):
return False
grandparent = parent.parentWidget()
if (isinstance(grandparent, QAbstractItemView)
and grandparent.viewport() is parent):
return False
return True
#: Built gradients, keyed by ``(colour, alpha, left, right)``. A form of
#: thirty settings repaints two gradients of seventeen stops per field,
#: and the answer only ever depends on those four numbers. Capped and
#: dropped wholesale rather than evicted one at a time: the working set
#: is two colours per state per field width, so it is small or it is
#: pathological, and there is nothing in between worth an LRU.
_GRADIENTS: dict = {}
_GRADIENT_CAP = 64
def _ramped(colour: str, alpha: float, left: float, right: float
) -> QLinearGradient:
"""A left-to-right gradient of ``colour`` following the fade curve.
``alpha`` is the colour's own opacity and the curve multiplies it, so
a theme with a translucent rim keeps its material and still reaches
zero on the right.
"""
key = (colour, round(alpha, 4), round(left, 2), round(right, 2))
cached = _GRADIENTS.get(key)
if cached is not None:
return cached
gradient = QLinearGradient(left, 0.0, right, 0.0)
last = FIELD_FADE_STOPS - 1
for i in range(FIELD_FADE_STOPS):
t = i / last
stop = QColor(colour)
stop.setAlphaF(max(0.0, min(1.0, alpha * field_fade_alpha(t))))
gradient.setColorAt(t, stop)
if len(_GRADIENTS) >= _GRADIENT_CAP:
_GRADIENTS.clear()
_GRADIENTS[key] = gradient
return gradient
#: Rasterised chrome, keyed by size, pixel ratio, radius and the two
#: state colours. A field repaints whenever the backdrop under it moves,
#: sixty times a second, and two antialiased rounded rectangles with
#: seventeen-stop gradients cost more than the field's own text; the
#: pixels only ever depend on this key. Dropped wholesale at the cap.
_CHROME: dict = {}
_CHROME_CAP = 256
def _chrome_pixmap(width: int, height: int, ratio: float, radius: float,
fill: tuple, line: tuple) -> QPixmap:
"""The ramped container and outline of a ``width`` x ``height`` field.
Painted exactly as a direct paint would draw it, into a transparent
pixmap at the painter's pixel ratio, so drawing the pixmap over the
field leaves the same pixels.
"""
key = (width, height, round(ratio, 4), radius, fill, line)
cached = _CHROME.get(key)
if cached is not None:
return cached
pixmap = QPixmap(max(1, math.ceil(width * ratio)),
max(1, math.ceil(height * ratio)))
pixmap.setDevicePixelRatio(ratio)
pixmap.fill(Qt.transparent)
inset = FIELD_BORDER_PX / 2.0
rect = QRectF(0.0, 0.0, width, height).adjusted(inset, inset,
-inset, -inset)
painter = QPainter(pixmap)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setPen(Qt.NoPen)
painter.setBrush(QBrush(_ramped(fill[0], fill[1],
rect.left(), rect.right())))
painter.drawRoundedRect(rect, radius, radius)
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QBrush(_ramped(line[0], line[1],
rect.left(), rect.right())),
FIELD_BORDER_PX))
painter.drawRoundedRect(rect, radius, radius)
finally:
painter.end()
if len(_CHROME) >= _CHROME_CAP:
_CHROME.clear()
_CHROME[key] = pixmap
return pixmap
[docs]
def paint_field_fade(widget, painter: QPainter, theme: Optional[str] = None
) -> None:
"""Draw ``widget``'s ramped container and outline with ``painter``.
Separated from the event filter so a test can drive it against a
plain image, and so a widget that wants the look inside its own
``paintEvent`` can call it directly.
:param widget: the field. Only its ``rect()`` and its focus/enabled
state are read.
:param painter: an active painter whose coordinates are the widget's.
:param theme: theme name; ``None`` resolves the effective one.
"""
pixmap = _chrome_of(widget, painter.device().devicePixelRatioF(), theme)
if pixmap is not None:
painter.drawPixmap(0, 0, pixmap)
def _chrome_of(widget, ratio: float, theme: Optional[str] = None
) -> Optional[QPixmap]:
"""The pixmap :func:`paint_field_fade` draws under ``widget``.
:param widget: the field.
:param ratio: the device pixel ratio it is painted at.
:param theme: theme name; ``None`` resolves the effective one.
:returns: ``None`` when the field is too small to have chrome.
"""
if theme is None:
theme = _painting_theme()
chrome = field_chrome(theme)
radius = float(chrome["radius"])
inset = FIELD_BORDER_PX / 2.0
rect = QRectF(widget.rect()).adjusted(inset, inset, -inset, -inset)
if rect.width() <= 0.0 or rect.height() <= 0.0:
return None
enabled = widget.isEnabled()
if not enabled:
fill_key, border_key = "fill_disabled", "border_disabled"
elif widget.hasFocus():
fill_key, border_key = "fill", "border_focus"
else:
fill_key, border_key = "fill", "border"
size = widget.size()
return _chrome_pixmap(
size.width(), size.height(), ratio,
radius, tuple(chrome[fill_key]), tuple(chrome[border_key]))
#: Fields whose chrome is painted elsewhere for now, each with the call that
#: hands it back. The window's field composites the chrome of a field that
#: has no focus into its frames, off the GUI thread, with the card
#: backgrounds (:class:`spacr.qt.widgets.ambient._CardTints`), and the fade's
#: filter is taken off the field meanwhile: over a moving field every field
#: on screen repaints on every frame, and the filter was a Python call, a
#: painter and a blend for each of them.
_LENT: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
def _chrome_image(widget) -> Optional[QImage]:
"""What the fade paints under ``widget`` now, for compositing it.
:param widget: any widget.
:returns: the chrome at a pixel ratio of one, the same pixels the filter
draws; ``None`` when the fade paints nothing under it, or when it has
focus, whose ring follows the pointer and the keyboard.
"""
if (_filter is None or not isinstance(widget, FIELD_TYPES)
or not field_fade_enabled() or not fades(widget)
or widget.hasFocus()):
return None
pixmap = _chrome_of(widget, 1.0)
return None if pixmap is None else pixmap.toImage()
def _lend_chrome(widget, give_back) -> None:
"""Stop painting ``widget``'s chrome; someone else paints it.
:param widget: a field :func:`_chrome_image` answered for.
:param give_back: called when the chrome may have changed in a way no
event of the field's says, such as the fade being switched off or
uninstalled; it must hand the field back with
:func:`_take_back_chrome` before the next paint.
"""
_LENT[widget] = give_back
if _filter is not None:
widget.removeEventFilter(_filter)
def _take_back_chrome(widget) -> None:
"""Paint ``widget``'s chrome here again.
:param widget: a field given to :func:`_lend_chrome`; one already
destroyed is skipped.
"""
from shiboken6 import isValid
_LENT.pop(widget, None)
if _filter is not None and isValid(widget):
widget.installEventFilter(_filter)
def _recall_chrome() -> None:
"""Ask for every lent field back."""
for give_back in set(_LENT.values()):
give_back()
class _FieldFadeFilter(QObject):
"""Paints the ramp under every field, just before the field paints.
The application tells it about each widget once, when the widget is
polished, and it filters the fields among them directly. It is not
asked about every paint event in the application: over an animated
backdrop every widget on screen repaints on every frame, and asking a
Python filter about each of them cost more than painting the ramps.
"""
def eventFilter(self, obj, event): # noqa: N802 - Qt contract
"""Watch a field as it is polished; paint its ramp before it paints.
:param obj: the field, or on a polish any widget.
:param event: the event.
:returns: ``False`` -- observed, never consumed.
"""
kind = event.type()
if kind == QEvent.Type.Polish:
if isinstance(obj, FIELD_TYPES) and obj not in _LENT:
obj.installEventFilter(self)
return False
if kind != QEvent.Type.Paint:
return False
if not field_fade_enabled() or not fades(obj):
return False
paint_owner = obj.window()
painter = None
try:
painter = QPainter(obj)
if painter.isActive():
paint_field_fade(obj, painter)
except Exception:
LOG.exception("Field fade could not paint %s",
type(obj).__name__)
finally:
if painter is not None and painter.isActive():
painter.end()
del paint_owner
return False
def _watch_fields(app) -> None:
"""Hear every widget's polish, and filter the fields that already exist."""
_watch_application_events(app, _filter, (QEvent.Type.Polish,))
for widget in app.allWidgets():
if isinstance(widget, FIELD_TYPES) and widget not in _LENT:
widget.installEventFilter(_filter)
[docs]
def install_field_fade(app=None) -> bool:
"""Install the application-wide paint hook. Idempotent.
:param app: the QApplication; defaults to the running instance.
:returns: ``True`` if a filter was installed by this call.
"""
global _filter
ensure_field_fade_qss()
app = app or QApplication.instance()
if app is None:
return False
if _filter is not None:
_watch_fields(app)
return False
_filter = _FieldFadeFilter()
_watch_fields(app)
return True
[docs]
def uninstall_field_fade(app=None) -> bool:
"""Remove the paint hook. ``True`` if there was one."""
global _filter
if _filter is None:
return False
_recall_chrome()
app = app or QApplication.instance()
if app is not None:
_stop_watching_application_events(app, _filter)
for widget in app.allWidgets():
widget.removeEventFilter(_filter)
_filter = None
return True
[docs]
def repaint_fields(app=None) -> int:
"""Schedule a repaint of every live field. Returns how many.
The stylesheet swap that follows a preference change already forces a
repolish, but a field whose look changed without its *style* changing
— turning the effect off while the QSS block was already empty — has
nothing else to trigger it.
"""
app = app or QApplication.instance()
if app is None:
return 0
count = 0
for widget in app.allWidgets():
if fades(widget):
widget.update()
count += 1
return count
#: Every selector the effect has to neutralise. Listed once, because a
#: state whose rule is missed here paints an opaque box over the ramp and
#: the fade silently stops working in that state only.
_FIELD_SELECTORS = (
"QLineEdit", "QComboBox", "QAbstractSpinBox",
"QSpinBox", "QDoubleSpinBox",
)
_FIELD_STATES = ("", ":focus", ":disabled", ":hover", ":read-only")
[docs]
def field_fade_qss(palette: dict, opacity: Optional[float] = None) -> str:
"""The registered QSS block. Empty when the preference is off.
Empty is load-bearing: with nothing emitted, the built-in input rules
are untouched and a field looks exactly as it did before this module
existed, which is what "turn it off" has to mean.
Signature is :func:`spacr.qt.theme.register_widget_qss`'s contract;
neither argument is used, and that is the point — a field is exempt
from ``opacity``, and its colours come from
:func:`spacr.qt.theme.field_chrome` at paint time so they survive a
theme switch without the stylesheet having baked them in.
:param palette: the theme palette passed by
:func:`spacr.qt.theme.register_widget_qss`; unused.
"""
if not field_fade_enabled():
return ""
selectors = ",\n".join(
f"{name}{state}"
for state in _FIELD_STATES
for name in _FIELD_SELECTORS
)
return f"""
/* The container and outline are painted by
spacr.qt.widgets.field_fade so they can ramp to transparent; the
widget keeps drawing its text, at full alpha, on top. The border is
still declared 1px so nothing reflows when the effect is toggled. */
{selectors} {{
background: transparent;
background-color: transparent;
border: {FIELD_BORDER_PX:.0f}px solid transparent;
}}"""
[docs]
def ensure_field_fade_qss() -> None:
"""(Re)register the QSS block. Idempotent, and called by
:func:`install_field_fade` as well as at import.
Both, because importing a module happens once per process while the
registry is a mutable global: a test that snapshots and restores it
would otherwise switch the effect off for the rest of the session
with no way to get it back.
"""
register_widget_qss("FieldFade", field_fade_qss, replace=True)
ensure_field_fade_qss()