use teksilo_canvas::{Point, Rect, Size, Transform2D};
use teksilo_tokens::{InputTokens, PointerKind, TargetDensity, TargetRole};
use crate::pointer::hit_slop::HitContext;
use crate::widget_id::WidgetId;
use crate::widget_tree::WidgetTree;
fn probe_limit(tokens: &InputTokens) -> f32 {
tokens.target_size.max(tokens.min_target_conformance) + 1.0
}
const PROBE_STEP: f32 = 0.5;
const PROBE_REFINE: u32 = 4;
const PROBE_EPSILON: f32 = 3.0 * PROBE_STEP / (1 << PROBE_REFINE) as f32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TargetRule {
MinTargetConformance,
SpacingException,
TouchTargetRecommendation,
}
impl TargetRule {
pub fn is_conformance_failure(self) -> bool {
matches!(self, TargetRule::MinTargetConformance)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct ReachSources {
pub outset: bool,
pub slop: bool,
}
impl ReachSources {
pub fn any(self) -> bool {
self.outset || self.slop
}
}
#[derive(Debug, Clone)]
pub struct TargetMeasurement {
pub widget: &'static str,
pub node: WidgetId,
pub part: Option<u16>,
pub path: String,
pub density: TargetDensity,
pub theme: crate::styles::ThemeId,
pub conformance_floor: f32,
pub size: Size,
pub expanded: Size,
pub capped: bool,
pub sources: ReachSources,
pub transformed: bool,
pub rule: Option<TargetRule>,
pub skipped: Option<SkipReason>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SkipReason {
DelegatesToDescendant,
ObscuredByOverlay,
EmptyRectangle,
NotOnScreen,
AffordanceOfItsOwnTarget,
ShadowedByAnotherTarget,
}
#[derive(Debug, Clone)]
pub struct TargetViolation {
pub widget: &'static str,
pub node: WidgetId,
pub part: Option<u16>,
pub path: String,
pub density: TargetDensity,
pub theme: crate::styles::ThemeId,
pub conformance_floor: f32,
pub size: Size,
pub expanded: Size,
pub sources: ReachSources,
pub transformed: bool,
pub rule: TargetRule,
}
impl std::fmt::Display for TargetViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:?} at {:?} under {}: {}",
self.rule, self.density, self.theme, self.path
)?;
if let Some(part) = self.part {
write!(f, " [part {part}]")?;
}
write!(
f,
" — paints {:.1}×{:.1}, reaches {:.1}×{:.1} against a {} dp floor",
self.size.width,
self.size.height,
self.expanded.width,
self.expanded.height,
self.conformance_floor,
)?;
if self.sources.any() {
write!(
f,
" (grown by{}{})",
if self.sources.outset {
" hit_outset"
} else {
""
},
if self.sources.slop { " slop" } else { "" },
)?;
}
if self.transformed {
write!(f, " (transformed)")?;
}
Ok(())
}
}
pub fn measure_targets(tree: &WidgetTree, density: TargetDensity) -> Vec<TargetMeasurement> {
let tokens = tree.theme().input;
debug_assert_eq!(
tokens.density, density,
"the tree was built at {:?} and is being judged against {:?}: a \
control's painted size is baked in `build()`, so this measures a \
mixture of two ladders. Build the tree at the density you audit, or \
use `audit_at_density`.",
tokens.density, density,
);
let walker = Walker::new(tree, tokens.density, &tokens);
walker.run()
}
pub fn target_audit(tree: &WidgetTree, density: TargetDensity) -> Vec<TargetViolation> {
measure_targets(tree, density)
.into_iter()
.filter_map(|m| {
m.rule.map(|rule| TargetViolation {
widget: m.widget,
node: m.node,
part: m.part,
path: m.path,
density: m.density,
theme: m.theme,
conformance_floor: m.conformance_floor,
size: m.size,
expanded: m.expanded,
sources: m.sources,
transformed: m.transformed,
rule,
})
})
.collect()
}
pub fn audit_at_density(
tree: &mut WidgetTree,
density: TargetDensity,
viewport: Size,
) -> Vec<TargetViolation> {
tree.set_input_density(density);
tree.layout(teksilo_canvas::SizeProposal::exact(
viewport.width,
viewport.height,
));
target_audit(tree, density)
}
#[derive(Clone, Copy)]
pub struct TargetFixture {
pub name: &'static str,
pub viewport: Size,
pub theme: fn() -> crate::styles::Theme,
pub build: fn(&mut WidgetTree) -> WidgetId,
}
impl TargetFixture {
pub const fn new(name: &'static str, build: fn(&mut WidgetTree) -> WidgetId) -> Self {
Self::sized(name, 800.0, 600.0, build)
}
pub const fn sized(
name: &'static str,
width: f32,
height: f32,
build: fn(&mut WidgetTree) -> WidgetId,
) -> Self {
Self {
name,
viewport: Size { width, height },
theme: crate::presets::intui::light,
build,
}
}
pub const fn with_theme(mut self, theme: fn() -> crate::styles::Theme) -> Self {
self.theme = theme;
self
}
}
pub fn audit_fixtures(fixtures: &[TargetFixture], density: TargetDensity) -> Vec<TargetViolation> {
let mut out = Vec::new();
for fixture in fixtures {
let tree = build_fixture(fixture, density);
for mut violation in target_audit(&tree, density) {
violation.path = format!("{}: {}", fixture.name, violation.path);
out.push(violation);
}
}
out
}
fn build_fixture(fixture: &TargetFixture, density: TargetDensity) -> WidgetTree {
let mut tree = WidgetTree::new().with_theme((fixture.theme)().with_density(density));
(fixture.build)(&mut tree);
tree.layout(teksilo_canvas::SizeProposal::exact(
fixture.viewport.width,
fixture.viewport.height,
));
tree
}
pub fn measure_fixtures(
fixtures: &[TargetFixture],
density: TargetDensity,
) -> Vec<TargetMeasurement> {
let mut out = Vec::new();
for fixture in fixtures {
let tree = build_fixture(fixture, density);
for mut m in measure_targets(&tree, density) {
m.path = format!("{}: {}", fixture.name, m.path);
out.push(m);
}
}
out
}
pub fn unprojected_style_slots(theme: &crate::styles::Theme) -> Vec<&'static str> {
let compact = theme.with_density(TargetDensity::Compact);
let touch = theme.with_density(TargetDensity::Touch);
compact.style_slots.unchanged_against(&touch.style_slots)
}
pub const PIN_TOLERANCE: f32 = 0.1;
#[derive(Clone, Copy, Debug)]
pub enum PinnedDp {
Is(f32),
ClearsFloor,
}
impl PinnedDp {
fn matches(self, actual: f32, v: &TargetViolation) -> bool {
match self {
PinnedDp::Is(dp) => (actual - dp).abs() <= PIN_TOLERANCE,
PinnedDp::ClearsFloor => actual + PIN_TOLERANCE >= v.conformance_floor,
}
}
}
pub struct PinnedGeometry {
pub densities: &'static [TargetDensity],
pub themes: &'static [&'static str],
pub paints: (PinnedDp, PinnedDp),
pub reaches: (PinnedDp, PinnedDp),
}
impl PinnedGeometry {
pub fn covers(&self, v: &TargetViolation) -> bool {
self.densities.contains(&v.density)
&& self.themes.iter().any(|t| *t == v.theme.as_str())
&& self.paints.0.matches(v.size.width, v)
&& self.paints.1.matches(v.size.height, v)
&& self.reaches.0.matches(v.expanded.width, v)
&& self.reaches.1.matches(v.expanded.height, v)
}
}
pub enum Owner {
Named(&'static str),
NobodyBecause(&'static str),
}
impl Owner {
pub fn text(&self) -> &'static str {
match self {
Owner::Named(s) | Owner::NobodyBecause(s) => s,
}
}
}
pub struct AllowedViolation {
pub path: &'static str,
pub measured: &'static [PinnedGeometry],
pub owner: Owner,
pub exception: Option<&'static str>,
pub why: &'static str,
}
fn split_path(path: &str) -> (Option<&str>, Vec<&str>) {
let (fixture, rest) = match path.split_once(':') {
Some((f, r)) => (Some(f.trim()), r),
None => (None, path),
};
let segments = rest
.split('>')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
(fixture, segments)
}
impl AllowedViolation {
pub fn matches(&self, v: &TargetViolation) -> bool {
self.names(&v.path) && self.measured.iter().any(|m| m.covers(v))
}
fn names(&self, path: &str) -> bool {
let (want_fixture, want_segments) = split_path(self.path);
let (got_fixture, got_segments) = split_path(path);
if want_fixture.is_none()
&& want_segments.len() == 1
&& got_fixture == Some(want_segments[0])
{
return true;
}
if want_fixture.is_some() && want_fixture != got_fixture {
return false;
}
want_segments.is_empty()
|| got_segments
.windows(want_segments.len())
.any(|w| w == want_segments.as_slice())
}
}
pub mod gate;
struct Candidate {
node: WidgetId,
widget: &'static str,
path: String,
screen: Rect,
transformed: bool,
regions: Vec<(u16, TargetRole, Rect)>,
}
struct Walker<'a> {
tree: &'a WidgetTree,
density: TargetDensity,
theme: crate::styles::ThemeId,
tokens: &'a InputTokens,
limit: f32,
floor: f32,
viewport: Option<Rect>,
}
impl<'a> Walker<'a> {
fn new(tree: &'a WidgetTree, density: TargetDensity, tokens: &'a InputTokens) -> Self {
let proposal = tree.last_proposal();
let viewport = match (proposal.width, proposal.height) {
(Some(w), Some(h)) => Some(Rect::new(0.0, 0.0, w, h)),
_ => None,
};
Self {
tree,
density,
theme: tree.theme().id.clone(),
tokens,
limit: probe_limit(tokens),
floor: tokens.min_target_conformance,
viewport,
}
}
fn run(&self) -> Vec<TargetMeasurement> {
let candidates = self.collect();
let neighbours: Vec<Neighbour> = candidates
.iter()
.flat_map(|c| {
std::iter::once((c.node, None, c.screen)).chain(
c.regions
.iter()
.filter(|(_, role, _)| *role != TargetRole::Decoration)
.map(move |(part, _, rect)| (c.node, Some(*part), *rect)),
)
})
.map(|(node, part, rect)| Neighbour {
node,
part,
rect: rect.expand(self.max_growth(node, rect)),
})
.collect();
let mut out = Vec::new();
for candidate in &candidates {
let mut growth = [0.0_f32; 4];
if let Some(m) = self.measure_node(candidate, &neighbours, &mut growth) {
out.push(m);
}
let enclosing: Vec<Rect> = candidate
.regions
.iter()
.filter(|(_, role, _)| *role == TargetRole::Target)
.map(|(_, _, rect)| *rect)
.collect();
for &(part, role, rect) in &candidate.regions {
if role == TargetRole::Decoration {
continue;
}
let affordance =
role == TargetRole::Grab && enclosing.iter().any(|t| t.contains(rect.center()));
let mut m = self.measure_region(candidate, part, role, rect, &growth, &neighbours);
if affordance {
m.rule = None;
m.skipped = Some(SkipReason::AffordanceOfItsOwnTarget);
}
out.push(m);
}
}
out
}
fn max_growth(&self, node: WidgetId, rect: Rect) -> f32 {
let arena = &self.tree.arena;
let outset = arena
.get(node)
.map(|n| n.widget.hit_outset(PointerKind::Touch, self.tokens))
.unwrap_or(teksilo_canvas::EdgeInsets::ZERO);
let widest = outset
.top
.max(outset.bottom)
.max(outset.leading)
.max(outset.trailing);
let slop = crate::pointer::hit_slop::HitSlop::for_pointer(PointerKind::Touch, self.tokens)
.outset_for(rect.size());
if widest.is_finite() {
widest.max(slop)
} else {
slop
}
}
fn collect(&self) -> Vec<Candidate> {
let mut out = Vec::new();
for root in self.tree.roots() {
self.collect_from(root, &mut Vec::new(), &mut out);
}
out
}
fn collect_from(&self, id: WidgetId, path: &mut Vec<&'static str>, out: &mut Vec<Candidate>) {
let arena = &self.tree.arena;
if !arena.is_active(id) {
return;
}
let Some(node) = arena.get(id) else { return };
if node.hit_transparent {
return;
}
let widget = node.widget.type_name();
path.push(short_name(widget));
let bounds = arena.bounds(id);
let (screen, transformed) = self.to_screen(id, bounds);
let regions: Vec<(u16, TargetRole, Rect)> = node
.widget
.target_regions(bounds)
.into_iter()
.map(|r| (r.part, r.role, self.to_screen(id, r.rect).0))
.collect();
let is_target = arena.takes_a_press(id) && !node.event_pass_through;
if is_target || !regions.is_empty() {
out.push(Candidate {
node: id,
widget,
path: path.join(" > "),
screen,
transformed,
regions: if is_target { regions } else { Vec::new() },
});
}
for &child in arena.children(id) {
self.collect_from(child, path, out);
}
path.pop();
}
fn to_screen(&self, id: WidgetId, rect: Rect) -> (Rect, bool) {
let arena = &self.tree.arena;
let content = arena.get(id).map(|n| n.content_transform).unwrap_or(false);
let t = if content {
arena
.parent(id)
.map(|p| arena.effective_transform(p))
.unwrap_or(Transform2D::IDENTITY)
} else {
arena.effective_transform(id)
};
if t.is_identity() {
(rect, false)
} else {
(t.apply_rect(rect), true)
}
}
fn measure_node(
&self,
candidate: &Candidate,
neighbours: &[Neighbour],
growth: &mut [f32; 4],
) -> Option<TargetMeasurement> {
let arena = &self.tree.arena;
if !arena.takes_a_press(candidate.node) {
return None;
}
let rect = candidate.screen;
let base =
|skipped: Option<SkipReason>, expanded: Size, capped, sources| TargetMeasurement {
widget: candidate.widget,
node: candidate.node,
part: None,
path: candidate.path.clone(),
density: self.density,
theme: self.theme.clone(),
conformance_floor: self.floor,
size: rect.size(),
expanded,
capped,
sources,
transformed: candidate.transformed,
rule: None,
skipped,
};
if rect.width <= 0.0 || rect.height <= 0.0 {
return Some(base(
Some(SkipReason::EmptyRectangle),
Size::new(0.0, 0.0),
false,
ReachSources::default(),
));
}
let centre = rect.center();
if !self.on_screen(candidate.node, centre) {
return Some(base(
Some(SkipReason::NotOnScreen),
Size::new(0.0, 0.0),
false,
ReachSources::default(),
));
}
match self.owner_at(centre, true) {
Some(owner) if owner == candidate.node => {}
Some(owner) if self.is_descendant(owner, candidate.node) => {
return Some(base(
Some(SkipReason::DelegatesToDescendant),
Size::new(0.0, 0.0),
false,
ReachSources::default(),
));
}
other => {
if other.is_some_and(|o| self.in_foreign_overlay(o, candidate.node)) {
return Some(base(
Some(SkipReason::ObscuredByOverlay),
Size::new(0.0, 0.0),
false,
ReachSources::default(),
));
}
let mut m = base(None, Size::new(0.0, 0.0), false, ReachSources::default());
m.rule = Some(TargetRule::MinTargetConformance);
return Some(m);
}
}
let mut sources = ReachSources::default();
let mut capped = false;
let mut capped_axis = [false; 2];
let mut extent = [0.0_f32; 4];
for (index, dir) in DIRECTIONS.iter().enumerate() {
let spent = if index % 2 == 0 {
0.0
} else {
extent[index - 1]
};
let budget = (self.limit - spent).max(0.0);
let reach = self.reach(centre, *dir, candidate.node, budget);
extent[index] = reach.distance;
capped |= reach.capped;
capped_axis[index / 2] |= reach.capped;
let inside = match index {
0 | 1 => rect.width / 2.0,
_ => rect.height / 2.0,
};
growth[index] = (reach.distance - inside).max(0.0);
if reach.distance > inside + PROBE_STEP {
if reach.confirmed_without_slop {
sources.outset = true;
} else {
sources.slop = true;
}
}
}
let expanded = Size::new(extent[0] + extent[1], extent[2] + extent[3]);
let mut m = base(None, expanded, capped, sources);
let shadowed = expanded.width > 0.0
&& expanded.height > 0.0
&& ((!capped_axis[0] && expanded.width + PROBE_EPSILON < rect.width)
|| (!capped_axis[1] && expanded.height + PROBE_EPSILON < rect.height));
if shadowed {
m.skipped = Some(SkipReason::ShadowedByAnotherTarget);
return Some(m);
}
m.rule = self.classify(
expanded,
rect,
TargetRole::Target,
(candidate.node, None),
neighbours,
);
Some(m)
}
#[allow(clippy::too_many_arguments)]
fn measure_region(
&self,
candidate: &Candidate,
part: u16,
role: TargetRole,
rect: Rect,
growth: &[f32; 4],
neighbours: &[Neighbour],
) -> TargetMeasurement {
let node = candidate.screen;
if !self.on_screen(candidate.node, rect.center()) {
return TargetMeasurement {
widget: candidate.widget,
node: candidate.node,
part: Some(part),
path: candidate.path.clone(),
density: self.density,
theme: self.theme.clone(),
conformance_floor: self.floor,
size: rect.size(),
expanded: Size::new(0.0, 0.0),
capped: false,
sources: ReachSources::default(),
transformed: candidate.transformed,
rule: None,
skipped: Some(SkipReason::NotOnScreen),
};
}
let mut grown = rect;
let shares = [
(rect.x - node.x).abs() <= 0.01,
(rect.right() - node.right()).abs() <= 0.01,
(rect.y - node.y).abs() <= 0.01,
(rect.bottom() - node.bottom()).abs() <= 0.01,
];
if shares[0] {
grown.x -= growth[0];
grown.width += growth[0];
}
if shares[1] {
grown.width += growth[1];
}
if shares[2] {
grown.y -= growth[2];
grown.height += growth[2];
}
if shares[3] {
grown.height += growth[3];
}
TargetMeasurement {
widget: candidate.widget,
node: candidate.node,
part: Some(part),
path: candidate.path.clone(),
density: self.density,
theme: self.theme.clone(),
conformance_floor: self.floor,
size: rect.size(),
expanded: grown.size(),
capped: false,
sources: ReachSources::default(),
transformed: candidate.transformed,
rule: self.classify(
grown.size(),
rect,
role,
(candidate.node, Some(part)),
neighbours,
),
skipped: None,
}
}
fn classify(
&self,
reach: Size,
painted: Rect,
role: TargetRole,
identity: (WidgetId, Option<u16>),
neighbours: &[Neighbour],
) -> Option<TargetRule> {
let smaller = reach.width.min(reach.height);
if smaller + PROBE_EPSILON < self.floor {
return Some(
if self.spacing_exception_applies(painted, identity, neighbours) {
TargetRule::SpacingException
} else {
TargetRule::MinTargetConformance
},
);
}
let recommended = match role {
TargetRole::Grab => self.floor,
_ => self.tokens.target_size,
};
if smaller + PROBE_EPSILON < recommended {
return Some(TargetRule::TouchTargetRecommendation);
}
None
}
fn spacing_exception_applies(
&self,
painted: Rect,
identity: (WidgetId, Option<u16>),
neighbours: &[Neighbour],
) -> bool {
let radius = self.floor / 2.0;
let centre = painted.center();
for neighbour in neighbours {
if (neighbour.node, neighbour.part) == identity {
continue;
}
if crate::pointer::hit_slop::rect_distance(neighbour.rect, centre) < radius {
return false;
}
}
true
}
fn reach(&self, centre: Point, dir: (f32, f32), node: WidgetId, limit: f32) -> Reach {
let mut last = 0.0_f32;
let mut d = PROBE_STEP;
while d <= limit {
if self.actuates(centre, dir, d, node, true) {
last = d;
d += PROBE_STEP;
} else {
break;
}
}
if last + PROBE_STEP > limit {
return Reach {
distance: last,
capped: true,
confirmed_without_slop: last > 0.0 && self.actuates(centre, dir, last, node, false),
};
}
let mut low = last;
let mut high = (last + PROBE_STEP).min(limit);
for _ in 0..PROBE_REFINE {
let mid = (low + high) / 2.0;
if self.actuates(centre, dir, mid, node, true) {
low = mid;
} else {
high = mid;
}
}
Reach {
distance: low,
capped: false,
confirmed_without_slop: low > 0.0 && self.actuates(centre, dir, low, node, false),
}
}
fn actuates(
&self,
from: Point,
dir: (f32, f32),
distance: f32,
node: WidgetId,
with_slop: bool,
) -> bool {
let at = Point::new(from.x + dir.0 * distance, from.y + dir.1 * distance);
if self.viewport.is_some_and(|v| !v.contains(at)) {
return false;
}
self.owner_at(at, with_slop) == Some(node)
}
fn owner_at(&self, at: Point, with_slop: bool) -> Option<WidgetId> {
let surfaces = self.tree.text_surfaces();
let read_only = |id: WidgetId| surfaces.is_read_only(id);
let hit = HitContext::new(PointerKind::Touch, self.tokens)
.direction(self.tree.layout_direction)
.read_only_probe(&read_only);
let hit = if with_slop { hit } else { hit.without_slop() };
let target = self.tree.hit_test_with(at, None, None, &hit)?;
let mut current = Some(target);
while let Some(id) = current {
if self.tree.arena.takes_a_press(id) {
return Some(id);
}
current = self.tree.arena.parent(id);
}
None
}
fn on_screen(&self, id: WidgetId, point: Point) -> bool {
if self.viewport.is_some_and(|v| !v.contains(point)) {
return false;
}
let arena = &self.tree.arena;
let mut current = arena.parent(id);
while let Some(ancestor) = current {
if arena
.get(ancestor)
.map(|n| n.clips_children)
.unwrap_or(false)
{
let (rect, _) = self.to_screen(ancestor, arena.bounds(ancestor));
if !rect.contains(point) {
return false;
}
}
current = arena.parent(ancestor);
}
true
}
fn is_descendant(&self, maybe_descendant: WidgetId, of: WidgetId) -> bool {
let mut current = self.tree.arena.parent(maybe_descendant);
while let Some(id) = current {
if id == of {
return true;
}
current = self.tree.arena.parent(id);
}
false
}
fn in_foreign_overlay(&self, owner: WidgetId, node: WidgetId) -> bool {
let contains = |root: WidgetId, id: WidgetId| id == root || self.is_descendant(id, root);
self.tree
.overlay_manager
.active_content_ids()
.into_iter()
.any(|content| contains(content, owner) && !contains(content, node))
}
}
struct Reach {
distance: f32,
capped: bool,
confirmed_without_slop: bool,
}
const DIRECTIONS: [(f32, f32); 4] = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)];
struct Neighbour {
node: WidgetId,
part: Option<u16>,
rect: Rect,
}
fn short_name(type_name: &'static str) -> &'static str {
let head = type_name.split('<').next().unwrap_or(type_name);
head.rsplit("::").next().unwrap_or(head)
}
#[cfg(test)]
mod tests;