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