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_core::styles::density::spacing;
35use teksilo_tokens::{InputTokens, SurfaceRole};
36
37pub const STATUS_BAR_HEIGHT: f32 = 22.0;
39pub const STATUS_BAR_PADDING_HORIZONTAL: f32 = 8.0;
40
41pub fn status_bar_padding_horizontal(tokens: &InputTokens) -> f32 {
44 spacing(STATUS_BAR_PADDING_HORIZONTAL, tokens)
45}
46pub const STATUS_BAR_ITEM_GAP: f32 = 2.0;
47
48pub fn status_bar_item_gap(tokens: &InputTokens) -> f32 {
51 spacing(STATUS_BAR_ITEM_GAP, tokens)
52}
53
54pub struct StatusBar {
71 pending: Vec<PendingChild>,
72 child_ids: Vec<WidgetId>,
73 root_child_id: Option<WidgetId>,
74 background: Option<ColorProp>,
75 corner_radius: Option<Prop<f32>>,
76 border_color: Option<ColorProp>,
77 border_width: Option<Prop<f32>>,
78 name: Option<Prop<String>>,
79 announce_changes: bool,
80}
81
82impl StatusBar {
83 pub fn new() -> Self {
86 Self {
87 pending: Vec::new(),
88 child_ids: Vec::new(),
89 root_child_id: None,
90 background: None,
91 corner_radius: None,
92 border_color: None,
93 border_width: None,
94 name: None,
95 announce_changes: false,
96 }
97 }
98
99 pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
101 self.pending
102 .push(teksilo_core::IntoTeksiChild::into_pending(widget));
103 self
104 }
105
106 pub fn children(
111 self,
112 iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
113 ) -> Self {
114 iter.into_iter().fold(self, Self::child)
115 }
116 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
123 match widget {
124 Some(w) => self.child(w),
125 None => self,
126 }
127 }
128
129 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
133 self.background = Some(color.into());
134 self
135 }
136
137 pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
140 self.corner_radius = Some(radius.into());
141 self
142 }
143
144 pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
148 self.border_color = Some(color.into());
149 self
150 }
151
152 pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
155 self.border_width = Some(width.into());
156 self
157 }
158
159 pub fn name(mut self, name: impl Into<Prop<String>>) -> Self {
164 self.name = Some(name.into());
165 self
166 }
167
168 pub fn announce_changes(mut self, announce: bool) -> Self {
176 self.announce_changes = announce;
177 self
178 }
179}
180
181impl Default for StatusBar {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187impl std::fmt::Debug for StatusBar {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.debug_struct("StatusBar").finish()
190 }
191}
192
193impl Widget for StatusBar {
194 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
195 let _ = ctx.theme_signal();
196 let spacing = status_bar_item_gap(&ctx.theme().input);
197
198 if let Some(name) = self.name.as_ref() {
204 name.register_if_bound(
205 ctx.self_id(),
206 ctx.binding_registry(),
207 teksilo_core::binding::BindingLevel::AccessibilityOnly,
208 );
209 }
210
211 let pending = std::mem::take(&mut self.pending);
213 if !pending.is_empty() {
214 self.child_ids = pending
215 .into_iter()
216 .map(|child| match child {
217 PendingChild::Id(id) => id,
218 PendingChild::Deferred(w) => ctx.add_boxed(w),
219 })
220 .collect();
221 }
222
223 let mut row = HStack::new().spacing(spacing);
224 for &id in &self.child_ids {
225 row = row.child(id);
226 }
227
228 let row_id = ctx.add(row);
229 let mut panel = Panel::new()
230 .background(
231 self.background
232 .take()
233 .unwrap_or_else(|| SurfaceRole::Sunken.into()),
234 )
235 .corner_radius(self.corner_radius.take().unwrap_or(Prop::Static(0.0)))
236 .padding(spacing)
237 .a11y_presentational()
238 .child(row_id);
239 if let Some(border_color) = self.border_color.take() {
240 panel = panel.border_color(border_color);
241 }
242 if let Some(border_width) = self.border_width.take() {
243 panel = panel.border_width(border_width);
244 }
245 let root = ctx.add(panel);
246 self.root_child_id = Some(root);
247 vec![root]
248 }
249
250 fn layout_response(
251 &self,
252 proposal: SizeProposal,
253 ctx: &LayoutContext,
254 ) -> teksilo_core::widget::LayoutResponse {
255 if let Some(root) = self.root_child_id
256 && let Some(size) = ctx.child_size(root, proposal)
257 {
258 return (size).into();
259 }
260 proposal.resolve(0.0, 0.0).into()
261 }
262
263 fn place_children(
264 &self,
265 bounds: Rect,
266 _proposal: SizeProposal,
267 children: &mut [WidgetPlacement],
268 _ctx: &LayoutContext,
269 ) {
270 for child in children.iter_mut() {
271 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
272 child.size = Size::new(bounds.width, bounds.height);
273 }
274 }
275
276 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
277 builder.set_role(teksilo_core::accesskit::Role::Status);
278 let name = match &self.name {
279 Some(prop) => prop.get(),
280 None => teksilo_i18n::tr_widget!(a11y_status_bar_name()).resolve_now(),
281 };
282 builder.set_name(name);
283 if self.announce_changes {
284 builder.set_live(teksilo_core::accesskit::Live::Polite);
285 }
286 }
287
288 fn children(&self) -> Vec<WidgetId> {
289 self.root_child_id.into_iter().collect()
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use teksilo_core::widget_tree::WidgetTree;
297
298 #[test]
299 fn status_bar_builds() {
300 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
301 let sb = tree.add(StatusBar::new());
302 tree.layout(SizeProposal::exact(400.0, 50.0));
303 let b = tree.bounds(sb);
304 assert!(b.width > 0.0);
305 }
306
307 #[test]
308 fn status_bar_accessibility() {
309 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
310 let sb = tree.add(StatusBar::new());
311 tree.layout(SizeProposal::exact(400.0, 50.0));
312 let info = tree.accessibility_node(sb);
313 assert_eq!(info.role(), teksilo_core::accesskit::Role::Status);
314 assert_eq!(info.name(), Some("Status"));
315 }
316
317 #[test]
318 fn status_bar_default_has_no_live_region() {
319 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
320 let sb = tree.add(StatusBar::new());
321 tree.layout(SizeProposal::exact(400.0, 50.0));
322 let update = tree.sync_accessibility();
323 let sb_nid = teksilo_core::accessibility::widget_id_to_node_id(sb);
324 let sb_node = update
325 .nodes
326 .iter()
327 .find(|(id, _)| *id == sb_nid)
328 .map(|(_, n)| n)
329 .expect("status bar node in tree");
330 assert_eq!(sb_node.role(), teksilo_core::accesskit::Role::Status);
332 assert_eq!(sb_node.live(), None);
333 }
334
335 #[test]
336 fn status_bar_name_override() {
337 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
338 let sb = tree.add(StatusBar::new().name("Editor status".to_string()));
339 tree.layout(SizeProposal::exact(400.0, 50.0));
340 let info = tree.accessibility_node(sb);
341 assert_eq!(info.name(), Some("Editor status"));
342 }
343
344 #[test]
345 fn status_bar_announce_changes_enables_polite_live_region_and_no_group_wrapper() {
346 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
347 let sb = tree.add(StatusBar::new().announce_changes(true));
348 tree.layout(SizeProposal::exact(400.0, 50.0));
349 let update = tree.sync_accessibility();
350 let sb_nid = teksilo_core::accessibility::widget_id_to_node_id(sb);
351 let sb_node = update
352 .nodes
353 .iter()
354 .find(|(id, _)| *id == sb_nid)
355 .map(|(_, n)| n)
356 .expect("status bar node in tree");
357 assert_eq!(sb_node.live(), Some(teksilo_core::accesskit::Live::Polite));
358 let groups: Vec<_> = update
361 .nodes
362 .iter()
363 .filter(|(_, n)| n.role() == teksilo_core::accesskit::Role::Group)
364 .collect();
365 assert!(
366 groups.is_empty(),
367 "expected no Role::Group wrapper under StatusBar, got {}",
368 groups.len()
369 );
370 }
371}