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;