Skip to main content

rosace_widgets/tree/
app_bar.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_layout::Constraints;
3use rosace_render::{Color, DrawCommand};
4use rosace_shader::ShaderMaterial;
5use rosace_theme::TitleAlign;
6use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget, avail_w};
7use super::material::{resolve_material, AppBarMaterial};
8
9/// A top app bar with title, leading, and trailing action slots.
10///
11/// Platform-adaptive (D105 Phase 23): `height`, `show_traffic_lights`, and
12/// title alignment default to the active theme's `app_bar` style
13/// (`ThemeData::app_bar`, an [`rosace_theme::AppBarStyle`]) — the SAME
14/// widget renders macOS/iOS/Android-appropriate chrome purely from theme
15/// data, no platform branch in this file. Per-instance builder calls
16/// (`.height(..)`, `.traffic_lights()`) override the theme for that one
17/// instance; a widget that doesn't call them follows the theme.
18pub struct AppBar {
19    pub title: String,
20    pub title_size: f32,
21    pub background: Color,
22    pub foreground: Color,
23    pub border_color: Color,
24    /// `None` = use the active theme's `app_bar.height`.
25    height: Option<f32>,
26    pub leading: Option<BoxedWidget>,
27    pub actions: Vec<BoxedWidget>,
28    /// `None` = use the active theme's `app_bar.show_traffic_lights`.
29    show_traffic_lights: Option<bool>,
30    material: Option<ShaderMaterial>,
31}
32
33impl AppBar {
34    pub fn new(title: impl Into<String>) -> Self {
35        Self {
36            title: title.into(),
37            title_size: 13.0,
38            background: Color::rgba(0, 0, 0, 0), // sentinel: use theme.surface
39            foreground: Color::rgba(0, 0, 0, 0), // sentinel: use theme.on_surface
40            border_color: Color::rgba(0, 0, 0, 0), // sentinel: use theme.outline
41            height: None,
42            leading: None,
43            actions: Vec::new(),
44            show_traffic_lights: None,
45            material: None,
46        }
47    }
48
49    pub fn background(mut self, c: Color) -> Self { self.background = c; self }
50    pub fn foreground(mut self, c: Color) -> Self { self.foreground = c; self }
51    /// Overrides the active theme's app-bar height for this instance.
52    pub fn height(mut self, h: f32) -> Self { self.height = Some(h); self }
53    pub fn leading(mut self, w: impl Widget + 'static) -> Self { self.leading = Some(Box::new(w)); self }
54    pub fn action(mut self, w: impl Widget + 'static) -> Self { self.actions.push(Box::new(w)); self }
55    /// Overrides the active theme's traffic-light setting for this instance.
56    pub fn no_traffic_lights(mut self) -> Self { self.show_traffic_lights = Some(false); self }
57    /// Draw faux macOS traffic-light dots (only for standalone mockup
58    /// screenshots — a real app window already has real OS traffic lights).
59    /// Overrides the active theme's traffic-light setting for this instance.
60    pub fn traffic_lights(mut self) -> Self { self.show_traffic_lights = Some(true); self }
61    pub fn title_size(mut self, s: f32) -> Self { self.title_size = s; self }
62    /// Per-instance shader material — replaces the bar fill when resolved.
63    /// Beats the theme's `AppBarMaterial` default (D124 Step 5).
64    pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
65
66    fn effective_height(&self, theme: &rosace_theme::ThemeData) -> f32 {
67        self.height.unwrap_or(theme.app_bar.height)
68    }
69}
70
71impl Widget for AppBar {
72    fn layout(&self, ctx: &LayoutCtx) -> Size {
73        let constraints = ctx.constraints;
74        Size { width: avail_w(constraints), height: self.effective_height(ctx.theme) }
75    }
76
77    fn paint(&self, ctx: &mut PaintCtx) {
78        // The app bar's title is the screen's heading — maps to <h1> in the
79        // HTML/SEO export (D107/Phase 25): a screen only ever has one.
80        ctx.semantics(super::Semantics::new(rosace_core::Role::Heading).label(&self.title).heading_level(1));
81        let style = ctx.theme.app_bar;
82        let t = &ctx.theme.colors;
83        let bg     = if self.background.a   == 0 { ctx.tc(t.surface)     } else { self.background   };
84        let fg     = if self.foreground.a   == 0 { ctx.tc(t.on_surface)  } else { self.foreground   };
85        let border = if self.border_color.a == 0 { ctx.tc(t.outline)     } else { self.border_color };
86        let show_traffic_lights = self.show_traffic_lights.unwrap_or(style.show_traffic_lights);
87
88        let r = ctx.rect;
89        // With a material, only paint a fallback it EXPLICITLY carries —
90        // an unconditional base fill is what a backdrop-sampling glass
91        // material would sample instead of the content behind the bar
92        // (same rule as Container/Card).
93        let material = resolve_material::<AppBarMaterial>(&ctx.theme, self.material.as_ref());
94        match &material {
95            Some(m) => {
96                if let Some(fallback) = m.fallback {
97                    ctx.fill_rect(r, fallback);
98                }
99                ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
100            }
101            None => ctx.fill_rect(r, bg),
102        }
103
104        // Elevation — theme-controlled (elevation > 0 draws it; 0 omits it
105        // entirely, e.g. the flat Cupertino look). A real Gaussian-blurred
106        // shadow (`ctx.fill_shadow_rrect`, the same primitive Card/
107        // Container/FAB use), not the previous hand-rolled 4-band linear
108        // gradient PLUS a hard 1px line — that hard line was unconditional
109        // whenever elevation was on, which is what read as "a stroke, not
110        // a shadow" (2026-08-01 user feedback on a real Android device).
111        // Content paints after the bar, so a shadow that bled downward
112        // past this rect would get covered — clip to the bar's own bounds
113        // and cast the shadow from a hairline strip AT the bottom edge:
114        // half its blur bleeds down (clipped away, harmless), half bleeds
115        // up into the bar's own bottom region as a soft falloff — genuinely
116        // self-contained, no dependency on paint order.
117        if style.elevation > 0.0 {
118            let shadow = ctx.tc(ctx.theme.colors.shadow);
119            ctx.record(DrawCommand::PushClip { rect: r });
120            let blur = (3.0 * style.elevation).clamp(2.0, 14.0);
121            let alpha = (70.0 * style.elevation).clamp(0.0, 130.0) as u8;
122            ctx.fill_shadow_rrect(
123                Rect {
124                    origin: Point { x: r.origin.x, y: r.origin.y + r.size.height },
125                    size: Size { width: r.size.width, height: 1.0 },
126                },
127                0.0,
128                Color::rgba(shadow.r, shadow.g, shadow.b, alpha),
129                blur,
130            );
131            ctx.record(DrawCommand::PopClip);
132        } else {
133            // Flat style (elevation == 0, e.g. Cupertino's flat look) — a
134            // hairline separator instead of nothing, so the bar still
135            // reads as distinct from the content below it.
136            ctx.fill_rect(Rect {
137                origin: Point { x: r.origin.x, y: r.origin.y + r.size.height - 1.0 },
138                size: Size { width: r.size.width, height: 1.0 },
139            }, border);
140        }
141
142        let cy = r.origin.y + r.size.height / 2.0;
143        let mut lx = r.origin.x + 16.0;
144
145        // Traffic lights (opt-in mockup chrome).
146        if show_traffic_lights {
147            for (i, color) in [
148                Color::rgb(235, 85, 75),
149                Color::rgb(245, 185, 55),
150                Color::rgb(75, 200, 85),
151            ].iter().enumerate() {
152                ctx.fill_circle(Point { x: lx + i as f32 * 20.0, y: cy }, 7.0, *color);
153            }
154            lx += 72.0;
155        }
156
157        // Leading widget — sized to its content (up to a sane cap), advancing
158        // the left boundary so the title never overlaps it.
159        let height = self.effective_height(&ctx.theme);
160        if let Some(lead) = &self.leading {
161            let ls = lead.layout(&ctx.layout_ctx(Constraints::loose(160.0, height)));
162            let ly = r.origin.y + (r.size.height - ls.height) / 2.0;
163            lead.paint(&mut ctx.child(Rect { origin: Point { x: lx, y: ly }, size: ls }));
164            lx += ls.width + 12.0;
165        }
166
167        // Actions (right side) — paint right-to-left, tracking the left edge
168        // so the title stops before them.
169        let mut ax = r.origin.x + r.size.width - 12.0;
170        for action in self.actions.iter().rev() {
171            let as_ = action.layout(&ctx.layout_ctx(Constraints::loose(160.0, height)));
172            ax -= as_.width + 6.0;
173            let ay = r.origin.y + (r.size.height - as_.height) / 2.0;
174            action.paint(&mut ctx.child(Rect { origin: Point { x: ax, y: ay }, size: as_ }));
175        }
176
177        // Title — the space BETWEEN leading and actions is always the clip
178        // region (the title must never overlap either), but WHERE within the
179        // full bar it centers depends on the theme (D105):
180        //   Leading (default, unchanged from pre-D105 behavior) — centered
181        //     within that between-leading-and-actions region, falling back
182        //     to left-aligned when it doesn't fit.
183        //   Center (Cupertino) — centered in the FULL bar width, still
184        //     clipped to the between region so it can't overlap leading/
185        //     actions — the iOS convention.
186        let region_l = lx;
187        let region_r = (ax - 8.0).max(region_l);
188        let region_w = (region_r - region_l).max(0.0);
189        if region_w > 4.0 {
190            let title_w = ctx.font.measure_text(&self.title, self.title_size);
191            let line_h = ctx.font.line_height(self.title_size);
192            let title_y = r.origin.y + (r.size.height - line_h) / 2.0;
193            let title_x = match style.title_align {
194                TitleAlign::Leading => {
195                    if title_w <= region_w {
196                        region_l + (region_w - title_w) / 2.0
197                    } else {
198                        region_l
199                    }
200                }
201                TitleAlign::Center => {
202                    let full_center = r.origin.x + (r.size.width - title_w) / 2.0;
203                    full_center.clamp(region_l, (region_r - title_w).max(region_l))
204                }
205            };
206            let clip = Rect {
207                origin: Point { x: region_l, y: r.origin.y },
208                size: Size { width: region_w, height: r.size.height },
209            };
210            ctx.record(DrawCommand::PushClip { rect: clip });
211            ctx.draw_text_at(&self.title, Point { x: title_x, y: title_y }, fg, self.title_size);
212            ctx.record(DrawCommand::PopClip);
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn instance_material_paints_a_shader_fill() {
223        let font = rosace_render::FontCache::embedded();
224        let theme = rosace_theme::built_in::dark_theme();
225        let mut recorder = rosace_render::PictureRecorder::new();
226        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
227        let rect = Rect {
228            origin: Point { x: 0.0, y: 0.0 },
229            size: Size { width: 400.0, height: 44.0 },
230        };
231        let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
232        let m = ShaderMaterial::new(rosace_shader::PipelineId::user(0x4004), vec![0u8; 16]);
233        AppBar::new("t").material(m).paint(&mut ctx);
234        let picture = recorder.finish();
235        assert!(picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. })));
236    }
237}