teksilo_core/pointer/hit_slop.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Hit slop — the *miss-only* re-attribution of a press to a small target it
5//! nearly landed on, and the context every hit test carries.
6//!
7//! # Three mechanisms, three domains
8//!
9//! Teksilo widens hit targets in three places, and they do not overlap. Pick by
10//! what the widget's problem actually is:
11//!
12//! | mechanism | where it runs | for |
13//! | --- | --- | --- |
14//! | [`Widget::target_regions`] | reporting only | a control painted as sub-regions of ONE leaf node — the scroll-bar thumb, the slider thumb |
15//! | [`Widget::hit_outset`] | **inside** the exact pass | a thin grip that must win over what it overlaps — a splitter gutter, a column-resize strip |
16//! | [`Widget::hit_distance`] + this module | **only after** the exact pass missed | an isolated small target — a radio dot, a chart mark |
17//!
18//! A grip needs `hit_outset` because it must *beat* its neighbours, and the
19//! slop pass never beats anything the exact pass found. A radio dot needs the
20//! slop pass because widening its rectangle would steal presses from the row
21//! it sits in. Nothing needs both.
22//!
23//! # The size formula
24//!
25//! A node earns an outset of
26//!
27//! ```text
28//! ((up_to − min(width, height)) / 2).clamp(0, radius)
29//! ```
30//!
31//! so a target that is already at least `up_to` on its smaller axis earns
32//! **nothing**: the mechanism is for small controls, and a full-viewport scrim
33//! or a list row is excluded by arithmetic rather than by a rule. `radius`
34//! comes from the pointer's gesture profile (0 dp mouse / 8 dp touch / 2 dp
35//! pen) and is capped for a coarse pointer by `InputTokens::slop_budget`
36//! (12 / 12 / 16 dp). `up_to` is the density's `target_size`.
37//!
38//! Because the mouse profile's radius is `0.0`, **every mouse hit test is
39//! exactly the hit test Teksilo has always run** — the whole pass short-circuits
40//! before it walks anything.
41//!
42//! Reference: `docs/density-and-targets.md`.
43//!
44//! [`Widget::target_regions`]: crate::widget::Widget::target_regions
45//! [`Widget::hit_outset`]: crate::widget::Widget::hit_outset
46//! [`Widget::hit_distance`]: crate::widget::Widget::hit_distance
47
48use teksilo_canvas::{Point, Rect, Size, Transform2D};
49use teksilo_tokens::{InputTokens, PointerKind, TargetDensity};
50
51use crate::environment::LayoutDirection;
52use crate::widget_id::WidgetId;
53
54/// The token set the plain, pointer-less hit-test doors read.
55///
56/// [`WidgetArena::hit_test_at`](crate::arena::WidgetArena::hit_test_at) has no
57/// theme to consult, so it reads the Compact ladder — which is the identity for
58/// every hit-targeting mechanism (mouse radius `0.0`, grip outset `0.0`).
59/// Callers that *do* hold a theme (`WidgetTree`) pass the live tokens instead.
60static COMPACT_TOKENS: InputTokens = InputTokens::for_density(TargetDensity::Compact);
61
62/// How far a *miss* may be re-attributed to a target, and up to what target
63/// size the offer stands.
64///
65/// `radius` is a hard ceiling in dp; `up_to` is the size a target is topped up
66/// *towards*. See the module docs for the formula and why a large node earns
67/// nothing.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct HitSlop {
70 /// The largest outset any node may earn, in dp. `0.0` disables the
71 /// mechanism outright.
72 pub radius: f32,
73 /// The target size a node is topped up towards, in dp. A node already at
74 /// least this big on its smaller axis earns no outset at all.
75 pub up_to: f32,
76}
77
78impl HitSlop {
79 /// No slop at all — the mouse's value, and what
80 /// [`no_hit_slop`](crate::widget_builder::HandlerSet::no_hit_slop) resolves
81 /// to.
82 pub const NONE: HitSlop = HitSlop {
83 radius: 0.0,
84 up_to: 0.0,
85 };
86
87 /// The density default for a pointer kind: the kind's profile `hit_slop`,
88 /// capped for a **coarse** pointer by `InputTokens::slop_budget`, topped up
89 /// towards `InputTokens::target_size`.
90 ///
91 /// A precise pointer (mouse, pen) never draws on the budget — its radius is
92 /// already the tool's, and the budget describes a contact patch it does not
93 /// have. The mouse's profile radius is `0.0`, so this returns
94 /// [`NONE`](Self::NONE) for a mouse at every density.
95 pub fn for_pointer(kind: PointerKind, tokens: &InputTokens) -> Self {
96 let profile_radius = tokens.profile(kind).hit_slop;
97 let radius = if kind.is_coarse() {
98 profile_radius.min(tokens.slop_budget)
99 } else {
100 profile_radius
101 };
102 Self {
103 radius: sanitize(radius),
104 up_to: sanitize(tokens.target_size),
105 }
106 }
107
108 /// Whether this slop can ever produce an outset.
109 pub fn is_none(&self) -> bool {
110 self.radius <= 0.0 || self.up_to <= 0.0
111 }
112
113 /// The outset a node of `size` earns:
114 /// `((up_to − min(w, h)) / 2).clamp(0, radius)`.
115 ///
116 /// Zero for any node already at least `up_to` on its smaller axis, and zero
117 /// for a degenerate (non-finite, negative) size.
118 pub fn outset_for(&self, size: Size) -> f32 {
119 let radius = sanitize(self.radius);
120 if radius <= 0.0 {
121 return 0.0;
122 }
123 let smaller = sanitize(size.width.min(size.height));
124 ((sanitize(self.up_to) - smaller) / 2.0).clamp(0.0, radius)
125 }
126}
127
128/// Replace a non-finite or negative value with `0.0`.
129///
130/// Every arithmetic path below feeds `f32::clamp`, which **panics** when its
131/// bounds are `NaN` or out of order, so the guard is load-bearing rather than
132/// defensive: a widget that measures to `NaN` under a degenerate proposal must
133/// not take the process down through the hit test.
134fn sanitize(v: f32) -> f32 {
135 if v.is_finite() && v > 0.0 { v } else { 0.0 }
136}
137
138/// Euclidean distance from `point` to the nearest edge of `rect`, `0.0` when it
139/// is inside. The default [`Widget::hit_distance`] shape.
140///
141/// [`Widget::hit_distance`]: crate::widget::Widget::hit_distance
142pub fn rect_distance(rect: Rect, point: Point) -> f32 {
143 let dx = (rect.x - point.x).max(point.x - rect.right()).max(0.0);
144 let dy = (rect.y - point.y).max(point.y - rect.bottom()).max(0.0);
145 (dx * dx + dy * dy).sqrt()
146}
147
148/// Euclidean distance from `point` to a disc, `0.0` when it is inside.
149///
150/// The shape a round control — a radio dot, a slider knob, a colour-strip thumb
151/// — reports from [`Widget::hit_distance`] so its slop follows the silhouette
152/// the user sees rather than the square it is laid out in.
153///
154/// [`Widget::hit_distance`]: crate::widget::Widget::hit_distance
155pub fn circle_distance(center: Point, radius: f32, point: Point) -> f32 {
156 let dx = point.x - center.x;
157 let dy = point.y - center.y;
158 ((dx * dx + dy * dy).sqrt() - radius.max(0.0)).max(0.0)
159}
160
161/// The **minimum singular value** of a transform's linear part — the factor by
162/// which it shrinks the axis it shrinks most.
163///
164/// Closed form for a 2×2: with `E = (a+d)/2`, `F = (a−d)/2`, `G = (b+c)/2`,
165/// `H = (b−c)/2`, the singular values are `hypot(E,H) ± hypot(F,G)`.
166///
167/// The slop pass measures distance in a node's own local space and compares it
168/// against a radius quoted in screen dp, so it needs one number for "how much
169/// bigger is a local unit on screen". σ<sub>min</sub> is the conservative choice
170/// *in the user's favour*: under an anisotropic transform it is the axis along
171/// which local distance buys the fewest screen pixels, so the reach never comes
172/// out shorter than the token promised on any axis. A pure rotation returns
173/// `1.0`; a singular transform returns `0.0` (its subtree is invisible, so it
174/// takes no candidates).
175pub fn min_singular_value(t: &Transform2D) -> f32 {
176 let [a, b, c, d, _, _] = t.m;
177 let e = (a + d) / 2.0;
178 let f = (a - d) / 2.0;
179 let g = (b + c) / 2.0;
180 let h = (b - c) / 2.0;
181 let q = e.hypot(h);
182 let r = f.hypot(g);
183 let sigma_min = (q - r).abs();
184 if sigma_min.is_finite() {
185 sigma_min
186 } else {
187 0.0
188 }
189}
190
191/// One node the miss-only slop pass considered, and how far away it was.
192///
193/// Produced by
194/// [`WidgetArena::hit_candidates`](crate::arena::WidgetArena::hit_candidates),
195/// which is public so a test — and the target-conformance audit — can inspect
196/// the pass's reasoning rather than only its verdict.
197#[derive(Debug, Clone, Copy, PartialEq)]
198pub struct HitCandidate {
199 /// The node that offered itself.
200 pub id: WidgetId,
201 /// Distance from the pointer to the node's **uninflated** shape, in screen
202 /// dp (a local distance under a transform is converted by
203 /// [`min_singular_value`]).
204 pub distance: f32,
205 /// The outset this node earned from its resolved [`HitSlop`], in screen dp.
206 /// A candidate is only ever reported when `distance <= outset`.
207 pub outset: f32,
208}
209
210/// Everything a hit test needs to know that the arena cannot work out alone:
211/// which pointer is asking, which token ladder is in force, which way the UI
212/// reads, and which nodes refuse edits.
213///
214/// Built once per hit test and passed by reference through the recursion.
215/// [`HitContext::mouse`] is the pointer-less door — an exact mouse hit test on
216/// the Compact ladder, which is what every call site that predates the touch
217/// programme has always meant.
218pub struct HitContext<'a> {
219 kind: PointerKind,
220 tokens: &'a InputTokens,
221 direction: LayoutDirection,
222 slop: HitSlop,
223 read_only: Option<&'a dyn Fn(WidgetId) -> bool>,
224}
225
226impl std::fmt::Debug for HitContext<'_> {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 f.debug_struct("HitContext")
229 .field("kind", &self.kind)
230 .field("density", &self.tokens.density)
231 .field("direction", &self.direction)
232 .field("slop", &self.slop)
233 .field("has_read_only_probe", &self.read_only.is_some())
234 .finish()
235 }
236}
237
238impl<'a> HitContext<'a> {
239 /// A hit test on behalf of `kind` against `tokens`, left-to-right, with no
240 /// read-only probe.
241 pub fn new(kind: PointerKind, tokens: &'a InputTokens) -> Self {
242 Self {
243 kind,
244 tokens,
245 direction: LayoutDirection::LeftToRight,
246 slop: HitSlop::for_pointer(kind, tokens),
247 read_only: None,
248 }
249 }
250
251 /// The pointer-less door: **mouse, exact**, Compact ladder.
252 ///
253 /// This is what [`WidgetArena::hit_test_at`](crate::arena::WidgetArena::hit_test_at)
254 /// and the inspector's picker use. The mouse's slop radius is `0.0` at every
255 /// density, so the ladder choice is unobservable — it matters only to a
256 /// widget that opts a *precise* pointer into a hit outset.
257 pub fn mouse() -> HitContext<'static> {
258 HitContext::new(PointerKind::Mouse, &COMPACT_TOKENS)
259 }
260
261 /// Set the reading direction, so a widget's `leading` / `trailing` hit
262 /// outsets land on the right screen edges.
263 pub fn direction(mut self, direction: LayoutDirection) -> Self {
264 self.direction = direction;
265 self
266 }
267
268 /// Turn the miss-only slop pass **off** while keeping the pointer kind, so
269 /// the exact pass — including every widget's [`Widget::hit_outset`] for that
270 /// kind — is all that runs.
271 ///
272 /// The one consumer is the target-conformance audit
273 /// ([`target_audit`](crate::accessibility::target_audit)), which measures a
274 /// control's reach twice: once through this door and once through the full
275 /// one, and attributes the difference to the mechanism that produced it. A
276 /// harness that could not tell the two apart would certify a target and be
277 /// unable to say what makes it reachable.
278 ///
279 /// [`Widget::hit_outset`]: crate::widget::Widget::hit_outset
280 pub fn without_slop(mut self) -> Self {
281 self.slop = HitSlop::NONE;
282 self
283 }
284
285 /// Install a probe the slop pass calls to ask whether a node refuses edits.
286 ///
287 /// A read-only surface is never a slop candidate, and "read-only" is a
288 /// question only [`TextSurface`](crate::text_surface::TextSurface) can
289 /// answer — the registry lives on the `WidgetTree`, not the arena, so the
290 /// tree hands the arena a probe rather than the arena reaching for state it
291 /// does not own.
292 pub fn read_only_probe(mut self, probe: &'a dyn Fn(WidgetId) -> bool) -> Self {
293 self.read_only = Some(probe);
294 self
295 }
296
297 /// The pointer kind this test serves.
298 pub fn kind(&self) -> PointerKind {
299 self.kind
300 }
301
302 /// The token ladder in force.
303 pub fn tokens(&self) -> &InputTokens {
304 self.tokens
305 }
306
307 /// The reading direction.
308 pub fn layout_direction(&self) -> LayoutDirection {
309 self.direction
310 }
311
312 /// The density default slop for this pointer — the last link of the
313 /// precedence chain.
314 pub fn default_slop(&self) -> HitSlop {
315 self.slop
316 }
317
318 /// Whether the miss-only pass can produce anything at all. `false` for a
319 /// mouse, which is what keeps the historical path free of new work.
320 pub fn slop_enabled(&self) -> bool {
321 !self.slop.is_none()
322 }
323
324 /// Ask the installed probe whether `id` refuses edits. `false` when no
325 /// probe was installed.
326 pub fn is_read_only(&self, id: WidgetId) -> bool {
327 self.read_only.map(|p| p(id)).unwrap_or(false)
328 }
329}
330
331#[cfg(test)]
332mod tests;