agg-gui 0.4.0

Immediate-mode Rust GUI library with AGG rendering, Y-up layout, widgets, text, SVG, and native/WASM adapters
Documentation
//! ModalSheet — a centered modal panel over a dimming scrim, the
//! SwiftUI-`.sheet` / desktop-dialog presentation.
//!
//! Unlike [`Window`](super::window::Window) the sheet is not draggable,
//! resizable, or collapsible: it is a fixed-size surface centered over
//! the app, with a translucent scrim blocking interaction with
//! everything behind it. Place it as a late child of the root
//! [`Stack`](super::primitives::Stack) so it paints above the app; while
//! its visibility cell is `true`, `has_active_modal` routes all input to
//! the sheet subtree (the scrim swallows what the panel doesn't use) and
//! `Escape` closes it.
//!
//! ```ignore
//! let visible = Rc::new(Cell::new(false));
//! let sheet = ModalSheet::new(Rc::clone(&visible), Box::new(content))
//!     .with_panel_size(Size::new(620.0, 420.0));
//! Stack::new().add(Box::new(app)).add(Box::new(sheet))
//! ```

use std::cell::Cell;
use std::rc::Rc;

use crate::color::Color;
use crate::draw_ctx::DrawCtx;
use crate::event::{Event, EventResult, Key};
use crate::geometry::{Rect, Size};
use crate::layout_props::WidgetBase;
use crate::theme::current_visuals;
use crate::widget::Widget;
use crate::widgets::window::chrome::{paint_chrome_shadow, ChromeStyle};

/// Minimum gap kept between the panel and the host bounds when the
/// desired panel size doesn't fit.
const EDGE_MARGIN: f64 = 24.0;
const CORNER_R: f64 = 10.0;

/// A centered, fixed-size modal panel over a scrim. One content child.
pub struct ModalSheet {
    bounds: Rect,
    children: Vec<Box<dyn Widget>>, // exactly 1: the content
    base: WidgetBase,
    visible: Rc<Cell<bool>>,
    /// Desired panel size; clamped to the available bounds at layout.
    panel_size: Size,
    /// Panel rect in local coordinates, computed each layout.
    panel: Rect,
    /// Dismiss on `Escape` (default true; SwiftUI's `.cancelAction`).
    escape_closes: bool,
    /// Let unhandled keys fall through to the app behind the sheet
    /// (default false). For sheets that measure live input — a latency
    /// calibration tapping along on the app's instrument keys.
    key_passthrough: bool,
    /// Invoked whenever the sheet closes itself (Escape). External
    /// closes (a Done button clearing the cell) don't route through
    /// this — wire those callbacks at the button.
    on_close: Option<Box<dyn Fn()>>,
}

impl ModalSheet {
    pub fn new(visible: Rc<Cell<bool>>, content: Box<dyn Widget>) -> Self {
        Self {
            bounds: Rect::default(),
            children: vec![content],
            base: WidgetBase::new(),
            visible,
            panel_size: Size::new(480.0, 360.0),
            panel: Rect::default(),
            escape_closes: true,
            key_passthrough: false,
            on_close: None,
        }
    }

    /// Let unhandled keys reach the app behind the sheet (see
    /// `key_passthrough`).
    pub fn with_key_passthrough(mut self, passthrough: bool) -> Self {
        self.key_passthrough = passthrough;
        self
    }

    /// Desired panel size (clamped to the host bounds minus a margin).
    pub fn with_panel_size(mut self, size: Size) -> Self {
        self.panel_size = size;
        self
    }

    /// Disable the default Escape-to-close behaviour.
    pub fn with_escape_closes(mut self, close: bool) -> Self {
        self.escape_closes = close;
        self
    }

    /// Run a callback when the sheet dismisses itself via Escape.
    pub fn with_on_close(mut self, on_close: impl Fn() + 'static) -> Self {
        self.on_close = Some(Box::new(on_close));
        self
    }

    pub fn visibility_cell(&self) -> Rc<Cell<bool>> {
        Rc::clone(&self.visible)
    }

    fn close(&self) {
        self.visible.set(false);
        if let Some(on_close) = &self.on_close {
            on_close();
        }
        crate::animation::request_draw();
    }
}

impl Widget for ModalSheet {
    fn type_name(&self) -> &'static str {
        "ModalSheet"
    }

    fn bounds(&self) -> Rect {
        self.bounds
    }

    fn set_bounds(&mut self, b: Rect) {
        self.bounds = b;
    }

    fn children(&self) -> &[Box<dyn Widget>] {
        &self.children
    }

    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
        &mut self.children
    }

    fn widget_base(&self) -> Option<&WidgetBase> {
        Some(&self.base)
    }

    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
        Some(&mut self.base)
    }

    fn is_visible(&self) -> bool {
        self.visible.get()
    }

    fn has_active_modal(&self) -> bool {
        self.visible.get()
    }

    fn layout(&mut self, available: Size) -> Size {
        if !self.visible.get() {
            self.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
            return Size::new(0.0, 0.0);
        }
        let w = self
            .panel_size
            .width
            .min(available.width - 2.0 * EDGE_MARGIN)
            .max(0.0);
        let h = self
            .panel_size
            .height
            .min(available.height - 2.0 * EDGE_MARGIN)
            .max(0.0);
        // Centered, snapped to whole pixels for crisp chrome.
        let x = ((available.width - w) / 2.0).round();
        let y = ((available.height - h) / 2.0).round();
        self.panel = Rect::new(x, y, w, h);
        if let Some(child) = self.children.first_mut() {
            child.layout(Size::new(w, h));
            child.set_bounds(self.panel);
        }
        self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
        available
    }

    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
        if !self.visible.get() {
            return;
        }
        let v = current_visuals();

        // Scrim: dim everything behind the sheet.
        ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
        ctx.begin_path();
        ctx.rect(0.0, 0.0, self.bounds.width, self.bounds.height);
        ctx.fill();

        // Panel chrome: shadow + rounded body + hairline border.
        let style = ChromeStyle {
            corner_radius: CORNER_R,
            ..ChromeStyle::from_visuals(&v)
        };
        ctx.save();
        ctx.translate(self.panel.x, self.panel.y);
        paint_chrome_shadow(ctx, self.panel.width, self.panel.height, &style);
        ctx.set_fill_color(v.window_fill);
        ctx.begin_path();
        ctx.rounded_rect(0.0, 0.0, self.panel.width, self.panel.height, CORNER_R);
        ctx.fill();
        ctx.set_stroke_color(v.window_stroke);
        ctx.set_line_width(1.0);
        ctx.begin_path();
        ctx.rounded_rect(
            0.5,
            0.5,
            self.panel.width - 1.0,
            self.panel.height - 1.0,
            CORNER_R,
        );
        ctx.stroke();
        ctx.restore();
    }

    fn on_event(&mut self, event: &Event) -> EventResult {
        if !self.visible.get() {
            return EventResult::Ignored;
        }
        match event {
            // Scrim: swallow pointer input the panel content didn't take,
            // so nothing behind the sheet reacts (no click-outside close —
            // matching macOS sheets).
            Event::MouseDown { .. } | Event::MouseUp { .. } | Event::MouseWheel { .. } => {
                EventResult::Consumed
            }
            Event::KeyDown {
                key: Key::Escape, ..
            } if self.escape_closes => {
                self.close();
                EventResult::Consumed
            }
            // Swallow every other key the panel content didn't take:
            // keystrokes must not fall through to shortcut handlers
            // behind the sheet (a piano app's key-to-note routing, say).
            Event::KeyDown { .. } | Event::KeyUp { .. } if !self.key_passthrough => {
                EventResult::Consumed
            }
            _ => EventResult::Ignored,
        }
    }
}