Skip to main content

rosace_widgets/tree/
overlay_api.rs

1use std::sync::Arc;
2use rosace_core::types::{Point, Rect};
3use rosace_render::Color;
4use rosace_state::Atom;
5use super::{Widget, PaintCtx, BoxedWidget};
6use super::overlay::{
7    FocusBehavior, InputBehavior, LayerPosition, OverlayEntry, ScrimConfig,
8};
9
10// ── OverlayKind ───────────────────────────────────────────────────────────────
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum OverlayKind {
14    /// Anchored at trigger bottom-left. PassThrough input. No scrim.
15    Dropdown,
16    /// Bottom of window. PassThrough input. Dim scrim with tap-to-dismiss.
17    Sheet,
18    /// Centered. Blocks input. Traps focus. Dim scrim with tap-to-dismiss.
19    Dialog,
20    /// Anchored at trigger top-right. PassThrough. Inert. No scrim.
21    Tooltip,
22    /// Floating above the bottom edge, centered. PassThrough. Inert. No scrim.
23    Toast,
24}
25
26// ── Overlay config entry ──────────────────────────────────────────────────────
27
28struct OverlayConfig {
29    kind:    OverlayKind,
30    open:    Atom<bool>,
31    content: Arc<dyn Fn() -> BoxedWidget + Send + Sync>,
32}
33
34// ── WithOverlay wrapper ───────────────────────────────────────────────────────
35
36/// Wraps a widget with co-located overlay declarations.
37///
38/// Created by the [`OverlayApi`] builder methods. Implements [`Widget`] and
39/// can be chained with further `.dropdown()` / `.sheet()` / `.dialog()` calls.
40pub struct WithOverlay<W: Widget> {
41    inner:    W,
42    overlays: Vec<OverlayConfig>,
43}
44
45impl<W: Widget + 'static> WithOverlay<W> {
46    pub fn new(inner: W) -> Self {
47        Self { inner, overlays: Vec::new() }
48    }
49
50    fn push(mut self, kind: OverlayKind, open: Atom<bool>,
51            content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> Self {
52        self.overlays.push(OverlayConfig { kind, open, content: Arc::new(content) });
53        self
54    }
55
56    /// Attach a dropdown overlay to this widget.
57    pub fn dropdown(self, open: Atom<bool>,
58                    content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> Self {
59        self.push(OverlayKind::Dropdown, open, content)
60    }
61
62    /// Attach a bottom sheet overlay to this widget.
63    pub fn sheet(self, open: Atom<bool>,
64                 content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> Self {
65        self.push(OverlayKind::Sheet, open, content)
66    }
67
68    /// Attach a modal dialog overlay to this widget.
69    pub fn dialog(self, open: Atom<bool>,
70                  content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> Self {
71        self.push(OverlayKind::Dialog, open, content)
72    }
73
74    /// Attach a CUSTOM-body tooltip overlay to this widget (content-aware:
75    /// the closure builds any widget, not just a text label). The everyday
76    /// string tooltip is the ergonomic [`super::WidgetExt::tooltip`].
77    pub fn rich_tooltip(self, content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> Self {
78        // Tooltip uses a permanent-true open atom — visibility is controlled by hover (Phase 14)
79        let open = rosace_state::use_atom(true);
80        self.push(OverlayKind::Tooltip, open, content)
81    }
82
83    /// Attach a toast overlay to this widget. Use [`Toast::show`] to open it
84    /// with auto-dismiss.
85    ///
86    /// [`Toast::show`]: super::toast::Toast::show
87    pub fn toast(self, open: Atom<bool>,
88                 content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> Self {
89        self.push(OverlayKind::Toast, open, content)
90    }
91}
92
93impl<W: Widget + Send + Sync + 'static> Widget for WithOverlay<W> {
94    fn children(&self) -> super::Children<'_> {
95        super::Children::One(&self.inner)
96    }
97
98    fn paint(&self, ctx: &mut PaintCtx) {
99        self.inner.paint(ctx);
100        let anchor: Rect = ctx.rect;
101
102        for cfg in &self.overlays {
103            if !cfg.open.get() { continue; }
104
105            let content = (cfg.content)();
106            let open_atom = cfg.open.clone();
107
108            let entry = match cfg.kind {
109                OverlayKind::Dropdown => {
110                    let pos = Point {
111                        x: anchor.origin.x,
112                        y: anchor.origin.y + anchor.size.height,
113                    };
114                    // Invisible scrim: a tap anywhere outside the menu closes
115                    // it (and is consumed) — standard menu behavior.
116                    let dismiss = Arc::new(move || open_atom.set(false));
117                    OverlayEntry::new(LayerPosition::Absolute(pos), content)
118                        .input(InputBehavior::PassThrough)
119                        .focus(FocusBehavior::PassThrough)
120                        .scrim(ScrimConfig {
121                            color: Color::TRANSPARENT,
122                            on_tap: Some(dismiss),
123                        exclude_rect: None,
124                        })
125                }
126
127                OverlayKind::Sheet => {
128                    let dismiss = Arc::new(move || open_atom.set(false));
129                    OverlayEntry::new(LayerPosition::BottomAnchored, content)
130                        .input(InputBehavior::PassThrough)
131                        .focus(FocusBehavior::PassThrough)
132                        .scrim(ScrimConfig {
133                            color: Color::rgba(0, 0, 0, 100),
134                            on_tap: Some(dismiss),
135                        exclude_rect: None,
136                        })
137                }
138
139                OverlayKind::Dialog => {
140                    let dismiss = Arc::new(move || open_atom.set(false));
141                    OverlayEntry::new(LayerPosition::Centered, content)
142                        .input(InputBehavior::Block)
143                        .focus(FocusBehavior::Trap)
144                        .scrim(ScrimConfig {
145                            color: Color::rgba(0, 0, 0, 160),
146                            on_tap: Some(dismiss),
147                        exclude_rect: None,
148                        })
149                }
150
151                OverlayKind::Tooltip => {
152                    // Centered just above the hovered widget (user-reported:
153                    // the old right-edge Absolute position drifted far from
154                    // the anchor).
155                    OverlayEntry::new(LayerPosition::AboveCentered(anchor), content)
156                        .input(InputBehavior::PassThrough)
157                        .focus(FocusBehavior::Inert)
158                }
159
160                OverlayKind::Toast => {
161                    OverlayEntry::new(LayerPosition::BottomCenter, content)
162                        .input(InputBehavior::PassThrough)
163                        .focus(FocusBehavior::Inert)
164                }
165            };
166
167            // Attach to the render-tree node (D091): the entry persists across
168            // cache-hit frames and is cleared when this node repaints — an
169            // open dialog can no longer vanish on the MouseUp frame.
170            ctx.attach_overlay(entry);
171        }
172    }
173    // layout, flex_factor: protocol defaults delegate to the child.
174}
175
176// ── OverlayApi trait — blanket impl for all widgets ───────────────────────────
177
178/// Builder methods that attach co-located overlay declarations to any widget.
179///
180/// Each method wraps the widget in a [`WithOverlay`] (or extends an existing
181/// one) and stores the open-state atom + content factory. The framework pushes
182/// the correct [`OverlayEntry`] automatically when the atom is true.
183///
184/// ```rust,ignore
185/// Button::new("Settings")
186///     .sheet(is_open.clone(), || SettingsSheet::new())
187///
188/// Button::new("Delete")
189///     .dialog(confirm_open.clone(), || {
190///         Dialog::new("Are you sure?")
191///             .action("Cancel", || confirm_open.set(false))
192///             .action("Delete", on_delete.clone())
193///     })
194/// ```
195pub trait OverlayApi: Widget + Sized + Send + Sync + 'static {
196    fn dropdown(self, open: Atom<bool>,
197                content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> WithOverlay<Self> {
198        WithOverlay::new(self).dropdown(open, content)
199    }
200
201    fn sheet(self, open: Atom<bool>,
202             content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> WithOverlay<Self> {
203        WithOverlay::new(self).sheet(open, content)
204    }
205
206    fn dialog(self, open: Atom<bool>,
207              content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> WithOverlay<Self> {
208        WithOverlay::new(self).dialog(open, content)
209    }
210
211    fn rich_tooltip(self,
212               content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> WithOverlay<Self> {
213        WithOverlay::new(self).rich_tooltip(content)
214    }
215
216    fn toast(self, open: Atom<bool>,
217             content: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> WithOverlay<Self> {
218        WithOverlay::new(self).toast(open, content)
219    }
220}
221
222impl<W: Widget + Send + Sync + 'static> OverlayApi for W {}