teksilo_scene/pick.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! One paint order, read by every picker.
5//!
6//! A [`SceneView`](crate::SceneView) paints in three passes: the lightweight
7//! [`SceneLayer::Under`](crate::SceneLayer) band, then the heavyweight widget
8//! children (the arena's child walk), then the lightweight
9//! [`Over`](crate::SceneLayer::Over) band. That is a **total order across both
10//! tiers**, and [`PaintKey`] is that order named as a value, so a hit test can
11//! be written as a comparison instead of as a second, independently-invented
12//! rule.
13//!
14//! # What is unified here, and what is not
15//!
16//! **The ordering is unified; the narrow phase is shared but not centralised.**
17//! A hit test runs on the pointer's hot path, where the scene's `RefCell` must
18//! not be re-entered (an `ItemChange` observer can be mid-mutation), so the two
19//! dispatch entry points keep their own per-layout snapshots rather than
20//! calling back into [`Scene`](crate::Scene). What stops them diverging is that
21//! every one of them compares [`PaintKey`]s and narrow-phases through the same
22//! [`ItemShape`](crate::ItemShape) value the scene itself would use — not that
23//! they share one function.
24//!
25//! The five places that used to answer "what did the pointer hit?" their own
26//! way now all read this module:
27//!
28//! | site | what it reads |
29//! |------|---------------|
30//! | `Scene::item_at` / `items_at` | [`Scene::paint_key`](crate::Scene::paint_key), descending |
31//! | the `SceneView` tap / hover / cursor snapshot | `HandlerSnapshotEntry::key`, descending |
32//! | the `SceneView` drag snapshot | `DraggableSnapshotEntry::key`, descending |
33//! | `SceneView::paint_band` | [`Scene::paint_key`](crate::Scene::paint_key), ascending |
34//! | the arena's own walk | the [`Over`](crate::SceneLayer::Over) veto, below |
35//!
36//! A **sixth** picker is deliberately left out of that table: the magnet probe
37//! in `view/gestures_impl.rs` is still ordered by *distance*, because a magnet
38//! handle is grabbed by proximity, runs before the item hit test and takes
39//! priority over it. Folding it into the paint order is a separate decision
40//! with its own owner.
41//!
42//! # The occlusion rule
43//!
44//! **Occlusion follows press-claiming, not painting.** A lightweight entry
45//! takes a press away from a heavyweight card only if it would *act on that
46//! press* — a tap, a double-tap, a context menu, or a drag. See
47//! [`claims_press`]. A decorative halo drawn over an embedded note claims
48//! nothing, so it never vetoes, and clicking it leaves the note pressed and
49//! focused.
50//!
51//! This is deliberately **not** the widget tier's rule, where a plain
52//! `RectWidget` on top of a `Button` absorbs the click. It matches Qt's
53//! `setAcceptedMouseButtons(NoButton)` and Godot's `mouse_filter = IGNORE`, and
54//! it is what keeps a scene whose foreground is decorative working exactly as
55//! it did.
56//!
57//! **A hover affordance is not a press claim.** `on_hover`, `cursor` and
58//! `tooltip` fire on a pointer that merely *passes over*, and none of them is
59//! the beginning of a press. Treating one as a claim looks conservative and is
60//! the opposite: the card leaves the arena's walk, the view becomes the target,
61//! the tap resolves to an item with no `on_tap`, and the click reaches nobody
62//! at all. They are served instead by the view's own hover seam, which runs in
63//! the **preview** pass — on every strict ancestor of the target — and so
64//! reaches the item whether a card won the walk or not.
65//!
66//! Note what the rule does **not** change: inside the lightweight tier the
67//! topmost *entry* still wins, and only then are its handlers consulted. A
68//! handler-less item painted on top of a handler-bearing one still blocks it,
69//! as it always has. Press-claiming decides one thing only — whether the
70//! lightweight tier gets to veto a heavyweight card.
71//!
72//! # What this rule does *not* reach
73//!
74//! It governs the **pointer**: the arena's hit test, and therefore press
75//! feedback, focus-on-release, the touch hold route, the cursor and the view's
76//! own drag recognizer. It does not touch the AccessKit tree, and a platform
77//! explore-by-touch probe resolves through that tree, not through this one. The
78//! two can therefore still disagree about what is under a point — see
79//! `docs/teksilo-scene-a11y.md`, "Where the pointer and the AT probe still
80//! disagree".
81
82use crate::flags::ItemFlags;
83use crate::item_handlers::SceneItemHandlerSet;
84
85/// The [`PaintKey::rank`] of a lightweight item in the
86/// [`Under`](crate::SceneLayer::Under) band.
87pub const RANK_UNDER: u8 = 0;
88/// The [`PaintKey::rank`] of a heavyweight widget entry.
89pub const RANK_WIDGET: u8 = 1;
90/// The [`PaintKey::rank`] of a lightweight item in the
91/// [`Over`](crate::SceneLayer::Over) band.
92pub const RANK_OVER: u8 = 2;
93
94/// Where an entry sits in a [`SceneView`](crate::SceneView)'s **single** paint
95/// order.
96///
97/// | rank | contents |
98/// |------|----------|
99/// | [`RANK_UNDER`] | lightweight items in [`SceneLayer::Under`](crate::SceneLayer::Under) |
100/// | [`RANK_WIDGET`] | heavyweight widget entries, and lightweight items in [`SceneLayer::Interleaved`](crate::SceneLayer::Interleaved) |
101/// | [`RANK_OVER`] | lightweight items in [`SceneLayer::Over`](crate::SceneLayer::Over) |
102///
103/// Within a rank, ascending `z`; ties broken by `seq`, the entry's
104/// [`ItemId`](crate::ItemId) — which is a process-monotone counter, so ties
105/// resolve in **insertion order**. A *higher* key paints later, is therefore on
106/// top, and therefore wins the pointer.
107///
108/// [`SceneLayer`](crate::SceneLayer) is not a separate concept any more: it is
109/// the leading digit of this key.
110///
111/// # Why the tie-break is load-bearing
112///
113/// Paint sorts ascending and stably over an id-ordered candidate list, so the
114/// newest of two equal-`z` items paints last and is on top. The hit snapshots
115/// used to sort **descending** and stably over that same ascending list, which
116/// leaves equal-`z` ties in *ascending* order — so the first match was the
117/// oldest, i.e. the **bottom-most**, item. Paint and hit were exactly inverted
118/// on ties. Comparing whole keys fixes that by construction: a descending sort
119/// on `(rank, z, seq)` really does put the topmost entry first.
120///
121/// # NaN
122///
123/// A non-finite `z` is normalised to `0.0` at construction, so the ordering is
124/// total and `sort_by` is not order-dependent. The old sites compared with
125/// `partial_cmp(..).unwrap_or(Equal)`, which is not a total order.
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub struct PaintKey {
128 rank: u8,
129 z: f32,
130 seq: u64,
131}
132
133impl PaintKey {
134 /// Build a key. `z` is normalised (`NaN` → `0.0`) so the ordering is total.
135 pub fn new(rank: u8, z: f32, seq: u64) -> Self {
136 Self {
137 rank,
138 z: if z.is_nan() { 0.0 } else { z },
139 seq,
140 }
141 }
142
143 /// The tier / band digit — one of [`RANK_UNDER`], [`RANK_WIDGET`],
144 /// [`RANK_OVER`].
145 pub const fn rank(self) -> u8 {
146 self.rank
147 }
148
149 /// The entry's z-order within its rank.
150 pub const fn z(self) -> f32 {
151 self.z
152 }
153
154 /// The insertion tie-break — the entry's [`ItemId`](crate::ItemId) as a
155 /// number.
156 ///
157 /// Stable only as long as the id is. A
158 /// [`SceneListAdapter`](crate::SceneListAdapter) re-mints ids on a
159 /// structural `DataChange`, so equal-`z` ordering inside an adapter-owned
160 /// run does shuffle on an append. That is a property of the adapter, not a
161 /// guarantee this key makes.
162 pub const fn seq(self) -> u64 {
163 self.seq
164 }
165
166 /// True when this key can never lose to a heavyweight entry.
167 pub const fn is_above_widgets(self) -> bool {
168 self.rank > RANK_WIDGET
169 }
170
171 /// A floor below every real key — "admit everything".
172 ///
173 /// `z` is `-inf` rather than a finite sentinel so that a real entry sitting
174 /// at `f32::MIN` is still admitted, and `Ord` compares `z` with
175 /// `total_cmp`, under which `-inf` is below every finite value.
176 pub const fn bottom() -> Self {
177 Self {
178 rank: RANK_UNDER,
179 z: f32::NEG_INFINITY,
180 seq: 0,
181 }
182 }
183
184 /// A floor admitting exactly the entries at `rank` and above.
185 ///
186 /// The coarse form of a floor, and the one the dispatch path uses: "a card
187 /// won the arena's walk, so only the band that outranks *every* card may
188 /// still take this event". The fine form — one specific entry's key — is
189 /// what the `accepts_child_hit` veto needs, because an
190 /// [`Interleaved`](crate::SceneLayer::Interleaved) entry is above some
191 /// cards and below others.
192 pub const fn rank_floor(rank: u8) -> Self {
193 Self {
194 rank,
195 z: f32::NEG_INFINITY,
196 seq: 0,
197 }
198 }
199}
200
201impl Eq for PaintKey {}
202
203impl Ord for PaintKey {
204 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
205 self.rank
206 .cmp(&other.rank)
207 // `z` is NaN-free by construction (see `PaintKey::new`), so
208 // `total_cmp` here is the same order `partial_cmp` would give —
209 // it is used because it is *provably* total rather than
210 // conditionally so.
211 .then(self.z.total_cmp(&other.z))
212 .then(self.seq.cmp(&other.seq))
213 }
214}
215
216impl PartialOrd for PaintKey {
217 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
218 Some(self.cmp(other))
219 }
220}
221
222/// Whether an entry would **act on a press** — the occlusion rule of this
223/// module, in one predicate.
224///
225/// True when the item is [`IS_DRAGGABLE`](ItemFlags::IS_DRAGGABLE), or carries
226/// a handler that a press is the beginning of: `on_tap`, `on_double_tap` or
227/// `on_context_menu`. False for everything else.
228///
229/// Read at exactly one place — the above-a-card veto that decides whether the
230/// lightweight tier may take a press away from a heavyweight card. Not
231/// band-scoped: it compares whole [`PaintKey`]s per child, so an
232/// [`Interleaved`](crate::SceneLayer::Interleaved) claimant vetoes the cards it
233/// is painted over too. Everything
234/// else in the scene still resolves the topmost *entry* and consults its
235/// handlers afterwards.
236///
237/// # Why a hover affordance is not a claim
238///
239/// `on_hover`, `cursor` and `tooltip` are **hover** affordances, and a hover
240/// affordance does not take a press. Counting one as a claim is not a
241/// conservative over-approximation, it is a click-swallower: the veto removes
242/// the card from the arena's walk, the view becomes the target, and the tap
243/// then resolves to an item that has no `on_tap` — so the press reaches
244/// *nobody*, silently. A corkboard that adds a `.tooltip(..)` to an `Over` hint
245/// region would make every note beneath it un-clickable, un-focusable and
246/// un-editable by mouse.
247///
248/// All three keep working, and they never needed the veto to: the view's own
249/// hover seam runs in the **preview** pass, which fires on every strict
250/// ancestor of the target, so it reaches the item whether a card won the walk
251/// or not. The cursor is the one that has to be arbitrated rather than merely
252/// allowed, and it is — by the `SceneView`'s cursor rule (an `Over` item's
253/// declared cursor wins, silence otherwise), not by taking the card's press
254/// away.
255///
256/// `accepts_drops` is not a claim either, for the same reason in the other
257/// direction: a drop is the *release* of somebody else's drag, not a press on
258/// this item, and nothing in the crate routes a drop through this predicate.
259pub fn claims_press(handlers: Option<&SceneItemHandlerSet>, flags: ItemFlags) -> bool {
260 if flags.contains(ItemFlags::IS_DRAGGABLE) {
261 return true;
262 }
263 handlers.is_some_and(|h| {
264 h.on_tap.is_some() || h.on_double_tap.is_some() || h.on_context_menu.is_some()
265 })
266}
267
268/// Whether an entry takes part in pointer hit-testing at all.
269///
270/// The single home of two flag contracts that used to be written nowhere and
271/// honoured nowhere:
272///
273/// * [`IS_VISIBLE`](ItemFlags::IS_VISIBLE) — "the item is neither painted nor
274/// hit-tested". `effectively_visible` is
275/// [`Scene::is_effectively_visible`](crate::Scene::is_effectively_visible),
276/// so a visible item under a hidden ancestor is excluded too.
277/// * [`IS_ENABLED`](ItemFlags::IS_ENABLED) — "disabled items are still painted
278/// but pass clicks through to items beneath".
279///
280/// Applied by `Scene::item_at` / `items_at` / `item_at_in_view` and by both of
281/// the `SceneView`'s per-layout snapshots, so the eager query and the dispatch
282/// path answer the same question.
283pub fn hit_testable(effectively_visible: bool, flags: ItemFlags) -> bool {
284 effectively_visible && flags.contains(ItemFlags::IS_ENABLED)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use teksilo_core::widget::CursorIcon;
291 use teksilo_i18n::lit;
292
293 fn key(rank: u8, z: f32, seq: u64) -> PaintKey {
294 PaintKey::new(rank, z, seq)
295 }
296
297 #[test]
298 fn the_order_is_banded_before_it_is_z() {
299 // An Under item at z = 100 loses to a widget at z = 0, which loses to
300 // an Over item at z = -100. This is the whole contract in one line.
301 let under = key(RANK_UNDER, 100.0, 1);
302 let widget = key(RANK_WIDGET, 0.0, 2);
303 let over = key(RANK_OVER, -100.0, 3);
304 assert!(under < widget);
305 assert!(widget < over);
306 assert!(under < over);
307 assert!(!under.is_above_widgets());
308 assert!(!widget.is_above_widgets());
309 assert!(over.is_above_widgets());
310 }
311
312 #[test]
313 fn within_a_rank_z_decides_and_ties_go_to_the_newer_entry() {
314 assert!(key(RANK_UNDER, 1.0, 9) < key(RANK_UNDER, 2.0, 1));
315 // Equal z: the later-inserted (higher ItemId) entry is on top, which
316 // is what paint's stable ascending sort produces.
317 assert!(key(RANK_UNDER, 1.0, 1) < key(RANK_UNDER, 1.0, 2));
318 }
319
320 #[test]
321 fn a_descending_sort_puts_the_topmost_entry_first() {
322 // The defect this key exists to remove: a *stable descending* sort of
323 // `z` alone over an ascending-id input leaves equal-z ties ascending,
324 // so the first match was the bottom-most item. Sorting whole keys does
325 // not have that failure mode.
326 let mut keys = [
327 key(RANK_UNDER, 0.0, 1),
328 key(RANK_UNDER, 0.0, 2),
329 key(RANK_UNDER, 0.0, 3),
330 ];
331 keys.sort_by_key(|k| std::cmp::Reverse(*k));
332 assert_eq!(keys.iter().map(|k| k.seq()).collect::<Vec<_>>(), [3, 2, 1]);
333 }
334
335 #[test]
336 fn a_nan_z_does_not_poison_the_order() {
337 let nan = key(RANK_UNDER, f32::NAN, 1);
338 assert_eq!(nan.z(), 0.0);
339 assert_eq!(nan.cmp(&key(RANK_UNDER, 0.0, 1)), std::cmp::Ordering::Equal);
340 assert!(nan < key(RANK_UNDER, 1.0, 1));
341 assert!(nan > key(RANK_UNDER, -1.0, 1));
342 // Totality: a sort over a list containing it terminates with a
343 // consistent answer regardless of input order.
344 let mut a = vec![key(RANK_UNDER, 1.0, 1), nan, key(RANK_UNDER, -1.0, 3)];
345 let mut b = vec![key(RANK_UNDER, -1.0, 3), key(RANK_UNDER, 1.0, 1), nan];
346 a.sort();
347 b.sort();
348 assert_eq!(a, b);
349 }
350
351 #[test]
352 fn a_decorative_item_does_not_claim_the_press() {
353 assert!(!claims_press(None, ItemFlags::default()));
354 assert!(!claims_press(
355 Some(&SceneItemHandlerSet::new()),
356 ItemFlags::default()
357 ));
358 }
359
360 #[test]
361 fn a_press_acting_handler_claims() {
362 let mut h = SceneItemHandlerSet::new();
363 h.on_tap(|_, _| {});
364 assert!(claims_press(Some(&h), ItemFlags::default()));
365
366 let mut h = SceneItemHandlerSet::new();
367 h.on_double_tap(|_, _| {});
368 assert!(claims_press(Some(&h), ItemFlags::default()));
369
370 let mut h = SceneItemHandlerSet::new();
371 h.on_context_menu(|_, _| {});
372 assert!(claims_press(Some(&h), ItemFlags::default()));
373
374 // Draggable with no handler set at all still claims.
375 assert!(claims_press(
376 None,
377 ItemFlags::default().with(ItemFlags::IS_DRAGGABLE)
378 ));
379 }
380
381 /// The regression direction, named. Each of these fires on a pointer that
382 /// merely passes over, and counting one as a press claim does not make the
383 /// veto safer — it makes the card underneath un-clickable, because the veto
384 /// hands the point to the view and the view then finds no `on_tap` to fire.
385 ///
386 /// `crate::view::tests::pick_order` measures that consequence on a real
387 /// card; this pins the boundary itself so a "let's be conservative" edit
388 /// has to argue with a test instead of with a comment.
389 #[test]
390 fn a_hover_affordance_is_not_a_press_claim() {
391 let mut h = SceneItemHandlerSet::new();
392 h.on_hover(|_, _| {});
393 assert!(!claims_press(Some(&h), ItemFlags::default()), "on_hover");
394
395 let mut h = SceneItemHandlerSet::new();
396 h.cursor(CursorIcon::Pointer);
397 assert!(!claims_press(Some(&h), ItemFlags::default()), "cursor");
398
399 let mut h = SceneItemHandlerSet::new();
400 h.tooltip(lit!("hi"));
401 assert!(!claims_press(Some(&h), ItemFlags::default()), "tooltip");
402
403 // A drop is the release of somebody else's drag, not a press on this
404 // item — and nothing routes a drop through this predicate.
405 let mut h = SceneItemHandlerSet::new();
406 h.accepts_drops(true);
407 assert!(
408 !claims_press(Some(&h), ItemFlags::default()),
409 "accepts_drops"
410 );
411 }
412
413 #[test]
414 fn hit_testability_honours_both_flag_contracts() {
415 let d = ItemFlags::default();
416 assert!(hit_testable(true, d));
417 assert!(!hit_testable(false, d), "a hidden ancestor hides the item");
418 assert!(
419 !hit_testable(true, d.without(ItemFlags::IS_ENABLED)),
420 "a disabled item passes clicks through",
421 );
422 }
423}