use std::panic::Location;
use super::text::h3;
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
#[track_caller]
pub fn overlay<I, E>(children: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
El::new(Kind::Overlay)
.at_loc(Location::caller())
.children(children)
.fill_size()
.align(Align::Center)
.justify(Justify::Center)
.axis(Axis::Overlay)
.clip()
}
#[track_caller]
pub fn overlays<I>(main: impl Into<El>, layers: I) -> El
where
I: IntoIterator<Item = Option<El>>,
{
let mut main = main.into();
if matches!(main.width, Size::Hug) {
main.width = Size::Fill(1.0);
}
if matches!(main.height, Size::Hug) {
main.height = Size::Fill(1.0);
}
let mut children: Vec<El> = Vec::new();
children.push(main);
children.extend(layers.into_iter().flatten());
crate::stack(children)
}
#[track_caller]
pub fn scrim(key: impl Into<String>) -> El {
El::new(Kind::Scrim)
.at_loc(Location::caller())
.key(key)
.fill(tokens::OVERLAY_SCRIM)
.fill_size()
}
#[track_caller]
pub fn modal<I, E>(key: impl Into<String>, title: impl Into<String>, body: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
let key = key.into();
overlay([
scrim(format!("{key}:dismiss")),
modal_panel(title, body).block_pointer(),
])
}
#[track_caller]
pub fn modal_panel<I, E>(title: impl Into<String>, body: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
let mut children: Vec<El> = vec![h3(title)];
children.extend(body.into_iter().map(Into::into));
El::new(Kind::Modal)
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::Panel)
.surface_role(SurfaceRole::Popover)
.children(children)
.fill(tokens::POPOVER)
.stroke(tokens::BORDER)
.default_radius(tokens::RADIUS_LG)
.shadow(tokens::SHADOW_LG)
.default_padding(tokens::SPACE_4)
.default_gap(tokens::SPACE_3)
.width(Size::Fixed(420.0))
.height(Size::Hug)
.axis(Axis::Column)
.align(Align::Stretch)
.clip()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::button::button;
#[test]
fn block_pointer_panel_owns_inflated_child_clicks_over_scrim_sibling() {
use crate::Align;
use crate::Sides;
use crate::hit_test;
use crate::layout::layout;
use crate::state::UiState;
use crate::tree::{column, divider, row, scroll, stack};
use crate::widgets::sidebar::{sidebar, sidebar_menu_button};
let nav = sidebar([
sidebar_menu_button("Connection", true).key("settings:tabs:tab:connection"),
sidebar_menu_button("Devices", false).key("settings:tabs:tab:devices"),
sidebar_menu_button("Voice", false).key("settings:tabs:tab:voice"),
])
.width(Size::Fixed(196.0))
.height(Size::Fill(1.0))
.padding(Sides::all(crate::tokens::SPACE_2))
.gap(crate::tokens::SPACE_1);
let body_scroll = scroll([column([crate::widgets::text::text("Body")])
.padding(Sides::xy(12.0, 0.0))
.width(Size::Fill(1.0))])
.width(Size::Fill(1.0))
.height(Size::Fill(1.0));
let panel = modal_panel(
"Settings",
[
row([nav, body_scroll])
.gap(12.0)
.width(Size::Fill(1.0))
.height(Size::Fill(1.0))
.align(Align::Stretch),
divider(),
row([
button("Close").key("settings:close"),
crate::spacer(),
button("Save").primary().key("settings:save"),
])
.gap(8.0)
.width(Size::Fill(1.0))
.align(Align::Center),
],
)
.width(Size::Fixed(960.0))
.height(Size::Fixed(620.0));
let panel_layer = overlay([scrim("settings:dismiss"), panel.block_pointer()]);
let mut tree = stack([panel_layer]);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 1280.0, 800.0));
let conn_id = state
.layout
.key_index
.get("settings:tabs:tab:connection")
.cloned()
.expect("connection button laid out");
let conn_rect = state
.layout
.computed_rects
.get(&conn_id)
.copied()
.expect("connection rect");
let gap_click = (conn_rect.center_x(), conn_rect.bottom() + 2.0);
let hit = hit_test::hit_test(&tree, &state, gap_click);
assert!(
hit.as_deref() != Some("settings:dismiss"),
"click at {gap_click:?} in sidebar gap must NOT route to settings:dismiss; \
got {hit:?} — block_pointer on the modal panel should own this click",
);
let scrim_pt = (40.0, 40.0);
assert_eq!(
hit_test::hit_test(&tree, &state, scrim_pt).as_deref(),
Some("settings:dismiss"),
"click in the dim region outside the panel must still dismiss",
);
}
#[test]
fn overlays_filters_none_layers_in_order() {
let main = button("main").key("main");
let one = button("one").key("one");
let two = button("two").key("two");
let stacked = overlays(main, [None, Some(one), None, Some(two)]);
let keys: Vec<_> = stacked
.children
.iter()
.map(|c| c.key.clone().unwrap_or_default())
.collect();
assert_eq!(keys, vec!["main", "one", "two"]);
}
#[test]
fn overlays_with_no_layers_is_just_main_in_a_stack() {
let stacked = overlays(button("main").key("main"), std::iter::empty::<Option<El>>());
assert_eq!(stacked.children.len(), 1);
assert_eq!(stacked.children[0].key.as_deref(), Some("main"));
}
#[test]
fn overlays_promotes_hug_main_to_fill() {
let main = crate::column(std::iter::empty::<El>());
assert!(matches!(main.width, Size::Hug));
assert!(matches!(main.height, Size::Hug));
let stacked = overlays(main, std::iter::empty::<Option<El>>());
let promoted = &stacked.children[0];
assert!(matches!(promoted.width, Size::Fill(_)));
assert!(matches!(promoted.height, Size::Fill(_)));
}
#[test]
fn overlays_preserves_explicit_main_sizes() {
let main = crate::column(std::iter::empty::<El>())
.width(Size::Fixed(320.0))
.height(Size::Fill(1.0));
let stacked = overlays(main, std::iter::empty::<Option<El>>());
let kept = &stacked.children[0];
assert!(matches!(kept.width, Size::Fixed(v) if (v - 320.0).abs() < 0.01));
assert!(matches!(kept.height, Size::Fill(_)));
}
}