Skip to content

Rework pixel snapping.#1792

Draft
xStrom wants to merge 5 commits intolinebender:mainfrom
xStrom:compose-snap-visual
Draft

Rework pixel snapping.#1792
xStrom wants to merge 5 commits intolinebender: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
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.

1 participant