use crate::core::{Color, HorizontalAlignment, Point, Rect, Size};
use crate::event::key_codes;
use crate::event::{Event, EventHandler};
use crate::render::{RenderCommand, RenderContext};
use crate::signal::GenericSignal;
use crate::style::{MotionSlot, PropertyDriver};
use crate::widget::capability::coercion::expect_bool;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::effective_font;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const ARROW_SIZE: i32 = 10;
const CORNER_RADIUS: u32 = 8;
const REVEAL_MIN_SCALE: f32 = 0.06;
const SHADOW_ALPHA: u8 = 40;
const SHADOW_OFFSET_Y: i32 = 4;
const SHADOW_BLUR_RADIUS: u32 = 12;
const TRANSLUCENT_CARD_ALPHA: u8 = 217;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PopoverBehavior {
#[default]
Transient,
SemiTransient,
Permanent,
}
impl PopoverBehavior {
pub fn as_str(self) -> &'static str {
match self {
Self::Transient => "transient",
Self::SemiTransient => "semi_transient",
Self::Permanent => "permanent",
}
}
pub fn from_token(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"transient" => Some(Self::Transient),
"semi_transient" | "semitransient" => Some(Self::SemiTransient),
"permanent" => Some(Self::Permanent),
_ => None,
}
}
pub fn closes_on_outside_click(self) -> bool {
!matches!(self, Self::Permanent)
}
pub fn closes_on_escape(self) -> bool {
matches!(self, Self::Transient)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PopoverArrow {
#[default]
Auto,
Up,
Down,
Left,
Right,
}
impl PopoverArrow {
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Up => "up",
Self::Down => "down",
Self::Left => "left",
Self::Right => "right",
}
}
pub fn from_token(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"up" => Some(Self::Up),
"down" => Some(Self::Down),
"left" => Some(Self::Left),
"right" => Some(Self::Right),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PopoverEdge {
#[default]
Auto,
Top,
Bottom,
Left,
Right,
}
pub struct Popover {
base: BaseWidget,
content: Option<Box<dyn Widget>>,
anchor_rect: Rect,
visible: bool,
behavior: PopoverBehavior,
arrow: PopoverArrow,
placeholder: Option<String>,
translucent: bool,
body_rect: Rect,
reveal: PropertyDriver,
pub about_to_show: GenericSignal,
pub shown: GenericSignal,
pub about_to_hide: GenericSignal,
pub dismissed: GenericSignal,
}
impl Popover {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Popover, geometry, "Popover"),
content: None,
anchor_rect: Rect::default(),
visible: false,
behavior: PopoverBehavior::default(),
arrow: PopoverArrow::default(),
placeholder: None,
translucent: false,
body_rect: Rect::default(),
reveal: PropertyDriver::at(0.0, MotionSlot::Normal),
about_to_show: GenericSignal::new(),
shown: GenericSignal::new(),
about_to_hide: GenericSignal::new(),
dismissed: GenericSignal::new(),
}
}
pub fn show(&mut self, anchor: Rect) {
self.anchor_rect = anchor;
if self.visible {
self.base.request_redraw();
return;
}
self.about_to_show.emit();
self.visible = true;
self.reveal.set_target(1.0);
self.base.request_redraw();
self.shown.emit();
}
pub fn hide(&mut self) {
if !self.visible {
return;
}
self.about_to_hide.emit();
self.visible = false;
self.reveal.set_target(0.0);
self.base.request_redraw();
self.dismissed.emit();
}
pub fn placement(&self) -> PopoverEdge {
match self.arrow {
PopoverArrow::Auto => PopoverEdge::Auto,
PopoverArrow::Down => PopoverEdge::Top,
PopoverArrow::Up => PopoverEdge::Bottom,
PopoverArrow::Right => PopoverEdge::Left,
PopoverArrow::Left => PopoverEdge::Right,
}
}
pub fn set_placement(&mut self, edge: PopoverEdge) {
let arrow = match edge {
PopoverEdge::Auto => PopoverArrow::Auto,
PopoverEdge::Top => PopoverArrow::Down,
PopoverEdge::Bottom => PopoverArrow::Up,
PopoverEdge::Left => PopoverArrow::Right,
PopoverEdge::Right => PopoverArrow::Left,
};
self.set_arrow_direction(arrow);
}
pub fn reveal_progress(&self) -> f32 {
self.reveal.value()
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn is_shown(&self) -> bool {
self.visible
}
pub fn set_shown(&mut self, shown: bool) {
if shown {
self.show(self.anchor_rect);
} else {
self.hide();
}
}
pub fn content_text(&self) -> String {
self.content
.as_deref()
.and_then(
crate::widget::capability::coercion::widget_as::<
crate::widget::base_widgets::label::Label,
>,
)
.map_or_else(String::new, |label| label.text().to_string())
}
pub fn set_content(&mut self, widget: Box<dyn Widget>) {
self.content = Some(widget);
self.base.request_redraw();
}
pub fn content(&self) -> Option<&dyn Widget> {
self.content.as_deref()
}
pub fn content_mut(&mut self) -> Option<&mut dyn Widget> {
self.content.as_deref_mut()
}
pub fn anchor_rect(&self) -> Rect {
self.anchor_rect
}
pub fn behavior(&self) -> PopoverBehavior {
self.behavior
}
pub fn set_behavior(&mut self, behavior: PopoverBehavior) {
if self.behavior == behavior {
return;
}
self.behavior = behavior;
self.base.request_redraw();
}
pub fn arrow_direction(&self) -> PopoverArrow {
self.arrow
}
pub fn set_arrow_direction(&mut self, arrow: PopoverArrow) {
if self.arrow == arrow {
return;
}
self.arrow = arrow;
self.base.request_redraw();
}
pub fn placeholder_text(&self) -> Option<&str> {
self.placeholder.as_deref()
}
pub fn set_placeholder_text(&mut self, text: Option<String>) {
let text = text.filter(|value| !value.is_empty());
if self.placeholder == text {
return;
}
self.placeholder = text;
self.base.request_redraw();
}
pub fn is_translucent(&self) -> bool {
self.translucent
}
pub fn set_translucent(&mut self, translucent: bool) {
if self.translucent == translucent {
return;
}
self.translucent = translucent;
self.base.request_redraw();
}
fn placeholder_label(&self) -> &str {
match self.placeholder.as_deref() {
Some(text) => text,
None if self.content.is_some() => "Popover",
None => "Popover (empty)",
}
}
pub fn set_anchor_rect(&mut self, rect: Rect) {
if self.anchor_rect == rect {
return;
}
self.anchor_rect = rect;
self.base.request_redraw();
}
fn compute_layout(&self) -> (Rect, Point, ArrowDirection) {
let geom = self.geometry();
let body_width = geom.width.clamp(100, 400);
let body_height = geom.height.clamp(60, 400);
let below_space =
geom.y + geom.height as i32 - (self.anchor_rect.y + self.anchor_rect.height as i32);
let above_space = self.anchor_rect.y - geom.y;
let auto_dir = if below_space >= body_height as i32 + ARROW_SIZE {
ArrowDirection::Up
} else if above_space >= body_height as i32 + ARROW_SIZE {
ArrowDirection::Down
} else {
ArrowDirection::Up
};
let arrow_dir = match self.arrow {
PopoverArrow::Auto => auto_dir,
PopoverArrow::Up => ArrowDirection::Up,
PopoverArrow::Down => ArrowDirection::Down,
PopoverArrow::Left => ArrowDirection::Left,
PopoverArrow::Right => ArrowDirection::Right,
};
let anchor_center_x = self.anchor_rect.x + self.anchor_rect.width as i32 / 2;
let anchor_center_y = self.anchor_rect.y + self.anchor_rect.height as i32 / 2;
let body_rect = match arrow_dir {
ArrowDirection::Up => Rect::new(
(anchor_center_x - body_width as i32 / 2).max(geom.x),
self.anchor_rect.y + self.anchor_rect.height as i32 + ARROW_SIZE,
body_width,
body_height,
),
ArrowDirection::Down => Rect::new(
(anchor_center_x - body_width as i32 / 2).max(geom.x),
self.anchor_rect.y - body_height as i32 - ARROW_SIZE,
body_width,
body_height,
),
ArrowDirection::Left => Rect::new(
self.anchor_rect.x + self.anchor_rect.width as i32 + ARROW_SIZE,
(anchor_center_y - body_height as i32 / 2).max(geom.y),
body_width,
body_height,
),
ArrowDirection::Right => Rect::new(
self.anchor_rect.x - body_width as i32 - ARROW_SIZE,
(anchor_center_y - body_height as i32 / 2).max(geom.y),
body_width,
body_height,
),
};
let arrow_tip = match arrow_dir {
ArrowDirection::Up => Point::new(anchor_center_x, body_rect.y - ARROW_SIZE),
ArrowDirection::Down => {
Point::new(anchor_center_x, body_rect.y + body_rect.height as i32 + ARROW_SIZE)
}
ArrowDirection::Left => Point::new(body_rect.x - ARROW_SIZE, anchor_center_y),
ArrowDirection::Right => {
Point::new(body_rect.x + body_rect.width as i32 + ARROW_SIZE, anchor_center_y)
}
};
(body_rect, arrow_tip, arrow_dir)
}
}
impl Widget for Popover {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
crate::core::Size::new(
crate::widget::metrics::dimensions::POPOVER_DEFAULT_WIDTH,
crate::widget::metrics::dimensions::POPOVER_DEFAULT_HEIGHT,
)
}
fn tick(&mut self, delta_ms: u32) -> bool {
self.reveal.tick(delta_ms)
}
fn is_animating(&self) -> bool {
self.reveal.is_moving()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"about_to_show" => Some(EventSignalRef::unit("about_to_show", &self.about_to_show)),
"shown" => Some(EventSignalRef::unit("shown", &self.shown)),
"about_to_hide" => Some(EventSignalRef::unit("about_to_hide", &self.about_to_hide)),
"dismissed" => Some(EventSignalRef::unit("dismissed", &self.dismissed)),
_ => None,
}
}
}
impl WidgetProperties for Popover {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"shown" => Ok(CapabilityValue::Bool(self.is_shown())),
"text" => Ok(CapabilityValue::String(self.content_text())),
"behavior" => Ok(CapabilityValue::String(self.behavior.as_str().to_string())),
"arrow_direction" => Ok(CapabilityValue::String(self.arrow.as_str().to_string())),
"placeholder_text" => {
Ok(CapabilityValue::String(self.placeholder.clone().unwrap_or_default()))
}
"translucent" => Ok(CapabilityValue::Bool(self.is_translucent())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"shown" => {
self.set_shown(expect_bool(value)?);
Ok(())
}
"text" => Err(CapabilityAccessError::ReadOnlyProperty),
"behavior" => {
let text = crate::widget::capability::coercion::expect_string(value)?;
let behavior =
PopoverBehavior::from_token(&text).ok_or(CapabilityAccessError::OutOfRange)?;
self.set_behavior(behavior);
Ok(())
}
"arrow_direction" => {
let text = crate::widget::capability::coercion::expect_string(value)?;
let arrow =
PopoverArrow::from_token(&text).ok_or(CapabilityAccessError::OutOfRange)?;
self.set_arrow_direction(arrow);
Ok(())
}
"placeholder_text" => {
let text = crate::widget::capability::coercion::expect_string(value)?;
self.set_placeholder_text(Some(text));
Ok(())
}
"translucent" => {
self.set_translucent(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"shown",
"text",
"behavior",
"arrow_direction",
"placeholder_text",
"translucent",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_visible" | "set_text" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for Popover {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("popover");
let window_fill = {
let manager = crate::style::theme_manager();
manager.current_theme().map(|active| active.colors.background).unwrap_or(Color::WHITE)
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::rgb(40, 40, 40));
let card = match style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
{
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.08),
};
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != card)
.unwrap_or_else(|| card.blend(&ink, 0.35));
let muted_ink = ink.blend(&card, 0.45);
let visible = self.visible;
let reveal = self.reveal.value();
let (body_rect, arrow_tip, arrow_dir) = self.compute_layout();
self.body_rect = body_rect;
let (body_rect, arrow_tip) = if visible {
(body_rect, arrow_tip)
} else {
(
Rect::new(
rect.x + ARROW_SIZE,
rect.y,
rect.width.saturating_sub(ARROW_SIZE as u32),
rect.height,
),
arrow_tip,
)
};
let closed_height = ((rect.height as f32 * REVEAL_MIN_SCALE) + 0.5) as u32;
let revealed_height = ((body_rect.height as f32 * reveal) + 0.5) as u32;
let body_rect = Rect::new(
body_rect.x,
body_rect.y,
body_rect.width,
revealed_height.max(closed_height),
);
let card = if self.translucent { card.with_alpha(TRANSLUCENT_CARD_ALPHA) } else { card };
let card = if visible { card } else { window_fill.blend(&card, 0.45) };
let border = if visible { border } else { window_fill.blend(&border, 0.45) };
if body_rect.height > 0 && reveal > 0.0 {
context.draw_box_shadow(
body_rect,
Color::rgba(0, 0, 0, (SHADOW_ALPHA as f32 * reveal) as u8),
0,
SHADOW_OFFSET_Y,
SHADOW_BLUR_RADIUS,
0,
);
}
if body_rect.height > 0 {
context.fill_rounded_rect(body_rect, CORNER_RADIUS, card);
context.draw_rounded_rect_stroke(body_rect, CORNER_RADIUS, border, 1);
}
if visible && reveal > 0.0 {
self.draw_arrow(context, arrow_tip, arrow_dir, card, border);
}
let content_padding = 8i32;
let content_rect = crate::core::Rect::new(
body_rect.x + content_padding,
body_rect.y + content_padding,
body_rect.width.saturating_sub((content_padding as u32) * 2),
body_rect.height.saturating_sub((content_padding as u32) * 2),
);
if content_rect.height == 0 {
return;
}
let font = effective_font(style);
let label = self.placeholder_label();
let line = context.text_line(content_rect, font);
context.draw_text_fitted_cow(line, label, font, muted_ink, HorizontalAlignment::Center);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArrowDirection {
Up,
Down,
Left,
Right,
}
impl Popover {
fn draw_arrow(
&self,
context: &mut RenderContext,
tip: Point,
dir: ArrowDirection,
fill: Color,
outline: Color,
) {
let half_base = ARROW_SIZE / 2;
let (_base_center, base_left, base_right) = match dir {
ArrowDirection::Up => {
let base_center = Point::new(tip.x, tip.y + ARROW_SIZE);
let base_left = Point::new(tip.x - half_base, tip.y + ARROW_SIZE);
let base_right = Point::new(tip.x + half_base, tip.y + ARROW_SIZE);
(base_center, base_left, base_right)
}
ArrowDirection::Down => {
let base_center = Point::new(tip.x, tip.y - ARROW_SIZE);
let base_left = Point::new(tip.x - half_base, tip.y - ARROW_SIZE);
let base_right = Point::new(tip.x + half_base, tip.y - ARROW_SIZE);
(base_center, base_left, base_right)
}
ArrowDirection::Left => {
let base_center = Point::new(tip.x + ARROW_SIZE, tip.y);
let base_left = Point::new(tip.x + ARROW_SIZE, tip.y - half_base);
let base_right = Point::new(tip.x + ARROW_SIZE, tip.y + half_base);
(base_center, base_left, base_right)
}
ArrowDirection::Right => {
let base_center = Point::new(tip.x - ARROW_SIZE, tip.y);
let base_left = Point::new(tip.x - ARROW_SIZE, tip.y - half_base);
let base_right = Point::new(tip.x - ARROW_SIZE, tip.y + half_base);
(base_center, base_left, base_right)
}
};
let points = vec![tip, base_left, base_right];
context.execute_command(RenderCommand::DrawPath {
points: points.as_slice().into(),
closed: true,
color: fill,
filled: true,
width: 1,
});
context.execute_command(RenderCommand::DrawPath {
points: points.into(),
closed: true,
color: outline,
filled: false,
width: 1,
});
}
}
impl EventHandler for Popover {
fn handle_event(&mut self, event: &Event) {
if !self.visible {
self.base.handle_event(event);
return;
}
if !self.base.is_enabled() {
self.base.handle_event(event);
return;
}
match event {
Event::MousePress { pos, button, .. } => {
if *button == 1
&& self.behavior.closes_on_outside_click()
&& !self.body_rect.contains_point(*pos)
{
self.hide();
}
}
Event::KeyPress { key, modifiers: _ } => {
if *key == key_codes::ESCAPE && self.behavior.closes_on_escape() {
self.hide();
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Font;
use crate::widget::svg::render_to_svg;
const LINE_TOLERANCE: i32 = 8;
fn first_rounded_rect(svg: &str) -> Option<(i32, i32)> {
svg.split("<rect ").filter(|chunk| chunk.contains("rx=\"")).find_map(|chunk| {
let y = chunk.split("y=\"").nth(1)?.split('"').next()?.parse::<i32>().ok()?;
let h = chunk.split("height=\"").nth(1)?.split('"').next()?.parse::<i32>().ok()?;
Some((y, h))
})
}
struct TestContent {
base: BaseWidget,
text: String,
}
impl TestContent {
fn new(text: &str, geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Label, geometry, "TestContent"),
text: text.to_string(),
}
}
}
impl Widget for TestContent {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
}
impl Draw for TestContent {
fn draw(&mut self, context: &mut RenderContext) {
let font = Font::simple("sans-serif", 12.0);
context.draw_text(
Point::new(self.geometry().x, self.geometry().y),
&self.text,
&font,
Color::BLACK,
HorizontalAlignment::Left,
);
}
}
impl EventHandler for TestContent {
fn handle_event(&mut self, _event: &Event) {}
}
#[test]
fn popover_default_creation() {
let popover = Popover::new(Rect::new(0, 0, 300, 200));
assert_eq!(popover.kind(), WidgetKind::Popover);
assert!(!popover.is_visible());
assert!(popover.content().is_none());
assert_eq!(popover.geometry(), Rect::new(0, 0, 300, 200));
}
#[test]
fn popover_show_hide() {
let mut popover = Popover::new(Rect::new(0, 0, 300, 200));
assert!(!popover.is_visible());
popover.show(Rect::new(100, 100, 50, 20));
assert!(popover.is_visible());
popover.hide();
assert!(!popover.is_visible());
}
#[test]
fn popover_anchor_rect() {
let mut popover = Popover::new(Rect::new(0, 0, 300, 400));
let anchor = Rect::new(100, 100, 50, 20);
popover.show(anchor);
assert_eq!(popover.anchor_rect(), anchor);
let new_anchor = Rect::new(50, 50, 80, 30);
popover.set_anchor_rect(new_anchor);
assert_eq!(popover.anchor_rect(), new_anchor);
}
#[test]
fn popover_set_content() {
let mut popover = Popover::new(Rect::new(0, 0, 300, 200));
assert!(popover.content().is_none());
let content = TestContent::new("Hello", Rect::new(0, 0, 100, 30));
popover.set_content(Box::new(content));
assert!(popover.content().is_some());
}
#[test]
fn popover_auto_dismiss_on_click_outside() {
let mut popover = Popover::new(Rect::new(0, 0, 400, 400));
let anchor = Rect::new(150, 100, 50, 20);
popover.show(anchor);
assert!(popover.is_visible());
popover.handle_event(&Event::MousePress { pos: Point::new(5, 5), button: 1, modifiers: 0 });
assert!(!popover.is_visible());
}
#[test]
fn popover_escape_key_dismisses() {
let mut popover = Popover::new(Rect::new(0, 0, 400, 400));
popover.show(Rect::new(150, 100, 50, 20));
assert!(popover.is_visible());
popover.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert!(!popover.is_visible());
}
#[test]
fn popover_disabled_ignores_dismissal_input() {
let mut popover = Popover::new(Rect::new(0, 0, 400, 400));
popover.show(Rect::new(150, 100, 50, 20));
popover.set_enabled(false);
popover.handle_event(&Event::MousePress { pos: Point::new(5, 5), button: 1, modifiers: 0 });
assert!(popover.is_visible(), "a disabled popover must not auto-dismiss");
popover.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert!(popover.is_visible(), "a disabled popover must not close on Escape");
popover.set_enabled(true);
popover.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert!(!popover.is_visible(), "re-enabling must restore dismissal");
}
#[test]
fn popover_svg_output_visible() {
let mut popover = Popover::new(Rect::new(0, 0, 300, 200));
popover.show(Rect::new(100, 100, 50, 20));
let svg = render_to_svg(&mut popover);
assert!(svg.starts_with("<svg"), "SVG should start with <svg, got: {svg:.60}");
assert!(svg.ends_with("</svg>"), "SVG should end with </svg>");
}
#[test]
fn opening_a_popover_unfolds_it() {
let mut popover = Popover::new(Rect::new(0, 0, 300, 200));
assert_eq!(popover.reveal_progress(), 0.0, "a fresh popover is closed");
assert!(!popover.is_animating(), "and owes no frames");
popover.show(Rect::new(100, 100, 50, 20));
assert!(popover.is_visible(), "the logical state answers at once");
assert!(popover.is_animating(), "while the drawn card owes frames");
fn card_height(svg: &str) -> u32 {
svg.split("<rect ")
.filter(|chunk| chunk.contains("rx=\""))
.filter_map(|chunk| {
let h = chunk.split("height=\"").nth(1)?;
h.split('"').next()?.parse::<u32>().ok()
})
.max()
.unwrap_or(0)
}
assert!(popover.tick(20), "still unfolding after one step");
let mid = popover.reveal_progress();
assert!(
mid > 0.0 && mid < 1.0,
"the popover must pass through an interior reveal (got {mid})"
);
let mid_height = card_height(&render_to_svg(&mut popover));
while popover.tick(1000) {}
assert_eq!(popover.reveal_progress(), 1.0, "and settle fully open");
let open_height = card_height(&render_to_svg(&mut popover));
assert!(
mid_height > 0 && mid_height < open_height,
"an unfolding card must be shorter than a settled one: mid={mid_height} open={open_height}"
);
}
#[test]
fn popover_svg_output_hidden() {
let _theme_guard = crate::style::theme_test_guard();
let mut popover = Popover::new(Rect::new(0, 0, 300, 200));
let svg = render_to_svg(&mut popover);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
let rounded = svg.matches("rx=\"").count();
assert!(rounded > 0, "a closed popover must still paint its card: {svg}");
assert!(
crate::widget::svg::text_ink_box(&svg).is_none(),
"a closed popover paints no interior label: {svg}"
);
let mut open = Popover::new(Rect::new(0, 0, 300, 400));
open.show(Rect::new(100, 20, 50, 20));
while open.tick(1000) {}
let shown = render_to_svg(&mut open);
assert_ne!(svg, shown, "showing the popover must change what is painted");
let (left, top, right, bottom) = crate::widget::svg::text_ink_box(&shown)
.unwrap_or_else(|| panic!("the placeholder label must be painted when open: {shown}"));
assert!(right > left, "the placeholder laid down ink: {left}..{right}");
let (card_y, card_h) = first_rounded_rect(&shown).expect("the open popover paints a card");
assert!(
top >= card_y && bottom <= card_y + card_h,
"the label must sit inside the card: ink {top}..{bottom}, card {card_y}..{}",
card_y + card_h
);
}
#[test]
fn popover_empty_placeholder_is_centred_in_the_card() {
let rect = Rect::new(0, 0, 240, 120);
let mut popover = Popover::new(rect);
popover.show(Rect::new(100, 100, 50, 20));
while popover.tick(1000) {}
let svg = render_to_svg(&mut popover);
let (left, top, right, bottom) = crate::widget::svg::text_ink_box(&svg)
.expect("the empty-state placeholder must be drawn");
assert!(right > left && bottom > top, "the placeholder laid down ink: {svg}");
let (card_y, card_h) = first_rounded_rect(&svg).expect("the open popover paints a card");
let middle = card_y + card_h / 2;
assert!(
(top - middle).abs() <= LINE_TOLERANCE,
"the empty-state placeholder must be vertically centred in the card: top={top}, \
card centre={middle} (card y={card_y} h={card_h})"
);
assert!(
top >= card_y + 8,
"a top-edge origin would pin the placeholder at the padding, got {top} in card y={card_y}"
);
}
#[test]
fn an_open_popover_paints_a_blurred_shadow() {
let rect = Rect::new(0, 0, 300, 400);
let mut popover = Popover::new(rect);
popover.show(Rect::new(100, 20, 50, 20));
while popover.tick(1000) {}
let svg = render_to_svg(&mut popover);
assert!(
svg.contains(&format!("shadow_blur_{SHADOW_BLUR_RADIUS}")),
"the shadow must go through the box-blur primitive: {svg}"
);
assert!(
svg.contains(&format!("filter=\"url(#shadow_blur_{SHADOW_BLUR_RADIUS})\"")),
"the emitted shadow rectangle must reference the blur filter: {svg}"
);
}
#[test]
fn the_shadow_is_not_dropped_when_it_extends_past_the_control() {
let mut popover = Popover::new(Rect::new(0, 0, 240, 120));
popover.show(Rect::new(100, 100, 50, 20));
while popover.tick(1000) {}
let svg = render_to_svg(&mut popover);
assert!(
svg.contains(&format!("shadow_blur_{SHADOW_BLUR_RADIUS}")),
"the shadow must survive a card that sits past the control's bottom edge: {svg}"
);
}
#[test]
fn a_closed_popover_paints_no_shadow() {
let mut popover = Popover::new(Rect::new(0, 0, 300, 400));
assert_eq!(popover.reveal_progress(), 0.0);
let svg = render_to_svg(&mut popover);
assert!(!svg.contains("shadow_blur_"), "a closed popover must not cast a shadow: {svg}");
}
#[test]
fn behavior_decides_which_gestures_close_the_card() {
let outside = Event::MousePress { pos: Point::new(5, 5), button: 1, modifiers: 0 };
let escape = Event::KeyPress { key: 27, modifiers: 0 };
let mut semi = Popover::new(Rect::new(0, 0, 400, 400));
semi.show(Rect::new(150, 100, 50, 20));
semi.set_behavior(PopoverBehavior::SemiTransient);
semi.handle_event(&escape);
assert!(semi.is_visible(), "a semi-transient popover ignores Escape");
semi.handle_event(&outside);
assert!(!semi.is_visible(), "an outside click still closes it");
let mut permanent = Popover::new(Rect::new(0, 0, 400, 400));
permanent.show(Rect::new(150, 100, 50, 20));
permanent.set_behavior(PopoverBehavior::Permanent);
permanent.handle_event(&escape);
assert!(permanent.is_visible());
permanent.handle_event(&outside);
assert!(permanent.is_visible(), "a permanent popover closes only when asked");
assert!(PopoverBehavior::from_token("semi-transient")
.expect("hyphenated spelling")
.closes_on_outside_click());
assert_eq!(
PopoverBehavior::from_token("semi_transient").map(PopoverBehavior::as_str),
Some("semi_transient")
);
}
#[test]
fn a_horizontal_arrow_places_the_card_beside_the_anchor() {
let anchor = Rect::new(150, 150, 40, 20);
let mut left = Popover::new(Rect::new(0, 0, 400, 400));
left.set_arrow_direction(PopoverArrow::Left);
left.show(anchor);
while left.tick(1000) {}
let _ = render_to_svg(&mut left);
assert!(
left.body_rect.x >= anchor.x + anchor.width as i32,
"a left-pointing arrow puts the card to the anchor's right: card x={}, anchor right={}",
left.body_rect.x,
anchor.x + anchor.width as i32
);
let mut right = Popover::new(Rect::new(0, 0, 400, 400));
right.set_arrow_direction(PopoverArrow::Right);
right.show(anchor);
while right.tick(1000) {}
let _ = render_to_svg(&mut right);
assert!(
right.body_rect.x + right.body_rect.width as i32 <= anchor.x,
"a right-pointing arrow puts the card to the anchor's left: card right={}, anchor x={}",
right.body_rect.x + right.body_rect.width as i32,
anchor.x
);
assert_eq!(PopoverArrow::from_token("left"), Some(PopoverArrow::Left));
}
#[test]
fn the_placeholder_text_is_configurable() {
let mut popover = Popover::new(Rect::new(0, 0, 300, 400));
popover.show(Rect::new(100, 20, 50, 20));
while popover.tick(1000) {}
let built_in = render_to_svg(&mut popover);
popover.set_placeholder_text(Some("No notifications".to_string()));
let custom = render_to_svg(&mut popover);
assert_ne!(built_in, custom, "a custom placeholder must reach the pixels");
popover.set_placeholder_text(Some(String::new()));
assert_eq!(popover.placeholder_text(), None);
}
#[test]
fn the_translucent_material_is_opt_in() {
fn card_alpha(svg: &str) -> f32 {
let rect = svg
.split("<rect ")
.filter(|chunk| chunk.contains("rx=\""))
.find(|chunk| chunk.contains("fill=\"rgba("))
.expect("the card carries a fill");
let fill = rect.split("rgba(").nth(1).expect("rgba fill");
let channels: Vec<&str> =
fill.split(')').next().expect("close paren").split(',').collect();
channels[3].trim().parse::<f32>().expect("an alpha channel")
}
let mut opaque = Popover::new(Rect::new(0, 0, 300, 400));
opaque.show(Rect::new(100, 20, 50, 20));
while opaque.tick(1000) {}
assert!(!opaque.is_translucent(), "the default card is opaque");
let opaque_svg = render_to_svg(&mut opaque);
let mut translucent = Popover::new(Rect::new(0, 0, 300, 400));
translucent.set_translucent(true);
translucent.show(Rect::new(100, 20, 50, 20));
while translucent.tick(1000) {}
let translucent_svg = render_to_svg(&mut translucent);
assert_eq!(card_alpha(&opaque_svg), 1.0, "an opaque card is fully opaque");
let expected = f32::from(TRANSLUCENT_CARD_ALPHA) / 255.0;
assert!(
(card_alpha(&translucent_svg) - expected).abs() < 0.01,
"the translucent card must carry the material's alpha: got {}",
card_alpha(&translucent_svg)
);
assert_ne!(opaque_svg, translucent_svg);
}
#[test]
fn the_show_and_hide_lifecycle_is_observable() {
use std::sync::{Arc, Mutex};
let mut popover = Popover::new(Rect::new(0, 0, 300, 400));
let events = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let sink = Arc::clone(&events);
popover.about_to_show.connect({
let sink = Arc::clone(&sink);
move || sink.lock().expect("lock").push("about_to_show")
});
popover.shown.connect({
let sink = Arc::clone(&sink);
move || sink.lock().expect("lock").push("shown")
});
popover.about_to_hide.connect({
let sink = Arc::clone(&sink);
move || sink.lock().expect("lock").push("about_to_hide")
});
popover.dismissed.connect(move || sink.lock().expect("lock").push("dismissed"));
popover.show(Rect::new(10, 10, 40, 20));
assert_eq!(
*events.lock().expect("lock"),
vec!["about_to_show", "shown"],
"showing announces both halves of the transition"
);
popover.show(Rect::new(20, 20, 40, 20));
assert_eq!(events.lock().expect("lock").len(), 2);
popover.hide();
assert_eq!(
*events.lock().expect("lock"),
vec!["about_to_show", "shown", "about_to_hide", "dismissed"]
);
popover.hide();
assert_eq!(events.lock().expect("lock").len(), 4);
}
#[test]
fn placement_selects_the_arrow_that_points_at_the_anchor() {
let mut popover = Popover::new(Rect::new(0, 0, 400, 400));
popover.set_placement(PopoverEdge::Bottom);
assert_eq!(popover.placement(), PopoverEdge::Bottom);
assert_eq!(popover.arrow_direction(), PopoverArrow::Up);
popover.set_placement(PopoverEdge::Top);
assert_eq!(popover.arrow_direction(), PopoverArrow::Down);
popover.set_placement(PopoverEdge::Left);
assert_eq!(popover.arrow_direction(), PopoverArrow::Right);
popover.set_placement(PopoverEdge::Right);
assert_eq!(popover.arrow_direction(), PopoverArrow::Left);
popover.set_placement(PopoverEdge::Auto);
assert_eq!(popover.arrow_direction(), PopoverArrow::Auto);
}
}