Source code for spacr.qt.widgets.field_fade

"""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
from typing import Optional

from PySide6.QtCore import QEvent, QObject, QRectF, Qt
from PySide6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPen
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,
    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
[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
[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. """ if theme is None: try: from ..preferences import resolve_effective_theme theme = resolve_effective_theme() except Exception: theme = "dark" 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 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" fill_colour, fill_alpha = chrome[fill_key] line_colour, line_alpha = chrome[border_key] painter.save() painter.setRenderHint(QPainter.Antialiasing, True) painter.setPen(Qt.NoPen) painter.setBrush(QBrush(_ramped(fill_colour, fill_alpha, rect.left(), rect.right()))) painter.drawRoundedRect(rect, radius, radius) painter.setBrush(Qt.NoBrush) painter.setPen(QPen(QBrush(_ramped(line_colour, line_alpha, rect.left(), rect.right())), FIELD_BORDER_PX)) painter.drawRoundedRect(rect, radius, radius) painter.restore()
class _FieldFadeFilter(QObject): """Paints the ramp under every field, just before the field paints.""" def eventFilter(self, obj, event): # noqa: N802 - Qt contract """Start the fade when the watched field changes. :param obj: the field. :param event: the event. :returns: ``False`` -- observed, never consumed. """ if event.type() != 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
[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_application_events(app, _filter, (QEvent.Type.Paint,)) return False _filter = _FieldFadeFilter() _watch_application_events(app, _filter, (QEvent.Type.Paint,)) 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 app = app or QApplication.instance() if app is not None: _stop_watching_application_events(app, _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()