teksilo_core/partition.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! In-node target geometry: [`TargetRegion`], the shape a widget *reports*, and
5//! [`partition_targets`], the engine that carves one node's rectangle into
6//! several of them.
7//!
8//! # Why a widget reports regions it did not lay out
9//!
10//! Some controls are painted as several targets inside **one** leaf node: a
11//! scroll bar draws its thumb on the same canvas as its track; a slider draws
12//! track, fill and knob together; a table header column draws a label beside a
13//! filter affordance. Nothing in the widget tree knows those sub-targets exist,
14//! so nothing can route a coarse press to the nearest one, and no audit can
15//! check that any of them clears the 24 dp conformance floor.
16//!
17//! [`Widget::target_regions`] is how such a widget says what it painted. It is
18//! **reporting only** — implementing it changes no layout and no hit test on
19//! its own. Three things read it: the router (routing a coarse press to the
20//! nearest region within its role's floor), the target-conformance audit, and
21//! the widget's own event handling, which can now ask one function where its
22//! parts are instead of re-deriving the split at press time and drifting from
23//! what it painted.
24//!
25//! [`partition_targets`] is that one function for the common case — a
26//! horizontal split of a node into named zones with a minimum size each.
27//!
28//! ```
29//! use teksilo_canvas::Rect;
30//! use teksilo_core::environment::LayoutDirection;
31//! use teksilo_core::partition::partition_targets;
32//!
33//! // A 200 dp header cell: label takes the room, a 24 dp filter button trails.
34//! let zones = partition_targets(
35//! Rect::new(0.0, 0.0, 200.0, 28.0),
36//! &[0.8, 0.2],
37//! 24.0,
38//! LayoutDirection::LeftToRight,
39//! );
40//! assert_eq!(zones[0], Rect::new(0.0, 0.0, 160.0, 28.0));
41//! assert_eq!(zones[1], Rect::new(160.0, 0.0, 40.0, 28.0));
42//! ```
43//!
44//! Reference: `docs/density-and-targets.md`.
45//!
46//! [`Widget::target_regions`]: crate::widget::Widget::target_regions
47
48use teksilo_canvas::Rect;
49use teksilo_tokens::TargetRole;
50
51use crate::environment::LayoutDirection;
52
53/// One interactive sub-region of a single widget node.
54///
55/// `part` is the widget's own discriminator — an index, or a `#[repr(u16)]`
56/// enum cast — so a consumer that knows the widget can tell the thumb from the
57/// track, and one that does not can still measure both.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct TargetRegion {
60 /// The region's rectangle, in the same space as the `bounds` the widget was
61 /// asked about (absolute arena coordinates for a node's own bounds).
62 pub rect: Rect,
63 /// What the region is for, which is what decides the floor it is audited
64 /// against: `Target` → `target_size`, `Grab` → `grab_size`, `Decoration` →
65 /// not audited.
66 pub role: TargetRole,
67 /// Which part of the widget this is. Meaningful only to the widget that
68 /// reported it.
69 pub part: u16,
70}
71
72impl TargetRegion {
73 /// A tappable region.
74 pub fn target(rect: Rect, part: u16) -> Self {
75 Self {
76 rect,
77 role: TargetRole::Target,
78 part,
79 }
80 }
81
82 /// A draggable grip or divider region.
83 pub fn grab(rect: Rect, part: u16) -> Self {
84 Self {
85 rect,
86 role: TargetRole::Grab,
87 part,
88 }
89 }
90
91 /// A region that is painted but not interactive, reported so an audit can
92 /// see the whole picture without flagging it.
93 pub fn decoration(rect: Rect, part: u16) -> Self {
94 Self {
95 rect,
96 role: TargetRole::Decoration,
97 part,
98 }
99 }
100}
101
102/// Split `bounds` **horizontally** into one rectangle per entry of `fractions`,
103/// giving every zone at least `min` dp of width, laid out in reading order.
104///
105/// The engine behind every in-node coordinate split: a table header's
106/// label / filter divide, a `SplitButton`'s chevron, a tab's label versus its
107/// close button. Using it rather than open-coding `bounds.width * 0.8` is what
108/// keeps the split the widget *paints* identical to the split it *hit-tests*,
109/// and what lets one floor be raised for every such control at once.
110///
111/// # The rules
112///
113/// * **Weights, not percentages.** `fractions` are normalised by their own sum,
114/// so `&[0.8, 0.2]`, `&[4.0, 1.0]` and `&[80.0, 20.0]` all mean the same
115/// thing. A negative or non-finite entry counts as `0.0`; an all-zero set
116/// splits evenly.
117/// * **The floor is enforced by clamp-and-redistribute.** Any zone whose
118/// proportional share falls below `min` is pinned to `min`, and the remaining
119/// width is re-proportioned among the zones still above it — repeated until
120/// it settles (at most one pass per zone).
121/// * **Reading order, not screen order.** The result is indexed by
122/// `fractions`: `zones[0]` is the *leading* zone, which is the leftmost under
123/// [`LeftToRight`](LayoutDirection::LeftToRight) and the **rightmost** under
124/// [`RightToLeft`](LayoutDirection::RightToLeft). A caller never re-orders
125/// for RTL.
126/// * **The partition is exact.** Zones tile `bounds` with no gap and no
127/// overlap; the last zone absorbs the floating-point residue, so the widths
128/// sum to `bounds.width` to the bit.
129/// * **`y` and `height` are `bounds`'.** This splits one axis; a vertical split
130/// (a `TreeView` row's before / into / after thirds, a drop target's edge
131/// bands) is [`DropRegion`](crate::styles::DropRegion)'s job, which has to
132/// answer in two dimensions anyway.
133///
134/// # When the floor cannot be met
135///
136/// If `min × zones > bounds.width` there is no partition that honours the
137/// floor, and the function **splits the width evenly** rather than honouring
138/// the floor for some zones and starving others, or returning fewer rectangles
139/// than it was asked for.
140///
141/// That is a deliberate choice among three bad options. Returning fewer
142/// rectangles would break every caller that indexes the result and would make a
143/// target silently vanish at a narrow width — the failure mode hardest to
144/// notice and worst to hit. Honouring the floor for a prefix would make which
145/// zone gets starved depend on declaration order, which is invisible at the
146/// call site. An even split keeps every zone reachable, keeps the geometry
147/// predictable, and leaves exactly one observable symptom: sub-floor zones,
148/// which is precisely what the target-conformance audit is for. A caller that
149/// would rather drop a zone than shrink it should check the width itself and
150/// pass a shorter `fractions`.
151pub fn partition_targets(
152 bounds: Rect,
153 fractions: &[f32],
154 min: f32,
155 direction: LayoutDirection,
156) -> Vec<Rect> {
157 let n = fractions.len();
158 if n == 0 {
159 return Vec::new();
160 }
161 let total = if bounds.width.is_finite() && bounds.width > 0.0 {
162 bounds.width
163 } else {
164 0.0
165 };
166 let min = if min.is_finite() && min > 0.0 {
167 min
168 } else {
169 0.0
170 };
171
172 let widths = solve_widths(total, fractions, min);
173
174 // Lay the solved widths out along the axis, leading first. RTL walks the
175 // rectangle from the right, so `zones[0]` is still the zone the reader
176 // meets first.
177 let mut zones = Vec::with_capacity(n);
178 let mut cursor = 0.0_f32;
179 for (i, w) in widths.iter().enumerate() {
180 // The last zone absorbs the residue so the tiling is exact.
181 let w = if i + 1 == n { total - cursor } else { *w };
182 let x = match direction {
183 LayoutDirection::LeftToRight => bounds.x + cursor,
184 LayoutDirection::RightToLeft => bounds.x + total - cursor - w,
185 };
186 zones.push(Rect::new(x, bounds.y, w.max(0.0), bounds.height));
187 cursor += w;
188 }
189 zones
190}
191
192/// Solve the one-dimensional distribution: proportional shares, with every zone
193/// below `min` pinned to it and the rest re-proportioned.
194fn solve_widths(total: f32, fractions: &[f32], min: f32) -> Vec<f32> {
195 let n = fractions.len();
196 // No partition can honour the floor — see the doc comment for why an even
197 // split is the deliberate answer.
198 if min * (n as f32) > total {
199 return vec![total / n as f32; n];
200 }
201
202 let weights: Vec<f32> = fractions
203 .iter()
204 .map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 })
205 .collect();
206 let sum: f32 = weights.iter().sum();
207 // An all-zero (or entirely invalid) weight set means "split evenly".
208 let weights: Vec<f32> = if sum > 0.0 { weights } else { vec![1.0; n] };
209
210 let mut pinned = vec![false; n];
211 let mut widths = vec![0.0_f32; n];
212 // Clamp-and-redistribute, exactly as the stack's shrink pass does: pin
213 // everything that undershoots the floor, share what is left among the rest,
214 // repeat. Each pass pins at least one zone, so it terminates in ≤ n passes.
215 for _ in 0..=n {
216 let free: f32 = total - min * pinned.iter().filter(|p| **p).count() as f32;
217 let live_weight: f32 = weights
218 .iter()
219 .zip(&pinned)
220 .filter(|(_, p)| !**p)
221 .map(|(w, _)| *w)
222 .sum();
223 let mut newly_pinned = false;
224 for i in 0..n {
225 if pinned[i] {
226 widths[i] = min;
227 continue;
228 }
229 let share = if live_weight > 0.0 {
230 free * weights[i] / live_weight
231 } else {
232 0.0
233 };
234 if share < min {
235 pinned[i] = true;
236 widths[i] = min;
237 newly_pinned = true;
238 } else {
239 widths[i] = share;
240 }
241 }
242 if !newly_pinned {
243 break;
244 }
245 }
246 widths
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn widths(zones: &[Rect]) -> Vec<f32> {
254 zones.iter().map(|z| z.width).collect()
255 }
256
257 fn approx(a: &[f32], b: &[f32]) {
258 assert_eq!(a.len(), b.len(), "{a:?} vs {b:?}");
259 for (x, y) in a.iter().zip(b) {
260 assert!((x - y).abs() < 1e-3, "{a:?} vs {b:?}");
261 }
262 }
263
264 #[test]
265 fn an_empty_split_returns_nothing() {
266 assert!(
267 partition_targets(
268 Rect::new(0.0, 0.0, 100.0, 10.0),
269 &[],
270 24.0,
271 LayoutDirection::LeftToRight
272 )
273 .is_empty()
274 );
275 }
276
277 /// Weights normalise by their own sum, so a caller may write percentages,
278 /// fractions or ratios.
279 #[test]
280 fn weights_are_normalised_by_their_sum() {
281 let r = Rect::new(0.0, 0.0, 100.0, 10.0);
282 for f in [
283 [0.8_f32, 0.2].as_slice(),
284 [4.0, 1.0].as_slice(),
285 [80.0, 20.0].as_slice(),
286 ] {
287 approx(
288 &widths(&partition_targets(r, f, 0.0, LayoutDirection::LeftToRight)),
289 &[80.0, 20.0],
290 );
291 }
292 }
293
294 /// The zones tile the bounds exactly — no gap, no overlap, no residue.
295 #[test]
296 fn the_partition_is_exact() {
297 let r = Rect::new(7.5, 3.0, 101.0, 10.0);
298 for dir in [LayoutDirection::LeftToRight, LayoutDirection::RightToLeft] {
299 let z = partition_targets(r, &[1.0, 1.0, 1.0], 0.0, dir);
300 let sum: f32 = z.iter().map(|q| q.width).sum();
301 assert!(
302 (sum - r.width).abs() < 1e-4,
303 "{dir:?}: {sum} != {}",
304 r.width
305 );
306 let mut xs: Vec<f32> = z.iter().map(|q| q.x).collect();
307 xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
308 assert!((xs[0] - r.x).abs() < 1e-4);
309 let last = z.iter().map(|q| q.right()).fold(f32::MIN, f32::max);
310 assert!((last - r.right()).abs() < 1e-4);
311 }
312 }
313
314 /// RTL keeps the *index* order and flips the *screen* order: `zones[0]` is
315 /// the leading zone, which is on the right.
316 #[test]
317 fn rtl_reverses_the_screen_order_and_not_the_index_order() {
318 let r = Rect::new(0.0, 0.0, 100.0, 10.0);
319 let ltr = partition_targets(r, &[0.7, 0.3], 0.0, LayoutDirection::LeftToRight);
320 let rtl = partition_targets(r, &[0.7, 0.3], 0.0, LayoutDirection::RightToLeft);
321 // Same widths, in the same index order.
322 approx(&widths(<r), &widths(&rtl));
323 assert_eq!(ltr[0].x, 0.0, "LTR: the leading zone starts at the left");
324 assert_eq!(
325 rtl[0].right(),
326 100.0,
327 "RTL: the leading zone ends at the right"
328 );
329 assert_eq!(rtl[1].x, 0.0, "RTL: the trailing zone is on the left");
330 }
331
332 /// A zone whose proportional share undershoots the floor is pinned to it,
333 /// and the rest re-proportion around the pin.
334 #[test]
335 fn the_floor_is_enforced_by_clamp_and_redistribute() {
336 // 200 dp, 95/5 — the trailing zone's 10 dp share is below the 24 dp
337 // floor, so it takes 24 and the leading zone keeps the other 176.
338 let z = partition_targets(
339 Rect::new(0.0, 0.0, 200.0, 28.0),
340 &[0.95, 0.05],
341 24.0,
342 LayoutDirection::LeftToRight,
343 );
344 approx(&widths(&z), &[176.0, 24.0]);
345
346 // Two zones under the floor, one comfortably above: both pin, the third
347 // absorbs the rest.
348 let z = partition_targets(
349 Rect::new(0.0, 0.0, 200.0, 28.0),
350 &[0.9, 0.05, 0.05],
351 24.0,
352 LayoutDirection::LeftToRight,
353 );
354 approx(&widths(&z), &[152.0, 24.0, 24.0]);
355 }
356
357 /// The pinning cascades: pinning one zone can push a second below the floor,
358 /// and the loop must catch that rather than settling after one pass.
359 #[test]
360 fn pinning_cascades_until_it_settles() {
361 // 100 dp, 24 dp floor, weights 70/20/10. First pass: 70/20/10 → the
362 // 10 dp zone pins. Second pass: 76 free over 90 weight → 59.1 / 16.9 →
363 // the 16.9 zone pins too. Third: 52 / 24 / 24.
364 let z = partition_targets(
365 Rect::new(0.0, 0.0, 100.0, 10.0),
366 &[0.7, 0.2, 0.1],
367 24.0,
368 LayoutDirection::LeftToRight,
369 );
370 approx(&widths(&z), &[52.0, 24.0, 24.0]);
371 }
372
373 /// When `min × n` exceeds the width there is no conforming partition. The
374 /// deliberate answer is an even split: every zone stays reachable, every
375 /// zone is visibly sub-floor, and no caller's index goes missing.
376 #[test]
377 fn an_unmeetable_floor_splits_evenly_and_keeps_every_zone() {
378 let z = partition_targets(
379 Rect::new(0.0, 0.0, 50.0, 10.0),
380 &[0.9, 0.05, 0.05],
381 24.0,
382 LayoutDirection::LeftToRight,
383 );
384 assert_eq!(z.len(), 3, "no zone may be dropped");
385 approx(&widths(&z), &[50.0 / 3.0, 50.0 / 3.0, 50.0 / 3.0]);
386 assert!(
387 z.iter().all(|q| q.width < 24.0),
388 "the shortfall stays visible to the audit rather than being hidden"
389 );
390 // Still an exact tiling.
391 let sum: f32 = z.iter().map(|q| q.width).sum();
392 assert!((sum - 50.0).abs() < 1e-4);
393 }
394
395 /// A degenerate input must not produce a `NaN` rectangle or panic.
396 #[test]
397 fn degenerate_inputs_are_inert() {
398 let z = partition_targets(
399 Rect::new(0.0, 0.0, 100.0, 10.0),
400 &[f32::NAN, -1.0, 0.0],
401 f32::NAN,
402 LayoutDirection::LeftToRight,
403 );
404 approx(&widths(&z), &[100.0 / 3.0; 3]);
405 let z = partition_targets(
406 Rect::new(0.0, 0.0, 0.0, 10.0),
407 &[1.0, 1.0],
408 24.0,
409 LayoutDirection::LeftToRight,
410 );
411 approx(&widths(&z), &[0.0, 0.0]);
412 }
413
414 #[test]
415 fn a_region_carries_its_role_and_part() {
416 let r = Rect::new(1.0, 2.0, 3.0, 4.0);
417 assert_eq!(TargetRegion::target(r, 0).role, TargetRole::Target);
418 assert_eq!(TargetRegion::grab(r, 1).role, TargetRole::Grab);
419 assert_eq!(TargetRegion::decoration(r, 2).role, TargetRole::Decoration);
420 assert_eq!(TargetRegion::grab(r, 9).part, 9);
421 }
422}