1use 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
59pub struct Badge {
61 label: LocalizedString,
62 background: Option<ColorProp>,
63 text_role: Option<ColorProp>,
64 text_style: Option<teksilo_core::color_prop::TextStyleProp>,
67 style_override: Option<SharedBadgeStyle>,
69 root_child_id: Option<WidgetId>,
70 label_geometry: Option<TextGeometryHandle>,
74 tooltip_text: Option<LocalizedString>,
78 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
80 composite_tooltip_content: Option<Box<dyn Widget>>,
82}
83
84impl Badge {
85 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 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 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
112 self.background = Some(color.into());
113 self
114 }
115
116 pub fn text_role(mut self, color: impl Into<ColorProp>) -> Self {
119 self.text_role = Some(color.into());
120 self
121 }
122
123 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 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 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 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 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 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 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 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 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 None => {
296 unplaced = self.label.resolve_now();
297 TextRunSource::flat(&unplaced, 0)
298 }
299 };
300 let emission = push_text_runs(builder, None, &source);
301 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 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 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 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 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 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 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}