azul_core/spaces.rs
1//! Pointer coordinate spaces, as distinct types.
2//!
3//! # Why this module exists
4//!
5//! Five different "cursor position" conventions coexist in this codebase and,
6//! until this module, every one of them was a bare
7//! [`LogicalPosition`](crate::geom::LogicalPosition). Nothing stopped a value
8//! from one convention being handed to a consumer expecting another, and the
9//! resulting bugs are all invisible in the default widget set (which happens to
10//! use unpadded, unbordered, unscrolled text boxes) and appear the moment a
11//! real app pads an editable or scrolls a field.
12//!
13//! # The five spaces
14//!
15//! Let, for one node in one DOM:
16//!
17//! * `P` = the node's STATIC border-box origin — what `calculated_positions`
18//! stores. "Static" means *before* any scroll offset is applied.
19//! * `A` = the sum of every scrolling ANCESTOR's current offset.
20//! * `S` = the node's OWN current scroll offset.
21//! * `E` = the node's content inset, `padding-left + border-left` /
22//! `padding-top + border-top` (see [`ContentInset`]).
23//!
24//! The raster paints a glyph whose inline-layout position is `g` at window
25//! position `P + E + g − S − A`. Inverting that one equation names every space:
26//!
27//! | # | Type | Value | Who produces it |
28//! |---|------|-------|-----------------|
29//! | 1 | [`WindowPoint`] | `w` | the platform cursor event |
30//! | 2 | [`StaticLayoutPoint`] | `w + A` | `CpuHitTester::hit_test_scrolled`, `headless::resolve_chain` |
31//! | 3 | [`BorderBoxLocal`] | `w + A − P` | `WebRender`'s `point_relative_to_item` |
32//! | 4 | [`ContentBoxLocal`] | `w + A − P − E` | [`BorderBoxLocal::to_content_box_local`] |
33//! | 5 | [`ScrolledContentPoint`] | `w + A − P − E + S` | [`ContentBoxLocal::scrolled_by`] — the ONLY space `UnifiedLayout::hittest_cursor` accepts |
34//!
35//! The historical sixth convention — "static layout space with the node's own
36//! scroll added back too" (`w + A + S`, what the self-inclusive scroll walkers
37//! produced) — is deliberately **not** a type here. It is the mixed space that
38//! caused the bugs: own scroll belongs to the node's *content*, so it may only
39//! be added once the point is already node-local AND content-box-relative.
40//! With this vocabulary that combination is unreachable: [`scrolled_by`] exists
41//! only on [`ContentBoxLocal`].
42//!
43//! [`scrolled_by`]: ContentBoxLocal::scrolled_by
44//!
45//! # Cost
46//!
47//! Every type here is `#[repr(transparent)]` over `LogicalPosition` and every
48//! conversion is a `const fn` doing at most two `f32` adds, so the vocabulary
49//! is free at runtime and ABI-identical to the bare position it replaces.
50
51use crate::geom::LogicalPosition;
52
53/// A scroll offset, i.e. how far a scroll container's content has been moved
54/// UP/LEFT relative to its scrollport.
55///
56/// Distinct from a position so that "add the scroll" and "add a position"
57/// cannot be confused, and so the two very different sums — ancestors-only vs
58/// self-and-ancestors — are at least visible at the call site.
59#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
60#[repr(transparent)]
61pub struct ScrollOffset(pub LogicalPosition);
62
63impl ScrollOffset {
64 /// The zero offset (nothing scrolled).
65 #[inline]
66 #[must_use]
67 pub const fn zero() -> Self {
68 Self(LogicalPosition::zero())
69 }
70
71 /// Build an offset from raw components.
72 #[inline]
73 #[must_use]
74 pub const fn new(x: f32, y: f32) -> Self {
75 Self(LogicalPosition { x, y })
76 }
77
78 /// The raw offset.
79 #[inline]
80 #[must_use]
81 pub const fn get(self) -> LogicalPosition {
82 self.0
83 }
84
85 /// Accumulate another container's offset into this one.
86 #[inline]
87 #[must_use]
88 pub const fn plus(self, other: Self) -> Self {
89 Self(LogicalPosition {
90 x: self.0.x + other.0.x,
91 y: self.0.y + other.0.y,
92 })
93 }
94}
95
96/// The left/top inset from a node's BORDER box to its CONTENT box:
97/// `padding-left + border-left-width` and `padding-top + border-top-width`.
98///
99/// This is the `E` term in the module docs. It is the difference between the
100/// box layout positions the node (border box, `calculated_positions`) and the
101/// box inline text is laid out in (content box) — which is exactly the term
102/// that used to be silently missing on one of the two hit-test hosts.
103#[derive(Debug, Copy, Clone, Default, PartialEq)]
104#[repr(C)]
105pub struct ContentInset {
106 /// `padding-left + border-left-width`
107 pub left: f32,
108 /// `padding-top + border-top-width`
109 pub top: f32,
110}
111
112impl ContentInset {
113 /// No padding and no border — the content box IS the border box.
114 pub const ZERO: Self = Self {
115 left: 0.0,
116 top: 0.0,
117 };
118
119 /// Build an inset from the already-summed left/top edges.
120 #[inline]
121 #[must_use]
122 pub const fn new(left: f32, top: f32) -> Self {
123 Self { left, top }
124 }
125}
126
127/// Declare a `#[repr(transparent)]` point newtype with the shared boilerplate.
128macro_rules! point_space {
129 ($(#[$meta:meta])* $name:ident) => {
130 $(#[$meta])*
131 // The full set `LogicalPosition` itself carries (its Eq/Ord/Hash are
132 // quantized, so they agree with each other), so a typed point can go
133 // wherever an untyped one used to — including as a BTreeMap value in a
134 // derived-Ord struct.
135 #[derive(Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
136 #[repr(transparent)]
137 pub struct $name(LogicalPosition);
138
139 impl $name {
140 /// Assert that `p` is already in this space.
141 ///
142 /// Only correct at a PRODUCER boundary — the place that computed
143 /// the point and therefore knows which space it is in. Everywhere
144 /// else, use one of the named conversions instead; that is the
145 /// entire point of this module.
146 #[inline]
147 #[must_use]
148 pub const fn new(p: LogicalPosition) -> Self {
149 Self(p)
150 }
151
152 /// The origin of this space.
153 #[inline]
154 #[must_use]
155 pub const fn zero() -> Self {
156 Self(LogicalPosition::zero())
157 }
158
159 /// Drop back to an untyped position.
160 ///
161 /// Only correct at a CONSUMER boundary that documents which space
162 /// it wants.
163 #[inline]
164 #[must_use]
165 pub const fn get(self) -> LogicalPosition {
166 self.0
167 }
168
169 /// The x component, in this space.
170 #[inline]
171 #[must_use]
172 pub const fn x(self) -> f32 {
173 self.0.x
174 }
175
176 /// The y component, in this space.
177 #[inline]
178 #[must_use]
179 pub const fn y(self) -> f32 {
180 self.0.y
181 }
182 }
183 };
184}
185
186point_space! {
187 /// **Space 1** — a raw pointer position in window coordinates, exactly as
188 /// the platform delivered it. Nothing has been unwound.
189 WindowPoint
190}
191
192point_space! {
193 /// **Space 2** — a window point mapped into a DOM's STATIC layout
194 /// coordinate system: every scrolling ANCESTOR's offset added back (and
195 /// any ancestor transform inverted).
196 ///
197 /// This is the space `calculated_positions` lives in, so a
198 /// `StaticLayoutPoint` may be compared against a node's static rect. It is
199 /// what `CpuHitTester::hit_test_scrolled` returns and what
200 /// `headless::resolve_chain`'s `map_screen_to_local` produces.
201 ///
202 /// It does NOT include the node's own scroll offset: a container's own
203 /// scrolling moves its CONTENT, never its box.
204 StaticLayoutPoint
205}
206
207point_space! {
208 /// **Space 3** — relative to a node's static BORDER-box origin, own scroll
209 /// NOT applied.
210 ///
211 /// This is what `WebRender` reports as `point_relative_to_item`: azul
212 /// pushes a scroll container's hit rect BEFORE its scroll frame, so the
213 /// point WR subtracts the rect from is in the parent's (unscrolled) space.
214 ///
215 /// It is also the space the public `CallbackInfo::get_cursor_relative_to_node`
216 /// promises, which is why widgets that divide by the node's border-box
217 /// width (sliders, split panes, colour wheels, map panning) are correct
218 /// against it.
219 BorderBoxLocal
220}
221
222point_space! {
223 /// **Space 4** — relative to a node's static CONTENT-box origin, own
224 /// scroll NOT applied.
225 ///
226 /// Padding and border have been removed ([`ContentInset`]), so this is the
227 /// space inline text is laid out in — but only for an UNSCROLLED box.
228 ContentBoxLocal
229}
230
231point_space! {
232 /// **Space 5** — content-box-local WITH the node's own scroll added back:
233 /// a point in the node's scrollable CONTENT.
234 ///
235 /// This is the only space `UnifiedLayout::hittest_cursor` accepts, because
236 /// the inline layout is built once, unscrolled, and the scroll frame moves
237 /// it at paint time. Producing it requires all four of: ancestor scroll,
238 /// the node's static origin, its content inset, and its own scroll — and
239 /// the conversion chain in this module is the only way to have supplied
240 /// all four.
241 ScrolledContentPoint
242}
243
244impl WindowPoint {
245 /// Map into the DOM's static layout space by adding back the accumulated
246 /// scroll of the node's ANCESTORS (`w → w + A`).
247 ///
248 /// Pass an ancestors-only sum ([`Inclusivity::AncestorsOnly`]). Passing a
249 /// self-inclusive sum here is the classic double-count.
250 #[inline]
251 #[must_use]
252 pub const fn to_static_layout(self, ancestor_scroll: ScrollOffset) -> StaticLayoutPoint {
253 StaticLayoutPoint(LogicalPosition {
254 x: self.0.x + ancestor_scroll.0.x,
255 y: self.0.y + ancestor_scroll.0.y,
256 })
257 }
258}
259
260impl StaticLayoutPoint {
261 /// Back to window space (`w + A → w`).
262 #[inline]
263 #[must_use]
264 pub const fn to_window(self, ancestor_scroll: ScrollOffset) -> WindowPoint {
265 WindowPoint(LogicalPosition {
266 x: self.0.x - ancestor_scroll.0.x,
267 y: self.0.y - ancestor_scroll.0.y,
268 })
269 }
270
271 /// Make the point node-local by subtracting the node's STATIC border-box
272 /// origin (`w + A → w + A − P`).
273 #[inline]
274 #[must_use]
275 pub const fn to_border_box_local(self, border_box_origin: LogicalPosition) -> BorderBoxLocal {
276 BorderBoxLocal(LogicalPosition {
277 x: self.0.x - border_box_origin.x,
278 y: self.0.y - border_box_origin.y,
279 })
280 }
281}
282
283impl BorderBoxLocal {
284 /// Back to the DOM's static layout space (`w + A − P → w + A`).
285 #[inline]
286 #[must_use]
287 pub const fn to_static_layout(self, border_box_origin: LogicalPosition) -> StaticLayoutPoint {
288 StaticLayoutPoint(LogicalPosition {
289 x: self.0.x + border_box_origin.x,
290 y: self.0.y + border_box_origin.y,
291 })
292 }
293
294 /// Step in from the border box to the content box (`… − P → … − P − E`).
295 #[inline]
296 #[must_use]
297 pub const fn to_content_box_local(self, inset: ContentInset) -> ContentBoxLocal {
298 ContentBoxLocal(LogicalPosition {
299 x: self.0.x - inset.left,
300 y: self.0.y - inset.top,
301 })
302 }
303}
304
305impl ContentBoxLocal {
306 /// Step back out to the border box (`… − P − E → … − P`).
307 #[inline]
308 #[must_use]
309 pub const fn to_border_box_local(self, inset: ContentInset) -> BorderBoxLocal {
310 BorderBoxLocal(LogicalPosition {
311 x: self.0.x + inset.left,
312 y: self.0.y + inset.top,
313 })
314 }
315
316 /// Add back the node's OWN scroll offset to reach the point in its
317 /// scrollable content (`… − P − E → … − P − E + S`).
318 ///
319 /// Pass the node's own offset only. This is the step both hit-test hosts
320 /// used to skip, which is why clicking in a horizontally scrolled text
321 /// field placed the caret `scroll_x` px to the left of the pointer.
322 #[inline]
323 #[must_use]
324 pub const fn scrolled_by(self, own_scroll: ScrollOffset) -> ScrolledContentPoint {
325 ScrolledContentPoint(LogicalPosition {
326 x: self.0.x + own_scroll.0.x,
327 y: self.0.y + own_scroll.0.y,
328 })
329 }
330}
331
332impl ScrolledContentPoint {
333 /// Remove the node's own scroll again (`… + S → …`).
334 #[inline]
335 #[must_use]
336 pub const fn unscrolled_by(self, own_scroll: ScrollOffset) -> ContentBoxLocal {
337 ContentBoxLocal(LogicalPosition {
338 x: self.0.x - own_scroll.0.x,
339 y: self.0.y - own_scroll.0.y,
340 })
341 }
342
343 /// Clamp into `0 ..= size`, staying in this space.
344 ///
345 /// Used when a drag leaves the block: the nearest line is wanted, not a
346 /// miss.
347 #[inline]
348 #[must_use]
349 pub const fn clamp_to(self, width: f32, height: f32) -> Self {
350 Self(LogicalPosition {
351 x: self.0.x.clamp(0.0, width.max(0.0)),
352 y: self.0.y.clamp(0.0, height.max(0.0)),
353 })
354 }
355}
356
357/// Whether a tree walk starts at the node itself or at its parent.
358///
359/// Five different ancestor walks in this codebase encoded this choice in a
360/// loop's starting value, so the difference between "the caret's own scroll
361/// box" and "the scroll box around it" was invisible at the call site — and
362/// the two mirror-image helper pairs (`accumulated_scroll_for_node` vs
363/// `node_rect_to_screen`, `find_scrollable_ancestor` vs `find_scroll_parent`)
364/// had names that gave no hint which was which.
365#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
366pub enum Inclusivity {
367 /// Start at the node itself. Correct when the node's own scrolling is part
368 /// of the answer: how far this box's content has moved, or which box a
369 /// caret inside it lives in.
370 SelfAndAncestors,
371 /// Start at the node's parent. Correct when the answer is about where the
372 /// node's BOX sits, or which OTHER container should take over: a
373 /// container's own scrolling never moves its own box, and momentum must
374 /// chain outwards, not back into itself.
375 AncestorsOnly,
376}
377
378impl Inclusivity {
379 /// Whether the walk visits the starting node.
380 #[inline]
381 #[must_use]
382 pub const fn includes_self(self) -> bool {
383 matches!(self, Self::SelfAndAncestors)
384 }
385}
386
387#[cfg(test)]
388#[path = "spaces_test.rs"]
389mod spaces_test;