use std::ops::Range;
use crate::event::UiTarget;
use crate::selection::SelectionPoint;
use crate::state::UiState;
use crate::text::metrics;
use crate::tree::{El, FontWeight, Kind, Rect, TextWrap};
pub fn hit_test(root: &El, ui_state: &UiState, point: (f32, f32)) -> Option<String> {
hit_test_target(root, ui_state, point).map(|target| target.key)
}
pub fn hit_test_target(root: &El, ui_state: &UiState, point: (f32, f32)) -> Option<UiTarget> {
match hit_test_rec(root, ui_state, point, None, (0.0, 0.0)) {
Hit::Target(target) => Some(target),
Hit::Blocked | Hit::Miss => None,
}
}
enum Hit {
Target(UiTarget),
Blocked,
Miss,
}
fn hit_test_rec(
node: &El,
ui_state: &UiState,
point: (f32, f32),
inherited_clip: Option<Rect>,
inherited_translate: (f32, f32),
) -> Hit {
if let Some(clip) = inherited_clip
&& !clip.contains(point.0, point.1)
{
return Hit::Miss;
}
let total_translate = (
inherited_translate.0 + node.translate.0,
inherited_translate.1 + node.translate.1,
);
let computed = ui_state.rect(&node.computed_id);
let translated_rect = translated(computed, total_translate);
let painted_rect = scaled_around_center(translated_rect, node.scale);
let child_clip = if node.clip {
match inherited_clip {
Some(clip) => Some(
clip.intersect(painted_rect)
.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)),
),
None => Some(painted_rect),
}
} else {
inherited_clip
};
for child in node.children.iter().rev() {
match hit_test_rec(child, ui_state, point, child_clip, total_translate) {
Hit::Target(target) => return Hit::Target(target),
Hit::Blocked => return Hit::Blocked,
Hit::Miss => {}
}
}
let hit_rect = painted_rect.outset(node.hit_overflow);
if !hit_rect.contains(point.0, point.1) {
return Hit::Miss;
}
if let Some(key) = &node.key {
return Hit::Target(UiTarget {
key: key.clone(),
node_id: node.computed_id.clone(),
rect: painted_rect,
tooltip: node.tooltip.clone(),
scroll_offset_y: nearest_descendant_scroll_offset_y(node, ui_state),
});
}
if node.block_pointer {
return Hit::Blocked;
}
Hit::Miss
}
pub fn selection_point_at(
root: &El,
ui_state: &UiState,
point: (f32, f32),
) -> Option<SelectionPoint> {
let mut hit: Option<SelectableHit<'_>> = None;
selectable_rec(root, ui_state, point, None, (0.0, 0.0), &mut hit);
let SelectableHit { node, painted } = hit?;
let key = node.key.clone()?;
let value = node
.selection_source
.as_ref()
.map(|source| source.visible.as_str())
.or(node.text.as_deref())?;
if matches!(node.kind, Kind::Inlines)
&& node.children.iter().any(|c| matches!(c.kind, Kind::Math))
&& let Some(byte) = mixed_inline_hit_byte(node, painted, point)
{
return Some(SelectionPoint { key, byte });
}
let local_x = (point.0 - painted.x).max(0.0);
let local_y = (point.1 - painted.y).clamp(0.0, painted.h.max(1.0) - 1.0);
let geometry = metrics::TextGeometry::new_with_family(
value,
node.font_size,
effective_text_family(node),
node.font_weight,
node.font_mono,
node.text_wrap,
Some(painted.w),
);
let byte = match geometry.hit_byte(local_x, local_y) {
Some(byte) => byte.min(value.len()),
None => {
if local_x <= 0.0 {
0
} else {
value.len()
}
}
};
Some(SelectionPoint { key, byte })
}
fn mixed_inline_hit_byte(node: &El, painted_rect: Rect, point: (f32, f32)) -> Option<usize> {
let glyph_rect = painted_rect.inset(node.padding);
let items = mixed_inline_hit_items(node, glyph_rect);
if items.is_empty() {
return Some(0);
}
let line = mixed_hit_line(&items, point.1);
let line_items: Vec<&MixedHitItem> = items.iter().filter(|item| item.line == line).collect();
let first = line_items.first()?;
let last = line_items.last()?;
if point.0 <= first.rect.x {
return Some(first.visible.start);
}
for item in &line_items {
if point.0 <= item.rect.right() {
return Some(match &item.kind {
MixedHitKind::Text {
text,
font_size,
font_family,
font_weight,
font_mono,
} => {
let geometry = metrics::TextGeometry::new_with_family(
text,
*font_size,
*font_family,
*font_weight,
*font_mono,
TextWrap::NoWrap,
None,
);
let local_x = (point.0 - item.rect.x).max(0.0);
let local_y = (point.1 - item.rect.y).clamp(0.0, item.rect.h.max(1.0) - 1.0);
let byte = geometry
.hit_byte(local_x, local_y)
.unwrap_or(if local_x <= 0.0 { 0 } else { text.len() });
item.visible.start + byte.min(text.len())
}
MixedHitKind::Atomic => {
if point.0 < item.rect.center_x() {
item.visible.start
} else {
item.visible.end
}
}
});
}
}
Some(last.visible.end)
}
#[derive(Clone)]
struct PendingMixedHitItem {
kind: PendingMixedHitKind,
x: f32,
visible: Range<usize>,
}
#[derive(Clone)]
enum PendingMixedHitKind {
Text { child: Box<El>, text: String },
Math { layout: crate::math::MathLayout },
}
struct MixedHitItem {
rect: Rect,
line_top: f32,
line_bottom: f32,
line: usize,
visible: Range<usize>,
kind: MixedHitKind,
}
enum MixedHitKind {
Text {
text: String,
font_size: f32,
font_family: crate::tree::FontFamily,
font_weight: FontWeight,
font_mono: bool,
},
Atomic,
}
fn mixed_inline_hit_items(node: &El, rect: Rect) -> Vec<MixedHitItem> {
let mut breaker = crate::inline_mixed::MixedInlineBreaker::new(
node.text_wrap,
Some(rect.w),
node.font_size * 0.82,
node.font_size * 0.22,
node.line_height,
);
let mut pending = Vec::new();
let mut out = Vec::new();
let mut visible_cursor = 0usize;
let mut line_index = 0usize;
for child in &node.children {
match child.kind {
Kind::HardBreak => {
flush_mixed_hit_line(node, rect, &mut breaker, &mut pending, &mut out, line_index);
line_index += 1;
visible_cursor += "\n".len();
}
Kind::Text => {
if let Some(text) = &child.text {
for chunk in inline_text_chunks(text) {
let visible = visible_cursor..(visible_cursor + chunk.len());
visible_cursor += chunk.len();
let is_space = chunk.chars().all(char::is_whitespace);
if breaker.skips_leading_space(is_space) {
continue;
}
let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
if breaker.wraps_before(is_space, w) {
flush_mixed_hit_line(
node,
rect,
&mut breaker,
&mut pending,
&mut out,
line_index,
);
line_index += 1;
}
if breaker.skips_overflowing_space(is_space, w) {
continue;
}
if is_space
&& !matches!(
pending.last(),
Some(PendingMixedHitItem {
kind: PendingMixedHitKind::Text { .. },
..
})
)
{
breaker.push(w, ascent, descent);
continue;
}
pending.push(PendingMixedHitItem {
kind: PendingMixedHitKind::Text {
child: Box::new(child.clone()),
text: chunk.to_string(),
},
x: breaker.x(),
visible,
});
breaker.push(w, ascent, descent);
}
}
}
Kind::Math => {
if let Some(expr) = &child.math {
let layout =
crate::math::layout_math(expr, child.font_size, child.math_display);
if breaker.wraps_before(false, layout.width) {
flush_mixed_hit_line(
node,
rect,
&mut breaker,
&mut pending,
&mut out,
line_index,
);
line_index += 1;
}
let visible_len = "\u{fffc}".len();
let visible = visible_cursor..(visible_cursor + visible_len);
visible_cursor += visible_len;
let width = layout.width;
let ascent = layout.ascent;
let descent = layout.descent;
pending.push(PendingMixedHitItem {
kind: PendingMixedHitKind::Math { layout },
x: breaker.x(),
visible,
});
breaker.push(width, ascent, descent);
}
}
_ => {}
}
}
flush_mixed_hit_line(node, rect, &mut breaker, &mut pending, &mut out, line_index);
out
}
fn flush_mixed_hit_line(
parent: &El,
rect: Rect,
breaker: &mut crate::inline_mixed::MixedInlineBreaker,
pending: &mut Vec<PendingMixedHitItem>,
out: &mut Vec<MixedHitItem>,
line_index: usize,
) {
let line = breaker.finish_line();
let line_top = rect.y + line.top;
let line_bottom = line_top + (line.ascent + line.descent).max(parent.line_height);
let baseline_y = rect.y + line.top + line.ascent;
for item in pending.drain(..) {
match item.kind {
PendingMixedHitKind::Text { child, text } => {
let size = child.font_size * parent.scale;
let glyph_layout = metrics::layout_text_with_line_height_and_family(
&text,
size,
child.line_height * parent.scale,
child.font_family,
child.font_weight,
child.font_mono,
TextWrap::NoWrap,
None,
);
let glyph_baseline = glyph_layout
.lines
.first()
.map(|line| line.baseline)
.unwrap_or_else(|| metrics::line_height(size) * 0.75);
out.push(MixedHitItem {
rect: Rect::new(
rect.x + item.x,
baseline_y - glyph_baseline,
glyph_layout.width,
glyph_layout.height,
),
line_top,
line_bottom,
line: line_index,
visible: item.visible,
kind: MixedHitKind::Text {
text,
font_size: size,
font_family: child.font_family,
font_weight: child.font_weight,
font_mono: child.font_mono,
},
});
}
PendingMixedHitKind::Math { layout } => {
out.push(MixedHitItem {
rect: Rect::new(
rect.x + item.x,
baseline_y - layout.ascent,
layout.width,
layout.height(),
),
line_top,
line_bottom,
line: line_index,
visible: item.visible,
kind: MixedHitKind::Atomic,
});
}
}
}
}
fn mixed_hit_line(items: &[MixedHitItem], y: f32) -> usize {
for item in items {
if y >= item.line_top && y <= item.line_bottom {
return item.line;
}
}
items
.iter()
.min_by(|a, b| {
let ac = (a.line_top + a.line_bottom) * 0.5;
let bc = (b.line_top + b.line_bottom) * 0.5;
(y - ac).abs().total_cmp(&(y - bc).abs())
})
.map(|item| item.line)
.unwrap_or(0)
}
fn inline_text_chunks(text: &str) -> Vec<&str> {
let mut chunks = Vec::new();
let mut start = 0;
let mut last_space = None;
for (i, ch) in text.char_indices() {
let is_space = ch.is_whitespace();
match last_space {
None => last_space = Some(is_space),
Some(prev) if prev != is_space => {
chunks.push(&text[start..i]);
start = i;
last_space = Some(is_space);
}
_ => {}
}
}
if start < text.len() {
chunks.push(&text[start..]);
}
chunks
}
fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
let layout = metrics::layout_text_with_line_height_and_family(
text,
child.font_size,
child.line_height,
child.font_family,
child.font_weight,
child.font_mono,
TextWrap::NoWrap,
None,
);
(layout.width, child.font_size * 0.82, child.font_size * 0.22)
}
pub fn link_at(root: &El, ui_state: &UiState, point: (f32, f32)) -> Option<String> {
link_at_rec(root, ui_state, point, None, (0.0, 0.0))
}
fn link_at_rec(
node: &El,
ui_state: &UiState,
point: (f32, f32),
inherited_clip: Option<Rect>,
inherited_translate: (f32, f32),
) -> Option<String> {
if let Some(clip) = inherited_clip
&& !clip.contains(point.0, point.1)
{
return None;
}
let total_translate = (
inherited_translate.0 + node.translate.0,
inherited_translate.1 + node.translate.1,
);
let computed = ui_state.rect(&node.computed_id);
let translated_rect = translated(computed, total_translate);
let painted_rect = scaled_around_center(translated_rect, node.scale);
let child_clip = if node.clip {
match inherited_clip {
Some(clip) => Some(
clip.intersect(painted_rect)
.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)),
),
None => Some(painted_rect),
}
} else {
inherited_clip
};
for child in node.children.iter().rev() {
if let Some(url) = link_at_rec(child, ui_state, point, child_clip, total_translate) {
return Some(url);
}
}
if !matches!(node.kind, Kind::Inlines) {
return None;
}
if !painted_rect.contains(point.0, point.1) {
return None;
}
link_in_inlines_at(node, painted_rect, point)
}
fn link_in_inlines_at(node: &El, painted_rect: Rect, point: (f32, f32)) -> Option<String> {
let glyph_rect = painted_rect.inset(node.padding);
if !glyph_rect.contains(point.0, point.1) {
return None;
}
let runs = collect_link_runs(node);
if runs.iter().all(|(_, link)| link.is_none()) {
return None;
}
let concat: String = runs.iter().map(|(t, _)| t.as_str()).collect();
if concat.is_empty() {
return None;
}
let inline_size = inline_paragraph_font_size(node) * node.scale;
let geometry = metrics::TextGeometry::new_with_family(
&concat,
inline_size,
node.font_family,
FontWeight::Regular,
false,
node.text_wrap,
match node.text_wrap {
TextWrap::NoWrap => None,
TextWrap::Wrap => Some(glyph_rect.w),
},
);
let local_x = (point.0 - glyph_rect.x).max(0.0);
let local_y = (point.1 - glyph_rect.y).max(0.0);
let byte = geometry.hit_byte(local_x, local_y)?;
let mut offset = 0usize;
for (text, link) in &runs {
let len = text.len();
if byte < offset + len {
return link.clone();
}
offset += len;
}
None
}
fn collect_link_runs(node: &El) -> Vec<(String, Option<String>)> {
let mut runs = Vec::new();
for c in &node.children {
match c.kind {
Kind::Text => {
if let Some(text) = &c.text {
runs.push((text.clone(), c.text_link.clone()));
}
}
Kind::HardBreak => runs.push(("\n".to_string(), None)),
_ => {}
}
}
runs
}
fn inline_paragraph_font_size(node: &El) -> f32 {
let mut size: f32 = node.font_size;
for c in &node.children {
if matches!(c.kind, Kind::Text) {
size = size.max(c.font_size);
}
}
size
}
struct SelectableHit<'a> {
node: &'a El,
painted: Rect,
}
fn effective_text_family(node: &El) -> crate::tree::FontFamily {
if node.font_mono {
node.mono_font_family
} else {
node.font_family
}
}
fn selectable_rec<'a>(
node: &'a El,
ui_state: &UiState,
point: (f32, f32),
inherited_clip: Option<Rect>,
inherited_translate: (f32, f32),
out: &mut Option<SelectableHit<'a>>,
) {
if let Some(clip) = inherited_clip
&& !clip.contains(point.0, point.1)
{
return;
}
let total_translate = (
inherited_translate.0 + node.translate.0,
inherited_translate.1 + node.translate.1,
);
let computed = ui_state.rect(&node.computed_id);
let translated_rect = translated(computed, total_translate);
let painted_rect = scaled_around_center(translated_rect, node.scale);
let child_clip = if node.clip {
match inherited_clip {
Some(clip) => Some(
clip.intersect(painted_rect)
.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)),
),
None => Some(painted_rect),
}
} else {
inherited_clip
};
for child in node.children.iter().rev() {
selectable_rec(child, ui_state, point, child_clip, total_translate, out);
if out.is_some() {
return;
}
}
if node.selectable
&& node.key.is_some()
&& (matches!(node.kind, Kind::Text | Kind::Heading) || node.selection_source.is_some())
&& painted_rect.contains(point.0, point.1)
{
*out = Some(SelectableHit {
node,
painted: painted_rect,
});
}
}
pub(crate) fn scroll_target_at(root: &El, ui_state: &UiState, point: (f32, f32)) -> Option<String> {
let mut hit = None;
scroll_target_rec(root, ui_state, point, None, (0.0, 0.0), &mut hit);
hit
}
fn scroll_target_rec(
node: &El,
ui_state: &UiState,
point: (f32, f32),
inherited_clip: Option<Rect>,
inherited_translate: (f32, f32),
out: &mut Option<String>,
) {
if let Some(clip) = inherited_clip
&& !clip.contains(point.0, point.1)
{
return;
}
let total_translate = (
inherited_translate.0 + node.translate.0,
inherited_translate.1 + node.translate.1,
);
let computed = ui_state.rect(&node.computed_id);
let translated_rect = translated(computed, total_translate);
let painted_rect = scaled_around_center(translated_rect, node.scale);
if node.scrollable && painted_rect.contains(point.0, point.1) {
*out = Some(node.computed_id.clone());
}
let child_clip = if node.clip {
match inherited_clip {
Some(clip) => Some(
clip.intersect(painted_rect)
.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)),
),
None => Some(painted_rect),
}
} else {
inherited_clip
};
for c in &node.children {
scroll_target_rec(c, ui_state, point, child_clip, total_translate, out);
}
}
fn nearest_descendant_scroll_offset_y(node: &El, ui_state: &UiState) -> f32 {
if matches!(node.kind, Kind::Scroll) {
return ui_state
.scroll
.offsets
.get(&node.computed_id)
.copied()
.unwrap_or(0.0);
}
for c in &node.children {
if let Some(off) = find_scroll_offset_y(c, ui_state) {
return off;
}
}
0.0
}
fn find_scroll_offset_y(node: &El, ui_state: &UiState) -> Option<f32> {
if matches!(node.kind, Kind::Scroll) {
return Some(
ui_state
.scroll
.offsets
.get(&node.computed_id)
.copied()
.unwrap_or(0.0),
);
}
node.children
.iter()
.find_map(|c| find_scroll_offset_y(c, ui_state))
}
fn translated(r: Rect, offset: (f32, f32)) -> Rect {
if offset.0 == 0.0 && offset.1 == 0.0 {
return r;
}
Rect::new(r.x + offset.0, r.y + offset.1, r.w, r.h)
}
fn scaled_around_center(r: Rect, s: f32) -> Rect {
if (s - 1.0).abs() < f32::EPSILON {
return r;
}
let cx = r.center_x();
let cy = r.center_y();
let w = r.w * s;
let h = r.h * s;
Rect::new(cx - w * 0.5, cy - h * 0.5, w, h)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::layout;
use crate::state::UiState;
use crate::tree::*;
use crate::{button, column, row};
fn lay_out_counter() -> (El, UiState) {
let mut tree = column([
crate::text("0"),
row([button("-").key("dec"), button("+").key("inc")]),
])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
(tree, state)
}
fn find_rect(node: &El, state: &UiState, key: &str) -> Option<Rect> {
if node.key.as_deref() == Some(key) {
return Some(state.rect(&node.computed_id));
}
node.children.iter().find_map(|c| find_rect(c, state, key))
}
fn find_text_rect(node: &El, state: &UiState) -> Option<Rect> {
if matches!(node.kind, Kind::Text) {
return Some(state.rect(&node.computed_id));
}
node.children.iter().find_map(|c| find_text_rect(c, state))
}
fn find_inlines_rect(node: &El, state: &UiState) -> Option<Rect> {
if matches!(node.kind, Kind::Inlines) {
return Some(state.rect(&node.computed_id));
}
node.children
.iter()
.find_map(|c| find_inlines_rect(c, state))
}
#[test]
fn link_at_resolves_per_run_inside_inline_paragraph() {
const PREFIX: &str = "Visit ";
const LINKED: &str = "github.com/computer-whisperer/aetna";
const URL: &str = "https://github.com/computer-whisperer/aetna";
let mut tree = column([crate::text_runs([
crate::text(PREFIX),
crate::text(LINKED).link(URL),
crate::text("."),
])])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 600.0, 200.0));
let para = find_inlines_rect(&tree, &state).expect("inlines rect");
let cy = para.y + para.h * 0.5;
let prefix_x = para.x + 1.0;
assert_eq!(
link_at(&tree, &state, (prefix_x, cy)),
None,
"clicking the unlinked prefix should not surface the link URL",
);
let linked_x = para.x + para.w * 0.5;
assert_eq!(
link_at(&tree, &state, (linked_x, cy)).as_deref(),
Some(URL),
"clicking inside the linked run should surface its URL",
);
}
#[test]
fn selection_point_for_mixed_inline_respects_math_widths() {
use crate::selection::SelectionSource;
const TEXT_A: &str = "alpha ";
const TEXT_B: &str = " middle ";
let object = "\u{fffc}";
let visible = format!("{TEXT_A}{object}{TEXT_B}{object}");
let expr_a = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("fixture TeX parses");
let expr_b = crate::math::parse_tex(r"\sqrt{x_1+x_2}").expect("fixture TeX parses");
let mut tree = column([crate::text_runs([
crate::text(TEXT_A),
crate::math_inline(expr_a.clone()),
crate::text(TEXT_B),
crate::math_inline(expr_b.clone()),
])
.key("mixed")
.selectable()
.selection_source(SelectionSource::identity(visible))])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 700.0, 200.0));
let para = find_inlines_rect(&tree, &state).expect("inlines rect");
let text_a_w = metrics::line_width_with_family(
TEXT_A,
16.0,
FontFamily::default(),
FontWeight::Regular,
false,
);
let math_a_w =
crate::math::layout_math(&expr_a, 16.0, crate::math::MathDisplay::Inline).width;
let text_b_w = metrics::line_width_with_family(
TEXT_B,
16.0,
FontFamily::default(),
FontWeight::Regular,
false,
);
let probe_x = para.x + text_a_w + math_a_w + text_b_w * 0.5;
let probe_y = para.center_y();
let point = selection_point_at(&tree, &state, (probe_x, probe_y)).expect("selection point");
let text_b_start = TEXT_A.len() + object.len();
let math_b_start = text_b_start + TEXT_B.len();
assert_eq!(point.key, "mixed");
assert!(
point.byte >= text_b_start && point.byte < math_b_start,
"probe inside second text run must not jump to second math atom; got byte {}, expected {}..{}",
point.byte,
text_b_start,
math_b_start,
);
}
#[test]
fn hit_test_finds_keyed_button() {
let (tree, state) = lay_out_counter();
for key in &["dec", "inc"] {
let r = find_rect(&tree, &state, key).expect("button rect");
let center = (r.x + r.w * 0.5, r.y + r.h * 0.5);
let hit = hit_test(&tree, &state, center);
assert_eq!(hit.as_deref(), Some(*key));
}
}
#[test]
fn hit_overflow_expands_pointer_target_but_not_target_rect() {
let mut tree = column([button("x")
.key("x")
.hit_overflow(Sides::all(8.0))
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0))])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let rect = find_rect(&tree, &state, "x").expect("button rect");
let target = hit_test_target(&tree, &state, (rect.x - 4.0, rect.center_y()))
.expect("left hit overflow should route to the button");
assert_eq!(target.key, "x");
assert_eq!(
target.rect, rect,
"hit overflow should not change UiTarget::rect used by widgets for pointer math"
);
}
#[test]
fn paint_overflow_does_not_expand_pointer_target() {
let mut tree = column([button("x")
.key("x")
.paint_overflow(Sides::all(8.0))
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0))])
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let rect = find_rect(&tree, &state, "x").expect("button rect");
assert_eq!(
hit_test(&tree, &state, (rect.x - 4.0, rect.center_y())),
None,
"paint overflow is visual only; hit-test requires explicit hit_overflow"
);
}
#[test]
fn hit_overflow_respects_clipping_ancestor() {
let mut tree = column([button("x")
.key("x")
.hit_overflow(Sides::left(16.0))
.width(Size::Fixed(40.0))
.height(Size::Fixed(24.0))])
.clip()
.padding(20.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(20.0, 0.0, 120.0, 80.0));
let rect = find_rect(&tree, &state, "x").expect("button rect");
assert_eq!(
hit_test(&tree, &state, (rect.x - 8.0, rect.center_y())).as_deref(),
Some("x"),
"overflow inside the ancestor clip should remain hittable"
);
assert_eq!(
hit_test(&tree, &state, (19.0, rect.center_y())),
None,
"ancestor clip should bound hit overflow"
);
}
#[test]
fn hit_test_misses_unkeyed_text() {
let (tree, state) = lay_out_counter();
let r = find_text_rect(&tree, &state).expect("text rect");
let center = (r.x + r.w * 0.5, r.y + r.h * 0.5);
assert!(hit_test(&tree, &state, center).is_none());
}
#[test]
fn hit_test_outside_returns_none() {
let (tree, state) = lay_out_counter();
assert!(hit_test(&tree, &state, (-10.0, -10.0)).is_none());
assert!(hit_test(&tree, &state, (9999.0, 9999.0)).is_none());
}
#[test]
fn hit_test_respects_clipping_ancestor() {
let mut tree = column([row([
button("-").key("visible"),
button("+").key("clipped").width(Size::Fixed(240.0)),
])
.clip()
.width(Size::Fixed(80.0))]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
let clipped = find_rect(&tree, &state, "clipped").expect("clipped button rect");
assert!(hit_test(&tree, &state, (clipped.center_x(), clipped.center_y())).is_none());
}
#[test]
fn hit_test_follows_ancestor_translate() {
let mut tree = row([
column([button("A").key("a")]).translate(120.0, 0.0),
button("B").key("b"),
]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
let untranslated = find_rect(&tree, &state, "a").expect("a layout rect");
let translated_center = (untranslated.center_x() + 120.0, untranslated.center_y());
let untranslated_center = (untranslated.center_x(), untranslated.center_y());
assert_eq!(
hit_test(&tree, &state, translated_center).as_deref(),
Some("a"),
"click at translated location should hit the translated button"
);
assert_ne!(
hit_test(&tree, &state, untranslated_center).as_deref(),
Some("a"),
"click at the un-translated layout slot must not hit the translated button"
);
}
#[test]
fn hit_test_child_lifted_above_parent_still_hits() {
let mut tree = row([crate::titled_card("c", [crate::text("body")])
.key("swatch")
.width(Size::Fixed(120.0))
.height(Size::Fixed(120.0))
.scale(1.15)
.translate(0.0, -20.0)]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
let layout_rect = find_rect(&tree, &state, "swatch").expect("swatch rect");
let painted_top_y = layout_rect.y - 20.0 - layout_rect.h * 0.075 + 1.0;
let painted_top_x = layout_rect.center_x();
assert_eq!(
hit_test(&tree, &state, (painted_top_x, painted_top_y)).as_deref(),
Some("swatch"),
"click on lifted top of scaled+translated child should hit"
);
}
#[test]
fn hit_test_translate_inherits_to_descendants() {
let mut tree = column([row([button("X").key("x")]).translate(0.0, 50.0)]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
let pre = find_rect(&tree, &state, "x").expect("x layout rect");
let translated = (pre.center_x(), pre.center_y() + 50.0);
assert_eq!(
hit_test(&tree, &state, translated).as_deref(),
Some("x"),
"ancestor translate must accumulate to descendants"
);
}
#[test]
fn unkeyed_blocking_node_stops_fallthrough() {
use crate::tree::stack;
let mut tree = stack([
El::new(Kind::Scrim)
.key("dismiss")
.fill(crate::tokens::OVERLAY_SCRIM)
.fill_size(),
El::new(Kind::Modal)
.block_pointer()
.width(Size::Fixed(100.0))
.height(Size::Fixed(100.0)),
])
.align(Align::Center)
.justify(Justify::Center)
.fill_size();
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 300.0));
assert!(hit_test(&tree, &state, (150.0, 150.0)).is_none());
assert_eq!(
hit_test(&tree, &state, (10.0, 10.0)).as_deref(),
Some("dismiss")
);
}
}