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_core::styles::density::spacing;
35use teksilo_tokens::{InputTokens, SurfaceRole};
36
37/// StatusBar design tokens.
38pub const STATUS_BAR_HEIGHT: f32 = 22.0;
39pub const STATUS_BAR_PADDING_HORIZONTAL: f32 = 8.0;
40
41/// [`STATUS_BAR_PADDING_HORIZONTAL`] scaled by the density's `spacing_factor`
42/// (1.00 / 1.15 / 1.30).
43pub 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
48/// [`STATUS_BAR_ITEM_GAP`] scaled by the density's `spacing_factor`
49/// (1.00 / 1.15 / 1.30).
50pub fn status_bar_item_gap(tokens: &InputTokens) -> f32 {
51    spacing(STATUS_BAR_ITEM_GAP, tokens)
52}
53
54/// A status bar for displaying information at the bottom of a window.
55///
56/// Visual chrome is delegated to an inner [`Panel`]. By default the bar
57/// uses the `SurfaceRole::Sunken` surface with **square corners** (a bar
58/// spanning the window edge shouldn't be rounded); override the surface
59/// with [`background`](Self::background), the corners with
60/// [`corner_radius`](Self::corner_radius), or add a frame with
61/// [`border_color`](Self::border_color) / [`border_width`](Self::border_width).
62///
63/// Accessibility: the bar publishes `Role::Status` (→ AT-SPI `StatusBar`,
64/// macOS `AXApplicationStatus`, Windows `UIA_StatusBarControlTypeId`) so
65/// it is discoverable as a status landmark. It is **not** a live region
66/// by default — a status bar showing continuously-changing data (cursor
67/// position, zoom level, word count) would otherwise flood the screen
68/// reader. Call [`announce_changes(true)`](Self::announce_changes) for a
69/// bar that surfaces transient messages worth reading aloud ("Saved").
70pub 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    /// Create an empty status bar with default styling (`SurfaceRole::Sunken`,
84    /// square corners, no live region).
85    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    /// Add an inline child widget (deferred insertion).
100    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    /// Add several inline children from an iterator.
107    ///
108    /// The loop form of [`child`](Self::child): one call where a chain of
109    /// `.child(..)` would otherwise repeat per element.
110    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    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
117    ///
118    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
119    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
120    /// adds no arena node, so nothing is laid out, painted, or published to the
121    /// accessibility tree, and a stack applies no spacing around it.
122    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    /// Override the background surface. Accepts `Color`, a
130    /// [`SurfaceRole`], or a `Signal<Color>`.
131    /// Default (unset) is `SurfaceRole::Sunken`.
132    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
133        self.background = Some(color.into());
134        self
135    }
136
137    /// Override the corner radius. Accepts a static `f32` or a reactive
138    /// `Signal<f32>`. Default (unset) is `0.0` — square corners.
139    pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
140        self.corner_radius = Some(radius.into());
141        self
142    }
143
144    /// Override the border color. Accepts `Color`, a
145    /// [`BorderRole`](teksilo_tokens::BorderRole), or a `Signal<Color>`.
146    /// Only painted when [`border_width`](Self::border_width) > 0.
147    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
148        self.border_color = Some(color.into());
149        self
150    }
151
152    /// Override the border width. Accepts a static `f32` or a reactive
153    /// `Signal<f32>`. Default (unset) is `0.0` — no border.
154    pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
155        self.border_width = Some(width.into());
156        self
157    }
158
159    /// Override the accessible name announced for the bar. Accepts a
160    /// static string, a `Signal<String>`, or a `tr!(...)`
161    /// [`LocalizedString`](teksilo_i18n::LocalizedString) (locale-reactive).
162    /// Default (unset) is the localized "Status".
163    pub fn name(mut self, name: impl Into<Prop<String>>) -> Self {
164        self.name = Some(name.into());
165        self
166    }
167
168    /// Control whether content changes are announced by assistive tech.
169    ///
170    /// Default `false`: the `Role::Status` landmark is published (still
171    /// navigable) but the bar is not a live region, so continuously-changing
172    /// data (cursor position, zoom, word count) doesn't flood the screen
173    /// reader. Set `true` to make it a `Live::Polite` region for bars that
174    /// surface transient messages worth reading aloud ("Saved").
175    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        // Register a bound `name` prop on the StatusBar itself at
199        // AccessibilityOnly so a change to the status text re-walks the AT tree
200        // and re-announces it (WCAG 4.1.3) — particularly when
201        // `announce_changes(true)` makes this a `Live::Polite` region.
202        // Static names are ignored by `register_if_bound`.
203        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        // Resolve pending children
212        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        // Role stays discoverable, but no auto-announce by default.
331        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        // Panel wrapper should be hidden so StatusBar → HStack directly,
359        // no intermediate Role::Group node.
360        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}