Skip to main content

teksilo_widgets/
status_bar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! StatusBar — a horizontal chrome bar at the bottom of a window for status
5//! information.
6//!
7//! The bar publishes `Role::Status` so assistive technology can discover it as
8//! a status landmark. It is **not** a live region by default — use
9//! [`announce_changes(true)`](StatusBar::announce_changes) only for bars that
10//! surface transient messages worth reading aloud (e.g. "Saved"), not for bars
11//! showing continuous data like cursor position or zoom level that would flood
12//! the screen reader. Visual chrome (background, border, corner radius) is
13//! delegated to an inner [`Panel`].
14//!
15//! ```rust
16//! # use teksilo_widgets::StatusBar;
17//! # use teksilo_widgets::primitives::TextWidget;
18//! # use teksilo_i18n::lit;
19//! let _bar = StatusBar::new()
20//!     .child(TextWidget::new(lit!("Ln 1, Col 1")))
21//!     .announce_changes(false);
22//! ```
23
24use 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
36/// StatusBar design tokens.
37pub 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
41/// A status bar for displaying information at the bottom of a window.
42///
43/// Visual chrome is delegated to an inner [`Panel`]. By default the bar
44/// uses the `SurfaceRole::Sunken` surface with **square corners** (a bar
45/// spanning the window edge shouldn't be rounded); override the surface
46/// with [`background`](Self::background), the corners with
47/// [`corner_radius`](Self::corner_radius), or add a frame with
48/// [`border_color`](Self::border_color) / [`border_width`](Self::border_width).
49///
50/// Accessibility: the bar publishes `Role::Status` (→ AT-SPI `StatusBar`,
51/// macOS `AXApplicationStatus`, Windows `UIA_StatusBarControlTypeId`) so
52/// it is discoverable as a status landmark. It is **not** a live region
53/// by default — a status bar showing continuously-changing data (cursor
54/// position, zoom level, word count) would otherwise flood the screen
55/// reader. Call [`announce_changes(true)`](Self::announce_changes) for a
56/// bar that surfaces transient messages worth reading aloud ("Saved").
57pub 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    /// Create an empty status bar with default styling (`SurfaceRole::Sunken`,
71    /// square corners, no live region).
72    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    /// Add an inline child widget (deferred insertion).
87    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
88        self.pending.push(PendingChild::Deferred(Box::new(widget)));
89        self
90    }
91
92    /// Add a pre-registered child widget by ID.
93    pub fn add_child(mut self, id: WidgetId) -> Self {
94        self.pending.push(PendingChild::Id(id));
95        self
96    }
97
98    /// Override the background surface. Accepts `Color`, a
99    /// [`SurfaceRole`], or a `Signal<Color>`.
100    /// Default (unset) is `SurfaceRole::Sunken`.
101    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
102        self.background = Some(color.into());
103        self
104    }
105
106    /// Override the corner radius. Accepts a static `f32` or a reactive
107    /// `Signal<f32>`. Default (unset) is `0.0` — square corners.
108    pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
109        self.corner_radius = Some(radius.into());
110        self
111    }
112
113    /// Override the border color. Accepts `Color`, a
114    /// [`BorderRole`](teksilo_tokens::BorderRole), or a `Signal<Color>`.
115    /// Only painted when [`border_width`](Self::border_width) > 0.
116    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
117        self.border_color = Some(color.into());
118        self
119    }
120
121    /// Override the border width. Accepts a static `f32` or a reactive
122    /// `Signal<f32>`. Default (unset) is `0.0` — no border.
123    pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
124        self.border_width = Some(width.into());
125        self
126    }
127
128    /// Override the accessible name announced for the bar. Accepts a
129    /// static string, a `Signal<String>`, or a `tr!(...)`
130    /// [`LocalizedString`](teksilo_i18n::LocalizedString) (locale-reactive).
131    /// Default (unset) is the localized "Status".
132    pub fn name(mut self, name: impl Into<Prop<String>>) -> Self {
133        self.name = Some(name.into());
134        self
135    }
136
137    /// Control whether content changes are announced by assistive tech.
138    ///
139    /// Default `false`: the `Role::Status` landmark is published (still
140    /// navigable) but the bar is not a live region, so continuously-changing
141    /// data (cursor position, zoom, word count) doesn't flood the screen
142    /// reader. Set `true` to make it a `Live::Polite` region for bars that
143    /// surface transient messages worth reading aloud ("Saved").
144    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        // Register a bound `name` prop on the StatusBar itself at
168        // AccessibilityOnly so a change to the status text re-walks the AT tree
169        // and re-announces it (WCAG 4.1.3) — particularly when
170        // `announce_changes(true)` makes this a `Live::Polite` region.
171        // Static names are ignored by `register_if_bound`.
172        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        // Resolve pending children
181        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        // Role stays discoverable, but no auto-announce by default.
300        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        // Panel wrapper should be hidden so StatusBar → HStack directly,
328        // no intermediate Role::Group node.
329        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}