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//!
24//! ```rust
25//! # use teksilo_widgets::Badge;
26//! # use teksilo_i18n::lit;
27//! # use teksilo_tokens::Color;
28//! let _badge = Badge::new(lit!("NEW"))
29//!     .background(Color::new(0.2, 0.6, 1.0, 1.0));
30//! ```
31
32use std::rc::Rc;
33
34use teksilo_canvas::{Rect, Size, SizeProposal};
35use teksilo_core::accessibility::AccessNodeBuilder;
36use teksilo_core::build_context::BuildContext;
37use teksilo_core::color_prop::ColorProp;
38use teksilo_core::styles::{BadgeStyleConfig, SharedBadgeStyle};
39use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
40use teksilo_core::widget_id::WidgetId;
41use teksilo_tokens::TextStyleRole;
42
43use crate::primitives::TextWidget;
44use teksilo_i18n::LocalizedString;
45
46/// A pill-shaped label for displaying tags, counts, or status.
47pub struct Badge {
48    label: LocalizedString,
49    background: Option<ColorProp>,
50    text_role: Option<ColorProp>,
51    /// Per-call override for the label's text style (font, size, weight).
52    /// `None` ⇒ the default `TextStyleRole::Tiny`.
53    text_style: Option<teksilo_core::color_prop::TextStyleProp>,
54    /// Per-call override for the pill chrome.
55    style_override: Option<SharedBadgeStyle>,
56    root_child_id: Option<WidgetId>,
57    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
58    /// with the rich / composite slots — every setter clears the other two so
59    /// the last call wins.
60    tooltip_text: Option<LocalizedString>,
61    /// Optional rich tooltip source (registry key or inline content).
62    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
63    /// Optional composite tooltip body (arbitrary widget tree).
64    composite_tooltip_content: Option<Box<dyn Widget>>,
65}
66
67impl Badge {
68    /// Construct a badge with the given label text.
69    pub fn new(label: impl Into<LocalizedString>) -> Self {
70        Self {
71            label: label.into(),
72            background: None,
73            text_role: None,
74            text_style: None,
75            style_override: None,
76            root_child_id: None,
77            tooltip_text: None,
78            rich_tooltip_source: None,
79            composite_tooltip_content: None,
80        }
81    }
82
83    /// Per-call style override for the badge pill chrome. Replaces the
84    /// theme-wide default `BadgeStyle` for just this instance.
85    pub fn style(mut self, style: impl teksilo_core::styles::BadgeStyle) -> Self {
86        self.style_override = Some(Rc::new(style));
87        self
88    }
89
90    /// Override the badge background. Accepts `Color`, a
91    /// [`SurfaceRole`](teksilo_tokens::SurfaceRole) / [`TextRole`](teksilo_tokens::TextRole),
92    /// or a `Signal<Color>`. Default (unset) is `SurfaceRole::AccentSubtle`.
93    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
94        self.background = Some(color.into());
95        self
96    }
97
98    /// Override the badge text color. Accepts `Color`, a role, or a signal.
99    /// Default (unset) is the theme's `status_info_fg`.
100    pub fn text_role(mut self, color: impl Into<ColorProp>) -> Self {
101        self.text_role = Some(color.into());
102        self
103    }
104
105    /// Override the label's text style (font, size, weight). Accepts a
106    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default
107    /// (unset) is `TextStyleRole::Tiny`.
108    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
109        self.text_style = Some(style.into());
110        self
111    }
112
113    /// Attach a plain single-line tooltip shown after a hover delay.
114    ///
115    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
116    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
117    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called wins.
118    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
119        self.tooltip_text = Some(text.into());
120        self.rich_tooltip_source = None;
121        self.composite_tooltip_content = None;
122        self
123    }
124
125    /// Attach a rich tooltip identified by a registry key.
126    ///
127    /// Mutually exclusive with [`tooltip`](Self::tooltip),
128    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
129    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called wins.
130    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
131        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
132        self.tooltip_text = None;
133        self.composite_tooltip_content = None;
134        self
135    }
136
137    /// Attach a rich tooltip from inline [`TooltipContent`](crate::tooltip::TooltipContent).
138    ///
139    /// Mutually exclusive with [`tooltip`](Self::tooltip),
140    /// [`rich_tooltip`](Self::rich_tooltip), and
141    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called wins.
142    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
143        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
144        self.tooltip_text = None;
145        self.composite_tooltip_content = None;
146        self
147    }
148
149    /// Attach a composite tooltip with an arbitrary widget tree body.
150    ///
151    /// Mutually exclusive with [`tooltip`](Self::tooltip),
152    /// [`rich_tooltip`](Self::rich_tooltip), and
153    /// [`rich_tooltip_content`](Self::rich_tooltip_content) — the last setter called wins.
154    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
155        self.composite_tooltip_content = Some(Box::new(content));
156        self.tooltip_text = None;
157        self.rich_tooltip_source = None;
158        self
159    }
160}
161
162impl std::fmt::Debug for Badge {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("Badge").field("label", &self.label).finish()
165    }
166}
167
168impl Widget for Badge {
169    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
170        let theme_signal = ctx.theme_signal();
171
172        // Default text color: `status_info_fg` via a derived signal so
173        // theme changes still propagate. Callers override with
174        // `.text_role(...)`. The pill background default
175        // (`AccentSubtle`) lives in the recipe; `.background(...)` reaches
176        // the style as `background_override`.
177        let text: ColorProp = self
178            .text_role
179            .take()
180            .unwrap_or_else(|| ColorProp::Bound(theme_signal.map(|t| t.colors.status_info_fg)));
181
182        let mut text_widget = TextWidget::new(self.label.clone())
183            .color(text)
184            .single_line()
185            .a11y_hidden();
186        text_widget = match &self.text_style {
187            Some(style) => text_widget.style(style.clone()),
188            None => text_widget.style(TextStyleRole::Tiny),
189        };
190        let content = ctx.add(text_widget);
191
192        // The pill chrome (rounded background + padding inset) is owned
193        // by the active `BadgeStyle`.
194        let style: SharedBadgeStyle = self
195            .style_override
196            .clone()
197            .or_else(|| ctx.theme().style_slots.badge.clone())
198            .unwrap_or_else(|| Rc::new(crate::styles::RecipeBadgeStyle::default()));
199        let root = style.make_body(
200            &BadgeStyleConfig {
201                content,
202                background_override: self.background.take(),
203            },
204            ctx,
205        );
206        self.root_child_id = Some(root);
207
208        if let Some(content) = self.composite_tooltip_content.take() {
209            let delay = ctx.theme().motion.tooltip_delay_heavy;
210            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
211        } else if let Some(source) = self.rich_tooltip_source.clone() {
212            let delay = ctx.theme().motion.tooltip_delay;
213            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
214        } else if let Some(text) = self.tooltip_text.clone() {
215            let delay = ctx.theme().motion.tooltip_delay;
216            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
217        }
218
219        vec![root]
220    }
221
222    fn layout_response(
223        &self,
224        proposal: SizeProposal,
225        ctx: &LayoutContext,
226    ) -> teksilo_core::widget::LayoutResponse {
227        // Rigid: size to content, no shrink (see Button's note).
228        if let Some(root) = self.root_child_id
229            && let Some(size) = ctx.child_size(root, proposal)
230        {
231            return (size).into();
232        }
233        proposal.resolve(0.0, 0.0).into()
234    }
235
236    fn place_children(
237        &self,
238        bounds: Rect,
239        _proposal: SizeProposal,
240        children: &mut [WidgetPlacement],
241        _ctx: &LayoutContext,
242    ) {
243        for child in children.iter_mut() {
244            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
245            child.size = Size::new(bounds.width, bounds.height);
246        }
247    }
248
249    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
250        builder.set_role(teksilo_core::accesskit::Role::Label);
251        builder.set_name(self.label.resolve_now());
252    }
253
254    fn children(&self) -> Vec<WidgetId> {
255        self.root_child_id.into_iter().collect()
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use teksilo_core::widget_tree::WidgetTree;
263    use teksilo_i18n::lit;
264
265    #[test]
266    fn badge_builds_and_renders() {
267        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
268        let badge = tree.add(Badge::new(lit!("New")));
269        tree.layout(SizeProposal::exact(200.0, 50.0));
270        let b = tree.bounds(badge);
271        assert!(b.width > 0.0);
272        assert!(b.height > 0.0);
273    }
274
275    #[test]
276    fn badge_accessibility() {
277        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
278        let badge = tree.add(Badge::new(lit!("3")));
279        tree.layout(SizeProposal::exact(200.0, 50.0));
280        let info = tree.accessibility_node(badge);
281        assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
282        assert_eq!(info.name(), Some("3"));
283    }
284
285    #[test]
286    fn tooltip_appears_on_hover() {
287        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
288        let id = tree.add(Badge::new(lit!("New")).tooltip(lit!("Tip")));
289        tree.layout(SizeProposal::exact(300.0, 200.0));
290        tree.pointer_move(tree.bounds(id).center());
291        tree.advance_time(std::time::Duration::from_secs(1));
292        assert_eq!(
293            tree.active_overlays().len(),
294            1,
295            "tooltip should appear on hover"
296        );
297        assert!(tree.find_by_label("Tip").is_some());
298    }
299}