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