commit 746265b214cfb2e87d508febc005c34e53a75a3c
parent a5576f10a02db5fe488df7006c304e5f248f2384
Author: Emilio Cobos Álvarez <emilio@crisal.io>
Date: Tue, 7 Oct 2025 17:09:26 +0000
Bug 1985190 - Snap outline offsets like border-widths. r=firefox-style-system-reviewers,layout-reviewers,dshin
This implements https://github.com/w3c/csswg-drafts/issues/12906 for
chrome contexts and nightly, for now, pending working group discussion.
We might want to do this only for negative offset but that's a bit
weird. For now do it unconditionally.
Add a test and fix border-width-rounding to also test outline-width, and
also report tests per entry, since we fail one subtest due to floating
point precision.
Differential Revision: https://phabricator.services.mozilla.com/D267770
Diffstat:
14 files changed, 132 insertions(+), 43 deletions(-)
diff --git a/layout/painting/nsDisplayList.cpp b/layout/painting/nsDisplayList.cpp
@@ -4116,15 +4116,11 @@ bool nsDisplayOutline::HasRadius() const {
}
bool nsDisplayOutline::IsInvisibleInRect(const nsRect& aRect) const {
- const nsStyleOutline* outline = mFrame->StyleOutline();
nsRect borderBox(ToReferenceFrame(), mFrame->GetSize());
- if (borderBox.Contains(aRect) && !HasRadius() &&
- outline->mOutlineOffset.ToCSSPixels() >= 0.0f) {
- // aRect is entirely inside the border-rect, and the outline isn't rendered
- // inside the border-rect, so the outline is not visible.
- return true;
- }
- return false;
+ // aRect is entirely inside the border-rect, and the outline isn't rendered
+ // inside the border-rect, so the outline is not visible.
+ return borderBox.Contains(aRect) && !HasRadius() &&
+ mFrame->StyleOutline()->mOutlineOffset >= 0;
}
void nsDisplayEventReceiver::HitTest(nsDisplayListBuilder* aBuilder,
diff --git a/layout/style/nsStyleStruct.cpp b/layout/style/nsStyleStruct.cpp
@@ -552,7 +552,7 @@ nsChangeHint nsStyleBorder::CalcDifference(
nsStyleOutline::nsStyleOutline()
: mOutlineWidth(kMediumBorderWidth),
- mOutlineOffset({0.0f}),
+ mOutlineOffset(0),
mOutlineColor(StyleColor::CurrentColor()),
mOutlineStyle(StyleOutlineStyle::BorderStyle(StyleBorderStyle::None)),
mActualOutlineWidth(0) {
@@ -596,8 +596,7 @@ nsChangeHint nsStyleOutline::CalcDifference(
}
nsSize nsStyleOutline::EffectiveOffsetFor(const nsRect& aRect) const {
- const nscoord offset = mOutlineOffset.ToAppUnits();
-
+ const nscoord offset = mOutlineOffset;
if (offset >= 0) {
// Fast path for non-negative offset values
return nsSize(offset, offset);
diff --git a/layout/style/nsStyleStruct.h b/layout/style/nsStyleStruct.h
@@ -703,7 +703,7 @@ struct MOZ_NEEDS_MEMMOVABLE_MEMBERS nsStyleOutline {
// style struct resolution reasons that we do nsStyleBorder::mBorder;
// see that field's comment.)
nscoord mOutlineWidth;
- mozilla::Length mOutlineOffset;
+ mozilla::StyleAu mOutlineOffset;
mozilla::StyleColor mOutlineColor;
mozilla::StyleOutlineStyle mOutlineStyle;
diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml
@@ -11002,6 +11002,21 @@
value: false
mirror: always
+# Determines how we snap css outline-offset:
+# 0: No snapping (historical behavior)
+# 1: Always snap as border-width (proposed behavior in
+# https://github.com/w3c/csswg-drafts/issues/12906)
+# 2: The above, but only on chrome code, for now.
+- name: layout.css.outline-offset.snapping
+ type: RelaxedAtomicInt32
+#ifdef NIGHTLY_BUILD
+ value: 1
+#else
+ value: 2
+#endif
+ mirror: always
+ rust: true
+
- name: layout.visibility.min-recompute-interval-ms
type: uint32_t
value: 1000
diff --git a/servo/components/style/properties/longhands/outline.mako.rs b/servo/components/style/properties/longhands/outline.mako.rs
@@ -41,8 +41,8 @@ ${helpers.predefined_type(
${helpers.predefined_type(
"outline-offset",
- "Length",
- "crate::values::computed::Length::new(0.)",
+ "BorderSideOffset",
+ "app_units::Au(0)",
engines="gecko servo",
spec="https://drafts.csswg.org/css-ui/#propdef-outline-offset",
servo_restyle_damage="repaint",
diff --git a/servo/components/style/values/computed/border.rs b/servo/components/style/values/computed/border.rs
@@ -24,6 +24,9 @@ pub type LineWidth = Au;
/// A computed value for border-width (and the like).
pub type BorderSideWidth = Au;
+/// A computed value for outline-offset
+pub type BorderSideOffset = Au;
+
/// A computed value for the `border-image-width` property.
pub type BorderImageWidth = Rect<BorderImageSideWidth>;
diff --git a/servo/components/style/values/computed/mod.rs b/servo/components/style/values/computed/mod.rs
@@ -50,7 +50,7 @@ pub use self::background::{BackgroundRepeat, BackgroundSize};
pub use self::basic_shape::FillRule;
pub use self::border::{
BorderCornerRadius, BorderImageRepeat, BorderImageSideWidth, BorderImageSlice,
- BorderImageWidth, BorderRadius, BorderSideWidth, BorderSpacing, LineWidth,
+ BorderImageWidth, BorderRadius, BorderSideOffset, BorderSideWidth, BorderSpacing, LineWidth,
};
pub use self::box_::{
Appearance, BaselineSource, BreakBetween, BreakWithin, Clear, Contain, ContainIntrinsicSize,
diff --git a/servo/components/style/values/specified/border.rs b/servo/components/style/values/specified/border.rs
@@ -145,7 +145,7 @@ impl Parse for LineWidth {
}
impl ToComputedValue for LineWidth {
- type ComputedValue = app_units::Au;
+ type ComputedValue = Au;
#[inline]
fn to_computed_value(&self, context: &Context) -> Self::ComputedValue {
@@ -199,23 +199,29 @@ impl Parse for BorderSideWidth {
}
}
+// https://drafts.csswg.org/css-values-4/#snap-a-length-as-a-border-width
+fn snap_as_border_width(len: Au, context: &Context) -> Au {
+ debug_assert!(len >= Au(0));
+
+ // Round `width` down to the nearest device pixel, but any non-zero value that would round
+ // down to zero is clamped to 1 device pixel.
+ if len == Au(0) {
+ return len;
+ }
+
+ let au_per_dev_px = context.device().app_units_per_device_pixel();
+ std::cmp::max(
+ Au(au_per_dev_px),
+ Au(len.0 / au_per_dev_px * au_per_dev_px),
+ )
+}
+
impl ToComputedValue for BorderSideWidth {
- type ComputedValue = app_units::Au;
+ type ComputedValue = Au;
#[inline]
fn to_computed_value(&self, context: &Context) -> Self::ComputedValue {
- let width = self.0.to_computed_value(context);
- // Round `width` down to the nearest device pixel, but any non-zero value that would round
- // down to zero is clamped to 1 device pixel.
- if width == Au(0) {
- return width;
- }
-
- let au_per_dev_px = context.device().app_units_per_device_pixel();
- std::cmp::max(
- Au(au_per_dev_px),
- Au(width.0 / au_per_dev_px * au_per_dev_px),
- )
+ snap_as_border_width(self.0.to_computed_value(context), context)
}
#[inline]
@@ -224,6 +230,37 @@ impl ToComputedValue for BorderSideWidth {
}
}
+/// A specified value for outline-offset.
+#[derive(Clone, Debug, MallocSizeOf, PartialEq, Parse, SpecifiedValueInfo, ToCss, ToShmem, ToTyped)]
+pub struct BorderSideOffset(Length);
+
+impl ToComputedValue for BorderSideOffset {
+ type ComputedValue = Au;
+
+ #[inline]
+ fn to_computed_value(&self, context: &Context) -> Self::ComputedValue {
+ let offset = Au::from_f32_px(self.0.to_computed_value(context).px());
+ let should_snap = match static_prefs::pref!("layout.css.outline-offset.snapping") {
+ 1 => true,
+ 2 => context.device().chrome_rules_enabled_for_document(),
+ _ => false,
+ };
+ if !should_snap {
+ return offset;
+ }
+ if offset < Au(0) {
+ -snap_as_border_width(-offset, context)
+ } else {
+ snap_as_border_width(offset, context)
+ }
+ }
+
+ #[inline]
+ fn from_computed_value(computed: &Au) -> Self {
+ Self(Length::from_px(computed.to_f32_px()))
+ }
+}
+
impl BorderImageSideWidth {
/// Returns `1`.
#[inline]
diff --git a/servo/components/style/values/specified/mod.rs b/servo/components/style/values/specified/mod.rs
@@ -38,7 +38,8 @@ pub use self::background::{BackgroundRepeat, BackgroundSize};
pub use self::basic_shape::FillRule;
pub use self::border::{
BorderCornerRadius, BorderImageRepeat, BorderImageSideWidth, BorderImageSlice,
- BorderImageWidth, BorderRadius, BorderSideWidth, BorderSpacing, BorderStyle, LineWidth,
+ BorderImageWidth, BorderRadius, BorderSideOffset, BorderSideWidth, BorderSpacing, BorderStyle,
+ LineWidth,
};
pub use self::box_::{
Appearance, BaselineSource, BreakBetween, BreakWithin, Clear, Contain, ContainIntrinsicSize,
diff --git a/testing/web-platform/meta/css/css-borders/border-width-rounding.tentative.html.ini b/testing/web-platform/meta/css/css-borders/border-width-rounding.tentative.html.ini
@@ -1,3 +1,6 @@
[border-width-rounding.tentative.html]
- [Test that border widths are rounded up when they are greater than 0px but less than 1px, and rounded down when they are greater than 1px.]
+ [border-width: 2.999px]
+ expected: FAIL
+
+ [outline-width: 2.999px]
expected: FAIL
diff --git a/testing/web-platform/tests/css/css-borders/border-width-rounding.tentative.html b/testing/web-platform/tests/css/css-borders/border-width-rounding.tentative.html
@@ -35,19 +35,17 @@
{ input: "2.999px", expected: "2px" },
];
- for (const value of values) {
+ for (const { input, expected } of values) {
const div = document.createElement("div");
- div.style = `border: solid ${value.input} blue; margin-bottom: 20px;`;
+ div.style = `border: solid ${input} blue; outline: solid ${input} purple; margin-bottom: 20px;`;
document.body.appendChild(div);
+ test(function() {
+ assert_equals(getComputedStyle(div).borderWidth, expected);
+ }, `border-width: ${input}`);
+ test(function() {
+ assert_equals(getComputedStyle(div).outlineWidth, expected);
+ }, `outline-width: ${input}`);
}
-
- test(function() {
- var targets = document.querySelectorAll("div");
-
- for (var i=0; i < targets.length; ++i) {
- assert_equals(getComputedStyle(targets[i]).borderWidth, values[i].expected);
- }
- }, "Test that border widths are rounded up when they are greater than 0px but less than 1px, and rounded down when they are greater than 1px.");
</script>
</body>
</html>
diff --git a/testing/web-platform/tests/css/css-borders/outline-offset-rounding.tentative.html b/testing/web-platform/tests/css/css-borders/outline-offset-rounding.tentative.html
@@ -0,0 +1,37 @@
+<!doctype html>
+<title>outline-offset gets snapped like outline-width</title>
+<link rel="help" href="https://github.com/w3c/csswg-drafts/issues/12906">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body>
+<script>
+ const values = [
+ { input: "0px", expected: "0px" },
+ { input: "0.1px", expected: "1px" },
+ { input: "0.25px", expected: "1px" },
+ { input: "0.5px", expected: "1px" },
+ { input: "0.9px", expected: "1px" },
+ { input: "1px", expected: "1px" },
+ { input: "1.25px", expected: "1px" },
+ { input: "1.5px", expected: "1px" },
+ { input: "2px", expected: "2px" },
+ { input: "2.75px", expected: "2px" },
+ ];
+
+ for (const {input, expected} of values) {
+ test(function() {
+ const div = document.createElement("div");
+ div.style.outlineOffset = input;
+ document.body.appendChild(div);
+ assert_equals(getComputedStyle(div).outlineOffset, expected);
+ }, input)
+
+ let negExpected = input == "0px" ? "0px" : "-" + expected;
+ test(function() {
+ const div = document.createElement("div");
+ div.style.outlineOffset = "-" + input;
+ document.body.appendChild(div);
+ assert_equals(getComputedStyle(div).outlineOffset, negExpected);
+ }, "-" + input)
+ }
+</script>
diff --git a/testing/web-platform/tests/css/css-ui/parsing/outline-offset-computed.html b/testing/web-platform/tests/css/css-ui/parsing/outline-offset-computed.html
@@ -17,7 +17,7 @@
</style>
<div id="target"></div>
<script>
-test_computed_value("outline-offset", "2.5px");
+test_computed_value("outline-offset", "2.5px", ["2.5px", "2px"]); // See https://github.com/w3c/csswg-drafts/issues/12906
test_computed_value("outline-offset", "10px");
test_computed_value("outline-offset", "0.5em", "20px");
test_computed_value("outline-offset", "calc(10px + 0.5em)", "30px");
diff --git a/widget/Theme.cpp b/widget/Theme.cpp
@@ -1290,7 +1290,7 @@ void Theme::PaintAutoStyleOutline(nsIFrame* aFrame,
const LayoutDeviceRect& aRect,
const Colors& aColors, DPIRatio aDpiRatio) {
const nscoord a2d = aFrame->PresContext()->AppUnitsPerDevPixel();
- const auto cssOffset = aFrame->StyleOutline()->mOutlineOffset.ToAppUnits();
+ const auto cssOffset = aFrame->StyleOutline()->mOutlineOffset;
LayoutDeviceRect rect(aRect);
auto devOffset = LayoutDevicePixel::FromAppUnits(cssOffset, a2d);