Skip to main content

rosace_widgets/tree/
drawer.rs

1use std::sync::Arc;
2use rosace_core::types::Size;
3use rosace_state::Atom;
4use rosace_render::Color;
5use rosace_shader::ShaderMaterial;
6use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget};
7use super::material::{resolve_material, DrawerMaterial};
8use super::overlay::{OverlayEntry, LayerPosition, InputBehavior, FocusBehavior, ScrimConfig, push_overlay};
9
10/// A slide-in side panel. Attach to any widget's paint via `.drawer(open, ..)`
11/// (see DrawerApi) or use directly: when `open`, it pushes a dimmed scrim +
12/// a left-anchored panel overlay. Tapping the scrim closes it.
13///
14/// Customization (D115/Phase 32 Step 1): [`Drawer::full_screen`] makes the
15/// panel cover the whole window (mobile nav-page style); [`Drawer::background`]
16/// and [`Drawer::scrim_color`] replace the theme-derived defaults.
17pub struct Drawer {
18    open: Atom<bool>,
19    width: f32,
20    full_screen: bool,
21    background: Option<Color>,
22    scrim_color: Color,
23    material: Option<ShaderMaterial>,
24    panel: Arc<dyn Fn() -> BoxedWidget + Send + Sync>,
25}
26
27impl Drawer {
28    pub fn new(open: Atom<bool>, panel: impl Fn() -> BoxedWidget + Send + Sync + 'static) -> Self {
29        Self {
30            open,
31            width: 280.0,
32            full_screen: false,
33            background: None,
34            scrim_color: Color::rgba(0, 0, 0, 120),
35            material: None,
36            panel: Arc::new(panel),
37        }
38    }
39    pub fn width(mut self, w: f32) -> Self { self.width = w; self }
40
41    /// Cover the entire window instead of a fixed-width side panel — the
42    /// full-screen navigation-page presentation. There is no scrim area
43    /// left to tap, so dismissal is the panel content's job (or Escape).
44    pub fn full_screen(mut self) -> Self { self.full_screen = true; self }
45
46    /// Panel fill — defaults to the theme's `surface`.
47    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
48
49    /// Scrim (barrier) color over the content behind the panel — defaults
50    /// to black at ~47% opacity.
51    pub fn scrim_color(mut self, c: Color) -> Self { self.scrim_color = c; self }
52    /// Per-instance shader material — replaces the panel fill when
53    /// resolved. Beats the theme's `DrawerMaterial` default (D124 Step 5).
54    pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
55
56    /// Emit the drawer overlay if open. Call from a host widget's paint (the
57    /// Scaffold does this) — the drawer has no visual of its own when closed.
58    pub fn emit(&self) {
59        if !self.open.get() { return; }
60        let close = self.open.clone();
61        let panel = (self.panel)();
62        push_overlay(
63            OverlayEntry::new(LayerPosition::Fill, DrawerPanel {
64                width: self.width,
65                full_screen: self.full_screen,
66                background: self.background,
67                material: self.material.clone(),
68                panel,
69            })
70                .input(InputBehavior::Block)
71                .focus(FocusBehavior::Trap)
72                .scrim(ScrimConfig { color: self.scrim_color, on_tap: Some(Arc::new(move || close.set(false))), exclude_rect: None }),
73        );
74    }
75}
76
77struct DrawerPanel {
78    width: f32,
79    full_screen: bool,
80    background: Option<Color>,
81    material: Option<ShaderMaterial>,
82    panel: BoxedWidget,
83}
84
85impl Widget for DrawerPanel {
86    fn layout(&self, ctx: &LayoutCtx) -> Size {
87        // The panel sizes itself to its REAL width (full window height),
88        // not the whole window: the overlay dispatch treats the widget's
89        // rect as "the surface" — taps inside it are absorbed, taps outside
90        // it reach the scrim's tap-to-dismiss. Sizing the panel to the full
91        // window (the original version) made every tap land "inside" and
92        // the documented scrim tap-to-close unreachable.
93        let avail_w = super::avail_w(ctx.constraints);
94        let w = if self.full_screen { avail_w } else { self.width.min(avail_w) };
95        Size { width: w, height: super::avail_h(ctx.constraints) }
96    }
97    fn paint(&self, ctx: &mut PaintCtx) {
98        let bg = self.background.unwrap_or_else(|| ctx.tc(ctx.theme.colors.surface));
99        let r = ctx.rect;
100        // With a material, only paint a fallback it EXPLICITLY carries — an
101        // unconditional base fill is what a backdrop-sampling glass material
102        // would sample instead of the content behind the panel (same rule
103        // as Container/Card).
104        let material = resolve_material::<DrawerMaterial>(&ctx.theme, self.material.as_ref());
105        match &material {
106            Some(m) => {
107                if let Some(fallback) = m.fallback {
108                    ctx.fill_rect(r, fallback);
109                }
110                ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
111            }
112            None => ctx.fill_rect(r, bg),
113        }
114        self.panel.paint(&mut ctx.child(r));
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use super::super::overlay::{clear_overlays, drain_overlays};
122    use super::super::spacer::Spacer;
123    use rosace_layout::Constraints;
124
125    fn emit_one(drawer: &Drawer) -> OverlayEntry {
126        clear_overlays();
127        drawer.emit();
128        let mut entries = drain_overlays();
129        assert_eq!(entries.len(), 1);
130        entries.pop().unwrap()
131    }
132
133    #[test]
134    fn instance_material_paints_a_shader_fill() {
135        let open = rosace_state::use_atom(true);
136        let m = ShaderMaterial::new(rosace_shader::PipelineId::user(0x4002), vec![0u8; 16]);
137        let drawer = Drawer::new(open, || Box::new(Spacer::new(0.0))).material(m);
138        let entry = emit_one(&drawer);
139
140        let font = rosace_render::FontCache::embedded();
141        let theme = rosace_theme::built_in::dark_theme();
142        let mut recorder = rosace_render::PictureRecorder::new();
143        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
144        let rect = rosace_core::types::Rect {
145            origin: rosace_core::types::Point { x: 0.0, y: 0.0 },
146            size: Size { width: 280.0, height: 600.0 },
147        };
148        let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
149        entry.widget.paint(&mut ctx);
150        let picture = recorder.finish();
151        assert!(picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. })));
152    }
153
154    #[test]
155    fn emit_pushes_nothing_while_closed() {
156        clear_overlays();
157        let open = rosace_state::use_atom(false);
158        Drawer::new(open, || Box::new(Spacer::new(0.0))).emit();
159        assert!(drain_overlays().is_empty());
160    }
161
162    #[test]
163    fn emit_maps_to_fill_block_trap_with_dismissable_scrim() {
164        let open = rosace_state::use_atom(true);
165        let drawer = Drawer::new(open.clone(), || Box::new(Spacer::new(0.0)));
166        let e = emit_one(&drawer);
167        assert!(matches!(e.position, LayerPosition::Fill));
168        assert_eq!(e.input, InputBehavior::Block);
169        assert_eq!(e.focus, FocusBehavior::Trap);
170        let scrim = e.scrim.expect("drawer must have a scrim");
171        let on_tap = scrim.on_tap.expect("scrim must dismiss on tap");
172        on_tap();
173        assert!(!open.get(), "scrim tap must close the drawer");
174    }
175
176    #[test]
177    fn panel_is_side_width_by_default_and_window_width_when_full_screen() {
178        let font = rosace_render::FontCache::embedded();
179        let theme = rosace_theme::built_in::dark_theme();
180        let ctx = LayoutCtx::new(Constraints::loose(800.0, 600.0), &font, &theme);
181
182        let open = rosace_state::use_atom(true);
183        let side = emit_one(&Drawer::new(open.clone(), || Box::new(Spacer::new(0.0))));
184        let size = side.widget.layout(&ctx);
185        assert_eq!((size.width, size.height), (280.0, 600.0));
186
187        let full = emit_one(&Drawer::new(open, || Box::new(Spacer::new(0.0))).full_screen());
188        let size = full.widget.layout(&ctx);
189        assert_eq!((size.width, size.height), (800.0, 600.0));
190    }
191}