Skip to main content

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(&ltr), &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}