Skip to main content

fret_ui_kit/primitives/
label.rs

1use std::sync::Arc;
2
3use fret_core::{AttributedText, SemanticsRole, TextAlign, TextOverflow, TextSpan, TextWrap};
4use fret_ui::element::{
5    AnyElement, Length, PointerRegionProps, SelectableTextProps, SemanticsProps, SizeStyle,
6    TextInkOverflow, TextProps,
7};
8use fret_ui::{ElementContext, Theme, UiHost};
9
10use super::control_registry::{ControlAction, ControlId, LabelEntry, control_registry_model};
11use crate::declarative::text::label_text_refinement;
12use crate::typography;
13
14#[derive(Debug)]
15pub struct Label {
16    text: Arc<str>,
17    for_control: Option<ControlId>,
18    test_id: Option<Arc<str>>,
19    wrapped_root: Option<AnyElement>,
20}
21
22impl Label {
23    pub fn new(text: impl Into<Arc<str>>) -> Self {
24        Self {
25            text: text.into(),
26            for_control: None,
27            test_id: None,
28            wrapped_root: None,
29        }
30    }
31
32    /// Binds this label to a logical form control id (similar to HTML `label[for]` / `htmlFor`).
33    ///
34    /// When set, pointer activation on the label forwards to the registered control action and
35    /// requests focus for the control. This also enables `aria-labelledby`-like semantics when
36    /// the control uses the same `ControlId`.
37    pub fn for_control(mut self, id: impl Into<ControlId>) -> Self {
38        self.for_control = Some(id.into());
39        self
40    }
41
42    /// Sets a stable `test_id` on the label root.
43    pub fn test_id(mut self, test_id: impl Into<Arc<str>>) -> Self {
44        self.test_id = Some(test_id.into());
45        self
46    }
47
48    /// Wraps an arbitrary already-built subtree in this label's association semantics.
49    ///
50    /// This keeps the label's accessible name and control forwarding behavior while letting higher
51    /// layers own the visible layout/chrome subtree.
52    pub fn wrap_root(mut self, root: AnyElement) -> Self {
53        self.wrapped_root = Some(root);
54        self
55    }
56
57    #[track_caller]
58    pub fn into_element<H: UiHost>(self, cx: &mut ElementContext<'_, H>) -> AnyElement {
59        let wrapped_root = self.wrapped_root;
60        let Some(for_control) = self.for_control else {
61            let mut el = wrapped_root.unwrap_or_else(|| label(cx, self.text));
62            if let Some(test_id) = self.test_id {
63                el = el.test_id(test_id);
64            }
65            return el;
66        };
67
68        label_for_control(cx, self.text, for_control, self.test_id, wrapped_root)
69    }
70}
71
72#[track_caller]
73pub fn label<H: UiHost>(cx: &mut ElementContext<'_, H>, text: impl Into<Arc<str>>) -> AnyElement {
74    let text = text.into();
75    let (fg, refinement, line_height) = {
76        let theme = Theme::global(&*cx.app);
77
78        let fg = theme
79            .color_by_key("foreground")
80            .unwrap_or_else(|| theme.color_token("foreground"));
81        let (refinement, line_height) = label_text_refinement(theme);
82
83        (fg, refinement, line_height)
84    };
85
86    typography::scope_text_style_with_color(
87        cx.text_props(TextProps {
88            layout: fret_ui::element::LayoutStyle {
89                size: SizeStyle {
90                    height: Length::Px(line_height),
91                    ..Default::default()
92                },
93                ..Default::default()
94            },
95            text,
96            style: None,
97            color: None,
98            wrap: TextWrap::None,
99            overflow: TextOverflow::Clip,
100            align: TextAlign::Start,
101            ink_overflow: TextInkOverflow::None,
102        }),
103        refinement,
104        fg,
105    )
106}
107
108#[track_caller]
109fn label_for_control<H: UiHost>(
110    cx: &mut ElementContext<'_, H>,
111    text: Arc<str>,
112    for_control: ControlId,
113    test_id: Option<Arc<str>>,
114    wrapped_root: Option<AnyElement>,
115) -> AnyElement {
116    let control_registry = control_registry_model(cx);
117    let control_snapshot = cx
118        .app
119        .models()
120        .read(&control_registry, |reg| {
121            reg.control_for(cx.window, &for_control).cloned()
122        })
123        .ok()
124        .flatten();
125    let enabled = control_snapshot.as_ref().map(|c| c.enabled).unwrap_or(true);
126    let controls_element = control_snapshot.as_ref().map(|c| c.element.0);
127
128    let props = SemanticsProps {
129        role: SemanticsRole::Text,
130        label: Some(text.clone()),
131        test_id,
132        controls_element,
133        disabled: !enabled,
134        ..Default::default()
135    };
136
137    let for_control_outer = for_control.clone();
138    let control_registry_outer = control_registry.clone();
139    cx.semantics(props, move |cx| {
140        let label_element = cx.root_id();
141
142        let _ = cx.app.models_mut().update(&control_registry_outer, |reg| {
143            reg.register_label(
144                cx.window,
145                cx.frame_id,
146                for_control_outer.clone(),
147                LabelEntry {
148                    element: label_element,
149                },
150            );
151        });
152
153        let for_control_inner = for_control_outer.clone();
154        let control_registry_inner = control_registry_outer.clone();
155        let control_snapshot_inner = control_snapshot.clone();
156
157        vec![cx.pointer_region(PointerRegionProps::default(), move |cx| {
158            let control_registry_on_down = control_registry_inner.clone();
159            let for_control_on_down = for_control_inner.clone();
160            let control_snapshot_on_down = control_snapshot_inner.clone();
161            cx.pointer_region_add_on_pointer_down(Arc::new(move |host, acx, _down| {
162                // Plain `Label` only owns a text child, so pressables elsewhere in the
163                // hit-test chain are ambient shells rather than embedded interactive content.
164                let target = host
165                    .models_mut()
166                    .read(&control_registry_on_down, |reg| {
167                        reg.control_for(acx.window, &for_control_on_down).map(|c| {
168                            (
169                                c.enabled,
170                                c.element,
171                                matches!(c.action, ControlAction::FocusOnly),
172                            )
173                        })
174                    })
175                    .ok()
176                    .flatten()
177                    .or_else(|| {
178                        control_snapshot_on_down.as_ref().map(|c| {
179                            (
180                                c.enabled,
181                                c.element,
182                                matches!(c.action, ControlAction::FocusOnly),
183                            )
184                        })
185                    });
186                if let Some((true, element, focus_on_pointer_down)) = target {
187                    if focus_on_pointer_down {
188                        host.request_focus(element);
189                        return false;
190                    }
191                    host.capture_pointer();
192                }
193                true
194            }));
195
196            let control_registry_on_up = control_registry_inner.clone();
197            let for_control_on_up = for_control_inner.clone();
198            let control_snapshot_on_up = control_snapshot_inner.clone();
199            cx.pointer_region_add_on_pointer_up(Arc::new(move |host, acx, up| {
200                host.release_pointer_capture();
201                if !up.is_click {
202                    return true;
203                }
204
205                let control = host
206                    .models_mut()
207                    .read(&control_registry_on_up, |reg| {
208                        reg.control_for(acx.window, &for_control_on_up).cloned()
209                    })
210                    .ok()
211                    .flatten();
212                let Some(control) = control.or_else(|| control_snapshot_on_up.clone()) else {
213                    return true;
214                };
215                if !control.enabled {
216                    return true;
217                }
218                if matches!(control.action, ControlAction::FocusOnly) {
219                    return true;
220                }
221
222                host.request_focus(control.element);
223                control.action.invoke(host, acx);
224                host.request_redraw(acx.window);
225                true
226            }));
227
228            let enabled = control_snapshot_inner
229                .as_ref()
230                .map(|c| c.enabled)
231                .unwrap_or(true);
232            let child = wrapped_root.unwrap_or_else(|| label(cx, text.clone()));
233            if enabled {
234                vec![child]
235            } else {
236                vec![cx.opacity(0.5, move |_cx| vec![child])]
237            }
238        })]
239    })
240}
241
242#[derive(Debug, Clone)]
243pub struct SelectableLabel {
244    text: Arc<str>,
245}
246
247impl SelectableLabel {
248    pub fn new(text: impl Into<Arc<str>>) -> Self {
249        Self { text: text.into() }
250    }
251
252    #[track_caller]
253    pub fn into_element<H: UiHost>(self, cx: &mut ElementContext<'_, H>) -> AnyElement {
254        selectable_label(cx, self.text)
255    }
256}
257
258/// A non-editable label that supports text selection (drag-to-select + `edit.copy`).
259///
260/// Recommended usage:
261/// - Use this for "information" labels (IDs, paths, log snippets, read-only values).
262/// - Avoid using it inside pressable/clickable rows because it intentionally captures left-drag
263///   selection gestures and stops propagation (use a dedicated copy button instead).
264#[track_caller]
265pub fn selectable_label<H: UiHost>(
266    cx: &mut ElementContext<'_, H>,
267    text: impl Into<Arc<str>>,
268) -> AnyElement {
269    let text: Arc<str> = text.into();
270    let (fg, refinement, line_height) = {
271        let theme = Theme::global(&*cx.app);
272
273        let fg = theme
274            .color_by_key("foreground")
275            .unwrap_or_else(|| theme.color_token("foreground"));
276        let (refinement, line_height) = label_text_refinement(theme);
277
278        (fg, refinement, line_height)
279    };
280
281    let spans: Arc<[TextSpan]> = Arc::from([TextSpan::new(text.len())]);
282    let rich = AttributedText::new(Arc::clone(&text), spans);
283
284    typography::scope_text_style_with_color(
285        cx.selectable_text_props(SelectableTextProps {
286            layout: fret_ui::element::LayoutStyle {
287                size: SizeStyle {
288                    height: Length::Px(line_height),
289                    ..Default::default()
290                },
291                ..Default::default()
292            },
293            rich,
294            style: None,
295            color: None,
296            wrap: TextWrap::None,
297            overflow: TextOverflow::Clip,
298            align: TextAlign::Start,
299            ink_overflow: TextInkOverflow::None,
300            interactive_spans: Arc::from([]),
301        }),
302        refinement,
303        fg,
304    )
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    use fret_app::App;
312    use fret_core::{
313        AppWindowId, Axis, Edges, MouseButton, PathCommand, PathConstraints, PathId, PathMetrics,
314        PathStyle, Point, Px, Rect, SemanticsRole, Size, SvgId, TextBlobId, TextConstraints,
315        TextInput, TextMetrics,
316    };
317    use fret_ui::GlobalElementId;
318    use fret_ui::UiTree;
319    use fret_ui::element::{
320        ContainerProps, CrossAlign, ElementKind, FlexProps, LayoutStyle, Length, MainAlign,
321        PressableProps, SizeStyle,
322    };
323    use fret_ui::elements;
324    use fret_ui::{Theme, ThemeConfig};
325    use std::sync::Arc;
326
327    struct FakeServices;
328
329    impl fret_core::TextService for FakeServices {
330        fn prepare(
331            &mut self,
332            _input: &TextInput,
333            _constraints: TextConstraints,
334        ) -> (TextBlobId, TextMetrics) {
335            (
336                TextBlobId::default(),
337                TextMetrics {
338                    size: Size::new(Px(10.0), Px(10.0)),
339                    baseline: Px(8.0),
340                },
341            )
342        }
343
344        fn release(&mut self, _blob: TextBlobId) {}
345    }
346
347    impl fret_core::PathService for FakeServices {
348        fn prepare(
349            &mut self,
350            _commands: &[PathCommand],
351            _style: PathStyle,
352            _constraints: PathConstraints,
353        ) -> (PathId, PathMetrics) {
354            (PathId::default(), PathMetrics::default())
355        }
356
357        fn release(&mut self, _path: PathId) {}
358    }
359
360    impl fret_core::SvgService for FakeServices {
361        fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
362            SvgId::default()
363        }
364
365        fn unregister_svg(&mut self, _svg: SvgId) -> bool {
366            true
367        }
368    }
369
370    impl fret_core::MaterialService for FakeServices {
371        fn register_material(
372            &mut self,
373            _desc: fret_core::MaterialDescriptor,
374        ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
375            Ok(fret_core::MaterialId::default())
376        }
377
378        fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
379            true
380        }
381    }
382
383    fn contains_opacity(node: &AnyElement, opacity: f32) -> bool {
384        let matches = match &node.kind {
385            ElementKind::Opacity(props) => (props.opacity - opacity).abs() <= 1e-6,
386            _ => false,
387        };
388        if matches {
389            return true;
390        }
391        node.children
392            .iter()
393            .any(|child| contains_opacity(child, opacity))
394    }
395
396    fn test_app() -> App {
397        let mut app = App::new();
398        Theme::with_global_mut(&mut app, |theme| {
399            theme.apply_config(&ThemeConfig {
400                name: "Label Test".to_string(),
401                metrics: std::collections::HashMap::from([
402                    ("font.size".to_string(), 13.0),
403                    ("font.line_height".to_string(), 20.0),
404                    ("component.label.text_px".to_string(), 13.0),
405                    ("component.label.line_height".to_string(), 18.0),
406                ]),
407                colors: std::collections::HashMap::from([(
408                    "foreground".to_string(),
409                    "#112233".to_string(),
410                )]),
411                ..ThemeConfig::default()
412            });
413        });
414        app
415    }
416
417    fn test_bounds() -> Rect {
418        Rect::new(
419            Point::new(Px(0.0), Px(0.0)),
420            Size::new(Px(240.0), Px(120.0)),
421        )
422    }
423
424    #[test]
425    fn label_defaults_match_shadcn_expectations() {
426        let window = AppWindowId::default();
427        let mut app = test_app();
428        let bounds = test_bounds();
429
430        let el = fret_ui::elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
431            label(cx, "Email")
432        });
433        let theme = Theme::global(&app);
434        let (expected_refinement, line_height) = label_text_refinement(&theme);
435
436        let ElementKind::Text(props) = &el.kind else {
437            panic!("expected label(...) to build a Text element");
438        };
439
440        assert_eq!(props.wrap, TextWrap::None);
441        assert_eq!(props.overflow, TextOverflow::Clip);
442        assert_eq!(props.layout.size.height, Length::Px(line_height));
443        assert!(props.style.is_none());
444        assert!(props.color.is_none());
445        assert_eq!(
446            el.inherited_foreground,
447            Some(theme.color_token("foreground"))
448        );
449        assert_eq!(el.inherited_text_style, Some(expected_refinement));
450        assert_eq!(
451            el.inherited_text_style
452                .as_ref()
453                .and_then(|style| style.font.clone()),
454            Some(fret_core::FontId::ui())
455        );
456        assert_eq!(
457            el.inherited_text_style
458                .as_ref()
459                .and_then(|style| style.weight),
460            Some(fret_core::FontWeight::MEDIUM)
461        );
462    }
463
464    #[test]
465    fn selectable_label_scopes_inherited_refinement_without_leaf_style() {
466        let window = AppWindowId::default();
467        let mut app = test_app();
468        let bounds = test_bounds();
469
470        let el = fret_ui::elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
471            selectable_label(cx, "Order #42")
472        });
473        let theme = Theme::global(&app);
474
475        let ElementKind::SelectableText(props) = &el.kind else {
476            panic!("expected selectable_label(...) to build a SelectableText element");
477        };
478
479        assert_eq!(props.layout.size.height, Length::Px(Px(18.0)));
480        assert!(props.style.is_none());
481        assert!(props.color.is_none());
482        assert_eq!(
483            el.inherited_foreground,
484            Some(theme.color_token("foreground"))
485        );
486        assert_eq!(
487            el.inherited_text_style,
488            Some(label_text_refinement(&theme).0)
489        );
490    }
491
492    #[test]
493    fn label_for_control_registers_in_control_registry() {
494        let window = AppWindowId::default();
495        let mut app = test_app();
496        let bounds = test_bounds();
497
498        let control_id = ControlId::from("email");
499        let mut reg_model: Option<
500            fret_runtime::Model<crate::primitives::control_registry::ControlRegistry>,
501        > = None;
502
503        let el = elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
504            reg_model = Some(control_registry_model(cx));
505            Label::new("Email")
506                .for_control(control_id.clone())
507                .into_element(cx)
508        });
509
510        let ElementKind::Semantics(_props) = &el.kind else {
511            panic!("expected Label::for_control(...) to build a Semantics root");
512        };
513
514        let reg_model = reg_model.expect("control registry model");
515        let entry = app
516            .models()
517            .read(&reg_model, |reg| {
518                reg.label_for(window, &control_id).cloned()
519            })
520            .ok()
521            .flatten()
522            .expect("expected label to register itself in the control registry");
523
524        assert_eq!(entry.element, el.id);
525    }
526
527    #[test]
528    fn label_for_disabled_control_uses_half_opacity() {
529        let window = AppWindowId::default();
530        let mut app = test_app();
531        let bounds = test_bounds();
532        let control_id = ControlId::from("disabled-email");
533
534        let el = elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
535            let reg_model = control_registry_model(cx);
536            let _ = cx.app.models_mut().update(&reg_model, |reg| {
537                reg.register_control(
538                    cx.window,
539                    cx.frame_id,
540                    control_id.clone(),
541                    crate::primitives::control_registry::ControlEntry {
542                        element: GlobalElementId(42),
543                        enabled: false,
544                        action: ControlAction::FocusOnly,
545                    },
546                );
547            });
548
549            Label::new("Email")
550                .for_control(control_id.clone())
551                .into_element(cx)
552        });
553
554        let ElementKind::Semantics(props) = &el.kind else {
555            panic!("expected disabled associated label to build a Semantics root");
556        };
557        assert!(
558            props.disabled,
559            "expected disabled associated label semantics to be disabled"
560        );
561        assert!(
562            contains_opacity(&el, 0.5),
563            "expected disabled associated label to apply opacity 0.5"
564        );
565    }
566
567    #[test]
568    fn label_for_control_click_invokes_registered_control_action() {
569        let window = AppWindowId::default();
570        let mut app = test_app();
571        let mut ui: UiTree<App> = UiTree::new();
572        ui.set_window(window);
573
574        let bounds = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(240.0), Px(80.0)));
575        let mut services = FakeServices;
576        let checked = app.models_mut().insert(false);
577        let checked_for_render = checked.clone();
578        let control_id = ControlId::from("label.toggle.control");
579
580        let root = fret_ui::declarative::render_root(
581            &mut ui,
582            &mut app,
583            &mut services,
584            window,
585            bounds,
586            "label-for-control-click-invokes-control-action",
587            |cx| {
588                let mut row_layout = LayoutStyle::default();
589                row_layout.size.width = Length::Fill;
590                let registry_model = control_registry_model(cx);
591
592                vec![cx.flex(
593                    FlexProps {
594                        layout: row_layout,
595                        direction: Axis::Horizontal,
596                        gap: Px(8.0).into(),
597                        padding: Edges::all(Px(0.0)).into(),
598                        justify: MainAlign::Start,
599                        align: CrossAlign::Center,
600                        wrap: false,
601                    },
602                    move |cx| {
603                        let registry_model = registry_model.clone();
604                        let control_id_for_control = control_id.clone();
605                        let checked_for_control = checked_for_render.clone();
606                        let control = cx.semantics(
607                            SemanticsProps {
608                                role: SemanticsRole::Checkbox,
609                                label: Some(Arc::from("Test checkbox")),
610                                checked: Some(false),
611                                test_id: Some(Arc::from("test.control")),
612                                ..Default::default()
613                            },
614                            move |cx| {
615                                let id = cx.root_id();
616                                let entry = crate::primitives::control_registry::ControlEntry {
617                                    element: id,
618                                    enabled: true,
619                                    action: ControlAction::ToggleBool(checked_for_control.clone()),
620                                };
621                                let _ = cx.app.models_mut().update(&registry_model, |reg| {
622                                    reg.register_control(
623                                        cx.window,
624                                        cx.frame_id,
625                                        control_id_for_control.clone(),
626                                        entry,
627                                    );
628                                });
629
630                                vec![cx.container(
631                                    ContainerProps {
632                                        layout: LayoutStyle {
633                                            size: SizeStyle {
634                                                width: Length::Px(Px(16.0)),
635                                                height: Length::Px(Px(16.0)),
636                                                min_width: None,
637                                                min_height: None,
638                                                max_width: None,
639                                                max_height: None,
640                                            },
641                                            ..Default::default()
642                                        },
643                                        ..Default::default()
644                                    },
645                                    |_cx| Vec::new(),
646                                )]
647                            },
648                        );
649
650                        vec![
651                            control,
652                            Label::new("Toggle via label")
653                                .for_control(control_id.clone())
654                                .test_id("test.label")
655                                .into_element(cx),
656                        ]
657                    },
658                )]
659            },
660        );
661        ui.set_root(root);
662        ui.request_semantics_snapshot();
663        ui.layout_all(&mut app, &mut services, bounds, 1.0);
664
665        let snap = ui.semantics_snapshot().expect("semantics snapshot");
666        let label = snap
667            .nodes
668            .iter()
669            .find(|n| n.test_id.as_deref() == Some("test.label"))
670            .expect("label semantics node");
671
672        let position = Point::new(
673            Px(label.bounds.origin.x.0 + label.bounds.size.width.0 * 0.5),
674            Px(label.bounds.origin.y.0 + label.bounds.size.height.0 * 0.5),
675        );
676
677        ui.dispatch_event(
678            &mut app,
679            &mut services,
680            &fret_core::Event::Pointer(fret_core::PointerEvent::Down {
681                pointer_id: fret_core::PointerId(0),
682                position,
683                button: MouseButton::Left,
684                modifiers: fret_core::Modifiers::default(),
685                pointer_type: fret_core::PointerType::Mouse,
686                click_count: 1,
687            }),
688        );
689        ui.dispatch_event(
690            &mut app,
691            &mut services,
692            &fret_core::Event::Pointer(fret_core::PointerEvent::Up {
693                pointer_id: fret_core::PointerId(0),
694                position,
695                button: MouseButton::Left,
696                modifiers: fret_core::Modifiers::default(),
697                is_click: true,
698                click_count: 1,
699                pointer_type: fret_core::PointerType::Mouse,
700            }),
701        );
702
703        assert_eq!(app.models().get_copied(&checked), Some(true));
704    }
705
706    #[test]
707    fn label_for_control_click_invokes_registered_control_action_inside_ancestor_pressable() {
708        let window = AppWindowId::default();
709        let mut app = test_app();
710        let mut ui: UiTree<App> = UiTree::new();
711        ui.set_window(window);
712
713        let bounds = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(240.0), Px(80.0)));
714        let mut services = FakeServices;
715        let checked = app.models_mut().insert(false);
716        let checked_for_render = checked.clone();
717        let control_id = ControlId::from("label.toggle.control.inside.ancestor.pressable");
718
719        let root = fret_ui::declarative::render_root(
720            &mut ui,
721            &mut app,
722            &mut services,
723            window,
724            bounds,
725            "label-for-control-click-inside-ancestor-pressable",
726            |cx| {
727                let mut row_layout = LayoutStyle::default();
728                row_layout.size.width = Length::Fill;
729                let registry_model = control_registry_model(cx);
730
731                vec![cx.pressable(PressableProps::default(), move |cx, _state| {
732                    vec![cx.flex(
733                        FlexProps {
734                            layout: row_layout,
735                            direction: Axis::Horizontal,
736                            gap: Px(8.0).into(),
737                            padding: Edges::all(Px(0.0)).into(),
738                            justify: MainAlign::Start,
739                            align: CrossAlign::Center,
740                            wrap: false,
741                        },
742                        move |cx| {
743                            let registry_model = registry_model.clone();
744                            let control_id_for_control = control_id.clone();
745                            let checked_for_control = checked_for_render.clone();
746                            let control = cx.semantics(
747                                SemanticsProps {
748                                    role: SemanticsRole::Checkbox,
749                                    label: Some(Arc::from("Test checkbox")),
750                                    checked: Some(false),
751                                    test_id: Some(Arc::from("test.control")),
752                                    ..Default::default()
753                                },
754                                move |cx| {
755                                    let id = cx.root_id();
756                                    let entry = crate::primitives::control_registry::ControlEntry {
757                                        element: id,
758                                        enabled: true,
759                                        action: ControlAction::ToggleBool(
760                                            checked_for_control.clone(),
761                                        ),
762                                    };
763                                    let _ = cx.app.models_mut().update(&registry_model, |reg| {
764                                        reg.register_control(
765                                            cx.window,
766                                            cx.frame_id,
767                                            control_id_for_control.clone(),
768                                            entry,
769                                        );
770                                    });
771
772                                    vec![cx.container(
773                                        ContainerProps {
774                                            layout: LayoutStyle {
775                                                size: SizeStyle {
776                                                    width: Length::Px(Px(16.0)),
777                                                    height: Length::Px(Px(16.0)),
778                                                    min_width: None,
779                                                    min_height: None,
780                                                    max_width: None,
781                                                    max_height: None,
782                                                },
783                                                ..Default::default()
784                                            },
785                                            ..Default::default()
786                                        },
787                                        |_cx| Vec::new(),
788                                    )]
789                                },
790                            );
791
792                            vec![
793                                control,
794                                Label::new("Toggle via label")
795                                    .for_control(control_id.clone())
796                                    .test_id("test.label")
797                                    .into_element(cx),
798                            ]
799                        },
800                    )]
801                })]
802            },
803        );
804        ui.set_root(root);
805        ui.request_semantics_snapshot();
806        ui.layout_all(&mut app, &mut services, bounds, 1.0);
807
808        let snap = ui.semantics_snapshot().expect("semantics snapshot");
809        let label = snap
810            .nodes
811            .iter()
812            .find(|n| n.test_id.as_deref() == Some("test.label"))
813            .expect("label semantics node");
814
815        let position = Point::new(
816            Px(label.bounds.origin.x.0 + label.bounds.size.width.0 * 0.5),
817            Px(label.bounds.origin.y.0 + label.bounds.size.height.0 * 0.5),
818        );
819
820        ui.dispatch_event(
821            &mut app,
822            &mut services,
823            &fret_core::Event::Pointer(fret_core::PointerEvent::Down {
824                pointer_id: fret_core::PointerId(0),
825                position,
826                button: MouseButton::Left,
827                modifiers: fret_core::Modifiers::default(),
828                pointer_type: fret_core::PointerType::Mouse,
829                click_count: 1,
830            }),
831        );
832        ui.dispatch_event(
833            &mut app,
834            &mut services,
835            &fret_core::Event::Pointer(fret_core::PointerEvent::Up {
836                pointer_id: fret_core::PointerId(0),
837                position,
838                button: MouseButton::Left,
839                modifiers: fret_core::Modifiers::default(),
840                is_click: true,
841                click_count: 1,
842                pointer_type: fret_core::PointerType::Mouse,
843            }),
844        );
845
846        assert_eq!(app.models().get_copied(&checked), Some(true));
847    }
848}