Skip to content

Rework pixel snapping.#1792

Draft
xStrom wants to merge 3 commits into
linebender:mainfrom
xStrom:compose-snap-visual
Draft

Rework pixel snapping.#1792
xStrom wants to merge 3 commits into
linebender:mainfrom
xStrom:compose-snap-visual

Conversation

@xStrom
Copy link
Copy Markdown
Member

@xStrom xStrom commented May 6, 2026

Background

The old pixel snapping system rounded both the origin (top-left) and end point (bottom-right) of the layout border-box in the parent widget's layout border-box coordinate space. Specifically this happened during the layout pass, when the parent called place_child.

The major benefit of this approach is that adjacent geometry will be pixel snapped in a compatible way where there won't be any overlap or empty gaps. This has the promise of a very polished and sharp looking render.

The cost of this approach is that the layout size and pixel snapped size may diverge. The box might shrink, remain the same, or grow.

The problems keep coming

As we've lived with this system, more problems have become clear.

Masonry supports arbitrary transforms on widgets. Doing the pixel snapping in the parent's coordinate space means that none of the transforms that convert from the parent's space to the window's space are taken into account. This is a critical flaw, because the idea of snapping is to align to device pixels, not to parent widget local pixels.

Because snapping happens in the parent's space, there is no consistent rounding of boxes across nested widget levels. It's easy to run into situations where both parent and child have the exact same fractional size, yet they get snapped to different integer sizes. I give an example of this below, in the screenshot changes section.

We've known that the cost of this approach is that snapped box sizes will differ from layout sizes. However, what has not been as obvious and definitely not documented, is that this also effectively introduces a whole new coordinate space. The (1,1) of the layout box isn't the same window pixel location as the (1,1) of the snapped box. Yet there is a bunch of code that ignores this shift and mixes layout and snapped coordinates. Sometimes that can be ok, but thus far very little of it seems intentional.

Improving the situation

We can achieve much improved pixel snapping if we resolve the layout border-box via the full window transform, apply the DPI scale factor to get device pixels, round, and map the snapped box back into local layout border-box space. The rounding will also be consistent across arbitrary nested transforms, so equally sized parent-child combos will result in equal snapped sizes. Pixel snapping will be skipped for a specific widget if the transform has rotation or shear, as it will just add noise and won't be correct. In the future we can also skip the snapping of specific widgets during a smooth animation.

The differences between layout and snapped coordinates will still exist and there is no way around that. What we can do is be much clearer in our method/variable naming and documentation. We can also provide ergonomic helpers to convert between these spaces.

Changes in this PR

  • Pixel snapping moved from the layout pass to the compose pass, immediately after window transform resolution.
  • Renamed the concept of aligned box to visual box. Alignment is used a bunch in other Masonry contexts already, visual makes it more distinct. Especially as the snapping is sometimes skipped, but it's always the visual geometry.
  • Removed the concept of effective box as it isn't really a local box change like the other four, it's just a coordinate space change.
  • Explicitly documented the different coordinate spaces, e.g. layout border-box space, visual content-box space.
  • Made it clearer that Widget::measure/layout operate in layout content-box space and other methods in visual content-box space.
  • Added Ctx::visual_translation for visual<->layout spaces in addition to Ctx::border_box_translation that does border<->content spaces.
  • Added Ctx::layout_border_box and other layout_ methods for widgets that prefer layout geometry.
  • Removed Ctx::content_box_size, Ctx::border_box_size, Ctx::paint_box_size because they were too big of a footgun. They often can't just be converted to Rect, but that's what was sometimes happening. Ctx::border_box etc remain and when really needed Ctx::border_box().size() can be used.
  • Removed ComposeCtx::set_animated_child_scroll_translation as the regular set_child_scroll_translation no longer rounds and thus these two were equivalent.
  • Removed Ctx::window_origin which was a major footgun and often used incorrectly. You can't just take the transformed origin and add non-transformed values to it. The proper results can still be achieved with Ctx::window_transform and Ctx::to_window.
  • Renamed Ctx::get_scale_factor to Ctx::scale_factor and made it available in the same contexts as Ctx::window_transform.
  • WindowEvent::Rescale handler now also requests compose and runs rewrite passes so visual geometry is up-to-date before the next pointer event.
  • Tweaked widget code as needed.
  • Added more comprehensive pixel snapping tests to tests/compose.rs.
  • Added a bunch of box size/transform tests to tests/layout.rs.

Screenshot changes

All test screenshot changes are due to improved pixel snapping. Specifically that pixel snapping is now consistent across widget nesting levels as it is done in the window's coordinate space.

Let's take the badged_button_no_badge screenshot as an example, because it's visually just a simple button, though structurally it's Align(Badged(Button(Label))). The button is 1px narrower in the new screenshot.

The old snapping system worked in the parent widget's coordinate space. So in this case the Button width is 70.6. The Button is placed at (0,0) in the parent's space (Badged), which means that the rounded x coords will be (round(0) = 0, round(70.6) = 71) and the visual Button will be 71 pixels wide. Badged sizes itself also 70.6 during layout, but because it is centered in its parent's space (Align) with x0 = (240 - 70.6) / 2 = 84.7, the rounding will be different: (round(84.7) = 85, round(84.7 + 70.6 = 155.3) = 155) and so the visual Badged will be 155-85 = 70 pixels wide. So we end up in a situation where despite the child and parent having the exact same layout size, they end up with different pixel snapped visual sizes.

With the new system all of this is solved. Everything is snapped in the window's coordinate space and so in this case both Badged and Button end up with a visual width of 70 pixels.

Follow-up work

These are related things that are either certainly or potentially broken that didn't receive full attention in this PR here yet, in order to limit PR size.

  • Baseline snapping continues to be busted.
  • Various helpers like for hairlines don't yet exist.
  • Clip geometry doesn't deal with coordinate space changes and will require a deeper look.
  • Scroll related code may contain issues, especially in Portal.
  • Switch and to a lesser degree Checkbox and RadioButton have custom paint logic that behaves as before, but may contain logic flaws and deserve a deeper inspection at some later date.

Draft because it depends on #1791 and currently includes all of those commits as well.

@xStrom xStrom added masonry Issues relating to the Masonry widget layer blocked Progress is blocked by some other task. labels May 6, 2026
@xStrom xStrom force-pushed the compose-snap-visual branch from 5225253 to 66635af Compare May 6, 2026 14:28
@PoignardAzur
Copy link
Copy Markdown
Contributor

So in this case the Button width is 70.6. The Button is placed at (0,0) in the parent's space (Badged), which means that the rounded x coords will be (round(0) = 0, round(70.6) = 71) and the visual Button will be 71 pixels wide. Badged sizes itself also 70.6 during layout, but because it is centered in its parent's space (Align) with x0 = (240 - 70.6) / 2 = 84.7, the rounding will be different: (round(84.7) = 85, round(84.7 + 70.6 = 155.3) = 155) and so the visual Badged will be 155-85 = 70 pixels wide.

Wait, that doesn't sound right at all. Shouldn't the size passed down from the parent Badged to the child Button be a pixel-snapped 71 pixels?

I guess the reason we don't do that is because we want pixel snapping to account for matrix transforms? If so, seems like we should just drop snapping for non-integer translates anyway.

@xStrom
Copy link
Copy Markdown
Member Author

xStrom commented May 8, 2026

Wait, that doesn't sound right at all. Shouldn't the size passed down from the parent Badged to the child Button be a pixel-snapped 71 pixels?

(Just to be clear to everyone, this is discussion about the old system.)

Pixel snapping occurs in place_child not in run_layout. The size (fractional 70.6) gets passed down in run_layout. By the time place_child is called for the child, the child's Widget::layout execution is already in the past.

@PoignardAzur
Copy link
Copy Markdown
Contributor

PoignardAzur commented May 9, 2026

(Continuing discussion in Zulip: #masonry > Reworking pixel snapping)

xStrom added a commit to xStrom/xilem that referenced this pull request May 11, 2026
## Background

linebender#1264 set out a plan to move the whole Masonry Core API to physical
pixels. That conversion to physical pixels would happen at the widget
level. Some of the benefits mentioned [on Zulip] were that this would
also allow the removal of `ui-events` conversions as those coordinates
could remain in physical pixels, and that pixel snapping would be
better.

## The problem

What has happened is that we have effectively created a third pixel
unit, which is defined as logical pixels multiplied by the DPI scale.
Critically, this is not the same as physical pixels. Because it does not
account for any of the transforms that the widget branch may have. So
pointer events still need to be converted from physical pixels to these
"masonry pixels" and pixel snapping will be incorrect.

Achieving actual physical pixel usage inside widgets is incompatible
with the current layout system, as currently the full tree transform is
not known before layout. However, let's assume for a moment that it
would be possible to have actual physical pixels inside a widget.

If the widget branch has a non-axis-aligned transform with e.g. some
shear or rotation then the widget code suddenly needs to know how to
properly deal with that. That's a lot of complexity that doesn't need to
exist if it just dealt with logical pixels.

Thus, we don't really want to achieve true physical pixel usage. It
makes everything harder to reason about and requires a lot more complex
code.

Keeping this third "masonry pixel" variant still around also doesn't
make sense. It's an extra layer that will still need to be converted to
physical pixels. Part of the original rationale was that *we end up
doing the conversion at different places and it's kind of awkward*.
Well, introducing these "masonry pixels" doesn't actually remove the
need for that conversion. Because those places still need physical
pixels, which means having undergone the full transformation. The DPI
scale can be part of that same transformation, it doesn't need to be
distributed elsewhere.

[on Zulip]:
https://xi.zulipchat.com/#narrow/channel/317477-masonry/topic/Plan.20for.20scale.20factor.2C.20dpi.2C.20and.20layout.20units/with/592271589

## The path forward

We should instead move almost everything to logical pixels. This will
make things easier to reason about, because there won't be unit
conversions and all logic will work the same regardless of what DPI
scale or complex transform is active. Conversions to physical pixels can
happen deep in the core as part of the full transformation and it will
be invisible to most of the code.

For cases like `ui-events` where physical pixels arrive to widgets, this
once again doesn't change much. These events will need a conversion call
anyway, because they aren't in the widget's local coordinate space,
regardless of logical or physical pixels. So this conversion can
continue to do everything that is needed.

Painting should also happen in logical pixels. No surprises, no
conversions, easy sharing of common code and data between various widget
helper methods. For doing physical pixel aware painting, there should be
special helper methods, e.g. for hairlines.

## Changes in this PR

The layout system and all widget implementations have been changed to
logical pixels.

There is now a lot more usage of the `Length` type to encapsulate the
contract of using finite non-negative values which was previously
comment based. This includes various layout method signatures, layout
type internals (e.g. `LenDef`), and also various `Widget::measure`
implementations.

## Follow-up work

There are things that are broken with the old approach that aren't fixed
in this PR here yet, in order to limit PR size.

* Pixel snapping (linebender#1792)
* Baseline snapping
* Physical pixel aligned paint helpers e.g. hairlines
@xStrom xStrom force-pushed the compose-snap-visual branch from 66635af to 2586950 Compare May 11, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked Progress is blocked by some other task. masonry Issues relating to the Masonry widget layer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants