use teksilo_canvas::Rect;
use teksilo_tokens::TargetRole;
use crate::environment::LayoutDirection;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TargetRegion {
pub rect: Rect,
pub role: TargetRole,
pub part: u16,
}
impl TargetRegion {
pub fn target(rect: Rect, part: u16) -> Self {
Self {
rect,
role: TargetRole::Target,
part,
}
}
pub fn grab(rect: Rect, part: u16) -> Self {
Self {
rect,
role: TargetRole::Grab,
part,
}
}
pub fn decoration(rect: Rect, part: u16) -> Self {
Self {
rect,
role: TargetRole::Decoration,
part,
}
}
}
pub fn partition_targets(
bounds: Rect,
fractions: &[f32],
min: f32,
direction: LayoutDirection,
) -> Vec<Rect> {
let n = fractions.len();
if n == 0 {
return Vec::new();
}
let total = if bounds.width.is_finite() && bounds.width > 0.0 {
bounds.width
} else {
0.0
};
let min = if min.is_finite() && min > 0.0 {
min
} else {
0.0
};
let widths = solve_widths(total, fractions, min);
let mut zones = Vec::with_capacity(n);
let mut cursor = 0.0_f32;
for (i, w) in widths.iter().enumerate() {
let w = if i + 1 == n { total - cursor } else { *w };
let x = match direction {
LayoutDirection::LeftToRight => bounds.x + cursor,
LayoutDirection::RightToLeft => bounds.x + total - cursor - w,
};
zones.push(Rect::new(x, bounds.y, w.max(0.0), bounds.height));
cursor += w;
}
zones
}
fn solve_widths(total: f32, fractions: &[f32], min: f32) -> Vec<f32> {
let n = fractions.len();
if min * (n as f32) > total {
return vec![total / n as f32; n];
}
let weights: Vec<f32> = fractions
.iter()
.map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 })
.collect();
let sum: f32 = weights.iter().sum();
let weights: Vec<f32> = if sum > 0.0 { weights } else { vec![1.0; n] };
let mut pinned = vec![false; n];
let mut widths = vec![0.0_f32; n];
for _ in 0..=n {
let free: f32 = total - min * pinned.iter().filter(|p| **p).count() as f32;
let live_weight: f32 = weights
.iter()
.zip(&pinned)
.filter(|(_, p)| !**p)
.map(|(w, _)| *w)
.sum();
let mut newly_pinned = false;
for i in 0..n {
if pinned[i] {
widths[i] = min;
continue;
}
let share = if live_weight > 0.0 {
free * weights[i] / live_weight
} else {
0.0
};
if share < min {
pinned[i] = true;
widths[i] = min;
newly_pinned = true;
} else {
widths[i] = share;
}
}
if !newly_pinned {
break;
}
}
widths
}
#[cfg(test)]
mod tests {
use super::*;
fn widths(zones: &[Rect]) -> Vec<f32> {
zones.iter().map(|z| z.width).collect()
}
fn approx(a: &[f32], b: &[f32]) {
assert_eq!(a.len(), b.len(), "{a:?} vs {b:?}");
for (x, y) in a.iter().zip(b) {
assert!((x - y).abs() < 1e-3, "{a:?} vs {b:?}");
}
}
#[test]
fn an_empty_split_returns_nothing() {
assert!(
partition_targets(
Rect::new(0.0, 0.0, 100.0, 10.0),
&[],
24.0,
LayoutDirection::LeftToRight
)
.is_empty()
);
}
#[test]
fn weights_are_normalised_by_their_sum() {
let r = Rect::new(0.0, 0.0, 100.0, 10.0);
for f in [
[0.8_f32, 0.2].as_slice(),
[4.0, 1.0].as_slice(),
[80.0, 20.0].as_slice(),
] {
approx(
&widths(&partition_targets(r, f, 0.0, LayoutDirection::LeftToRight)),
&[80.0, 20.0],
);
}
}
#[test]
fn the_partition_is_exact() {
let r = Rect::new(7.5, 3.0, 101.0, 10.0);
for dir in [LayoutDirection::LeftToRight, LayoutDirection::RightToLeft] {
let z = partition_targets(r, &[1.0, 1.0, 1.0], 0.0, dir);
let sum: f32 = z.iter().map(|q| q.width).sum();
assert!(
(sum - r.width).abs() < 1e-4,
"{dir:?}: {sum} != {}",
r.width
);
let mut xs: Vec<f32> = z.iter().map(|q| q.x).collect();
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!((xs[0] - r.x).abs() < 1e-4);
let last = z.iter().map(|q| q.right()).fold(f32::MIN, f32::max);
assert!((last - r.right()).abs() < 1e-4);
}
}
#[test]
fn rtl_reverses_the_screen_order_and_not_the_index_order() {
let r = Rect::new(0.0, 0.0, 100.0, 10.0);
let ltr = partition_targets(r, &[0.7, 0.3], 0.0, LayoutDirection::LeftToRight);
let rtl = partition_targets(r, &[0.7, 0.3], 0.0, LayoutDirection::RightToLeft);
approx(&widths(<r), &widths(&rtl));
assert_eq!(ltr[0].x, 0.0, "LTR: the leading zone starts at the left");
assert_eq!(
rtl[0].right(),
100.0,
"RTL: the leading zone ends at the right"
);
assert_eq!(rtl[1].x, 0.0, "RTL: the trailing zone is on the left");
}
#[test]
fn the_floor_is_enforced_by_clamp_and_redistribute() {
let z = partition_targets(
Rect::new(0.0, 0.0, 200.0, 28.0),
&[0.95, 0.05],
24.0,
LayoutDirection::LeftToRight,
);
approx(&widths(&z), &[176.0, 24.0]);
let z = partition_targets(
Rect::new(0.0, 0.0, 200.0, 28.0),
&[0.9, 0.05, 0.05],
24.0,
LayoutDirection::LeftToRight,
);
approx(&widths(&z), &[152.0, 24.0, 24.0]);
}
#[test]
fn pinning_cascades_until_it_settles() {
let z = partition_targets(
Rect::new(0.0, 0.0, 100.0, 10.0),
&[0.7, 0.2, 0.1],
24.0,
LayoutDirection::LeftToRight,
);
approx(&widths(&z), &[52.0, 24.0, 24.0]);
}
#[test]
fn an_unmeetable_floor_splits_evenly_and_keeps_every_zone() {
let z = partition_targets(
Rect::new(0.0, 0.0, 50.0, 10.0),
&[0.9, 0.05, 0.05],
24.0,
LayoutDirection::LeftToRight,
);
assert_eq!(z.len(), 3, "no zone may be dropped");
approx(&widths(&z), &[50.0 / 3.0, 50.0 / 3.0, 50.0 / 3.0]);
assert!(
z.iter().all(|q| q.width < 24.0),
"the shortfall stays visible to the audit rather than being hidden"
);
let sum: f32 = z.iter().map(|q| q.width).sum();
assert!((sum - 50.0).abs() < 1e-4);
}
#[test]
fn degenerate_inputs_are_inert() {
let z = partition_targets(
Rect::new(0.0, 0.0, 100.0, 10.0),
&[f32::NAN, -1.0, 0.0],
f32::NAN,
LayoutDirection::LeftToRight,
);
approx(&widths(&z), &[100.0 / 3.0; 3]);
let z = partition_targets(
Rect::new(0.0, 0.0, 0.0, 10.0),
&[1.0, 1.0],
24.0,
LayoutDirection::LeftToRight,
);
approx(&widths(&z), &[0.0, 0.0]);
}
#[test]
fn a_region_carries_its_role_and_part() {
let r = Rect::new(1.0, 2.0, 3.0, 4.0);
assert_eq!(TargetRegion::target(r, 0).role, TargetRole::Target);
assert_eq!(TargetRegion::grab(r, 1).role, TargetRole::Grab);
assert_eq!(TargetRegion::decoration(r, 2).role, TargetRole::Decoration);
assert_eq!(TargetRegion::grab(r, 9).part, 9);
}
}