Add pixel snapping to layout pass (#1239)

Remove coordinates rounding code from Align and Flex.

Remove rounding from BoxConstraints: instead, widgets do layout under
the assumption that they're in a full f64 coordinate space, and rounding
only happens at the end of layout.

Document pixel snapping.
Pixel-snap the baseline as well.
Remove `invalid_screenshot_2` test (which relied on placing a child
widget with sub-pixel boundaries to produce a slightly incorrect image,
which we can no longer do).
Update all screenshot tests.

See [#masonry > Aligning layout boxes to pixel
boundaries](https://xi.zulipchat.com/#narrow/channel/317477-masonry/topic/Aligning.20layout.20boxes.20to.20pixel.20boundaries)
for details.
This commit is contained in:
Olivier FAURE 2025-07-30 14:10:04 +02:00 committed by GitHub
parent cc4f92812c
commit 094e645754
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 222 additions and 206 deletions

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4219042fb8d93f5f75fa08c822c27d6e6d00bfdddc78a88230be58eab3f9c15
size 5046
oid sha256:4d4d0e2a87e035b16ee11553f8207fed43dd6611d66abf8e568429ede6ca5c7a
size 707

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:09338ac2197bc689ff51ad17b71082718953c4a3f71314c55f7cf2abad1f0160
size 606
oid sha256:4d769e8626a1ce79815c0785940eb959eba088ea9ff37fe8ec92995efd8f8113
size 626

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5dac2e84f7c0364f43ddead8dbbb2255fc0aba253c53b47fb0ba3d137c174d7a
size 1870
oid sha256:98841e12976633ede32f2557c70df5ce48339da22aeea12605242f9fe1b2fbae
size 1884

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6dbd8018c101682858c4e0bb7b333a3c6d34812733b1fc84deebc71321b30029
oid sha256:9ef4b2887ae39e46452a4e84cc00c06ad0d2ea9baf572fd1a13f0db2d1cb803e
size 1551

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bce29bfdc86a0b6455699739586fd625b7a603ecf5f0b398d00dfc48eb37615
size 1547
oid sha256:6693738f97a9f0308bc71d2e79e2b4ac05f499442d23a1fda2ef9125f7de8b6b
size 1548

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24918f3a64f4de0607db3eb172b3c20b5f34247ff00a52ecd8ab1bf2ffbb1511
size 1546
oid sha256:1f88bb0b6034b2921c39e6b47b64ecb2f0d11844985eca4c720bc4f4a4a2c406
size 1548

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bce29bfdc86a0b6455699739586fd625b7a603ecf5f0b398d00dfc48eb37615
size 1547
oid sha256:6693738f97a9f0308bc71d2e79e2b4ac05f499442d23a1fda2ef9125f7de8b6b
size 1548

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f9b3ac09e7c5551c9c422ae3685e51d0a835dd20d824417dffa1221a14f5309
size 1545
oid sha256:6253b755d9845d4b4b00e375bdfaa9dcee3afb67623ab5ca4ddd20659aaaa3c1
size 1546

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f9b3ac09e7c5551c9c422ae3685e51d0a835dd20d824417dffa1221a14f5309
size 1545
oid sha256:6253b755d9845d4b4b00e375bdfaa9dcee3afb67623ab5ca4ddd20659aaaa3c1
size 1546

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:253beeac75d44efe88afcfea1bac13e5b91218b7db18598bd03160de484daa20
size 1549
oid sha256:a9eed917ebca5a294d7bd107c4045f6e4fb1cf04bda3d658551a7417058be64e
size 1550

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:128cff403dec272b051515c6638a99799a50c297d6cf344509917ae32c2a3769
size 1547
oid sha256:c1217eb34cf2d835cf1ff08b0d1d0fbb4998f9c5a4237a9785c679f5d24f3554
size 1548

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:faeceb501f44bbee5b23e5fbd0ef81a346bd648f5547bf0ab64ca9756676cb37
size 1546
oid sha256:0aa2ec10b7595a6943d8027ff023300ddc841c542535536b0f90a22d9895d987
size 1547

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:253beeac75d44efe88afcfea1bac13e5b91218b7db18598bd03160de484daa20
size 1549
oid sha256:a9eed917ebca5a294d7bd107c4045f6e4fb1cf04bda3d658551a7417058be64e
size 1550

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c943b7244bda6434adfbf7d32e40987a0d27fb866dd2e642a933b5adc157155c
size 1548
oid sha256:90f385e9ce2cd45a96c0ee1fee1ba64c38735c93223ca7a7998010fd6c051037
size 1549

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05d6090e200e1a793e4b4dae74316beee735d9f7aec6ef21587cc300bea56b76
size 1549
oid sha256:858ea2b00ec56aeb53e343dada1cb38befd5f6ef62af5ee8be52a7052215d3bf
size 1551

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24918f3a64f4de0607db3eb172b3c20b5f34247ff00a52ecd8ab1bf2ffbb1511
size 1546
oid sha256:1f88bb0b6034b2921c39e6b47b64ecb2f0d11844985eca4c720bc4f4a4a2c406
size 1548

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:02bd8aa06219d26bc25d9c2823f1546e6633bdfe99ccadf60d71ec77ee7c554e
size 1523
oid sha256:804684ebe6aff9cb7999037d492e1cd4135a417bf4e0c85ba70ec9cad074628b
size 1227

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aea53c5fe09a5e334442afc855940d36d35c0b9f62c6f03bb5a7742596f01104
size 1301
oid sha256:c1bcee6e923b31aaade8485d1bec738723b64c4b08bd57f785f9c2dcfb708038
size 1302

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1245ef4d86251e528e8a534e17c9d351d65c42562c3d4ea99cfad1146ede4bb2
size 811
oid sha256:3567bc8a996adfc56f4685e7cded04ba6598cf99c9f26715592cf433d5491bb3
size 809

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3040d971481e6fec69744783e9744dbc82ee1e5767f6c0f3e151fd36d3b3d92e
size 1134
oid sha256:139c45a4fb6ce62a2ab6d2981f3932767e1cc03ad6a83d95433453792600f7f5
size 1132

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c0f23011f92278b0cb23c2d91ddd8903dd5114e249e2911d0ce8c7dd52c318be
size 10972
oid sha256:5f23fa121d4aa3bfcfdfb20d891e117d850c412c20f8f0848bd35b95bed19339
size 10896

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:564ab7a7b5b9e5d8b560184ce6eb32fc1269818d7f05a84d3d1cbbc42da62036
size 3627
oid sha256:f84b37d6e23fa860f62fd11b5a3fead28e7f6cdd1d35e2a1fd0e7a3cf7889ca6
size 3589

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1ae2a7497e323109cdf51a9de3f8c646afd49ef621a8f1090f87c381f4d6f5bd
size 3508
oid sha256:991f9cd50d5e76c925aeae96a235e162e5932be5edd6b0472ce2e5723b7a1f01
size 3507

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a77590ca4973a2bd112cf6536a8c1ea758bc857c8b08cdac95872011716082e6
oid sha256:1632a46b46f41a9dc35ec19669c3e48b6a139598437e39e9e8d19b570855cb57
size 3552

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:02a85e771ac021a2fa559c876d3d29b6d49122ba0ed6c2a3bed883ebccd187d0
oid sha256:fb472c4d0844e39b1a8040b75b05b703e1218173d36637d768d05e05ec1d0779
size 3504

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:67e19a79c24a64f1b067f8b180e96800874fe5a0707266ee517984a54f50945b
size 3683
oid sha256:4e66dfcc21d7b1e01de6fab0fac0b2877fdcb41ef1f33a6159d3ec96ba3ba3de
size 3649

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7019cea498151e0c6f72dac4844b919956e4055fd7bd776d08b6facca8f7d696
size 3551
oid sha256:f9f379a9d8be3cd53f382eba937cf1fd23ae3b51448bc8b02e7d8aab81ca59b4
size 3550

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94086bbc895aca70ab17c2f7bf5b74bff7aab7ddf3cb33f7e3a7e297fc182845
size 3549
oid sha256:33719b1b3b4481ba3aa117b3858dce01da95fe036102090070d233b12741f7e0
size 3548

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:67e19a79c24a64f1b067f8b180e96800874fe5a0707266ee517984a54f50945b
size 3683
oid sha256:4e66dfcc21d7b1e01de6fab0fac0b2877fdcb41ef1f33a6159d3ec96ba3ba3de
size 3649

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e4945bd5c06215232b0e851a09d065027b4c004c7333453e7ea5dad4ff7f51d
size 3328
oid sha256:47674923c9b7d988a929f167303af756b68a0e6e307e557d3c7a6b3d759ccd59
size 3214

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36f39001b0617c2d10b28e2dd0f04166ba9fc637542bdb416382a59dc66469b2
oid sha256:98ea4b9d237e3f70faa60dcd189fb53126aa4c78026cb75986b3bb156dd2cf24
size 1448

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38ac7d320671e311a15ef95b6ea9b07dffa4cb05e0f2c08c4d1721cc3de40129
size 3358
oid sha256:74ac5adf7b351e50893113d3986201ef769962d354b01e7ba57082b6d1ae2599
size 3359

View File

@ -47,7 +47,7 @@ impl CrossAxisAlignment {
match self {
Self::Start => 0.0,
// in vertical layout, baseline is equivalent to center
Self::Center | Self::Baseline => (val / 2.0).round(),
Self::Center | Self::Baseline => val / 2.0,
Self::End => val,
Self::Fill => 0.0,
}

View File

@ -120,8 +120,7 @@ impl Widget for Align {
let extra_height = (my_size.height - size.height).max(0.);
let origin = self
.align
.resolve(Rect::new(0., 0., extra_width, extra_height))
.expand();
.resolve(Rect::new(0., 0., extra_width, extra_height));
ctx.place_child(&mut self.child, origin);
let my_insets = ctx.compute_insets_from_child(&self.child, my_size);

View File

@ -8,7 +8,6 @@ use std::any::TypeId;
use accesskit::{Node, Role};
use tracing::{Span, trace_span};
use vello::Scene;
use vello::kurbo::common::FloatExt;
use vello::kurbo::{Affine, Line, Point, Size, Stroke};
use crate::core::{
@ -55,7 +54,6 @@ struct Spacing {
n_children: usize,
index: usize,
equal_space: f64,
remainder: f64,
}
enum Child {
@ -546,16 +544,8 @@ impl Spacing {
n_children,
index: 0,
equal_space,
remainder: 0.,
}
}
fn next_space(&mut self) -> f64 {
let desired_space = self.equal_space + self.remainder;
let actual_space = desired_space.round();
self.remainder = desired_space - actual_space;
actual_space
}
}
impl Iterator for Spacing {
@ -580,24 +570,24 @@ impl Iterator for Spacing {
false => 0.,
},
MainAxisAlignment::Center => match self.index {
0 => self.next_space(),
i if i == self.n_children => self.next_space(),
0 => self.equal_space,
i if i == self.n_children => self.equal_space,
_ => 0.,
},
MainAxisAlignment::SpaceBetween => match self.index {
0 => 0.,
i if i != self.n_children => self.next_space(),
i if i != self.n_children => self.equal_space,
_ => match self.n_children {
1 => self.next_space(),
1 => self.equal_space,
_ => 0.,
},
},
MainAxisAlignment::SpaceEvenly => self.next_space(),
MainAxisAlignment::SpaceEvenly => self.equal_space,
MainAxisAlignment::SpaceAround => {
if self.index == 0 || self.index == self.n_children {
self.next_space()
self.equal_space
} else {
self.next_space() + self.next_space()
self.equal_space + self.equal_space
}
}
}
@ -731,8 +721,8 @@ impl Widget for Flex {
let baseline_offset = ctx.child_baseline_offset(widget);
major_non_flex += self.direction.major(child_size).expand();
minor = minor.max(self.direction.minor(child_size).expand());
major_non_flex += self.direction.major(child_size);
minor = minor.max(self.direction.minor(child_size));
max_above_baseline =
max_above_baseline.max(child_size.height - baseline_offset);
max_below_baseline = max_below_baseline.max(baseline_offset);
@ -770,7 +760,7 @@ impl Widget for Flex {
any_use_baseline |= alignment == CrossAxisAlignment::Baseline;
let desired_major = (*flex) * px_per_flex + remainder;
let actual_major = desired_major.round();
let actual_major = desired_major;
remainder = desired_major - actual_major;
let child_bc = self.direction.constraints(&loosened_bc, 0.0, actual_major);
@ -779,15 +769,15 @@ impl Widget for Flex {
let baseline_offset = ctx.child_baseline_offset(widget);
major_flex += self.direction.major(child_size).expand();
minor = minor.max(self.direction.minor(child_size).expand());
major_flex += self.direction.major(child_size);
minor = minor.max(self.direction.minor(child_size));
max_above_baseline =
max_above_baseline.max(child_size.height - baseline_offset);
max_below_baseline = max_below_baseline.max(baseline_offset);
}
Child::FlexedSpacer(flex, calculated_size) => {
let desired_major = (*flex) * px_per_flex + remainder;
*calculated_size = desired_major.round();
*calculated_size = desired_major;
remainder = desired_major - *calculated_size;
major_flex += *calculated_size;
}
@ -859,7 +849,7 @@ impl Widget for Flex {
let child_pos = border.place_down(child_pos);
let child_pos = padding.place_down(child_pos);
ctx.place_child(widget, child_pos);
major += self.direction.major(child_size).expand();
major += self.direction.major(child_size);
major += spacing.next().unwrap_or(0.);
major += gap;
}
@ -998,48 +988,29 @@ mod tests {
assert_eq!(vec(a, 10., 2), vec![5., 0., 5.]);
assert_eq!(vec(a, 10., 3), vec![5., 0., 0., 5.]);
assert_eq!(vec(a, 1., 0), vec![1.]);
assert_eq!(vec(a, 3., 1), vec![2., 1.]);
assert_eq!(vec(a, 5., 2), vec![3., 0., 2.]);
assert_eq!(vec(a, 17., 3), vec![9., 0., 0., 8.]);
assert_eq!(vec(a, 3., 1), vec![1.5, 1.5]);
assert_eq!(vec(a, 5., 2), vec![2.5, 0., 2.5]);
assert_eq!(vec(a, 17., 3), vec![8.5, 0., 0., 8.5]);
let a = MainAxisAlignment::SpaceBetween;
assert_eq!(vec(a, 10., 0), vec![10.]);
assert_eq!(vec(a, 10., 1), vec![0., 10.]);
assert_eq!(vec(a, 10., 2), vec![0., 10., 0.]);
assert_eq!(vec(a, 10., 3), vec![0., 5., 5., 0.]);
assert_eq!(vec(a, 33., 5), vec![0., 8., 9., 8., 8., 0.]);
assert_eq!(vec(a, 34., 5), vec![0., 9., 8., 9., 8., 0.]);
assert_eq!(vec(a, 35., 5), vec![0., 9., 9., 8., 9., 0.]);
assert_eq!(vec(a, 36., 5), vec![0., 9., 9., 9., 9., 0.]);
assert_eq!(vec(a, 37., 5), vec![0., 9., 10., 9., 9., 0.]);
assert_eq!(vec(a, 38., 5), vec![0., 10., 9., 10., 9., 0.]);
assert_eq!(vec(a, 39., 5), vec![0., 10., 10., 9., 10., 0.]);
assert_eq!(vec(a, 34., 5), vec![0., 8.5, 8.5, 8.5, 8.5, 0.]);
let a = MainAxisAlignment::SpaceEvenly;
assert_eq!(vec(a, 10., 0), vec![10.]);
assert_eq!(vec(a, 10., 1), vec![5., 5.]);
assert_eq!(vec(a, 10., 2), vec![3., 4., 3.]);
assert_eq!(vec(a, 10., 3), vec![3., 2., 3., 2.]);
assert_eq!(vec(a, 33., 5), vec![6., 5., 6., 5., 6., 5.]);
assert_eq!(vec(a, 34., 5), vec![6., 5., 6., 6., 5., 6.]);
assert_eq!(vec(a, 35., 5), vec![6., 6., 5., 6., 6., 6.]);
assert_eq!(vec(a, 36., 5), vec![6., 6., 6., 6., 6., 6.]);
assert_eq!(vec(a, 37., 5), vec![6., 6., 7., 6., 6., 6.]);
assert_eq!(vec(a, 38., 5), vec![6., 7., 6., 6., 7., 6.]);
assert_eq!(vec(a, 39., 5), vec![7., 6., 7., 6., 7., 6.]);
assert_eq!(vec(a, 10., 2), vec![10. / 3., 10. / 3., 10. / 3.]);
assert_eq!(vec(a, 10., 3), vec![2.5, 2.5, 2.5, 2.5]);
let a = MainAxisAlignment::SpaceAround;
assert_eq!(vec(a, 10., 0), vec![10.]);
assert_eq!(vec(a, 10., 1), vec![5., 5.]);
assert_eq!(vec(a, 10., 2), vec![3., 5., 2.]);
assert_eq!(vec(a, 10., 3), vec![2., 3., 3., 2.]);
assert_eq!(vec(a, 33., 5), vec![3., 7., 6., 7., 7., 3.]);
assert_eq!(vec(a, 34., 5), vec![3., 7., 7., 7., 7., 3.]);
assert_eq!(vec(a, 35., 5), vec![4., 7., 7., 7., 7., 3.]);
assert_eq!(vec(a, 36., 5), vec![4., 7., 7., 7., 7., 4.]);
assert_eq!(vec(a, 37., 5), vec![4., 7., 8., 7., 7., 4.]);
assert_eq!(vec(a, 38., 5), vec![4., 7., 8., 8., 7., 4.]);
assert_eq!(vec(a, 39., 5), vec![4., 8., 7., 8., 8., 4.]);
assert_eq!(vec(a, 10., 2), vec![2.5, 5., 2.5]);
assert_eq!(vec(a, 12., 3), vec![2., 4., 4., 2.]);
assert_eq!(vec(a, 35., 5), vec![3.5, 7., 7., 7., 7., 3.5]);
}
// TODO - fix this test

View File

@ -543,27 +543,4 @@ mod tests {
assert_failing_render_snapshot!(harness, "sized_box_empty_box");
}
#[test]
fn invalid_screenshot_2() {
// Copy-pasted from label_box_with_size
let mut box_props = Properties::new();
box_props.insert(BorderColor::new(palette::css::BLUE));
box_props.insert(BorderWidth::all(5.0));
box_props.insert(CornerRadius::all(5.0));
// This is the difference
box_props.insert(Padding::all(0.2));
let widget = SizedBox::new(Label::new("hello").with_auto_id())
.width(20.0)
.height(20.0)
.with_props(box_props);
let window_size = Size::new(100.0, 100.0);
let mut harness =
TestHarness::create_with_size(default_property_set(), widget, window_size);
assert_failing_render_snapshot!(harness, "sized_box_label_box_with_size");
}
}

View File

@ -17,6 +17,9 @@ use crate::peniko::Color;
use crate::theme;
use crate::util::{fill_color, include_screenshot, stroke};
// TODO - Remove size rounding.
// Pixel snapping is now done at the Masonry level.
/// A container containing two other widgets, splitting the area either horizontally or vertically.
///
#[doc = include_screenshot!("split_columns.png", "Split panel with two labels.")]

View File

@ -15,12 +15,8 @@ use vello::kurbo::Size;
/// Further, a container widget should compute appropriate constraints
/// for each of its child widgets, and pass those down when recursing.
///
/// The constraints are always [rounded away from zero] to integers
/// to enable pixel perfect layout.
///
/// [`layout`]: crate::core::Widget::layout
/// [Flutter BoxConstraints]: https://api.flutter.dev/flutter/rendering/BoxConstraints-class.html
/// [rounded away from zero]: Size::expand
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BoxConstraints {
min: Size,
@ -39,28 +35,14 @@ impl BoxConstraints {
/// Create a new box constraints object.
///
/// Create constraints based on minimum and maximum size.
///
/// The given sizes are also [rounded away from zero],
/// so that the layout is aligned to integers.
///
/// [rounded away from zero]: Size::expand
pub fn new(min: Size, max: Size) -> Self {
Self {
min: min.expand(),
max: max.expand(),
}
Self { min, max }
}
/// Create a "tight" box constraints object.
///
/// A "tight" constraint can only be satisfied by a single size.
///
/// The given size is also [rounded away from zero],
/// so that the layout is aligned to integers.
///
/// [rounded away from zero]: Size::expand
pub fn tight(size: Size) -> Self {
let size = size.expand();
Self {
min: size,
max: size,
@ -78,13 +60,8 @@ impl BoxConstraints {
}
/// Clamp a given size so that it fits within the constraints.
///
/// The given size is also [rounded away from zero],
/// so that the layout is aligned to integers.
///
/// [rounded away from zero]: Size::expand
pub fn constrain(&self, size: impl Into<Size>) -> Size {
size.into().expand().clamp(self.min, self.max)
size.into().clamp(self.min, self.max)
}
/// Returns the max size of these constraints.
@ -154,22 +131,15 @@ impl BoxConstraints {
if !(0.0 <= self.min.width
&& self.min.width <= self.max.width
&& 0.0 <= self.min.height
&& self.min.height <= self.max.height
&& self.min.expand() == self.min
&& self.max.expand() == self.max)
&& self.min.height <= self.max.height)
{
debug_panic!("Bad BoxConstraints passed to {name}: {self:?}",);
}
}
/// Shrink min and max constraints by size
///
/// The given size is also [rounded away from zero],
/// so that the layout is aligned to integers.
///
/// [rounded away from zero]: Size::expand
pub fn shrink(&self, diff: impl Into<Size>) -> Self {
let diff = diff.into().expand();
let diff = diff.into();
let min = Size::new(
(self.min().width - diff.width).max(0.),
(self.min().height - diff.height).max(0.),

View File

@ -20,7 +20,7 @@ use crate::core::{
WidgetMut, WidgetPod, WidgetRef, WidgetState,
};
use crate::debug_panic;
use crate::passes::layout::run_layout_on;
use crate::passes::layout::{place_widget, run_layout_on};
use crate::peniko::Color;
use crate::util::get_debug_color;
@ -573,12 +573,10 @@ impl LayoutCtx<'_> {
origin,
);
}
if origin != self.get_child_state_mut(child).origin {
self.get_child_state_mut(child).origin = origin;
self.get_child_state_mut(child).transform_changed = true;
}
self.get_child_state_mut(child)
.is_expecting_place_child_call = false;
let child_state = self.get_child_state_mut(child);
place_widget(child_state, origin);
self.widget_state.local_paint_rect = self
.widget_state
@ -691,7 +689,7 @@ impl LayoutCtx<'_> {
#[track_caller]
pub fn child_size(&self, child: &WidgetPod<impl Widget + ?Sized>) -> Size {
self.assert_layout_done(child, "child_size");
self.get_child_state(child).size
self.get_child_state(child).layout_size
}
/// Gives the widget a clip path.
@ -730,7 +728,7 @@ impl LayoutCtx<'_> {
///
/// **TODO** This method should be removed after the layout refactor.
pub fn old_size(&self) -> Size {
self.widget_state.size
self.widget_state.size()
}
}
@ -744,6 +742,9 @@ impl ComposeCtx<'_> {
/// Set the scroll translation for the child widget.
///
/// The translation is applied on top of the position from [`LayoutCtx::place_child`].
///
/// The given translation may be quantized so the child's final position
/// stays pixel-perfect.
pub fn set_child_scroll_translation(
&mut self,
child: &mut WidgetPod<impl Widget + ?Sized>,
@ -762,6 +763,41 @@ impl ComposeCtx<'_> {
translation,
);
}
let translation = translation.round();
let child = self.get_child_state_mut(child);
if translation != child.scroll_translation {
child.scroll_translation = translation;
child.transform_changed = true;
}
}
/// Set the scroll translation for the child widget.
///
/// The translation is applied on top of the position from [`LayoutCtx::place_child`].
///
/// Unlike [`Self::set_child_scroll_translation`], doesn't perform pixel-snapping.
/// This method should be used for intermediary scroll values during scroll animations.
pub fn set_animated_child_scroll_translation(
&mut self,
child: &mut WidgetPod<impl Widget + ?Sized>,
translation: Vec2,
) {
if translation.x.is_nan()
|| translation.x.is_infinite()
|| translation.y.is_nan()
|| translation.y.is_infinite()
{
debug_panic!(
"Error in {}: trying to call 'set_animated_child_scroll_translation' with child '{}' {} with invalid translation {:?}",
self.widget_id(),
self.get_child_dyn(child).short_type_name(),
child.id(),
translation,
);
}
let child = self.get_child_state_mut(child);
if translation != child.scroll_translation {
child.scroll_translation = translation;
@ -784,12 +820,12 @@ impl_context_method!(
{
/// The layout size.
///
/// This is the layout size returned by the [`layout`] method on the previous
/// layout pass.
/// This is roughly the layout size returned by the [`layout`] method on
/// the previous layout pass, with some adjustment for pixel snapping.
///
/// [`layout`]: Widget::layout
pub fn size(&self) -> Size {
self.widget_state.size
self.widget_state.size()
}
// TODO - Remove. Currently only used in tests.
@ -800,7 +836,7 @@ impl_context_method!(
/// The offset of the baseline relative to the bottom of the widget.
pub fn baseline_offset(&self) -> f64 {
self.widget_state.baseline_offset
self.widget_state.baseline_offset()
}
/// The origin of the widget in window coordinates, relative to the top left corner of the

View File

@ -67,12 +67,16 @@ pub(crate) struct WidgetState {
pub(crate) id: WidgetId,
// --- LAYOUT ---
/// The size of the widget; this is the value returned by the widget's layout
/// method.
pub(crate) size: Size,
/// The origin of the widget in the `window_transform` coordinate space; together with
/// `size` these constitute the widget's layout rect.
// TODO - Better explain origin and end_point
/// The origin (top-left) of the widget in the `window_transform` coordinate space.
/// Together with `end_point`, these constitute the widget's layout rect.
pub(crate) origin: Point,
/// The bottom right of the widget in the `window_transform` coordinate space.
/// Computed from the widget's origin and size, with some pixel snapping.
pub(crate) end_point: Point,
/// The value returned by the widget's layout method.
/// Used to compute `end_point`.
pub(crate) layout_size: Size,
/// The insets applied to the layout rect to generate the paint rect.
/// In general, these will be zero; the exception is for things like
/// drop shadows or overflowing text.
@ -88,6 +92,8 @@ pub(crate) struct WidgetState {
/// the baseline. Widgets that contain text or controls that expect to be
/// laid out alongside text can set this as appropriate.
pub(crate) baseline_offset: f64,
/// The pixel-snapped position of the baseline, computed from `baseline_offset`
pub(crate) baseline_y: f64,
/// Data cached from previous layout passes.
pub(crate) layout_cache: LayoutCache,
@ -206,7 +212,8 @@ impl WidgetState {
Self {
id,
origin: Point::ORIGIN,
size: Size::ZERO,
end_point: Point::ORIGIN,
layout_size: Size::ZERO,
is_expecting_place_child_call: false,
paint_insets: Insets::ZERO,
local_paint_rect: Rect::ZERO,
@ -223,6 +230,7 @@ impl WidgetState {
is_disabled: false,
is_stashed: false,
baseline_offset: 0.0,
baseline_y: 0.0,
is_new: true,
has_hovered: false,
is_hovered: false,
@ -277,10 +285,26 @@ impl WidgetState {
self.local_paint_rect + self.origin.to_vec2()
}
/// The size of this widget.
///
/// This may be different from the value returned by [`Widget::layout`](crate::core::Widget::layout)
/// depending on pixel snapping.
pub(crate) fn size(&self) -> Size {
(self.end_point - self.origin).to_size()
}
/// The offset of the baseline relative to the bottom of the widget.
///
/// This may be different from the value set by [`LayoutCtx::set_baseline_offset`](crate::core::LayoutCtx::set_baseline_offset)
/// depending on pixel snapping.
pub(crate) fn baseline_offset(&self) -> f64 {
self.end_point.y - self.baseline_y
}
// TODO - Remove
/// The rectangle used when calculating layout with other widgets.
pub(crate) fn layout_rect(&self) -> Rect {
Rect::from_origin_size(self.origin, self.size)
Rect::from_points(self.origin, self.end_point)
}
/// The axis aligned bounding rect of this widget in window coordinates. Includes `paint_insets`.
@ -298,7 +322,7 @@ impl WidgetState {
// Note: this returns sensible values for a widget that is translated and/or rescaled.
// Other transformations like rotation may produce weird IME areas.
self.window_transform
.transform_rect_bbox(self.ime_area.unwrap_or_else(|| self.size.to_rect()))
.transform_rect_bbox(self.ime_area.unwrap_or_else(|| self.size().to_rect()))
}
pub(crate) fn window_origin(&self) -> Point {

View File

@ -165,6 +165,20 @@ Handling writing modes is in-scope for Masonry in the long term, but is deferred
We will probably need to implement other features before we can handle it properly, such as style cascading.
## Pixel snapping
Masonry currently handles pixel snapping for its widgets.
The basic idea is that when widgets are laid out, Masonry takes their reported sizes and positions, and rounds them to integer values, so that the drawn shapes line up with pixels.
This is done "at the end" of the layout pass, so to speak, so that widgets can lay themselves out assuming a floating point coordinate space, and without worrying about rounding errors.
The snapping is done in a way that preserves relations between widgets: if one widget ends precisely where another stops, Masonry will pick values so that their pixel-snapped layout rects have no gap or overlap.
**Note:** This may produce incorrect results with DPI scaling.
DPI-aware pixel snapping is a future feature.
[`Cancel`]: ui_events::pointer::PointerEvent::Cancel
[`FocusChanged`]: crate::core::Update::FocusChanged
[`Widget::accepts_focus`]: crate::core::Widget::accepts_focus

View File

@ -83,7 +83,7 @@ fn build_access_node(
scale_factor: Option<f64>,
) -> Node {
let mut node = Node::new(widget.accessibility_role());
node.set_bounds(to_accesskit_rect(ctx.widget_state.size.to_rect()));
node.set_bounds(to_accesskit_rect(ctx.widget_state.size().to_rect()));
let local_translation = ctx.widget_state.scroll_translation + ctx.widget_state.origin.to_vec2();
let mut local_transform = ctx.widget_state.transform.then_translate(local_translation);

View File

@ -35,7 +35,7 @@ fn compose_widget(
state.window_transform =
parent_window_transform * state.transform.then_translate(local_translation);
let local_rect = state.size.to_rect() + state.paint_insets;
let local_rect = state.size().to_rect() + state.paint_insets;
state.bounding_rect = state.window_transform.transform_rect_bbox(local_rect);
let mut ctx = ComposeCtx {

View File

@ -10,11 +10,11 @@
use dpi::LogicalSize;
use tracing::{info_span, trace};
use tree_arena::ArenaMut;
use vello::kurbo::{Rect, Size};
use vello::kurbo::{Point, Rect, Size};
use crate::app::RenderRootState;
use crate::app::{RenderRoot, RenderRootSignal, WindowSizePolicy};
use crate::core::{BoxConstraints, ChildrenIds, LayoutCtx, PropertiesMut};
use crate::core::{BoxConstraints, ChildrenIds, LayoutCtx, PropertiesMut, WidgetState};
use crate::core::{DefaultProperties, WidgetArenaNode};
use crate::debug_panic;
use crate::passes::{enter_span_if, recurse_on_children};
@ -50,7 +50,9 @@ pub(crate) fn run_layout_on(
widget.short_type_name(),
id,
);
state.size = Size::ZERO;
state.origin = Point::ZERO;
state.end_point = Point::ZERO;
state.layout_size = Size::ZERO;
return Size::ZERO;
}
@ -59,7 +61,7 @@ pub(crate) fn run_layout_on(
if !state.needs_layout && state.layout_cache.old_bc == Some(*bc) {
// We reset this to false to mark that the current widget has been visited.
state.request_layout = false;
return state.size;
return state.layout_size;
}
// TODO - Not everything that has been re-laid out needs to be repainted.
@ -176,7 +178,7 @@ pub(crate) fn run_layout_on(
state.layout_cache.old_bc = Some(*bc);
state.size = new_size;
state.layout_size = new_size;
new_size
}
@ -197,6 +199,26 @@ fn clear_layout_flags(node: ArenaMut<'_, WidgetArenaNode>) {
});
}
// --- MARK: PLACE WIDGET
pub(crate) fn place_widget(child_state: &mut WidgetState, origin: Point) {
let end_point = origin + child_state.layout_size.to_vec2();
let baseline_y = origin.y + child_state.baseline_offset;
// TODO - Account for display scale in pixel snapping.
let origin = origin.round();
let end_point = end_point.round();
let baseline_y = baseline_y.round();
// TODO - We may want to invalidate in other cases as well
if origin != child_state.origin {
child_state.transform_changed = true;
}
child_state.origin = origin;
child_state.end_point = end_point;
child_state.baseline_y = baseline_y;
child_state.is_expecting_place_child_call = false;
}
// --- MARK: ROOT
/// See the [passes documentation](../doc/05_pass_system.md#layout-pass).
pub(crate) fn run_layout_pass(root: &mut RenderRoot) {
@ -221,7 +243,7 @@ pub(crate) fn run_layout_pass(root: &mut RenderRoot) {
root_node.reborrow_mut(),
&bc,
);
root_node.item.state.is_expecting_place_child_call = false;
place_widget(&mut root_node.item.state, Point::ORIGIN);
if let WindowSizePolicy::Content = root.size_policy {
let new_size =

View File

@ -140,7 +140,7 @@ pub(crate) fn run_paint_pass(root: &mut RenderRoot) -> Scene {
if let Some(hovered_widget) = root.global_state.inspector_state.hovered_widget {
const HOVER_FILL_COLOR: Color = Color::from_rgba8(60, 60, 250, 100);
let state = root.widget_arena.get_state(hovered_widget);
let rect = Rect::from_origin_size(state.window_origin(), state.size);
let rect = Rect::from_origin_size(state.window_origin(), state.size());
complete_scene.fill(
Fill::NonZero,

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ec1d4904bb61ec8a74fb671b1af714af75348e77e6dd9076b66eeba7d206d3ce
size 9149
oid sha256:7e7ee0cd4349e12b058ab6da934000c6c905cde924b03d641c4dd8889b8cb884
size 7075

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7090b195462875320a8b15d2396c16167d853a569475061eba27d85ed704baed
size 15795
oid sha256:04d9fe1c117a91a1eda2efe440e6d93f601f93d470f0671ffa438b232960768d
size 13946