Skip to main content

teksilo_widgets/
badge.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Badge — a pill-shaped label for tags, status indicators, and counts.
5//!
6//! `Badge` renders a short piece of text inside a rounded-pill container.
7//! Common uses include tag chips on list items, unread-count bubbles in
8//! navigation rails, and severity labels in alert rows. The pill chrome
9//! (corner radius, padding, surface tint) is driven by the active
10//! `BadgeStyle`; callers may swap it per-instance (`.style(...)`) or
11//! theme-wide via `theme.style_slots.badge`.
12//!
13//! ## When to use
14//!
15//! - Inline chip that annotates another widget (version tag, "NEW" label).
16//! - Standalone count indicator; pair with `SeverityBadge` for icon-backed
17//!   status glyphs.
18//!
19//! ## Accessibility
20//!
21//! Announces as `Role::Label` with its resolved text as the AT name.
22//! The inner `TextWidget` is hidden from AT to avoid double-announcement;
23//! the badge emits that label's text runs from its own node instead, so a
24//! reader can review the text by character, word and line rather than only
25//! hear it.
26//!
27//! ```rust
28//! # use teksilo_widgets::Badge;
29//! # use teksilo_i18n::lit;
30//! # use teksilo_tokens::Color;
31//! let _badge = Badge::new(lit!("NEW"))
32//!     .background(Color::new(0.2, 0.6, 1.0, 1.0));
33//! ```
34//!
35//! ## Touch and pen
36//!
37//! Nothing to do, and it is worth saying why: a `Badge` carries no pointer
38//! handler of any kind — no tap, no hover, no drag — so it is not a target,
39//! takes no press and needs no widened hit area. Its only pointer-adjacent
40//! feature is a tooltip, whose touch route is the long press the tooltip
41//! package owns. A badge that an app makes tappable does so by wrapping it,
42//! and the wrapper is the target.
43
44use std::rc::Rc;
45
46use teksilo_canvas::{Rect, Size, SizeProposal};
47use teksilo_core::accessibility::AccessNodeBuilder;
48use teksilo_core::accessibility::text_runs::{TextGeometryHandle, TextRunSource, push_text_runs};
49use teksilo_core::build_context::BuildContext;
50use teksilo_core::color_prop::ColorProp;
51use teksilo_core::styles::{BadgeStyleConfig, SharedBadgeStyle};
52use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
53use teksilo_core::widget_id::WidgetId;
54use teksilo_tokens::TextStyleRole;
55
56use crate::primitives::TextWidget;
57use teksilo_i18n::LocalizedString;
58
59/// A pill-shaped label for displaying tags, counts, or status.
60pub struct Badge {
61    label: LocalizedString,
62    background: Option<ColorProp>,
63    text_role: Option<ColorProp>,
64    /// Per-call override for the label's text style (font, size, weight).
65    /// `None` ⇒ the default `TextStyleRole::Tiny`.
66    text_style: Option<teksilo_core::color_prop::TextStyleProp>,
67    /// Per-call override for the pill chrome.
68    style_override: Option<SharedBadgeStyle>,
69    root_child_id: Option<WidgetId>,
70    /// What the hidden label last measured. The badge owns its accessible
71    /// name, so it also owns the text runs behind it; the label lends its
72    /// layout through this handle. Empty until the label has been placed.
73    label_geometry: Option<TextGeometryHandle>,
74    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
75    /// with the rich / composite slots — every setter clears the other two so
76    /// the last call wins.
77    tooltip_text: Option<LocalizedString>,
78    /// Optional rich tooltip source (registry key or inline content).
79    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
80    /// Optional composite tooltip body (arbitrary widget tree).
81    composite_tooltip_content: Option<Box<dyn Widget>>,
82}
83
84impl Badge {
85    /// Construct a badge with the given label text.
86    pub fn new(label: impl Into<LocalizedString>) -> Self {
87        Self {
88            label: label.into(),
89            background: None,
90            text_role: None,
91            text_style: None,
92            style_override: None,
93            root_child_id: None,
94            label_geometry: None,
95            tooltip_text: None,
96            rich_tooltip_source: None,
97            composite_tooltip_content: None,
98        }
99    }
100
101    /// Per-call style override for the badge pill chrome. Replaces the
102    /// theme-wide default `BadgeStyle` for just this instance.
103    pub fn style(mut self, style: impl teksilo_core::styles::BadgeStyle) -> Self {
104        self.style_override = Some(Rc::new(style));
105        self
106    }
107
108    /// Override the badge background. Accepts `Color`, a
109    /// [`SurfaceRole`](teksilo_tokens::SurfaceRole) / [`TextRole`](teksilo_tokens::TextRole),
110    /// or a `Signal<Color>`. Default (unset) is `SurfaceRole::AccentSubtle`.
111    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
112        self.background = Some(color.into());
113        self
114    }
115
116    /// Override the badge text color. Accepts `Color`, a role, or a signal.
117    /// Default (unset) is the theme's `status_info_fg`.
118    pub fn text_role(mut self, color: impl Into<ColorProp>) -> Self {
119        self.text_role = Some(color.into());
120        self
121    }
122
123    /// Override the label's text style (font, size, weight). Accepts a
124    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default
125    /// (unset) is `TextStyleRole::Tiny`.
126    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
127        self.text_style = Some(style.into());
128        self
129    }
130
131    /// Attach a plain single-line tooltip shown after a hover delay.
132    ///
133    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
134    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
135    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called wins.
136    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
137        self.tooltip_text = Some(text.into());
138        self.rich_tooltip_source = None;
139        self.composite_tooltip_content = None;
140        self
141    }
142
143    /// Attach a rich tooltip identified by a registry key.
144    ///
145    /// Mutually exclusive with [`tooltip`](Self::tooltip),
146    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
147    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called wins.
148    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
149        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
150        self.tooltip_text = None;
151        self.composite_tooltip_content = None;
152        self
153    }
154
155    /// Attach a rich tooltip from inline [`TooltipContent`](crate::tooltip::TooltipContent).
156    ///
157    /// Mutually exclusive with [`tooltip`](Self::tooltip),
158    /// [`rich_tooltip`](Self::rich_tooltip), and
159    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called wins.
160    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
161        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
162        self.tooltip_text = None;
163        self.composite_tooltip_content = None;
164        self
165    }
166
167    /// Attach a composite tooltip with an arbitrary widget tree body.
168    ///
169    /// Mutually exclusive with [`tooltip`](Self::tooltip),
170    /// [`rich_tooltip`](Self::rich_tooltip), and
171    /// [`rich_tooltip_content`](Self::rich_tooltip_content) — the last setter called wins.
172    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
173        self.composite_tooltip_content = Some(Box::new(content));
174        self.tooltip_text = None;
175        self.rich_tooltip_source = None;
176        self
177    }
178}
179
180impl std::fmt::Debug for Badge {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("Badge").field("label", &self.label).finish()
183    }
184}
185
186impl Widget for Badge {
187    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
188        let theme_signal = ctx.theme_signal();
189
190        // Default text color: `status_info_fg` via a derived signal so
191        // theme changes still propagate. Callers override with
192        // `.text_role(...)`. The pill background default
193        // (`AccentSubtle`) lives in the recipe; `.background(...)` reaches
194        // the style as `background_override`.
195        let text: ColorProp = self
196            .text_role
197            .take()
198            .unwrap_or_else(|| ColorProp::Bound(theme_signal.map(|t| t.colors.status_info_fg)));
199
200        let mut text_widget = TextWidget::new(self.label.clone())
201            .color(text)
202            .single_line()
203            .a11y_hidden();
204        text_widget = match &self.text_style {
205            Some(style) => text_widget.style(style.clone()),
206            None => text_widget.style(TextStyleRole::Tiny),
207        };
208        self.label_geometry = Some(text_widget.geometry_handle());
209        let content = ctx.add(text_widget);
210
211        // The pill chrome (rounded background + padding inset) is owned
212        // by the active `BadgeStyle`.
213        let style: SharedBadgeStyle = self
214            .style_override
215            .clone()
216            .or_else(|| ctx.theme().style_slots.badge.clone())
217            .unwrap_or_else(|| {
218                Rc::new(crate::styles::RecipeBadgeStyle::for_tokens(
219                    &ctx.theme().input,
220                ))
221            });
222        let root = style.make_body(
223            &BadgeStyleConfig {
224                content,
225                background_override: self.background.take(),
226            },
227            ctx,
228        );
229        self.root_child_id = Some(root);
230
231        if let Some(content) = self.composite_tooltip_content.take() {
232            let delay = ctx.theme().motion.tooltip_delay_heavy;
233            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
234        } else if let Some(source) = self.rich_tooltip_source.clone() {
235            let delay = ctx.theme().motion.tooltip_delay;
236            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
237        } else if let Some(text) = self.tooltip_text.clone() {
238            let delay = ctx.theme().motion.tooltip_delay;
239            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
240        }
241
242        vec![root]
243    }
244
245    fn layout_response(
246        &self,
247        proposal: SizeProposal,
248        ctx: &LayoutContext,
249    ) -> teksilo_core::widget::LayoutResponse {
250        // Rigid: size to content, no shrink (see Button's note).
251        if let Some(root) = self.root_child_id
252            && let Some(size) = ctx.child_size(root, proposal)
253        {
254            return (size).into();
255        }
256        proposal.resolve(0.0, 0.0).into()
257    }
258
259    fn place_children(
260        &self,
261        bounds: Rect,
262        _proposal: SizeProposal,
263        children: &mut [WidgetPlacement],
264        _ctx: &LayoutContext,
265    ) {
266        for child in children.iter_mut() {
267            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
268            child.size = Size::new(bounds.width, bounds.height);
269        }
270    }
271
272    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
273        builder.set_role(teksilo_core::accesskit::Role::Label);
274
275        // The badge owns the accessible name, so its label is hidden from
276        // AT — which would leave the text announceable but not reviewable,
277        // since `supports_text_ranges` needs `Role::TextRun` children.
278        // Borrowing the hidden label's layout puts them on this node
279        // instead, one level up from where they were measured.
280        //
281        // Those rects are in window space, which is sound only while the
282        // label sits rigidly inside the badge: `place_children` gives the
283        // whole subtree the badge's own bounds on every pass, so a move
284        // re-places the label too. A composite that positioned its donor
285        // independently would have to register local rects instead.
286        let unplaced;
287        let source = match self
288            .label_geometry
289            .as_ref()
290            .and_then(|handle| TextRunSource::from_handle(handle, 0))
291        {
292            Some(source) => source,
293            // Never placed — a name probe, or a tree asked for its
294            // accessibility before its first layout.
295            None => {
296                unplaced = self.label.resolve_now();
297                TextRunSource::flat(&unplaced, 0)
298            }
299        };
300        let emission = push_text_runs(builder, None, &source);
301        // The name must be byte-identical to the runs' concatenation: the
302        // consumer derives the document text from the runs, and every
303        // divergence is a place where what a reader reviews and what it
304        // announces disagree.
305        builder.set_name(&emission.value);
306    }
307
308    fn children(&self) -> Vec<WidgetId> {
309        self.root_child_id.into_iter().collect()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use teksilo_core::widget_tree::WidgetTree;
317    use teksilo_i18n::lit;
318
319    #[test]
320    fn badge_builds_and_renders() {
321        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
322        let badge = tree.add(Badge::new(lit!("New")));
323        tree.layout(SizeProposal::exact(200.0, 50.0));
324        let b = tree.bounds(badge);
325        assert!(b.width > 0.0);
326        assert!(b.height > 0.0);
327    }
328
329    /// The `Role::TextRun` children one node published, in tree order,
330    /// as `(value, window-space bounds)`.
331    fn runs_of(
332        update: &teksilo_core::accesskit::TreeUpdate,
333        owner: WidgetId,
334    ) -> Vec<(String, teksilo_core::accesskit::Rect)> {
335        let node_id = teksilo_core::accessibility::widget_id_to_node_id(owner);
336        let node = update
337            .nodes
338            .iter()
339            .find(|(id, _)| *id == node_id)
340            .map(|(_, node)| node)
341            .expect("the badge is absent from the emitted tree");
342        node.children()
343            .iter()
344            .filter_map(|child| update.nodes.iter().find(|(id, _)| id == child))
345            .filter(|(_, node)| node.role() == teksilo_core::accesskit::Role::TextRun)
346            .map(|(_, node)| {
347                (
348                    node.value().unwrap_or_default().to_string(),
349                    node.bounds().expect("a run without bounds"),
350                )
351            })
352            .collect()
353    }
354
355    /// A tree whose text is measured, so the runs carry real extents
356    /// rather than the degenerate boxes an unmeasured label falls back to.
357    fn measured_tree() -> WidgetTree {
358        WidgetTree::new()
359            .with_theme(teksilo_core::presets::intui::light())
360            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
361                teksilo_canvas::MockTextBackend::new(),
362            )))
363    }
364
365    #[test]
366    fn badge_accessibility() {
367        let mut tree = measured_tree();
368        let badge = tree.add(Badge::new(lit!("3")));
369        tree.layout(SizeProposal::exact(200.0, 50.0));
370        let info = tree.accessibility_node(badge);
371        assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
372        assert_eq!(info.name(), Some("3"));
373
374        // The label is hidden from AT, so without borrowed runs the badge
375        // would be announceable but not reviewable: `supports_text_ranges`
376        // is false for a `Role::Label` with no `Role::TextRun` children.
377        let update = tree.sync_accessibility();
378        let runs = runs_of(&update, badge);
379        assert_eq!(runs.len(), 1);
380        assert_eq!(runs[0].0, "3");
381        // Borrowed rects are absolute, so the run sits where the label was
382        // painted — inside the pill, not at the window origin.
383        assert!(
384            runs[0].1.x0 > 0.0,
385            "the run was not placed: {:?}",
386            runs[0].1
387        );
388    }
389
390    #[test]
391    fn a_badge_is_reviewable_by_character() {
392        // `supports_text_ranges` is a consumer method and the gate every
393        // platform's text API sits behind, and `bounding_boxes` is where a
394        // single geometry-less run silently empties a whole label. Neither
395        // can be asserted off the emitted node.
396        let mut tree = measured_tree();
397        let badge = tree.add(Badge::new(lit!("NEW")));
398        tree.layout(SizeProposal::exact(200.0, 50.0));
399        let update = tree.sync_accessibility();
400        let node_id = teksilo_core::accessibility::widget_id_to_node_id(badge);
401
402        let consumer = accesskit_consumer::Tree::new(update, false);
403        let state = consumer.state();
404        let mut stack = vec![state.root()];
405        let node = loop {
406            let node = stack
407                .pop()
408                .expect("the badge is absent from the emitted tree");
409            if node.locate().0 == node_id {
410                break node;
411            }
412            stack.extend(node.children());
413        };
414        assert!(node.supports_text_ranges());
415        assert_eq!(node.document_range().text(), "NEW");
416        assert!(
417            !node.document_range().bounding_boxes().is_empty(),
418            "the badge reports no geometry, so a magnifier cannot follow it"
419        );
420    }
421
422    #[test]
423    fn a_translated_badge_moves_its_runs_by_the_same_delta() {
424        // The borrowed rects are in window space, so they are only right
425        // while the label is re-placed with the badge. A leading spacer
426        // that grows moves both.
427        use crate::primitives::{FixedSize, HStack};
428        use teksilo_core::signal::Signal;
429
430        let offset = Signal::new(0.0f32);
431        let mut tree = measured_tree();
432        let spacer = tree.add(FixedSize::new().width(offset.clone()).height(1.0));
433        let badge = tree.add(Badge::new(lit!("NEW")));
434        let _row = tree.add(HStack::new().child(spacer).child(badge));
435        offset.bind_to(
436            spacer,
437            tree.binding_registry(),
438            teksilo_core::binding::BindingLevel::Relayout,
439        );
440
441        tree.layout(SizeProposal::exact(400.0, 50.0));
442        let before = runs_of(&tree.sync_accessibility(), badge);
443        let x_before = tree.bounds(badge).x;
444
445        offset.set(60.0);
446        tree.layout(SizeProposal::exact(400.0, 50.0));
447        let after = runs_of(&tree.sync_accessibility(), badge);
448        let delta = f64::from(tree.bounds(badge).x - x_before);
449
450        assert_eq!(before.len(), after.len());
451        assert!(delta > 0.0, "the badge did not move");
452        for ((text, before), (_, after)) in before.iter().zip(after.iter()) {
453            assert!(
454                (after.x0 - before.x0 - delta).abs() < 0.01,
455                "run {text:?} moved by {} rather than {delta}",
456                after.x0 - before.x0
457            );
458        }
459    }
460
461    #[test]
462    fn tooltip_appears_on_hover() {
463        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
464        let id = tree.add(Badge::new(lit!("New")).tooltip(lit!("Tip")));
465        tree.layout(SizeProposal::exact(300.0, 200.0));
466        tree.pointer_move(tree.bounds(id).center());
467        tree.advance_time(std::time::Duration::from_secs(1));
468        assert_eq!(
469            tree.active_overlays().len(),
470            1,
471            "tooltip should appear on hover"
472        );
473        assert!(tree.find_by_label("Tip").is_some());
474    }
475}