//! 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>, } 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>, /// 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, } 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 { 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 { 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 { 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) -> 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;