fix(ui): preserve bounds and paint origins for aligned text

This commit is contained in:
skylartaylor committed 2026-10-24 10:51:20 +02:00
1 parent 8c05070a63
commit a415e404d6
1 file changed
+330 -47
+330 -47
View File
@@ -2,7 +2,68 @@
use std::sync::Arc;
use egui::{Align, Galley, Rect, epaint::text::PlacedRow, pos2, vec2};
use egui::emath::GuiRounding as _;
use egui::{Align, Color32, Galley, Painter, Pos2, Rect, Vec2, epaint::TextShape, epaint::text::PlacedRow, pos2, vec2};
/// Already-shaped rows and their explicit placement relative to a reading area.
///
/// The normalized galley alone does not include its reading-area offset. Use [`Self::paint`]
/// or [`Self::text_shape`] to retain it; there is deliberately no implicit galley conversion.
#[derive(Clone, Debug)]
#[must_use]
pub enum AlignedGalley {
/// A complete left-anchored logical bound, with the reading area kept separately.
Aligned { galley: Arc<Galley>, paint_offset: Vec2, reading_rect: Rect },
/// An invalid width leaves the original galley untouched, including its alignment.
InvalidWidth { original: Arc<Galley> },
}
impl AlignedGalley {
/// The requested reading area, in coordinates relative to the supplied reading origin.
/// An invalid width has no reading rectangle.
pub fn reading_rect(&self) -> Option<Rect> {
match self {
Self::Aligned { reading_rect, .. } => Some(*reading_rect),
Self::InvalidWidth { .. } => None,
}
}
/// The original text height, independent of horizontal overflow.
pub fn height(&self) -> f32 {
match self {
Self::Aligned { reading_rect, .. } => reading_rect.height(),
Self::InvalidWidth { original } => original.size().y,
}
}
/// A shape placed at the reading area's origin, retaining the explicit galley offset.
/// Like [`TextShape::new`], this leaves final pixel rounding to the renderer.
pub fn text_shape(&self, reading_origin: Pos2, color: Color32) -> TextShape {
match self {
Self::Aligned { galley, paint_offset, .. } => TextShape::new(reading_origin + *paint_offset, galley.clone(), color),
Self::InvalidWidth { original } => TextShape::new(reading_origin, original.clone(), color),
}
}
/// Paint at the reading area's origin using the painter's existing clipping rectangle.
///
/// When text rounding is enabled, snap the reading origin before adding the pixel-aligned
/// offset. Otherwise translating an origin near half a pixel could change which pixel the
/// renderer selects. An invalid width uses the original painting path unchanged.
pub fn paint(self, painter: &Painter, reading_origin: Pos2, color: Color32) {
match self {
Self::Aligned { galley, paint_offset, .. } => {
let origin = if painter.ctx().tessellation_options(|options| options.round_text_to_pixels) {
reading_origin.round_to_pixels(galley.pixels_per_point)
} else {
reading_origin
};
painter.galley(origin + paint_offset, galley, color);
}
Self::InvalidWidth { original } => painter.galley(reading_origin, original, color),
}
}
}
/// Align each row inside `width`, without changing its glyphs, wrapping, or logical text.
///
@@ -11,38 +72,48 @@ use egui::{Align, Galley, Rect, epaint::text::PlacedRow, pos2, vec2};
/// The caller supplies the row alignment, for example from paragraph direction in its shaper.
/// This helper uses only stock egui APIs and does not implement bidirectional shaping.
///
/// The returned galley uses a left-edge anchor (`job.halign = Align::Min`) and its logical
/// rectangle is exactly the requested reading area. Ink can extend outside that rectangle:
/// oversized right-aligned rows overflow to the left, and centered rows overflow on both sides.
/// `mesh_bounds` describes the complete ink, including that overflow. Apply clipping separately
/// when required. Positions snap down to physical pixels so right-aligned rows do not cross the
/// right edge through nearest-pixel rounding.
/// The result keeps the requested reading rectangle separately from the complete logical bound.
/// Oversized right-aligned rows overflow to the left, and centered rows overflow on both sides.
/// The galley is normalized to a left-edge anchor (`job.halign = Align::Min`, `rect.left() = 0`);
/// its explicit paint offset preserves every row's position relative to the reading area.
/// `mesh_bounds` describes the complete normalized ink. Apply clipping separately when required.
/// Positions snap down to physical pixels so right-aligned rows do not cross the right edge
/// through nearest-pixel rounding.
///
/// Use the returned galley for painting; per-row alignment is not represented by its single
/// `LayoutJob` alignment, so editable text should retain its original galley for hit testing.
/// Zero width is valid and may place ink entirely outside the logical rectangle.
/// Invalid widths leave the input untouched. Row positions are copy-on-write; the original
/// shared glyph meshes remain shared, and a cached galley is never modified.
#[must_use]
pub fn align_galley_rows(mut galley: Arc<Galley>, width: f32, mut align: impl FnMut(&PlacedRow) -> Align) -> Arc<Galley> {
/// Use the result's paint method, supplying the reading area's origin. Per-row alignment is not
/// represented by its single `LayoutJob` alignment, so editable text should retain its original
/// galley for hit testing.
/// Zero width is valid and may place ink entirely outside the reading rectangle.
/// Invalid widths explicitly return the untouched original, without calling `align`.
/// Row positions are copy-on-write; the original shared glyph meshes remain shared, and a cached
/// galley is never modified.
pub fn align_galley_rows(mut galley: Arc<Galley>, width: f32, mut align: impl FnMut(&PlacedRow) -> Align) -> AlignedGalley {
if !width.is_finite() || width < 0.0 {
return galley;
return AlignedGalley::InvalidWidth { original: galley };
}
let positioned = Arc::make_mut(&mut galley);
if positioned.job.halign != Align::Min {
Arc::make_mut(&mut positioned.job).halign = Align::Min;
}
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, positioned.rect.height()));
let mut mesh_bounds = Rect::NOTHING;
let reading_rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, positioned.rect.height()));
let mut bounds = reading_rect;
for row in &mut positioned.rows {
let spare = f64::from(width) - f64::from(row.size.x);
let ppp = f64::from(positioned.pixels_per_point);
row.pos.x = ((f64::from(align(row).to_factor()) * spare * ppp).floor() / ppp) as f32;
bounds |= row.rect();
}
let paint_offset = vec2(bounds.left(), 0.0);
let mut rect = reading_rect.translate(-paint_offset);
let mut mesh_bounds = Rect::NOTHING;
for row in &mut positioned.rows {
row.pos.x -= paint_offset.x;
rect |= row.rect();
mesh_bounds |= row.visuals.mesh_bounds.translate(row.pos.to_vec2());
}
positioned.rect = rect;
positioned.mesh_bounds = mesh_bounds;
galley
AlignedGalley::Aligned { galley, paint_offset, reading_rect }
}
#[cfg(test)]
@@ -53,13 +124,46 @@ mod tests {
layout_text("Short\nA longer line\nEnd", 1.0, Align::Min)
}
fn layout_text(text: &str, ppp: f32, halign: Align) -> Arc<Galley> {
fn layout_with_context(text: &str, ppp: f32, halign: Align) -> (egui::Context, Arc<Galley>) {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(ppp);
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
let mut job = egui::text::LayoutJob::simple(text.into(), egui::FontId::proportional(14.0), egui::Color32::BLACK, f32::INFINITY);
let mut job = egui::text::LayoutJob::simple(text.into(), egui::FontId::proportional(14.0), Color32::BLACK, f32::INFINITY);
job.halign = halign;
ctx.fonts_mut(|fonts| fonts.layout_job(job))
let galley = ctx.fonts_mut(|fonts| fonts.layout_job(job));
(ctx, galley)
}
fn layout_text(text: &str, ppp: f32, halign: Align) -> Arc<Galley> {
layout_with_context(text, ppp, halign).1
}
fn parts(aligned: &AlignedGalley) -> (&Arc<Galley>, Vec2, Rect) {
match aligned {
AlignedGalley::Aligned { galley, paint_offset, reading_rect } => (galley, *paint_offset, *reading_rect),
AlignedGalley::InvalidWidth { .. } => panic!("a valid fixture width must produce aligned rows"),
}
}
fn close_rect(actual: Rect, expected: Rect, epsilon: f32) {
assert!((actual.min - expected.min).length() <= epsilon, "{actual:?} != {expected:?}");
assert!((actual.max - expected.max).length() <= epsilon, "{actual:?} != {expected:?}");
}
// The pre-normalization row placement, independent of the new wrapper/offset code. This is
// the old physical-pixel floor formula, applied to the original shared glyph rows.
fn reading_space_reference(original: &Galley, width: f32, alignments: &[Align]) -> Arc<Galley> {
let mut reference = original.clone();
reference.rect = Rect::from_min_size(Pos2::ZERO, vec2(width, original.rect.height()));
reference.mesh_bounds = Rect::NOTHING;
assert_eq!(reference.rows.len(), alignments.len());
for (row, alignment) in reference.rows.iter_mut().zip(alignments) {
let pixels = (f64::from(width) - f64::from(row.size.x)) * f64::from(alignment.to_factor()) * f64::from(original.pixels_per_point);
row.pos.x = (pixels.floor() / f64::from(original.pixels_per_point)) as f32;
reference.rect |= row.rect();
reference.mesh_bounds |= row.visuals.mesh_bounds.translate(row.pos.to_vec2());
}
Arc::new(reference)
}
#[test]
@@ -67,7 +171,7 @@ mod tests {
let original = layout();
let positions: Vec<_> = original.rows.iter().map(|row| row.pos).collect();
let mut i = 0;
let aligned = align_galley_rows(original.clone(), 400.0, |_| {
let result = align_galley_rows(original.clone(), 400.0, |_| {
let align = match i {
0 => Align::Max,
1 => Align::Min,
@@ -76,7 +180,10 @@ mod tests {
i += 1;
align
});
let (aligned, offset, reading) = parts(&result);
assert_eq!(i, 3);
assert_eq!(offset, Vec2::ZERO);
assert_eq!(result.reading_rect(), Some(reading));
assert_eq!(original.rows.iter().map(|row| row.pos).collect::<Vec<_>>(), positions);
assert!(Arc::ptr_eq(&original.job, &aligned.job));
assert_eq!(aligned.num_vertices, original.num_vertices);
@@ -95,14 +202,24 @@ mod tests {
}
assert_eq!(aligned.size().x, 400.0);
assert_eq!(aligned.size().y, original.size().y);
assert_eq!(result.height(), original.size().y);
}
#[test]
fn invalid_widths_preserve_the_input() {
let original = layout();
for width in [f32::NAN, f32::INFINITY, -1.0] {
let aligned = align_galley_rows(original.clone(), width, |_| Align::Max);
assert!(Arc::ptr_eq(&original, &aligned));
for halign in [Align::Min, Align::Center, Align::Max] {
let original = layout_text("Short", 1.0, halign);
for width in [f32::NAN, f32::INFINITY, -1.0] {
let result = align_galley_rows(original.clone(), width, |_| panic!("an invalid width must not call the alignment callback"));
let AlignedGalley::InvalidWidth { original: unchanged } = &result else { panic!("invalid width must be explicit") };
assert!(Arc::ptr_eq(&original, unchanged));
assert_eq!(unchanged.job.halign, halign);
assert_eq!(result.reading_rect(), None);
assert_eq!(result.height(), original.size().y);
let shape = result.text_shape(pos2(17.25, 31.5), Color32::BLACK);
assert_eq!(shape.pos, pos2(17.25, 31.5));
assert!(Arc::ptr_eq(&shape.galley, &original));
}
}
}
@@ -112,29 +229,50 @@ mod tests {
let width = 23.3;
assert!(original.rows[0].size.x > width);
for alignment in [Align::Min, Align::Center, Align::Max] {
let aligned = align_galley_rows(original.clone(), width, |_| alignment);
let result = align_galley_rows(original.clone(), width, |_| alignment);
let (aligned, offset, reading) = parts(&result);
let row = &aligned.rows[0];
let x = offset.x + row.pos.x;
let expected = (width - row.size.x) * alignment.to_factor();
assert!(row.pos.x <= expected + 0.0001);
assert!(expected - row.pos.x < 0.5);
assert_eq!(row.pos.x * 2.0, (row.pos.x * 2.0).floor());
assert!(x <= expected + 0.0001);
assert!(expected - x < 0.5);
assert_eq!(x * 2.0, (x * 2.0).floor());
if alignment == Align::Max {
assert!(row.pos.x < 0.0);
assert!(row.rect().right() <= width + 0.0001);
assert!(x < 0.0);
assert!(x + row.size.x <= width + 0.0001);
}
assert_eq!(aligned.rect.left(), 0.0);
assert_eq!(aligned.rect.width(), width);
assert_eq!(aligned.job.halign, Align::Min);
assert_eq!(reading.width(), width);
assert!(aligned.rect.contains_rect(row.rect()));
assert!(aligned.rect.contains_rect(reading.translate(-offset)));
assert!(aligned.mesh_bounds.contains_rect(row.visuals.mesh_bounds.translate(row.pos.to_vec2())));
}
}
#[test]
fn overwide_right_aligned_rows_have_complete_logical_bounds() {
let original = layout_text("unbreakable-long-token", 2.0, Align::Min);
let width = 23.3;
assert!(original.rows[0].size.x > width, "the fixture must overflow the reading area");
let result = align_galley_rows(original, width, |_| Align::Max);
let (aligned, offset, _) = parts(&result);
let row = &aligned.rows[0];
let reading_row = row.rect().translate(offset);
assert!(reading_row.left() < 0.0, "the right-aligned overwide row must extend to the left");
assert!(reading_row.right() <= width, "the requested right reading edge stays fixed");
assert!(aligned.rect.contains_rect(row.rect()), "the public logical bound {:?} must contain the actual row {:?}", aligned.rect, row.rect());
}
#[test]
fn fractional_width_and_nonleft_input_normalize_the_anchor() {
for original_align in [Align::Min, Align::Center, Align::Max] {
let original = layout_text("Short", 2.0, original_align);
let aligned = align_galley_rows(original.clone(), 163.3, |_| Align::Max);
let result = align_galley_rows(original.clone(), 163.3, |_| Align::Max);
let (aligned, offset, _) = parts(&result);
assert_eq!(original.job.halign, original_align);
assert_eq!(aligned.job.halign, Align::Min);
assert_eq!(offset, Vec2::ZERO);
assert_eq!(aligned.rect.left(), 0.0);
assert_eq!(aligned.rect.right(), 163.3);
for row in &aligned.rows {
@@ -150,15 +288,21 @@ mod tests {
for text in ["", " ", "Short\n"] {
let original = layout_text(text, 1.0, Align::Min);
let height = original.rect.height();
let aligned = align_galley_rows(original, 80.25, |_| Align::Max);
let result = align_galley_rows(original, 80.25, |_| Align::Max);
let (aligned, offset, reading) = parts(&result);
assert_eq!(offset, Vec2::ZERO);
assert_eq!(aligned.rect, Rect::from_min_size(pos2(0.0, 0.0), vec2(80.25, height)));
assert_eq!(reading, aligned.rect);
assert_eq!(result.height(), height);
}
let original = layout();
let mut empty = (*original).clone();
empty.rows.clear();
empty.rect = Rect::ZERO;
empty.mesh_bounds = Rect::NOTHING;
let aligned = align_galley_rows(Arc::new(empty), 80.25, |_| Align::Max);
let result = align_galley_rows(Arc::new(empty), 80.25, |_| Align::Max);
let (aligned, offset, _) = parts(&result);
assert_eq!(offset, Vec2::ZERO);
assert_eq!(aligned.rect, Rect::from_min_size(pos2(0.0, 0.0), vec2(80.25, 0.0)));
assert_eq!(aligned.mesh_bounds, Rect::NOTHING);
}
@@ -170,13 +314,21 @@ mod tests {
for width in [0.0, 163.3] {
for alignment in [Align::Min, Align::Center, Align::Max] {
let direct = align_galley_rows(original.clone(), width, |_| alignment);
let first = align_galley_rows(original.clone(), 230.5, |_| Align::Max);
let repeated = align_galley_rows(first, width, |_| alignment);
assert_eq!(direct.rect.width(), width);
for (a, b) in direct.rows.iter().zip(&repeated.rows) {
assert_eq!(a.pos, b.pos);
let pixel = a.pos.x * ppp;
assert!((pixel - pixel.round()).abs() < 1e-4);
for first_width in [3.5, 230.5] {
let first = align_galley_rows(original.clone(), first_width, |_| Align::Max);
let repeated = align_galley_rows(parts(&first).0.clone(), width, |_| alignment);
let (a, a_offset, a_reading) = parts(&direct);
let (b, b_offset, b_reading) = parts(&repeated);
assert_eq!(a_reading.width(), width);
assert_eq!(a_reading, b_reading);
assert_eq!(a_offset, b_offset);
assert_eq!(a.rect, b.rect);
assert_eq!(a.mesh_bounds, b.mesh_bounds);
for (a, b) in a.rows.iter().zip(&b.rows) {
assert_eq!(a.pos, b.pos);
let pixel = (a.pos.x + a_offset.x) * ppp;
assert!((pixel - pixel.round()).abs() < 1e-4);
}
}
}
}
@@ -185,10 +337,12 @@ mod tests {
#[test]
fn logical_container_and_ink_bounds_remain_distinct() {
let aligned = align_galley_rows(layout_text("Short", 1.0, Align::Min), 400.0, |_| Align::Min);
let result = align_galley_rows(layout_text("Short", 1.0, Align::Min), 400.0, |_| Align::Min);
let (aligned, _, _) = parts(&result);
assert_eq!(aligned.rect.width(), 400.0);
assert!(aligned.mesh_bounds.right() < aligned.rect.right());
let whitespace = align_galley_rows(layout_text(" ", 1.0, Align::Min), 400.0, |_| Align::Max);
let result = align_galley_rows(layout_text(" ", 1.0, Align::Min), 400.0, |_| Align::Max);
let (whitespace, _, _) = parts(&result);
assert_eq!(whitespace.rect.width(), 400.0);
assert_eq!(whitespace.mesh_bounds, Rect::NOTHING);
}
@@ -198,7 +352,136 @@ mod tests {
let original = Arc::new((*layout()).clone());
assert_eq!(Arc::strong_count(&original), 1);
let before = Arc::as_ptr(&original);
let aligned = align_galley_rows(original, 400.0, |_| Align::Max);
assert_eq!(before, Arc::as_ptr(&aligned));
let result = align_galley_rows(original, 400.0, |_| Align::Max);
assert_eq!(before, Arc::as_ptr(parts(&result).0));
}
const MIXED: &str = "First extraordinarily long reading row\nSecond independently aligned reading row\nThird right-aligned overwide reading row";
const ALIGNMENTS: [Align; 3] = [Align::Min, Align::Center, Align::Max];
fn mixed(original: Arc<Galley>, width: f32) -> AlignedGalley {
assert_eq!(original.rows.len(), ALIGNMENTS.len());
assert!(original.rows.iter().all(|row| row.size.x > width), "each fixture row must overflow");
let mut index = 0;
let result = align_galley_rows(original, width, |_| {
let alignment = ALIGNMENTS[index];
index += 1;
alignment
});
assert_eq!(index, ALIGNMENTS.len());
result
}
#[test]
fn mixed_overflow_normalizes_bounds_without_changing_absolute_rows_or_ink() {
for ppp in [1.25, 1.5, 2.0] {
let original = layout_text(MIXED, ppp, Align::Center);
for width in [0.0, 23.3] {
let expected = reading_space_reference(&original, width, &ALIGNMENTS);
assert!(expected.rect.left() < 0.0 && expected.rect.right() > width, "fixture must overflow both reading edges");
let result = mixed(original.clone(), width);
let (aligned, offset, reading) = parts(&result);
assert_eq!(aligned.job.halign, Align::Min);
assert_eq!(aligned.rect.left_top(), Pos2::ZERO);
assert_eq!(reading, Rect::from_min_size(Pos2::ZERO, vec2(width, original.rect.height())));
assert_eq!(offset, vec2(expected.rect.left(), 0.0));
close_rect(aligned.rect.translate(offset), expected.rect, 1e-4 / ppp);
close_rect(aligned.mesh_bounds.translate(offset), expected.mesh_bounds, 1e-4 / ppp);
assert_eq!(aligned.num_vertices, original.num_vertices);
assert_eq!(aligned.num_indices, original.num_indices);
for ((row, expected), original) in aligned.rows.iter().zip(&expected.rows).zip(&original.rows) {
assert!(Arc::ptr_eq(&row.row, &original.row));
assert_eq!(row.pos.y, original.pos.y);
assert!(aligned.rect.contains_rect(row.rect()));
assert!(aligned.mesh_bounds.contains_rect(row.visuals.mesh_bounds.translate(row.pos.to_vec2())));
assert!(((row.pos + offset) - expected.pos).length() * ppp < 1e-4);
let pixel = (row.pos.x + offset.x) * ppp;
assert!((pixel - pixel.round()).abs() < 1e-4);
}
}
}
}
#[test]
fn public_text_shape_uses_the_complete_logical_bound_for_rotation_anchors() {
let original = layout_text(MIXED, 1.5, Align::Min);
let expected = reading_space_reference(&original, 23.3, &ALIGNMENTS);
let result = mixed(original, 23.3);
let (galley, _, _) = parts(&result);
let origin = pos2(51.25, 37.5);
let angle = 0.73;
let rotation = egui::emath::Rot2::from_angle(angle);
for anchor in [egui::Align2::LEFT_TOP, egui::Align2::CENTER_CENTER, egui::Align2::RIGHT_BOTTOM] {
let pivot = origin + anchor.pos_in_rect(&expected.rect).to_vec2();
let shape = result.text_shape(origin, Color32::BLACK).with_angle_and_anchor(angle, anchor);
for (row, expected) in galley.rows.iter().zip(&expected.rows) {
for (corner, expected) in
[(row.rect().left_top(), expected.rect().left_top()), (row.rect().right_bottom(), expected.rect().right_bottom())]
{
let actual = shape.pos + rotation * corner.to_vec2();
let expected = pivot + rotation * (origin + expected.to_vec2() - pivot);
assert!((actual - expected).length() < 1e-4, "rotation must retain the independently computed logical anchor");
}
}
let corners = [
expected.mesh_bounds.left_top(),
expected.mesh_bounds.right_top(),
expected.mesh_bounds.right_bottom(),
expected.mesh_bounds.left_bottom(),
];
let expected_ink = Rect::from_points(&corners.map(|p| pivot + rotation * (origin + p.to_vec2() - pivot)));
close_rect(shape.visual_bounding_rect(), expected_ink, 1e-4);
}
}
#[test]
fn painter_preserves_visible_vertices_at_fractional_origins_and_dpi() {
for ppp in [1.25, 1.5, 2.0] {
let (ctx, original) = layout_with_context(MIXED, ppp, Align::Min);
for width in [0.0, 23.3] {
let reference = reading_space_reference(&original, width, &ALIGNMENTS);
let aligned = mixed(original.clone(), width);
for rounding in [false, true] {
ctx.tessellation_options_mut(|options| options.round_text_to_pixels = rounding);
let options = ctx.tessellation_options(|options| *options);
for origin in [pos2(37.25, 19.375), pos2(-11.5, 9.5), pos2(0.5 / ppp - 1e-5, 0.5 / ppp), pos2(0.5 / ppp + 1e-5, 0.5 / ppp)] {
let output = ctx.run_ui(egui::RawInput::default(), |ui| aligned.clone().paint(ui.painter(), origin, Color32::BLACK));
let shapes: Vec<_> = output
.shapes
.iter()
.filter_map(|clipped| match &clipped.shape {
egui::Shape::Text(shape) => Some(shape),
_ => None,
})
.collect();
assert_eq!(shapes.len(), 1, "the actual paint method must emit one text shape");
let atlas_size = ctx.fonts(|fonts| fonts.image().size);
let tessellate = |shape: &TextShape| {
let mut tessellator = egui::epaint::Tessellator::new(ppp, options, atlas_size, Vec::new());
let mut mesh = egui::epaint::Mesh::default();
tessellator.tessellate_text(shape, &mut mesh);
mesh
};
let actual = tessellate(shapes[0]);
let expected = tessellate(&TextShape::new(origin, reference.clone(), Color32::BLACK));
assert!(!expected.vertices.is_empty(), "real glyph ink must be tessellated");
assert_eq!(actual.indices, expected.indices);
assert_eq!(actual.vertices.len(), expected.vertices.len());
for (actual, expected) in actual.vertices.iter().zip(&expected.vertices) {
assert_eq!(actual.uv, expected.uv);
assert_eq!(actual.color, expected.color);
assert!(
(actual.pos - expected.pos).length() * ppp < 1e-4,
"the wrapper must not move ink to another physical pixel: {:?} != {:?}",
actual.pos,
expected.pos
);
}
drop(shapes);
output.drop_without_applying_deltas();
}
}
}
}
}
}