Skip to main content

frust_native_widgets/api/
builders.rs

1//! The eleven app-facing builders: `native_button`/
2//! `native_label`/`native_switch`/`native_slider`/`native_progress`/
3//! `native_image`/`native_spinner`/`native_date_picker`/`native_segmented`/
4//! `native_stepper`/`native_tab_bar`, each
5//! composing exactly one [`platform_view`] slot behind this crate's one
6//! factory per platform (the "N controls = N slots" envelope) —
7//! `native_segmented`/`native_stepper` only on iOS and macOS and
8//! `native_tab_bar` only on iOS; elsewhere each renders its own refusal
9//! banner at compile time ([`SEGMENTED_ARM`]/[`STEPPER_ARM`]/
10//! [`TAB_BAR_ARM`]).
11//!
12//! # Retained identity via `Component`, not a hand-rolled `Widget`
13//!
14//! Each builder is a plain, `Clone`-able data struct implementing
15//! [`Component`] (`frust-core`'s retained-local-state seam,
16//! `docs/ARCHITECTURE.md`'s Component state boundary): `Component::State` is
17//! a bare [`SlotId`], allocated once in `Component::init` and retained across
18//! every rebuild by the `ComponentWidget` frust-core builds around it. That
19//! stable id is what gets injected into `params_json` as `__frustSlot`
20//! (`crate::runtime`'s generic-factory contract) — the builder function
21//! itself runs fresh every rebuild (a new struct value, current props), but
22//! the SAME slot id round-trips through every `create`/`update_params` call
23//! this plugin's runtime ever sees for that widget instance. Note this slot
24//! id is this plugin's OWN bookkeeping key (drawn from a private counter,
25//! below) — it never needs to equal `frust_core::widget::next_slot_id()`'s
26//! differ-facing id for the SAME `platform_view` widget, because nothing on
27//! the platform side ever compares the two: `create`/`update_params` key
28//! `NativeRuntime`'s own registry by whatever `__frustSlot` says, and
29//! `disposeView` resolves by native-view **object identity**, never a slot
30//! id at all (`crate::android`'s module doc, "Which call carries the slot
31//! id").
32//!
33//! Each builder therefore implements `View<Outer>` for **every** `Outer` by
34//! hand-delegating to [`frust_core::component`] (mirroring
35//! `ComponentView<C>`'s own blanket impl) — see the [`impl_native_view!`]
36//! macro at the bottom of this file.
37//!
38//! # No public `NativeWidget` trait
39//!
40//! None of this reaches for `crate::runtime::NativeWidget` (which stays
41//! `pub(crate)` by design) — a builder only ever calls the
42//! runtime's already-`pub(crate)` `with_runtime`/`set_callback` seam, same
43//! crate.
44//!
45//! # The translucency-refused fallback
46//!
47//! Every builder consults `frust::resolved_surface_mode()` before composing
48//! its native slot: on [`ResolvedSurfaceMode::RefusedTranslucent`], it
49//! renders [`placeholder`] instead — a frust-drawn box that both *paints*
50//! the refusal as visible warning prose and publishes the same wording to a
51//! screen reader — rather than an invisible, untappable native slot
52//! (`docs/ARCHITECTURE.md`'s Platform-view flow: "App Rust now is told ...
53//! so a plugin can fall back deliberately instead of a dead slot"). The
54//! refusal is logged once, crate-wide, not once per control per frame.
55
56use 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/// The one factory class every control resolves through, per platform.
90///
91/// - **Android**: `dev.frust.nativewidgets.FrustNativeControlFactory`, the
92///   class in this plugin's own `com.android.library` module
93///   (`plugins/native-widgets/platform/android`), which a consuming app wires
94///   in via `Contribution::GradleModule` — never a copied file. The
95///   `dev.frust.` prefix is required by `FrustViewHost`'s factory resolution
96///   and the `nativewidgets` subpackage by the packaging rule; both halves
97///   are baked into the JNI export symbol names (`crate::android`'s *Package*
98///   note), so this string is fixed once shipped.
99/// - **iOS**: the bare Objective-C runtime name `FrustNativeControlFactory`,
100///   which `FrustViewHost.resolveFactory` feeds to `NSClassFromString`
101///   (`docs/CODE_STANDARDS.md`'s Naming Conventions: iOS has no package
102///   prefix). That class is a Rust `define_class!` class — no
103///   Swift — and **this string must stay byte-identical to
104///   `crate::apple::factory::FACTORY_CLASS_NAME`**, which is the name that
105///   class registers under. A mismatch is silent: the lookup returns nil, the
106///   host takes its unresolvable-factory branch, and every native control on
107///   iOS renders nothing.
108/// - **macOS**: the desktop registry key `crate::appkit::factory::VIEW_TYPE`
109///   (`"dev.frust.nativewidgets.FrustNativeControlFactory"`, the Android
110///   spelling), named here rather than repeated: the desktop Mode-A host
111///   resolves a slot's factory by looking this exact string up in
112///   `frust_plugin::desktop`'s registry, where the AppKit arm registered it.
113///   A mismatch would be just as silent as on iOS — no factory, an empty slot.
114/// - **Anywhere else** (Linux/Windows/web): no factory exists; the Android
115///   spelling stands in so the constant is always defined.
116///
117/// `pub(super)` rather than private: the generic mounting builder
118/// ([`crate::api::mount`]) composes the same one factory these eight do —
119/// a public component is served by the same runtime, so it must resolve
120/// through the same class.
121#[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
130/// This plugin's own per-widget-instance identity counter (module doc: "this
131/// slot id is this plugin's OWN bookkeeping key"). Deliberately independent
132/// of `frust_core::widget::next_slot_id()` — nothing on the platform side
133/// ever compares the two, so a private counter avoids reaching into
134/// `frust-core`'s widget-tree internals for a value nothing downstream reads
135/// as a differ id.
136///
137/// `pub(super)` — the generic mounting builder
138/// ([`crate::api::mount`]) draws its slot ids from the same counter, so a
139/// public component and a built-in control can never collide on one.
140pub(super) fn next_local_slot() -> SlotId {
141    static NEXT: AtomicU64 = AtomicU64::new(1);
142    NEXT.fetch_add(1, Ordering::Relaxed)
143}
144
145/// Log the translucency-refused fallback exactly once, crate-wide — never
146/// once per control, never once per frame.
147static 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
160/// The frust-drawn placeholder every builder degrades to under
161/// [`ResolvedSurfaceMode::RefusedTranslucent`]: a sized box holding a
162/// [`RefusalBanner`] — a warning fill and border, the label plus explanation
163/// painted as visible prose, and the same wording published as a
164/// `Role::Alert` semantics node — instead of an invisible, untappable native
165/// slot. Both halves matter: a sighted user sees the warning, a screen-reader
166/// user hears it.
167///
168/// # Why the banner is crate-local
169///
170/// No design-system catalog is reachable from here: this is a platform
171/// plugin, and its dependency charter is `frust`/`frust-core` plus FFI
172/// (`docs/CODE_STANDARDS.md`'s Plugin Conventions), while every design system
173/// — including whichever one the consuming app installed — ships as its own
174/// plugin crate beside this one. So the banner is a thin crate-local
175/// `View`/`Widget` pair, the same shape [`ClipToSlot`] below already uses,
176/// shaping its own runs through [`BannerText`] and painting from the ambient
177/// [`Theme`]'s own error roles with an unthemed fallback
178/// (`docs/WIDGETS_CODE_STANDARDS.md`'s token-resolution rule) rather than
179/// borrowing a catalog's alert widget.
180///
181/// # The clip-to-slot fix for oversized placeholder prose
182///
183/// The explanatory prose is longer than most slot boxes allow (e.g.
184/// `native_switch`'s 70x40, `native_progress`'s 260x24), and `SizedBox` only
185/// tightens the reported [`Size`] the layout pass sees — it does not stop a
186/// child from drawing content sized off its own unclamped natural extent. The
187/// banner budgets its runs against the slot width, but a slot too short for
188/// even one wrapped line still overflows vertically. Clipping the whole
189/// banner's paint to the slot rect (below) is therefore load-bearing: it lets
190/// the full explanation stay painted, in the semantics tree, and in the
191/// one-time [`warn_refusal_once`] log while guaranteeing the placeholder never
192/// paints outside its own slot, at any slot size the builders allow.
193///
194/// `pub(super)`: the generic mounting builder
195/// ([`crate::api::mount`]) degrades through this same placeholder — including
196/// its clip wrapper — rather than re-deriving the refusal path.
197pub(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
211/// The sized, slot-clipped [`RefusalBanner`] itself, with caller-chosen
212/// wording — [`placeholder`]'s body, shared with the compile-time
213/// no-platform-arm fallback ([`NativeSegmentedView`] on Android, see
214/// [`SEGMENTED_ARM`]), which refuses for a different reason and so says a
215/// different thing. Logging is the caller's: each refusal reason logs once
216/// under its own `Once`.
217// erasure: keep result is a child of mixed-type if/else arms and AnyView-returning builders
218fn 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
228/// Unthemed fallback fill for [`RefusalBanner`] — a muted warning amber, used
229/// only when no [`Theme`] is threaded into the paint pass (a bare-core test).
230/// A themed paint reads `error_container`/`outline` instead.
231const REFUSAL_FILL: Color = Color::from_rgb8(0xFF, 0xDD, 0xB0);
232
233/// Unthemed fallback border for [`RefusalBanner`]. See [`REFUSAL_FILL`].
234const REFUSAL_BORDER: Color = Color::from_rgb8(0x8A, 0x53, 0x00);
235
236/// Unthemed fallback prose colour for [`RefusalBanner`] — the "on" role for
237/// [`REFUSAL_FILL`], dark enough to read over that amber. A themed paint reads
238/// `on_error_container` instead. See [`REFUSAL_FILL`].
239const REFUSAL_TEXT: Color = Color::from_rgb8(0x3B, 0x24, 0x00);
240
241/// Inset between the banner's border hairline and its prose, in logical px.
242const REFUSAL_PAD: f64 = 4.0;
243/// Gap between the label row and the description row, in logical px.
244const REFUSAL_ROW_GAP: f64 = 2.0;
245/// Label font size, in logical px — deliberately small, because the slots
246/// these placeholders stand in for are themselves small (`native_switch`'s
247/// 70x40 is the reference case).
248const REFUSAL_LABEL_SIZE: f32 = 12.0;
249/// Description font size, in logical px. See [`REFUSAL_LABEL_SIZE`].
250const REFUSAL_DESC_SIZE: f32 = 11.0;
251/// Prose line height, as a multiple of the font size.
252const REFUSAL_LINE_HEIGHT: f32 = 1.3;
253
254/// A minimal retained text run: shape once per (content, style, width),
255/// measure during `layout`, emit glyph runs during `paint`.
256///
257/// Mirrors `plugins/glyph/src/alert.rs`'s `GlyphLabel` — that module's own
258/// docs sanction duplicating this small helper per crate rather than sharing
259/// one, and a platform plugin could not share it anyway: it may not depend on
260/// a design-system plugin at all (`docs/PLUGINS_CODE_STANDARDS.md`'s charter
261/// line).
262///
263/// The shaping vocabulary comes through `frust::authoring::text` rather than a
264/// direct `frust-text` dependency — the one place this file reaches through
265/// the facade instead of naming a framework crate (`frust-core` is named
266/// directly, per this crate's `Cargo.toml`). `frust-text` is a **dev**-only
267/// dependency here, and the refusal placeholder is not worth promoting it to a
268/// production one: the facade re-export costs nothing and stays behind the
269/// same default-on `frust-api` feature gate as `frust` itself, so the
270/// `--no-default-features` charter line is untouched.
271struct 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    /// Emit this run's glyphs at `origin`. A no-op before the first
314    /// [`BannerText::layout`] — a paint without a preceding layout pass draws
315    /// no text rather than panicking for want of a text context.
316    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
341/// Explicit builder value > theme > fallback constant
342/// (`docs/WIDGETS_CODE_STANDARDS.md`) — there is no explicit override on the
343/// banner, so: the theme's `on_error_container` (the "on" role for the
344/// `error_container` fill the banner paints under it), else [`REFUSAL_TEXT`].
345fn 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
352/// The refusal placeholder's own visual + accessible body — see
353/// [`placeholder`] for why it is crate-local rather than a catalog widget.
354///
355/// Paints a filled, outlined box across whatever the slot gave it (so a
356/// refused control still reads as a deliberate "something is wrong here"
357/// marker rather than a hole) carrying `label` and `description` as visible
358/// prose, and publishes exactly one `Role::Alert` semantics node with the same
359/// two strings — the accessible half of the same refusal contract.
360struct RefusalBanner {
361    label: String,
362    description: String,
363}
364
365/// The retained widget for [`RefusalBanner`].
366///
367/// `label`/`description` are kept as plain strings alongside their
368/// [`BannerText`] runs because [`RefusalBannerWidget::semantics`] reports the
369/// wording whether or not a layout pass ever shaped it (the same split
370/// `AlertWidget` uses in `plugins/glyph/src/alert.rs`).
371struct 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        // New wording re-shapes, so this is a layout change, not paint-only.
408        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        // Fill whatever the enclosing `SizedBox` tightened to; with no
417        // explicit slot size the builders leave it filling the parent.
418        let size = bc.max();
419
420        // Colour is baked into the shaped runs, so it is resolved here rather
421        // than at paint time — the same reason `AlertWidget::layout` reads the
422        // theme during layout.
423        let color = refusal_text_color(Theme::from_layout_ctx(ctx));
424        // Budget both runs against the slot, minus the prose inset on each
425        // side, so the wording wraps inside the slot instead of running off
426        // its natural single-line extent. A non-finite max (an unconstrained
427        // parent, i.e. no explicit `.size(w, h)`) means "no wrap budget"
428        // rather than a wrap at infinity.
429        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        // Explicit builder value > theme > fallback constant
445        // (`docs/WIDGETS_CODE_STANDARDS.md`) — there is no explicit override
446        // here, so: theme, else the two `REFUSAL_*` constants.
447        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        // A 1px inset hairline, drawn as four edge fills rather than a
457        // stroked path so it needs no `BezPath`/`Brush` vocabulary here.
458        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        // The prose paints last, over the fill and the border hairline. What
475        // keeps a run that outgrows a short slot from escaping it is
476        // [`ClipToSlot`], which wraps the whole banner (see [`placeholder`]) —
477        // deliberately, so the wording stays complete rather than truncated.
478        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
491/// Clips its child's paint to this widget's own laid-out bounds — see
492/// [`placeholder`]'s "The clip-to-slot fix for oversized placeholder prose"
493/// for why this exists instead of truncating the prose itself. A thin,
494/// crate-local wrapper (not a `frust-widgets` container) built directly
495/// against [`AnyView`]/[`Widget`] rather than `frust-widgets`' crate-private
496/// `ChildPod` plumbing, which this crate has no access to
497/// (`docs/CODE_STANDARDS.md`'s Plugin Conventions).
498struct ClipToSlot<State: 'static> {
499    child: AnyView<State>,
500}
501
502/// The retained widget for [`ClipToSlot`].
503struct 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        // The clip rect is THIS widget's own laid-out origin/size — exactly
537        // the slot rect the placeholder was given, never the child's
538        // unclamped natural content size.
539        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        // Transparent wrapper: forward unchanged so the full title+body
546        // detail still reaches a screen reader regardless of what got
547        // visually clipped (`docs/CODE_STANDARDS.md`'s Semantics
548        // Conventions — a container must forward, never drop, a child's
549        // subtree).
550        self.child.semantics(ctx);
551    }
552}
553
554/// Apply an explicit `.size(w, h)` if the caller provided one, else leave the
555/// slot at [`PlatformViewView`]'s own default (fill the parent) — v1 has no
556/// measure step either way, so an explicit `.size(w,h)` is required and an
557/// omitted call degrades to filling the parent rather than a made-up
558/// constant. `pub(super)`, for
559/// [`crate::api::mount`]'s generic builder.
560pub(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
567/// The active theme's resolved tokens (theme ladder L2), or `None`
568/// when no theme has been threaded — `use_context::<Theme>()`'s own
569/// documented `None` cases (a bare-core test, a build running outside any
570/// reactive `Owner`; `reactive_graph::owner::use_context`'s own doc: "Panics
571/// if no value is found" only applies to its `expect_context` sibling, never
572/// this one). Every builder's `Component::build` calls this once per
573/// rebuild — the mechanism `crates/frust/tests/
574/// theme_reactivity_spike.rs` proves is what makes that rebuild re-run, and
575/// therefore re-resolve, on `set_app_theme` — and threads the result into
576/// `build_with_mode` explicitly, the same "thread it as a parameter so a
577/// test can force it" shape `resolved_surface_mode()` already uses above.
578fn ambient_theme_tokens() -> Option<ResolvedTheme> {
579    use_context::<Theme>().as_ref().map(theme::resolve)
580}
581
582/// A tiny flat-JSON body writer — this crate hand-rolls JSON at the wire
583/// boundary rather than pulling in `serde` (`docs/CODE_STANDARDS.md`'s
584/// Language Idioms; `crate::runtime::Params`/`with_identity` are the
585/// reader/identity-encoder halves this writes the *body* half for). Only the
586/// handful of primitive field shapes the eight controls need.
587struct 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    /// A raw (unquoted) literal — a bool or integer, whose `Display` already
604    /// matches JSON's own spelling (`true`/`false`, plain digits).
605    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    /// A JSON string value, escaped via [`crate::runtime::escape`].
611    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    /// A string field only when present — a missing optional field decodes
619    /// to the control's own platform default (`crate::controls`'s
620    /// degrade-don't-fail rule), so omitting the key entirely is correct.
621    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// ============================================================================
633// Button
634// ============================================================================
635
636/// A real `android.widget.Button` rendered from pure Rust — see the
637/// [module docs](self). Build one with [`native_button`].
638#[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
647/// A native `Button` captioned `text` — see [`NativeButtonView`].
648pub 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    /// `View.setEnabled` — default `true`.
660    pub fn enabled(mut self, enabled: bool) -> Self {
661        self.enabled = enabled;
662        self
663    }
664
665    /// The TalkBack label; falls back to the caption when unset.
666    pub fn content_description(mut self, label: impl Into<String>) -> Self {
667        self.content_description = Some(label.into());
668        self
669    }
670
671    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
672    /// fallback.
673    pub fn size(mut self, width: f64, height: f64) -> Self {
674        self.size = Some((width, height));
675        self
676    }
677
678    /// Fires on a tap, on the platform main thread (events-as-signals — see
679    /// `crate::api::signals`): write an `RwSignal` from inside for the
680    /// blessed one-frame-wake idiom.
681    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    /// The encoded `params_json` for `slot` — split out from
687    /// [`Component::build`] so a test can snapshot it directly. `tokens`
688    /// (theme ladder L2) folds the active theme's background/text
689    /// colour, corner radius, and text size in, threaded explicitly like
690    /// `mode` below so a test can pin an exact resolved value without a live
691    /// reactive context.
692    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    /// [`Component::build`]'s real body, with `mode`/`tokens` threaded
709    /// explicitly so a test can force the
710    /// [`ResolvedSurfaceMode::RefusedTranslucent`] branch or an exact theme
711    /// resolution without touching the process-global resolved-mode slot
712    /// (whose writer is pinned to the two shells' own FFI glue,
713    /// `crates/frust/tests/surface_mode_conformance.rs`) or a live reactive
714    /// context.
715    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        // Ties this slot's `NativeRuntime::pending_callbacks` entry to the
746        // Component's own lifetime, not to the native create/dispose
747        // lifecycle (the same remedy `NativeImageView::init` applies to
748        // the identical leak shape one table over — see
749        // `crate::runtime`'s doc on `pending_callbacks` and
750        // `NativeRuntime::forget_pending_callback`). `init` runs exactly
751        // once, under this component's own `Owner`, so `on_cleanup` fires
752        // exactly once when that owner disposes — regardless of whether a
753        // culled-then-republished cycle already parked a second pending
754        // entry `dispose_slot` never sees.
755        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// ============================================================================
767// Label
768// ============================================================================
769
770/// A real `android.widget.TextView` rendered from pure Rust — display-only
771/// (no listener, no `.interactive()`). Build one with [`native_label`].
772#[derive(Clone)]
773pub struct NativeLabelView {
774    text: String,
775    enabled: bool,
776    content_description: Option<String>,
777    size: Option<(f64, f64)>,
778}
779
780/// A native `Label` showing `text` — see [`NativeLabelView`].
781pub 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    /// `View.setEnabled` — a `TextView` renders its disabled state through
792    /// the colour state list, so this is visible even without interaction.
793    pub fn enabled(mut self, enabled: bool) -> Self {
794        self.enabled = enabled;
795        self
796    }
797
798    /// The TalkBack label; falls back to `text` when unset.
799    pub fn content_description(mut self, label: impl Into<String>) -> Self {
800        self.content_description = Some(label.into());
801        self
802    }
803
804    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
805    /// fallback.
806    pub fn size(mut self, width: f64, height: f64) -> Self {
807        self.size = Some((width, height));
808        self
809    }
810
811    /// `tokens` (theme ladder L2, including a later followup) folds the
812    /// active theme's background/body-text colour and size in — see
813    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
814    /// explicitly, and [`crate::api::theme`]'s module doc's *Explicit
815    /// backgrounds* section for why an EXPLICIT background is folded here
816    /// too — it used to be entirely absent, pinning `Label` to
817    /// whichever brightness it was created under.
818    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    // No `on_cleanup` here: `Label` is display-only and never calls
858    // `set_callback`, so its slot never has a `pending_callbacks` entry to
859    // reap. `NativeButtonView::init`'s doc explains the cleanup this
860    // Component deliberately omits.
861    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// ============================================================================
871// Switch
872// ============================================================================
873
874/// A real `android.widget.Switch` rendered from pure Rust — a **controlled**
875/// component (`docs/CODE_STANDARDS.md`'s Interaction Semantics): the app owns
876/// `checked`, and a user toggle only ever arrives through [`Self::on_toggle`]
877/// as a *requested* value. Build one with [`native_switch`].
878#[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
887/// A native `Switch` at the app-owned `checked` state — see
888/// [`NativeSwitchView`].
889pub 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    /// `View.setEnabled` — default `true`.
901    pub fn enabled(mut self, enabled: bool) -> Self {
902        self.enabled = enabled;
903        self
904    }
905
906    /// The TalkBack label.
907    pub fn content_description(mut self, label: impl Into<String>) -> Self {
908        self.content_description = Some(label.into());
909        self
910    }
911
912    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
913    /// fallback.
914    pub fn size(mut self, width: f64, height: f64) -> Self {
915        self.size = Some((width, height));
916        self
917    }
918
919    /// Fires with the *requested* checked state on a user toggle — the app
920    /// confirms (or rejects) it by feeding `checked` back through the next
921    /// build, the controlled-component contract every interactive frust
922    /// widget follows.
923    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    /// `tokens` (theme ladder L2) folds the active theme's
929    /// thumb/track tints in — see [`NativeButtonView::params_for`]'s doc for
930    /// why it's threaded explicitly. Deliberately **no** background fold
931    /// (reversing an earlier followup that added one): see [`crate::api::theme`]'s
932    /// module doc's *Explicit backgrounds* section for why `Switch` is
933    /// excluded — a flat `View.setBackgroundColor` here would replace
934    /// `?attr/selectableItemBackgroundBorderless`'s touch ripple, and the
935    /// thumb/track tints below already carry the theme without it.
936    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            // `Switch` never sets on/off text through this plugin today, but
946            // it's a `TextView` subclass under the hood (`android.widget.Switch
947            // extends CompoundButton extends Button extends TextView`) — see
948            // `crate::api::theme`'s module doc on why this shares `Label`'s
949            // typeface rather than going unset.
950            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        // See `NativeButtonView::init`'s doc — `Switch` registers a
986        // callback via `Self::on_toggle`, so it needs the same
987        // `pending_callbacks` reaper.
988        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// ============================================================================
1000// Slider
1001// ============================================================================
1002
1003/// A real `android.widget.SeekBar` rendered from pure Rust — controlled, like
1004/// [`NativeSwitchView`]: the app owns `value`, and a drag only ever arrives
1005/// through [`Self::on_change`] as a requested value. Build one with
1006/// [`native_slider`].
1007#[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
1018/// A native `Slider` at `value`, ranging over `[min, max]` — see
1019/// [`NativeSliderView`].
1020pub 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    /// `View.setEnabled` — default `true`.
1034    pub fn enabled(mut self, enabled: bool) -> Self {
1035        self.enabled = enabled;
1036        self
1037    }
1038
1039    /// The TalkBack label.
1040    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1041        self.content_description = Some(label.into());
1042        self
1043    }
1044
1045    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1046    /// fallback.
1047    pub fn size(mut self, width: f64, height: f64) -> Self {
1048        self.size = Some((width, height));
1049        self
1050    }
1051
1052    /// Fires with the requested **app-space** value on a drag
1053    /// (`crate::controls::slider`'s platform-space mapping already undone).
1054    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    /// `tokens` (theme ladder L2) folds the active theme's
1060    /// progress/thumb tints in — see [`NativeButtonView::params_for`]'s doc
1061    /// for why it's threaded explicitly. Deliberately **no** background fold
1062    /// (reversing an earlier followup that added one): see [`crate::api::theme`]'s
1063    /// module doc's *Explicit backgrounds* section for why `Slider` is
1064    /// excluded — a flat `View.setBackgroundColor` here would replace
1065    /// `AbsSeekBar`'s `?attr/selectableItemBackgroundBorderless` touch
1066    /// ripple, and the progress/thumb tints below already carry the theme
1067    /// without it.
1068    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        // See `NativeButtonView::init`'s doc — `Slider` registers a
1114        // callback via `Self::on_change`, so it needs the same
1115        // `pending_callbacks` reaper.
1116        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// ============================================================================
1128// Progress
1129// ============================================================================
1130
1131/// A real `android.widget.ProgressBar` rendered from pure Rust — display-only
1132/// (no listener, no `.interactive()`). Build one with [`native_progress`].
1133#[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
1143/// A native `ProgressBar` at `value`, ranging over `[min, max]` — see
1144/// [`NativeProgressView`].
1145pub 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    /// Spinner mode: `true` ignores `value` entirely.
1158    pub fn indeterminate(mut self, indeterminate: bool) -> Self {
1159        self.indeterminate = indeterminate;
1160        self
1161    }
1162
1163    /// The TalkBack label.
1164    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1165        self.content_description = Some(label.into());
1166        self
1167    }
1168
1169    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1170    /// fallback.
1171    pub fn size(mut self, width: f64, height: f64) -> Self {
1172        self.size = Some((width, height));
1173        self
1174    }
1175
1176    /// `tokens` (theme ladder L2, including a later followup) folds the
1177    /// active theme's background/progress tint in — see
1178    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
1179    /// explicitly.
1180    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    // No `on_cleanup` here: `ProgressBar` is display-only and never
1220    // calls `set_callback` — see `NativeLabelView::init`'s doc.
1221    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// ============================================================================
1231// Spinner
1232// ============================================================================
1233
1234/// How large the spinner renders — the public mirror of
1235/// `crate::controls::spinner::SizeClass`, which stays `pub(crate)`. iOS has
1236/// no distinct small style (`UIActivityIndicatorView.Style` offers only
1237/// `.medium`/`.large`), so [`Self::Small`] renders the same as
1238/// [`Self::Medium`] on that one arm — see `crate::controls::spinner`'s
1239/// module doc.
1240#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1241pub enum NativeSpinnerSize {
1242    /// The smallest stock size. Degrades to [`Self::Medium`] on iOS.
1243    Small,
1244    /// The platform's own default circular spinner size.
1245    #[default]
1246    Medium,
1247    /// The largest stock size.
1248    Large,
1249}
1250
1251impl NativeSpinnerSize {
1252    /// The wire spelling `crate::controls::spinner::SizeClass::from_wire`
1253    /// decodes.
1254    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/// A real indeterminate activity indicator rendered from pure Rust —
1264/// display-only (no listener, no `.interactive()`). Build one with
1265/// [`native_spinner`].
1266#[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
1275/// A native spinner, animating or not — see [`NativeSpinnerView`].
1276pub 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    /// The spinner's size — see [`NativeSpinnerSize`].
1288    pub fn size_class(mut self, size_class: NativeSpinnerSize) -> Self {
1289        self.size_class = size_class;
1290        self
1291    }
1292
1293    /// `View.setEnabled` — Android only; the other two arms have no
1294    /// `enabled` property on this control at all (`crate::controls::spinner`'s
1295    /// module doc's *`enabled`* section). Default `true`.
1296    pub fn enabled(mut self, enabled: bool) -> Self {
1297        self.enabled = enabled;
1298        self
1299    }
1300
1301    /// The TalkBack/VoiceOver label.
1302    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1303        self.content_description = Some(label.into());
1304        self
1305    }
1306
1307    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1308    /// fallback.
1309    pub fn size(mut self, width: f64, height: f64) -> Self {
1310        self.size = Some((width, height));
1311        self
1312    }
1313
1314    /// `tokens` (theme ladder L2) folds the active theme's `accent_ink` in
1315    /// as the spinner's tint — see [`NativeButtonView::params_for`]'s doc
1316    /// for why it's threaded explicitly.
1317    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    // No `on_cleanup` here: the spinner is display-only and never calls
1355    // `set_callback` — see `NativeLabelView::init`'s doc.
1356    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// ============================================================================
1366// Date picker
1367// ============================================================================
1368
1369/// How [`NativeDatePickerView`] presents itself — the public mirror of
1370/// `crate::controls::date_picker::DatePickerStyle`, which stays `pub(crate)`.
1371///
1372/// | Style | Android | iOS | macOS |
1373/// |---|---|---|---|
1374/// | [`Self::Compact`] | spinner mode | `.compact` | text field + stepper, calendar overlay on click |
1375/// | [`Self::Wheels`] | spinner mode | `.wheels` | text field + stepper (AppKit has no wheels) |
1376/// | [`Self::Inline`] | calendar mode | `.inline` | clock-and-calendar |
1377///
1378/// Android fixes the mode when the picker is created: a later style change
1379/// is not applied there (logged once) — see `crate::controls::date_picker`'s
1380/// module doc.
1381#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1382pub enum NativeDatePickerStyle {
1383    /// The smallest footprint the platform offers.
1384    #[default]
1385    Compact,
1386    /// Spinning wheels.
1387    Wheels,
1388    /// A full, always-visible calendar.
1389    Inline,
1390}
1391
1392impl NativeDatePickerStyle {
1393    /// The wire spelling `crate::controls::date_picker::DatePickerStyle`
1394    /// decodes.
1395    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/// A real DATE-mode picker (no time) rendered from pure Rust —
1405/// `android.widget.DatePicker`, `UIDatePicker`, `NSDatePicker`. Controlled,
1406/// like [`NativeSwitchView`]: the app owns `date`, and a pick only ever
1407/// arrives through [`Self::on_change`] as a *requested* [`CivilDate`] the app
1408/// confirms by feeding it back. Build one with [`native_date_picker`].
1409#[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
1421/// A native date picker showing `date` — see [`NativeDatePickerView`].
1422pub 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    /// The earliest selectable date — a missing bound is the arm's own
1437    /// default: unbounded on iOS/macOS, 1900-01-01 on Android. A `date`
1438    /// before it is shown (and reported) as `min`; a `max` before it
1439    /// collapses the range to the single day `min`. On Android, an
1440    /// explicit `min` (and `date`) outside the platform's own
1441    /// 1900-01-01..2100-12-31 range is clamped into it, with a warning
1442    /// (`native-widgets-android-date-picker-range` in LIMITATIONS.md).
1443    pub fn min(mut self, min: CivilDate) -> Self {
1444        self.min = Some(min);
1445        self
1446    }
1447
1448    /// The latest selectable date — a missing bound is the arm's own
1449    /// default: unbounded on iOS/macOS, 2100-12-31 on Android. A `date`
1450    /// after it is shown (and reported) as `max`. On Android, an explicit
1451    /// `max` (and `date`) outside the platform's own
1452    /// 1900-01-01..2100-12-31 range is clamped into it, with a warning
1453    /// (`native-widgets-android-date-picker-range` in LIMITATIONS.md).
1454    pub fn max(mut self, max: CivilDate) -> Self {
1455        self.max = Some(max);
1456        self
1457    }
1458
1459    /// The presentation — see [`NativeDatePickerStyle`]. Default
1460    /// [`NativeDatePickerStyle::Compact`].
1461    pub fn style(mut self, style: NativeDatePickerStyle) -> Self {
1462        self.style = style;
1463        self
1464    }
1465
1466    /// `View.setEnabled` / `UIControl.enabled` / `NSControl.enabled` —
1467    /// default `true`.
1468    pub fn enabled(mut self, enabled: bool) -> Self {
1469        self.enabled = enabled;
1470        self
1471    }
1472
1473    /// The TalkBack/VoiceOver label.
1474    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1475        self.content_description = Some(label.into());
1476        self
1477    }
1478
1479    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1480    /// fallback.
1481    pub fn size(mut self, width: f64, height: f64) -> Self {
1482        self.size = Some((width, height));
1483        self
1484    }
1485
1486    /// Fires with the **requested** date when the user picks one. Feed it
1487    /// back as the builder's `date` to accept it; keep the old one to refuse
1488    /// it (the picker snaps back on the next differing params).
1489    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    /// `tokens` (theme ladder L2) folds the active theme's `accent_ink` in as
1495    /// the tint and `body_text` as the text colour — see
1496    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
1497    /// explicitly, and `crate::api::theme`'s mapping table for which arm
1498    /// honours which (Android's `DatePicker` honours neither).
1499    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        // See `NativeButtonView::init`'s doc — the picker registers a
1550        // callback via `Self::on_change`, so it needs the same
1551        // `pending_callbacks` reaper.
1552        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// ============================================================================
1564// Segmented
1565// ============================================================================
1566
1567/// Whether this build's platform arm registers the segmented control — the
1568/// crate's first **compile-time** platform gate on a builder. `Segmented` is
1569/// in `crate::controls::APPLE_KINDS`: iOS and macOS carry a `NativeWidget`
1570/// impl, Android does not (decision D2 — `crate::controls::segmented`'s module
1571/// doc), and neither does any host target. Where it is `false`,
1572/// [`NativeSegmentedView`] renders the frust-drawn refusal banner
1573/// ([`RefusalBanner`], via [`banner_placeholder`]) instead of publishing a slot
1574/// no registered kind could serve — a visible, screen-reader-announced
1575/// refusal rather than the factory's silent empty dead-slot view.
1576///
1577/// A `cfg`-selected constant rather than `cfg`'d code paths so both branches
1578/// compile, and are host-tested ([`NativeSegmentedView::build_for_arm`]), on
1579/// every target.
1580#[cfg(any(target_os = "ios", target_os = "macos"))]
1581const SEGMENTED_ARM: bool = true;
1582/// See the Apple-arm definition above: no segmented arm on Android (D2) or on
1583/// any host target.
1584#[cfg(not(any(target_os = "ios", target_os = "macos")))]
1585const SEGMENTED_ARM: bool = false;
1586
1587/// The banner's visible label on a target with no segmented arm.
1588const SEGMENTED_UNAVAILABLE_LABEL: &str = "Native segmented control unavailable";
1589
1590/// The banner's explanation on a target with no segmented arm — names the
1591/// missing Android arm and where it is tracked.
1592const 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
1596/// Log the no-segmented-arm fallback exactly once, crate-wide.
1597static 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/// A real segmented control rendered from pure Rust — `UISegmentedControl` on
1610/// iOS/iPadOS, `NSSegmentedControl` on macOS, and a frust-drawn refusal banner
1611/// everywhere else (Android included: see [`SEGMENTED_ARM`]). A
1612/// **controlled** component, like [`NativeSwitchView`]: the app owns
1613/// `selected`, and a tap only ever arrives through [`Self::on_select`] as a
1614/// *requested* index. Build one with [`native_segmented`].
1615#[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
1626/// A native segmented control over `labels`, with the app-owned `selected`
1627/// segment — see [`NativeSegmentedView`]. An out-of-range `selected` shows no
1628/// selection rather than failing; at most 64 segments are shown
1629/// (`crate::controls::segmented::MAX_SEGMENTS`).
1630pub 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    /// `UIControl.enabled` / `NSControl.enabled` — default `true`.
1644    pub fn enabled(mut self, enabled: bool) -> Self {
1645        self.enabled = enabled;
1646        self
1647    }
1648
1649    /// Momentary tracking: a tap flashes its segment and reports it through
1650    /// [`Self::on_select`] without leaving it selected (a toolbar of
1651    /// actions rather than a choice). Default `false`.
1652    pub fn momentary(mut self, momentary: bool) -> Self {
1653        self.momentary = momentary;
1654        self
1655    }
1656
1657    /// The VoiceOver label.
1658    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1659        self.content_description = Some(label.into());
1660        self
1661    }
1662
1663    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1664    /// fallback.
1665    pub fn size(mut self, width: f64, height: f64) -> Self {
1666        self.size = Some((width, height));
1667        self
1668    }
1669
1670    /// Fires with the *requested* segment index on a user tap — the app
1671    /// confirms (or rejects) it by feeding `selected` back through the next
1672    /// build, the controlled-component contract every interactive frust
1673    /// widget follows. Never fires on a target with no segmented arm.
1674    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    /// `tokens` (theme ladder L2) folds the active theme's `accent_fill` in
1680    /// as the selected segment's tint — see [`NativeButtonView::params_for`]'s
1681    /// doc for why it's threaded explicitly, and
1682    /// `crate::controls::segmented`'s module doc for the per-arm property.
1683    /// The labels ride flat `segmentCount` + `segment<i>` keys (that module
1684    /// doc's *The labels ride flat params*).
1685    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    /// [`Self::build_with_mode`] with the platform-arm gate threaded as a
1712    /// parameter, so a host test can drive both branches on any target.
1713    ///
1714    /// No arm → the unavailable banner, before anything else: the refusal is
1715    /// structural, whatever the surface mode, and no callback is registered
1716    /// (no event could ever arrive for it).
1717    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        // See `NativeButtonView::init`'s doc — `Segmented` registers a
1757        // callback via `Self::on_select`, so it needs the same
1758        // `pending_callbacks` reaper (a harmless no-op on a target with no
1759        // arm, where nothing is ever registered).
1760        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// ============================================================================
1772// Stepper
1773// ============================================================================
1774
1775/// Whether this build's platform arm registers the stepper control — the
1776/// same compile-time platform gate [`SEGMENTED_ARM`] is, for `Stepper`
1777/// instead. `Stepper` is in `crate::controls::APPLE_KINDS`: iOS and macOS
1778/// carry a `NativeWidget` impl, Android does not (`android.widget` has no
1779/// increment/decrement control — `crate::controls::stepper`'s module doc),
1780/// and neither does any host target. Where it is `false`,
1781/// [`NativeStepperView`] renders the frust-drawn refusal banner
1782/// ([`RefusalBanner`], via [`banner_placeholder`]) instead of publishing a
1783/// slot no registered kind could serve.
1784///
1785/// A `cfg`-selected constant rather than `cfg`'d code paths so both branches
1786/// compile, and are host-tested ([`NativeStepperView::build_for_arm`]), on
1787/// every target.
1788#[cfg(any(target_os = "ios", target_os = "macos"))]
1789const STEPPER_ARM: bool = true;
1790/// See the Apple-arm definition above: no stepper arm on Android or on any
1791/// host target.
1792#[cfg(not(any(target_os = "ios", target_os = "macos")))]
1793const STEPPER_ARM: bool = false;
1794
1795/// The banner's visible label on a target with no stepper arm.
1796const STEPPER_UNAVAILABLE_LABEL: &str = "Native stepper unavailable";
1797
1798/// The banner's explanation on a target with no stepper arm — names the
1799/// missing Android arm and where it is tracked.
1800const 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
1804/// Log the no-stepper-arm fallback exactly once, crate-wide.
1805static 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/// A real increment/decrement control rendered from pure Rust — `UIStepper`
1818/// on iOS/iPadOS, `NSStepper` on macOS, and a frust-drawn refusal banner
1819/// everywhere else (Android included: see [`STEPPER_ARM`]). A **controlled**
1820/// component, like [`NativeSliderView`]: the app owns `value`, and a tap on
1821/// either button only ever arrives through [`Self::on_change`] as a
1822/// *requested* value. Build one with [`native_stepper`].
1823///
1824/// `min`/`max`/[`Self::step`] are normalized before they ever reach a
1825/// platform control (`crate::controls::stepper`'s module doc's *Range and
1826/// step invariants*: `UIStepper` aborts the process on a non-positive
1827/// `stepValue` or a `maximumValue` not strictly greater than
1828/// `minimumValue`). A degenerate range (`max <= min`) renders the control
1829/// **disabled** — the user can never tap it, and no value outside `[min,
1830/// max]` is ever reported — rather than refusing to mount or crashing; it
1831/// re-enables the moment a later update makes the range non-degenerate
1832/// again.
1833#[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
1846/// A native `Stepper` at `value`, ranging over `[min, max]` — see
1847/// [`NativeStepperView`].
1848pub 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    /// The increment a tap on either button applies — default `1`. A
1864    /// non-positive value (`0` or negative) is normalized to `1` before it
1865    /// ever reaches a platform control, logged once per process —
1866    /// `UIStepper` requires `stepValue > 0` (`crate::controls::stepper`'s
1867    /// module doc's *Range and step invariants*).
1868    pub fn step(mut self, step: i32) -> Self {
1869        self.step = step;
1870        self
1871    }
1872
1873    /// Whether the value wraps from `max` back to `min` (and back) instead
1874    /// of clamping at the bounds — default `false`.
1875    pub fn wraps(mut self, wraps: bool) -> Self {
1876        self.wraps = wraps;
1877        self
1878    }
1879
1880    /// `UIControl.enabled` / `NSControl.enabled` — default `true`.
1881    pub fn enabled(mut self, enabled: bool) -> Self {
1882        self.enabled = enabled;
1883        self
1884    }
1885
1886    /// The VoiceOver label.
1887    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1888        self.content_description = Some(label.into());
1889        self
1890    }
1891
1892    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1893    /// fallback.
1894    pub fn size(mut self, width: f64, height: f64) -> Self {
1895        self.size = Some((width, height));
1896        self
1897    }
1898
1899    /// Fires with the requested **app-space** value on a tap
1900    /// (`crate::controls::stepper`'s platform-space mapping already undone —
1901    /// the same mapping [`NativeSliderView::on_change`] documents). Never
1902    /// fires on a target with no stepper arm.
1903    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    /// `tokens` (theme ladder L2) folds the active theme's `accent_ink` in as
1909    /// `UIStepper.tintColor` — see [`NativeButtonView::params_for`]'s doc for
1910    /// why it's threaded explicitly, and `crate::controls::stepper`'s module
1911    /// doc's *Tint* section for why `NSStepper` never receives it (logged and
1912    /// no-op'd on that one arm, not a gap in this fold).
1913    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    /// [`Self::build_with_mode`] with the platform-arm gate threaded as a
1939    /// parameter, so a host test can drive both branches on any target —
1940    /// [`NativeSegmentedView::build_for_arm`]'s exact shape.
1941    ///
1942    /// No arm → the unavailable banner, before anything else: the refusal is
1943    /// structural, whatever the surface mode, and no callback is registered
1944    /// (no event could ever arrive for it).
1945    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        // See `NativeButtonView::init`'s doc — `Stepper` registers a
1985        // callback via `Self::on_change`, so it needs the same
1986        // `pending_callbacks` reaper (a harmless no-op on a target with no
1987        // arm, where nothing is ever registered).
1988        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// ============================================================================
2000// Tab bar
2001// ============================================================================
2002
2003/// Whether this build's platform arm registers the tab bar — the same
2004/// compile-time gate as [`SEGMENTED_ARM`], narrower: `TabBar` is in
2005/// `crate::controls::IOS_ONLY_KINDS`, so **only iOS/iPadOS** carries a
2006/// `NativeWidget` impl. macOS has no bottom-tab-bar idiom and Android's
2007/// `BottomNavigationView` needs Material (decision D2), so both — and every
2008/// host target — render the refusal banner.
2009#[cfg(target_os = "ios")]
2010const TAB_BAR_ARM: bool = true;
2011/// See the iOS definition above.
2012#[cfg(not(target_os = "ios"))]
2013const TAB_BAR_ARM: bool = false;
2014
2015/// The banner's visible label on a target with no tab-bar arm.
2016const TAB_BAR_UNAVAILABLE_LABEL: &str = "Native tab bar unavailable";
2017
2018/// The banner's explanation on a target with no tab-bar arm — names both
2019/// reasons (macOS idiom, Android Material).
2020const 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
2024/// Log the no-tab-bar-arm fallback exactly once, crate-wide.
2025static 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/// A tab's stable, app-chosen identity — what [`native_tab_bar`]'s
2038/// `selected` names and what [`NativeTabBarView::on_select`]/
2039/// [`NativeTabBarView::on_reselect`] report. Never an index: reordering or
2040/// inserting items keeps every id meaning the same tab.
2041#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2042pub struct TabId(String);
2043
2044impl TabId {
2045    /// A tab id spelled `id`.
2046    pub fn new(id: impl Into<String>) -> Self {
2047        Self(id.into())
2048    }
2049
2050    /// The id's spelling.
2051    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/// A tab's icon: encoded image bytes, or an Apple SF Symbol name — never an
2075/// arbitrary string passed off as a cross-platform icon (the bar is
2076/// iOS-only, and a symbol name means nothing anywhere else).
2077#[derive(Clone, Debug, PartialEq)]
2078pub enum TabIcon {
2079    /// Encoded image bytes (PNG/JPEG — whatever `UIImage` decodes), shown as
2080    /// a template image tinted by the bar. Normalized to a 25pt box: supply
2081    /// 75×75px for a crisp 3x icon; a smaller image is never upscaled.
2082    Bytes(Arc<[u8]>),
2083    /// An SF Symbol name (`"house"`, `"gearshape.fill"`) —
2084    /// `UIImage.systemImageNamed:`, sized by the bar itself. An unknown name
2085    /// shows the title alone (logged once).
2086    AppleSymbol(String),
2087}
2088
2089/// One tab of a [`native_tab_bar`]: a stable [`TabId`], a title, an icon,
2090/// and optionally a selected-state icon, a badge and a disabled state.
2091#[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    /// An enabled, badge-less tab `id` titled `title` showing `icon`.
2103    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    /// The icon shown while this tab is selected (`UITabBarItem.selectedImage`)
2115    /// — e.g. the `.fill` variant of an SF Symbol. Default: the bar tints
2116    /// [`Self::new`]'s icon.
2117    pub fn selected_icon(mut self, icon: TabIcon) -> Self {
2118        self.selected_icon = Some(icon);
2119        self
2120    }
2121
2122    /// A badge (`UITabBarItem.badgeValue`) — a count, `"new"`, or `""` for a
2123    /// bare dot-sized badge. Default: none.
2124    pub fn badge(mut self, badge: impl Into<String>) -> Self {
2125        self.badge = Some(badge.into());
2126        self
2127    }
2128
2129    /// Whether the tab can be tapped (`UIBarItem.enabled`). Default `true`.
2130    pub fn enabled(mut self, enabled: bool) -> Self {
2131        self.enabled = enabled;
2132        self
2133    }
2134
2135    /// This tab's id.
2136    pub fn id(&self) -> &TabId {
2137        &self.id
2138    }
2139}
2140
2141/// The SF Symbol name one icon slot carries on the wire (byte icons ride
2142/// the side table instead — `crate::controls::tab_bar`'s module doc).
2143fn symbol_name(icon: Option<&TabIcon>) -> Option<&str> {
2144    match icon {
2145        Some(TabIcon::AppleSymbol(name)) => Some(name),
2146        _ => None,
2147    }
2148}
2149
2150/// The encoded bytes one icon slot publishes, if it is a byte icon.
2151fn 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/// A real, bare `UITabBar` on iOS/iPadOS — a **controlled** bottom tab bar
2159/// that never navigates by itself: a tap reports the requested [`TabId`]
2160/// through [`Self::on_select`], the app routes (typically
2161/// `RouteNavigator::go`) and feeds the confirmed id back as `selected` on the
2162/// next build. A tap on the tab already showing reports through
2163/// [`Self::on_reselect`] instead ("scroll to top / pop to root"). macOS and
2164/// Android (and every host target) render a frust-drawn refusal banner.
2165/// Build one with [`native_tab_bar`].
2166///
2167/// # Placement and height
2168///
2169/// The bar sizes itself: 49pt tall plus the window's bottom safe-area inset,
2170/// read at layout time, as wide as its parent allows — so its background runs
2171/// under the home indicator while UIKit keeps the items above it. Put it last
2172/// in a `Column` docked to the window's bottom edge, and do **not** also
2173/// consume the bottom inset above it: if you wrap it in `frust::safe_area`
2174/// for horizontal cutouts, use `.top(false).bottom(false)` — the shape
2175/// `examples/huddle` uses for Material's `navigation_bar`, which self-insets
2176/// the same way. For a bar that is not docked to the bottom edge, call
2177/// [`Self::safe_area`]`(false)` to get the bare 49pt.
2178///
2179/// On iPadOS a bare `UITabBar` stays a bottom bar (the iPadOS 18 top tab bar
2180/// and the Liquid Glass floating bar are `UITabBarController` features), which
2181/// is expected.
2182#[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
2191/// A native bottom tab bar over `items`, with the app-owned `selected` tab —
2192/// see [`NativeTabBarView`]. A `selected` id no item carries shows no
2193/// selection; duplicate ids resolve to the first item carrying them; at most
2194/// 16 items are shown (`crate::controls::tab_bar::MAX_ITEMS`).
2195///
2196/// ```ignore
2197/// let nav = router.route_navigator();
2198/// native_tab_bar(
2199///     vec![
2200///         TabItem::new("home", "Home", TabIcon::AppleSymbol("house".into())),
2201///         TabItem::new("inbox", "Inbox", TabIcon::AppleSymbol("tray".into())).badge("3"),
2202///     ],
2203///     TabId::new(current_tab.get()),
2204/// )
2205/// .on_select(move |id| nav.go(format!("/{id}")))
2206/// ```
2207pub 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    /// Fires with the *requested* tab's id when the user taps a tab other
2219    /// than the one showing — the app confirms (or rejects) it by feeding
2220    /// `selected` back through the next build. Never fires on a target with
2221    /// no tab-bar arm.
2222    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    /// Fires with the showing tab's id when the user taps it again —
2228    /// conventionally "scroll to top" or "pop to the tab's root". Changes
2229    /// nothing by itself.
2230    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    /// Whether the bar grows by the window's bottom safe-area inset (default
2236    /// `true`, for a bar docked to the bottom edge). `false` keeps the bare
2237    /// 49pt, for a bar embedded mid-screen.
2238    pub fn safe_area(mut self, enabled: bool) -> Self {
2239        self.safe_area = enabled;
2240        self
2241    }
2242
2243    /// The index of the first item carrying `selected`, if any.
2244    fn selected_index(&self) -> Option<usize> {
2245        self.items.iter().position(|item| item.id == self.selected)
2246    }
2247
2248    /// The byte icons this bar publishes into the side table, one entry per
2249    /// item (`crate::controls::tab_bar::publish_icon_bytes`).
2250    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    /// `tokens` (theme ladder L2) folds `accent_ink` (selected tint),
2261    /// `muted` (unselected tint) and `surface_bg` (bar background) in — see
2262    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
2263    /// explicitly. `icons_rev` is the side table's revision for this slot
2264    /// (`crate::controls::tab_bar`'s module doc).
2265    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    /// [`Self::build_with_mode`] with the platform-arm gate threaded as a
2310    /// parameter, so a host test can drive both branches on any target.
2311    /// Every branch — banner, translucency placeholder, native slot — sits
2312    /// inside the same inset-aware [`TabBarSlot`], so the bar's footprint is
2313    /// identical whichever one renders.
2314    // erasure: keep result feeds the AnyView-typed slot builders and build_any call sites
2315    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        // See `NativeButtonView::init`'s doc for the callback reaper; the tab
2362        // bar also owns an icon-bytes side-table entry, retired here exactly
2363        // once per mounted component (`crate::controls::tab_bar`'s module
2364        // doc) — both harmless no-ops on a target with no arm.
2365        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
2377/// The tab bar's inset-aware slot: lays its child out at exactly
2378/// [`tab_bar::BAR_HEIGHT`] plus — when `safe_area` — the window's bottom
2379/// safe-area inset (`LayoutCtx::window_insets().padding().bottom`, read at
2380/// layout time, the same inset Material's `navigation_bar` consumes), as wide
2381/// as the parent allows. `platform_view` can only be sized by a builder-time
2382/// `.size(w, h)` or `.expand()`, and the inset is not known until layout, so
2383/// this crate-local wrapper (the [`ClipToSlot`] shape) supplies the tight
2384/// constraints and the child `.expand()`s into them — the `UITabBar` frame
2385/// then covers the whole slot, home-indicator band included. It also clips
2386/// its child's paint to the slot, which the refusal banner's prose needs.
2387struct TabBarSlot<State: 'static> {
2388    child: AnyView<State>,
2389    safe_area: bool,
2390}
2391
2392/// The retained widget for [`TabBarSlot`].
2393struct 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    /// The slot size for `bc` under `bottom_inset` px of bottom safe-area
2429    /// padding — pure, so the height rule is host-tested directly.
2430    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        // Transparent wrapper: forward unchanged (`docs/CODE_STANDARDS.md`'s
2464        // Semantics Conventions).
2465        self.child.semantics(ctx);
2466    }
2467}
2468
2469// ============================================================================
2470// Image
2471// ============================================================================
2472
2473/// How a [`NativeImageView`] scales its bytes into the slot's box — the
2474/// public mirror of `crate::controls::image::Fit`, which stays `pub(crate)`.
2475#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2476pub enum NativeImageFit {
2477    /// Whole image, aspect kept, centred (the platform's own default).
2478    #[default]
2479    Contain,
2480    /// Fills the box, aspect kept, cropped.
2481    Cover,
2482    /// Fills the box, aspect ignored.
2483    Fill,
2484    /// No scaling at all, centred.
2485    Center,
2486}
2487
2488impl NativeImageFit {
2489    /// The wire spelling `crate::controls::image::Fit::from_params` decodes.
2490    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/// A real `android.widget.ImageView` rendered from pure Rust, showing
2501/// app-supplied encoded bytes (PNG/JPEG/WebP — whatever `BitmapFactory`
2502/// reads). Display-only (no listener, no `.interactive()`). Build one with
2503/// [`native_image`].
2504#[derive(Clone)]
2505pub struct NativeImageView {
2506    bytes: Arc<[u8]>,
2507    fit: NativeImageFit,
2508    content_description: Option<String>,
2509    size: Option<(f64, f64)>,
2510}
2511
2512/// A native `Image` showing `bytes` — see [`NativeImageView`].
2513pub 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    /// How the image scales into the slot's box.
2524    pub fn fit(mut self, fit: NativeImageFit) -> Self {
2525        self.fit = fit;
2526        self
2527    }
2528
2529    /// The TalkBack label. An unlabelled image is invisible to a screen
2530    /// reader — set this (or say so explicitly with an empty string).
2531    pub fn content_description(mut self, label: impl Into<String>) -> Self {
2532        self.content_description = Some(label.into());
2533        self
2534    }
2535
2536    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
2537    /// fallback.
2538    pub fn size(mut self, width: f64, height: f64) -> Self {
2539        self.size = Some((width, height));
2540        self
2541    }
2542
2543    /// The encoded `params_json` for `slot`, given the publish revision
2544    /// [`crate::controls::image::publish_bytes`] already returned — split out
2545    /// from [`Self::build_with_mode`] so a test can snapshot it without
2546    /// re-publishing. `tokens` only ever contributes [`DARK`] here (theme
2547    /// ladder L1): an app-supplied image's *content* is arbitrary
2548    /// bytes, so folding an accent tint over it the way the other five
2549    /// controls fold colour tokens would corrupt a real photo rather than
2550    /// theme a control — [`NativeImageView`] exposes no tint builder yet for
2551    /// the same reason (`api::builders`' own "left for a future task" note
2552    /// on styling knobs).
2553    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        // Publish (or re-confirm) this slot's bytes BEFORE encoding params —
2572        // `image::publish_bytes` is idempotent for the same `Arc` (module
2573        // doc: "an app that hands its buffer down every rebuild produces no
2574        // params change and no decode"), and the runtime's later
2575        // `ImageProps::decode` reads this same table back by slot.
2576        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        // Tie the publish-table entry's lifetime to this Component, not to
2595        // the native create/dispose lifecycle (see
2596        // `docs/CODE_STANDARDS.md`'s "Teardown disposes the component's
2597        // `Owner`; register cleanup via `on_cleanup`, not `Drop`"). `init`
2598        // runs exactly once, under this component's own `Owner`, so
2599        // `on_cleanup` here fires exactly once when that owner disposes —
2600        // regardless of whether paint culling already ran the counted
2601        // `claim_bytes`/`release_bytes` pair to zero and back on the
2602        // platform side in between (`crate::controls::image`'s module doc).
2603        //
2604        // No second `on_cleanup` for `NativeRuntime::pending_callbacks`:
2605        // `Image` is display-only and never calls `set_callback` —
2606        // see `NativeLabelView::init`'s doc for the same reasoning.
2607        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
2616// ============================================================================
2617// The `View<Outer>` delegate (module doc)
2618// ============================================================================
2619
2620/// Implement `View<Outer>` for every `Outer` state by delegating to
2621/// `frust_core::component` — mirroring `ComponentView<C>`'s own blanket impl,
2622/// which this crate cannot reach directly (its `component: C` field is
2623/// private): each call clones `self`/`prev` into a throwaway `ComponentView`,
2624/// cheap for these small builder structs, and correct because
2625/// `ComponentView::rebuild` never actually reads its `prev` argument (it
2626/// always re-runs `Component::build` against the retained `State` — see
2627/// `frust_core::component`'s own doc comment).
2628macro_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                // Fully-qualified: `ComponentView<$ty>: View<Outer>` for every
2635                // `Outer`, so a plain `.build(ctx)` call leaves `Outer`
2636                // unconstrained — pin it to the impl we're writing.
2637                <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    /// A minimal `PaintScene` — only `fill_rect`/`draw_text` have no default
2690    /// (see `frust_core::widget::PaintScene`'s trait definition); every other
2691    /// method a placeholder's banner/`SizedBox` might call is defaulted.
2692    #[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    // --- params_json snapshots, one per control (no theme) -------------------
2706    //
2707    // `None` is exactly what `ambient_theme_tokens()` returns absent a live
2708    // reactive context (this module's own doc comment) — every snapshot
2709    // below carries a plain `"dark":false` and no other theme field, the
2710    // original shape plus theme ladder L1's always-present flag.
2711
2712    #[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        // A shared control: no compile-time arm gate, unlike
2834        // `native_segmented`/`native_stepper`.
2835        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        // Every surface mode, including the ones a native slot would render
2911        // in: with no arm, the refusal is structural.
2912        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        // Every surface mode, including the ones a native slot would render
3035        // in: with no arm, the refusal is structural.
3036        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    // --- tab bar ------------------------------------------------------------
3101
3102    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    /// Lay `element` out under `bc` in a window carrying `bottom` px of
3116    /// bottom system-bar inset — the shape a shell pushes for the home
3117    /// indicator.
3118    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    // --- theme ladder L2: token folding, one snapshot per colour-bearing
3286    // control (dark + light, via `theme::resolve`) -------------------------
3287
3288    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        // `Switch` deliberately folds no background — the thumb/track
3350        // tints already carry the theme, and an explicit `backgroundColor`
3351        // would replace `?attr/selectableItemBackgroundBorderless`'s ripple
3352        // (`crate::api::theme`'s module doc's *Explicit backgrounds*
3353        // section).
3354        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        // Same rationale as the Switch test above — `AbsSeekBar` also
3377        // carries `?attr/selectableItemBackgroundBorderless`.
3378        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        // An app-supplied photo is arbitrary content — theming it would
3414        // corrupt the image, not style a control (module doc on
3415        // `NativeImageView::params_for`).
3416        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        // `UIStepper.tintColor` reads the same accent-ink role Switch/Slider's
3441        // thumb tint and Spinner's own tint already do — `NSStepper` simply
3442        // has no AppKit property to apply it through (module doc's *Tint*
3443        // section on `crate::controls::stepper`), a platform gap this fold
3444        // does not need to know about at the params level.
3445        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    // --- the zero-FFI property: an unchanged theme yields PartialEq-equal
3475    // Props (acceptance criterion) -------------------------------------------
3476
3477    #[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        // A genuinely different theme (light) must NOT compare equal.
3494        let c =
3495            ButtonProps::decode(&Params::new(&view.params_for(7, Some(light_tokens())))).unwrap();
3496        assert_ne!(a, c);
3497    }
3498
3499    // --- the translucency-refused fallback ----------------------------------
3500
3501    #[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    // --- The refusal placeholder's prose must never paint outside its own
3569    // slot rect -------------------------------------------------------------
3570
3571    /// A recording [`PaintScene`] that actually honours the clip stack
3572    /// (unlike [`NullScene`] above), so it models what a real backend would
3573    /// visually produce: every primitive's own reported bounds get
3574    /// intersected against whatever clip is active *at paint time* before
3575    /// being recorded. With no clip pushed (the pre-fix shape), a
3576    /// primitive's raw, unclamped bounds are recorded as-is — which is
3577    /// exactly what makes this test capable of catching the original
3578    /// overflow rather than trivially passing by construction.
3579    #[derive(Default)]
3580    struct BoundsRecorder {
3581        clip_stack: Vec<Rect>,
3582        /// Every painted primitive's bounds, already clipped against
3583        /// whatever was active when it painted.
3584        painted: Vec<Rect>,
3585        /// How many glyph runs carrying at least one glyph reached the scene,
3586        /// counted *before* the clip intersection — the "the banner really
3587        /// paints its warning text" half of the contract, which the clipped
3588        /// bounds above cannot answer on their own (a fully-clipped run
3589        /// records nothing).
3590        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            // A fully-clipped-away rect never actually paints a pixel —
3600            // skip it rather than recording a degenerate zero-size rect.
3601            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            // Pad by the stroke width so the border's own ink is covered,
3627            // not just its centerline path.
3628            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            // The run's transform is a pure translation baked from the
3650            // paint-time origin (`frust_text::TextLayout::to_scene_runs`) —
3651            // `.translation()` recovers it directly. Glyph x/y are local
3652            // (pre-transform) positions; no font-metrics access exists at
3653            // this layer, so a generous `font_size`-wide margin around the
3654            // glyphs' local extent stands in for real ascent/descent —
3655            // over-approximating is fine here, since this test only needs
3656            // to catch genuine overflow, not measure exact ink bounds.
3657            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        // A range of slot sizes the app-facing builders actually allow,
3687        // including `native_switch`'s own deliberately small box
3688        // (`examples/native-widgets-demo/src/pages/controls.rs`) and an
3689        // even smaller one to stress the invariant further.
3690        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            // The visible half of the refusal contract: a sighted user must
3719            // see the warning wording, not just the warning fill — the
3720            // `Role::Alert` node alone is not a substitute.
3721            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            // ... and the clip wrapper must keep every one of those runs
3728            // (plus the fill/border) inside the slot at the same time.
3729            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    /// The original refusal test must still pass unchanged after the clip wrapper
3740    /// — re-asserted here (mirrors `a_refused_slot_publishes_no_platform_view_frame`
3741    /// above) against the `BoundsRecorder`'s clip-aware scene too, so both
3742    /// scenes agree the refusal path never touches platform-view frames.
3743    #[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}