overlay.rs raw

//! Overlay layers: subtrees that float above the base tree.
//!
//! Menus, dialogs, dropdowns, and tooltips all need content that escapes
//! its ancestors' clips, paints above everything, hit-tests first, and —
//! for modals — traps focus. An overlay is an ordinary widget subtree with
//! its own root, laid out against the viewport and positioned by a
//! [`Placement`]; the frame composes the base tree and every overlay in
//! order, so painting, damage tracking, and the fresh-paint oracle need no
//! special cases beyond the per-layer transform.

use guiduck_scene::geom::{Point, Rect, Size, Vec2};

use super::{Container, WidgetId, WidgetTree};

/// Where an overlay places itself, resolved after its content is laid out.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Placement {
    /// Centered in the viewport — the dialog position.
    Center,
    /// At a fixed point in window coordinates (a context menu at the
    /// pointer), clamped to keep the overlay inside the viewport.
    At(Point),
    /// Against a side of an anchor widget's rectangle (a dropdown under
    /// its field), flipping to the opposite side when the preferred one
    /// would leave the viewport, then clamped.
    Anchored { anchor: WidgetId, side: AnchorSide },
}

/// Which side of the anchor an [`Placement::Anchored`] overlay prefers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnchorSide {
    Below,
    Above,
    RightOf,
    LeftOf,
}

/// How an overlay behaves; see the constructors for the common shapes.
pub struct OverlayOptions {
    pub placement: Placement,
    /// A modal overlay is an input barrier (pointer events cannot reach
    /// layers beneath it) and a focus trap (Tab cycles within it).
    pub modal: bool,
    /// Close when a pointer press lands outside this overlay and every
    /// overlay above it. The press is consumed by the dismissal.
    pub dismiss_on_outside_click: bool,
    /// Close when Escape is pressed while this is the topmost overlay
    /// (and the focused widget did not consume the key).
    pub dismiss_on_escape: bool,
    /// Whether the pointer can land on this overlay at all.
    ///
    /// A tooltip is a *note*, not a surface: it must not take hover from the
    /// widget it describes — which would make it flicker, since losing that
    /// hover is what takes it away — nor swallow a click meant for what is
    /// underneath. The pointer passes straight through.
    pub hit_testable: bool,
    /// Runs when the overlay closes, however it closes.
    pub on_close: Option<Box<dyn FnOnce()>>,
}

impl OverlayOptions {
    /// A modal dialog: centered, focus-trapping, dismissed by Escape.
    pub fn dialog() -> Self {
        Self {
            placement: Placement::Center,
            modal: true,
            dismiss_on_outside_click: false,
            dismiss_on_escape: true,
            hit_testable: true,
            on_close: None,
        }
    }

    /// A light-dismiss popup (menu, dropdown, completion list) anchored to
    /// a widget.
    pub fn popup(anchor: WidgetId, side: AnchorSide) -> Self {
        Self {
            placement: Placement::Anchored { anchor, side },
            modal: false,
            dismiss_on_outside_click: true,
            dismiss_on_escape: true,
            hit_testable: true,
            on_close: None,
        }
    }

    /// A light-dismiss popup at a fixed point (a context menu at the
    /// pointer position).
    pub fn popup_at(position: Point) -> Self {
        Self {
            placement: Placement::At(position),
            modal: false,
            dismiss_on_outside_click: true,
            dismiss_on_escape: true,
            hit_testable: true,
            on_close: None,
        }
    }

    pub fn with_placement(mut self, placement: Placement) -> Self {
        self.placement = placement;
        self
    }

    pub fn on_close(mut self, f: impl FnOnce() + 'static) -> Self {
        self.on_close = Some(Box::new(f));
        self
    }
}

/// One live overlay layer.
pub(crate) struct Overlay {
    pub(crate) root: WidgetId,
    pub(crate) placement: Placement,
    pub(crate) modal: bool,
    pub(crate) dismiss_on_outside_click: bool,
    pub(crate) dismiss_on_escape: bool,
    pub(crate) hit_testable: bool,
    pub(crate) on_close: Option<Box<dyn FnOnce()>>,
    /// Resolved window-coordinate position of the overlay root.
    pub(crate) position: Point,
    /// The widget focused when the overlay opened, restored on close.
    pub(crate) prior_focus: Option<WidgetId>,
}

impl WidgetTree {
    /// Open an overlay: a new layer above the base tree (and above every
    /// existing overlay). Returns the overlay's root widget — a container
    /// with the given layout style — for the caller to mount content under;
    /// a `.gdc` component mounts into it like into any parent.
    pub fn open_overlay(&mut self, style: taffy::Style, options: OverlayOptions) -> WidgetId {
        let root = self.create_node(Container::new(), style);
        self.dirt.push((root, super::Dirt::LAYOUT));
        self.style_dirty.push(root);
        self.overlays.push(Overlay {
            root,
            placement: options.placement,
            modal: options.modal,
            dismiss_on_outside_click: options.dismiss_on_outside_click,
            dismiss_on_escape: options.dismiss_on_escape,
            hit_testable: options.hit_testable,
            on_close: options.on_close,
            position: Point::ORIGIN,
            prior_focus: self.focus(),
        });
        self.frame_dirty = true;
        root
    }

    /// Close an overlay by its root id, tearing down its subtree, restoring
    /// the previously focused widget when it still exists, and running the
    /// overlay's `on_close`.
    pub fn close_overlay(&mut self, root: WidgetId) {
        let Some(index) = self.overlays.iter().position(|o| o.root == root) else {
            return;
        };
        let mut overlay = self.overlays.remove(index);
        let on_close = overlay.on_close.take();
        self.remove_subtree(root);
        self.frame_dirty = true;
        // Restore focus to where it was before the overlay opened; if that
        // widget is gone (or another overlay now scopes focus), the focus
        // invariant re-establishes a valid focus on the next frame.
        self.set_focus(overlay.prior_focus);
        if let Some(on_close) = on_close {
            on_close();
        }
    }

    /// Whether `id` is an overlay root.
    pub fn is_overlay(&self, id: WidgetId) -> bool {
        self.overlays.iter().any(|o| o.root == id)
    }

    /// The overlay roots, bottom to top.
    pub fn overlay_roots(&self) -> Vec<WidgetId> {
        self.overlays.iter().map(|o| o.root).collect()
    }

    /// The root of the current focus scope: the topmost modal overlay if
    /// one exists, else the base root. Tab traversal cycles within it, and
    /// the focus invariant refocuses into it.
    pub(crate) fn focus_scope_root(&self) -> Option<WidgetId> {
        self.overlays
            .iter()
            .rev()
            .find(|o| o.modal)
            .map(|o| o.root)
            .or(self.base_root())
    }

    /// Whether focus on `id` is admissible: at or above the topmost modal
    /// overlay (a modal is a floor, not an exact scope — clicking a
    /// focusable in a menu stacked over the modal keeps focus there, but
    /// focus may never rest *beneath* the modal).
    pub(crate) fn focus_admissible(&self, id: WidgetId) -> bool {
        let Some(floor) = self.overlays.iter().rposition(|o| o.modal) else {
            return true;
        };
        self.layer_index_of(id) >= Some(floor)
    }

    /// The overlay stack index of the layer containing `id`; `None` for
    /// the base tree (which sits below every overlay).
    fn layer_index_of(&self, id: WidgetId) -> Option<usize> {
        let mut top = id;
        while let Some(parent) = self.parent(top) {
            top = parent;
        }
        self.overlays.iter().position(|o| o.root == top)
    }

    /// Whether `ancestor` is `id` or one of its ancestors.
    pub(crate) fn is_within(&self, id: WidgetId, ancestor: WidgetId) -> bool {
        let mut current = Some(id);
        while let Some(c) = current {
            if c == ancestor {
                return true;
            }
            current = self.parent(c);
        }
        false
    }

    /// Handle a pointer press against the overlay stack: walking top-down,
    /// every light-dismiss overlay the press landed outside of closes,
    /// stopping at the layer that contains the press or at a modal (a
    /// modal is a barrier — whether it closed or stayed, nothing beneath
    /// it reacts to this press). Returns true when the press dismissed
    /// something and must not dispatch further.
    pub(crate) fn dismiss_for_press(&mut self, target: Option<WidgetId>) -> bool {
        let mut dismissed = false;
        let mut index = self.overlays.len();
        while index > 0 {
            index -= 1;
            let overlay = &self.overlays[index];
            let root = overlay.root;
            let modal = overlay.modal;
            let dismissable = overlay.dismiss_on_outside_click;
            if target.is_some_and(|t| self.is_within(t, root)) {
                // The press landed in this layer; the stack below stands.
                break;
            }
            if dismissable {
                self.close_overlay(root);
                dismissed = true;
            }
            if modal {
                break;
            }
        }
        dismissed
    }

    /// Handle Escape against the overlay stack: the topmost overlay closes
    /// if it is Escape-dismissable. Returns true when consumed.
    pub(crate) fn dismiss_for_escape(&mut self) -> bool {
        let Some(top) = self.overlays.last() else {
            return false;
        };
        if top.dismiss_on_escape {
            let root = top.root;
            self.close_overlay(root);
            true
        } else {
            false
        }
    }

    /// Resolve every overlay's position from its placement and laid-out
    /// size. Runs after layout, when anchor rectangles and content sizes
    /// are known. Marks the frame dirty when anything moved.
    pub(crate) fn position_overlays(&mut self, viewport: Size) {
        for index in 0..self.overlays.len() {
            let overlay = &self.overlays[index];
            let root = overlay.root;
            let size = self.layout(root).size();
            let position = match overlay.placement {
                Placement::Center => Point::new(
                    (viewport.width - size.width) / 2.0,
                    (viewport.height - size.height) / 2.0,
                ),
                Placement::At(point) => point,
                Placement::Anchored { anchor, side } => {
                    if self.node_exists(anchor) {
                        let origin = self.absolute_origin(anchor);
                        let rect = Rect::from_origin_size(origin, self.layout(anchor).size());
                        place_anchored(rect, size, side, viewport)
                    } else {
                        // The anchor vanished; keep the last position
                        // rather than jumping. Closing is the opener's
                        // decision.
                        self.overlays[index].position
                    }
                }
            };
            let position = clamp_to_viewport(position, size, viewport);
            if self.overlays[index].position != position {
                self.overlays[index].position = position;
                self.frame_dirty = true;
            }
        }
    }
}

/// The position for an anchored overlay: the preferred side, flipped to
/// the opposite side when it would overflow the viewport and the opposite
/// side has room.
fn place_anchored(anchor: Rect, size: Size, side: AnchorSide, viewport: Size) -> Point {
    let below = Point::new(anchor.x0, anchor.y1);
    let above = Point::new(anchor.x0, anchor.y0 - size.height);
    let right = Point::new(anchor.x1, anchor.y0);
    let left = Point::new(anchor.x0 - size.width, anchor.y0);
    let fits = |p: Point| {
        p.x >= 0.0
            && p.y >= 0.0
            && p.x + size.width <= viewport.width
            && p.y + size.height <= viewport.height
    };
    match side {
        AnchorSide::Below if !fits(below) && fits(above) => above,
        AnchorSide::Below => below,
        AnchorSide::Above if !fits(above) && fits(below) => below,
        AnchorSide::Above => above,
        AnchorSide::RightOf if !fits(right) && fits(left) => left,
        AnchorSide::RightOf => right,
        AnchorSide::LeftOf if !fits(left) && fits(right) => right,
        AnchorSide::LeftOf => left,
    }
}

/// Shift a position so the overlay stays inside the viewport (top-left
/// wins when it is larger than the viewport).
fn clamp_to_viewport(position: Point, size: Size, viewport: Size) -> Point {
    let x = position.x.min(viewport.width - size.width).max(0.0);
    let y = position.y.min(viewport.height - size.height).max(0.0);
    Point::new(x, y)
}

/// The overlay's own placement shift, applied on top of layout geometry.
pub(crate) fn layer_offset(tree: &WidgetTree, id: WidgetId) -> Vec2 {
    let mut top = id;
    while let Some(parent) = tree.parent(top) {
        top = parent;
    }
    tree.overlays
        .iter()
        .find(|o| o.root == top)
        .map(|o| o.position.to_vec2())
        .unwrap_or(Vec2::ZERO)
}

#[cfg(test)]
mod tests;