use std::panic::Location;
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::overlay::overlay;
pub const ANCHOR_GAP: f32 = tokens::SPACE_1;
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum Side {
Below,
Above,
Right,
Left,
AtPoint,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Anchor {
Key { key: String, side: Side },
Id { id: String, side: Side },
Point { x: f32, y: f32, side: Side },
}
impl Anchor {
pub fn below_key(key: impl Into<String>) -> Self {
Anchor::Key {
key: key.into(),
side: Side::Below,
}
}
pub fn above_key(key: impl Into<String>) -> Self {
Anchor::Key {
key: key.into(),
side: Side::Above,
}
}
pub fn right_of_key(key: impl Into<String>) -> Self {
Anchor::Key {
key: key.into(),
side: Side::Right,
}
}
pub fn left_of_key(key: impl Into<String>) -> Self {
Anchor::Key {
key: key.into(),
side: Side::Left,
}
}
pub fn at_point(x: f32, y: f32) -> Self {
Anchor::Point {
x,
y,
side: Side::AtPoint,
}
}
pub fn below_id(id: impl Into<String>) -> Self {
Anchor::Id {
id: id.into(),
side: Side::Below,
}
}
}
pub fn anchor_rect(
anchor: &Anchor,
panel_size: (f32, f32),
viewport: Rect,
lookup: &dyn Fn(&str) -> Option<Rect>,
gap: f32,
) -> Rect {
let (w, h) = panel_size;
let (anchor_rect, side) = match anchor {
Anchor::Key { key, side } => match lookup(key) {
Some(r) => (r, *side),
None => return Rect::new(viewport.x, viewport.y, w, h),
},
Anchor::Id { id, side } => match lookup(id) {
Some(r) => (r, *side),
None => return Rect::new(viewport.x, viewport.y, w, h),
},
Anchor::Point { x, y, side } => (Rect::new(*x, *y, 0.0, 0.0), *side),
};
let (mut x, mut y) = match side {
Side::Below => (anchor_rect.x, anchor_rect.bottom() + gap),
Side::Above => (anchor_rect.x, anchor_rect.y - gap - h),
Side::Right => (anchor_rect.right() + gap, anchor_rect.y),
Side::Left => (anchor_rect.x - gap - w, anchor_rect.y),
Side::AtPoint => (anchor_rect.x, anchor_rect.y),
};
match side {
Side::Below if y + h > viewport.bottom() => {
let flipped = anchor_rect.y - gap - h;
if flipped >= viewport.y {
y = flipped;
}
}
Side::Above if y < viewport.y => {
let flipped = anchor_rect.bottom() + gap;
if flipped + h <= viewport.bottom() {
y = flipped;
}
}
Side::Right if x + w > viewport.right() => {
let flipped = anchor_rect.x - gap - w;
if flipped >= viewport.x {
x = flipped;
}
}
Side::Left if x < viewport.x => {
let flipped = anchor_rect.right() + gap;
if flipped + w <= viewport.right() {
x = flipped;
}
}
_ => {}
}
if x + w > viewport.right() {
x = viewport.right() - w;
}
if x < viewport.x {
x = viewport.x;
}
if y + h > viewport.bottom() {
y = viewport.bottom() - h;
}
if y < viewport.y {
y = viewport.y;
}
Rect::new(x, y, w, h)
}
#[track_caller]
pub fn popover(key: impl Into<String>, anchor: Anchor, panel: impl Into<El>) -> El {
let key = key.into();
let dismiss_key = format!("{key}:dismiss");
overlay([
El::new(Kind::Scrim)
.at_loc(Location::caller())
.key(dismiss_key)
.fill_size(),
anchored_panel(anchor, panel.into()),
])
}
#[track_caller]
pub fn popover_panel<I, E>(body: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
let children: Vec<El> = body.into_iter().map(Into::into).collect();
El::new(Kind::Custom("popover_panel"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::Panel)
.surface_role(SurfaceRole::Popover)
.arrow_nav_siblings()
.children(children)
.fill(tokens::POPOVER)
.stroke(tokens::BORDER)
.radius(0.0)
.shadow(tokens::SHADOW_MD)
.padding(Sides::zero())
.gap(0.0)
.width(Size::Hug)
.height(Size::Hug)
.axis(Axis::Column)
.align(Align::Stretch)
}
#[track_caller]
pub fn menu_item(label: impl Into<String>) -> El {
let label = El::new(Kind::Text)
.at_loc(Location::caller())
.style_profile(StyleProfile::TextOnly)
.text(label)
.text_role(TextRole::Label)
.text_color(tokens::FOREGROUND)
.font_weight(FontWeight::Regular)
.hug();
El::new(Kind::Custom("menu_item"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Solid)
.metrics_role(MetricsRole::MenuItem)
.focusable()
.child(label)
.fill(tokens::POPOVER)
.default_padding(Sides::xy(tokens::SPACE_3, 0.0))
.default_gap(0.0)
.width(Size::Fill(1.0))
.default_height(Size::Fixed(28.0))
.axis(Axis::Row)
.align(Align::Center)
.justify(Justify::Start)
}
#[track_caller]
pub fn context_menu<I, E>(key: impl Into<String>, point: (f32, f32), items: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
popover(
key,
Anchor::at_point(point.0, point.1),
popover_panel(items),
)
}
#[track_caller]
pub fn dropdown<I, E>(key: impl Into<String>, trigger_key: impl Into<String>, items: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
popover(key, Anchor::below_key(trigger_key), popover_panel(items))
}
#[track_caller]
fn anchored_panel(anchor: Anchor, panel: El) -> El {
let panel = panel.block_pointer();
El::new(Kind::Custom("popover_layer"))
.at_loc(Location::caller())
.child(panel)
.fill_size()
.layout(move |ctx| {
let (w, h) = (ctx.measure)(&ctx.children[0]);
let rect = anchor_rect(&anchor, (w, h), ctx.container, ctx.rect_of_key, 0.0);
vec![rect]
})
}
#[cfg(test)]
mod tests {
use super::*;
fn no_lookup() -> impl Fn(&str) -> Option<Rect> {
|_: &str| None
}
fn lookup_one(key: &'static str, rect: Rect) -> impl Fn(&str) -> Option<Rect> {
move |k: &str| if k == key { Some(rect) } else { None }
}
fn vp() -> Rect {
Rect::new(0.0, 0.0, 400.0, 300.0)
}
#[test]
fn anchor_rect_below_key_aligns_left_edge_and_drops_below() {
let trig = Rect::new(50.0, 40.0, 80.0, 24.0);
let r = anchor_rect(
&Anchor::below_key("t"),
(120.0, 60.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.x, 50.0);
assert_eq!(r.y, 40.0 + 24.0 + ANCHOR_GAP);
assert_eq!((r.w, r.h), (120.0, 60.0));
}
#[test]
fn anchor_rect_above_key_aligns_above_with_gap() {
let trig = Rect::new(60.0, 200.0, 80.0, 24.0);
let r = anchor_rect(
&Anchor::above_key("t"),
(120.0, 50.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.x, 60.0);
assert_eq!(r.y, 200.0 - ANCHOR_GAP - 50.0);
}
#[test]
fn anchor_rect_below_flips_to_above_when_overflow_bottom() {
let trig = Rect::new(50.0, 270.0, 80.0, 24.0);
let r = anchor_rect(
&Anchor::below_key("t"),
(120.0, 60.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.y, 270.0 - ANCHOR_GAP - 60.0);
}
#[test]
fn anchor_rect_below_does_not_flip_when_above_also_overflows() {
let trig = Rect::new(50.0, 280.0, 80.0, 12.0);
let r = anchor_rect(
&Anchor::below_key("t"),
(120.0, 320.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.y, 0.0);
}
#[test]
fn anchor_rect_above_flips_to_below_when_overflow_top() {
let trig = Rect::new(50.0, 10.0, 80.0, 24.0);
let r = anchor_rect(
&Anchor::above_key("t"),
(120.0, 60.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.y, 10.0 + 24.0 + ANCHOR_GAP);
}
#[test]
fn anchor_rect_right_flips_to_left_when_overflow_right() {
let trig = Rect::new(360.0, 100.0, 30.0, 30.0);
let r = anchor_rect(
&Anchor::right_of_key("t"),
(80.0, 50.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.x, 360.0 - ANCHOR_GAP - 80.0);
}
#[test]
fn anchor_rect_left_flips_to_right_when_overflow_left() {
let trig = Rect::new(10.0, 100.0, 30.0, 30.0);
let r = anchor_rect(
&Anchor::left_of_key("t"),
(80.0, 50.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.x, 10.0 + 30.0 + ANCHOR_GAP);
}
#[test]
fn anchor_rect_at_point_pins_top_left_to_point() {
let r = anchor_rect(
&Anchor::at_point(120.0, 80.0),
(60.0, 40.0),
vp(),
&no_lookup(),
ANCHOR_GAP,
);
assert_eq!((r.x, r.y), (120.0, 80.0));
}
#[test]
fn anchor_rect_at_point_clamps_into_viewport_on_overflow() {
let r = anchor_rect(
&Anchor::at_point(380.0, 280.0),
(60.0, 40.0),
vp(),
&no_lookup(),
ANCHOR_GAP,
);
assert_eq!((r.x, r.y), (340.0, 260.0));
}
#[test]
fn anchor_rect_below_clamps_x_when_panel_overflows_right() {
let trig = Rect::new(380.0, 50.0, 20.0, 20.0);
let r = anchor_rect(
&Anchor::below_key("t"),
(100.0, 40.0),
vp(),
&lookup_one("t", trig),
ANCHOR_GAP,
);
assert_eq!(r.x, 300.0);
}
#[test]
fn anchor_rect_missing_key_falls_back_to_viewport_origin() {
let r = anchor_rect(
&Anchor::below_key("missing"),
(60.0, 40.0),
vp(),
&no_lookup(),
ANCHOR_GAP,
);
assert_eq!((r.x, r.y), (vp().x, vp().y));
}
#[test]
fn popover_exposes_dismiss_scrim_and_block_pointer_on_panel() {
let p = popover(
"menu",
Anchor::at_point(100.0, 100.0),
popover_panel([menu_item("Copy"), menu_item("Paste")]),
);
let scrim = &p.children[0];
assert_eq!(scrim.key.as_deref(), Some("menu:dismiss"));
assert_eq!(scrim.kind, Kind::Scrim);
let layer = &p.children[1];
assert!(
!layer.block_pointer,
"the popover layer must be hit-test transparent — block_pointer belongs on the panel"
);
let panel = &layer.children[0];
assert!(
panel.block_pointer,
"the panel itself must block_pointer so clicks on it don't fall through"
);
}
#[test]
fn click_outside_panel_routes_to_dismiss_scrim() {
use crate::layout::layout;
use crate::state::UiState;
use crate::tree::stack;
let panel_anchor_pt = (40.0, 40.0);
let mut tree = stack([popover(
"ctx",
Anchor::at_point(panel_anchor_pt.0, panel_anchor_pt.1),
popover_panel([menu_item("A"), menu_item("B")]),
)]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 300.0));
let click_far = (350.0, 250.0);
let hit = crate::hit_test::hit_test(&tree, &state, click_far);
assert_eq!(hit.as_deref(), Some("ctx:dismiss"));
}
#[test]
fn click_inside_panel_does_not_route_to_dismiss_scrim() {
use crate::layout::layout;
use crate::state::UiState;
use crate::tree::stack;
let pt = (40.0, 40.0);
let mut tree = stack([popover(
"ctx",
Anchor::at_point(pt.0, pt.1),
popover_panel([menu_item("A"), menu_item("B")]),
)]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 300.0));
let panel_corner = (pt.0 + 1.0, pt.1 + 1.0);
let hit = crate::hit_test::hit_test(&tree, &state, panel_corner);
assert!(
hit.is_none() || hit.as_deref() != Some("ctx:dismiss"),
"click on panel must not route to dismiss; got {hit:?}",
);
}
#[test]
fn dropdown_anchors_below_trigger_key() {
let dd = dropdown("colors", "trig", [menu_item("Red"), menu_item("Blue")]);
let scrim = &dd.children[0];
assert_eq!(scrim.key.as_deref(), Some("colors:dismiss"));
let layer = &dd.children[1];
assert_eq!(layer.children.len(), 1);
let panel = &layer.children[0];
assert_eq!(panel.kind, Kind::Custom("popover_panel"));
assert_eq!(panel.children.len(), 2);
}
#[test]
fn context_menu_anchors_at_click_point() {
let cm = context_menu("ctx", (120.0, 80.0), [menu_item("Cut"), menu_item("Copy")]);
let scrim = &cm.children[0];
assert_eq!(scrim.key.as_deref(), Some("ctx:dismiss"));
}
}