Skip to main content

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;