use crate::session::AnnotId;
use crate::tab::Rect;
pub const FOCUS_INFLATION: f32 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LayoutBand {
Popup = 1,
Widget = 2,
Other = 5,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Candidate {
pub(crate) id: AnnotId,
pub(crate) rect: Rect,
pub(crate) band: LayoutBand,
pub(crate) widget: Option<WidgetHit>,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WidgetHit {
pub signature: bool,
pub hidden: bool,
pub read_only: bool,
pub push_button: bool,
}
impl WidgetHit {
#[must_use]
pub fn accepts_click(self, permissions: Permissions) -> bool {
if self.signature || self.hidden || self.read_only {
return false;
}
self.push_button || permissions.may_interact()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Permissions {
pub fill_form: bool,
pub modify_annotation: bool,
}
impl Permissions {
pub const ALL: Permissions = Permissions {
fill_form: true,
modify_annotation: true,
};
pub const NONE: Permissions = Permissions {
fill_form: false,
modify_annotation: false,
};
#[must_use]
pub fn may_interact(self) -> bool {
self.fill_form || self.modify_annotation
}
}
pub(crate) fn contains(rect: Rect, x: f32, y: f32) -> bool {
let rect = crate::geom::normalize(rect);
x >= rect.left && x <= rect.right && y >= rect.bottom && y <= rect.top
}
pub(crate) fn inflate(rect: Rect, by: f32) -> Rect {
Rect::new(
rect.left - by,
rect.bottom - by,
rect.right + by,
rect.top + by,
)
}
pub(crate) fn hit_order(candidates: &[Candidate], focused: Option<AnnotId>) -> Vec<Candidate> {
let mut ordered = band_sorted(candidates);
if let Some(focused) = focused
&& let Some(at) = ordered.iter().position(|c| c.id == focused)
{
let moved = ordered.remove(at);
ordered.insert(0, moved);
}
ordered
}
#[cfg(test)]
pub(crate) fn draw_order(candidates: &[Candidate], focused: Option<AnnotId>) -> Vec<Candidate> {
let mut ordered = band_sorted(candidates);
if let Some(focused) = focused
&& let Some(at) = ordered.iter().position(|c| c.id == focused)
{
let moved = ordered.remove(at);
ordered.push(moved);
}
ordered
}
fn band_sorted(candidates: &[Candidate]) -> Vec<Candidate> {
let mut ordered = candidates.to_vec();
ordered.sort_by_key(|c| c.band);
ordered
}
pub(crate) fn annot_at_point(
candidates: &[Candidate],
focused: Option<AnnotId>,
x: f32,
y: f32,
) -> Option<AnnotId> {
hit_order(candidates, focused)
.into_iter()
.find(|c| c.band != LayoutBand::Popup && contains(c.rect, x, y))
.map(|c| c.id)
}
pub(crate) fn widget_at_point(
candidates: &[Candidate],
focused: Option<AnnotId>,
permissions: Permissions,
x: f32,
y: f32,
) -> Option<AnnotId> {
hit_order(candidates, focused)
.into_iter()
.find(|c| {
let Some(widget) = c.widget else {
return false;
};
if !widget.accepts_click(permissions) {
return false;
}
let box_ = if Some(c.id) == focused {
inflate(c.rect, FOCUS_INFLATION)
} else {
c.rect
};
contains(box_, x, y)
})
.map(|c| c.id)
}
#[cfg(test)]
pub(crate) fn widget_z_order_at_point(
candidates: &[Candidate],
permissions: Permissions,
x: f32,
y: f32,
) -> Option<usize> {
band_sorted(candidates).into_iter().position(|c| {
c.widget.is_some_and(|w| w.accepts_click(permissions)) && contains(c.rect, x, y)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn plain_widget() -> WidgetHit {
WidgetHit {
signature: false,
hidden: false,
read_only: false,
push_button: false,
}
}
fn widget(index: u32, rect: Rect) -> Candidate {
Candidate {
id: AnnotId::new(0, index),
rect,
band: LayoutBand::Widget,
widget: Some(plain_widget()),
}
}
fn other(index: u32, rect: Rect) -> Candidate {
Candidate {
id: AnnotId::new(0, index),
rect,
band: LayoutBand::Other,
widget: None,
}
}
fn popup(index: u32, rect: Rect) -> Candidate {
Candidate {
id: AnnotId::new(0, index),
rect,
band: LayoutBand::Popup,
widget: None,
}
}
fn box_at(left: f32, bottom: f32) -> Rect {
Rect::new(left, bottom, left + 100.0, bottom + 50.0)
}
#[test]
fn an_inside_out_rectangle_is_still_hit_testable() {
let inverted = Rect::new(100.0, 100.0, 200.0, -130.0);
assert!(contains(inverted, 150.0, 0.0));
assert!(contains(inverted, 150.0, -100.0));
assert!(!contains(inverted, 150.0, 200.0));
let mut candidate = widget(0, inverted);
candidate.rect = inverted;
assert_eq!(
widget_at_point(&[candidate], None, Permissions::ALL, 150.0, 0.0),
Some(AnnotId::new(0, 0)),
"an inverted rect must not make a widget unclickable"
);
}
#[test]
fn containment_includes_every_edge() {
let rect = Rect::new(10.0, 20.0, 30.0, 40.0);
assert!(contains(rect, 20.0, 30.0));
assert!(contains(rect, 10.0, 20.0), "the corner is inside");
assert!(contains(rect, 30.0, 40.0), "the far corner is inside");
assert!(!contains(rect, 9.9, 30.0));
assert!(!contains(rect, 20.0, 40.1));
}
#[test]
fn hover_matches_a_non_widget_annotation() {
let candidates = [other(0, box_at(100.0, 700.0))];
assert_eq!(
annot_at_point(&candidates, None, 128.0, 713.0),
Some(AnnotId::new(0, 0))
);
assert_eq!(
widget_at_point(&candidates, None, Permissions::ALL, 128.0, 713.0),
None
);
}
#[test]
fn hover_skips_popups() {
let candidates = [popup(0, box_at(0.0, 0.0))];
assert_eq!(annot_at_point(&candidates, None, 50.0, 25.0), None);
}
#[test]
fn a_read_only_widget_is_not_clickable() {
let mut candidate = widget(0, box_at(0.0, 0.0));
candidate.widget = Some(WidgetHit {
read_only: true,
..plain_widget()
});
assert_eq!(
widget_at_point(&[candidate], None, Permissions::ALL, 50.0, 25.0),
None
);
}
#[test]
fn a_signature_or_hidden_widget_is_not_clickable() {
for hit in [
WidgetHit {
signature: true,
..plain_widget()
},
WidgetHit {
hidden: true,
..plain_widget()
},
] {
let mut candidate = widget(0, box_at(0.0, 0.0));
candidate.widget = Some(hit);
assert_eq!(
widget_at_point(&[candidate], None, Permissions::ALL, 50.0, 25.0),
None
);
}
}
#[test]
fn permissions_gate_everything_but_a_push_button() {
let ordinary = plain_widget();
assert!(!ordinary.accepts_click(Permissions::NONE));
assert!(ordinary.accepts_click(Permissions::ALL));
assert!(ordinary.accepts_click(Permissions {
fill_form: true,
modify_annotation: false
}));
assert!(ordinary.accepts_click(Permissions {
fill_form: false,
modify_annotation: true
}));
let button = WidgetHit {
push_button: true,
..plain_widget()
};
assert!(button.accepts_click(Permissions::NONE));
}
#[test]
fn the_focused_widget_wins_an_overlap() {
let candidates = [widget(0, box_at(0.0, 0.0)), widget(1, box_at(0.0, 0.0))];
assert_eq!(
widget_at_point(&candidates, None, Permissions::ALL, 50.0, 25.0),
Some(AnnotId::new(0, 0))
);
assert_eq!(
widget_at_point(
&candidates,
Some(AnnotId::new(0, 1)),
Permissions::ALL,
50.0,
25.0
),
Some(AnnotId::new(0, 1))
);
}
#[test]
fn hit_order_and_draw_order_are_mirror_images() {
let candidates = [
widget(0, box_at(0.0, 0.0)),
widget(1, box_at(0.0, 0.0)),
widget(2, box_at(0.0, 0.0)),
];
let focused = Some(AnnotId::new(0, 1));
let hit: Vec<u32> = hit_order(&candidates, focused)
.iter()
.map(|c| c.id.index)
.collect();
let draw: Vec<u32> = draw_order(&candidates, focused)
.iter()
.map(|c| c.id.index)
.collect();
assert_eq!(hit, vec![1, 0, 2], "focused first for hit testing");
assert_eq!(draw, vec![0, 2, 1], "focused last for drawing");
}
#[test]
fn the_band_sort_is_stable_within_each_band() {
let candidates = [
other(0, box_at(0.0, 0.0)),
widget(1, box_at(0.0, 0.0)),
popup(2, box_at(0.0, 0.0)),
widget(3, box_at(0.0, 0.0)),
other(4, box_at(0.0, 0.0)),
];
let ordered: Vec<u32> = hit_order(&candidates, None)
.iter()
.map(|c| c.id.index)
.collect();
assert_eq!(ordered, vec![2, 1, 3, 0, 4]);
}
#[test]
fn a_focused_widget_has_a_slightly_larger_target() {
let candidates = [widget(0, Rect::new(10.0, 10.0, 20.0, 20.0))];
let just_outside = (20.5, 15.0);
assert_eq!(
widget_at_point(
&candidates,
None,
Permissions::ALL,
just_outside.0,
just_outside.1
),
None
);
assert_eq!(
widget_at_point(
&candidates,
Some(AnnotId::new(0, 0)),
Permissions::ALL,
just_outside.0,
just_outside.1
),
Some(AnnotId::new(0, 0))
);
}
#[test]
fn a_point_over_nothing_hits_nothing() {
let candidates = [widget(0, box_at(100.0, 100.0))];
assert_eq!(
widget_at_point(&candidates, None, Permissions::ALL, 1.0, 1.0),
None
);
assert_eq!(annot_at_point(&candidates, None, 1.0, 1.0), None);
assert_eq!(
widget_z_order_at_point(&candidates, Permissions::ALL, 1.0, 1.0),
None
);
}
#[test]
fn an_empty_page_hits_nothing() {
assert_eq!(widget_at_point(&[], None, Permissions::ALL, 0.0, 0.0), None);
assert_eq!(annot_at_point(&[], None, 0.0, 0.0), None);
}
#[test]
fn a_page_with_a_popup_reports_raw_annots_indices() {
let candidates = [
popup(0, box_at(0.0, 600.0)),
widget(1, box_at(100.0, 400.0)),
widget(2, box_at(100.0, 200.0)),
];
assert_eq!(
widget_at_point(&candidates, None, Permissions::ALL, 150.0, 425.0),
Some(AnnotId::new(0, 1))
);
assert_eq!(
widget_at_point(&candidates, None, Permissions::ALL, 150.0, 225.0),
Some(AnnotId::new(0, 2))
);
assert_eq!(annot_at_point(&candidates, None, 50.0, 625.0), None);
assert_eq!(
annot_at_point(&candidates, None, 150.0, 425.0),
Some(AnnotId::new(0, 1))
);
}
#[test]
fn the_band_sort_reorders_without_renumbering() {
let candidates = [
widget(0, box_at(0.0, 0.0)),
popup(1, box_at(0.0, 0.0)),
widget(2, box_at(0.0, 0.0)),
];
let ordered: Vec<u32> = hit_order(&candidates, None)
.iter()
.map(|c| c.id.index)
.collect();
assert_eq!(ordered, vec![1, 0, 2]);
}
#[test]
fn z_order_counts_from_the_band_sorted_list() {
let candidates = [
other(0, box_at(0.0, 0.0)),
widget(1, box_at(0.0, 0.0)),
popup(2, box_at(0.0, 0.0)),
];
assert_eq!(
widget_z_order_at_point(&candidates, Permissions::ALL, 50.0, 25.0),
Some(1)
);
}
}