Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ transition: background 400ms -100ms ease;

## Supported properties

Color values accepted everywhere a color is listed: named (`red`, `steelblue`, …), `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()`.
Color values accepted everywhere a color is listed: named (`red`, `steelblue`, …), `#RRGGBB`, `#AARRGGBB`, `rgb()`, `rgba()`, `hsl()`, `hsla()`.

Numeric values accepted everywhere a length is listed: `<n>px`, `<n>pt`, `<n>em`.

Expand Down
22 changes: 22 additions & 0 deletions qt_css_engine/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@
}
)

ENGINE_EVENT_TYPES: frozenset[QEvent.Type] = PSEUDO_EVENTS | frozenset(
{
QEvent.Type.Polish,
QEvent.Type.DynamicPropertyChange,
QEvent.Type.WindowActivate,
QEvent.Type.WindowDeactivate,
QEvent.Type.Leave,
}
)


EASING_MAP: dict[str, QEasingCurve.Type] = {
"linear": QEasingCurve.Type.Linear,
"ease": QEasingCurve.Type.InOutQuad,
Expand Down Expand Up @@ -144,6 +155,17 @@
}
)


BORDER_RADIUS_PROPS: frozenset[str] = frozenset(
{
"border-radius",
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
}
)

# ---------------------------------------------------------------------------
# Cursor map
# ---------------------------------------------------------------------------
Expand Down
487 changes: 315 additions & 172 deletions qt_css_engine/engine.py

Large diffs are not rendered by default.

73 changes: 66 additions & 7 deletions qt_css_engine/handlers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from qt_css_engine.constants import NON_NEGATIVE_PROPS
import math

from qt_css_engine.constants import BORDER_RADIUS_PROPS, NON_NEGATIVE_PROPS, SIZE_PROPS

from .qt_compat import is_qobject_alive
from .qt_compat.QtCore import QEasingCurve, QObject, QVariantAnimation
Expand All @@ -10,15 +12,48 @@
apply_shadow_to_widget,
interpolate_oklab,
lerp_shadow,
margin_side_px,
parse_box_shadow,
parse_color,
parse_css_numeric,
parse_css_val,
scoped_anim_style,
shadow_as_transparent,
update_shadow_ancestor,
)


def clamp_border_radius(
widget: QWidget,
prop: str,
value: float,
unit: str,
box_props: dict[str, str] | None = None,
) -> float:
"""
Clamp pixel border-radius values to Qt's maximum supported corner radius.

Qt snaps radii above half of the painted border rect's smaller side back to square corners.
QSS margin sits outside that painted rect, while padding and border stay inside it.
"""
if unit != "px" or prop not in BORDER_RADIUS_PROPS:
return value
props = box_props or {}
width = widget.width()
height = widget.height()
if width <= 0 or height <= 0:
hint = widget.sizeHint()
if width <= 0:
width = hint.width()
if height <= 0:
height = hint.height()
width -= margin_side_px(props, "left") + margin_side_px(props, "right")
height -= margin_side_px(props, "top") + margin_side_px(props, "bottom")
if width <= 0 or height <= 0:
return value
return math.floor(min(value, min(width, height) / 2.0))


class BoxShadowHandle(QObject):
"""
Animates box-shadow via a QGraphicsDropShadowEffect on the widget.
Expand Down Expand Up @@ -187,6 +222,8 @@ def _on_tick(self, t: float) -> None:
props = self._props
props[self.prop] = self.current_color.name(QColor.NameFormat.HexArgb)
self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
if self.start_color.alpha() != 255 or self.end_color.alpha() != 255:
update_shadow_ancestor(self.widget)

def update_spec(self, duration_ms: int, easing_curve: QEasingCurve) -> None:
"""Update duration and easing curve without restarting the animation."""
Expand Down Expand Up @@ -261,19 +298,21 @@ def __init__(
parent: QObject | None = None,
unit: str = "px",
ctx: WidgetContext | None = None,
box_props: dict[str, str] | None = None,
) -> None:
super().__init__(parent)
self.widget = widget
self.prop = prop
self.unit = unit
self._ctx = ctx
self.current_val = float(initial_val)
self._box_props = dict(box_props or {})
self.current_val = self._effective_anim_value(float(initial_val), unit)
# Captured at creation time (before any inline constraint is applied).
# Used as the animation target when returning to natural/unconstrained state,
# so we never call sizeHint() while min-width/max-width are still active.
self.natural_val: float = float(initial_val)
self._clean_on_finish = False
self._anim_origin_val: float | None = float(initial_val)
self._anim_origin_val: float | None = self.current_val

self.anim = QVariantAnimation(self)
self.anim.setDuration(duration_ms)
Expand All @@ -293,16 +332,30 @@ def _props(self) -> dict[str, str]:
props: dict[str, str] = getattr(self.widget, "_css_anim_props", {})
return props

def _effective_anim_value(self, value: float, unit: str | None = None) -> float:
"""Normalize values used as animation endpoints/current state for Qt-limited props."""
resolved_unit = self.unit if unit is None else unit
if self.prop in BORDER_RADIUS_PROPS:
return clamp_border_radius(self.widget, self.prop, max(0.0, value), resolved_unit, self._box_props)
return value

def update_box_props(self, box_props: dict[str, str]) -> None:
"""Update box-model props used for border-radius clamping."""
self._box_props = dict(box_props)

def _on_tick(self, val: int | float) -> None:
"""Write interpolated numeric value to css_anim_props and refresh the widget stylesheet."""
if not is_qobject_alive(self.widget):
self.anim.stop()
return
self.current_val = float(val)
self.current_val = self._effective_anim_value(float(val))
written = max(0.0, self.current_val) if self.prop in NON_NEGATIVE_PROPS else self.current_val
written = clamp_border_radius(self.widget, self.prop, written, self.unit, self._box_props)
props = self._props
props[self.prop] = f"{written:.3f}{self.unit}"
self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
if self.prop in SIZE_PROPS:
update_shadow_ancestor(self.widget)

def _on_finished(self) -> None:
"""Remove the inline size constraint when targeting the natural layout size."""
Expand All @@ -314,6 +367,7 @@ def _on_finished(self) -> None:
del props[self.prop]
try:
self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
update_shadow_ancestor(self.widget)
except RuntimeError:
pass

Expand All @@ -328,9 +382,14 @@ def snap_to(self, value_raw: str) -> None:
self._clean_on_finish = False
parsed = parse_css_numeric(value_raw)
if parsed is not None:
self.current_val = parsed[0]
raw_val, unit = parsed
self.unit = unit
self.current_val = self._effective_anim_value(raw_val, unit)
self._anim_origin_val = self.current_val
self._props[self.prop] = f"{self.current_val:.3f}{self.unit}"
written = self.current_val
if self.prop in BORDER_RADIUS_PROPS:
written = clamp_border_radius(self.widget, self.prop, max(0.0, written), self.unit, self._box_props)
self._props[self.prop] = f"{written:.3f}{self.unit}"

def snap_to_natural(self) -> None:
"""Stop animation and remove this prop from css_anim_props (returns widget to natural layout)."""
Expand All @@ -345,7 +404,7 @@ def set_target(self, target_raw: str, clean_on_finish: bool = False) -> None:
parsed = parse_css_numeric(target_raw)
if parsed is None:
return
t_val = float(parsed[0])
t_val = self._effective_anim_value(float(parsed[0]), parsed[1])
is_running = self.anim.state() == self.anim.State.Running
if is_running and t_val == self.anim.endValue():
return
Expand Down
18 changes: 18 additions & 0 deletions qt_css_engine/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,3 +461,21 @@ def get_preferred_size_fallback(widget: QWidget, base_props: dict[str, str], pro
hint = widget.sizeHint()
px = hint.width() if "width" in prop else hint.height()
return f"{max(0, content_box_px(widget, base_props, prop, px))}px"


def update_shadow_ancestor(widget: QWidget) -> None:
"""Force a full repaint on the nearest ancestor with a QGraphicsEffect.

When a child's inline stylesheet changes during animation, Qt propagates a dirty
region equal to the child's bounding rect. A QGraphicsDropShadowEffect on an
ancestor casts shadow pixels *outside* that rect (offset + blur), so those pixels
are never cleared and appear as residual outlines. Calling update() on the
effect-bearing ancestor invalidates its full offscreen pixmap and forces a clean
re-render including the shadow region.
"""
w = widget.parentWidget()
while w is not None:
if w.graphicsEffect() is not None:
w.update()
return
w = w.parentWidget()
92 changes: 91 additions & 1 deletion tests/test_anim.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@
ColorAnimation,
GenericPropertyAnimation,
OpacityAnimation,
clamp_border_radius,
)
from qt_css_engine.qt_compat import qt_delete
from qt_css_engine.qt_compat.QtCore import QAbstractAnimation, QEasingCurve, QEvent, Qt
from qt_css_engine.qt_compat.QtCore import QAbstractAnimation, QEasingCurve, QEvent, QSize, Qt
from qt_css_engine.qt_compat.QtGui import QColor
from qt_css_engine.qt_compat.QtWidgets import (
QApplication,
QCheckBox,
QFrame,
QGraphicsDropShadowEffect,
QGraphicsOpacityEffect,
QLabel,
QWidget,
)
from qt_css_engine.types import Animation, EvaluationCause, ShadowParams, WidgetContext
Expand Down Expand Up @@ -90,6 +93,13 @@ def setStyleSheet(self, styleSheet: str | None) -> None:
super().setStyleSheet(styleSheet)


class FixedHintWidget(QWidget):
"""Deterministic sizeHint for geometry-free layout regressions."""

def sizeHint(self) -> QSize:
return QSize(20, 10)


# ---------------------------------------------------------------------------
# Widget lifetime / crash prevention
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1286,6 +1296,69 @@ def test_generic_property_animation(_app: QApplication) -> None:
destroy(widget)


def test_generic_border_radius_animation_clamped_to_half_min_side(_app: QApplication) -> None:
widget = QWidget()
widget.resize(20, 10)
ctx = WidgetContext()
anim = GenericPropertyAnimation(widget, "border-top-left-radius", 0.0, 100, QEasingCurve.Type.Linear, ctx=ctx)

anim.set_target("20px")
assert anim.anim.endValue() == pytest.approx(5.0)

anim.anim.setCurrentTime(50)

assert anim.current_val == pytest.approx(2.0)
assert ctx.css_anim_props.get("border-top-left-radius") == "2.000px"

anim.snap_to("20px")
assert anim.current_val == pytest.approx(5.0)
assert ctx.css_anim_props.get("border-top-left-radius") == "5.000px"

destroy(widget)


def test_generic_border_radius_uses_size_hint_when_geometry_unset(_app: QApplication) -> None:
widget = FixedHintWidget()
widget.resize(0, 0)
ctx = WidgetContext()
anim = GenericPropertyAnimation(widget, "border-top-left-radius", 0.0, 100, QEasingCurve.Type.Linear, ctx=ctx)

anim.set_target("20px")

assert anim.anim.endValue() == pytest.approx(5.0)

anim.snap_to("20px")
assert anim.current_val == pytest.approx(5.0)
assert ctx.css_anim_props.get("border-top-left-radius") == "5.000px"

destroy(widget)


def test_border_radius_clamp_uses_widget_rect_for_qframe(_app: QApplication) -> None:
label = QLabel("hello")
label.setFrameStyle(QFrame.Shape.Box.value | QFrame.Shadow.Plain.value)
label.setLineWidth(2)
label.resize(100, 30)
label.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen)
label.show()

expected = min(label.width(), label.height()) // 2
actual = clamp_border_radius(label, "border-top-left-radius", 100.0, "px")

assert actual == expected
destroy(label)


def test_border_radius_clamp_subtracts_qss_margin(_app: QApplication) -> None:
widget = QWidget()
widget.resize(100, 40)

actual = clamp_border_radius(widget, "border-top-left-radius", 100.0, "px", {"margin": "10px"})

assert actual == 10
destroy(widget)


# ---------------------------------------------------------------------------
# TransitionEngine Events & Advanced Mechanics
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2865,3 +2938,20 @@ def test_reload_removes_opacity_when_rule_dropped(_app: QApplication, qtbot: QtB
assert not _has_anim(engine, widget, "opacity")
assert widget.graphicsEffect() is None
destroy(widget)


def test_reload_removes_box_shadow_when_rule_dropped(_app: QApplication, qtbot: QtBot) -> None:
"""Reload that removes the box-shadow rule must tear down the QGraphicsDropShadowEffect."""
engine = make_engine(".box { box-shadow: 0 4px 8px rgba(0,0,0,0.5); }")
widget = QWidget()
widget.setProperty("class", "box")
engine._evaluate_widget_state(widget, cause=EvaluationCause.POLISH)
assert isinstance(widget.graphicsEffect(), QGraphicsDropShadowEffect)

_, new_rules = extract_rules(".box { background-color: red; }")
engine.reload_rules(new_rules)
qtbot.wait(20)

assert not _has_anim(engine, widget, "box-shadow")
assert widget.graphicsEffect() is None
destroy(widget)
Loading