1use std::sync::atomic::{AtomicU64, Ordering};
57use std::sync::{Arc, Once};
58
59use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
60use frust::{
61 Color, PlatformViewView, ResolvedSurfaceMode, SizedBox, Theme, on_cleanup, platform_view,
62 resolved_surface_mode, use_context,
63};
64use frust_core::accesskit::Role;
65use frust_core::{
66 AnyView, BoxConstraints, BuildCtx, ChangeFlags, Component, ComponentWidget, EventCtx,
67 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
68 component,
69};
70use kurbo::{Size, Vec2};
71
72use crate::controls::date_picker::{self, CivilDate};
73use crate::controls::{
74 BACKGROUND_COLOR, CHECKED, CONTENT_DESCRIPTION, CORNER_RADIUS_DP, DARK, ENABLED, FIT,
75 INDETERMINATE, MAX, MIN, PROGRESS_TINT, STEP, TEXT, TEXT_COLOR, TEXT_SIZE_SP, THUMB_TINT, TINT,
76 TRACK_TINT, TYPEFACE, VALUE, WRAPS,
77};
78use crate::controls::{
79 button, image, label, progress, segmented, slider, spinner, stepper, switch, tab_bar,
80};
81use crate::registry::SlotId;
82use crate::runtime::{escape, with_identity, with_runtime};
83
84use super::signals::{
85 TabHandler, on_click, on_date, on_selected, on_tab_bar, on_toggled, on_value_changed,
86};
87use super::theme::{self, ResolvedTheme};
88
89#[cfg(target_os = "android")]
122pub(super) const VIEW_TYPE: &str = "dev.frust.nativewidgets.FrustNativeControlFactory";
123#[cfg(target_os = "ios")]
124pub(super) const VIEW_TYPE: &str = "FrustNativeControlFactory";
125#[cfg(target_os = "macos")]
126pub(super) const VIEW_TYPE: &str = crate::appkit::factory::VIEW_TYPE;
127#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
128pub(super) const VIEW_TYPE: &str = "dev.frust.nativewidgets.FrustNativeControlFactory";
129
130pub(super) fn next_local_slot() -> SlotId {
141 static NEXT: AtomicU64 = AtomicU64::new(1);
142 NEXT.fetch_add(1, Ordering::Relaxed)
143}
144
145static REFUSAL_LOGGED: Once = Once::new();
148
149fn warn_refusal_once() {
150 REFUSAL_LOGGED.call_once(|| {
151 log::warn!(
152 "frust-native-widgets: the host declared a translucent surface but the platform \
153 refused it (ResolvedSurfaceMode::RefusedTranslucent) — rendering frust-drawn \
154 placeholders instead of native controls; see docs/ARCHITECTURE.md's Platform-view \
155 flow"
156 );
157 });
158}
159
160pub(super) fn placeholder<State: 'static>(
198 size: Option<(f64, f64)>,
199 control: &str,
200) -> AnyView<State> {
201 warn_refusal_once();
202 banner_placeholder(
203 size,
204 format!("Native {control} unavailable"),
205 "the host declared a translucent surface but the platform refused it — rendering a \
206 frust placeholder instead of an invisible native slot."
207 .to_string(),
208 )
209}
210
211fn banner_placeholder<State: 'static>(
218 size: Option<(f64, f64)>,
219 label: String,
220 description: String,
221) -> AnyView<State> {
222 let banner: AnyView<State> = any(RefusalBanner { label, description });
223 let sized = SizedBox(size.map(|(w, _)| w), size.map(|(_, h)| h)).child(banner);
224 any(ClipToSlot { child: any(sized) })
225}
226
227const REFUSAL_FILL: Color = Color::from_rgb8(0xFF, 0xDD, 0xB0);
231
232const REFUSAL_BORDER: Color = Color::from_rgb8(0x8A, 0x53, 0x00);
234
235const REFUSAL_TEXT: Color = Color::from_rgb8(0x3B, 0x24, 0x00);
239
240const REFUSAL_PAD: f64 = 4.0;
242const REFUSAL_ROW_GAP: f64 = 2.0;
244const REFUSAL_LABEL_SIZE: f32 = 12.0;
248const REFUSAL_DESC_SIZE: f32 = 11.0;
250const REFUSAL_LINE_HEIGHT: f32 = 1.3;
252
253struct BannerText {
271 content: String,
272 layout: Option<TextLayout>,
273 laid_out_style: Option<TextStyle>,
274 laid_out_max_width: Option<f32>,
275}
276
277impl BannerText {
278 fn new(content: impl Into<String>) -> Self {
279 Self {
280 content: content.into(),
281 layout: None,
282 laid_out_style: None,
283 laid_out_max_width: None,
284 }
285 }
286
287 fn set_content(&mut self, content: impl Into<String>) {
288 let content = content.into();
289 if self.content != content {
290 self.content = content;
291 self.layout = None;
292 }
293 }
294
295 fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f32>) -> Size {
296 if let Some(cached) = &self.layout
297 && self.laid_out_max_width == max_width
298 && self.laid_out_style.as_ref() == Some(style)
299 {
300 return cached.size();
301 }
302 let laid = ctx
303 .text_context::<TextContext>()
304 .layout(&self.content, style, max_width);
305 let size = laid.size();
306 self.layout = Some(laid);
307 self.laid_out_style = Some(style.clone());
308 self.laid_out_max_width = max_width;
309 size
310 }
311
312 fn paint(&self, origin: kurbo::Point, scene: &mut dyn PaintScene) {
316 if let Some(layout) = &self.layout {
317 for run in layout.to_scene_runs(origin) {
318 scene.draw_glyph_run(run);
319 }
320 }
321 }
322}
323
324fn refusal_label_style(color: Color) -> TextStyle {
325 TextStyle {
326 weight: FontWeight::SEMI_BOLD,
327 line_height: LineHeight::FontSizeRelative(REFUSAL_LINE_HEIGHT),
328 ..TextStyle::new(REFUSAL_LABEL_SIZE, color)
329 }
330}
331
332fn refusal_description_style(color: Color) -> TextStyle {
333 TextStyle {
334 weight: FontWeight::REGULAR,
335 line_height: LineHeight::FontSizeRelative(REFUSAL_LINE_HEIGHT),
336 ..TextStyle::new(REFUSAL_DESC_SIZE, color)
337 }
338}
339
340fn refusal_text_color(theme: Option<&Theme>) -> Color {
345 match theme {
346 Some(theme) => theme.scheme().on_error_container,
347 None => REFUSAL_TEXT,
348 }
349}
350
351struct RefusalBanner {
360 label: String,
361 description: String,
362}
363
364struct RefusalBannerWidget {
371 label: String,
372 description: String,
373 label_run: BannerText,
374 description_run: BannerText,
375 label_size: Size,
376}
377
378impl<State: 'static> View<State> for RefusalBanner {
379 type Element = RefusalBannerWidget;
380
381 fn build(&self, _ctx: &mut BuildCtx<'_>) -> Self::Element {
382 RefusalBannerWidget {
383 label: self.label.clone(),
384 description: self.description.clone(),
385 label_run: BannerText::new(self.label.clone()),
386 description_run: BannerText::new(self.description.clone()),
387 label_size: Size::ZERO,
388 }
389 }
390
391 fn rebuild(
392 &self,
393 prev: &Self,
394 element: &mut Self::Element,
395 _ctx: &mut BuildCtx<'_>,
396 ) -> ChangeFlags {
397 if prev.label == self.label && prev.description == self.description {
398 return ChangeFlags::NONE;
399 }
400 element.label = self.label.clone();
401 element.description = self.description.clone();
402 element.label_run.set_content(self.label.clone());
403 element
404 .description_run
405 .set_content(self.description.clone());
406 ChangeFlags::LAYOUT | ChangeFlags::PAINT
408 }
409
410 fn teardown(&self, _element: &mut Self::Element, _ctx: &mut BuildCtx<'_>) {}
411}
412
413impl Widget for RefusalBannerWidget {
414 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
415 let size = bc.max();
418
419 let color = refusal_text_color(Theme::from_layout_ctx(ctx));
423 let max_width = size
429 .width
430 .is_finite()
431 .then(|| (size.width - REFUSAL_PAD * 2.0).max(0.0) as f32);
432
433 self.label_size = self
434 .label_run
435 .layout(ctx, &refusal_label_style(color), max_width);
436 self.description_run
437 .layout(ctx, &refusal_description_style(color), max_width);
438
439 size
440 }
441
442 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
443 let (fill, border) = match Theme::from_paint_ctx(ctx) {
447 Some(theme) => {
448 let s = theme.scheme();
449 (s.error_container, s.error)
450 }
451 None => (REFUSAL_FILL, REFUSAL_BORDER),
452 };
453 let (origin, size) = (ctx.origin(), ctx.size());
454 scene.fill_rect(origin, size, fill);
455 const EDGE: f64 = 1.0;
458 let edge = EDGE.min(size.width / 2.0).min(size.height / 2.0);
459 if edge > 0.0 {
460 scene.fill_rect(origin, Size::new(size.width, edge), border);
461 scene.fill_rect(
462 kurbo::Point::new(origin.x, origin.y + size.height - edge),
463 Size::new(size.width, edge),
464 border,
465 );
466 scene.fill_rect(origin, Size::new(edge, size.height), border);
467 scene.fill_rect(
468 kurbo::Point::new(origin.x + size.width - edge, origin.y),
469 Size::new(edge, size.height),
470 border,
471 );
472 }
473 self.label_run
478 .paint(origin + Vec2::new(REFUSAL_PAD, REFUSAL_PAD), scene);
479 let description_y = REFUSAL_PAD + self.label_size.height + REFUSAL_ROW_GAP;
480 self.description_run
481 .paint(origin + Vec2::new(REFUSAL_PAD, description_y), scene);
482 }
483
484 fn semantics(&self, ctx: &mut SemanticsCtx) {
485 let label = format!("{}. {}", self.label, self.description);
486 ctx.push_node(Role::Alert, |node| node.set_label(label));
487 }
488}
489
490struct ClipToSlot<State: 'static> {
498 child: AnyView<State>,
499}
500
501struct ClipToSlotWidget {
503 child: Box<dyn Widget>,
504}
505
506impl<State: 'static> View<State> for ClipToSlot<State> {
507 type Element = ClipToSlotWidget;
508
509 fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
510 ClipToSlotWidget {
511 child: self.child.build(ctx),
512 }
513 }
514
515 fn rebuild(
516 &self,
517 prev: &Self,
518 element: &mut Self::Element,
519 ctx: &mut BuildCtx<'_>,
520 ) -> ChangeFlags {
521 self.child.rebuild(&prev.child, &mut element.child, ctx)
522 }
523
524 fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
525 self.child.teardown(&mut element.child, ctx);
526 }
527}
528
529impl Widget for ClipToSlotWidget {
530 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
531 self.child.layout(ctx, bc)
532 }
533
534 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
535 scene.push_clip(ctx.origin(), ctx.size());
539 self.child.paint(ctx, scene);
540 scene.pop_clip();
541 }
542
543 fn semantics(&self, ctx: &mut SemanticsCtx) {
544 self.child.semantics(ctx);
550 }
551}
552
553pub(super) fn resolve_size(size: Option<(f64, f64)>, view: PlatformViewView) -> PlatformViewView {
560 match size {
561 Some((w, h)) => view.size(w, h),
562 None => view,
563 }
564}
565
566fn ambient_theme_tokens() -> Option<ResolvedTheme> {
578 use_context::<Theme>().as_ref().map(theme::resolve)
579}
580
581struct ParamsBody(String);
587
588impl ParamsBody {
589 fn new() -> Self {
590 Self(String::new())
591 }
592
593 fn push_key(&mut self, key: &str) {
594 if !self.0.is_empty() {
595 self.0.push(',');
596 }
597 self.0.push('"');
598 self.0.push_str(key);
599 self.0.push_str("\":");
600 }
601
602 fn push_raw(&mut self, key: &str, value: impl std::fmt::Display) {
605 self.push_key(key);
606 self.0.push_str(&value.to_string());
607 }
608
609 fn push_str(&mut self, key: &str, value: &str) {
611 self.push_key(key);
612 self.0.push('"');
613 self.0.push_str(&escape(value));
614 self.0.push('"');
615 }
616
617 fn push_opt_str(&mut self, key: &str, value: Option<&str>) {
621 if let Some(v) = value {
622 self.push_str(key, v);
623 }
624 }
625
626 fn finish(self) -> String {
627 self.0
628 }
629}
630
631#[derive(Clone)]
638pub struct NativeButtonView {
639 text: String,
640 enabled: bool,
641 content_description: Option<String>,
642 size: Option<(f64, f64)>,
643 on_press: Option<Arc<dyn Fn() + Send + Sync>>,
644}
645
646pub fn native_button(text: impl Into<String>) -> NativeButtonView {
648 NativeButtonView {
649 text: text.into(),
650 enabled: true,
651 content_description: None,
652 size: None,
653 on_press: None,
654 }
655}
656
657impl NativeButtonView {
658 pub fn enabled(mut self, enabled: bool) -> Self {
660 self.enabled = enabled;
661 self
662 }
663
664 pub fn content_description(mut self, label: impl Into<String>) -> Self {
666 self.content_description = Some(label.into());
667 self
668 }
669
670 pub fn size(mut self, width: f64, height: f64) -> Self {
673 self.size = Some((width, height));
674 self
675 }
676
677 pub fn on_press(mut self, handler: impl Fn() + Send + Sync + 'static) -> Self {
681 self.on_press = Some(Arc::new(handler));
682 self
683 }
684
685 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
692 let mut body = ParamsBody::new();
693 body.push_str(TEXT, &self.text);
694 body.push_raw(ENABLED, self.enabled);
695 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
696 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
697 if let Some(t) = tokens {
698 body.push_raw(TEXT_COLOR, t.on_accent_fill);
699 body.push_raw(BACKGROUND_COLOR, t.accent_fill);
700 body.push_raw(CORNER_RADIUS_DP, t.corner_radius_dp);
701 body.push_raw(TEXT_SIZE_SP, t.button_text_size_sp);
702 body.push_str(TYPEFACE, t.button_typeface.wire());
703 }
704 with_identity(button::KIND, slot, &body.finish())
705 }
706
707 fn build_with_mode(
715 &self,
716 slot: SlotId,
717 mode: ResolvedSurfaceMode,
718 tokens: Option<ResolvedTheme>,
719 ) -> AnyView<SlotId> {
720 if mode.translucency_refused() {
721 return placeholder(self.size, "Button");
722 }
723 let params = self.params_for(slot, tokens);
724 if let Some(on_press) = self.on_press.clone() {
725 with_runtime(|rt| rt.set_callback(slot, on_click(on_press)));
726 }
727 let view = platform_view(VIEW_TYPE)
728 .params_json(params)
729 .interactive()
730 .semantics_label(
731 self.content_description
732 .clone()
733 .unwrap_or_else(|| self.text.clone()),
734 );
735 any(resolve_size(self.size, view))
736 }
737}
738
739impl Component for NativeButtonView {
740 type State = SlotId;
741
742 fn init(&self) -> SlotId {
743 let slot = next_local_slot();
744 on_cleanup(move || {
755 with_runtime(|rt| rt.forget_pending_callback(slot));
756 });
757 slot
758 }
759
760 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
761 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
762 }
763}
764
765#[derive(Clone)]
772pub struct NativeLabelView {
773 text: String,
774 enabled: bool,
775 content_description: Option<String>,
776 size: Option<(f64, f64)>,
777}
778
779pub fn native_label(text: impl Into<String>) -> NativeLabelView {
781 NativeLabelView {
782 text: text.into(),
783 enabled: true,
784 content_description: None,
785 size: None,
786 }
787}
788
789impl NativeLabelView {
790 pub fn enabled(mut self, enabled: bool) -> Self {
793 self.enabled = enabled;
794 self
795 }
796
797 pub fn content_description(mut self, label: impl Into<String>) -> Self {
799 self.content_description = Some(label.into());
800 self
801 }
802
803 pub fn size(mut self, width: f64, height: f64) -> Self {
806 self.size = Some((width, height));
807 self
808 }
809
810 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
818 let mut body = ParamsBody::new();
819 body.push_str(TEXT, &self.text);
820 body.push_raw(ENABLED, self.enabled);
821 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
822 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
823 if let Some(t) = tokens {
824 body.push_raw(BACKGROUND_COLOR, t.surface_bg);
825 body.push_raw(TEXT_COLOR, t.body_text);
826 body.push_raw(TEXT_SIZE_SP, t.body_text_size_sp);
827 body.push_str(TYPEFACE, t.body_typeface.wire());
828 }
829 with_identity(label::KIND, slot, &body.finish())
830 }
831
832 fn build_with_mode(
833 &self,
834 slot: SlotId,
835 mode: ResolvedSurfaceMode,
836 tokens: Option<ResolvedTheme>,
837 ) -> AnyView<SlotId> {
838 if mode.translucency_refused() {
839 return placeholder(self.size, "Label");
840 }
841 let params = self.params_for(slot, tokens);
842 let view = platform_view(VIEW_TYPE)
843 .params_json(params)
844 .semantics_label(
845 self.content_description
846 .clone()
847 .unwrap_or_else(|| self.text.clone()),
848 );
849 any(resolve_size(self.size, view))
850 }
851}
852
853impl Component for NativeLabelView {
854 type State = SlotId;
855
856 fn init(&self) -> SlotId {
861 next_local_slot()
862 }
863
864 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
865 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
866 }
867}
868
869#[derive(Clone)]
878pub struct NativeSwitchView {
879 checked: bool,
880 enabled: bool,
881 content_description: Option<String>,
882 size: Option<(f64, f64)>,
883 on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
884}
885
886pub fn native_switch(checked: bool) -> NativeSwitchView {
889 NativeSwitchView {
890 checked,
891 enabled: true,
892 content_description: None,
893 size: None,
894 on_toggle: None,
895 }
896}
897
898impl NativeSwitchView {
899 pub fn enabled(mut self, enabled: bool) -> Self {
901 self.enabled = enabled;
902 self
903 }
904
905 pub fn content_description(mut self, label: impl Into<String>) -> Self {
907 self.content_description = Some(label.into());
908 self
909 }
910
911 pub fn size(mut self, width: f64, height: f64) -> Self {
914 self.size = Some((width, height));
915 self
916 }
917
918 pub fn on_toggle(mut self, handler: impl Fn(bool) + Send + Sync + 'static) -> Self {
923 self.on_toggle = Some(Arc::new(handler));
924 self
925 }
926
927 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
936 let mut body = ParamsBody::new();
937 body.push_raw(CHECKED, self.checked);
938 body.push_raw(ENABLED, self.enabled);
939 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
940 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
941 if let Some(t) = tokens {
942 body.push_raw(THUMB_TINT, t.accent_ink);
943 body.push_raw(TRACK_TINT, t.accent_fill);
944 body.push_str(TYPEFACE, t.body_typeface.wire());
950 }
951 with_identity(switch::KIND, slot, &body.finish())
952 }
953
954 fn build_with_mode(
955 &self,
956 slot: SlotId,
957 mode: ResolvedSurfaceMode,
958 tokens: Option<ResolvedTheme>,
959 ) -> AnyView<SlotId> {
960 if mode.translucency_refused() {
961 return placeholder(self.size, "Switch");
962 }
963 let params = self.params_for(slot, tokens);
964 if let Some(on_toggle) = self.on_toggle.clone() {
965 with_runtime(|rt| rt.set_callback(slot, on_toggled(on_toggle)));
966 }
967 let view = platform_view(VIEW_TYPE)
968 .params_json(params)
969 .interactive()
970 .semantics_label(
971 self.content_description
972 .clone()
973 .unwrap_or_else(|| "switch".into()),
974 );
975 any(resolve_size(self.size, view))
976 }
977}
978
979impl Component for NativeSwitchView {
980 type State = SlotId;
981
982 fn init(&self) -> SlotId {
983 let slot = next_local_slot();
984 on_cleanup(move || {
988 with_runtime(|rt| rt.forget_pending_callback(slot));
989 });
990 slot
991 }
992
993 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
994 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
995 }
996}
997
998#[derive(Clone)]
1007pub struct NativeSliderView {
1008 value: i32,
1009 min: i32,
1010 max: i32,
1011 enabled: bool,
1012 content_description: Option<String>,
1013 size: Option<(f64, f64)>,
1014 on_change: Option<Arc<dyn Fn(i32) + Send + Sync>>,
1015}
1016
1017pub fn native_slider(value: i32, min: i32, max: i32) -> NativeSliderView {
1020 NativeSliderView {
1021 value,
1022 min,
1023 max,
1024 enabled: true,
1025 content_description: None,
1026 size: None,
1027 on_change: None,
1028 }
1029}
1030
1031impl NativeSliderView {
1032 pub fn enabled(mut self, enabled: bool) -> Self {
1034 self.enabled = enabled;
1035 self
1036 }
1037
1038 pub fn content_description(mut self, label: impl Into<String>) -> Self {
1040 self.content_description = Some(label.into());
1041 self
1042 }
1043
1044 pub fn size(mut self, width: f64, height: f64) -> Self {
1047 self.size = Some((width, height));
1048 self
1049 }
1050
1051 pub fn on_change(mut self, handler: impl Fn(i32) + Send + Sync + 'static) -> Self {
1054 self.on_change = Some(Arc::new(handler));
1055 self
1056 }
1057
1058 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1068 let mut body = ParamsBody::new();
1069 body.push_raw(VALUE, self.value);
1070 body.push_raw(MIN, self.min);
1071 body.push_raw(MAX, self.max);
1072 body.push_raw(ENABLED, self.enabled);
1073 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1074 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1075 if let Some(t) = tokens {
1076 body.push_raw(PROGRESS_TINT, t.accent_fill);
1077 body.push_raw(THUMB_TINT, t.accent_ink);
1078 }
1079 with_identity(slider::KIND, slot, &body.finish())
1080 }
1081
1082 fn build_with_mode(
1083 &self,
1084 slot: SlotId,
1085 mode: ResolvedSurfaceMode,
1086 tokens: Option<ResolvedTheme>,
1087 ) -> AnyView<SlotId> {
1088 if mode.translucency_refused() {
1089 return placeholder(self.size, "Slider");
1090 }
1091 let params = self.params_for(slot, tokens);
1092 if let Some(on_change) = self.on_change.clone() {
1093 with_runtime(|rt| rt.set_callback(slot, on_value_changed(on_change)));
1094 }
1095 let view = platform_view(VIEW_TYPE)
1096 .params_json(params)
1097 .interactive()
1098 .semantics_label(
1099 self.content_description
1100 .clone()
1101 .unwrap_or_else(|| "slider".into()),
1102 );
1103 any(resolve_size(self.size, view))
1104 }
1105}
1106
1107impl Component for NativeSliderView {
1108 type State = SlotId;
1109
1110 fn init(&self) -> SlotId {
1111 let slot = next_local_slot();
1112 on_cleanup(move || {
1116 with_runtime(|rt| rt.forget_pending_callback(slot));
1117 });
1118 slot
1119 }
1120
1121 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1122 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1123 }
1124}
1125
1126#[derive(Clone)]
1133pub struct NativeProgressView {
1134 value: i32,
1135 min: i32,
1136 max: i32,
1137 indeterminate: bool,
1138 content_description: Option<String>,
1139 size: Option<(f64, f64)>,
1140}
1141
1142pub fn native_progress(value: i32, min: i32, max: i32) -> NativeProgressView {
1145 NativeProgressView {
1146 value,
1147 min,
1148 max,
1149 indeterminate: false,
1150 content_description: None,
1151 size: None,
1152 }
1153}
1154
1155impl NativeProgressView {
1156 pub fn indeterminate(mut self, indeterminate: bool) -> Self {
1158 self.indeterminate = indeterminate;
1159 self
1160 }
1161
1162 pub fn content_description(mut self, label: impl Into<String>) -> Self {
1164 self.content_description = Some(label.into());
1165 self
1166 }
1167
1168 pub fn size(mut self, width: f64, height: f64) -> Self {
1171 self.size = Some((width, height));
1172 self
1173 }
1174
1175 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1180 let mut body = ParamsBody::new();
1181 body.push_raw(VALUE, self.value);
1182 body.push_raw(MIN, self.min);
1183 body.push_raw(MAX, self.max);
1184 body.push_raw(INDETERMINATE, self.indeterminate);
1185 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1186 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1187 if let Some(t) = tokens {
1188 body.push_raw(BACKGROUND_COLOR, t.surface_bg);
1189 body.push_raw(PROGRESS_TINT, t.accent_fill);
1190 }
1191 with_identity(progress::KIND, slot, &body.finish())
1192 }
1193
1194 fn build_with_mode(
1195 &self,
1196 slot: SlotId,
1197 mode: ResolvedSurfaceMode,
1198 tokens: Option<ResolvedTheme>,
1199 ) -> AnyView<SlotId> {
1200 if mode.translucency_refused() {
1201 return placeholder(self.size, "ProgressBar");
1202 }
1203 let params = self.params_for(slot, tokens);
1204 let view = platform_view(VIEW_TYPE)
1205 .params_json(params)
1206 .semantics_label(
1207 self.content_description
1208 .clone()
1209 .unwrap_or_else(|| "progress".into()),
1210 );
1211 any(resolve_size(self.size, view))
1212 }
1213}
1214
1215impl Component for NativeProgressView {
1216 type State = SlotId;
1217
1218 fn init(&self) -> SlotId {
1221 next_local_slot()
1222 }
1223
1224 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1225 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1226 }
1227}
1228
1229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1240pub enum NativeSpinnerSize {
1241 Small,
1243 #[default]
1245 Medium,
1246 Large,
1248}
1249
1250impl NativeSpinnerSize {
1251 fn wire(self) -> &'static str {
1254 match self {
1255 Self::Small => "small",
1256 Self::Medium => "medium",
1257 Self::Large => "large",
1258 }
1259 }
1260}
1261
1262#[derive(Clone)]
1266pub struct NativeSpinnerView {
1267 animating: bool,
1268 size_class: NativeSpinnerSize,
1269 enabled: bool,
1270 content_description: Option<String>,
1271 size: Option<(f64, f64)>,
1272}
1273
1274pub fn native_spinner(animating: bool) -> NativeSpinnerView {
1276 NativeSpinnerView {
1277 animating,
1278 size_class: NativeSpinnerSize::default(),
1279 enabled: true,
1280 content_description: None,
1281 size: None,
1282 }
1283}
1284
1285impl NativeSpinnerView {
1286 pub fn size_class(mut self, size_class: NativeSpinnerSize) -> Self {
1288 self.size_class = size_class;
1289 self
1290 }
1291
1292 pub fn enabled(mut self, enabled: bool) -> Self {
1296 self.enabled = enabled;
1297 self
1298 }
1299
1300 pub fn content_description(mut self, label: impl Into<String>) -> Self {
1302 self.content_description = Some(label.into());
1303 self
1304 }
1305
1306 pub fn size(mut self, width: f64, height: f64) -> Self {
1309 self.size = Some((width, height));
1310 self
1311 }
1312
1313 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1317 let mut body = ParamsBody::new();
1318 body.push_raw(spinner::ANIMATING, self.animating);
1319 body.push_str(spinner::SIZE_CLASS, self.size_class.wire());
1320 body.push_raw(ENABLED, self.enabled);
1321 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1322 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1323 if let Some(t) = tokens {
1324 body.push_raw(TINT, t.accent_ink);
1325 }
1326 with_identity(spinner::KIND, slot, &body.finish())
1327 }
1328
1329 fn build_with_mode(
1330 &self,
1331 slot: SlotId,
1332 mode: ResolvedSurfaceMode,
1333 tokens: Option<ResolvedTheme>,
1334 ) -> AnyView<SlotId> {
1335 if mode.translucency_refused() {
1336 return placeholder(self.size, "Spinner");
1337 }
1338 let params = self.params_for(slot, tokens);
1339 let view = platform_view(VIEW_TYPE)
1340 .params_json(params)
1341 .semantics_label(
1342 self.content_description
1343 .clone()
1344 .unwrap_or_else(|| "spinner".into()),
1345 );
1346 any(resolve_size(self.size, view))
1347 }
1348}
1349
1350impl Component for NativeSpinnerView {
1351 type State = SlotId;
1352
1353 fn init(&self) -> SlotId {
1356 next_local_slot()
1357 }
1358
1359 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1360 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1361 }
1362}
1363
1364#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1381pub enum NativeDatePickerStyle {
1382 #[default]
1384 Compact,
1385 Wheels,
1387 Inline,
1389}
1390
1391impl NativeDatePickerStyle {
1392 fn wire(self) -> &'static str {
1395 match self {
1396 Self::Compact => "compact",
1397 Self::Wheels => "wheels",
1398 Self::Inline => "inline",
1399 }
1400 }
1401}
1402
1403#[derive(Clone)]
1409pub struct NativeDatePickerView {
1410 date: CivilDate,
1411 min: Option<CivilDate>,
1412 max: Option<CivilDate>,
1413 style: NativeDatePickerStyle,
1414 enabled: bool,
1415 content_description: Option<String>,
1416 size: Option<(f64, f64)>,
1417 on_change: Option<Arc<dyn Fn(CivilDate) + Send + Sync>>,
1418}
1419
1420pub fn native_date_picker(date: CivilDate) -> NativeDatePickerView {
1422 NativeDatePickerView {
1423 date,
1424 min: None,
1425 max: None,
1426 style: NativeDatePickerStyle::default(),
1427 enabled: true,
1428 content_description: None,
1429 size: None,
1430 on_change: None,
1431 }
1432}
1433
1434impl NativeDatePickerView {
1435 pub fn min(mut self, min: CivilDate) -> Self {
1443 self.min = Some(min);
1444 self
1445 }
1446
1447 pub fn max(mut self, max: CivilDate) -> Self {
1454 self.max = Some(max);
1455 self
1456 }
1457
1458 pub fn style(mut self, style: NativeDatePickerStyle) -> Self {
1461 self.style = style;
1462 self
1463 }
1464
1465 pub fn enabled(mut self, enabled: bool) -> Self {
1468 self.enabled = enabled;
1469 self
1470 }
1471
1472 pub fn content_description(mut self, label: impl Into<String>) -> Self {
1474 self.content_description = Some(label.into());
1475 self
1476 }
1477
1478 pub fn size(mut self, width: f64, height: f64) -> Self {
1481 self.size = Some((width, height));
1482 self
1483 }
1484
1485 pub fn on_change(mut self, handler: impl Fn(CivilDate) + Send + Sync + 'static) -> Self {
1489 self.on_change = Some(Arc::new(handler));
1490 self
1491 }
1492
1493 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1499 let mut body = ParamsBody::new();
1500 body.push_raw(date_picker::DATE, date_picker::wire(self.date));
1501 if let Some(min) = self.min {
1502 body.push_raw(date_picker::MIN_DATE, date_picker::wire(min));
1503 }
1504 if let Some(max) = self.max {
1505 body.push_raw(date_picker::MAX_DATE, date_picker::wire(max));
1506 }
1507 body.push_str(date_picker::STYLE, self.style.wire());
1508 body.push_raw(ENABLED, self.enabled);
1509 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1510 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1511 if let Some(t) = tokens {
1512 body.push_raw(TINT, t.accent_ink);
1513 body.push_raw(TEXT_COLOR, t.body_text);
1514 }
1515 with_identity(date_picker::KIND, slot, &body.finish())
1516 }
1517
1518 fn build_with_mode(
1519 &self,
1520 slot: SlotId,
1521 mode: ResolvedSurfaceMode,
1522 tokens: Option<ResolvedTheme>,
1523 ) -> AnyView<SlotId> {
1524 if mode.translucency_refused() {
1525 return placeholder(self.size, "Date picker");
1526 }
1527 let params = self.params_for(slot, tokens);
1528 if let Some(on_change) = self.on_change.clone() {
1529 with_runtime(|rt| rt.set_callback(slot, on_date(on_change)));
1530 }
1531 let view = platform_view(VIEW_TYPE)
1532 .params_json(params)
1533 .interactive()
1534 .semantics_label(
1535 self.content_description
1536 .clone()
1537 .unwrap_or_else(|| "date picker".into()),
1538 );
1539 any(resolve_size(self.size, view))
1540 }
1541}
1542
1543impl Component for NativeDatePickerView {
1544 type State = SlotId;
1545
1546 fn init(&self) -> SlotId {
1547 let slot = next_local_slot();
1548 on_cleanup(move || {
1552 with_runtime(|rt| rt.forget_pending_callback(slot));
1553 });
1554 slot
1555 }
1556
1557 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1558 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1559 }
1560}
1561
1562#[cfg(any(target_os = "ios", target_os = "macos"))]
1580const SEGMENTED_ARM: bool = true;
1581#[cfg(not(any(target_os = "ios", target_os = "macos")))]
1584const SEGMENTED_ARM: bool = false;
1585
1586const SEGMENTED_UNAVAILABLE_LABEL: &str = "Native segmented control unavailable";
1588
1589const SEGMENTED_UNAVAILABLE_DESCRIPTION: &str = "native_segmented has no Android arm in this \
1592 build (the framework has no segmented control; a Material-backed Android arm is a \
1593 follow-up plan) — rendering a frust placeholder instead of an empty native slot.";
1594
1595static SEGMENTED_NO_ARM_LOGGED: Once = Once::new();
1597
1598fn warn_no_segmented_arm_once() {
1599 SEGMENTED_NO_ARM_LOGGED.call_once(|| {
1600 log::warn!(
1601 "frust-native-widgets: native_segmented is an iOS/macOS-only control in this build \
1602 (no Android arm yet — a Material-backed one is a follow-up plan); rendering the \
1603 frust-drawn refusal banner instead of a native slot"
1604 );
1605 });
1606}
1607
1608#[derive(Clone)]
1615pub struct NativeSegmentedView {
1616 labels: Vec<String>,
1617 selected: usize,
1618 enabled: bool,
1619 momentary: bool,
1620 content_description: Option<String>,
1621 size: Option<(f64, f64)>,
1622 on_select: Option<Arc<dyn Fn(usize) + Send + Sync>>,
1623}
1624
1625pub fn native_segmented(labels: Vec<String>, selected: usize) -> NativeSegmentedView {
1630 NativeSegmentedView {
1631 labels,
1632 selected,
1633 enabled: true,
1634 momentary: false,
1635 content_description: None,
1636 size: None,
1637 on_select: None,
1638 }
1639}
1640
1641impl NativeSegmentedView {
1642 pub fn enabled(mut self, enabled: bool) -> Self {
1644 self.enabled = enabled;
1645 self
1646 }
1647
1648 pub fn momentary(mut self, momentary: bool) -> Self {
1652 self.momentary = momentary;
1653 self
1654 }
1655
1656 pub fn content_description(mut self, label: impl Into<String>) -> Self {
1658 self.content_description = Some(label.into());
1659 self
1660 }
1661
1662 pub fn size(mut self, width: f64, height: f64) -> Self {
1665 self.size = Some((width, height));
1666 self
1667 }
1668
1669 pub fn on_select(mut self, handler: impl Fn(usize) + Send + Sync + 'static) -> Self {
1674 self.on_select = Some(Arc::new(handler));
1675 self
1676 }
1677
1678 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1685 let mut body = ParamsBody::new();
1686 body.push_raw(segmented::SEGMENT_COUNT, self.labels.len());
1687 for (index, label) in self.labels.iter().enumerate() {
1688 body.push_str(&segmented::segment_key(index), label);
1689 }
1690 body.push_raw(segmented::SELECTED, self.selected);
1691 body.push_raw(segmented::MOMENTARY, self.momentary);
1692 body.push_raw(ENABLED, self.enabled);
1693 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1694 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1695 if let Some(t) = tokens {
1696 body.push_raw(TINT, t.accent_fill);
1697 }
1698 with_identity(segmented::KIND, slot, &body.finish())
1699 }
1700
1701 fn build_with_mode(
1702 &self,
1703 slot: SlotId,
1704 mode: ResolvedSurfaceMode,
1705 tokens: Option<ResolvedTheme>,
1706 ) -> AnyView<SlotId> {
1707 self.build_for_arm(slot, mode, tokens, SEGMENTED_ARM)
1708 }
1709
1710 fn build_for_arm(
1717 &self,
1718 slot: SlotId,
1719 mode: ResolvedSurfaceMode,
1720 tokens: Option<ResolvedTheme>,
1721 arm_available: bool,
1722 ) -> AnyView<SlotId> {
1723 if !arm_available {
1724 warn_no_segmented_arm_once();
1725 return banner_placeholder(
1726 self.size,
1727 SEGMENTED_UNAVAILABLE_LABEL.to_string(),
1728 SEGMENTED_UNAVAILABLE_DESCRIPTION.to_string(),
1729 );
1730 }
1731 if mode.translucency_refused() {
1732 return placeholder(self.size, "SegmentedControl");
1733 }
1734 let params = self.params_for(slot, tokens);
1735 if let Some(on_select) = self.on_select.clone() {
1736 with_runtime(|rt| rt.set_callback(slot, on_selected(on_select)));
1737 }
1738 let view = platform_view(VIEW_TYPE)
1739 .params_json(params)
1740 .interactive()
1741 .semantics_label(
1742 self.content_description
1743 .clone()
1744 .unwrap_or_else(|| "segmented control".into()),
1745 );
1746 any(resolve_size(self.size, view))
1747 }
1748}
1749
1750impl Component for NativeSegmentedView {
1751 type State = SlotId;
1752
1753 fn init(&self) -> SlotId {
1754 let slot = next_local_slot();
1755 on_cleanup(move || {
1760 with_runtime(|rt| rt.forget_pending_callback(slot));
1761 });
1762 slot
1763 }
1764
1765 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1766 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1767 }
1768}
1769
1770#[cfg(any(target_os = "ios", target_os = "macos"))]
1788const STEPPER_ARM: bool = true;
1789#[cfg(not(any(target_os = "ios", target_os = "macos")))]
1792const STEPPER_ARM: bool = false;
1793
1794const STEPPER_UNAVAILABLE_LABEL: &str = "Native stepper unavailable";
1796
1797const STEPPER_UNAVAILABLE_DESCRIPTION: &str = "native_stepper has no Android arm in this build \
1800 (the framework has no increment/decrement control; a composite Android arm is a follow-up \
1801 plan) — rendering a frust placeholder instead of an empty native slot.";
1802
1803static STEPPER_NO_ARM_LOGGED: Once = Once::new();
1805
1806fn warn_no_stepper_arm_once() {
1807 STEPPER_NO_ARM_LOGGED.call_once(|| {
1808 log::warn!(
1809 "frust-native-widgets: native_stepper is an iOS/macOS-only control in this build (no \
1810 Android arm yet — a composite one is a follow-up plan); rendering the frust-drawn \
1811 refusal banner instead of a native slot"
1812 );
1813 });
1814}
1815
1816#[derive(Clone)]
1833pub struct NativeStepperView {
1834 value: i32,
1835 min: i32,
1836 max: i32,
1837 step: i32,
1838 wraps: bool,
1839 enabled: bool,
1840 content_description: Option<String>,
1841 size: Option<(f64, f64)>,
1842 on_change: Option<Arc<dyn Fn(i32) + Send + Sync>>,
1843}
1844
1845pub fn native_stepper(value: i32, min: i32, max: i32) -> NativeStepperView {
1848 NativeStepperView {
1849 value,
1850 min,
1851 max,
1852 step: 1,
1853 wraps: false,
1854 enabled: true,
1855 content_description: None,
1856 size: None,
1857 on_change: None,
1858 }
1859}
1860
1861impl NativeStepperView {
1862 pub fn step(mut self, step: i32) -> Self {
1868 self.step = step;
1869 self
1870 }
1871
1872 pub fn wraps(mut self, wraps: bool) -> Self {
1875 self.wraps = wraps;
1876 self
1877 }
1878
1879 pub fn enabled(mut self, enabled: bool) -> Self {
1881 self.enabled = enabled;
1882 self
1883 }
1884
1885 pub fn content_description(mut self, label: impl Into<String>) -> Self {
1887 self.content_description = Some(label.into());
1888 self
1889 }
1890
1891 pub fn size(mut self, width: f64, height: f64) -> Self {
1894 self.size = Some((width, height));
1895 self
1896 }
1897
1898 pub fn on_change(mut self, handler: impl Fn(i32) + Send + Sync + 'static) -> Self {
1903 self.on_change = Some(Arc::new(handler));
1904 self
1905 }
1906
1907 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1913 let mut body = ParamsBody::new();
1914 body.push_raw(VALUE, self.value);
1915 body.push_raw(MIN, self.min);
1916 body.push_raw(MAX, self.max);
1917 body.push_raw(STEP, self.step);
1918 body.push_raw(WRAPS, self.wraps);
1919 body.push_raw(ENABLED, self.enabled);
1920 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1921 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1922 if let Some(t) = tokens {
1923 body.push_raw(TINT, t.accent_ink);
1924 }
1925 with_identity(stepper::KIND, slot, &body.finish())
1926 }
1927
1928 fn build_with_mode(
1929 &self,
1930 slot: SlotId,
1931 mode: ResolvedSurfaceMode,
1932 tokens: Option<ResolvedTheme>,
1933 ) -> AnyView<SlotId> {
1934 self.build_for_arm(slot, mode, tokens, STEPPER_ARM)
1935 }
1936
1937 fn build_for_arm(
1945 &self,
1946 slot: SlotId,
1947 mode: ResolvedSurfaceMode,
1948 tokens: Option<ResolvedTheme>,
1949 arm_available: bool,
1950 ) -> AnyView<SlotId> {
1951 if !arm_available {
1952 warn_no_stepper_arm_once();
1953 return banner_placeholder(
1954 self.size,
1955 STEPPER_UNAVAILABLE_LABEL.to_string(),
1956 STEPPER_UNAVAILABLE_DESCRIPTION.to_string(),
1957 );
1958 }
1959 if mode.translucency_refused() {
1960 return placeholder(self.size, "Stepper");
1961 }
1962 let params = self.params_for(slot, tokens);
1963 if let Some(on_change) = self.on_change.clone() {
1964 with_runtime(|rt| rt.set_callback(slot, on_value_changed(on_change)));
1965 }
1966 let view = platform_view(VIEW_TYPE)
1967 .params_json(params)
1968 .interactive()
1969 .semantics_label(
1970 self.content_description
1971 .clone()
1972 .unwrap_or_else(|| "stepper".into()),
1973 );
1974 any(resolve_size(self.size, view))
1975 }
1976}
1977
1978impl Component for NativeStepperView {
1979 type State = SlotId;
1980
1981 fn init(&self) -> SlotId {
1982 let slot = next_local_slot();
1983 on_cleanup(move || {
1988 with_runtime(|rt| rt.forget_pending_callback(slot));
1989 });
1990 slot
1991 }
1992
1993 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1994 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1995 }
1996}
1997
1998#[cfg(target_os = "ios")]
2009const TAB_BAR_ARM: bool = true;
2010#[cfg(not(target_os = "ios"))]
2012const TAB_BAR_ARM: bool = false;
2013
2014const TAB_BAR_UNAVAILABLE_LABEL: &str = "Native tab bar unavailable";
2016
2017const TAB_BAR_UNAVAILABLE_DESCRIPTION: &str = "native_tab_bar is iOS/iPadOS-only: macOS has no \
2020 bottom tab bar idiom, and Android's BottomNavigationView needs Material, which this plugin \
2021 never assumes — rendering a frust placeholder instead of an empty native slot.";
2022
2023static TAB_BAR_NO_ARM_LOGGED: Once = Once::new();
2025
2026fn warn_no_tab_bar_arm_once() {
2027 TAB_BAR_NO_ARM_LOGGED.call_once(|| {
2028 log::warn!(
2029 "frust-native-widgets: native_tab_bar is an iOS/iPadOS-only control (macOS has no \
2030 bottom tab bar idiom; Android's BottomNavigationView needs Material); rendering the \
2031 frust-drawn refusal banner instead of a native slot"
2032 );
2033 });
2034}
2035
2036#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2041pub struct TabId(String);
2042
2043impl TabId {
2044 pub fn new(id: impl Into<String>) -> Self {
2046 Self(id.into())
2047 }
2048
2049 pub fn as_str(&self) -> &str {
2051 &self.0
2052 }
2053}
2054
2055impl From<&str> for TabId {
2056 fn from(id: &str) -> Self {
2057 Self::new(id)
2058 }
2059}
2060
2061impl From<String> for TabId {
2062 fn from(id: String) -> Self {
2063 Self(id)
2064 }
2065}
2066
2067impl std::fmt::Display for TabId {
2068 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2069 f.write_str(&self.0)
2070 }
2071}
2072
2073#[derive(Clone, Debug, PartialEq)]
2077pub enum TabIcon {
2078 Bytes(Arc<[u8]>),
2082 AppleSymbol(String),
2086}
2087
2088#[derive(Clone, Debug, PartialEq)]
2091pub struct TabItem {
2092 id: TabId,
2093 title: String,
2094 icon: TabIcon,
2095 selected_icon: Option<TabIcon>,
2096 badge: Option<String>,
2097 enabled: bool,
2098}
2099
2100impl TabItem {
2101 pub fn new(id: impl Into<TabId>, title: impl Into<String>, icon: TabIcon) -> Self {
2103 Self {
2104 id: id.into(),
2105 title: title.into(),
2106 icon,
2107 selected_icon: None,
2108 badge: None,
2109 enabled: true,
2110 }
2111 }
2112
2113 pub fn selected_icon(mut self, icon: TabIcon) -> Self {
2117 self.selected_icon = Some(icon);
2118 self
2119 }
2120
2121 pub fn badge(mut self, badge: impl Into<String>) -> Self {
2124 self.badge = Some(badge.into());
2125 self
2126 }
2127
2128 pub fn enabled(mut self, enabled: bool) -> Self {
2130 self.enabled = enabled;
2131 self
2132 }
2133
2134 pub fn id(&self) -> &TabId {
2136 &self.id
2137 }
2138}
2139
2140fn symbol_name(icon: Option<&TabIcon>) -> Option<&str> {
2143 match icon {
2144 Some(TabIcon::AppleSymbol(name)) => Some(name),
2145 _ => None,
2146 }
2147}
2148
2149fn icon_bytes(icon: Option<&TabIcon>) -> Option<Arc<[u8]>> {
2151 match icon {
2152 Some(TabIcon::Bytes(bytes)) => Some(Arc::clone(bytes)),
2153 _ => None,
2154 }
2155}
2156
2157#[derive(Clone)]
2182pub struct NativeTabBarView {
2183 items: Vec<TabItem>,
2184 selected: TabId,
2185 safe_area: bool,
2186 on_select: TabHandler,
2187 on_reselect: TabHandler,
2188}
2189
2190pub fn native_tab_bar(items: Vec<TabItem>, selected: impl Into<TabId>) -> NativeTabBarView {
2207 NativeTabBarView {
2208 items,
2209 selected: selected.into(),
2210 safe_area: true,
2211 on_select: None,
2212 on_reselect: None,
2213 }
2214}
2215
2216impl NativeTabBarView {
2217 pub fn on_select(mut self, handler: impl Fn(TabId) + Send + Sync + 'static) -> Self {
2222 self.on_select = Some(Arc::new(handler));
2223 self
2224 }
2225
2226 pub fn on_reselect(mut self, handler: impl Fn(TabId) + Send + Sync + 'static) -> Self {
2230 self.on_reselect = Some(Arc::new(handler));
2231 self
2232 }
2233
2234 pub fn safe_area(mut self, enabled: bool) -> Self {
2238 self.safe_area = enabled;
2239 self
2240 }
2241
2242 fn selected_index(&self) -> Option<usize> {
2244 self.items.iter().position(|item| item.id == self.selected)
2245 }
2246
2247 fn icon_bytes(&self) -> Vec<tab_bar::ItemIconBytes> {
2250 self.items
2251 .iter()
2252 .map(|item| tab_bar::ItemIconBytes {
2253 icon: icon_bytes(Some(&item.icon)),
2254 selected_icon: icon_bytes(item.selected_icon.as_ref()),
2255 })
2256 .collect()
2257 }
2258
2259 fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>, icons_rev: u64) -> String {
2265 let mut body = ParamsBody::new();
2266 body.push_raw(tab_bar::ITEM_COUNT, self.items.len());
2267 for (index, item) in self.items.iter().enumerate() {
2268 body.push_str(&tab_bar::item_key(index, tab_bar::FIELD_TITLE), &item.title);
2269 body.push_raw(
2270 &tab_bar::item_key(index, tab_bar::FIELD_ENABLED),
2271 item.enabled,
2272 );
2273 body.push_opt_str(
2274 &tab_bar::item_key(index, tab_bar::FIELD_BADGE),
2275 item.badge.as_deref(),
2276 );
2277 body.push_opt_str(
2278 &tab_bar::item_key(index, tab_bar::FIELD_SYMBOL),
2279 symbol_name(Some(&item.icon)),
2280 );
2281 body.push_opt_str(
2282 &tab_bar::item_key(index, tab_bar::FIELD_SELECTED_SYMBOL),
2283 symbol_name(item.selected_icon.as_ref()),
2284 );
2285 }
2286 if let Some(index) = self.selected_index() {
2287 body.push_raw(tab_bar::SELECTED, index);
2288 }
2289 body.push_raw(tab_bar::ICONS_REV, icons_rev);
2290 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
2291 if let Some(t) = tokens {
2292 body.push_raw(TINT, t.accent_ink);
2293 body.push_raw(tab_bar::UNSELECTED_TINT, t.muted);
2294 body.push_raw(BACKGROUND_COLOR, t.surface_bg);
2295 }
2296 with_identity(tab_bar::KIND, slot, &body.finish())
2297 }
2298
2299 fn build_with_mode(
2300 &self,
2301 slot: SlotId,
2302 mode: ResolvedSurfaceMode,
2303 tokens: Option<ResolvedTheme>,
2304 ) -> AnyView<SlotId> {
2305 self.build_for_arm(slot, mode, tokens, TAB_BAR_ARM)
2306 }
2307
2308 fn build_for_arm(
2314 &self,
2315 slot: SlotId,
2316 mode: ResolvedSurfaceMode,
2317 tokens: Option<ResolvedTheme>,
2318 arm_available: bool,
2319 ) -> AnyView<SlotId> {
2320 let child = if !arm_available {
2321 warn_no_tab_bar_arm_once();
2322 banner_placeholder(
2323 None,
2324 TAB_BAR_UNAVAILABLE_LABEL.to_string(),
2325 TAB_BAR_UNAVAILABLE_DESCRIPTION.to_string(),
2326 )
2327 } else if mode.translucency_refused() {
2328 placeholder(None, "TabBar")
2329 } else {
2330 let icons_rev = tab_bar::publish_icon_bytes(slot, self.icon_bytes());
2331 let params = self.params_for(slot, tokens, icons_rev);
2332 if self.on_select.is_some() || self.on_reselect.is_some() {
2333 let ids: Arc<[TabId]> = self.items.iter().map(|item| item.id.clone()).collect();
2334 with_runtime(|rt| {
2335 rt.set_callback(
2336 slot,
2337 on_tab_bar(ids, self.on_select.clone(), self.on_reselect.clone()),
2338 )
2339 });
2340 }
2341 any(platform_view(VIEW_TYPE)
2342 .params_json(params)
2343 .interactive()
2344 .semantics_label("tab bar")
2345 .expand())
2346 };
2347 any(TabBarSlot {
2348 child,
2349 safe_area: self.safe_area,
2350 })
2351 }
2352}
2353
2354impl Component for NativeTabBarView {
2355 type State = SlotId;
2356
2357 fn init(&self) -> SlotId {
2358 let slot = next_local_slot();
2359 on_cleanup(move || {
2364 with_runtime(|rt| rt.forget_pending_callback(slot));
2365 tab_bar::retire_icon_bytes(slot);
2366 });
2367 slot
2368 }
2369
2370 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
2371 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
2372 }
2373}
2374
2375struct TabBarSlot<State: 'static> {
2386 child: AnyView<State>,
2387 safe_area: bool,
2388}
2389
2390struct TabBarSlotWidget {
2392 child: Box<dyn Widget>,
2393 safe_area: bool,
2394}
2395
2396impl<State: 'static> View<State> for TabBarSlot<State> {
2397 type Element = TabBarSlotWidget;
2398
2399 fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
2400 TabBarSlotWidget {
2401 child: self.child.build(ctx),
2402 safe_area: self.safe_area,
2403 }
2404 }
2405
2406 fn rebuild(
2407 &self,
2408 prev: &Self,
2409 element: &mut Self::Element,
2410 ctx: &mut BuildCtx<'_>,
2411 ) -> ChangeFlags {
2412 let mut flags = self.child.rebuild(&prev.child, &mut element.child, ctx);
2413 if element.safe_area != self.safe_area {
2414 element.safe_area = self.safe_area;
2415 flags |= ChangeFlags::LAYOUT;
2416 }
2417 flags
2418 }
2419
2420 fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
2421 self.child.teardown(&mut element.child, ctx);
2422 }
2423}
2424
2425impl TabBarSlotWidget {
2426 fn slot_size(&self, bc: &BoxConstraints, bottom_inset: f64) -> Size {
2429 let inset = if self.safe_area {
2430 bottom_inset.max(0.0)
2431 } else {
2432 0.0
2433 };
2434 let width = if bc.max().width.is_finite() {
2435 bc.max().width
2436 } else {
2437 bc.min().width
2438 };
2439 bc.constrain(Size::new(width, tab_bar::BAR_HEIGHT + inset))
2440 }
2441}
2442
2443impl Widget for TabBarSlotWidget {
2444 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2445 let size = self.slot_size(bc, ctx.window_insets().padding().bottom);
2446 self.child.layout(ctx, &BoxConstraints::tight(size));
2447 size
2448 }
2449
2450 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2451 scene.push_clip(ctx.origin(), ctx.size());
2452 self.child.paint(ctx, scene);
2453 scene.pop_clip();
2454 }
2455
2456 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2457 self.child.event(ctx, event)
2458 }
2459
2460 fn semantics(&self, ctx: &mut SemanticsCtx) {
2461 self.child.semantics(ctx);
2464 }
2465}
2466
2467#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2474pub enum NativeImageFit {
2475 #[default]
2477 Contain,
2478 Cover,
2480 Fill,
2482 Center,
2484}
2485
2486impl NativeImageFit {
2487 fn wire(self) -> &'static str {
2489 match self {
2490 Self::Contain => "contain",
2491 Self::Cover => "cover",
2492 Self::Fill => "fill",
2493 Self::Center => "center",
2494 }
2495 }
2496}
2497
2498#[derive(Clone)]
2503pub struct NativeImageView {
2504 bytes: Arc<[u8]>,
2505 fit: NativeImageFit,
2506 content_description: Option<String>,
2507 size: Option<(f64, f64)>,
2508}
2509
2510pub fn native_image(bytes: Arc<[u8]>) -> NativeImageView {
2512 NativeImageView {
2513 bytes,
2514 fit: NativeImageFit::default(),
2515 content_description: None,
2516 size: None,
2517 }
2518}
2519
2520impl NativeImageView {
2521 pub fn fit(mut self, fit: NativeImageFit) -> Self {
2523 self.fit = fit;
2524 self
2525 }
2526
2527 pub fn content_description(mut self, label: impl Into<String>) -> Self {
2530 self.content_description = Some(label.into());
2531 self
2532 }
2533
2534 pub fn size(mut self, width: f64, height: f64) -> Self {
2537 self.size = Some((width, height));
2538 self
2539 }
2540
2541 fn params_for(&self, slot: SlotId, rev: u64, tokens: Option<ResolvedTheme>) -> String {
2552 let mut body = ParamsBody::new();
2553 body.push_raw(image::REV, rev);
2554 body.push_str(FIT, self.fit.wire());
2555 body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
2556 body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
2557 with_identity(image::KIND, slot, &body.finish())
2558 }
2559
2560 fn build_with_mode(
2561 &self,
2562 slot: SlotId,
2563 mode: ResolvedSurfaceMode,
2564 tokens: Option<ResolvedTheme>,
2565 ) -> AnyView<SlotId> {
2566 if mode.translucency_refused() {
2567 return placeholder(self.size, "Image");
2568 }
2569 let rev = image::publish_bytes(slot, Arc::clone(&self.bytes));
2575 let params = self.params_for(slot, rev, tokens);
2576 let view = platform_view(VIEW_TYPE)
2577 .params_json(params)
2578 .semantics_label(
2579 self.content_description
2580 .clone()
2581 .unwrap_or_else(|| "image".into()),
2582 );
2583 any(resolve_size(self.size, view))
2584 }
2585}
2586
2587impl Component for NativeImageView {
2588 type State = SlotId;
2589
2590 fn init(&self) -> SlotId {
2591 let slot = next_local_slot();
2592 on_cleanup(move || image::retire(slot));
2606 slot
2607 }
2608
2609 fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
2610 self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
2611 }
2612}
2613
2614macro_rules! impl_native_view {
2627 ($ty:ty) => {
2628 impl<Outer: 'static> View<Outer> for $ty {
2629 type Element = ComponentWidget<$ty>;
2630
2631 fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
2632 <frust_core::ComponentView<$ty> as View<Outer>>::build(
2636 &component(self.clone()),
2637 ctx,
2638 )
2639 }
2640
2641 fn rebuild(
2642 &self,
2643 prev: &Self,
2644 element: &mut Self::Element,
2645 ctx: &mut BuildCtx<'_>,
2646 ) -> ChangeFlags {
2647 <frust_core::ComponentView<$ty> as View<Outer>>::rebuild(
2648 &component(self.clone()),
2649 &component(prev.clone()),
2650 element,
2651 ctx,
2652 )
2653 }
2654
2655 fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
2656 <frust_core::ComponentView<$ty> as View<Outer>>::teardown(
2657 &component(self.clone()),
2658 element,
2659 ctx,
2660 );
2661 }
2662 }
2663 };
2664}
2665
2666impl_native_view!(NativeButtonView);
2667impl_native_view!(NativeLabelView);
2668impl_native_view!(NativeSwitchView);
2669impl_native_view!(NativeSliderView);
2670impl_native_view!(NativeProgressView);
2671impl_native_view!(NativeImageView);
2672impl_native_view!(NativeSpinnerView);
2673impl_native_view!(NativeDatePickerView);
2674impl_native_view!(NativeSegmentedView);
2675impl_native_view!(NativeStepperView);
2676impl_native_view!(NativeTabBarView);
2677
2678#[cfg(test)]
2679mod tests {
2680 use super::*;
2681 use frust_core::{BoxConstraints, LayoutCtx, PaintCtx, PaintScene, Widget};
2682 use frust_text::TextContext;
2683 use kurbo::{Point, Rect, Shape, Size};
2684 use peniko::{Brush, Color};
2685 use std::any::Any;
2686
2687 #[derive(Default)]
2691 struct NullScene;
2692
2693 impl PaintScene for NullScene {
2694 fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
2695 fn draw_text(&mut self, _origin: Point, _text: &str) {}
2696 }
2697
2698 fn build_any<State: 'static>(view: AnyView<State>) -> Box<dyn Widget> {
2699 let mut counter = 0u64;
2700 view.build(&mut BuildCtx::new(&mut counter))
2701 }
2702
2703 #[test]
2711 fn button_params_snapshot() {
2712 let view = native_button("Save")
2713 .enabled(false)
2714 .content_description("Save the note");
2715 assert_eq!(
2716 view.params_for(7, None),
2717 "{\"__frustControl\":\"button\",\"__frustSlot\":7,\"text\":\"Save\",\"enabled\":false,\
2718 \"contentDescription\":\"Save the note\",\"dark\":false}"
2719 );
2720 }
2721
2722 #[test]
2723 fn label_params_snapshot() {
2724 let view = native_label("42 fps");
2725 assert_eq!(
2726 view.params_for(3, None),
2727 "{\"__frustControl\":\"label\",\"__frustSlot\":3,\"text\":\"42 fps\",\"enabled\":true,\
2728 \"dark\":false}"
2729 );
2730 }
2731
2732 #[test]
2733 fn switch_params_snapshot() {
2734 let view = native_switch(true).content_description("wifi");
2735 assert_eq!(
2736 view.params_for(11, None),
2737 "{\"__frustControl\":\"switch\",\"__frustSlot\":11,\"checked\":true,\"enabled\":true,\
2738 \"contentDescription\":\"wifi\",\"dark\":false}"
2739 );
2740 }
2741
2742 #[test]
2743 fn slider_params_snapshot() {
2744 let view = native_slider(25, 0, 50);
2745 assert_eq!(
2746 view.params_for(5, None),
2747 "{\"__frustControl\":\"slider\",\"__frustSlot\":5,\"value\":25,\"min\":0,\"max\":50,\
2748 \"enabled\":true,\"dark\":false}"
2749 );
2750 }
2751
2752 #[test]
2753 fn progress_params_snapshot() {
2754 let view = native_progress(30, 10, 110).indeterminate(false);
2755 assert_eq!(
2756 view.params_for(2, None),
2757 "{\"__frustControl\":\"progress\",\"__frustSlot\":2,\"value\":30,\"min\":10,\
2758 \"max\":110,\"indeterminate\":false,\"dark\":false}"
2759 );
2760 }
2761
2762 #[test]
2763 fn image_params_snapshot() {
2764 let view =
2765 native_image(Arc::from(vec![1u8, 2, 3].into_boxed_slice())).fit(NativeImageFit::Cover);
2766 assert_eq!(
2767 view.params_for(900, 42, None),
2768 "{\"__frustControl\":\"image\",\"__frustSlot\":900,\"imageRev\":42,\"fit\":\"cover\",\
2769 \"dark\":false}"
2770 );
2771 }
2772
2773 #[test]
2774 fn spinner_params_snapshot() {
2775 let view = native_spinner(true)
2776 .size_class(NativeSpinnerSize::Large)
2777 .content_description("loading");
2778 assert_eq!(
2779 view.params_for(6, None),
2780 "{\"__frustControl\":\"spinner\",\"__frustSlot\":6,\"animating\":true,\
2781 \"sizeClass\":\"large\",\"enabled\":true,\"contentDescription\":\"loading\",\
2782 \"dark\":false}"
2783 );
2784 }
2785
2786 fn civil(year: i32, month: u8, day: u8) -> CivilDate {
2787 CivilDate::new(year, month, day).expect("a real date")
2788 }
2789
2790 #[test]
2791 fn date_picker_params_snapshot() {
2792 let view = native_date_picker(civil(2026, 9, 29))
2793 .min(civil(2026, 1, 1))
2794 .max(civil(2026, 12, 31))
2795 .style(NativeDatePickerStyle::Inline)
2796 .content_description("due date");
2797 assert_eq!(
2798 view.params_for(12, None),
2799 format!(
2800 "{{\"__frustControl\":\"date_picker\",\"__frustSlot\":12,\"date\":{},\
2801 \"minDate\":{},\"maxDate\":{},\"style\":\"inline\",\"enabled\":true,\
2802 \"contentDescription\":\"due date\",\"dark\":false}}",
2803 (2026 << 16) | (9 << 8) | 29,
2804 (2026 << 16) | (1 << 8) | 1,
2805 (2026 << 16) | (12 << 8) | 31,
2806 )
2807 );
2808 }
2809
2810 #[test]
2811 fn date_picker_params_round_trip_through_the_control_decoder() {
2812 use crate::controls::date_picker::DatePickerProps;
2813 use crate::runtime::Params;
2814
2815 let view = native_date_picker(civil(2024, 2, 29))
2816 .min(civil(2000, 1, 1))
2817 .style(NativeDatePickerStyle::Wheels)
2818 .enabled(false);
2819 let raw = view.params_for(12, Some(dark_tokens()));
2820 let props = DatePickerProps::decode(&Params::new(&raw)).expect("decodes");
2821 assert_eq!(props.date, Some(civil(2024, 2, 29)));
2822 assert_eq!(props.min, Some(civil(2000, 1, 1)));
2823 assert_eq!(props.max, None);
2824 assert!(!props.enabled);
2825 assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
2826 assert_eq!(props.text_color, Some(dark_tokens().body_text as i32));
2827 }
2828
2829 #[test]
2830 fn date_picker_publishes_one_interactive_slot_on_every_target() {
2831 let view = native_date_picker(civil(2026, 9, 29)).size(320.0, 216.0);
2834 let expected_params = view.params_for(4, None);
2835 let built = view.build_with_mode(4, ResolvedSurfaceMode::Opaque, None);
2836 let mut element = build_any(built);
2837 let mut lctx = LayoutCtx::new();
2838 element.layout(&mut lctx, &BoxConstraints::tight(Size::new(320.0, 216.0)));
2839 let mut scene = NullScene;
2840 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(320.0, 216.0));
2841 element.paint(&mut pctx, &mut scene);
2842 let frames = pctx.take_platform_views();
2843 assert_eq!(frames.len(), 1);
2844 assert_eq!(frames[0].view_type, VIEW_TYPE);
2845 assert_eq!(frames[0].params_json, expected_params);
2846 assert!(
2847 frames[0].interactive,
2848 "a date picker slot forwards native input"
2849 );
2850 }
2851
2852 #[test]
2853 fn date_picker_honours_the_translucency_refusal() {
2854 let view = native_date_picker(civil(2026, 9, 29)).build_with_mode(
2855 5,
2856 ResolvedSurfaceMode::RefusedTranslucent,
2857 None,
2858 );
2859 let mut element = build_any(view);
2860 let mut scene = NullScene;
2861 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(320.0, 216.0));
2862 element.paint(&mut pctx, &mut scene);
2863 assert!(pctx.take_platform_views().is_empty());
2864 }
2865
2866 #[test]
2867 fn segmented_params_snapshot() {
2868 let view = native_segmented(vec!["Day".into(), "Week \"W\"".into()], 1)
2869 .momentary(true)
2870 .content_description("range");
2871 assert_eq!(
2872 view.params_for(8, None),
2873 "{\"__frustControl\":\"segmented\",\"__frustSlot\":8,\"segmentCount\":2,\
2874 \"segment0\":\"Day\",\"segment1\":\"Week \\\"W\\\"\",\"selected\":1,\
2875 \"momentary\":true,\"enabled\":true,\"contentDescription\":\"range\",\
2876 \"dark\":false}"
2877 );
2878 }
2879
2880 #[test]
2881 fn segmented_params_round_trip_through_the_control_decoder() {
2882 use crate::controls::segmented::SegmentedProps;
2883 use crate::runtime::Params;
2884
2885 let view = native_segmented(vec!["A".into(), "B".into(), "C".into()], 2).enabled(false);
2886 let raw = view.params_for(8, Some(dark_tokens()));
2887 let props = SegmentedProps::decode(&Params::new(&raw)).expect("decodes");
2888 assert_eq!(props.labels, vec!["A", "B", "C"]);
2889 assert_eq!(props.selected, Some(2));
2890 assert!(!props.enabled);
2891 assert!(!props.momentary);
2892 assert_eq!(props.tint, Some(dark_tokens().accent_fill as i32));
2893 }
2894
2895 #[test]
2896 fn the_segmented_arm_gate_is_exactly_the_apple_targets() {
2897 assert_eq!(
2898 SEGMENTED_ARM,
2899 cfg!(any(target_os = "ios", target_os = "macos")),
2900 "Android (decision D2) and every host target render the banner"
2901 );
2902 assert!(SEGMENTED_UNAVAILABLE_DESCRIPTION.contains("Android"));
2903 assert!(SEGMENTED_UNAVAILABLE_DESCRIPTION.contains("follow-up"));
2904 }
2905
2906 #[test]
2907 fn segmented_without_a_platform_arm_paints_the_banner_and_publishes_no_slot() {
2908 for mode in [
2911 ResolvedSurfaceMode::Unknown,
2912 ResolvedSurfaceMode::Opaque,
2913 ResolvedSurfaceMode::Translucent,
2914 ResolvedSurfaceMode::RefusedTranslucent,
2915 ] {
2916 let view = native_segmented(vec!["A".into(), "B".into()], 0)
2917 .size(240.0, 60.0)
2918 .build_for_arm(3, mode, None, false);
2919 let mut element = build_any(view);
2920 let mut text_ctx = TextContext::new();
2921 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
2922 let laid = element.layout(&mut lctx, &BoxConstraints::tight(Size::new(240.0, 60.0)));
2923 let mut rec = BoundsRecorder::default();
2924 let mut pctx = PaintCtx::new(Point::ZERO, laid);
2925 element.paint(&mut pctx, &mut rec);
2926 assert!(
2927 pctx.take_platform_views().is_empty(),
2928 "{mode:?}: no arm must publish no native platform_view frame"
2929 );
2930 assert!(
2931 rec.glyph_runs >= 2,
2932 "{mode:?}: the banner's label and description must paint, saw {} runs",
2933 rec.glyph_runs
2934 );
2935 }
2936 }
2937
2938 #[test]
2939 fn segmented_with_a_platform_arm_publishes_one_interactive_slot() {
2940 let view = native_segmented(vec!["A".into(), "B".into()], 1).size(200.0, 32.0);
2941 let expected_params = view.params_for(4, None);
2942 let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
2943 let mut element = build_any(built);
2944 let mut lctx = LayoutCtx::new();
2945 element.layout(&mut lctx, &BoxConstraints::tight(Size::new(200.0, 32.0)));
2946 let mut scene = NullScene;
2947 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 32.0));
2948 element.paint(&mut pctx, &mut scene);
2949 let frames = pctx.take_platform_views();
2950 assert_eq!(frames.len(), 1);
2951 assert_eq!(frames[0].view_type, VIEW_TYPE);
2952 assert_eq!(frames[0].params_json, expected_params);
2953 assert!(
2954 frames[0].interactive,
2955 "a segmented slot forwards native input"
2956 );
2957 }
2958
2959 #[test]
2960 fn segmented_with_an_arm_still_honours_the_translucency_refusal() {
2961 let view = native_segmented(vec!["A".into()], 0).build_for_arm(
2962 5,
2963 ResolvedSurfaceMode::RefusedTranslucent,
2964 None,
2965 true,
2966 );
2967 let mut element = build_any(view);
2968 let mut scene = NullScene;
2969 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 32.0));
2970 element.paint(&mut pctx, &mut scene);
2971 assert!(pctx.take_platform_views().is_empty());
2972 }
2973
2974 #[test]
2975 fn stepper_params_snapshot() {
2976 let view = native_stepper(4, 0, 10)
2977 .step(2)
2978 .wraps(true)
2979 .content_description("count");
2980 assert_eq!(
2981 view.params_for(8, None),
2982 "{\"__frustControl\":\"stepper\",\"__frustSlot\":8,\"value\":4,\"min\":0,\"max\":10,\
2983 \"step\":2,\"wraps\":true,\"enabled\":true,\"contentDescription\":\"count\",\
2984 \"dark\":false}"
2985 );
2986 }
2987
2988 #[test]
2989 fn stepper_params_round_trip_through_the_control_decoder() {
2990 use crate::controls::stepper::StepperProps;
2991 use crate::runtime::Params;
2992
2993 let view = native_stepper(3, 0, 20).step(5).wraps(true).enabled(false);
2994 let raw = view.params_for(8, Some(dark_tokens()));
2995 let props = StepperProps::decode(&Params::new(&raw)).expect("decodes");
2996 assert_eq!(props.value, 3);
2997 assert_eq!(props.step, 5);
2998 assert!(props.wraps);
2999 assert!(!props.enabled);
3000 assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
3001 }
3002
3003 #[test]
3004 fn a_non_positive_step_mounts_as_one_through_the_control_decoder() {
3005 use crate::controls::stepper::StepperProps;
3006 use crate::runtime::Params;
3007
3008 let view = native_stepper(3, 0, 20).step(0);
3009 let raw = view.params_for(8, None);
3010 assert!(
3011 raw.contains("\"step\":0"),
3012 "the builder still sends the app's raw value over the wire — \
3013 normalization is the control's job, not the builder's"
3014 );
3015 let props = StepperProps::decode(&Params::new(&raw)).expect("decodes");
3016 assert_eq!(props.step, 1, "a non-positive step mounts as 1");
3017 }
3018
3019 #[test]
3020 fn the_stepper_arm_gate_is_exactly_the_apple_targets() {
3021 assert_eq!(
3022 STEPPER_ARM,
3023 cfg!(any(target_os = "ios", target_os = "macos")),
3024 "Android and every host target render the banner"
3025 );
3026 assert!(STEPPER_UNAVAILABLE_DESCRIPTION.contains("Android"));
3027 assert!(STEPPER_UNAVAILABLE_DESCRIPTION.contains("follow-up"));
3028 }
3029
3030 #[test]
3031 fn stepper_without_a_platform_arm_paints_the_banner_and_publishes_no_slot() {
3032 for mode in [
3035 ResolvedSurfaceMode::Unknown,
3036 ResolvedSurfaceMode::Opaque,
3037 ResolvedSurfaceMode::Translucent,
3038 ResolvedSurfaceMode::RefusedTranslucent,
3039 ] {
3040 let view = native_stepper(0, 0, 10)
3041 .size(120.0, 40.0)
3042 .build_for_arm(3, mode, None, false);
3043 let mut element = build_any(view);
3044 let mut text_ctx = TextContext::new();
3045 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3046 let laid = element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 40.0)));
3047 let mut rec = BoundsRecorder::default();
3048 let mut pctx = PaintCtx::new(Point::ZERO, laid);
3049 element.paint(&mut pctx, &mut rec);
3050 assert!(
3051 pctx.take_platform_views().is_empty(),
3052 "{mode:?}: no arm must publish no native platform_view frame"
3053 );
3054 assert!(
3055 rec.glyph_runs >= 2,
3056 "{mode:?}: the banner's label and description must paint, saw {} runs",
3057 rec.glyph_runs
3058 );
3059 }
3060 }
3061
3062 #[test]
3063 fn stepper_with_a_platform_arm_publishes_one_interactive_slot() {
3064 let view = native_stepper(2, 0, 10).size(94.0, 29.0);
3065 let expected_params = view.params_for(4, None);
3066 let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
3067 let mut element = build_any(built);
3068 let mut lctx = LayoutCtx::new();
3069 element.layout(&mut lctx, &BoxConstraints::tight(Size::new(94.0, 29.0)));
3070 let mut scene = NullScene;
3071 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(94.0, 29.0));
3072 element.paint(&mut pctx, &mut scene);
3073 let frames = pctx.take_platform_views();
3074 assert_eq!(frames.len(), 1);
3075 assert_eq!(frames[0].view_type, VIEW_TYPE);
3076 assert_eq!(frames[0].params_json, expected_params);
3077 assert!(
3078 frames[0].interactive,
3079 "a stepper slot forwards native input"
3080 );
3081 }
3082
3083 #[test]
3084 fn stepper_with_an_arm_still_honours_the_translucency_refusal() {
3085 let view = native_stepper(0, 0, 10).build_for_arm(
3086 5,
3087 ResolvedSurfaceMode::RefusedTranslucent,
3088 None,
3089 true,
3090 );
3091 let mut element = build_any(view);
3092 let mut scene = NullScene;
3093 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(94.0, 29.0));
3094 element.paint(&mut pctx, &mut scene);
3095 assert!(pctx.take_platform_views().is_empty());
3096 }
3097
3098 fn symbol(name: &str) -> TabIcon {
3101 TabIcon::AppleSymbol(name.into())
3102 }
3103
3104 fn two_tabs() -> Vec<TabItem> {
3105 vec![
3106 TabItem::new("home", "Home", symbol("house")).selected_icon(symbol("house.fill")),
3107 TabItem::new("inbox", "Inbox", symbol("tray"))
3108 .badge("3")
3109 .enabled(false),
3110 ]
3111 }
3112
3113 fn layout_with_bottom_inset(
3117 element: &mut Box<dyn Widget>,
3118 bc: &BoxConstraints,
3119 bottom: f64,
3120 ) -> Size {
3121 use frust_core::{WindowEdgeInsets, WindowInsets};
3122 let insets = WindowInsets::new(
3123 WindowEdgeInsets::new(0.0, 0.0, 0.0, bottom),
3124 WindowEdgeInsets::ZERO,
3125 );
3126 let mut text_ctx = TextContext::new();
3127 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3128 lctx.with_window_insets(insets, |ctx| element.layout(ctx, bc))
3129 }
3130
3131 #[test]
3132 fn tab_bar_params_snapshot() {
3133 let view = native_tab_bar(two_tabs(), "inbox");
3134 assert_eq!(
3135 view.params_for(8, None, 0),
3136 "{\"__frustControl\":\"tab_bar\",\"__frustSlot\":8,\"itemCount\":2,\
3137 \"tab0Title\":\"Home\",\"tab0Enabled\":true,\"tab0Symbol\":\"house\",\
3138 \"tab0SelectedSymbol\":\"house.fill\",\"tab1Title\":\"Inbox\",\
3139 \"tab1Enabled\":false,\"tab1Badge\":\"3\",\"tab1Symbol\":\"tray\",\
3140 \"selected\":1,\"iconsRev\":0,\"dark\":false}"
3141 );
3142 }
3143
3144 #[test]
3145 fn an_unknown_selected_id_encodes_no_selection() {
3146 let raw = native_tab_bar(two_tabs(), "settings").params_for(8, None, 0);
3147 assert!(!raw.contains("\"selected\""), "{raw}");
3148 }
3149
3150 #[test]
3151 fn tab_bar_params_round_trip_through_the_control_decoder() {
3152 use crate::controls::tab_bar::{IconSource, TabBarProps};
3153 use crate::runtime::Params;
3154
3155 let bytes: Arc<[u8]> = Arc::from(vec![9u8, 9, 9].into_boxed_slice());
3156 let view = native_tab_bar(
3157 vec![
3158 TabItem::new("a", "A", TabIcon::Bytes(Arc::clone(&bytes))),
3159 TabItem::new("b", "B", symbol("gear")),
3160 ],
3161 "b",
3162 );
3163 let slot = 7_701;
3164 let rev = tab_bar::publish_icon_bytes(slot, view.icon_bytes());
3165 let raw = view.params_for(slot, Some(dark_tokens()), rev);
3166 let props = TabBarProps::decode(&Params::new(&raw)).expect("decodes");
3167 assert_eq!(props.items.len(), 2);
3168 assert_eq!(props.items[0].icon, Some(IconSource::Bytes(bytes)));
3169 assert_eq!(props.items[1].icon, Some(IconSource::Symbol("gear".into())));
3170 assert_eq!(props.selected, Some(1));
3171 assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
3172 assert_eq!(props.unselected_tint, Some(dark_tokens().muted as i32));
3173 assert_eq!(props.background, Some(dark_tokens().surface_bg as i32));
3174 tab_bar::retire_icon_bytes(slot);
3175 }
3176
3177 #[test]
3178 fn tab_bar_folds_accent_ink_muted_and_surface_from_the_theme() {
3179 let tokens = light_tokens();
3180 let raw = native_tab_bar(two_tabs(), "home").params_for(3, Some(tokens), 0);
3181 assert!(raw.ends_with(&format!(
3182 "\"dark\":false,\"tint\":{},\"unselectedTint\":{},\"backgroundColor\":{}}}",
3183 tokens.accent_ink, tokens.muted, tokens.surface_bg
3184 )));
3185 }
3186
3187 #[test]
3188 fn the_tab_bar_arm_gate_is_exactly_ios() {
3189 assert_eq!(
3190 TAB_BAR_ARM,
3191 cfg!(target_os = "ios"),
3192 "macOS (no idiom), Android (Material, D2) and every host render the banner"
3193 );
3194 assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("macOS"));
3195 assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("Android"));
3196 assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("Material"));
3197 }
3198
3199 #[test]
3200 fn tab_bar_without_a_platform_arm_paints_the_banner_in_the_inset_aware_slot() {
3201 for mode in [
3202 ResolvedSurfaceMode::Unknown,
3203 ResolvedSurfaceMode::Opaque,
3204 ResolvedSurfaceMode::RefusedTranslucent,
3205 ] {
3206 let view = native_tab_bar(two_tabs(), "home").build_for_arm(3, mode, None, false);
3207 let mut element = build_any(view);
3208 let laid = layout_with_bottom_inset(
3209 &mut element,
3210 &BoxConstraints::new(Size::ZERO, Size::new(390.0, f64::INFINITY)),
3211 34.0,
3212 );
3213 assert_eq!(laid, Size::new(390.0, 49.0 + 34.0), "{mode:?}");
3214 let mut rec = BoundsRecorder::default();
3215 let mut pctx = PaintCtx::new(Point::ZERO, laid);
3216 element.paint(&mut pctx, &mut rec);
3217 assert!(
3218 pctx.take_platform_views().is_empty(),
3219 "{mode:?}: no arm must publish no native platform_view frame"
3220 );
3221 assert!(rec.glyph_runs >= 1, "{mode:?}: the banner must paint");
3222 }
3223 }
3224
3225 #[test]
3226 fn tab_bar_with_a_platform_arm_publishes_one_interactive_slot_under_the_home_indicator() {
3227 let view = native_tab_bar(two_tabs(), "home");
3228 let expected_params = view.params_for(4, None, 0);
3229 let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
3230 let mut element = build_any(built);
3231 let laid = layout_with_bottom_inset(
3232 &mut element,
3233 &BoxConstraints::new(Size::ZERO, Size::new(390.0, 844.0)),
3234 34.0,
3235 );
3236 assert_eq!(
3237 laid,
3238 Size::new(390.0, 83.0),
3239 "49pt plus the 34pt home-indicator inset, full width"
3240 );
3241 let mut scene = NullScene;
3242 let mut pctx = PaintCtx::new(Point::ZERO, laid);
3243 element.paint(&mut pctx, &mut scene);
3244 let frames = pctx.take_platform_views();
3245 assert_eq!(frames.len(), 1);
3246 assert_eq!(frames[0].view_type, VIEW_TYPE);
3247 assert_eq!(frames[0].params_json, expected_params);
3248 assert!(
3249 frames[0].interactive,
3250 "a tab bar slot forwards native input"
3251 );
3252 }
3253
3254 #[test]
3255 fn an_opted_out_tab_bar_is_the_bare_bar_height() {
3256 let built = native_tab_bar(two_tabs(), "home")
3257 .safe_area(false)
3258 .build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
3259 let mut element = build_any(built);
3260 let laid = layout_with_bottom_inset(
3261 &mut element,
3262 &BoxConstraints::new(Size::ZERO, Size::new(320.0, 600.0)),
3263 34.0,
3264 );
3265 assert_eq!(laid, Size::new(320.0, 49.0));
3266 }
3267
3268 #[test]
3269 fn tab_bar_with_an_arm_still_honours_the_translucency_refusal() {
3270 let view = native_tab_bar(two_tabs(), "home").build_for_arm(
3271 5,
3272 ResolvedSurfaceMode::RefusedTranslucent,
3273 None,
3274 true,
3275 );
3276 let mut element = build_any(view);
3277 let mut scene = NullScene;
3278 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(390.0, 49.0));
3279 element.paint(&mut pctx, &mut scene);
3280 assert!(pctx.take_platform_views().is_empty());
3281 }
3282
3283 fn dark_tokens() -> ResolvedTheme {
3287 theme::resolve(&Theme::neutral().with_brightness(frust::Brightness::Dark))
3288 }
3289
3290 fn light_tokens() -> ResolvedTheme {
3291 theme::resolve(&Theme::neutral().with_brightness(frust::Brightness::Light))
3292 }
3293
3294 #[test]
3295 fn button_folds_background_text_radius_and_size_from_a_dark_theme() {
3296 let view = native_button("Save");
3297 let tokens = dark_tokens();
3298 assert_eq!(
3299 view.params_for(7, Some(tokens)),
3300 format!(
3301 "{{\"__frustControl\":\"button\",\"__frustSlot\":7,\"text\":\"Save\",\"enabled\":\
3302 true,\"dark\":true,\"textColor\":{},\"backgroundColor\":{},\"cornerRadiusDp\":{},\
3303 \"textSizeSp\":{},\"typeface\":\"{}\"}}",
3304 tokens.on_accent_fill,
3305 tokens.accent_fill,
3306 tokens.corner_radius_dp,
3307 tokens.button_text_size_sp,
3308 tokens.button_typeface.wire()
3309 )
3310 );
3311 }
3312
3313 #[test]
3314 fn button_folds_a_light_theme_distinctly_from_dark() {
3315 let view = native_button("Save");
3316 let dark = view.params_for(7, Some(dark_tokens()));
3317 let light = view.params_for(7, Some(light_tokens()));
3318 assert_ne!(
3319 dark, light,
3320 "a brightness flip must reach the folded params — both the `dark` \
3321 flag and every colour role the button folds change with it"
3322 );
3323 assert!(light.contains("\"dark\":false"));
3324 assert!(dark.contains("\"dark\":true"));
3325 }
3326
3327 #[test]
3328 fn label_folds_body_text_colour_and_size() {
3329 let view = native_label("42 fps");
3330 let tokens = dark_tokens();
3331 assert_eq!(
3332 view.params_for(3, Some(tokens)),
3333 format!(
3334 "{{\"__frustControl\":\"label\",\"__frustSlot\":3,\"text\":\"42 fps\",\"enabled\":\
3335 true,\"dark\":true,\"backgroundColor\":{},\"textColor\":{},\"textSizeSp\":{},\
3336 \"typeface\":\"{}\"}}",
3337 tokens.surface_bg,
3338 tokens.body_text,
3339 tokens.body_text_size_sp,
3340 tokens.body_typeface.wire()
3341 )
3342 );
3343 }
3344
3345 #[test]
3346 fn switch_folds_thumb_and_track_tint_but_never_a_background() {
3347 let view = native_switch(true);
3353 let tokens = dark_tokens();
3354 let params = view.params_for(11, Some(tokens));
3355 assert_eq!(
3356 params,
3357 format!(
3358 "{{\"__frustControl\":\"switch\",\"__frustSlot\":11,\"checked\":true,\"enabled\":\
3359 true,\"dark\":true,\"thumbTint\":{},\"trackTint\":{},\
3360 \"typeface\":\"{}\"}}",
3361 tokens.accent_ink,
3362 tokens.accent_fill,
3363 tokens.body_typeface.wire()
3364 )
3365 );
3366 assert!(
3367 !params.contains("backgroundColor"),
3368 "Switch must never fold an explicit background — it would defeat the ripple"
3369 );
3370 }
3371
3372 #[test]
3373 fn slider_folds_progress_and_thumb_tint_but_never_a_background() {
3374 let view = native_slider(25, 0, 50);
3377 let tokens = dark_tokens();
3378 let params = view.params_for(5, Some(tokens));
3379 assert_eq!(
3380 params,
3381 format!(
3382 "{{\"__frustControl\":\"slider\",\"__frustSlot\":5,\"value\":25,\"min\":0,\"max\":\
3383 50,\"enabled\":true,\"dark\":true,\"progressTint\":{},\
3384 \"thumbTint\":{}}}",
3385 tokens.accent_fill, tokens.accent_ink
3386 )
3387 );
3388 assert!(
3389 !params.contains("backgroundColor"),
3390 "Slider must never fold an explicit background — it would defeat the ripple"
3391 );
3392 }
3393
3394 #[test]
3395 fn progress_folds_progress_tint_only() {
3396 let view = native_progress(30, 10, 110);
3397 let tokens = dark_tokens();
3398 assert_eq!(
3399 view.params_for(2, Some(tokens)),
3400 format!(
3401 "{{\"__frustControl\":\"progress\",\"__frustSlot\":2,\"value\":30,\"min\":10,\
3402 \"max\":110,\"indeterminate\":false,\"dark\":true,\"backgroundColor\":{},\
3403 \"progressTint\":{}}}",
3404 tokens.surface_bg, tokens.accent_fill
3405 )
3406 );
3407 }
3408
3409 #[test]
3410 fn image_folds_only_the_dark_flag_never_a_tint() {
3411 let view = native_image(Arc::from(vec![1u8].into_boxed_slice()));
3415 assert_eq!(
3416 view.params_for(900, 42, Some(dark_tokens())),
3417 "{\"__frustControl\":\"image\",\"__frustSlot\":900,\"imageRev\":42,\"fit\":\"contain\",\
3418 \"dark\":true}"
3419 );
3420 }
3421
3422 #[test]
3423 fn spinner_folds_accent_ink_as_its_tint() {
3424 let view = native_spinner(false);
3425 let tokens = dark_tokens();
3426 assert_eq!(
3427 view.params_for(6, Some(tokens)),
3428 format!(
3429 "{{\"__frustControl\":\"spinner\",\"__frustSlot\":6,\"animating\":false,\
3430 \"sizeClass\":\"medium\",\"enabled\":true,\"dark\":true,\"tint\":{}}}",
3431 tokens.accent_ink
3432 )
3433 );
3434 }
3435
3436 #[test]
3437 fn stepper_folds_accent_ink_as_its_tint() {
3438 let view = native_stepper(0, 0, 10);
3444 let tokens = dark_tokens();
3445 assert_eq!(
3446 view.params_for(6, Some(tokens)),
3447 format!(
3448 "{{\"__frustControl\":\"stepper\",\"__frustSlot\":6,\"value\":0,\"min\":0,\
3449 \"max\":10,\"step\":1,\"wraps\":false,\"enabled\":true,\"dark\":true,\"tint\":{}}}",
3450 tokens.accent_ink
3451 )
3452 );
3453 }
3454
3455 #[test]
3456 fn date_picker_folds_accent_ink_tint_and_body_text_colour() {
3457 let view = native_date_picker(civil(2026, 9, 29));
3458 let tokens = dark_tokens();
3459 assert_eq!(
3460 view.params_for(6, Some(tokens)),
3461 format!(
3462 "{{\"__frustControl\":\"date_picker\",\"__frustSlot\":6,\"date\":{},\
3463 \"style\":\"compact\",\"enabled\":true,\"dark\":true,\"tint\":{},\
3464 \"textColor\":{}}}",
3465 (2026 << 16) | (9 << 8) | 29,
3466 tokens.accent_ink,
3467 tokens.body_text
3468 )
3469 );
3470 }
3471
3472 #[test]
3476 fn an_unchanged_theme_yields_partial_eq_equal_button_props() {
3477 use crate::controls::button::ButtonProps;
3478 use crate::runtime::Params;
3479
3480 let view = native_button("Save").content_description("Save the note");
3481 let tokens = dark_tokens();
3482 let a = ButtonProps::decode(&Params::new(&view.params_for(7, Some(tokens)))).unwrap();
3483 let b = ButtonProps::decode(&Params::new(&view.params_for(7, Some(tokens)))).unwrap();
3484 assert_eq!(
3485 a, b,
3486 "the SAME resolved theme, folded twice, must decode to PartialEq-equal \
3487 Props — this is what keeps the runtime's diff gate from crossing the FFI \
3488 boundary on a rebuild the theme didn't actually change"
3489 );
3490
3491 let c =
3493 ButtonProps::decode(&Params::new(&view.params_for(7, Some(light_tokens())))).unwrap();
3494 assert_ne!(a, c);
3495 }
3496
3497 #[test]
3500 fn a_refused_slot_publishes_no_platform_view_frame() {
3501 for (name, view) in [
3502 (
3503 "Button",
3504 native_button("Save").build_with_mode(
3505 1,
3506 ResolvedSurfaceMode::RefusedTranslucent,
3507 None,
3508 ),
3509 ),
3510 (
3511 "Switch",
3512 native_switch(true).build_with_mode(
3513 2,
3514 ResolvedSurfaceMode::RefusedTranslucent,
3515 None,
3516 ),
3517 ),
3518 ] {
3519 let mut element = build_any(view);
3520 let mut scene = NullScene;
3521 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
3522 element.paint(&mut pctx, &mut scene);
3523 assert!(
3524 pctx.take_platform_views().is_empty(),
3525 "{name}: a refused slot must render no native platform_view frame"
3526 );
3527 }
3528 }
3529
3530 #[test]
3531 fn an_unrefused_slot_still_builds_the_native_platform_view() {
3532 for mode in [
3533 ResolvedSurfaceMode::Unknown,
3534 ResolvedSurfaceMode::Opaque,
3535 ResolvedSurfaceMode::Translucent,
3536 ] {
3537 let btn = native_button("Save").size(120.0, 44.0);
3538 let expected_params = btn.params_for(9, None);
3539 let view = btn.build_with_mode(9, mode, None);
3540 let mut element = build_any(view);
3541 let mut lctx = LayoutCtx::new();
3542 element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 44.0)));
3543 let mut scene = NullScene;
3544 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
3545 element.paint(&mut pctx, &mut scene);
3546 let frames = pctx.take_platform_views();
3547 assert_eq!(frames.len(), 1, "{mode:?}");
3548 assert_eq!(frames[0].view_type, VIEW_TYPE);
3549 assert_eq!(frames[0].params_json, expected_params);
3550 assert!(frames[0].interactive, "a Button slot is interactive");
3551 }
3552 }
3553
3554 #[test]
3555 fn a_display_only_control_never_sets_interactive() {
3556 let view = native_label("hi").build_with_mode(4, ResolvedSurfaceMode::Opaque, None);
3557 let mut element = build_any(view);
3558 let mut scene = NullScene;
3559 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 30.0));
3560 element.paint(&mut pctx, &mut scene);
3561 let frames = pctx.take_platform_views();
3562 assert_eq!(frames.len(), 1);
3563 assert!(!frames[0].interactive, "Label never forwards native input");
3564 }
3565
3566 #[derive(Default)]
3578 struct BoundsRecorder {
3579 clip_stack: Vec<Rect>,
3580 painted: Vec<Rect>,
3583 glyph_runs: usize,
3589 }
3590
3591 impl BoundsRecorder {
3592 fn record(&mut self, rect: Rect) {
3593 let bounded = match self.clip_stack.last() {
3594 Some(clip) => rect.intersect(*clip),
3595 None => rect,
3596 };
3597 if bounded.width() > 0.0 && bounded.height() > 0.0 {
3600 self.painted.push(bounded);
3601 }
3602 }
3603 }
3604
3605 impl PaintScene for BoundsRecorder {
3606 fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
3607 self.record(Rect::from_origin_size(origin, size));
3608 }
3609
3610 fn draw_text(&mut self, _origin: Point, _text: &str) {}
3611
3612 fn fill_rounded_rect(&mut self, origin: Point, size: Size, _radius: f64, _color: Color) {
3613 self.record(Rect::from_origin_size(origin, size));
3614 }
3615
3616 fn stroke_path(
3617 &mut self,
3618 origin: Point,
3619 path: &kurbo::BezPath,
3620 width: f64,
3621 _brush: &Brush,
3622 ) {
3623 let bbox = path.bounding_box() + origin.to_vec2();
3624 self.record(bbox.inflate(width, width));
3627 }
3628
3629 fn push_clip(&mut self, origin: Point, size: Size) {
3630 let rect = Rect::from_origin_size(origin, size);
3631 let bounded = match self.clip_stack.last() {
3632 Some(prev) => rect.intersect(*prev),
3633 None => rect,
3634 };
3635 self.clip_stack.push(bounded);
3636 }
3637
3638 fn pop_clip(&mut self) {
3639 self.clip_stack.pop();
3640 }
3641
3642 fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
3643 let Some(first) = run.glyphs.first() else {
3644 return;
3645 };
3646 self.glyph_runs += 1;
3647 let base = run.transform.translation();
3656 let margin = run.font_size as f64;
3657 let (min_x, max_x) = run
3658 .glyphs
3659 .iter()
3660 .map(|g| g.x as f64)
3661 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| {
3662 (lo.min(x), hi.max(x))
3663 });
3664 let rect = Rect::new(
3665 base.x + min_x - margin,
3666 base.y + first.y as f64 - margin,
3667 base.x + max_x + margin,
3668 base.y + first.y as f64 + margin,
3669 );
3670 self.record(rect);
3671 }
3672 }
3673
3674 fn rect_fits_within(outer: Rect, inner: Rect) -> bool {
3675 const TOLERANCE: f64 = 0.01;
3676 inner.x0 >= outer.x0 - TOLERANCE
3677 && inner.y0 >= outer.y0 - TOLERANCE
3678 && inner.x1 <= outer.x1 + TOLERANCE
3679 && inner.y1 <= outer.y1 + TOLERANCE
3680 }
3681
3682 #[test]
3683 fn placeholder_paints_visible_text_within_its_slot_rect_at_every_slot_size() {
3684 for (label, (w, h)) in [
3689 ("native_switch's own box (70x40)", (70.0, 40.0)),
3690 ("native_progress's own box (260x24)", (260.0, 24.0)),
3691 ("native_button's own box (160x48)", (160.0, 48.0)),
3692 ("a deliberately tiny box (40x16)", (40.0, 16.0)),
3693 ] {
3694 let view: AnyView<()> = placeholder(Some((w, h)), "Switch");
3695 let mut element = build_any(view);
3696
3697 let mut text_ctx = TextContext::new();
3698 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3699 let bc = BoxConstraints::tight(Size::new(w, h));
3700 let laid = element.layout(&mut lctx, &bc);
3701 assert_eq!(
3702 laid,
3703 Size::new(w, h),
3704 "{label}: the placeholder's own reported size must stay exactly the \
3705 slot's declared size"
3706 );
3707
3708 let mut rec = BoundsRecorder::default();
3709 let mut pctx = PaintCtx::new(Point::ZERO, laid);
3710 element.paint(&mut pctx, &mut rec);
3711
3712 assert!(
3713 !rec.painted.is_empty(),
3714 "{label}: expected the placeholder to paint something"
3715 );
3716 assert!(
3720 rec.glyph_runs >= 2,
3721 "{label}: expected the banner's label AND description to paint as glyph \
3722 runs, saw {} — a fill-only banner leaves a sighted user with no warning",
3723 rec.glyph_runs
3724 );
3725 let slot_rect = Rect::from_origin_size(Point::ZERO, laid);
3728 for bounds in &rec.painted {
3729 assert!(
3730 rect_fits_within(slot_rect, *bounds),
3731 "{label}: painted bounds {bounds:?} escaped the slot rect {slot_rect:?}"
3732 );
3733 }
3734 }
3735 }
3736
3737 #[test]
3742 fn a_refused_slot_still_publishes_no_platform_view_frame_through_the_clip_wrapper() {
3743 let view =
3744 native_switch(true).build_with_mode(2, ResolvedSurfaceMode::RefusedTranslucent, None);
3745 let mut element = build_any(view);
3746 let mut text_ctx = TextContext::new();
3747 let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3748 element.layout(&mut lctx, &BoxConstraints::tight(Size::new(70.0, 40.0)));
3749 let mut rec = BoundsRecorder::default();
3750 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(70.0, 40.0));
3751 element.paint(&mut pctx, &mut rec);
3752 assert!(
3753 pctx.take_platform_views().is_empty(),
3754 "a refused slot must render no native platform_view frame, even through the \
3755 clip wrapper"
3756 );
3757 }
3758}