use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Once};
use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
use frust::{
Color, PlatformViewView, ResolvedSurfaceMode, SizedBox, Theme, on_cleanup, platform_view,
resolved_surface_mode, use_context,
};
use frust_core::accesskit::Role;
use frust_core::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, Component, ComponentWidget, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
component,
};
use kurbo::{Size, Vec2};
use crate::controls::date_picker::{self, CivilDate};
use crate::controls::{
BACKGROUND_COLOR, CHECKED, CONTENT_DESCRIPTION, CORNER_RADIUS_DP, DARK, ENABLED, FIT,
INDETERMINATE, MAX, MIN, PROGRESS_TINT, STEP, TEXT, TEXT_COLOR, TEXT_SIZE_SP, THUMB_TINT, TINT,
TRACK_TINT, TYPEFACE, VALUE, WRAPS,
};
use crate::controls::{
button, image, label, progress, segmented, slider, spinner, stepper, switch, tab_bar,
};
use crate::registry::SlotId;
use crate::runtime::{escape, with_identity, with_runtime};
use super::signals::{
TabHandler, on_click, on_date, on_selected, on_tab_bar, on_toggled, on_value_changed,
};
use super::theme::{self, ResolvedTheme};
#[cfg(target_os = "android")]
pub(super) const VIEW_TYPE: &str = "dev.frust.nativewidgets.FrustNativeControlFactory";
#[cfg(target_os = "ios")]
pub(super) const VIEW_TYPE: &str = "FrustNativeControlFactory";
#[cfg(target_os = "macos")]
pub(super) const VIEW_TYPE: &str = crate::appkit::factory::VIEW_TYPE;
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
pub(super) const VIEW_TYPE: &str = "dev.frust.nativewidgets.FrustNativeControlFactory";
pub(super) fn next_local_slot() -> SlotId {
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
static REFUSAL_LOGGED: Once = Once::new();
fn warn_refusal_once() {
REFUSAL_LOGGED.call_once(|| {
log::warn!(
"frust-native-widgets: the host declared a translucent surface but the platform \
refused it (ResolvedSurfaceMode::RefusedTranslucent) — rendering frust-drawn \
placeholders instead of native controls; see docs/ARCHITECTURE.md's Platform-view \
flow"
);
});
}
pub(super) fn placeholder<State: 'static>(
size: Option<(f64, f64)>,
control: &str,
) -> AnyView<State> {
warn_refusal_once();
banner_placeholder(
size,
format!("Native {control} unavailable"),
"the host declared a translucent surface but the platform refused it — rendering a \
frust placeholder instead of an invisible native slot."
.to_string(),
)
}
fn banner_placeholder<State: 'static>(
size: Option<(f64, f64)>,
label: String,
description: String,
) -> AnyView<State> {
let banner: AnyView<State> = any(RefusalBanner { label, description });
let sized = SizedBox(size.map(|(w, _)| w), size.map(|(_, h)| h)).child(banner);
any(ClipToSlot { child: any(sized) })
}
const REFUSAL_FILL: Color = Color::from_rgb8(0xFF, 0xDD, 0xB0);
const REFUSAL_BORDER: Color = Color::from_rgb8(0x8A, 0x53, 0x00);
const REFUSAL_TEXT: Color = Color::from_rgb8(0x3B, 0x24, 0x00);
const REFUSAL_PAD: f64 = 4.0;
const REFUSAL_ROW_GAP: f64 = 2.0;
const REFUSAL_LABEL_SIZE: f32 = 12.0;
const REFUSAL_DESC_SIZE: f32 = 11.0;
const REFUSAL_LINE_HEIGHT: f32 = 1.3;
struct BannerText {
content: String,
layout: Option<TextLayout>,
laid_out_style: Option<TextStyle>,
laid_out_max_width: Option<f32>,
}
impl BannerText {
fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
layout: None,
laid_out_style: None,
laid_out_max_width: None,
}
}
fn set_content(&mut self, content: impl Into<String>) {
let content = content.into();
if self.content != content {
self.content = content;
self.layout = None;
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f32>) -> Size {
if let Some(cached) = &self.layout
&& self.laid_out_max_width == max_width
&& self.laid_out_style.as_ref() == Some(style)
{
return cached.size();
}
let laid = ctx
.text_context::<TextContext>()
.layout(&self.content, style, max_width);
let size = laid.size();
self.layout = Some(laid);
self.laid_out_style = Some(style.clone());
self.laid_out_max_width = max_width;
size
}
fn paint(&self, origin: kurbo::Point, scene: &mut dyn PaintScene) {
if let Some(layout) = &self.layout {
for run in layout.to_scene_runs(origin) {
scene.draw_glyph_run(run);
}
}
}
}
fn refusal_label_style(color: Color) -> TextStyle {
TextStyle {
weight: FontWeight::SEMI_BOLD,
line_height: LineHeight::FontSizeRelative(REFUSAL_LINE_HEIGHT),
..TextStyle::new(REFUSAL_LABEL_SIZE, color)
}
}
fn refusal_description_style(color: Color) -> TextStyle {
TextStyle {
weight: FontWeight::REGULAR,
line_height: LineHeight::FontSizeRelative(REFUSAL_LINE_HEIGHT),
..TextStyle::new(REFUSAL_DESC_SIZE, color)
}
}
fn refusal_text_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_error_container,
None => REFUSAL_TEXT,
}
}
struct RefusalBanner {
label: String,
description: String,
}
struct RefusalBannerWidget {
label: String,
description: String,
label_run: BannerText,
description_run: BannerText,
label_size: Size,
}
impl<State: 'static> View<State> for RefusalBanner {
type Element = RefusalBannerWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> Self::Element {
RefusalBannerWidget {
label: self.label.clone(),
description: self.description.clone(),
label_run: BannerText::new(self.label.clone()),
description_run: BannerText::new(self.description.clone()),
label_size: Size::ZERO,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut Self::Element,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
if prev.label == self.label && prev.description == self.description {
return ChangeFlags::NONE;
}
element.label = self.label.clone();
element.description = self.description.clone();
element.label_run.set_content(self.label.clone());
element
.description_run
.set_content(self.description.clone());
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
fn teardown(&self, _element: &mut Self::Element, _ctx: &mut BuildCtx<'_>) {}
}
impl Widget for RefusalBannerWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = bc.max();
let color = refusal_text_color(Theme::from_layout_ctx(ctx));
let max_width = size
.width
.is_finite()
.then(|| (size.width - REFUSAL_PAD * 2.0).max(0.0) as f32);
self.label_size = self
.label_run
.layout(ctx, &refusal_label_style(color), max_width);
self.description_run
.layout(ctx, &refusal_description_style(color), max_width);
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let (fill, border) = match Theme::from_paint_ctx(ctx) {
Some(theme) => {
let s = theme.scheme();
(s.error_container, s.error)
}
None => (REFUSAL_FILL, REFUSAL_BORDER),
};
let (origin, size) = (ctx.origin(), ctx.size());
scene.fill_rect(origin, size, fill);
const EDGE: f64 = 1.0;
let edge = EDGE.min(size.width / 2.0).min(size.height / 2.0);
if edge > 0.0 {
scene.fill_rect(origin, Size::new(size.width, edge), border);
scene.fill_rect(
kurbo::Point::new(origin.x, origin.y + size.height - edge),
Size::new(size.width, edge),
border,
);
scene.fill_rect(origin, Size::new(edge, size.height), border);
scene.fill_rect(
kurbo::Point::new(origin.x + size.width - edge, origin.y),
Size::new(edge, size.height),
border,
);
}
self.label_run
.paint(origin + Vec2::new(REFUSAL_PAD, REFUSAL_PAD), scene);
let description_y = REFUSAL_PAD + self.label_size.height + REFUSAL_ROW_GAP;
self.description_run
.paint(origin + Vec2::new(REFUSAL_PAD, description_y), scene);
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let label = format!("{}. {}", self.label, self.description);
ctx.push_node(Role::Alert, |node| node.set_label(label));
}
}
struct ClipToSlot<State: 'static> {
child: AnyView<State>,
}
struct ClipToSlotWidget {
child: Box<dyn Widget>,
}
impl<State: 'static> View<State> for ClipToSlot<State> {
type Element = ClipToSlotWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
ClipToSlotWidget {
child: self.child.build(ctx),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut Self::Element,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
self.child.rebuild(&prev.child, &mut element.child, ctx)
}
fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
self.child.teardown(&mut element.child, ctx);
}
}
impl Widget for ClipToSlotWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
self.child.layout(ctx, bc)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.push_clip(ctx.origin(), ctx.size());
self.child.paint(ctx, scene);
scene.pop_clip();
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.child.semantics(ctx);
}
}
pub(super) fn resolve_size(size: Option<(f64, f64)>, view: PlatformViewView) -> PlatformViewView {
match size {
Some((w, h)) => view.size(w, h),
None => view,
}
}
fn ambient_theme_tokens() -> Option<ResolvedTheme> {
use_context::<Theme>().as_ref().map(theme::resolve)
}
struct ParamsBody(String);
impl ParamsBody {
fn new() -> Self {
Self(String::new())
}
fn push_key(&mut self, key: &str) {
if !self.0.is_empty() {
self.0.push(',');
}
self.0.push('"');
self.0.push_str(key);
self.0.push_str("\":");
}
fn push_raw(&mut self, key: &str, value: impl std::fmt::Display) {
self.push_key(key);
self.0.push_str(&value.to_string());
}
fn push_str(&mut self, key: &str, value: &str) {
self.push_key(key);
self.0.push('"');
self.0.push_str(&escape(value));
self.0.push('"');
}
fn push_opt_str(&mut self, key: &str, value: Option<&str>) {
if let Some(v) = value {
self.push_str(key, v);
}
}
fn finish(self) -> String {
self.0
}
}
#[derive(Clone)]
pub struct NativeButtonView {
text: String,
enabled: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
on_press: Option<Arc<dyn Fn() + Send + Sync>>,
}
pub fn native_button(text: impl Into<String>) -> NativeButtonView {
NativeButtonView {
text: text.into(),
enabled: true,
content_description: None,
size: None,
on_press: None,
}
}
impl NativeButtonView {
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
pub fn on_press(mut self, handler: impl Fn() + Send + Sync + 'static) -> Self {
self.on_press = Some(Arc::new(handler));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_str(TEXT, &self.text);
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(TEXT_COLOR, t.on_accent_fill);
body.push_raw(BACKGROUND_COLOR, t.accent_fill);
body.push_raw(CORNER_RADIUS_DP, t.corner_radius_dp);
body.push_raw(TEXT_SIZE_SP, t.button_text_size_sp);
body.push_str(TYPEFACE, t.button_typeface.wire());
}
with_identity(button::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "Button");
}
let params = self.params_for(slot, tokens);
if let Some(on_press) = self.on_press.clone() {
with_runtime(|rt| rt.set_callback(slot, on_click(on_press)));
}
let view = platform_view(VIEW_TYPE)
.params_json(params)
.interactive()
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| self.text.clone()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeButtonView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
with_runtime(|rt| rt.forget_pending_callback(slot));
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[derive(Clone)]
pub struct NativeLabelView {
text: String,
enabled: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
}
pub fn native_label(text: impl Into<String>) -> NativeLabelView {
NativeLabelView {
text: text.into(),
enabled: true,
content_description: None,
size: None,
}
}
impl NativeLabelView {
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_str(TEXT, &self.text);
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(BACKGROUND_COLOR, t.surface_bg);
body.push_raw(TEXT_COLOR, t.body_text);
body.push_raw(TEXT_SIZE_SP, t.body_text_size_sp);
body.push_str(TYPEFACE, t.body_typeface.wire());
}
with_identity(label::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "Label");
}
let params = self.params_for(slot, tokens);
let view = platform_view(VIEW_TYPE)
.params_json(params)
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| self.text.clone()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeLabelView {
type State = SlotId;
fn init(&self) -> SlotId {
next_local_slot()
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[derive(Clone)]
pub struct NativeSwitchView {
checked: bool,
enabled: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
}
pub fn native_switch(checked: bool) -> NativeSwitchView {
NativeSwitchView {
checked,
enabled: true,
content_description: None,
size: None,
on_toggle: None,
}
}
impl NativeSwitchView {
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
pub fn on_toggle(mut self, handler: impl Fn(bool) + Send + Sync + 'static) -> Self {
self.on_toggle = Some(Arc::new(handler));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(CHECKED, self.checked);
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(THUMB_TINT, t.accent_ink);
body.push_raw(TRACK_TINT, t.accent_fill);
body.push_str(TYPEFACE, t.body_typeface.wire());
}
with_identity(switch::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "Switch");
}
let params = self.params_for(slot, tokens);
if let Some(on_toggle) = self.on_toggle.clone() {
with_runtime(|rt| rt.set_callback(slot, on_toggled(on_toggle)));
}
let view = platform_view(VIEW_TYPE)
.params_json(params)
.interactive()
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "switch".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeSwitchView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
with_runtime(|rt| rt.forget_pending_callback(slot));
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[derive(Clone)]
pub struct NativeSliderView {
value: i32,
min: i32,
max: i32,
enabled: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
on_change: Option<Arc<dyn Fn(i32) + Send + Sync>>,
}
pub fn native_slider(value: i32, min: i32, max: i32) -> NativeSliderView {
NativeSliderView {
value,
min,
max,
enabled: true,
content_description: None,
size: None,
on_change: None,
}
}
impl NativeSliderView {
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
pub fn on_change(mut self, handler: impl Fn(i32) + Send + Sync + 'static) -> Self {
self.on_change = Some(Arc::new(handler));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(VALUE, self.value);
body.push_raw(MIN, self.min);
body.push_raw(MAX, self.max);
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(PROGRESS_TINT, t.accent_fill);
body.push_raw(THUMB_TINT, t.accent_ink);
}
with_identity(slider::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "Slider");
}
let params = self.params_for(slot, tokens);
if let Some(on_change) = self.on_change.clone() {
with_runtime(|rt| rt.set_callback(slot, on_value_changed(on_change)));
}
let view = platform_view(VIEW_TYPE)
.params_json(params)
.interactive()
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "slider".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeSliderView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
with_runtime(|rt| rt.forget_pending_callback(slot));
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[derive(Clone)]
pub struct NativeProgressView {
value: i32,
min: i32,
max: i32,
indeterminate: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
}
pub fn native_progress(value: i32, min: i32, max: i32) -> NativeProgressView {
NativeProgressView {
value,
min,
max,
indeterminate: false,
content_description: None,
size: None,
}
}
impl NativeProgressView {
pub fn indeterminate(mut self, indeterminate: bool) -> Self {
self.indeterminate = indeterminate;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(VALUE, self.value);
body.push_raw(MIN, self.min);
body.push_raw(MAX, self.max);
body.push_raw(INDETERMINATE, self.indeterminate);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(BACKGROUND_COLOR, t.surface_bg);
body.push_raw(PROGRESS_TINT, t.accent_fill);
}
with_identity(progress::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "ProgressBar");
}
let params = self.params_for(slot, tokens);
let view = platform_view(VIEW_TYPE)
.params_json(params)
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "progress".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeProgressView {
type State = SlotId;
fn init(&self) -> SlotId {
next_local_slot()
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeSpinnerSize {
Small,
#[default]
Medium,
Large,
}
impl NativeSpinnerSize {
fn wire(self) -> &'static str {
match self {
Self::Small => "small",
Self::Medium => "medium",
Self::Large => "large",
}
}
}
#[derive(Clone)]
pub struct NativeSpinnerView {
animating: bool,
size_class: NativeSpinnerSize,
enabled: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
}
pub fn native_spinner(animating: bool) -> NativeSpinnerView {
NativeSpinnerView {
animating,
size_class: NativeSpinnerSize::default(),
enabled: true,
content_description: None,
size: None,
}
}
impl NativeSpinnerView {
pub fn size_class(mut self, size_class: NativeSpinnerSize) -> Self {
self.size_class = size_class;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(spinner::ANIMATING, self.animating);
body.push_str(spinner::SIZE_CLASS, self.size_class.wire());
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(TINT, t.accent_ink);
}
with_identity(spinner::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "Spinner");
}
let params = self.params_for(slot, tokens);
let view = platform_view(VIEW_TYPE)
.params_json(params)
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "spinner".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeSpinnerView {
type State = SlotId;
fn init(&self) -> SlotId {
next_local_slot()
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeDatePickerStyle {
#[default]
Compact,
Wheels,
Inline,
}
impl NativeDatePickerStyle {
fn wire(self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Wheels => "wheels",
Self::Inline => "inline",
}
}
}
#[derive(Clone)]
pub struct NativeDatePickerView {
date: CivilDate,
min: Option<CivilDate>,
max: Option<CivilDate>,
style: NativeDatePickerStyle,
enabled: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
on_change: Option<Arc<dyn Fn(CivilDate) + Send + Sync>>,
}
pub fn native_date_picker(date: CivilDate) -> NativeDatePickerView {
NativeDatePickerView {
date,
min: None,
max: None,
style: NativeDatePickerStyle::default(),
enabled: true,
content_description: None,
size: None,
on_change: None,
}
}
impl NativeDatePickerView {
pub fn min(mut self, min: CivilDate) -> Self {
self.min = Some(min);
self
}
pub fn max(mut self, max: CivilDate) -> Self {
self.max = Some(max);
self
}
pub fn style(mut self, style: NativeDatePickerStyle) -> Self {
self.style = style;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
pub fn on_change(mut self, handler: impl Fn(CivilDate) + Send + Sync + 'static) -> Self {
self.on_change = Some(Arc::new(handler));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(date_picker::DATE, date_picker::wire(self.date));
if let Some(min) = self.min {
body.push_raw(date_picker::MIN_DATE, date_picker::wire(min));
}
if let Some(max) = self.max {
body.push_raw(date_picker::MAX_DATE, date_picker::wire(max));
}
body.push_str(date_picker::STYLE, self.style.wire());
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(TINT, t.accent_ink);
body.push_raw(TEXT_COLOR, t.body_text);
}
with_identity(date_picker::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "Date picker");
}
let params = self.params_for(slot, tokens);
if let Some(on_change) = self.on_change.clone() {
with_runtime(|rt| rt.set_callback(slot, on_date(on_change)));
}
let view = platform_view(VIEW_TYPE)
.params_json(params)
.interactive()
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "date picker".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeDatePickerView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
with_runtime(|rt| rt.forget_pending_callback(slot));
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
const SEGMENTED_ARM: bool = true;
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
const SEGMENTED_ARM: bool = false;
const SEGMENTED_UNAVAILABLE_LABEL: &str = "Native segmented control unavailable";
const SEGMENTED_UNAVAILABLE_DESCRIPTION: &str = "native_segmented has no Android arm in this \
build (the framework has no segmented control; a Material-backed Android arm is a \
follow-up plan) — rendering a frust placeholder instead of an empty native slot.";
static SEGMENTED_NO_ARM_LOGGED: Once = Once::new();
fn warn_no_segmented_arm_once() {
SEGMENTED_NO_ARM_LOGGED.call_once(|| {
log::warn!(
"frust-native-widgets: native_segmented is an iOS/macOS-only control in this build \
(no Android arm yet — a Material-backed one is a follow-up plan); rendering the \
frust-drawn refusal banner instead of a native slot"
);
});
}
#[derive(Clone)]
pub struct NativeSegmentedView {
labels: Vec<String>,
selected: usize,
enabled: bool,
momentary: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
on_select: Option<Arc<dyn Fn(usize) + Send + Sync>>,
}
pub fn native_segmented(labels: Vec<String>, selected: usize) -> NativeSegmentedView {
NativeSegmentedView {
labels,
selected,
enabled: true,
momentary: false,
content_description: None,
size: None,
on_select: None,
}
}
impl NativeSegmentedView {
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn momentary(mut self, momentary: bool) -> Self {
self.momentary = momentary;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
pub fn on_select(mut self, handler: impl Fn(usize) + Send + Sync + 'static) -> Self {
self.on_select = Some(Arc::new(handler));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(segmented::SEGMENT_COUNT, self.labels.len());
for (index, label) in self.labels.iter().enumerate() {
body.push_str(&segmented::segment_key(index), label);
}
body.push_raw(segmented::SELECTED, self.selected);
body.push_raw(segmented::MOMENTARY, self.momentary);
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(TINT, t.accent_fill);
}
with_identity(segmented::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
self.build_for_arm(slot, mode, tokens, SEGMENTED_ARM)
}
fn build_for_arm(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
arm_available: bool,
) -> AnyView<SlotId> {
if !arm_available {
warn_no_segmented_arm_once();
return banner_placeholder(
self.size,
SEGMENTED_UNAVAILABLE_LABEL.to_string(),
SEGMENTED_UNAVAILABLE_DESCRIPTION.to_string(),
);
}
if mode.translucency_refused() {
return placeholder(self.size, "SegmentedControl");
}
let params = self.params_for(slot, tokens);
if let Some(on_select) = self.on_select.clone() {
with_runtime(|rt| rt.set_callback(slot, on_selected(on_select)));
}
let view = platform_view(VIEW_TYPE)
.params_json(params)
.interactive()
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "segmented control".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeSegmentedView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
with_runtime(|rt| rt.forget_pending_callback(slot));
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
const STEPPER_ARM: bool = true;
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
const STEPPER_ARM: bool = false;
const STEPPER_UNAVAILABLE_LABEL: &str = "Native stepper unavailable";
const STEPPER_UNAVAILABLE_DESCRIPTION: &str = "native_stepper has no Android arm in this build \
(the framework has no increment/decrement control; a composite Android arm is a follow-up \
plan) — rendering a frust placeholder instead of an empty native slot.";
static STEPPER_NO_ARM_LOGGED: Once = Once::new();
fn warn_no_stepper_arm_once() {
STEPPER_NO_ARM_LOGGED.call_once(|| {
log::warn!(
"frust-native-widgets: native_stepper is an iOS/macOS-only control in this build (no \
Android arm yet — a composite one is a follow-up plan); rendering the frust-drawn \
refusal banner instead of a native slot"
);
});
}
#[derive(Clone)]
pub struct NativeStepperView {
value: i32,
min: i32,
max: i32,
step: i32,
wraps: bool,
enabled: bool,
content_description: Option<String>,
size: Option<(f64, f64)>,
on_change: Option<Arc<dyn Fn(i32) + Send + Sync>>,
}
pub fn native_stepper(value: i32, min: i32, max: i32) -> NativeStepperView {
NativeStepperView {
value,
min,
max,
step: 1,
wraps: false,
enabled: true,
content_description: None,
size: None,
on_change: None,
}
}
impl NativeStepperView {
pub fn step(mut self, step: i32) -> Self {
self.step = step;
self
}
pub fn wraps(mut self, wraps: bool) -> Self {
self.wraps = wraps;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
pub fn on_change(mut self, handler: impl Fn(i32) + Send + Sync + 'static) -> Self {
self.on_change = Some(Arc::new(handler));
self
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(VALUE, self.value);
body.push_raw(MIN, self.min);
body.push_raw(MAX, self.max);
body.push_raw(STEP, self.step);
body.push_raw(WRAPS, self.wraps);
body.push_raw(ENABLED, self.enabled);
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(TINT, t.accent_ink);
}
with_identity(stepper::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
self.build_for_arm(slot, mode, tokens, STEPPER_ARM)
}
fn build_for_arm(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
arm_available: bool,
) -> AnyView<SlotId> {
if !arm_available {
warn_no_stepper_arm_once();
return banner_placeholder(
self.size,
STEPPER_UNAVAILABLE_LABEL.to_string(),
STEPPER_UNAVAILABLE_DESCRIPTION.to_string(),
);
}
if mode.translucency_refused() {
return placeholder(self.size, "Stepper");
}
let params = self.params_for(slot, tokens);
if let Some(on_change) = self.on_change.clone() {
with_runtime(|rt| rt.set_callback(slot, on_value_changed(on_change)));
}
let view = platform_view(VIEW_TYPE)
.params_json(params)
.interactive()
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "stepper".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeStepperView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
with_runtime(|rt| rt.forget_pending_callback(slot));
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
#[cfg(target_os = "ios")]
const TAB_BAR_ARM: bool = true;
#[cfg(not(target_os = "ios"))]
const TAB_BAR_ARM: bool = false;
const TAB_BAR_UNAVAILABLE_LABEL: &str = "Native tab bar unavailable";
const TAB_BAR_UNAVAILABLE_DESCRIPTION: &str = "native_tab_bar is iOS/iPadOS-only: macOS has no \
bottom tab bar idiom, and Android's BottomNavigationView needs Material, which this plugin \
never assumes — rendering a frust placeholder instead of an empty native slot.";
static TAB_BAR_NO_ARM_LOGGED: Once = Once::new();
fn warn_no_tab_bar_arm_once() {
TAB_BAR_NO_ARM_LOGGED.call_once(|| {
log::warn!(
"frust-native-widgets: native_tab_bar is an iOS/iPadOS-only control (macOS has no \
bottom tab bar idiom; Android's BottomNavigationView needs Material); rendering the \
frust-drawn refusal banner instead of a native slot"
);
});
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TabId(String);
impl TabId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for TabId {
fn from(id: &str) -> Self {
Self::new(id)
}
}
impl From<String> for TabId {
fn from(id: String) -> Self {
Self(id)
}
}
impl std::fmt::Display for TabId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum TabIcon {
Bytes(Arc<[u8]>),
AppleSymbol(String),
}
#[derive(Clone, Debug, PartialEq)]
pub struct TabItem {
id: TabId,
title: String,
icon: TabIcon,
selected_icon: Option<TabIcon>,
badge: Option<String>,
enabled: bool,
}
impl TabItem {
pub fn new(id: impl Into<TabId>, title: impl Into<String>, icon: TabIcon) -> Self {
Self {
id: id.into(),
title: title.into(),
icon,
selected_icon: None,
badge: None,
enabled: true,
}
}
pub fn selected_icon(mut self, icon: TabIcon) -> Self {
self.selected_icon = Some(icon);
self
}
pub fn badge(mut self, badge: impl Into<String>) -> Self {
self.badge = Some(badge.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn id(&self) -> &TabId {
&self.id
}
}
fn symbol_name(icon: Option<&TabIcon>) -> Option<&str> {
match icon {
Some(TabIcon::AppleSymbol(name)) => Some(name),
_ => None,
}
}
fn icon_bytes(icon: Option<&TabIcon>) -> Option<Arc<[u8]>> {
match icon {
Some(TabIcon::Bytes(bytes)) => Some(Arc::clone(bytes)),
_ => None,
}
}
#[derive(Clone)]
pub struct NativeTabBarView {
items: Vec<TabItem>,
selected: TabId,
safe_area: bool,
on_select: TabHandler,
on_reselect: TabHandler,
}
pub fn native_tab_bar(items: Vec<TabItem>, selected: impl Into<TabId>) -> NativeTabBarView {
NativeTabBarView {
items,
selected: selected.into(),
safe_area: true,
on_select: None,
on_reselect: None,
}
}
impl NativeTabBarView {
pub fn on_select(mut self, handler: impl Fn(TabId) + Send + Sync + 'static) -> Self {
self.on_select = Some(Arc::new(handler));
self
}
pub fn on_reselect(mut self, handler: impl Fn(TabId) + Send + Sync + 'static) -> Self {
self.on_reselect = Some(Arc::new(handler));
self
}
pub fn safe_area(mut self, enabled: bool) -> Self {
self.safe_area = enabled;
self
}
fn selected_index(&self) -> Option<usize> {
self.items.iter().position(|item| item.id == self.selected)
}
fn icon_bytes(&self) -> Vec<tab_bar::ItemIconBytes> {
self.items
.iter()
.map(|item| tab_bar::ItemIconBytes {
icon: icon_bytes(Some(&item.icon)),
selected_icon: icon_bytes(item.selected_icon.as_ref()),
})
.collect()
}
fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>, icons_rev: u64) -> String {
let mut body = ParamsBody::new();
body.push_raw(tab_bar::ITEM_COUNT, self.items.len());
for (index, item) in self.items.iter().enumerate() {
body.push_str(&tab_bar::item_key(index, tab_bar::FIELD_TITLE), &item.title);
body.push_raw(
&tab_bar::item_key(index, tab_bar::FIELD_ENABLED),
item.enabled,
);
body.push_opt_str(
&tab_bar::item_key(index, tab_bar::FIELD_BADGE),
item.badge.as_deref(),
);
body.push_opt_str(
&tab_bar::item_key(index, tab_bar::FIELD_SYMBOL),
symbol_name(Some(&item.icon)),
);
body.push_opt_str(
&tab_bar::item_key(index, tab_bar::FIELD_SELECTED_SYMBOL),
symbol_name(item.selected_icon.as_ref()),
);
}
if let Some(index) = self.selected_index() {
body.push_raw(tab_bar::SELECTED, index);
}
body.push_raw(tab_bar::ICONS_REV, icons_rev);
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
if let Some(t) = tokens {
body.push_raw(TINT, t.accent_ink);
body.push_raw(tab_bar::UNSELECTED_TINT, t.muted);
body.push_raw(BACKGROUND_COLOR, t.surface_bg);
}
with_identity(tab_bar::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
self.build_for_arm(slot, mode, tokens, TAB_BAR_ARM)
}
fn build_for_arm(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
arm_available: bool,
) -> AnyView<SlotId> {
let child = if !arm_available {
warn_no_tab_bar_arm_once();
banner_placeholder(
None,
TAB_BAR_UNAVAILABLE_LABEL.to_string(),
TAB_BAR_UNAVAILABLE_DESCRIPTION.to_string(),
)
} else if mode.translucency_refused() {
placeholder(None, "TabBar")
} else {
let icons_rev = tab_bar::publish_icon_bytes(slot, self.icon_bytes());
let params = self.params_for(slot, tokens, icons_rev);
if self.on_select.is_some() || self.on_reselect.is_some() {
let ids: Arc<[TabId]> = self.items.iter().map(|item| item.id.clone()).collect();
with_runtime(|rt| {
rt.set_callback(
slot,
on_tab_bar(ids, self.on_select.clone(), self.on_reselect.clone()),
)
});
}
any(platform_view(VIEW_TYPE)
.params_json(params)
.interactive()
.semantics_label("tab bar")
.expand())
};
any(TabBarSlot {
child,
safe_area: self.safe_area,
})
}
}
impl Component for NativeTabBarView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
with_runtime(|rt| rt.forget_pending_callback(slot));
tab_bar::retire_icon_bytes(slot);
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
struct TabBarSlot<State: 'static> {
child: AnyView<State>,
safe_area: bool,
}
struct TabBarSlotWidget {
child: Box<dyn Widget>,
safe_area: bool,
}
impl<State: 'static> View<State> for TabBarSlot<State> {
type Element = TabBarSlotWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
TabBarSlotWidget {
child: self.child.build(ctx),
safe_area: self.safe_area,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut Self::Element,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = self.child.rebuild(&prev.child, &mut element.child, ctx);
if element.safe_area != self.safe_area {
element.safe_area = self.safe_area;
flags |= ChangeFlags::LAYOUT;
}
flags
}
fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
self.child.teardown(&mut element.child, ctx);
}
}
impl TabBarSlotWidget {
fn slot_size(&self, bc: &BoxConstraints, bottom_inset: f64) -> Size {
let inset = if self.safe_area {
bottom_inset.max(0.0)
} else {
0.0
};
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
bc.min().width
};
bc.constrain(Size::new(width, tab_bar::BAR_HEIGHT + inset))
}
}
impl Widget for TabBarSlotWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.slot_size(bc, ctx.window_insets().padding().bottom);
self.child.layout(ctx, &BoxConstraints::tight(size));
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.push_clip(ctx.origin(), ctx.size());
self.child.paint(ctx, scene);
scene.pop_clip();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
self.child.event(ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.child.semantics(ctx);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeImageFit {
#[default]
Contain,
Cover,
Fill,
Center,
}
impl NativeImageFit {
fn wire(self) -> &'static str {
match self {
Self::Contain => "contain",
Self::Cover => "cover",
Self::Fill => "fill",
Self::Center => "center",
}
}
}
#[derive(Clone)]
pub struct NativeImageView {
bytes: Arc<[u8]>,
fit: NativeImageFit,
content_description: Option<String>,
size: Option<(f64, f64)>,
}
pub fn native_image(bytes: Arc<[u8]>) -> NativeImageView {
NativeImageView {
bytes,
fit: NativeImageFit::default(),
content_description: None,
size: None,
}
}
impl NativeImageView {
pub fn fit(mut self, fit: NativeImageFit) -> Self {
self.fit = fit;
self
}
pub fn content_description(mut self, label: impl Into<String>) -> Self {
self.content_description = Some(label.into());
self
}
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
fn params_for(&self, slot: SlotId, rev: u64, tokens: Option<ResolvedTheme>) -> String {
let mut body = ParamsBody::new();
body.push_raw(image::REV, rev);
body.push_str(FIT, self.fit.wire());
body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
with_identity(image::KIND, slot, &body.finish())
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
tokens: Option<ResolvedTheme>,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, "Image");
}
let rev = image::publish_bytes(slot, Arc::clone(&self.bytes));
let params = self.params_for(slot, rev, tokens);
let view = platform_view(VIEW_TYPE)
.params_json(params)
.semantics_label(
self.content_description
.clone()
.unwrap_or_else(|| "image".into()),
);
any(resolve_size(self.size, view))
}
}
impl Component for NativeImageView {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || image::retire(slot));
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
}
}
macro_rules! impl_native_view {
($ty:ty) => {
impl<Outer: 'static> View<Outer> for $ty {
type Element = ComponentWidget<$ty>;
fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
<frust_core::ComponentView<$ty> as View<Outer>>::build(
&component(self.clone()),
ctx,
)
}
fn rebuild(
&self,
prev: &Self,
element: &mut Self::Element,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
<frust_core::ComponentView<$ty> as View<Outer>>::rebuild(
&component(self.clone()),
&component(prev.clone()),
element,
ctx,
)
}
fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
<frust_core::ComponentView<$ty> as View<Outer>>::teardown(
&component(self.clone()),
element,
ctx,
);
}
}
};
}
impl_native_view!(NativeButtonView);
impl_native_view!(NativeLabelView);
impl_native_view!(NativeSwitchView);
impl_native_view!(NativeSliderView);
impl_native_view!(NativeProgressView);
impl_native_view!(NativeImageView);
impl_native_view!(NativeSpinnerView);
impl_native_view!(NativeDatePickerView);
impl_native_view!(NativeSegmentedView);
impl_native_view!(NativeStepperView);
impl_native_view!(NativeTabBarView);
#[cfg(test)]
mod tests {
use super::*;
use frust_core::{BoxConstraints, LayoutCtx, PaintCtx, PaintScene, Widget};
use frust_text::TextContext;
use kurbo::{Point, Rect, Shape, Size};
use peniko::{Brush, Color};
use std::any::Any;
#[derive(Default)]
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
}
fn build_any<State: 'static>(view: AnyView<State>) -> Box<dyn Widget> {
let mut counter = 0u64;
view.build(&mut BuildCtx::new(&mut counter))
}
#[test]
fn button_params_snapshot() {
let view = native_button("Save")
.enabled(false)
.content_description("Save the note");
assert_eq!(
view.params_for(7, None),
"{\"__frustControl\":\"button\",\"__frustSlot\":7,\"text\":\"Save\",\"enabled\":false,\
\"contentDescription\":\"Save the note\",\"dark\":false}"
);
}
#[test]
fn label_params_snapshot() {
let view = native_label("42 fps");
assert_eq!(
view.params_for(3, None),
"{\"__frustControl\":\"label\",\"__frustSlot\":3,\"text\":\"42 fps\",\"enabled\":true,\
\"dark\":false}"
);
}
#[test]
fn switch_params_snapshot() {
let view = native_switch(true).content_description("wifi");
assert_eq!(
view.params_for(11, None),
"{\"__frustControl\":\"switch\",\"__frustSlot\":11,\"checked\":true,\"enabled\":true,\
\"contentDescription\":\"wifi\",\"dark\":false}"
);
}
#[test]
fn slider_params_snapshot() {
let view = native_slider(25, 0, 50);
assert_eq!(
view.params_for(5, None),
"{\"__frustControl\":\"slider\",\"__frustSlot\":5,\"value\":25,\"min\":0,\"max\":50,\
\"enabled\":true,\"dark\":false}"
);
}
#[test]
fn progress_params_snapshot() {
let view = native_progress(30, 10, 110).indeterminate(false);
assert_eq!(
view.params_for(2, None),
"{\"__frustControl\":\"progress\",\"__frustSlot\":2,\"value\":30,\"min\":10,\
\"max\":110,\"indeterminate\":false,\"dark\":false}"
);
}
#[test]
fn image_params_snapshot() {
let view =
native_image(Arc::from(vec![1u8, 2, 3].into_boxed_slice())).fit(NativeImageFit::Cover);
assert_eq!(
view.params_for(900, 42, None),
"{\"__frustControl\":\"image\",\"__frustSlot\":900,\"imageRev\":42,\"fit\":\"cover\",\
\"dark\":false}"
);
}
#[test]
fn spinner_params_snapshot() {
let view = native_spinner(true)
.size_class(NativeSpinnerSize::Large)
.content_description("loading");
assert_eq!(
view.params_for(6, None),
"{\"__frustControl\":\"spinner\",\"__frustSlot\":6,\"animating\":true,\
\"sizeClass\":\"large\",\"enabled\":true,\"contentDescription\":\"loading\",\
\"dark\":false}"
);
}
fn civil(year: i32, month: u8, day: u8) -> CivilDate {
CivilDate::new(year, month, day).expect("a real date")
}
#[test]
fn date_picker_params_snapshot() {
let view = native_date_picker(civil(2026, 9, 29))
.min(civil(2026, 1, 1))
.max(civil(2026, 12, 31))
.style(NativeDatePickerStyle::Inline)
.content_description("due date");
assert_eq!(
view.params_for(12, None),
format!(
"{{\"__frustControl\":\"date_picker\",\"__frustSlot\":12,\"date\":{},\
\"minDate\":{},\"maxDate\":{},\"style\":\"inline\",\"enabled\":true,\
\"contentDescription\":\"due date\",\"dark\":false}}",
(2026 << 16) | (9 << 8) | 29,
(2026 << 16) | (1 << 8) | 1,
(2026 << 16) | (12 << 8) | 31,
)
);
}
#[test]
fn date_picker_params_round_trip_through_the_control_decoder() {
use crate::controls::date_picker::DatePickerProps;
use crate::runtime::Params;
let view = native_date_picker(civil(2024, 2, 29))
.min(civil(2000, 1, 1))
.style(NativeDatePickerStyle::Wheels)
.enabled(false);
let raw = view.params_for(12, Some(dark_tokens()));
let props = DatePickerProps::decode(&Params::new(&raw)).expect("decodes");
assert_eq!(props.date, Some(civil(2024, 2, 29)));
assert_eq!(props.min, Some(civil(2000, 1, 1)));
assert_eq!(props.max, None);
assert!(!props.enabled);
assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
assert_eq!(props.text_color, Some(dark_tokens().body_text as i32));
}
#[test]
fn date_picker_publishes_one_interactive_slot_on_every_target() {
let view = native_date_picker(civil(2026, 9, 29)).size(320.0, 216.0);
let expected_params = view.params_for(4, None);
let built = view.build_with_mode(4, ResolvedSurfaceMode::Opaque, None);
let mut element = build_any(built);
let mut lctx = LayoutCtx::new();
element.layout(&mut lctx, &BoxConstraints::tight(Size::new(320.0, 216.0)));
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(320.0, 216.0));
element.paint(&mut pctx, &mut scene);
let frames = pctx.take_platform_views();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].view_type, VIEW_TYPE);
assert_eq!(frames[0].params_json, expected_params);
assert!(
frames[0].interactive,
"a date picker slot forwards native input"
);
}
#[test]
fn date_picker_honours_the_translucency_refusal() {
let view = native_date_picker(civil(2026, 9, 29)).build_with_mode(
5,
ResolvedSurfaceMode::RefusedTranslucent,
None,
);
let mut element = build_any(view);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(320.0, 216.0));
element.paint(&mut pctx, &mut scene);
assert!(pctx.take_platform_views().is_empty());
}
#[test]
fn segmented_params_snapshot() {
let view = native_segmented(vec!["Day".into(), "Week \"W\"".into()], 1)
.momentary(true)
.content_description("range");
assert_eq!(
view.params_for(8, None),
"{\"__frustControl\":\"segmented\",\"__frustSlot\":8,\"segmentCount\":2,\
\"segment0\":\"Day\",\"segment1\":\"Week \\\"W\\\"\",\"selected\":1,\
\"momentary\":true,\"enabled\":true,\"contentDescription\":\"range\",\
\"dark\":false}"
);
}
#[test]
fn segmented_params_round_trip_through_the_control_decoder() {
use crate::controls::segmented::SegmentedProps;
use crate::runtime::Params;
let view = native_segmented(vec!["A".into(), "B".into(), "C".into()], 2).enabled(false);
let raw = view.params_for(8, Some(dark_tokens()));
let props = SegmentedProps::decode(&Params::new(&raw)).expect("decodes");
assert_eq!(props.labels, vec!["A", "B", "C"]);
assert_eq!(props.selected, Some(2));
assert!(!props.enabled);
assert!(!props.momentary);
assert_eq!(props.tint, Some(dark_tokens().accent_fill as i32));
}
#[test]
fn the_segmented_arm_gate_is_exactly_the_apple_targets() {
assert_eq!(
SEGMENTED_ARM,
cfg!(any(target_os = "ios", target_os = "macos")),
"Android (decision D2) and every host target render the banner"
);
assert!(SEGMENTED_UNAVAILABLE_DESCRIPTION.contains("Android"));
assert!(SEGMENTED_UNAVAILABLE_DESCRIPTION.contains("follow-up"));
}
#[test]
fn segmented_without_a_platform_arm_paints_the_banner_and_publishes_no_slot() {
for mode in [
ResolvedSurfaceMode::Unknown,
ResolvedSurfaceMode::Opaque,
ResolvedSurfaceMode::Translucent,
ResolvedSurfaceMode::RefusedTranslucent,
] {
let view = native_segmented(vec!["A".into(), "B".into()], 0)
.size(240.0, 60.0)
.build_for_arm(3, mode, None, false);
let mut element = build_any(view);
let mut text_ctx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
let laid = element.layout(&mut lctx, &BoxConstraints::tight(Size::new(240.0, 60.0)));
let mut rec = BoundsRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, laid);
element.paint(&mut pctx, &mut rec);
assert!(
pctx.take_platform_views().is_empty(),
"{mode:?}: no arm must publish no native platform_view frame"
);
assert!(
rec.glyph_runs >= 2,
"{mode:?}: the banner's label and description must paint, saw {} runs",
rec.glyph_runs
);
}
}
#[test]
fn segmented_with_a_platform_arm_publishes_one_interactive_slot() {
let view = native_segmented(vec!["A".into(), "B".into()], 1).size(200.0, 32.0);
let expected_params = view.params_for(4, None);
let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
let mut element = build_any(built);
let mut lctx = LayoutCtx::new();
element.layout(&mut lctx, &BoxConstraints::tight(Size::new(200.0, 32.0)));
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 32.0));
element.paint(&mut pctx, &mut scene);
let frames = pctx.take_platform_views();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].view_type, VIEW_TYPE);
assert_eq!(frames[0].params_json, expected_params);
assert!(
frames[0].interactive,
"a segmented slot forwards native input"
);
}
#[test]
fn segmented_with_an_arm_still_honours_the_translucency_refusal() {
let view = native_segmented(vec!["A".into()], 0).build_for_arm(
5,
ResolvedSurfaceMode::RefusedTranslucent,
None,
true,
);
let mut element = build_any(view);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 32.0));
element.paint(&mut pctx, &mut scene);
assert!(pctx.take_platform_views().is_empty());
}
#[test]
fn stepper_params_snapshot() {
let view = native_stepper(4, 0, 10)
.step(2)
.wraps(true)
.content_description("count");
assert_eq!(
view.params_for(8, None),
"{\"__frustControl\":\"stepper\",\"__frustSlot\":8,\"value\":4,\"min\":0,\"max\":10,\
\"step\":2,\"wraps\":true,\"enabled\":true,\"contentDescription\":\"count\",\
\"dark\":false}"
);
}
#[test]
fn stepper_params_round_trip_through_the_control_decoder() {
use crate::controls::stepper::StepperProps;
use crate::runtime::Params;
let view = native_stepper(3, 0, 20).step(5).wraps(true).enabled(false);
let raw = view.params_for(8, Some(dark_tokens()));
let props = StepperProps::decode(&Params::new(&raw)).expect("decodes");
assert_eq!(props.value, 3);
assert_eq!(props.step, 5);
assert!(props.wraps);
assert!(!props.enabled);
assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
}
#[test]
fn a_non_positive_step_mounts_as_one_through_the_control_decoder() {
use crate::controls::stepper::StepperProps;
use crate::runtime::Params;
let view = native_stepper(3, 0, 20).step(0);
let raw = view.params_for(8, None);
assert!(
raw.contains("\"step\":0"),
"the builder still sends the app's raw value over the wire — \
normalization is the control's job, not the builder's"
);
let props = StepperProps::decode(&Params::new(&raw)).expect("decodes");
assert_eq!(props.step, 1, "a non-positive step mounts as 1");
}
#[test]
fn the_stepper_arm_gate_is_exactly_the_apple_targets() {
assert_eq!(
STEPPER_ARM,
cfg!(any(target_os = "ios", target_os = "macos")),
"Android and every host target render the banner"
);
assert!(STEPPER_UNAVAILABLE_DESCRIPTION.contains("Android"));
assert!(STEPPER_UNAVAILABLE_DESCRIPTION.contains("follow-up"));
}
#[test]
fn stepper_without_a_platform_arm_paints_the_banner_and_publishes_no_slot() {
for mode in [
ResolvedSurfaceMode::Unknown,
ResolvedSurfaceMode::Opaque,
ResolvedSurfaceMode::Translucent,
ResolvedSurfaceMode::RefusedTranslucent,
] {
let view = native_stepper(0, 0, 10)
.size(120.0, 40.0)
.build_for_arm(3, mode, None, false);
let mut element = build_any(view);
let mut text_ctx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
let laid = element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 40.0)));
let mut rec = BoundsRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, laid);
element.paint(&mut pctx, &mut rec);
assert!(
pctx.take_platform_views().is_empty(),
"{mode:?}: no arm must publish no native platform_view frame"
);
assert!(
rec.glyph_runs >= 2,
"{mode:?}: the banner's label and description must paint, saw {} runs",
rec.glyph_runs
);
}
}
#[test]
fn stepper_with_a_platform_arm_publishes_one_interactive_slot() {
let view = native_stepper(2, 0, 10).size(94.0, 29.0);
let expected_params = view.params_for(4, None);
let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
let mut element = build_any(built);
let mut lctx = LayoutCtx::new();
element.layout(&mut lctx, &BoxConstraints::tight(Size::new(94.0, 29.0)));
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(94.0, 29.0));
element.paint(&mut pctx, &mut scene);
let frames = pctx.take_platform_views();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].view_type, VIEW_TYPE);
assert_eq!(frames[0].params_json, expected_params);
assert!(
frames[0].interactive,
"a stepper slot forwards native input"
);
}
#[test]
fn stepper_with_an_arm_still_honours_the_translucency_refusal() {
let view = native_stepper(0, 0, 10).build_for_arm(
5,
ResolvedSurfaceMode::RefusedTranslucent,
None,
true,
);
let mut element = build_any(view);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(94.0, 29.0));
element.paint(&mut pctx, &mut scene);
assert!(pctx.take_platform_views().is_empty());
}
fn symbol(name: &str) -> TabIcon {
TabIcon::AppleSymbol(name.into())
}
fn two_tabs() -> Vec<TabItem> {
vec![
TabItem::new("home", "Home", symbol("house")).selected_icon(symbol("house.fill")),
TabItem::new("inbox", "Inbox", symbol("tray"))
.badge("3")
.enabled(false),
]
}
fn layout_with_bottom_inset(
element: &mut Box<dyn Widget>,
bc: &BoxConstraints,
bottom: f64,
) -> Size {
use frust_core::{WindowEdgeInsets, WindowInsets};
let insets = WindowInsets::new(
WindowEdgeInsets::new(0.0, 0.0, 0.0, bottom),
WindowEdgeInsets::ZERO,
);
let mut text_ctx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
lctx.with_window_insets(insets, |ctx| element.layout(ctx, bc))
}
#[test]
fn tab_bar_params_snapshot() {
let view = native_tab_bar(two_tabs(), "inbox");
assert_eq!(
view.params_for(8, None, 0),
"{\"__frustControl\":\"tab_bar\",\"__frustSlot\":8,\"itemCount\":2,\
\"tab0Title\":\"Home\",\"tab0Enabled\":true,\"tab0Symbol\":\"house\",\
\"tab0SelectedSymbol\":\"house.fill\",\"tab1Title\":\"Inbox\",\
\"tab1Enabled\":false,\"tab1Badge\":\"3\",\"tab1Symbol\":\"tray\",\
\"selected\":1,\"iconsRev\":0,\"dark\":false}"
);
}
#[test]
fn an_unknown_selected_id_encodes_no_selection() {
let raw = native_tab_bar(two_tabs(), "settings").params_for(8, None, 0);
assert!(!raw.contains("\"selected\""), "{raw}");
}
#[test]
fn tab_bar_params_round_trip_through_the_control_decoder() {
use crate::controls::tab_bar::{IconSource, TabBarProps};
use crate::runtime::Params;
let bytes: Arc<[u8]> = Arc::from(vec![9u8, 9, 9].into_boxed_slice());
let view = native_tab_bar(
vec![
TabItem::new("a", "A", TabIcon::Bytes(Arc::clone(&bytes))),
TabItem::new("b", "B", symbol("gear")),
],
"b",
);
let slot = 7_701;
let rev = tab_bar::publish_icon_bytes(slot, view.icon_bytes());
let raw = view.params_for(slot, Some(dark_tokens()), rev);
let props = TabBarProps::decode(&Params::new(&raw)).expect("decodes");
assert_eq!(props.items.len(), 2);
assert_eq!(props.items[0].icon, Some(IconSource::Bytes(bytes)));
assert_eq!(props.items[1].icon, Some(IconSource::Symbol("gear".into())));
assert_eq!(props.selected, Some(1));
assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
assert_eq!(props.unselected_tint, Some(dark_tokens().muted as i32));
assert_eq!(props.background, Some(dark_tokens().surface_bg as i32));
tab_bar::retire_icon_bytes(slot);
}
#[test]
fn tab_bar_folds_accent_ink_muted_and_surface_from_the_theme() {
let tokens = light_tokens();
let raw = native_tab_bar(two_tabs(), "home").params_for(3, Some(tokens), 0);
assert!(raw.ends_with(&format!(
"\"dark\":false,\"tint\":{},\"unselectedTint\":{},\"backgroundColor\":{}}}",
tokens.accent_ink, tokens.muted, tokens.surface_bg
)));
}
#[test]
fn the_tab_bar_arm_gate_is_exactly_ios() {
assert_eq!(
TAB_BAR_ARM,
cfg!(target_os = "ios"),
"macOS (no idiom), Android (Material, D2) and every host render the banner"
);
assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("macOS"));
assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("Android"));
assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("Material"));
}
#[test]
fn tab_bar_without_a_platform_arm_paints_the_banner_in_the_inset_aware_slot() {
for mode in [
ResolvedSurfaceMode::Unknown,
ResolvedSurfaceMode::Opaque,
ResolvedSurfaceMode::RefusedTranslucent,
] {
let view = native_tab_bar(two_tabs(), "home").build_for_arm(3, mode, None, false);
let mut element = build_any(view);
let laid = layout_with_bottom_inset(
&mut element,
&BoxConstraints::new(Size::ZERO, Size::new(390.0, f64::INFINITY)),
34.0,
);
assert_eq!(laid, Size::new(390.0, 49.0 + 34.0), "{mode:?}");
let mut rec = BoundsRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, laid);
element.paint(&mut pctx, &mut rec);
assert!(
pctx.take_platform_views().is_empty(),
"{mode:?}: no arm must publish no native platform_view frame"
);
assert!(rec.glyph_runs >= 1, "{mode:?}: the banner must paint");
}
}
#[test]
fn tab_bar_with_a_platform_arm_publishes_one_interactive_slot_under_the_home_indicator() {
let view = native_tab_bar(two_tabs(), "home");
let expected_params = view.params_for(4, None, 0);
let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
let mut element = build_any(built);
let laid = layout_with_bottom_inset(
&mut element,
&BoxConstraints::new(Size::ZERO, Size::new(390.0, 844.0)),
34.0,
);
assert_eq!(
laid,
Size::new(390.0, 83.0),
"49pt plus the 34pt home-indicator inset, full width"
);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, laid);
element.paint(&mut pctx, &mut scene);
let frames = pctx.take_platform_views();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].view_type, VIEW_TYPE);
assert_eq!(frames[0].params_json, expected_params);
assert!(
frames[0].interactive,
"a tab bar slot forwards native input"
);
}
#[test]
fn an_opted_out_tab_bar_is_the_bare_bar_height() {
let built = native_tab_bar(two_tabs(), "home")
.safe_area(false)
.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
let mut element = build_any(built);
let laid = layout_with_bottom_inset(
&mut element,
&BoxConstraints::new(Size::ZERO, Size::new(320.0, 600.0)),
34.0,
);
assert_eq!(laid, Size::new(320.0, 49.0));
}
#[test]
fn tab_bar_with_an_arm_still_honours_the_translucency_refusal() {
let view = native_tab_bar(two_tabs(), "home").build_for_arm(
5,
ResolvedSurfaceMode::RefusedTranslucent,
None,
true,
);
let mut element = build_any(view);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(390.0, 49.0));
element.paint(&mut pctx, &mut scene);
assert!(pctx.take_platform_views().is_empty());
}
fn dark_tokens() -> ResolvedTheme {
theme::resolve(&Theme::neutral().with_brightness(frust::Brightness::Dark))
}
fn light_tokens() -> ResolvedTheme {
theme::resolve(&Theme::neutral().with_brightness(frust::Brightness::Light))
}
#[test]
fn button_folds_background_text_radius_and_size_from_a_dark_theme() {
let view = native_button("Save");
let tokens = dark_tokens();
assert_eq!(
view.params_for(7, Some(tokens)),
format!(
"{{\"__frustControl\":\"button\",\"__frustSlot\":7,\"text\":\"Save\",\"enabled\":\
true,\"dark\":true,\"textColor\":{},\"backgroundColor\":{},\"cornerRadiusDp\":{},\
\"textSizeSp\":{},\"typeface\":\"{}\"}}",
tokens.on_accent_fill,
tokens.accent_fill,
tokens.corner_radius_dp,
tokens.button_text_size_sp,
tokens.button_typeface.wire()
)
);
}
#[test]
fn button_folds_a_light_theme_distinctly_from_dark() {
let view = native_button("Save");
let dark = view.params_for(7, Some(dark_tokens()));
let light = view.params_for(7, Some(light_tokens()));
assert_ne!(
dark, light,
"a brightness flip must reach the folded params — both the `dark` \
flag and every colour role the button folds change with it"
);
assert!(light.contains("\"dark\":false"));
assert!(dark.contains("\"dark\":true"));
}
#[test]
fn label_folds_body_text_colour_and_size() {
let view = native_label("42 fps");
let tokens = dark_tokens();
assert_eq!(
view.params_for(3, Some(tokens)),
format!(
"{{\"__frustControl\":\"label\",\"__frustSlot\":3,\"text\":\"42 fps\",\"enabled\":\
true,\"dark\":true,\"backgroundColor\":{},\"textColor\":{},\"textSizeSp\":{},\
\"typeface\":\"{}\"}}",
tokens.surface_bg,
tokens.body_text,
tokens.body_text_size_sp,
tokens.body_typeface.wire()
)
);
}
#[test]
fn switch_folds_thumb_and_track_tint_but_never_a_background() {
let view = native_switch(true);
let tokens = dark_tokens();
let params = view.params_for(11, Some(tokens));
assert_eq!(
params,
format!(
"{{\"__frustControl\":\"switch\",\"__frustSlot\":11,\"checked\":true,\"enabled\":\
true,\"dark\":true,\"thumbTint\":{},\"trackTint\":{},\
\"typeface\":\"{}\"}}",
tokens.accent_ink,
tokens.accent_fill,
tokens.body_typeface.wire()
)
);
assert!(
!params.contains("backgroundColor"),
"Switch must never fold an explicit background — it would defeat the ripple"
);
}
#[test]
fn slider_folds_progress_and_thumb_tint_but_never_a_background() {
let view = native_slider(25, 0, 50);
let tokens = dark_tokens();
let params = view.params_for(5, Some(tokens));
assert_eq!(
params,
format!(
"{{\"__frustControl\":\"slider\",\"__frustSlot\":5,\"value\":25,\"min\":0,\"max\":\
50,\"enabled\":true,\"dark\":true,\"progressTint\":{},\
\"thumbTint\":{}}}",
tokens.accent_fill, tokens.accent_ink
)
);
assert!(
!params.contains("backgroundColor"),
"Slider must never fold an explicit background — it would defeat the ripple"
);
}
#[test]
fn progress_folds_progress_tint_only() {
let view = native_progress(30, 10, 110);
let tokens = dark_tokens();
assert_eq!(
view.params_for(2, Some(tokens)),
format!(
"{{\"__frustControl\":\"progress\",\"__frustSlot\":2,\"value\":30,\"min\":10,\
\"max\":110,\"indeterminate\":false,\"dark\":true,\"backgroundColor\":{},\
\"progressTint\":{}}}",
tokens.surface_bg, tokens.accent_fill
)
);
}
#[test]
fn image_folds_only_the_dark_flag_never_a_tint() {
let view = native_image(Arc::from(vec![1u8].into_boxed_slice()));
assert_eq!(
view.params_for(900, 42, Some(dark_tokens())),
"{\"__frustControl\":\"image\",\"__frustSlot\":900,\"imageRev\":42,\"fit\":\"contain\",\
\"dark\":true}"
);
}
#[test]
fn spinner_folds_accent_ink_as_its_tint() {
let view = native_spinner(false);
let tokens = dark_tokens();
assert_eq!(
view.params_for(6, Some(tokens)),
format!(
"{{\"__frustControl\":\"spinner\",\"__frustSlot\":6,\"animating\":false,\
\"sizeClass\":\"medium\",\"enabled\":true,\"dark\":true,\"tint\":{}}}",
tokens.accent_ink
)
);
}
#[test]
fn stepper_folds_accent_ink_as_its_tint() {
let view = native_stepper(0, 0, 10);
let tokens = dark_tokens();
assert_eq!(
view.params_for(6, Some(tokens)),
format!(
"{{\"__frustControl\":\"stepper\",\"__frustSlot\":6,\"value\":0,\"min\":0,\
\"max\":10,\"step\":1,\"wraps\":false,\"enabled\":true,\"dark\":true,\"tint\":{}}}",
tokens.accent_ink
)
);
}
#[test]
fn date_picker_folds_accent_ink_tint_and_body_text_colour() {
let view = native_date_picker(civil(2026, 9, 29));
let tokens = dark_tokens();
assert_eq!(
view.params_for(6, Some(tokens)),
format!(
"{{\"__frustControl\":\"date_picker\",\"__frustSlot\":6,\"date\":{},\
\"style\":\"compact\",\"enabled\":true,\"dark\":true,\"tint\":{},\
\"textColor\":{}}}",
(2026 << 16) | (9 << 8) | 29,
tokens.accent_ink,
tokens.body_text
)
);
}
#[test]
fn an_unchanged_theme_yields_partial_eq_equal_button_props() {
use crate::controls::button::ButtonProps;
use crate::runtime::Params;
let view = native_button("Save").content_description("Save the note");
let tokens = dark_tokens();
let a = ButtonProps::decode(&Params::new(&view.params_for(7, Some(tokens)))).unwrap();
let b = ButtonProps::decode(&Params::new(&view.params_for(7, Some(tokens)))).unwrap();
assert_eq!(
a, b,
"the SAME resolved theme, folded twice, must decode to PartialEq-equal \
Props — this is what keeps the runtime's diff gate from crossing the FFI \
boundary on a rebuild the theme didn't actually change"
);
let c =
ButtonProps::decode(&Params::new(&view.params_for(7, Some(light_tokens())))).unwrap();
assert_ne!(a, c);
}
#[test]
fn a_refused_slot_publishes_no_platform_view_frame() {
for (name, view) in [
(
"Button",
native_button("Save").build_with_mode(
1,
ResolvedSurfaceMode::RefusedTranslucent,
None,
),
),
(
"Switch",
native_switch(true).build_with_mode(
2,
ResolvedSurfaceMode::RefusedTranslucent,
None,
),
),
] {
let mut element = build_any(view);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
element.paint(&mut pctx, &mut scene);
assert!(
pctx.take_platform_views().is_empty(),
"{name}: a refused slot must render no native platform_view frame"
);
}
}
#[test]
fn an_unrefused_slot_still_builds_the_native_platform_view() {
for mode in [
ResolvedSurfaceMode::Unknown,
ResolvedSurfaceMode::Opaque,
ResolvedSurfaceMode::Translucent,
] {
let btn = native_button("Save").size(120.0, 44.0);
let expected_params = btn.params_for(9, None);
let view = btn.build_with_mode(9, mode, None);
let mut element = build_any(view);
let mut lctx = LayoutCtx::new();
element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 44.0)));
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
element.paint(&mut pctx, &mut scene);
let frames = pctx.take_platform_views();
assert_eq!(frames.len(), 1, "{mode:?}");
assert_eq!(frames[0].view_type, VIEW_TYPE);
assert_eq!(frames[0].params_json, expected_params);
assert!(frames[0].interactive, "a Button slot is interactive");
}
}
#[test]
fn a_display_only_control_never_sets_interactive() {
let view = native_label("hi").build_with_mode(4, ResolvedSurfaceMode::Opaque, None);
let mut element = build_any(view);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 30.0));
element.paint(&mut pctx, &mut scene);
let frames = pctx.take_platform_views();
assert_eq!(frames.len(), 1);
assert!(!frames[0].interactive, "Label never forwards native input");
}
#[derive(Default)]
struct BoundsRecorder {
clip_stack: Vec<Rect>,
painted: Vec<Rect>,
glyph_runs: usize,
}
impl BoundsRecorder {
fn record(&mut self, rect: Rect) {
let bounded = match self.clip_stack.last() {
Some(clip) => rect.intersect(*clip),
None => rect,
};
if bounded.width() > 0.0 && bounded.height() > 0.0 {
self.painted.push(bounded);
}
}
}
impl PaintScene for BoundsRecorder {
fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
self.record(Rect::from_origin_size(origin, size));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, _radius: f64, _color: Color) {
self.record(Rect::from_origin_size(origin, size));
}
fn stroke_path(
&mut self,
origin: Point,
path: &kurbo::BezPath,
width: f64,
_brush: &Brush,
) {
let bbox = path.bounding_box() + origin.to_vec2();
self.record(bbox.inflate(width, width));
}
fn push_clip(&mut self, origin: Point, size: Size) {
let rect = Rect::from_origin_size(origin, size);
let bounded = match self.clip_stack.last() {
Some(prev) => rect.intersect(*prev),
None => rect,
};
self.clip_stack.push(bounded);
}
fn pop_clip(&mut self) {
self.clip_stack.pop();
}
fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
let Some(first) = run.glyphs.first() else {
return;
};
self.glyph_runs += 1;
let base = run.transform.translation();
let margin = run.font_size as f64;
let (min_x, max_x) = run
.glyphs
.iter()
.map(|g| g.x as f64)
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| {
(lo.min(x), hi.max(x))
});
let rect = Rect::new(
base.x + min_x - margin,
base.y + first.y as f64 - margin,
base.x + max_x + margin,
base.y + first.y as f64 + margin,
);
self.record(rect);
}
}
fn rect_fits_within(outer: Rect, inner: Rect) -> bool {
const TOLERANCE: f64 = 0.01;
inner.x0 >= outer.x0 - TOLERANCE
&& inner.y0 >= outer.y0 - TOLERANCE
&& inner.x1 <= outer.x1 + TOLERANCE
&& inner.y1 <= outer.y1 + TOLERANCE
}
#[test]
fn placeholder_paints_visible_text_within_its_slot_rect_at_every_slot_size() {
for (label, (w, h)) in [
("native_switch's own box (70x40)", (70.0, 40.0)),
("native_progress's own box (260x24)", (260.0, 24.0)),
("native_button's own box (160x48)", (160.0, 48.0)),
("a deliberately tiny box (40x16)", (40.0, 16.0)),
] {
let view: AnyView<()> = placeholder(Some((w, h)), "Switch");
let mut element = build_any(view);
let mut text_ctx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
let bc = BoxConstraints::tight(Size::new(w, h));
let laid = element.layout(&mut lctx, &bc);
assert_eq!(
laid,
Size::new(w, h),
"{label}: the placeholder's own reported size must stay exactly the \
slot's declared size"
);
let mut rec = BoundsRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, laid);
element.paint(&mut pctx, &mut rec);
assert!(
!rec.painted.is_empty(),
"{label}: expected the placeholder to paint something"
);
assert!(
rec.glyph_runs >= 2,
"{label}: expected the banner's label AND description to paint as glyph \
runs, saw {} — a fill-only banner leaves a sighted user with no warning",
rec.glyph_runs
);
let slot_rect = Rect::from_origin_size(Point::ZERO, laid);
for bounds in &rec.painted {
assert!(
rect_fits_within(slot_rect, *bounds),
"{label}: painted bounds {bounds:?} escaped the slot rect {slot_rect:?}"
);
}
}
}
#[test]
fn a_refused_slot_still_publishes_no_platform_view_frame_through_the_clip_wrapper() {
let view =
native_switch(true).build_with_mode(2, ResolvedSurfaceMode::RefusedTranslucent, None);
let mut element = build_any(view);
let mut text_ctx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
element.layout(&mut lctx, &BoxConstraints::tight(Size::new(70.0, 40.0)));
let mut rec = BoundsRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(70.0, 40.0));
element.paint(&mut pctx, &mut rec);
assert!(
pctx.take_platform_views().is_empty(),
"a refused slot must render no native platform_view frame, even through the \
clip wrapper"
);
}
}