Decide layout on the grid end to end, and delete the tolerance
`Px` and `PxVec2` reach the last places a pixel was a float: the window, the box a widget reads, the box it is compared against, and `PixelRegion`. A pointer, a wheel notch and a shaped glyph advance still arrive as floats, and each is put on the grid where it arrives. `Holds` is an interval of `Px`. `HOLDS_EPSILON_PX` is gone with the `exact`/tolerant split it existed for: `at` is the length a widget read, an open end is the next step along, and `same_px` is equality. `Span`'s margin from `5ed9e87` goes too -- the box a parent hands back and the sum of what its children asked for are counts of the same step, so the boundary decides the same way from either side. Three things had to be true for that, and were not: `Holds::through` inverts `px + rel * box`, which rounds -- so a part of a given length came from a range of boxes, and inverting the length alone gave a point that need not contain the box the part was drawn in. It now maps the half step either side, and one more for a length composed down the chain against the same length measured against the window. `RegionRemap` translates when a box only moved, rather than dividing to find each part's fraction and multiplying to place it again. Two roundings landed a step from where growing the tree that way does; a move is exact on a grid, which is the whole reason `tests/drift.rs` was written. A pixel is `1/1024` rather than `1/64`. At `1/64` the residue of a length reached two ways was one step, and one step was 0.016 px -- enough to move a box. `PX_SHIFT` and `REL_SHIFT` are the only statement of the grid now, and the shader's copy is prepended from them rather than written twice. Checked: fmt, clippy, 102 tests, 100 generated seeds in 75 s, all five shrinker cases at 300 seeds, and `tabs`, `view`, `minimal`, `text` and `random` byte-identical at 1920x1200. What the fuzzers ask for is now a step, not a twentieth of a pixel: the shrinker's five cases agree within one (`resize` exactly), and the oracle's two-operation cases within two. The residue is a single rounding either way -- it scales with the grid rather than accumulating, which is why it is a thousandth of a pixel now. Closing it means one way of asking how long a box is, rather than a chain composed down and a length measured against the window; that is a bigger change than this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd6de71a55
commit
39e4ca20e6
24 files changed
+420
-194
No files matched your search
+6
-4
@@ -15,8 +15,10 @@ where
|
||||
let region = ctx.data.render.window_region(&id).unwrap();
|
||||
let id_pos = region.top_left;
|
||||
let container_pos = ctx.data.render.window_region(&container).unwrap().top_left;
|
||||
let pos = ctx.data.pos + container_pos - id_pos;
|
||||
let size = region.size();
|
||||
// The pointer arrives from the platform in floats; everything
|
||||
// it is compared against is on the grid.
|
||||
let pos = (PxVec2::from_f32(ctx.data.pos) + container_pos - id_pos).to_f32();
|
||||
let size = region.size().to_f32();
|
||||
select(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
@@ -70,8 +72,8 @@ fn select(
|
||||
if let Some(region) = render.window_region(&id) {
|
||||
state.window.set_ime_allowed(true);
|
||||
state.window.set_ime_cursor_area(
|
||||
LogicalPosition::<f32>::from(region.top_left.tuple()),
|
||||
LogicalSize::<f32>::from(region.size().tuple()),
|
||||
LogicalPosition::<f32>::from(region.top_left.to_f32().tuple()),
|
||||
LogicalSize::<f32>::from(region.size().to_f32().tuple()),
|
||||
);
|
||||
}
|
||||
state.focus = Some(id);
|
||||
|
||||
@@ -198,7 +198,7 @@ impl SensorUi for UiRenderState {
|
||||
let Some(region) = region_of(id) else {
|
||||
continue;
|
||||
};
|
||||
if !cursor.exists || !region.contains(cursor.pos) {
|
||||
if !cursor.exists || !region.contains(PxVec2::from_f32(cursor.pos)) {
|
||||
continue;
|
||||
}
|
||||
hovered.now.push(id);
|
||||
@@ -249,8 +249,8 @@ fn deliver<Rsc: HasEvents>(
|
||||
region: PixelRegion,
|
||||
) -> bool {
|
||||
let data = CursorData {
|
||||
pos: cursor.pos - region.top_left,
|
||||
size: region.bot_right - region.top_left,
|
||||
pos: cursor.pos - region.top_left.to_f32(),
|
||||
size: region.size().to_f32(),
|
||||
scroll_delta: cursor.scroll_delta,
|
||||
hover,
|
||||
cursor: cursor.clone(),
|
||||
|
||||
+9
-3
@@ -29,8 +29,14 @@ macro_rules! assert_corners {
|
||||
assert_eq!(
|
||||
$harness.region(&$id).expect("widget drew nothing"),
|
||||
$crate::core::PixelRegion {
|
||||
top_left: $crate::core::util::Vec2::new($x0 as f32, $y0 as f32),
|
||||
bot_right: $crate::core::util::Vec2::new($x1 as f32, $y1 as f32),
|
||||
top_left: $crate::core::PxVec2::new(
|
||||
$crate::core::Px::from_f32($x0 as f32),
|
||||
$crate::core::Px::from_f32($y0 as f32),
|
||||
),
|
||||
bot_right: $crate::core::PxVec2::new(
|
||||
$crate::core::Px::from_f32($x1 as f32),
|
||||
$crate::core::Px::from_f32($y1 as f32),
|
||||
),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -151,7 +157,7 @@ impl Harness {
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Vec2 {
|
||||
self.render.output_size()
|
||||
self.render.output_size().to_f32()
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
||||
|
||||
+2
-5
@@ -113,14 +113,11 @@ impl Widget for Branch {
|
||||
let mut top = UiRegion::FULL;
|
||||
top.y.end = top.y.start.offset(Px::from_int(40));
|
||||
let measured = painter.widget_within(&self.probe, top).len(Axis::X);
|
||||
let px = measured
|
||||
.apply_leftover()
|
||||
.to_px(Px::from_f32(painter.px_len(Axis::X)))
|
||||
.to_f32();
|
||||
let px = measured.apply_leftover().to_px(painter.px_len(Axis::X));
|
||||
|
||||
let mut below = UiRegion::FULL;
|
||||
below.y.start = below.y.start.offset(Px::from_int(40));
|
||||
match px > self.threshold {
|
||||
match px > Px::from_f32(self.threshold) {
|
||||
true => painter.widget_within(&self.wide, below),
|
||||
false => painter.widget_within(&self.narrow, below),
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::prelude::*;
|
||||
pub struct Scroll {
|
||||
inner: StrongWidget,
|
||||
axis: Axis,
|
||||
amt: f32,
|
||||
amt: Px,
|
||||
snap_end: bool,
|
||||
container_len: f32,
|
||||
content_len: f32,
|
||||
container_len: Px,
|
||||
content_len: Px,
|
||||
}
|
||||
|
||||
impl Widget for Scroll {
|
||||
@@ -20,7 +20,7 @@ impl Widget for Scroll {
|
||||
};
|
||||
let content = answer_len.apply_leftover();
|
||||
self.container_len = container_len;
|
||||
self.content_len = content.to_px(Px::from_f32(container_len)).to_f32();
|
||||
self.content_len = content.to_px(container_len);
|
||||
|
||||
if self.snap_end {
|
||||
self.amt = self.content_len - self.container_len;
|
||||
@@ -35,22 +35,24 @@ impl Widget for Scroll {
|
||||
// the end, it moves with every length.
|
||||
let fixed_len = content.rel == Rel::ZERO;
|
||||
if fixed_len && self.content_len <= self.container_len && align == AxisAlign::NEG {
|
||||
painter.holds(self.axis, self.content_len..=f32::INFINITY);
|
||||
painter.holds(self.axis, self.content_len..=Px::MAX);
|
||||
} else if fixed_len && !self.snap_end {
|
||||
let left = self.content_len - self.amt;
|
||||
painter.holds(self.axis, f32::NEG_INFINITY..=left);
|
||||
painter.holds(self.axis, Px::MIN..=left);
|
||||
}
|
||||
|
||||
// Content shorter than the viewport has room to sit in, and where it
|
||||
// sits is this widget's own alignment -- the same property that would
|
||||
// have placed the whole scroll in a box longer than it.
|
||||
let slack = (self.container_len - self.content_len).max(0.0);
|
||||
let anchor = slack * align.rel().to_f32();
|
||||
let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, anchor - self.amt, 0.0));
|
||||
region.axis_mut(self.axis).end = region
|
||||
.axis(self.axis)
|
||||
.start
|
||||
.offset(Px::from_f32(self.content_len));
|
||||
let slack = (self.container_len - self.content_len).max(Px::ZERO);
|
||||
let anchor = slack.mul(align.rel());
|
||||
let offset = UiVec2::from_axis(
|
||||
self.axis,
|
||||
UiScalar::from_parts(Rel::ZERO, anchor - self.amt),
|
||||
UiScalar::ZERO,
|
||||
);
|
||||
let mut region = UiRegion::FULL.offset(offset);
|
||||
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
|
||||
painter.widget_aligned(&self.inner, region, RegionAlign::NEAR);
|
||||
// What it occupies is its box, on both axes: it clips its content to
|
||||
// that box, so it can neither take less of one nor honestly ask for
|
||||
@@ -65,22 +67,24 @@ impl Scroll {
|
||||
Self {
|
||||
inner,
|
||||
axis,
|
||||
amt: 0.0,
|
||||
amt: Px::ZERO,
|
||||
snap_end: true,
|
||||
container_len: 0.0,
|
||||
content_len: 0.0,
|
||||
container_len: Px::ZERO,
|
||||
content_len: Px::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_amt(&mut self) {
|
||||
self.amt = self.amt.max(0.0);
|
||||
let len = (self.content_len - self.container_len).max(0.0);
|
||||
self.amt = self.amt.max(Px::ZERO);
|
||||
let len = (self.content_len - self.container_len).max(Px::ZERO);
|
||||
self.amt = self.amt.min(len);
|
||||
self.snap_end = self.amt == len;
|
||||
}
|
||||
|
||||
/// Scrolled by a distance the platform measures, which is the last place
|
||||
/// a wheel notch or a finger is a float.
|
||||
pub fn scroll(&mut self, amt: f32) {
|
||||
self.amt -= amt;
|
||||
self.amt -= Px::from_f32(amt);
|
||||
self.update_amt();
|
||||
}
|
||||
}
|
||||
+19
-20
@@ -52,40 +52,39 @@ impl Widget for Span {
|
||||
);
|
||||
|
||||
// Whether anything is left over is a question in pixels: `rel(0.5)`
|
||||
// beside 300 px is full at 600 and overfull at 400. The room to divide
|
||||
// is `len * fixed - total.px`, and under `HOLDS_EPSILON_PX` of it is
|
||||
// none -- because the length where the room runs out is exactly the
|
||||
// box a parent sizing itself from this answer hands back, and that box
|
||||
// returns through the chain a few bits either way. Without the margin
|
||||
// 0.00003 px of rounding decides whether a leftover-only child is
|
||||
// drawn at all. The validity range is split at the moved boundary, and
|
||||
// exact there, since no box lands on it any more.
|
||||
// beside 300 px is full at 600 and overfull at 400. The room to
|
||||
// divide is `len * fixed - total.px`, and the length where it runs
|
||||
// out is exactly the box a parent sizing itself from this answer
|
||||
// hands back -- which is why this used to need a margin either side
|
||||
// of the boundary, and why it does not now: that box and this sum are
|
||||
// whole counts of the same step, and both routes to it land on the
|
||||
// same count. What the generated oracle checks is the consequence,
|
||||
// since which children exist at all turns on this.
|
||||
let fixed = Rel::ONE - total.rel;
|
||||
let margin = Px::from_f32(HOLDS_EPSILON_PX);
|
||||
let mut shares = false;
|
||||
if total.leftover > Weight::ZERO {
|
||||
let current = Px::from_f32(painter.px_len(axis));
|
||||
let current = painter.px_len(axis);
|
||||
let holds = if fixed > Rel::ZERO {
|
||||
// The box length at which the room reaches the margin.
|
||||
let enough = (total.px + margin).div(fixed);
|
||||
shares = current > enough;
|
||||
// The box length the fixed parts alone fill.
|
||||
let full = total.px.div(fixed);
|
||||
shares = current > full;
|
||||
match shares {
|
||||
true => Holds::exact(enough.to_f32().next_up()..=f32::INFINITY),
|
||||
false => Holds::exact(f32::NEG_INFINITY..=enough.to_f32()),
|
||||
true => Holds::from(full.next_up()..=Px::MAX),
|
||||
false => Holds::from(Px::MIN..=full),
|
||||
}
|
||||
} else if fixed < Rel::ZERO {
|
||||
// The relative parts grow faster than the box does, so here
|
||||
// a shorter box is the one that leaves room.
|
||||
let enough = (total.px + margin).div(fixed);
|
||||
shares = current < enough;
|
||||
let full = total.px.div(fixed);
|
||||
shares = current < full;
|
||||
match shares {
|
||||
true => Holds::exact(f32::NEG_INFINITY..=enough.to_f32().next_down()),
|
||||
false => Holds::exact(enough.to_f32()..=f32::INFINITY),
|
||||
true => Holds::from(Px::MIN..=full.next_down()),
|
||||
false => Holds::from(full..=Px::MAX),
|
||||
}
|
||||
} else {
|
||||
// The relative parts take exactly the box, whatever it is, so
|
||||
// the only room is what negative pixels leave.
|
||||
shares = total.px < margin.neg();
|
||||
shares = total.px < Px::ZERO;
|
||||
Holds::ANY
|
||||
};
|
||||
painter.holds(axis, holds);
|
||||
|
||||
@@ -276,7 +276,13 @@ impl<'a> TextEditCtx<'a> {
|
||||
}
|
||||
|
||||
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
|
||||
let pos = pos - self.text.region().top_left().to_px(size);
|
||||
let pos = pos
|
||||
- self
|
||||
.text
|
||||
.region()
|
||||
.top_left()
|
||||
.to_px(PxVec2::from_f32(size))
|
||||
.to_f32();
|
||||
let prev_sel = self.text.selection;
|
||||
let prev_hit = self.text.double_hit;
|
||||
|
||||
|
||||
@@ -48,13 +48,15 @@ impl TextView {
|
||||
/// rather than invalidating anything.
|
||||
fn render(&mut self, painter: &mut Painter) -> &RenderedText {
|
||||
let width = self.attrs.wrap.then(|| painter.px_len(Axis::X));
|
||||
let text = painter.render_text(&mut self.buf, &self.attrs, width);
|
||||
// The shaper measures in floats, which is where a glyph advance comes
|
||||
// from; what it answers goes back on the grid.
|
||||
let text = painter.render_text(&mut self.buf, &self.attrs, width.map(Px::to_f32));
|
||||
// A greedy break is the same break at every width from its longest
|
||||
// line up to the one it was made at: each line still fits, and none
|
||||
// could take a word that did not fit in the wider box. A line too
|
||||
// long to fit at all says nothing about narrower boxes.
|
||||
if let Some(width) = width {
|
||||
painter.holds(Axis::X, text.size.x.min(width)..=width);
|
||||
painter.holds(Axis::X, Px::from_f32(text.size.x).min(width)..=width);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user