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