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 pub fn for_control(mut self, id: impl Into<ControlId>) -> Self {
38 self.for_control = Some(id.into());
39 self
40 }
41
42 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 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 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#[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(®_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(®_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(®istry_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(®istry_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}