teksilo_widgets/
status_bar.rs1use teksilo_canvas::{Rect, Size, SizeProposal};
25use teksilo_core::accessibility::AccessNodeBuilder;
26use teksilo_core::build_context::BuildContext;
27use teksilo_core::color_prop::ColorProp;
28use teksilo_core::signal::Prop;
29use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
30use teksilo_core::widget_id::WidgetId;
31
32use crate::Panel;
33use crate::primitives::HStack;
34use teksilo_tokens::SurfaceRole;
35
36pub const STATUS_BAR_HEIGHT: f32 = 22.0;
38pub const STATUS_BAR_PADDING_HORIZONTAL: f32 = 8.0;
39pub const STATUS_BAR_ITEM_GAP: f32 = 2.0;
40
41pub struct StatusBar {
58 pending: Vec<PendingChild>,
59 child_ids: Vec<WidgetId>,
60 root_child_id: Option<WidgetId>,
61 background: Option<ColorProp>,
62 corner_radius: Option<Prop<f32>>,
63 border_color: Option<ColorProp>,
64 border_width: Option<Prop<f32>>,
65 name: Option<Prop<String>>,
66 announce_changes: bool,
67}
68
69impl StatusBar {
70 pub fn new() -> Self {
73 Self {
74 pending: Vec::new(),
75 child_ids: Vec::new(),
76 root_child_id: None,
77 background: None,
78 corner_radius: None,
79 border_color: None,
80 border_width: None,
81 name: None,
82 announce_changes: false,
83 }
84 }
85
86 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
88 self.pending.push(PendingChild::Deferred(Box::new(widget)));
89 self
90 }
91
92 pub fn add_child(mut self, id: WidgetId) -> Self {
94 self.pending.push(PendingChild::Id(id));
95 self
96 }
97
98 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
102 self.background = Some(color.into());
103 self
104 }
105
106 pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
109 self.corner_radius = Some(radius.into());
110 self
111 }
112
113 pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
117 self.border_color = Some(color.into());
118 self
119 }
120
121 pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
124 self.border_width = Some(width.into());
125 self
126 }
127
128 pub fn name(mut self, name: impl Into<Prop<String>>) -> Self {
133 self.name = Some(name.into());
134 self
135 }
136
137 pub fn announce_changes(mut self, announce: bool) -> Self {
145 self.announce_changes = announce;
146 self
147 }
148}
149
150impl Default for StatusBar {
151 fn default() -> Self {
152 Self::new()
153 }
154}
155
156impl std::fmt::Debug for StatusBar {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.debug_struct("StatusBar").finish()
159 }
160}
161
162impl Widget for StatusBar {
163 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
164 let _ = ctx.theme_signal();
165 let spacing = STATUS_BAR_ITEM_GAP;
166
167 if let Some(name) = self.name.as_ref() {
173 name.register_if_bound(
174 ctx.self_id(),
175 ctx.binding_registry(),
176 teksilo_core::binding::BindingLevel::AccessibilityOnly,
177 );
178 }
179
180 let pending = std::mem::take(&mut self.pending);
182 if !pending.is_empty() {
183 self.child_ids = pending
184 .into_iter()
185 .map(|child| match child {
186 PendingChild::Id(id) => id,
187 PendingChild::Deferred(w) => ctx.add_boxed(w),
188 })
189 .collect();
190 }
191
192 let mut row = HStack::new().spacing(spacing);
193 for &id in &self.child_ids {
194 row = row.add_child(id);
195 }
196
197 let row_id = ctx.add(row);
198 let mut panel = Panel::new()
199 .background(
200 self.background
201 .take()
202 .unwrap_or_else(|| SurfaceRole::Sunken.into()),
203 )
204 .corner_radius(self.corner_radius.take().unwrap_or(Prop::Static(0.0)))
205 .padding(spacing)
206 .a11y_presentational()
207 .child_id(row_id);
208 if let Some(border_color) = self.border_color.take() {
209 panel = panel.border_color(border_color);
210 }
211 if let Some(border_width) = self.border_width.take() {
212 panel = panel.border_width(border_width);
213 }
214 let root = ctx.add(panel);
215 self.root_child_id = Some(root);
216 vec![root]
217 }
218
219 fn layout_response(
220 &self,
221 proposal: SizeProposal,
222 ctx: &LayoutContext,
223 ) -> teksilo_core::widget::LayoutResponse {
224 if let Some(root) = self.root_child_id
225 && let Some(size) = ctx.child_size(root, proposal)
226 {
227 return (size).into();
228 }
229 proposal.resolve(0.0, 0.0).into()
230 }
231
232 fn place_children(
233 &self,
234 bounds: Rect,
235 _proposal: SizeProposal,
236 children: &mut [WidgetPlacement],
237 _ctx: &LayoutContext,
238 ) {
239 for child in children.iter_mut() {
240 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
241 child.size = Size::new(bounds.width, bounds.height);
242 }
243 }
244
245 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
246 builder.set_role(teksilo_core::accesskit::Role::Status);
247 let name = match &self.name {
248 Some(prop) => prop.get(),
249 None => teksilo_i18n::tr_widget!(a11y_status_bar_name()).resolve_now(),
250 };
251 builder.set_name(name);
252 if self.announce_changes {
253 builder.set_live(teksilo_core::accesskit::Live::Polite);
254 }
255 }
256
257 fn children(&self) -> Vec<WidgetId> {
258 self.root_child_id.into_iter().collect()
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use teksilo_core::widget_tree::WidgetTree;
266
267 #[test]
268 fn status_bar_builds() {
269 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
270 let sb = tree.add(StatusBar::new());
271 tree.layout(SizeProposal::exact(400.0, 50.0));
272 let b = tree.bounds(sb);
273 assert!(b.width > 0.0);
274 }
275
276 #[test]
277 fn status_bar_accessibility() {
278 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
279 let sb = tree.add(StatusBar::new());
280 tree.layout(SizeProposal::exact(400.0, 50.0));
281 let info = tree.accessibility_node(sb);
282 assert_eq!(info.role(), teksilo_core::accesskit::Role::Status);
283 assert_eq!(info.name(), Some("Status"));
284 }
285
286 #[test]
287 fn status_bar_default_has_no_live_region() {
288 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
289 let sb = tree.add(StatusBar::new());
290 tree.layout(SizeProposal::exact(400.0, 50.0));
291 let update = tree.sync_accessibility();
292 let sb_nid = teksilo_core::accessibility::widget_id_to_node_id(sb);
293 let sb_node = update
294 .nodes
295 .iter()
296 .find(|(id, _)| *id == sb_nid)
297 .map(|(_, n)| n)
298 .expect("status bar node in tree");
299 assert_eq!(sb_node.role(), teksilo_core::accesskit::Role::Status);
301 assert_eq!(sb_node.live(), None);
302 }
303
304 #[test]
305 fn status_bar_name_override() {
306 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
307 let sb = tree.add(StatusBar::new().name("Editor status".to_string()));
308 tree.layout(SizeProposal::exact(400.0, 50.0));
309 let info = tree.accessibility_node(sb);
310 assert_eq!(info.name(), Some("Editor status"));
311 }
312
313 #[test]
314 fn status_bar_announce_changes_enables_polite_live_region_and_no_group_wrapper() {
315 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
316 let sb = tree.add(StatusBar::new().announce_changes(true));
317 tree.layout(SizeProposal::exact(400.0, 50.0));
318 let update = tree.sync_accessibility();
319 let sb_nid = teksilo_core::accessibility::widget_id_to_node_id(sb);
320 let sb_node = update
321 .nodes
322 .iter()
323 .find(|(id, _)| *id == sb_nid)
324 .map(|(_, n)| n)
325 .expect("status bar node in tree");
326 assert_eq!(sb_node.live(), Some(teksilo_core::accesskit::Live::Polite));
327 let groups: Vec<_> = update
330 .nodes
331 .iter()
332 .filter(|(_, n)| n.role() == teksilo_core::accesskit::Role::Group)
333 .collect();
334 assert!(
335 groups.is_empty(),
336 "expected no Role::Group wrapper under StatusBar, got {}",
337 groups.len()
338 );
339 }
340}