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`.
217fn banner_placeholder<State: 'static>(
218    size: Option<(f64, f64)>,
219    label: String,
220    description: String,
221) -> AnyView<State> {
222    let banner: AnyView<State> = any(RefusalBanner { label, description });
223    let sized = SizedBox(size.map(|(w, _)| w), size.map(|(_, h)| h)).child(banner);
224    any(ClipToSlot { child: any(sized) })
225}
226
227/// Unthemed fallback fill for [`RefusalBanner`] — a muted warning amber, used
228/// only when no [`Theme`] is threaded into the paint pass (a bare-core test).
229/// A themed paint reads `error_container`/`outline` instead.
230const REFUSAL_FILL: Color = Color::from_rgb8(0xFF, 0xDD, 0xB0);
231
232/// Unthemed fallback border for [`RefusalBanner`]. See [`REFUSAL_FILL`].
233const REFUSAL_BORDER: Color = Color::from_rgb8(0x8A, 0x53, 0x00);
234
235/// Unthemed fallback prose colour for [`RefusalBanner`] — the "on" role for
236/// [`REFUSAL_FILL`], dark enough to read over that amber. A themed paint reads
237/// `on_error_container` instead. See [`REFUSAL_FILL`].
238const REFUSAL_TEXT: Color = Color::from_rgb8(0x3B, 0x24, 0x00);
239
240/// Inset between the banner's border hairline and its prose, in logical px.
241const REFUSAL_PAD: f64 = 4.0;
242/// Gap between the label row and the description row, in logical px.
243const REFUSAL_ROW_GAP: f64 = 2.0;
244/// Label font size, in logical px — deliberately small, because the slots
245/// these placeholders stand in for are themselves small (`native_switch`'s
246/// 70x40 is the reference case).
247const REFUSAL_LABEL_SIZE: f32 = 12.0;
248/// Description font size, in logical px. See [`REFUSAL_LABEL_SIZE`].
249const REFUSAL_DESC_SIZE: f32 = 11.0;
250/// Prose line height, as a multiple of the font size.
251const REFUSAL_LINE_HEIGHT: f32 = 1.3;
252
253/// A minimal retained text run: shape once per (content, style, width),
254/// measure during `layout`, emit glyph runs during `paint`.
255///
256/// Mirrors `plugins/glyph/src/alert.rs`'s `GlyphLabel` — that module's own
257/// docs sanction duplicating this small helper per crate rather than sharing
258/// one, and a platform plugin could not share it anyway: it may not depend on
259/// a design-system plugin at all (`docs/PLUGINS_CODE_STANDARDS.md`'s charter
260/// line).
261///
262/// The shaping vocabulary comes through `frust::authoring::text` rather than a
263/// direct `frust-text` dependency — the one place this file reaches through
264/// the facade instead of naming a framework crate (`frust-core` is named
265/// directly, per this crate's `Cargo.toml`). `frust-text` is a **dev**-only
266/// dependency here, and the refusal placeholder is not worth promoting it to a
267/// production one: the facade re-export costs nothing and stays behind the
268/// same default-on `frust-api` feature gate as `frust` itself, so the
269/// `--no-default-features` charter line is untouched.
270struct BannerText {
271    content: String,
272    layout: Option<TextLayout>,
273    laid_out_style: Option<TextStyle>,
274    laid_out_max_width: Option<f32>,
275}
276
277impl BannerText {
278    fn new(content: impl Into<String>) -> Self {
279        Self {
280            content: content.into(),
281            layout: None,
282            laid_out_style: None,
283            laid_out_max_width: None,
284        }
285    }
286
287    fn set_content(&mut self, content: impl Into<String>) {
288        let content = content.into();
289        if self.content != content {
290            self.content = content;
291            self.layout = None;
292        }
293    }
294
295    fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f32>) -> Size {
296        if let Some(cached) = &self.layout
297            && self.laid_out_max_width == max_width
298            && self.laid_out_style.as_ref() == Some(style)
299        {
300            return cached.size();
301        }
302        let laid = ctx
303            .text_context::<TextContext>()
304            .layout(&self.content, style, max_width);
305        let size = laid.size();
306        self.layout = Some(laid);
307        self.laid_out_style = Some(style.clone());
308        self.laid_out_max_width = max_width;
309        size
310    }
311
312    /// Emit this run's glyphs at `origin`. A no-op before the first
313    /// [`BannerText::layout`] — a paint without a preceding layout pass draws
314    /// no text rather than panicking for want of a text context.
315    fn paint(&self, origin: kurbo::Point, scene: &mut dyn PaintScene) {
316        if let Some(layout) = &self.layout {
317            for run in layout.to_scene_runs(origin) {
318                scene.draw_glyph_run(run);
319            }
320        }
321    }
322}
323
324fn refusal_label_style(color: Color) -> TextStyle {
325    TextStyle {
326        weight: FontWeight::SEMI_BOLD,
327        line_height: LineHeight::FontSizeRelative(REFUSAL_LINE_HEIGHT),
328        ..TextStyle::new(REFUSAL_LABEL_SIZE, color)
329    }
330}
331
332fn refusal_description_style(color: Color) -> TextStyle {
333    TextStyle {
334        weight: FontWeight::REGULAR,
335        line_height: LineHeight::FontSizeRelative(REFUSAL_LINE_HEIGHT),
336        ..TextStyle::new(REFUSAL_DESC_SIZE, color)
337    }
338}
339
340/// Explicit builder value > theme > fallback constant
341/// (`docs/WIDGETS_CODE_STANDARDS.md`) — there is no explicit override on the
342/// banner, so: the theme's `on_error_container` (the "on" role for the
343/// `error_container` fill the banner paints under it), else [`REFUSAL_TEXT`].
344fn refusal_text_color(theme: Option<&Theme>) -> Color {
345    match theme {
346        Some(theme) => theme.scheme().on_error_container,
347        None => REFUSAL_TEXT,
348    }
349}
350
351/// The refusal placeholder's own visual + accessible body — see
352/// [`placeholder`] for why it is crate-local rather than a catalog widget.
353///
354/// Paints a filled, outlined box across whatever the slot gave it (so a
355/// refused control still reads as a deliberate "something is wrong here"
356/// marker rather than a hole) carrying `label` and `description` as visible
357/// prose, and publishes exactly one `Role::Alert` semantics node with the same
358/// two strings — the accessible half of the same refusal contract.
359struct RefusalBanner {
360    label: String,
361    description: String,
362}
363
364/// The retained widget for [`RefusalBanner`].
365///
366/// `label`/`description` are kept as plain strings alongside their
367/// [`BannerText`] runs because [`RefusalBannerWidget::semantics`] reports the
368/// wording whether or not a layout pass ever shaped it (the same split
369/// `AlertWidget` uses in `plugins/glyph/src/alert.rs`).
370struct RefusalBannerWidget {
371    label: String,
372    description: String,
373    label_run: BannerText,
374    description_run: BannerText,
375    label_size: Size,
376}
377
378impl<State: 'static> View<State> for RefusalBanner {
379    type Element = RefusalBannerWidget;
380
381    fn build(&self, _ctx: &mut BuildCtx<'_>) -> Self::Element {
382        RefusalBannerWidget {
383            label: self.label.clone(),
384            description: self.description.clone(),
385            label_run: BannerText::new(self.label.clone()),
386            description_run: BannerText::new(self.description.clone()),
387            label_size: Size::ZERO,
388        }
389    }
390
391    fn rebuild(
392        &self,
393        prev: &Self,
394        element: &mut Self::Element,
395        _ctx: &mut BuildCtx<'_>,
396    ) -> ChangeFlags {
397        if prev.label == self.label && prev.description == self.description {
398            return ChangeFlags::NONE;
399        }
400        element.label = self.label.clone();
401        element.description = self.description.clone();
402        element.label_run.set_content(self.label.clone());
403        element
404            .description_run
405            .set_content(self.description.clone());
406        // New wording re-shapes, so this is a layout change, not paint-only.
407        ChangeFlags::LAYOUT | ChangeFlags::PAINT
408    }
409
410    fn teardown(&self, _element: &mut Self::Element, _ctx: &mut BuildCtx<'_>) {}
411}
412
413impl Widget for RefusalBannerWidget {
414    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
415        // Fill whatever the enclosing `SizedBox` tightened to; with no
416        // explicit slot size the builders leave it filling the parent.
417        let size = bc.max();
418
419        // Colour is baked into the shaped runs, so it is resolved here rather
420        // than at paint time — the same reason `AlertWidget::layout` reads the
421        // theme during layout.
422        let color = refusal_text_color(Theme::from_layout_ctx(ctx));
423        // Budget both runs against the slot, minus the prose inset on each
424        // side, so the wording wraps inside the slot instead of running off
425        // its natural single-line extent. A non-finite max (an unconstrained
426        // parent, i.e. no explicit `.size(w, h)`) means "no wrap budget"
427        // rather than a wrap at infinity.
428        let max_width = size
429            .width
430            .is_finite()
431            .then(|| (size.width - REFUSAL_PAD * 2.0).max(0.0) as f32);
432
433        self.label_size = self
434            .label_run
435            .layout(ctx, &refusal_label_style(color), max_width);
436        self.description_run
437            .layout(ctx, &refusal_description_style(color), max_width);
438
439        size
440    }
441
442    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
443        // Explicit builder value > theme > fallback constant
444        // (`docs/WIDGETS_CODE_STANDARDS.md`) — there is no explicit override
445        // here, so: theme, else the two `REFUSAL_*` constants.
446        let (fill, border) = match Theme::from_paint_ctx(ctx) {
447            Some(theme) => {
448                let s = theme.scheme();
449                (s.error_container, s.error)
450            }
451            None => (REFUSAL_FILL, REFUSAL_BORDER),
452        };
453        let (origin, size) = (ctx.origin(), ctx.size());
454        scene.fill_rect(origin, size, fill);
455        // A 1px inset hairline, drawn as four edge fills rather than a
456        // stroked path so it needs no `BezPath`/`Brush` vocabulary here.
457        const EDGE: f64 = 1.0;
458        let edge = EDGE.min(size.width / 2.0).min(size.height / 2.0);
459        if edge > 0.0 {
460            scene.fill_rect(origin, Size::new(size.width, edge), border);
461            scene.fill_rect(
462                kurbo::Point::new(origin.x, origin.y + size.height - edge),
463                Size::new(size.width, edge),
464                border,
465            );
466            scene.fill_rect(origin, Size::new(edge, size.height), border);
467            scene.fill_rect(
468                kurbo::Point::new(origin.x + size.width - edge, origin.y),
469                Size::new(edge, size.height),
470                border,
471            );
472        }
473        // The prose paints last, over the fill and the border hairline. What
474        // keeps a run that outgrows a short slot from escaping it is
475        // [`ClipToSlot`], which wraps the whole banner (see [`placeholder`]) —
476        // deliberately, so the wording stays complete rather than truncated.
477        self.label_run
478            .paint(origin + Vec2::new(REFUSAL_PAD, REFUSAL_PAD), scene);
479        let description_y = REFUSAL_PAD + self.label_size.height + REFUSAL_ROW_GAP;
480        self.description_run
481            .paint(origin + Vec2::new(REFUSAL_PAD, description_y), scene);
482    }
483
484    fn semantics(&self, ctx: &mut SemanticsCtx) {
485        let label = format!("{}. {}", self.label, self.description);
486        ctx.push_node(Role::Alert, |node| node.set_label(label));
487    }
488}
489
490/// Clips its child's paint to this widget's own laid-out bounds — see
491/// [`placeholder`]'s "The clip-to-slot fix for oversized placeholder prose"
492/// for why this exists instead of truncating the prose itself. A thin,
493/// crate-local wrapper (not a `frust-widgets` container) built directly
494/// against [`AnyView`]/[`Widget`] rather than `frust-widgets`' crate-private
495/// `ChildPod` plumbing, which this crate has no access to
496/// (`docs/CODE_STANDARDS.md`'s Plugin Conventions).
497struct ClipToSlot<State: 'static> {
498    child: AnyView<State>,
499}
500
501/// The retained widget for [`ClipToSlot`].
502struct ClipToSlotWidget {
503    child: Box<dyn Widget>,
504}
505
506impl<State: 'static> View<State> for ClipToSlot<State> {
507    type Element = ClipToSlotWidget;
508
509    fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
510        ClipToSlotWidget {
511            child: self.child.build(ctx),
512        }
513    }
514
515    fn rebuild(
516        &self,
517        prev: &Self,
518        element: &mut Self::Element,
519        ctx: &mut BuildCtx<'_>,
520    ) -> ChangeFlags {
521        self.child.rebuild(&prev.child, &mut element.child, ctx)
522    }
523
524    fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
525        self.child.teardown(&mut element.child, ctx);
526    }
527}
528
529impl Widget for ClipToSlotWidget {
530    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
531        self.child.layout(ctx, bc)
532    }
533
534    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
535        // The clip rect is THIS widget's own laid-out origin/size — exactly
536        // the slot rect the placeholder was given, never the child's
537        // unclamped natural content size.
538        scene.push_clip(ctx.origin(), ctx.size());
539        self.child.paint(ctx, scene);
540        scene.pop_clip();
541    }
542
543    fn semantics(&self, ctx: &mut SemanticsCtx) {
544        // Transparent wrapper: forward unchanged so the full title+body
545        // detail still reaches a screen reader regardless of what got
546        // visually clipped (`docs/CODE_STANDARDS.md`'s Semantics
547        // Conventions — a container must forward, never drop, a child's
548        // subtree).
549        self.child.semantics(ctx);
550    }
551}
552
553/// Apply an explicit `.size(w, h)` if the caller provided one, else leave the
554/// slot at [`PlatformViewView`]'s own default (fill the parent) — v1 has no
555/// measure step either way, so an explicit `.size(w,h)` is required and an
556/// omitted call degrades to filling the parent rather than a made-up
557/// constant. `pub(super)`, for
558/// [`crate::api::mount`]'s generic builder.
559pub(super) fn resolve_size(size: Option<(f64, f64)>, view: PlatformViewView) -> PlatformViewView {
560    match size {
561        Some((w, h)) => view.size(w, h),
562        None => view,
563    }
564}
565
566/// The active theme's resolved tokens (theme ladder L2), or `None`
567/// when no theme has been threaded — `use_context::<Theme>()`'s own
568/// documented `None` cases (a bare-core test, a build running outside any
569/// reactive `Owner`; `reactive_graph::owner::use_context`'s own doc: "Panics
570/// if no value is found" only applies to its `expect_context` sibling, never
571/// this one). Every builder's `Component::build` calls this once per
572/// rebuild — the mechanism `crates/frust/tests/
573/// theme_reactivity_spike.rs` proves is what makes that rebuild re-run, and
574/// therefore re-resolve, on `set_app_theme` — and threads the result into
575/// `build_with_mode` explicitly, the same "thread it as a parameter so a
576/// test can force it" shape `resolved_surface_mode()` already uses above.
577fn ambient_theme_tokens() -> Option<ResolvedTheme> {
578    use_context::<Theme>().as_ref().map(theme::resolve)
579}
580
581/// A tiny flat-JSON body writer — this crate hand-rolls JSON at the wire
582/// boundary rather than pulling in `serde` (`docs/CODE_STANDARDS.md`'s
583/// Language Idioms; `crate::runtime::Params`/`with_identity` are the
584/// reader/identity-encoder halves this writes the *body* half for). Only the
585/// handful of primitive field shapes the eight controls need.
586struct ParamsBody(String);
587
588impl ParamsBody {
589    fn new() -> Self {
590        Self(String::new())
591    }
592
593    fn push_key(&mut self, key: &str) {
594        if !self.0.is_empty() {
595            self.0.push(',');
596        }
597        self.0.push('"');
598        self.0.push_str(key);
599        self.0.push_str("\":");
600    }
601
602    /// A raw (unquoted) literal — a bool or integer, whose `Display` already
603    /// matches JSON's own spelling (`true`/`false`, plain digits).
604    fn push_raw(&mut self, key: &str, value: impl std::fmt::Display) {
605        self.push_key(key);
606        self.0.push_str(&value.to_string());
607    }
608
609    /// A JSON string value, escaped via [`crate::runtime::escape`].
610    fn push_str(&mut self, key: &str, value: &str) {
611        self.push_key(key);
612        self.0.push('"');
613        self.0.push_str(&escape(value));
614        self.0.push('"');
615    }
616
617    /// A string field only when present — a missing optional field decodes
618    /// to the control's own platform default (`crate::controls`'s
619    /// degrade-don't-fail rule), so omitting the key entirely is correct.
620    fn push_opt_str(&mut self, key: &str, value: Option<&str>) {
621        if let Some(v) = value {
622            self.push_str(key, v);
623        }
624    }
625
626    fn finish(self) -> String {
627        self.0
628    }
629}
630
631// ============================================================================
632// Button
633// ============================================================================
634
635/// A real `android.widget.Button` rendered from pure Rust — see the
636/// [module docs](self). Build one with [`native_button`].
637#[derive(Clone)]
638pub struct NativeButtonView {
639    text: String,
640    enabled: bool,
641    content_description: Option<String>,
642    size: Option<(f64, f64)>,
643    on_press: Option<Arc<dyn Fn() + Send + Sync>>,
644}
645
646/// A native `Button` captioned `text` — see [`NativeButtonView`].
647pub fn native_button(text: impl Into<String>) -> NativeButtonView {
648    NativeButtonView {
649        text: text.into(),
650        enabled: true,
651        content_description: None,
652        size: None,
653        on_press: None,
654    }
655}
656
657impl NativeButtonView {
658    /// `View.setEnabled` — default `true`.
659    pub fn enabled(mut self, enabled: bool) -> Self {
660        self.enabled = enabled;
661        self
662    }
663
664    /// The TalkBack label; falls back to the caption when unset.
665    pub fn content_description(mut self, label: impl Into<String>) -> Self {
666        self.content_description = Some(label.into());
667        self
668    }
669
670    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
671    /// fallback.
672    pub fn size(mut self, width: f64, height: f64) -> Self {
673        self.size = Some((width, height));
674        self
675    }
676
677    /// Fires on a tap, on the platform main thread (events-as-signals — see
678    /// `crate::api::signals`): write an `RwSignal` from inside for the
679    /// blessed one-frame-wake idiom.
680    pub fn on_press(mut self, handler: impl Fn() + Send + Sync + 'static) -> Self {
681        self.on_press = Some(Arc::new(handler));
682        self
683    }
684
685    /// The encoded `params_json` for `slot` — split out from
686    /// [`Component::build`] so a test can snapshot it directly. `tokens`
687    /// (theme ladder L2) folds the active theme's background/text
688    /// colour, corner radius, and text size in, threaded explicitly like
689    /// `mode` below so a test can pin an exact resolved value without a live
690    /// reactive context.
691    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
692        let mut body = ParamsBody::new();
693        body.push_str(TEXT, &self.text);
694        body.push_raw(ENABLED, self.enabled);
695        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
696        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
697        if let Some(t) = tokens {
698            body.push_raw(TEXT_COLOR, t.on_accent_fill);
699            body.push_raw(BACKGROUND_COLOR, t.accent_fill);
700            body.push_raw(CORNER_RADIUS_DP, t.corner_radius_dp);
701            body.push_raw(TEXT_SIZE_SP, t.button_text_size_sp);
702            body.push_str(TYPEFACE, t.button_typeface.wire());
703        }
704        with_identity(button::KIND, slot, &body.finish())
705    }
706
707    /// [`Component::build`]'s real body, with `mode`/`tokens` threaded
708    /// explicitly so a test can force the
709    /// [`ResolvedSurfaceMode::RefusedTranslucent`] branch or an exact theme
710    /// resolution without touching the process-global resolved-mode slot
711    /// (whose writer is pinned to the two shells' own FFI glue,
712    /// `crates/frust/tests/surface_mode_conformance.rs`) or a live reactive
713    /// context.
714    fn build_with_mode(
715        &self,
716        slot: SlotId,
717        mode: ResolvedSurfaceMode,
718        tokens: Option<ResolvedTheme>,
719    ) -> AnyView<SlotId> {
720        if mode.translucency_refused() {
721            return placeholder(self.size, "Button");
722        }
723        let params = self.params_for(slot, tokens);
724        if let Some(on_press) = self.on_press.clone() {
725            with_runtime(|rt| rt.set_callback(slot, on_click(on_press)));
726        }
727        let view = platform_view(VIEW_TYPE)
728            .params_json(params)
729            .interactive()
730            .semantics_label(
731                self.content_description
732                    .clone()
733                    .unwrap_or_else(|| self.text.clone()),
734            );
735        any(resolve_size(self.size, view))
736    }
737}
738
739impl Component for NativeButtonView {
740    type State = SlotId;
741
742    fn init(&self) -> SlotId {
743        let slot = next_local_slot();
744        // Ties this slot's `NativeRuntime::pending_callbacks` entry to the
745        // Component's own lifetime, not to the native create/dispose
746        // lifecycle (the same remedy `NativeImageView::init` applies to
747        // the identical leak shape one table over — see
748        // `crate::runtime`'s doc on `pending_callbacks` and
749        // `NativeRuntime::forget_pending_callback`). `init` runs exactly
750        // once, under this component's own `Owner`, so `on_cleanup` fires
751        // exactly once when that owner disposes — regardless of whether a
752        // culled-then-republished cycle already parked a second pending
753        // entry `dispose_slot` never sees.
754        on_cleanup(move || {
755            with_runtime(|rt| rt.forget_pending_callback(slot));
756        });
757        slot
758    }
759
760    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
761        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
762    }
763}
764
765// ============================================================================
766// Label
767// ============================================================================
768
769/// A real `android.widget.TextView` rendered from pure Rust — display-only
770/// (no listener, no `.interactive()`). Build one with [`native_label`].
771#[derive(Clone)]
772pub struct NativeLabelView {
773    text: String,
774    enabled: bool,
775    content_description: Option<String>,
776    size: Option<(f64, f64)>,
777}
778
779/// A native `Label` showing `text` — see [`NativeLabelView`].
780pub fn native_label(text: impl Into<String>) -> NativeLabelView {
781    NativeLabelView {
782        text: text.into(),
783        enabled: true,
784        content_description: None,
785        size: None,
786    }
787}
788
789impl NativeLabelView {
790    /// `View.setEnabled` — a `TextView` renders its disabled state through
791    /// the colour state list, so this is visible even without interaction.
792    pub fn enabled(mut self, enabled: bool) -> Self {
793        self.enabled = enabled;
794        self
795    }
796
797    /// The TalkBack label; falls back to `text` when unset.
798    pub fn content_description(mut self, label: impl Into<String>) -> Self {
799        self.content_description = Some(label.into());
800        self
801    }
802
803    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
804    /// fallback.
805    pub fn size(mut self, width: f64, height: f64) -> Self {
806        self.size = Some((width, height));
807        self
808    }
809
810    /// `tokens` (theme ladder L2, including a later followup) folds the
811    /// active theme's background/body-text colour and size in — see
812    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
813    /// explicitly, and [`crate::api::theme`]'s module doc's *Explicit
814    /// backgrounds* section for why an EXPLICIT background is folded here
815    /// too — it used to be entirely absent, pinning `Label` to
816    /// whichever brightness it was created under.
817    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
818        let mut body = ParamsBody::new();
819        body.push_str(TEXT, &self.text);
820        body.push_raw(ENABLED, self.enabled);
821        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
822        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
823        if let Some(t) = tokens {
824            body.push_raw(BACKGROUND_COLOR, t.surface_bg);
825            body.push_raw(TEXT_COLOR, t.body_text);
826            body.push_raw(TEXT_SIZE_SP, t.body_text_size_sp);
827            body.push_str(TYPEFACE, t.body_typeface.wire());
828        }
829        with_identity(label::KIND, slot, &body.finish())
830    }
831
832    fn build_with_mode(
833        &self,
834        slot: SlotId,
835        mode: ResolvedSurfaceMode,
836        tokens: Option<ResolvedTheme>,
837    ) -> AnyView<SlotId> {
838        if mode.translucency_refused() {
839            return placeholder(self.size, "Label");
840        }
841        let params = self.params_for(slot, tokens);
842        let view = platform_view(VIEW_TYPE)
843            .params_json(params)
844            .semantics_label(
845                self.content_description
846                    .clone()
847                    .unwrap_or_else(|| self.text.clone()),
848            );
849        any(resolve_size(self.size, view))
850    }
851}
852
853impl Component for NativeLabelView {
854    type State = SlotId;
855
856    // No `on_cleanup` here: `Label` is display-only and never calls
857    // `set_callback`, so its slot never has a `pending_callbacks` entry to
858    // reap. `NativeButtonView::init`'s doc explains the cleanup this
859    // Component deliberately omits.
860    fn init(&self) -> SlotId {
861        next_local_slot()
862    }
863
864    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
865        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
866    }
867}
868
869// ============================================================================
870// Switch
871// ============================================================================
872
873/// A real `android.widget.Switch` rendered from pure Rust — a **controlled**
874/// component (`docs/CODE_STANDARDS.md`'s Interaction Semantics): the app owns
875/// `checked`, and a user toggle only ever arrives through [`Self::on_toggle`]
876/// as a *requested* value. Build one with [`native_switch`].
877#[derive(Clone)]
878pub struct NativeSwitchView {
879    checked: bool,
880    enabled: bool,
881    content_description: Option<String>,
882    size: Option<(f64, f64)>,
883    on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
884}
885
886/// A native `Switch` at the app-owned `checked` state — see
887/// [`NativeSwitchView`].
888pub fn native_switch(checked: bool) -> NativeSwitchView {
889    NativeSwitchView {
890        checked,
891        enabled: true,
892        content_description: None,
893        size: None,
894        on_toggle: None,
895    }
896}
897
898impl NativeSwitchView {
899    /// `View.setEnabled` — default `true`.
900    pub fn enabled(mut self, enabled: bool) -> Self {
901        self.enabled = enabled;
902        self
903    }
904
905    /// The TalkBack label.
906    pub fn content_description(mut self, label: impl Into<String>) -> Self {
907        self.content_description = Some(label.into());
908        self
909    }
910
911    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
912    /// fallback.
913    pub fn size(mut self, width: f64, height: f64) -> Self {
914        self.size = Some((width, height));
915        self
916    }
917
918    /// Fires with the *requested* checked state on a user toggle — the app
919    /// confirms (or rejects) it by feeding `checked` back through the next
920    /// build, the controlled-component contract every interactive frust
921    /// widget follows.
922    pub fn on_toggle(mut self, handler: impl Fn(bool) + Send + Sync + 'static) -> Self {
923        self.on_toggle = Some(Arc::new(handler));
924        self
925    }
926
927    /// `tokens` (theme ladder L2) folds the active theme's
928    /// thumb/track tints in — see [`NativeButtonView::params_for`]'s doc for
929    /// why it's threaded explicitly. Deliberately **no** background fold
930    /// (reversing an earlier followup that added one): see [`crate::api::theme`]'s
931    /// module doc's *Explicit backgrounds* section for why `Switch` is
932    /// excluded — a flat `View.setBackgroundColor` here would replace
933    /// `?attr/selectableItemBackgroundBorderless`'s touch ripple, and the
934    /// thumb/track tints below already carry the theme without it.
935    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
936        let mut body = ParamsBody::new();
937        body.push_raw(CHECKED, self.checked);
938        body.push_raw(ENABLED, self.enabled);
939        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
940        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
941        if let Some(t) = tokens {
942            body.push_raw(THUMB_TINT, t.accent_ink);
943            body.push_raw(TRACK_TINT, t.accent_fill);
944            // `Switch` never sets on/off text through this plugin today, but
945            // it's a `TextView` subclass under the hood (`android.widget.Switch
946            // extends CompoundButton extends Button extends TextView`) — see
947            // `crate::api::theme`'s module doc on why this shares `Label`'s
948            // typeface rather than going unset.
949            body.push_str(TYPEFACE, t.body_typeface.wire());
950        }
951        with_identity(switch::KIND, slot, &body.finish())
952    }
953
954    fn build_with_mode(
955        &self,
956        slot: SlotId,
957        mode: ResolvedSurfaceMode,
958        tokens: Option<ResolvedTheme>,
959    ) -> AnyView<SlotId> {
960        if mode.translucency_refused() {
961            return placeholder(self.size, "Switch");
962        }
963        let params = self.params_for(slot, tokens);
964        if let Some(on_toggle) = self.on_toggle.clone() {
965            with_runtime(|rt| rt.set_callback(slot, on_toggled(on_toggle)));
966        }
967        let view = platform_view(VIEW_TYPE)
968            .params_json(params)
969            .interactive()
970            .semantics_label(
971                self.content_description
972                    .clone()
973                    .unwrap_or_else(|| "switch".into()),
974            );
975        any(resolve_size(self.size, view))
976    }
977}
978
979impl Component for NativeSwitchView {
980    type State = SlotId;
981
982    fn init(&self) -> SlotId {
983        let slot = next_local_slot();
984        // See `NativeButtonView::init`'s doc — `Switch` registers a
985        // callback via `Self::on_toggle`, so it needs the same
986        // `pending_callbacks` reaper.
987        on_cleanup(move || {
988            with_runtime(|rt| rt.forget_pending_callback(slot));
989        });
990        slot
991    }
992
993    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
994        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
995    }
996}
997
998// ============================================================================
999// Slider
1000// ============================================================================
1001
1002/// A real `android.widget.SeekBar` rendered from pure Rust — controlled, like
1003/// [`NativeSwitchView`]: the app owns `value`, and a drag only ever arrives
1004/// through [`Self::on_change`] as a requested value. Build one with
1005/// [`native_slider`].
1006#[derive(Clone)]
1007pub struct NativeSliderView {
1008    value: i32,
1009    min: i32,
1010    max: i32,
1011    enabled: bool,
1012    content_description: Option<String>,
1013    size: Option<(f64, f64)>,
1014    on_change: Option<Arc<dyn Fn(i32) + Send + Sync>>,
1015}
1016
1017/// A native `Slider` at `value`, ranging over `[min, max]` — see
1018/// [`NativeSliderView`].
1019pub fn native_slider(value: i32, min: i32, max: i32) -> NativeSliderView {
1020    NativeSliderView {
1021        value,
1022        min,
1023        max,
1024        enabled: true,
1025        content_description: None,
1026        size: None,
1027        on_change: None,
1028    }
1029}
1030
1031impl NativeSliderView {
1032    /// `View.setEnabled` — default `true`.
1033    pub fn enabled(mut self, enabled: bool) -> Self {
1034        self.enabled = enabled;
1035        self
1036    }
1037
1038    /// The TalkBack label.
1039    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1040        self.content_description = Some(label.into());
1041        self
1042    }
1043
1044    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1045    /// fallback.
1046    pub fn size(mut self, width: f64, height: f64) -> Self {
1047        self.size = Some((width, height));
1048        self
1049    }
1050
1051    /// Fires with the requested **app-space** value on a drag
1052    /// (`crate::controls::slider`'s platform-space mapping already undone).
1053    pub fn on_change(mut self, handler: impl Fn(i32) + Send + Sync + 'static) -> Self {
1054        self.on_change = Some(Arc::new(handler));
1055        self
1056    }
1057
1058    /// `tokens` (theme ladder L2) folds the active theme's
1059    /// progress/thumb tints in — see [`NativeButtonView::params_for`]'s doc
1060    /// for why it's threaded explicitly. Deliberately **no** background fold
1061    /// (reversing an earlier followup that added one): see [`crate::api::theme`]'s
1062    /// module doc's *Explicit backgrounds* section for why `Slider` is
1063    /// excluded — a flat `View.setBackgroundColor` here would replace
1064    /// `AbsSeekBar`'s `?attr/selectableItemBackgroundBorderless` touch
1065    /// ripple, and the progress/thumb tints below already carry the theme
1066    /// without it.
1067    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1068        let mut body = ParamsBody::new();
1069        body.push_raw(VALUE, self.value);
1070        body.push_raw(MIN, self.min);
1071        body.push_raw(MAX, self.max);
1072        body.push_raw(ENABLED, self.enabled);
1073        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1074        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1075        if let Some(t) = tokens {
1076            body.push_raw(PROGRESS_TINT, t.accent_fill);
1077            body.push_raw(THUMB_TINT, t.accent_ink);
1078        }
1079        with_identity(slider::KIND, slot, &body.finish())
1080    }
1081
1082    fn build_with_mode(
1083        &self,
1084        slot: SlotId,
1085        mode: ResolvedSurfaceMode,
1086        tokens: Option<ResolvedTheme>,
1087    ) -> AnyView<SlotId> {
1088        if mode.translucency_refused() {
1089            return placeholder(self.size, "Slider");
1090        }
1091        let params = self.params_for(slot, tokens);
1092        if let Some(on_change) = self.on_change.clone() {
1093            with_runtime(|rt| rt.set_callback(slot, on_value_changed(on_change)));
1094        }
1095        let view = platform_view(VIEW_TYPE)
1096            .params_json(params)
1097            .interactive()
1098            .semantics_label(
1099                self.content_description
1100                    .clone()
1101                    .unwrap_or_else(|| "slider".into()),
1102            );
1103        any(resolve_size(self.size, view))
1104    }
1105}
1106
1107impl Component for NativeSliderView {
1108    type State = SlotId;
1109
1110    fn init(&self) -> SlotId {
1111        let slot = next_local_slot();
1112        // See `NativeButtonView::init`'s doc — `Slider` registers a
1113        // callback via `Self::on_change`, so it needs the same
1114        // `pending_callbacks` reaper.
1115        on_cleanup(move || {
1116            with_runtime(|rt| rt.forget_pending_callback(slot));
1117        });
1118        slot
1119    }
1120
1121    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1122        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1123    }
1124}
1125
1126// ============================================================================
1127// Progress
1128// ============================================================================
1129
1130/// A real `android.widget.ProgressBar` rendered from pure Rust — display-only
1131/// (no listener, no `.interactive()`). Build one with [`native_progress`].
1132#[derive(Clone)]
1133pub struct NativeProgressView {
1134    value: i32,
1135    min: i32,
1136    max: i32,
1137    indeterminate: bool,
1138    content_description: Option<String>,
1139    size: Option<(f64, f64)>,
1140}
1141
1142/// A native `ProgressBar` at `value`, ranging over `[min, max]` — see
1143/// [`NativeProgressView`].
1144pub fn native_progress(value: i32, min: i32, max: i32) -> NativeProgressView {
1145    NativeProgressView {
1146        value,
1147        min,
1148        max,
1149        indeterminate: false,
1150        content_description: None,
1151        size: None,
1152    }
1153}
1154
1155impl NativeProgressView {
1156    /// Spinner mode: `true` ignores `value` entirely.
1157    pub fn indeterminate(mut self, indeterminate: bool) -> Self {
1158        self.indeterminate = indeterminate;
1159        self
1160    }
1161
1162    /// The TalkBack label.
1163    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1164        self.content_description = Some(label.into());
1165        self
1166    }
1167
1168    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1169    /// fallback.
1170    pub fn size(mut self, width: f64, height: f64) -> Self {
1171        self.size = Some((width, height));
1172        self
1173    }
1174
1175    /// `tokens` (theme ladder L2, including a later followup) folds the
1176    /// active theme's background/progress tint in — see
1177    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
1178    /// explicitly.
1179    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1180        let mut body = ParamsBody::new();
1181        body.push_raw(VALUE, self.value);
1182        body.push_raw(MIN, self.min);
1183        body.push_raw(MAX, self.max);
1184        body.push_raw(INDETERMINATE, self.indeterminate);
1185        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1186        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1187        if let Some(t) = tokens {
1188            body.push_raw(BACKGROUND_COLOR, t.surface_bg);
1189            body.push_raw(PROGRESS_TINT, t.accent_fill);
1190        }
1191        with_identity(progress::KIND, slot, &body.finish())
1192    }
1193
1194    fn build_with_mode(
1195        &self,
1196        slot: SlotId,
1197        mode: ResolvedSurfaceMode,
1198        tokens: Option<ResolvedTheme>,
1199    ) -> AnyView<SlotId> {
1200        if mode.translucency_refused() {
1201            return placeholder(self.size, "ProgressBar");
1202        }
1203        let params = self.params_for(slot, tokens);
1204        let view = platform_view(VIEW_TYPE)
1205            .params_json(params)
1206            .semantics_label(
1207                self.content_description
1208                    .clone()
1209                    .unwrap_or_else(|| "progress".into()),
1210            );
1211        any(resolve_size(self.size, view))
1212    }
1213}
1214
1215impl Component for NativeProgressView {
1216    type State = SlotId;
1217
1218    // No `on_cleanup` here: `ProgressBar` is display-only and never
1219    // calls `set_callback` — see `NativeLabelView::init`'s doc.
1220    fn init(&self) -> SlotId {
1221        next_local_slot()
1222    }
1223
1224    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1225        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1226    }
1227}
1228
1229// ============================================================================
1230// Spinner
1231// ============================================================================
1232
1233/// How large the spinner renders — the public mirror of
1234/// `crate::controls::spinner::SizeClass`, which stays `pub(crate)`. iOS has
1235/// no distinct small style (`UIActivityIndicatorView.Style` offers only
1236/// `.medium`/`.large`), so [`Self::Small`] renders the same as
1237/// [`Self::Medium`] on that one arm — see `crate::controls::spinner`'s
1238/// module doc.
1239#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1240pub enum NativeSpinnerSize {
1241    /// The smallest stock size. Degrades to [`Self::Medium`] on iOS.
1242    Small,
1243    /// The platform's own default circular spinner size.
1244    #[default]
1245    Medium,
1246    /// The largest stock size.
1247    Large,
1248}
1249
1250impl NativeSpinnerSize {
1251    /// The wire spelling `crate::controls::spinner::SizeClass::from_wire`
1252    /// decodes.
1253    fn wire(self) -> &'static str {
1254        match self {
1255            Self::Small => "small",
1256            Self::Medium => "medium",
1257            Self::Large => "large",
1258        }
1259    }
1260}
1261
1262/// A real indeterminate activity indicator rendered from pure Rust —
1263/// display-only (no listener, no `.interactive()`). Build one with
1264/// [`native_spinner`].
1265#[derive(Clone)]
1266pub struct NativeSpinnerView {
1267    animating: bool,
1268    size_class: NativeSpinnerSize,
1269    enabled: bool,
1270    content_description: Option<String>,
1271    size: Option<(f64, f64)>,
1272}
1273
1274/// A native spinner, animating or not — see [`NativeSpinnerView`].
1275pub fn native_spinner(animating: bool) -> NativeSpinnerView {
1276    NativeSpinnerView {
1277        animating,
1278        size_class: NativeSpinnerSize::default(),
1279        enabled: true,
1280        content_description: None,
1281        size: None,
1282    }
1283}
1284
1285impl NativeSpinnerView {
1286    /// The spinner's size — see [`NativeSpinnerSize`].
1287    pub fn size_class(mut self, size_class: NativeSpinnerSize) -> Self {
1288        self.size_class = size_class;
1289        self
1290    }
1291
1292    /// `View.setEnabled` — Android only; the other two arms have no
1293    /// `enabled` property on this control at all (`crate::controls::spinner`'s
1294    /// module doc's *`enabled`* section). Default `true`.
1295    pub fn enabled(mut self, enabled: bool) -> Self {
1296        self.enabled = enabled;
1297        self
1298    }
1299
1300    /// The TalkBack/VoiceOver label.
1301    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1302        self.content_description = Some(label.into());
1303        self
1304    }
1305
1306    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1307    /// fallback.
1308    pub fn size(mut self, width: f64, height: f64) -> Self {
1309        self.size = Some((width, height));
1310        self
1311    }
1312
1313    /// `tokens` (theme ladder L2) folds the active theme's `accent_ink` in
1314    /// as the spinner's tint — see [`NativeButtonView::params_for`]'s doc
1315    /// for why it's threaded explicitly.
1316    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1317        let mut body = ParamsBody::new();
1318        body.push_raw(spinner::ANIMATING, self.animating);
1319        body.push_str(spinner::SIZE_CLASS, self.size_class.wire());
1320        body.push_raw(ENABLED, self.enabled);
1321        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1322        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1323        if let Some(t) = tokens {
1324            body.push_raw(TINT, t.accent_ink);
1325        }
1326        with_identity(spinner::KIND, slot, &body.finish())
1327    }
1328
1329    fn build_with_mode(
1330        &self,
1331        slot: SlotId,
1332        mode: ResolvedSurfaceMode,
1333        tokens: Option<ResolvedTheme>,
1334    ) -> AnyView<SlotId> {
1335        if mode.translucency_refused() {
1336            return placeholder(self.size, "Spinner");
1337        }
1338        let params = self.params_for(slot, tokens);
1339        let view = platform_view(VIEW_TYPE)
1340            .params_json(params)
1341            .semantics_label(
1342                self.content_description
1343                    .clone()
1344                    .unwrap_or_else(|| "spinner".into()),
1345            );
1346        any(resolve_size(self.size, view))
1347    }
1348}
1349
1350impl Component for NativeSpinnerView {
1351    type State = SlotId;
1352
1353    // No `on_cleanup` here: the spinner is display-only and never calls
1354    // `set_callback` — see `NativeLabelView::init`'s doc.
1355    fn init(&self) -> SlotId {
1356        next_local_slot()
1357    }
1358
1359    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1360        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1361    }
1362}
1363
1364// ============================================================================
1365// Date picker
1366// ============================================================================
1367
1368/// How [`NativeDatePickerView`] presents itself — the public mirror of
1369/// `crate::controls::date_picker::DatePickerStyle`, which stays `pub(crate)`.
1370///
1371/// | Style | Android | iOS | macOS |
1372/// |---|---|---|---|
1373/// | [`Self::Compact`] | spinner mode | `.compact` | text field + stepper, calendar overlay on click |
1374/// | [`Self::Wheels`] | spinner mode | `.wheels` | text field + stepper (AppKit has no wheels) |
1375/// | [`Self::Inline`] | calendar mode | `.inline` | clock-and-calendar |
1376///
1377/// Android fixes the mode when the picker is created: a later style change
1378/// is not applied there (logged once) — see `crate::controls::date_picker`'s
1379/// module doc.
1380#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1381pub enum NativeDatePickerStyle {
1382    /// The smallest footprint the platform offers.
1383    #[default]
1384    Compact,
1385    /// Spinning wheels.
1386    Wheels,
1387    /// A full, always-visible calendar.
1388    Inline,
1389}
1390
1391impl NativeDatePickerStyle {
1392    /// The wire spelling `crate::controls::date_picker::DatePickerStyle`
1393    /// decodes.
1394    fn wire(self) -> &'static str {
1395        match self {
1396            Self::Compact => "compact",
1397            Self::Wheels => "wheels",
1398            Self::Inline => "inline",
1399        }
1400    }
1401}
1402
1403/// A real DATE-mode picker (no time) rendered from pure Rust —
1404/// `android.widget.DatePicker`, `UIDatePicker`, `NSDatePicker`. Controlled,
1405/// like [`NativeSwitchView`]: the app owns `date`, and a pick only ever
1406/// arrives through [`Self::on_change`] as a *requested* [`CivilDate`] the app
1407/// confirms by feeding it back. Build one with [`native_date_picker`].
1408#[derive(Clone)]
1409pub struct NativeDatePickerView {
1410    date: CivilDate,
1411    min: Option<CivilDate>,
1412    max: Option<CivilDate>,
1413    style: NativeDatePickerStyle,
1414    enabled: bool,
1415    content_description: Option<String>,
1416    size: Option<(f64, f64)>,
1417    on_change: Option<Arc<dyn Fn(CivilDate) + Send + Sync>>,
1418}
1419
1420/// A native date picker showing `date` — see [`NativeDatePickerView`].
1421pub fn native_date_picker(date: CivilDate) -> NativeDatePickerView {
1422    NativeDatePickerView {
1423        date,
1424        min: None,
1425        max: None,
1426        style: NativeDatePickerStyle::default(),
1427        enabled: true,
1428        content_description: None,
1429        size: None,
1430        on_change: None,
1431    }
1432}
1433
1434impl NativeDatePickerView {
1435    /// The earliest selectable date — a missing bound is the arm's own
1436    /// default: unbounded on iOS/macOS, 1900-01-01 on Android. A `date`
1437    /// before it is shown (and reported) as `min`; a `max` before it
1438    /// collapses the range to the single day `min`. On Android, an
1439    /// explicit `min` (and `date`) outside the platform's own
1440    /// 1900-01-01..2100-12-31 range is clamped into it, with a warning
1441    /// (`native-widgets-android-date-picker-range` in LIMITATIONS.md).
1442    pub fn min(mut self, min: CivilDate) -> Self {
1443        self.min = Some(min);
1444        self
1445    }
1446
1447    /// The latest selectable date — a missing bound is the arm's own
1448    /// default: unbounded on iOS/macOS, 2100-12-31 on Android. A `date`
1449    /// after it is shown (and reported) as `max`. On Android, an explicit
1450    /// `max` (and `date`) outside the platform's own
1451    /// 1900-01-01..2100-12-31 range is clamped into it, with a warning
1452    /// (`native-widgets-android-date-picker-range` in LIMITATIONS.md).
1453    pub fn max(mut self, max: CivilDate) -> Self {
1454        self.max = Some(max);
1455        self
1456    }
1457
1458    /// The presentation — see [`NativeDatePickerStyle`]. Default
1459    /// [`NativeDatePickerStyle::Compact`].
1460    pub fn style(mut self, style: NativeDatePickerStyle) -> Self {
1461        self.style = style;
1462        self
1463    }
1464
1465    /// `View.setEnabled` / `UIControl.enabled` / `NSControl.enabled` —
1466    /// default `true`.
1467    pub fn enabled(mut self, enabled: bool) -> Self {
1468        self.enabled = enabled;
1469        self
1470    }
1471
1472    /// The TalkBack/VoiceOver label.
1473    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1474        self.content_description = Some(label.into());
1475        self
1476    }
1477
1478    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1479    /// fallback.
1480    pub fn size(mut self, width: f64, height: f64) -> Self {
1481        self.size = Some((width, height));
1482        self
1483    }
1484
1485    /// Fires with the **requested** date when the user picks one. Feed it
1486    /// back as the builder's `date` to accept it; keep the old one to refuse
1487    /// it (the picker snaps back on the next differing params).
1488    pub fn on_change(mut self, handler: impl Fn(CivilDate) + Send + Sync + 'static) -> Self {
1489        self.on_change = Some(Arc::new(handler));
1490        self
1491    }
1492
1493    /// `tokens` (theme ladder L2) folds the active theme's `accent_ink` in as
1494    /// the tint and `body_text` as the text colour — see
1495    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
1496    /// explicitly, and `crate::api::theme`'s mapping table for which arm
1497    /// honours which (Android's `DatePicker` honours neither).
1498    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1499        let mut body = ParamsBody::new();
1500        body.push_raw(date_picker::DATE, date_picker::wire(self.date));
1501        if let Some(min) = self.min {
1502            body.push_raw(date_picker::MIN_DATE, date_picker::wire(min));
1503        }
1504        if let Some(max) = self.max {
1505            body.push_raw(date_picker::MAX_DATE, date_picker::wire(max));
1506        }
1507        body.push_str(date_picker::STYLE, self.style.wire());
1508        body.push_raw(ENABLED, self.enabled);
1509        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1510        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1511        if let Some(t) = tokens {
1512            body.push_raw(TINT, t.accent_ink);
1513            body.push_raw(TEXT_COLOR, t.body_text);
1514        }
1515        with_identity(date_picker::KIND, slot, &body.finish())
1516    }
1517
1518    fn build_with_mode(
1519        &self,
1520        slot: SlotId,
1521        mode: ResolvedSurfaceMode,
1522        tokens: Option<ResolvedTheme>,
1523    ) -> AnyView<SlotId> {
1524        if mode.translucency_refused() {
1525            return placeholder(self.size, "Date picker");
1526        }
1527        let params = self.params_for(slot, tokens);
1528        if let Some(on_change) = self.on_change.clone() {
1529            with_runtime(|rt| rt.set_callback(slot, on_date(on_change)));
1530        }
1531        let view = platform_view(VIEW_TYPE)
1532            .params_json(params)
1533            .interactive()
1534            .semantics_label(
1535                self.content_description
1536                    .clone()
1537                    .unwrap_or_else(|| "date picker".into()),
1538            );
1539        any(resolve_size(self.size, view))
1540    }
1541}
1542
1543impl Component for NativeDatePickerView {
1544    type State = SlotId;
1545
1546    fn init(&self) -> SlotId {
1547        let slot = next_local_slot();
1548        // See `NativeButtonView::init`'s doc — the picker registers a
1549        // callback via `Self::on_change`, so it needs the same
1550        // `pending_callbacks` reaper.
1551        on_cleanup(move || {
1552            with_runtime(|rt| rt.forget_pending_callback(slot));
1553        });
1554        slot
1555    }
1556
1557    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1558        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1559    }
1560}
1561
1562// ============================================================================
1563// Segmented
1564// ============================================================================
1565
1566/// Whether this build's platform arm registers the segmented control — the
1567/// crate's first **compile-time** platform gate on a builder. `Segmented` is
1568/// in `crate::controls::APPLE_KINDS`: iOS and macOS carry a `NativeWidget`
1569/// impl, Android does not (decision D2 — `crate::controls::segmented`'s module
1570/// doc), and neither does any host target. Where it is `false`,
1571/// [`NativeSegmentedView`] renders the frust-drawn refusal banner
1572/// ([`RefusalBanner`], via [`banner_placeholder`]) instead of publishing a slot
1573/// no registered kind could serve — a visible, screen-reader-announced
1574/// refusal rather than the factory's silent empty dead-slot view.
1575///
1576/// A `cfg`-selected constant rather than `cfg`'d code paths so both branches
1577/// compile, and are host-tested ([`NativeSegmentedView::build_for_arm`]), on
1578/// every target.
1579#[cfg(any(target_os = "ios", target_os = "macos"))]
1580const SEGMENTED_ARM: bool = true;
1581/// See the Apple-arm definition above: no segmented arm on Android (D2) or on
1582/// any host target.
1583#[cfg(not(any(target_os = "ios", target_os = "macos")))]
1584const SEGMENTED_ARM: bool = false;
1585
1586/// The banner's visible label on a target with no segmented arm.
1587const SEGMENTED_UNAVAILABLE_LABEL: &str = "Native segmented control unavailable";
1588
1589/// The banner's explanation on a target with no segmented arm — names the
1590/// missing Android arm and where it is tracked.
1591const SEGMENTED_UNAVAILABLE_DESCRIPTION: &str = "native_segmented has no Android arm in this \
1592     build (the framework has no segmented control; a Material-backed Android arm is a \
1593     follow-up plan) — rendering a frust placeholder instead of an empty native slot.";
1594
1595/// Log the no-segmented-arm fallback exactly once, crate-wide.
1596static SEGMENTED_NO_ARM_LOGGED: Once = Once::new();
1597
1598fn warn_no_segmented_arm_once() {
1599    SEGMENTED_NO_ARM_LOGGED.call_once(|| {
1600        log::warn!(
1601            "frust-native-widgets: native_segmented is an iOS/macOS-only control in this build \
1602             (no Android arm yet — a Material-backed one is a follow-up plan); rendering the \
1603             frust-drawn refusal banner instead of a native slot"
1604        );
1605    });
1606}
1607
1608/// A real segmented control rendered from pure Rust — `UISegmentedControl` on
1609/// iOS/iPadOS, `NSSegmentedControl` on macOS, and a frust-drawn refusal banner
1610/// everywhere else (Android included: see [`SEGMENTED_ARM`]). A
1611/// **controlled** component, like [`NativeSwitchView`]: the app owns
1612/// `selected`, and a tap only ever arrives through [`Self::on_select`] as a
1613/// *requested* index. Build one with [`native_segmented`].
1614#[derive(Clone)]
1615pub struct NativeSegmentedView {
1616    labels: Vec<String>,
1617    selected: usize,
1618    enabled: bool,
1619    momentary: bool,
1620    content_description: Option<String>,
1621    size: Option<(f64, f64)>,
1622    on_select: Option<Arc<dyn Fn(usize) + Send + Sync>>,
1623}
1624
1625/// A native segmented control over `labels`, with the app-owned `selected`
1626/// segment — see [`NativeSegmentedView`]. An out-of-range `selected` shows no
1627/// selection rather than failing; at most 64 segments are shown
1628/// (`crate::controls::segmented::MAX_SEGMENTS`).
1629pub fn native_segmented(labels: Vec<String>, selected: usize) -> NativeSegmentedView {
1630    NativeSegmentedView {
1631        labels,
1632        selected,
1633        enabled: true,
1634        momentary: false,
1635        content_description: None,
1636        size: None,
1637        on_select: None,
1638    }
1639}
1640
1641impl NativeSegmentedView {
1642    /// `UIControl.enabled` / `NSControl.enabled` — default `true`.
1643    pub fn enabled(mut self, enabled: bool) -> Self {
1644        self.enabled = enabled;
1645        self
1646    }
1647
1648    /// Momentary tracking: a tap flashes its segment and reports it through
1649    /// [`Self::on_select`] without leaving it selected (a toolbar of
1650    /// actions rather than a choice). Default `false`.
1651    pub fn momentary(mut self, momentary: bool) -> Self {
1652        self.momentary = momentary;
1653        self
1654    }
1655
1656    /// The VoiceOver label.
1657    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1658        self.content_description = Some(label.into());
1659        self
1660    }
1661
1662    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1663    /// fallback.
1664    pub fn size(mut self, width: f64, height: f64) -> Self {
1665        self.size = Some((width, height));
1666        self
1667    }
1668
1669    /// Fires with the *requested* segment index on a user tap — the app
1670    /// confirms (or rejects) it by feeding `selected` back through the next
1671    /// build, the controlled-component contract every interactive frust
1672    /// widget follows. Never fires on a target with no segmented arm.
1673    pub fn on_select(mut self, handler: impl Fn(usize) + Send + Sync + 'static) -> Self {
1674        self.on_select = Some(Arc::new(handler));
1675        self
1676    }
1677
1678    /// `tokens` (theme ladder L2) folds the active theme's `accent_fill` in
1679    /// as the selected segment's tint — see [`NativeButtonView::params_for`]'s
1680    /// doc for why it's threaded explicitly, and
1681    /// `crate::controls::segmented`'s module doc for the per-arm property.
1682    /// The labels ride flat `segmentCount` + `segment<i>` keys (that module
1683    /// doc's *The labels ride flat params*).
1684    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1685        let mut body = ParamsBody::new();
1686        body.push_raw(segmented::SEGMENT_COUNT, self.labels.len());
1687        for (index, label) in self.labels.iter().enumerate() {
1688            body.push_str(&segmented::segment_key(index), label);
1689        }
1690        body.push_raw(segmented::SELECTED, self.selected);
1691        body.push_raw(segmented::MOMENTARY, self.momentary);
1692        body.push_raw(ENABLED, self.enabled);
1693        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1694        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1695        if let Some(t) = tokens {
1696            body.push_raw(TINT, t.accent_fill);
1697        }
1698        with_identity(segmented::KIND, slot, &body.finish())
1699    }
1700
1701    fn build_with_mode(
1702        &self,
1703        slot: SlotId,
1704        mode: ResolvedSurfaceMode,
1705        tokens: Option<ResolvedTheme>,
1706    ) -> AnyView<SlotId> {
1707        self.build_for_arm(slot, mode, tokens, SEGMENTED_ARM)
1708    }
1709
1710    /// [`Self::build_with_mode`] with the platform-arm gate threaded as a
1711    /// parameter, so a host test can drive both branches on any target.
1712    ///
1713    /// No arm → the unavailable banner, before anything else: the refusal is
1714    /// structural, whatever the surface mode, and no callback is registered
1715    /// (no event could ever arrive for it).
1716    fn build_for_arm(
1717        &self,
1718        slot: SlotId,
1719        mode: ResolvedSurfaceMode,
1720        tokens: Option<ResolvedTheme>,
1721        arm_available: bool,
1722    ) -> AnyView<SlotId> {
1723        if !arm_available {
1724            warn_no_segmented_arm_once();
1725            return banner_placeholder(
1726                self.size,
1727                SEGMENTED_UNAVAILABLE_LABEL.to_string(),
1728                SEGMENTED_UNAVAILABLE_DESCRIPTION.to_string(),
1729            );
1730        }
1731        if mode.translucency_refused() {
1732            return placeholder(self.size, "SegmentedControl");
1733        }
1734        let params = self.params_for(slot, tokens);
1735        if let Some(on_select) = self.on_select.clone() {
1736            with_runtime(|rt| rt.set_callback(slot, on_selected(on_select)));
1737        }
1738        let view = platform_view(VIEW_TYPE)
1739            .params_json(params)
1740            .interactive()
1741            .semantics_label(
1742                self.content_description
1743                    .clone()
1744                    .unwrap_or_else(|| "segmented control".into()),
1745            );
1746        any(resolve_size(self.size, view))
1747    }
1748}
1749
1750impl Component for NativeSegmentedView {
1751    type State = SlotId;
1752
1753    fn init(&self) -> SlotId {
1754        let slot = next_local_slot();
1755        // See `NativeButtonView::init`'s doc — `Segmented` registers a
1756        // callback via `Self::on_select`, so it needs the same
1757        // `pending_callbacks` reaper (a harmless no-op on a target with no
1758        // arm, where nothing is ever registered).
1759        on_cleanup(move || {
1760            with_runtime(|rt| rt.forget_pending_callback(slot));
1761        });
1762        slot
1763    }
1764
1765    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1766        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1767    }
1768}
1769
1770// ============================================================================
1771// Stepper
1772// ============================================================================
1773
1774/// Whether this build's platform arm registers the stepper control — the
1775/// same compile-time platform gate [`SEGMENTED_ARM`] is, for `Stepper`
1776/// instead. `Stepper` is in `crate::controls::APPLE_KINDS`: iOS and macOS
1777/// carry a `NativeWidget` impl, Android does not (`android.widget` has no
1778/// increment/decrement control — `crate::controls::stepper`'s module doc),
1779/// and neither does any host target. Where it is `false`,
1780/// [`NativeStepperView`] renders the frust-drawn refusal banner
1781/// ([`RefusalBanner`], via [`banner_placeholder`]) instead of publishing a
1782/// slot no registered kind could serve.
1783///
1784/// A `cfg`-selected constant rather than `cfg`'d code paths so both branches
1785/// compile, and are host-tested ([`NativeStepperView::build_for_arm`]), on
1786/// every target.
1787#[cfg(any(target_os = "ios", target_os = "macos"))]
1788const STEPPER_ARM: bool = true;
1789/// See the Apple-arm definition above: no stepper arm on Android or on any
1790/// host target.
1791#[cfg(not(any(target_os = "ios", target_os = "macos")))]
1792const STEPPER_ARM: bool = false;
1793
1794/// The banner's visible label on a target with no stepper arm.
1795const STEPPER_UNAVAILABLE_LABEL: &str = "Native stepper unavailable";
1796
1797/// The banner's explanation on a target with no stepper arm — names the
1798/// missing Android arm and where it is tracked.
1799const STEPPER_UNAVAILABLE_DESCRIPTION: &str = "native_stepper has no Android arm in this build \
1800     (the framework has no increment/decrement control; a composite Android arm is a follow-up \
1801     plan) — rendering a frust placeholder instead of an empty native slot.";
1802
1803/// Log the no-stepper-arm fallback exactly once, crate-wide.
1804static STEPPER_NO_ARM_LOGGED: Once = Once::new();
1805
1806fn warn_no_stepper_arm_once() {
1807    STEPPER_NO_ARM_LOGGED.call_once(|| {
1808        log::warn!(
1809            "frust-native-widgets: native_stepper is an iOS/macOS-only control in this build (no \
1810             Android arm yet — a composite one is a follow-up plan); rendering the frust-drawn \
1811             refusal banner instead of a native slot"
1812        );
1813    });
1814}
1815
1816/// A real increment/decrement control rendered from pure Rust — `UIStepper`
1817/// on iOS/iPadOS, `NSStepper` on macOS, and a frust-drawn refusal banner
1818/// everywhere else (Android included: see [`STEPPER_ARM`]). A **controlled**
1819/// component, like [`NativeSliderView`]: the app owns `value`, and a tap on
1820/// either button only ever arrives through [`Self::on_change`] as a
1821/// *requested* value. Build one with [`native_stepper`].
1822///
1823/// `min`/`max`/[`Self::step`] are normalized before they ever reach a
1824/// platform control (`crate::controls::stepper`'s module doc's *Range and
1825/// step invariants*: `UIStepper` aborts the process on a non-positive
1826/// `stepValue` or a `maximumValue` not strictly greater than
1827/// `minimumValue`). A degenerate range (`max <= min`) renders the control
1828/// **disabled** — the user can never tap it, and no value outside `[min,
1829/// max]` is ever reported — rather than refusing to mount or crashing; it
1830/// re-enables the moment a later update makes the range non-degenerate
1831/// again.
1832#[derive(Clone)]
1833pub struct NativeStepperView {
1834    value: i32,
1835    min: i32,
1836    max: i32,
1837    step: i32,
1838    wraps: bool,
1839    enabled: bool,
1840    content_description: Option<String>,
1841    size: Option<(f64, f64)>,
1842    on_change: Option<Arc<dyn Fn(i32) + Send + Sync>>,
1843}
1844
1845/// A native `Stepper` at `value`, ranging over `[min, max]` — see
1846/// [`NativeStepperView`].
1847pub fn native_stepper(value: i32, min: i32, max: i32) -> NativeStepperView {
1848    NativeStepperView {
1849        value,
1850        min,
1851        max,
1852        step: 1,
1853        wraps: false,
1854        enabled: true,
1855        content_description: None,
1856        size: None,
1857        on_change: None,
1858    }
1859}
1860
1861impl NativeStepperView {
1862    /// The increment a tap on either button applies — default `1`. A
1863    /// non-positive value (`0` or negative) is normalized to `1` before it
1864    /// ever reaches a platform control, logged once per process —
1865    /// `UIStepper` requires `stepValue > 0` (`crate::controls::stepper`'s
1866    /// module doc's *Range and step invariants*).
1867    pub fn step(mut self, step: i32) -> Self {
1868        self.step = step;
1869        self
1870    }
1871
1872    /// Whether the value wraps from `max` back to `min` (and back) instead
1873    /// of clamping at the bounds — default `false`.
1874    pub fn wraps(mut self, wraps: bool) -> Self {
1875        self.wraps = wraps;
1876        self
1877    }
1878
1879    /// `UIControl.enabled` / `NSControl.enabled` — default `true`.
1880    pub fn enabled(mut self, enabled: bool) -> Self {
1881        self.enabled = enabled;
1882        self
1883    }
1884
1885    /// The VoiceOver label.
1886    pub fn content_description(mut self, label: impl Into<String>) -> Self {
1887        self.content_description = Some(label.into());
1888        self
1889    }
1890
1891    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
1892    /// fallback.
1893    pub fn size(mut self, width: f64, height: f64) -> Self {
1894        self.size = Some((width, height));
1895        self
1896    }
1897
1898    /// Fires with the requested **app-space** value on a tap
1899    /// (`crate::controls::stepper`'s platform-space mapping already undone —
1900    /// the same mapping [`NativeSliderView::on_change`] documents). Never
1901    /// fires on a target with no stepper arm.
1902    pub fn on_change(mut self, handler: impl Fn(i32) + Send + Sync + 'static) -> Self {
1903        self.on_change = Some(Arc::new(handler));
1904        self
1905    }
1906
1907    /// `tokens` (theme ladder L2) folds the active theme's `accent_ink` in as
1908    /// `UIStepper.tintColor` — see [`NativeButtonView::params_for`]'s doc for
1909    /// why it's threaded explicitly, and `crate::controls::stepper`'s module
1910    /// doc's *Tint* section for why `NSStepper` never receives it (logged and
1911    /// no-op'd on that one arm, not a gap in this fold).
1912    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>) -> String {
1913        let mut body = ParamsBody::new();
1914        body.push_raw(VALUE, self.value);
1915        body.push_raw(MIN, self.min);
1916        body.push_raw(MAX, self.max);
1917        body.push_raw(STEP, self.step);
1918        body.push_raw(WRAPS, self.wraps);
1919        body.push_raw(ENABLED, self.enabled);
1920        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
1921        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
1922        if let Some(t) = tokens {
1923            body.push_raw(TINT, t.accent_ink);
1924        }
1925        with_identity(stepper::KIND, slot, &body.finish())
1926    }
1927
1928    fn build_with_mode(
1929        &self,
1930        slot: SlotId,
1931        mode: ResolvedSurfaceMode,
1932        tokens: Option<ResolvedTheme>,
1933    ) -> AnyView<SlotId> {
1934        self.build_for_arm(slot, mode, tokens, STEPPER_ARM)
1935    }
1936
1937    /// [`Self::build_with_mode`] with the platform-arm gate threaded as a
1938    /// parameter, so a host test can drive both branches on any target —
1939    /// [`NativeSegmentedView::build_for_arm`]'s exact shape.
1940    ///
1941    /// No arm → the unavailable banner, before anything else: the refusal is
1942    /// structural, whatever the surface mode, and no callback is registered
1943    /// (no event could ever arrive for it).
1944    fn build_for_arm(
1945        &self,
1946        slot: SlotId,
1947        mode: ResolvedSurfaceMode,
1948        tokens: Option<ResolvedTheme>,
1949        arm_available: bool,
1950    ) -> AnyView<SlotId> {
1951        if !arm_available {
1952            warn_no_stepper_arm_once();
1953            return banner_placeholder(
1954                self.size,
1955                STEPPER_UNAVAILABLE_LABEL.to_string(),
1956                STEPPER_UNAVAILABLE_DESCRIPTION.to_string(),
1957            );
1958        }
1959        if mode.translucency_refused() {
1960            return placeholder(self.size, "Stepper");
1961        }
1962        let params = self.params_for(slot, tokens);
1963        if let Some(on_change) = self.on_change.clone() {
1964            with_runtime(|rt| rt.set_callback(slot, on_value_changed(on_change)));
1965        }
1966        let view = platform_view(VIEW_TYPE)
1967            .params_json(params)
1968            .interactive()
1969            .semantics_label(
1970                self.content_description
1971                    .clone()
1972                    .unwrap_or_else(|| "stepper".into()),
1973            );
1974        any(resolve_size(self.size, view))
1975    }
1976}
1977
1978impl Component for NativeStepperView {
1979    type State = SlotId;
1980
1981    fn init(&self) -> SlotId {
1982        let slot = next_local_slot();
1983        // See `NativeButtonView::init`'s doc — `Stepper` registers a
1984        // callback via `Self::on_change`, so it needs the same
1985        // `pending_callbacks` reaper (a harmless no-op on a target with no
1986        // arm, where nothing is ever registered).
1987        on_cleanup(move || {
1988            with_runtime(|rt| rt.forget_pending_callback(slot));
1989        });
1990        slot
1991    }
1992
1993    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
1994        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
1995    }
1996}
1997
1998// ============================================================================
1999// Tab bar
2000// ============================================================================
2001
2002/// Whether this build's platform arm registers the tab bar — the same
2003/// compile-time gate as [`SEGMENTED_ARM`], narrower: `TabBar` is in
2004/// `crate::controls::IOS_ONLY_KINDS`, so **only iOS/iPadOS** carries a
2005/// `NativeWidget` impl. macOS has no bottom-tab-bar idiom and Android's
2006/// `BottomNavigationView` needs Material (decision D2), so both — and every
2007/// host target — render the refusal banner.
2008#[cfg(target_os = "ios")]
2009const TAB_BAR_ARM: bool = true;
2010/// See the iOS definition above.
2011#[cfg(not(target_os = "ios"))]
2012const TAB_BAR_ARM: bool = false;
2013
2014/// The banner's visible label on a target with no tab-bar arm.
2015const TAB_BAR_UNAVAILABLE_LABEL: &str = "Native tab bar unavailable";
2016
2017/// The banner's explanation on a target with no tab-bar arm — names both
2018/// reasons (macOS idiom, Android Material).
2019const TAB_BAR_UNAVAILABLE_DESCRIPTION: &str = "native_tab_bar is iOS/iPadOS-only: macOS has no \
2020     bottom tab bar idiom, and Android's BottomNavigationView needs Material, which this plugin \
2021     never assumes — rendering a frust placeholder instead of an empty native slot.";
2022
2023/// Log the no-tab-bar-arm fallback exactly once, crate-wide.
2024static TAB_BAR_NO_ARM_LOGGED: Once = Once::new();
2025
2026fn warn_no_tab_bar_arm_once() {
2027    TAB_BAR_NO_ARM_LOGGED.call_once(|| {
2028        log::warn!(
2029            "frust-native-widgets: native_tab_bar is an iOS/iPadOS-only control (macOS has no \
2030             bottom tab bar idiom; Android's BottomNavigationView needs Material); rendering the \
2031             frust-drawn refusal banner instead of a native slot"
2032        );
2033    });
2034}
2035
2036/// A tab's stable, app-chosen identity — what [`native_tab_bar`]'s
2037/// `selected` names and what [`NativeTabBarView::on_select`]/
2038/// [`NativeTabBarView::on_reselect`] report. Never an index: reordering or
2039/// inserting items keeps every id meaning the same tab.
2040#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2041pub struct TabId(String);
2042
2043impl TabId {
2044    /// A tab id spelled `id`.
2045    pub fn new(id: impl Into<String>) -> Self {
2046        Self(id.into())
2047    }
2048
2049    /// The id's spelling.
2050    pub fn as_str(&self) -> &str {
2051        &self.0
2052    }
2053}
2054
2055impl From<&str> for TabId {
2056    fn from(id: &str) -> Self {
2057        Self::new(id)
2058    }
2059}
2060
2061impl From<String> for TabId {
2062    fn from(id: String) -> Self {
2063        Self(id)
2064    }
2065}
2066
2067impl std::fmt::Display for TabId {
2068    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2069        f.write_str(&self.0)
2070    }
2071}
2072
2073/// A tab's icon: encoded image bytes, or an Apple SF Symbol name — never an
2074/// arbitrary string passed off as a cross-platform icon (the bar is
2075/// iOS-only, and a symbol name means nothing anywhere else).
2076#[derive(Clone, Debug, PartialEq)]
2077pub enum TabIcon {
2078    /// Encoded image bytes (PNG/JPEG — whatever `UIImage` decodes), shown as
2079    /// a template image tinted by the bar. Normalized to a 25pt box: supply
2080    /// 75×75px for a crisp 3x icon; a smaller image is never upscaled.
2081    Bytes(Arc<[u8]>),
2082    /// An SF Symbol name (`"house"`, `"gearshape.fill"`) —
2083    /// `UIImage.systemImageNamed:`, sized by the bar itself. An unknown name
2084    /// shows the title alone (logged once).
2085    AppleSymbol(String),
2086}
2087
2088/// One tab of a [`native_tab_bar`]: a stable [`TabId`], a title, an icon,
2089/// and optionally a selected-state icon, a badge and a disabled state.
2090#[derive(Clone, Debug, PartialEq)]
2091pub struct TabItem {
2092    id: TabId,
2093    title: String,
2094    icon: TabIcon,
2095    selected_icon: Option<TabIcon>,
2096    badge: Option<String>,
2097    enabled: bool,
2098}
2099
2100impl TabItem {
2101    /// An enabled, badge-less tab `id` titled `title` showing `icon`.
2102    pub fn new(id: impl Into<TabId>, title: impl Into<String>, icon: TabIcon) -> Self {
2103        Self {
2104            id: id.into(),
2105            title: title.into(),
2106            icon,
2107            selected_icon: None,
2108            badge: None,
2109            enabled: true,
2110        }
2111    }
2112
2113    /// The icon shown while this tab is selected (`UITabBarItem.selectedImage`)
2114    /// — e.g. the `.fill` variant of an SF Symbol. Default: the bar tints
2115    /// [`Self::new`]'s icon.
2116    pub fn selected_icon(mut self, icon: TabIcon) -> Self {
2117        self.selected_icon = Some(icon);
2118        self
2119    }
2120
2121    /// A badge (`UITabBarItem.badgeValue`) — a count, `"new"`, or `""` for a
2122    /// bare dot-sized badge. Default: none.
2123    pub fn badge(mut self, badge: impl Into<String>) -> Self {
2124        self.badge = Some(badge.into());
2125        self
2126    }
2127
2128    /// Whether the tab can be tapped (`UIBarItem.enabled`). Default `true`.
2129    pub fn enabled(mut self, enabled: bool) -> Self {
2130        self.enabled = enabled;
2131        self
2132    }
2133
2134    /// This tab's id.
2135    pub fn id(&self) -> &TabId {
2136        &self.id
2137    }
2138}
2139
2140/// The SF Symbol name one icon slot carries on the wire (byte icons ride
2141/// the side table instead — `crate::controls::tab_bar`'s module doc).
2142fn symbol_name(icon: Option<&TabIcon>) -> Option<&str> {
2143    match icon {
2144        Some(TabIcon::AppleSymbol(name)) => Some(name),
2145        _ => None,
2146    }
2147}
2148
2149/// The encoded bytes one icon slot publishes, if it is a byte icon.
2150fn icon_bytes(icon: Option<&TabIcon>) -> Option<Arc<[u8]>> {
2151    match icon {
2152        Some(TabIcon::Bytes(bytes)) => Some(Arc::clone(bytes)),
2153        _ => None,
2154    }
2155}
2156
2157/// A real, bare `UITabBar` on iOS/iPadOS — a **controlled** bottom tab bar
2158/// that never navigates by itself: a tap reports the requested [`TabId`]
2159/// through [`Self::on_select`], the app routes (typically
2160/// `RouteNavigator::go`) and feeds the confirmed id back as `selected` on the
2161/// next build. A tap on the tab already showing reports through
2162/// [`Self::on_reselect`] instead ("scroll to top / pop to root"). macOS and
2163/// Android (and every host target) render a frust-drawn refusal banner.
2164/// Build one with [`native_tab_bar`].
2165///
2166/// # Placement and height
2167///
2168/// The bar sizes itself: 49pt tall plus the window's bottom safe-area inset,
2169/// read at layout time, as wide as its parent allows — so its background runs
2170/// under the home indicator while UIKit keeps the items above it. Put it last
2171/// in a `Column` docked to the window's bottom edge, and do **not** also
2172/// consume the bottom inset above it: if you wrap it in `frust::safe_area`
2173/// for horizontal cutouts, use `.top(false).bottom(false)` — the shape
2174/// `examples/huddle` uses for Material's `navigation_bar`, which self-insets
2175/// the same way. For a bar that is not docked to the bottom edge, call
2176/// [`Self::safe_area`]`(false)` to get the bare 49pt.
2177///
2178/// On iPadOS a bare `UITabBar` stays a bottom bar (the iPadOS 18 top tab bar
2179/// and the Liquid Glass floating bar are `UITabBarController` features), which
2180/// is expected.
2181#[derive(Clone)]
2182pub struct NativeTabBarView {
2183    items: Vec<TabItem>,
2184    selected: TabId,
2185    safe_area: bool,
2186    on_select: TabHandler,
2187    on_reselect: TabHandler,
2188}
2189
2190/// A native bottom tab bar over `items`, with the app-owned `selected` tab —
2191/// see [`NativeTabBarView`]. A `selected` id no item carries shows no
2192/// selection; duplicate ids resolve to the first item carrying them; at most
2193/// 16 items are shown (`crate::controls::tab_bar::MAX_ITEMS`).
2194///
2195/// ```ignore
2196/// let nav = router.route_navigator();
2197/// native_tab_bar(
2198///     vec![
2199///         TabItem::new("home", "Home", TabIcon::AppleSymbol("house".into())),
2200///         TabItem::new("inbox", "Inbox", TabIcon::AppleSymbol("tray".into())).badge("3"),
2201///     ],
2202///     TabId::new(current_tab.get()),
2203/// )
2204/// .on_select(move |id| nav.go(format!("/{id}")))
2205/// ```
2206pub fn native_tab_bar(items: Vec<TabItem>, selected: impl Into<TabId>) -> NativeTabBarView {
2207    NativeTabBarView {
2208        items,
2209        selected: selected.into(),
2210        safe_area: true,
2211        on_select: None,
2212        on_reselect: None,
2213    }
2214}
2215
2216impl NativeTabBarView {
2217    /// Fires with the *requested* tab's id when the user taps a tab other
2218    /// than the one showing — the app confirms (or rejects) it by feeding
2219    /// `selected` back through the next build. Never fires on a target with
2220    /// no tab-bar arm.
2221    pub fn on_select(mut self, handler: impl Fn(TabId) + Send + Sync + 'static) -> Self {
2222        self.on_select = Some(Arc::new(handler));
2223        self
2224    }
2225
2226    /// Fires with the showing tab's id when the user taps it again —
2227    /// conventionally "scroll to top" or "pop to the tab's root". Changes
2228    /// nothing by itself.
2229    pub fn on_reselect(mut self, handler: impl Fn(TabId) + Send + Sync + 'static) -> Self {
2230        self.on_reselect = Some(Arc::new(handler));
2231        self
2232    }
2233
2234    /// Whether the bar grows by the window's bottom safe-area inset (default
2235    /// `true`, for a bar docked to the bottom edge). `false` keeps the bare
2236    /// 49pt, for a bar embedded mid-screen.
2237    pub fn safe_area(mut self, enabled: bool) -> Self {
2238        self.safe_area = enabled;
2239        self
2240    }
2241
2242    /// The index of the first item carrying `selected`, if any.
2243    fn selected_index(&self) -> Option<usize> {
2244        self.items.iter().position(|item| item.id == self.selected)
2245    }
2246
2247    /// The byte icons this bar publishes into the side table, one entry per
2248    /// item (`crate::controls::tab_bar::publish_icon_bytes`).
2249    fn icon_bytes(&self) -> Vec<tab_bar::ItemIconBytes> {
2250        self.items
2251            .iter()
2252            .map(|item| tab_bar::ItemIconBytes {
2253                icon: icon_bytes(Some(&item.icon)),
2254                selected_icon: icon_bytes(item.selected_icon.as_ref()),
2255            })
2256            .collect()
2257    }
2258
2259    /// `tokens` (theme ladder L2) folds `accent_ink` (selected tint),
2260    /// `muted` (unselected tint) and `surface_bg` (bar background) in — see
2261    /// [`NativeButtonView::params_for`]'s doc for why it's threaded
2262    /// explicitly. `icons_rev` is the side table's revision for this slot
2263    /// (`crate::controls::tab_bar`'s module doc).
2264    fn params_for(&self, slot: SlotId, tokens: Option<ResolvedTheme>, icons_rev: u64) -> String {
2265        let mut body = ParamsBody::new();
2266        body.push_raw(tab_bar::ITEM_COUNT, self.items.len());
2267        for (index, item) in self.items.iter().enumerate() {
2268            body.push_str(&tab_bar::item_key(index, tab_bar::FIELD_TITLE), &item.title);
2269            body.push_raw(
2270                &tab_bar::item_key(index, tab_bar::FIELD_ENABLED),
2271                item.enabled,
2272            );
2273            body.push_opt_str(
2274                &tab_bar::item_key(index, tab_bar::FIELD_BADGE),
2275                item.badge.as_deref(),
2276            );
2277            body.push_opt_str(
2278                &tab_bar::item_key(index, tab_bar::FIELD_SYMBOL),
2279                symbol_name(Some(&item.icon)),
2280            );
2281            body.push_opt_str(
2282                &tab_bar::item_key(index, tab_bar::FIELD_SELECTED_SYMBOL),
2283                symbol_name(item.selected_icon.as_ref()),
2284            );
2285        }
2286        if let Some(index) = self.selected_index() {
2287            body.push_raw(tab_bar::SELECTED, index);
2288        }
2289        body.push_raw(tab_bar::ICONS_REV, icons_rev);
2290        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
2291        if let Some(t) = tokens {
2292            body.push_raw(TINT, t.accent_ink);
2293            body.push_raw(tab_bar::UNSELECTED_TINT, t.muted);
2294            body.push_raw(BACKGROUND_COLOR, t.surface_bg);
2295        }
2296        with_identity(tab_bar::KIND, slot, &body.finish())
2297    }
2298
2299    fn build_with_mode(
2300        &self,
2301        slot: SlotId,
2302        mode: ResolvedSurfaceMode,
2303        tokens: Option<ResolvedTheme>,
2304    ) -> AnyView<SlotId> {
2305        self.build_for_arm(slot, mode, tokens, TAB_BAR_ARM)
2306    }
2307
2308    /// [`Self::build_with_mode`] with the platform-arm gate threaded as a
2309    /// parameter, so a host test can drive both branches on any target.
2310    /// Every branch — banner, translucency placeholder, native slot — sits
2311    /// inside the same inset-aware [`TabBarSlot`], so the bar's footprint is
2312    /// identical whichever one renders.
2313    fn build_for_arm(
2314        &self,
2315        slot: SlotId,
2316        mode: ResolvedSurfaceMode,
2317        tokens: Option<ResolvedTheme>,
2318        arm_available: bool,
2319    ) -> AnyView<SlotId> {
2320        let child = if !arm_available {
2321            warn_no_tab_bar_arm_once();
2322            banner_placeholder(
2323                None,
2324                TAB_BAR_UNAVAILABLE_LABEL.to_string(),
2325                TAB_BAR_UNAVAILABLE_DESCRIPTION.to_string(),
2326            )
2327        } else if mode.translucency_refused() {
2328            placeholder(None, "TabBar")
2329        } else {
2330            let icons_rev = tab_bar::publish_icon_bytes(slot, self.icon_bytes());
2331            let params = self.params_for(slot, tokens, icons_rev);
2332            if self.on_select.is_some() || self.on_reselect.is_some() {
2333                let ids: Arc<[TabId]> = self.items.iter().map(|item| item.id.clone()).collect();
2334                with_runtime(|rt| {
2335                    rt.set_callback(
2336                        slot,
2337                        on_tab_bar(ids, self.on_select.clone(), self.on_reselect.clone()),
2338                    )
2339                });
2340            }
2341            any(platform_view(VIEW_TYPE)
2342                .params_json(params)
2343                .interactive()
2344                .semantics_label("tab bar")
2345                .expand())
2346        };
2347        any(TabBarSlot {
2348            child,
2349            safe_area: self.safe_area,
2350        })
2351    }
2352}
2353
2354impl Component for NativeTabBarView {
2355    type State = SlotId;
2356
2357    fn init(&self) -> SlotId {
2358        let slot = next_local_slot();
2359        // See `NativeButtonView::init`'s doc for the callback reaper; the tab
2360        // bar also owns an icon-bytes side-table entry, retired here exactly
2361        // once per mounted component (`crate::controls::tab_bar`'s module
2362        // doc) — both harmless no-ops on a target with no arm.
2363        on_cleanup(move || {
2364            with_runtime(|rt| rt.forget_pending_callback(slot));
2365            tab_bar::retire_icon_bytes(slot);
2366        });
2367        slot
2368    }
2369
2370    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
2371        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
2372    }
2373}
2374
2375/// The tab bar's inset-aware slot: lays its child out at exactly
2376/// [`tab_bar::BAR_HEIGHT`] plus — when `safe_area` — the window's bottom
2377/// safe-area inset (`LayoutCtx::window_insets().padding().bottom`, read at
2378/// layout time, the same inset Material's `navigation_bar` consumes), as wide
2379/// as the parent allows. `platform_view` can only be sized by a builder-time
2380/// `.size(w, h)` or `.expand()`, and the inset is not known until layout, so
2381/// this crate-local wrapper (the [`ClipToSlot`] shape) supplies the tight
2382/// constraints and the child `.expand()`s into them — the `UITabBar` frame
2383/// then covers the whole slot, home-indicator band included. It also clips
2384/// its child's paint to the slot, which the refusal banner's prose needs.
2385struct TabBarSlot<State: 'static> {
2386    child: AnyView<State>,
2387    safe_area: bool,
2388}
2389
2390/// The retained widget for [`TabBarSlot`].
2391struct TabBarSlotWidget {
2392    child: Box<dyn Widget>,
2393    safe_area: bool,
2394}
2395
2396impl<State: 'static> View<State> for TabBarSlot<State> {
2397    type Element = TabBarSlotWidget;
2398
2399    fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
2400        TabBarSlotWidget {
2401            child: self.child.build(ctx),
2402            safe_area: self.safe_area,
2403        }
2404    }
2405
2406    fn rebuild(
2407        &self,
2408        prev: &Self,
2409        element: &mut Self::Element,
2410        ctx: &mut BuildCtx<'_>,
2411    ) -> ChangeFlags {
2412        let mut flags = self.child.rebuild(&prev.child, &mut element.child, ctx);
2413        if element.safe_area != self.safe_area {
2414            element.safe_area = self.safe_area;
2415            flags |= ChangeFlags::LAYOUT;
2416        }
2417        flags
2418    }
2419
2420    fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
2421        self.child.teardown(&mut element.child, ctx);
2422    }
2423}
2424
2425impl TabBarSlotWidget {
2426    /// The slot size for `bc` under `bottom_inset` px of bottom safe-area
2427    /// padding — pure, so the height rule is host-tested directly.
2428    fn slot_size(&self, bc: &BoxConstraints, bottom_inset: f64) -> Size {
2429        let inset = if self.safe_area {
2430            bottom_inset.max(0.0)
2431        } else {
2432            0.0
2433        };
2434        let width = if bc.max().width.is_finite() {
2435            bc.max().width
2436        } else {
2437            bc.min().width
2438        };
2439        bc.constrain(Size::new(width, tab_bar::BAR_HEIGHT + inset))
2440    }
2441}
2442
2443impl Widget for TabBarSlotWidget {
2444    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2445        let size = self.slot_size(bc, ctx.window_insets().padding().bottom);
2446        self.child.layout(ctx, &BoxConstraints::tight(size));
2447        size
2448    }
2449
2450    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2451        scene.push_clip(ctx.origin(), ctx.size());
2452        self.child.paint(ctx, scene);
2453        scene.pop_clip();
2454    }
2455
2456    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2457        self.child.event(ctx, event)
2458    }
2459
2460    fn semantics(&self, ctx: &mut SemanticsCtx) {
2461        // Transparent wrapper: forward unchanged (`docs/CODE_STANDARDS.md`'s
2462        // Semantics Conventions).
2463        self.child.semantics(ctx);
2464    }
2465}
2466
2467// ============================================================================
2468// Image
2469// ============================================================================
2470
2471/// How a [`NativeImageView`] scales its bytes into the slot's box — the
2472/// public mirror of `crate::controls::image::Fit`, which stays `pub(crate)`.
2473#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2474pub enum NativeImageFit {
2475    /// Whole image, aspect kept, centred (the platform's own default).
2476    #[default]
2477    Contain,
2478    /// Fills the box, aspect kept, cropped.
2479    Cover,
2480    /// Fills the box, aspect ignored.
2481    Fill,
2482    /// No scaling at all, centred.
2483    Center,
2484}
2485
2486impl NativeImageFit {
2487    /// The wire spelling `crate::controls::image::Fit::from_params` decodes.
2488    fn wire(self) -> &'static str {
2489        match self {
2490            Self::Contain => "contain",
2491            Self::Cover => "cover",
2492            Self::Fill => "fill",
2493            Self::Center => "center",
2494        }
2495    }
2496}
2497
2498/// A real `android.widget.ImageView` rendered from pure Rust, showing
2499/// app-supplied encoded bytes (PNG/JPEG/WebP — whatever `BitmapFactory`
2500/// reads). Display-only (no listener, no `.interactive()`). Build one with
2501/// [`native_image`].
2502#[derive(Clone)]
2503pub struct NativeImageView {
2504    bytes: Arc<[u8]>,
2505    fit: NativeImageFit,
2506    content_description: Option<String>,
2507    size: Option<(f64, f64)>,
2508}
2509
2510/// A native `Image` showing `bytes` — see [`NativeImageView`].
2511pub fn native_image(bytes: Arc<[u8]>) -> NativeImageView {
2512    NativeImageView {
2513        bytes,
2514        fit: NativeImageFit::default(),
2515        content_description: None,
2516        size: None,
2517    }
2518}
2519
2520impl NativeImageView {
2521    /// How the image scales into the slot's box.
2522    pub fn fit(mut self, fit: NativeImageFit) -> Self {
2523        self.fit = fit;
2524        self
2525    }
2526
2527    /// The TalkBack label. An unlabelled image is invisible to a screen
2528    /// reader — set this (or say so explicitly with an empty string).
2529    pub fn content_description(mut self, label: impl Into<String>) -> Self {
2530        self.content_description = Some(label.into());
2531        self
2532    }
2533
2534    /// Explicit slot size — see [`resolve_size`]'s doc for the no-call
2535    /// fallback.
2536    pub fn size(mut self, width: f64, height: f64) -> Self {
2537        self.size = Some((width, height));
2538        self
2539    }
2540
2541    /// The encoded `params_json` for `slot`, given the publish revision
2542    /// [`crate::controls::image::publish_bytes`] already returned — split out
2543    /// from [`Self::build_with_mode`] so a test can snapshot it without
2544    /// re-publishing. `tokens` only ever contributes [`DARK`] here (theme
2545    /// ladder L1): an app-supplied image's *content* is arbitrary
2546    /// bytes, so folding an accent tint over it the way the other five
2547    /// controls fold colour tokens would corrupt a real photo rather than
2548    /// theme a control — [`NativeImageView`] exposes no tint builder yet for
2549    /// the same reason (`api::builders`' own "left for a future task" note
2550    /// on styling knobs).
2551    fn params_for(&self, slot: SlotId, rev: u64, tokens: Option<ResolvedTheme>) -> String {
2552        let mut body = ParamsBody::new();
2553        body.push_raw(image::REV, rev);
2554        body.push_str(FIT, self.fit.wire());
2555        body.push_opt_str(CONTENT_DESCRIPTION, self.content_description.as_deref());
2556        body.push_raw(DARK, tokens.is_some_and(|t| t.dark));
2557        with_identity(image::KIND, slot, &body.finish())
2558    }
2559
2560    fn build_with_mode(
2561        &self,
2562        slot: SlotId,
2563        mode: ResolvedSurfaceMode,
2564        tokens: Option<ResolvedTheme>,
2565    ) -> AnyView<SlotId> {
2566        if mode.translucency_refused() {
2567            return placeholder(self.size, "Image");
2568        }
2569        // Publish (or re-confirm) this slot's bytes BEFORE encoding params —
2570        // `image::publish_bytes` is idempotent for the same `Arc` (module
2571        // doc: "an app that hands its buffer down every rebuild produces no
2572        // params change and no decode"), and the runtime's later
2573        // `ImageProps::decode` reads this same table back by slot.
2574        let rev = image::publish_bytes(slot, Arc::clone(&self.bytes));
2575        let params = self.params_for(slot, rev, tokens);
2576        let view = platform_view(VIEW_TYPE)
2577            .params_json(params)
2578            .semantics_label(
2579                self.content_description
2580                    .clone()
2581                    .unwrap_or_else(|| "image".into()),
2582            );
2583        any(resolve_size(self.size, view))
2584    }
2585}
2586
2587impl Component for NativeImageView {
2588    type State = SlotId;
2589
2590    fn init(&self) -> SlotId {
2591        let slot = next_local_slot();
2592        // Tie the publish-table entry's lifetime to this Component, not to
2593        // the native create/dispose lifecycle (see
2594        // `docs/CODE_STANDARDS.md`'s "Teardown disposes the component's
2595        // `Owner`; register cleanup via `on_cleanup`, not `Drop`"). `init`
2596        // runs exactly once, under this component's own `Owner`, so
2597        // `on_cleanup` here fires exactly once when that owner disposes —
2598        // regardless of whether paint culling already ran the counted
2599        // `claim_bytes`/`release_bytes` pair to zero and back on the
2600        // platform side in between (`crate::controls::image`'s module doc).
2601        //
2602        // No second `on_cleanup` for `NativeRuntime::pending_callbacks`:
2603        // `Image` is display-only and never calls `set_callback` —
2604        // see `NativeLabelView::init`'s doc for the same reasoning.
2605        on_cleanup(move || image::retire(slot));
2606        slot
2607    }
2608
2609    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
2610        self.build_with_mode(*state, resolved_surface_mode(), ambient_theme_tokens())
2611    }
2612}
2613
2614// ============================================================================
2615// The `View<Outer>` delegate (module doc)
2616// ============================================================================
2617
2618/// Implement `View<Outer>` for every `Outer` state by delegating to
2619/// `frust_core::component` — mirroring `ComponentView<C>`'s own blanket impl,
2620/// which this crate cannot reach directly (its `component: C` field is
2621/// private): each call clones `self`/`prev` into a throwaway `ComponentView`,
2622/// cheap for these small builder structs, and correct because
2623/// `ComponentView::rebuild` never actually reads its `prev` argument (it
2624/// always re-runs `Component::build` against the retained `State` — see
2625/// `frust_core::component`'s own doc comment).
2626macro_rules! impl_native_view {
2627    ($ty:ty) => {
2628        impl<Outer: 'static> View<Outer> for $ty {
2629            type Element = ComponentWidget<$ty>;
2630
2631            fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
2632                // Fully-qualified: `ComponentView<$ty>: View<Outer>` for every
2633                // `Outer`, so a plain `.build(ctx)` call leaves `Outer`
2634                // unconstrained — pin it to the impl we're writing.
2635                <frust_core::ComponentView<$ty> as View<Outer>>::build(
2636                    &component(self.clone()),
2637                    ctx,
2638                )
2639            }
2640
2641            fn rebuild(
2642                &self,
2643                prev: &Self,
2644                element: &mut Self::Element,
2645                ctx: &mut BuildCtx<'_>,
2646            ) -> ChangeFlags {
2647                <frust_core::ComponentView<$ty> as View<Outer>>::rebuild(
2648                    &component(self.clone()),
2649                    &component(prev.clone()),
2650                    element,
2651                    ctx,
2652                )
2653            }
2654
2655            fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
2656                <frust_core::ComponentView<$ty> as View<Outer>>::teardown(
2657                    &component(self.clone()),
2658                    element,
2659                    ctx,
2660                );
2661            }
2662        }
2663    };
2664}
2665
2666impl_native_view!(NativeButtonView);
2667impl_native_view!(NativeLabelView);
2668impl_native_view!(NativeSwitchView);
2669impl_native_view!(NativeSliderView);
2670impl_native_view!(NativeProgressView);
2671impl_native_view!(NativeImageView);
2672impl_native_view!(NativeSpinnerView);
2673impl_native_view!(NativeDatePickerView);
2674impl_native_view!(NativeSegmentedView);
2675impl_native_view!(NativeStepperView);
2676impl_native_view!(NativeTabBarView);
2677
2678#[cfg(test)]
2679mod tests {
2680    use super::*;
2681    use frust_core::{BoxConstraints, LayoutCtx, PaintCtx, PaintScene, Widget};
2682    use frust_text::TextContext;
2683    use kurbo::{Point, Rect, Shape, Size};
2684    use peniko::{Brush, Color};
2685    use std::any::Any;
2686
2687    /// A minimal `PaintScene` — only `fill_rect`/`draw_text` have no default
2688    /// (see `frust_core::widget::PaintScene`'s trait definition); every other
2689    /// method a placeholder's banner/`SizedBox` might call is defaulted.
2690    #[derive(Default)]
2691    struct NullScene;
2692
2693    impl PaintScene for NullScene {
2694        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
2695        fn draw_text(&mut self, _origin: Point, _text: &str) {}
2696    }
2697
2698    fn build_any<State: 'static>(view: AnyView<State>) -> Box<dyn Widget> {
2699        let mut counter = 0u64;
2700        view.build(&mut BuildCtx::new(&mut counter))
2701    }
2702
2703    // --- params_json snapshots, one per control (no theme) -------------------
2704    //
2705    // `None` is exactly what `ambient_theme_tokens()` returns absent a live
2706    // reactive context (this module's own doc comment) — every snapshot
2707    // below carries a plain `"dark":false` and no other theme field, the
2708    // original shape plus theme ladder L1's always-present flag.
2709
2710    #[test]
2711    fn button_params_snapshot() {
2712        let view = native_button("Save")
2713            .enabled(false)
2714            .content_description("Save the note");
2715        assert_eq!(
2716            view.params_for(7, None),
2717            "{\"__frustControl\":\"button\",\"__frustSlot\":7,\"text\":\"Save\",\"enabled\":false,\
2718             \"contentDescription\":\"Save the note\",\"dark\":false}"
2719        );
2720    }
2721
2722    #[test]
2723    fn label_params_snapshot() {
2724        let view = native_label("42 fps");
2725        assert_eq!(
2726            view.params_for(3, None),
2727            "{\"__frustControl\":\"label\",\"__frustSlot\":3,\"text\":\"42 fps\",\"enabled\":true,\
2728             \"dark\":false}"
2729        );
2730    }
2731
2732    #[test]
2733    fn switch_params_snapshot() {
2734        let view = native_switch(true).content_description("wifi");
2735        assert_eq!(
2736            view.params_for(11, None),
2737            "{\"__frustControl\":\"switch\",\"__frustSlot\":11,\"checked\":true,\"enabled\":true,\
2738             \"contentDescription\":\"wifi\",\"dark\":false}"
2739        );
2740    }
2741
2742    #[test]
2743    fn slider_params_snapshot() {
2744        let view = native_slider(25, 0, 50);
2745        assert_eq!(
2746            view.params_for(5, None),
2747            "{\"__frustControl\":\"slider\",\"__frustSlot\":5,\"value\":25,\"min\":0,\"max\":50,\
2748             \"enabled\":true,\"dark\":false}"
2749        );
2750    }
2751
2752    #[test]
2753    fn progress_params_snapshot() {
2754        let view = native_progress(30, 10, 110).indeterminate(false);
2755        assert_eq!(
2756            view.params_for(2, None),
2757            "{\"__frustControl\":\"progress\",\"__frustSlot\":2,\"value\":30,\"min\":10,\
2758             \"max\":110,\"indeterminate\":false,\"dark\":false}"
2759        );
2760    }
2761
2762    #[test]
2763    fn image_params_snapshot() {
2764        let view =
2765            native_image(Arc::from(vec![1u8, 2, 3].into_boxed_slice())).fit(NativeImageFit::Cover);
2766        assert_eq!(
2767            view.params_for(900, 42, None),
2768            "{\"__frustControl\":\"image\",\"__frustSlot\":900,\"imageRev\":42,\"fit\":\"cover\",\
2769             \"dark\":false}"
2770        );
2771    }
2772
2773    #[test]
2774    fn spinner_params_snapshot() {
2775        let view = native_spinner(true)
2776            .size_class(NativeSpinnerSize::Large)
2777            .content_description("loading");
2778        assert_eq!(
2779            view.params_for(6, None),
2780            "{\"__frustControl\":\"spinner\",\"__frustSlot\":6,\"animating\":true,\
2781             \"sizeClass\":\"large\",\"enabled\":true,\"contentDescription\":\"loading\",\
2782             \"dark\":false}"
2783        );
2784    }
2785
2786    fn civil(year: i32, month: u8, day: u8) -> CivilDate {
2787        CivilDate::new(year, month, day).expect("a real date")
2788    }
2789
2790    #[test]
2791    fn date_picker_params_snapshot() {
2792        let view = native_date_picker(civil(2026, 9, 29))
2793            .min(civil(2026, 1, 1))
2794            .max(civil(2026, 12, 31))
2795            .style(NativeDatePickerStyle::Inline)
2796            .content_description("due date");
2797        assert_eq!(
2798            view.params_for(12, None),
2799            format!(
2800                "{{\"__frustControl\":\"date_picker\",\"__frustSlot\":12,\"date\":{},\
2801                 \"minDate\":{},\"maxDate\":{},\"style\":\"inline\",\"enabled\":true,\
2802                 \"contentDescription\":\"due date\",\"dark\":false}}",
2803                (2026 << 16) | (9 << 8) | 29,
2804                (2026 << 16) | (1 << 8) | 1,
2805                (2026 << 16) | (12 << 8) | 31,
2806            )
2807        );
2808    }
2809
2810    #[test]
2811    fn date_picker_params_round_trip_through_the_control_decoder() {
2812        use crate::controls::date_picker::DatePickerProps;
2813        use crate::runtime::Params;
2814
2815        let view = native_date_picker(civil(2024, 2, 29))
2816            .min(civil(2000, 1, 1))
2817            .style(NativeDatePickerStyle::Wheels)
2818            .enabled(false);
2819        let raw = view.params_for(12, Some(dark_tokens()));
2820        let props = DatePickerProps::decode(&Params::new(&raw)).expect("decodes");
2821        assert_eq!(props.date, Some(civil(2024, 2, 29)));
2822        assert_eq!(props.min, Some(civil(2000, 1, 1)));
2823        assert_eq!(props.max, None);
2824        assert!(!props.enabled);
2825        assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
2826        assert_eq!(props.text_color, Some(dark_tokens().body_text as i32));
2827    }
2828
2829    #[test]
2830    fn date_picker_publishes_one_interactive_slot_on_every_target() {
2831        // A shared control: no compile-time arm gate, unlike
2832        // `native_segmented`/`native_stepper`.
2833        let view = native_date_picker(civil(2026, 9, 29)).size(320.0, 216.0);
2834        let expected_params = view.params_for(4, None);
2835        let built = view.build_with_mode(4, ResolvedSurfaceMode::Opaque, None);
2836        let mut element = build_any(built);
2837        let mut lctx = LayoutCtx::new();
2838        element.layout(&mut lctx, &BoxConstraints::tight(Size::new(320.0, 216.0)));
2839        let mut scene = NullScene;
2840        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(320.0, 216.0));
2841        element.paint(&mut pctx, &mut scene);
2842        let frames = pctx.take_platform_views();
2843        assert_eq!(frames.len(), 1);
2844        assert_eq!(frames[0].view_type, VIEW_TYPE);
2845        assert_eq!(frames[0].params_json, expected_params);
2846        assert!(
2847            frames[0].interactive,
2848            "a date picker slot forwards native input"
2849        );
2850    }
2851
2852    #[test]
2853    fn date_picker_honours_the_translucency_refusal() {
2854        let view = native_date_picker(civil(2026, 9, 29)).build_with_mode(
2855            5,
2856            ResolvedSurfaceMode::RefusedTranslucent,
2857            None,
2858        );
2859        let mut element = build_any(view);
2860        let mut scene = NullScene;
2861        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(320.0, 216.0));
2862        element.paint(&mut pctx, &mut scene);
2863        assert!(pctx.take_platform_views().is_empty());
2864    }
2865
2866    #[test]
2867    fn segmented_params_snapshot() {
2868        let view = native_segmented(vec!["Day".into(), "Week \"W\"".into()], 1)
2869            .momentary(true)
2870            .content_description("range");
2871        assert_eq!(
2872            view.params_for(8, None),
2873            "{\"__frustControl\":\"segmented\",\"__frustSlot\":8,\"segmentCount\":2,\
2874             \"segment0\":\"Day\",\"segment1\":\"Week \\\"W\\\"\",\"selected\":1,\
2875             \"momentary\":true,\"enabled\":true,\"contentDescription\":\"range\",\
2876             \"dark\":false}"
2877        );
2878    }
2879
2880    #[test]
2881    fn segmented_params_round_trip_through_the_control_decoder() {
2882        use crate::controls::segmented::SegmentedProps;
2883        use crate::runtime::Params;
2884
2885        let view = native_segmented(vec!["A".into(), "B".into(), "C".into()], 2).enabled(false);
2886        let raw = view.params_for(8, Some(dark_tokens()));
2887        let props = SegmentedProps::decode(&Params::new(&raw)).expect("decodes");
2888        assert_eq!(props.labels, vec!["A", "B", "C"]);
2889        assert_eq!(props.selected, Some(2));
2890        assert!(!props.enabled);
2891        assert!(!props.momentary);
2892        assert_eq!(props.tint, Some(dark_tokens().accent_fill as i32));
2893    }
2894
2895    #[test]
2896    fn the_segmented_arm_gate_is_exactly_the_apple_targets() {
2897        assert_eq!(
2898            SEGMENTED_ARM,
2899            cfg!(any(target_os = "ios", target_os = "macos")),
2900            "Android (decision D2) and every host target render the banner"
2901        );
2902        assert!(SEGMENTED_UNAVAILABLE_DESCRIPTION.contains("Android"));
2903        assert!(SEGMENTED_UNAVAILABLE_DESCRIPTION.contains("follow-up"));
2904    }
2905
2906    #[test]
2907    fn segmented_without_a_platform_arm_paints_the_banner_and_publishes_no_slot() {
2908        // Every surface mode, including the ones a native slot would render
2909        // in: with no arm, the refusal is structural.
2910        for mode in [
2911            ResolvedSurfaceMode::Unknown,
2912            ResolvedSurfaceMode::Opaque,
2913            ResolvedSurfaceMode::Translucent,
2914            ResolvedSurfaceMode::RefusedTranslucent,
2915        ] {
2916            let view = native_segmented(vec!["A".into(), "B".into()], 0)
2917                .size(240.0, 60.0)
2918                .build_for_arm(3, mode, None, false);
2919            let mut element = build_any(view);
2920            let mut text_ctx = TextContext::new();
2921            let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
2922            let laid = element.layout(&mut lctx, &BoxConstraints::tight(Size::new(240.0, 60.0)));
2923            let mut rec = BoundsRecorder::default();
2924            let mut pctx = PaintCtx::new(Point::ZERO, laid);
2925            element.paint(&mut pctx, &mut rec);
2926            assert!(
2927                pctx.take_platform_views().is_empty(),
2928                "{mode:?}: no arm must publish no native platform_view frame"
2929            );
2930            assert!(
2931                rec.glyph_runs >= 2,
2932                "{mode:?}: the banner's label and description must paint, saw {} runs",
2933                rec.glyph_runs
2934            );
2935        }
2936    }
2937
2938    #[test]
2939    fn segmented_with_a_platform_arm_publishes_one_interactive_slot() {
2940        let view = native_segmented(vec!["A".into(), "B".into()], 1).size(200.0, 32.0);
2941        let expected_params = view.params_for(4, None);
2942        let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
2943        let mut element = build_any(built);
2944        let mut lctx = LayoutCtx::new();
2945        element.layout(&mut lctx, &BoxConstraints::tight(Size::new(200.0, 32.0)));
2946        let mut scene = NullScene;
2947        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 32.0));
2948        element.paint(&mut pctx, &mut scene);
2949        let frames = pctx.take_platform_views();
2950        assert_eq!(frames.len(), 1);
2951        assert_eq!(frames[0].view_type, VIEW_TYPE);
2952        assert_eq!(frames[0].params_json, expected_params);
2953        assert!(
2954            frames[0].interactive,
2955            "a segmented slot forwards native input"
2956        );
2957    }
2958
2959    #[test]
2960    fn segmented_with_an_arm_still_honours_the_translucency_refusal() {
2961        let view = native_segmented(vec!["A".into()], 0).build_for_arm(
2962            5,
2963            ResolvedSurfaceMode::RefusedTranslucent,
2964            None,
2965            true,
2966        );
2967        let mut element = build_any(view);
2968        let mut scene = NullScene;
2969        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 32.0));
2970        element.paint(&mut pctx, &mut scene);
2971        assert!(pctx.take_platform_views().is_empty());
2972    }
2973
2974    #[test]
2975    fn stepper_params_snapshot() {
2976        let view = native_stepper(4, 0, 10)
2977            .step(2)
2978            .wraps(true)
2979            .content_description("count");
2980        assert_eq!(
2981            view.params_for(8, None),
2982            "{\"__frustControl\":\"stepper\",\"__frustSlot\":8,\"value\":4,\"min\":0,\"max\":10,\
2983             \"step\":2,\"wraps\":true,\"enabled\":true,\"contentDescription\":\"count\",\
2984             \"dark\":false}"
2985        );
2986    }
2987
2988    #[test]
2989    fn stepper_params_round_trip_through_the_control_decoder() {
2990        use crate::controls::stepper::StepperProps;
2991        use crate::runtime::Params;
2992
2993        let view = native_stepper(3, 0, 20).step(5).wraps(true).enabled(false);
2994        let raw = view.params_for(8, Some(dark_tokens()));
2995        let props = StepperProps::decode(&Params::new(&raw)).expect("decodes");
2996        assert_eq!(props.value, 3);
2997        assert_eq!(props.step, 5);
2998        assert!(props.wraps);
2999        assert!(!props.enabled);
3000        assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
3001    }
3002
3003    #[test]
3004    fn a_non_positive_step_mounts_as_one_through_the_control_decoder() {
3005        use crate::controls::stepper::StepperProps;
3006        use crate::runtime::Params;
3007
3008        let view = native_stepper(3, 0, 20).step(0);
3009        let raw = view.params_for(8, None);
3010        assert!(
3011            raw.contains("\"step\":0"),
3012            "the builder still sends the app's raw value over the wire — \
3013             normalization is the control's job, not the builder's"
3014        );
3015        let props = StepperProps::decode(&Params::new(&raw)).expect("decodes");
3016        assert_eq!(props.step, 1, "a non-positive step mounts as 1");
3017    }
3018
3019    #[test]
3020    fn the_stepper_arm_gate_is_exactly_the_apple_targets() {
3021        assert_eq!(
3022            STEPPER_ARM,
3023            cfg!(any(target_os = "ios", target_os = "macos")),
3024            "Android and every host target render the banner"
3025        );
3026        assert!(STEPPER_UNAVAILABLE_DESCRIPTION.contains("Android"));
3027        assert!(STEPPER_UNAVAILABLE_DESCRIPTION.contains("follow-up"));
3028    }
3029
3030    #[test]
3031    fn stepper_without_a_platform_arm_paints_the_banner_and_publishes_no_slot() {
3032        // Every surface mode, including the ones a native slot would render
3033        // in: with no arm, the refusal is structural.
3034        for mode in [
3035            ResolvedSurfaceMode::Unknown,
3036            ResolvedSurfaceMode::Opaque,
3037            ResolvedSurfaceMode::Translucent,
3038            ResolvedSurfaceMode::RefusedTranslucent,
3039        ] {
3040            let view = native_stepper(0, 0, 10)
3041                .size(120.0, 40.0)
3042                .build_for_arm(3, mode, None, false);
3043            let mut element = build_any(view);
3044            let mut text_ctx = TextContext::new();
3045            let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3046            let laid = element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 40.0)));
3047            let mut rec = BoundsRecorder::default();
3048            let mut pctx = PaintCtx::new(Point::ZERO, laid);
3049            element.paint(&mut pctx, &mut rec);
3050            assert!(
3051                pctx.take_platform_views().is_empty(),
3052                "{mode:?}: no arm must publish no native platform_view frame"
3053            );
3054            assert!(
3055                rec.glyph_runs >= 2,
3056                "{mode:?}: the banner's label and description must paint, saw {} runs",
3057                rec.glyph_runs
3058            );
3059        }
3060    }
3061
3062    #[test]
3063    fn stepper_with_a_platform_arm_publishes_one_interactive_slot() {
3064        let view = native_stepper(2, 0, 10).size(94.0, 29.0);
3065        let expected_params = view.params_for(4, None);
3066        let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
3067        let mut element = build_any(built);
3068        let mut lctx = LayoutCtx::new();
3069        element.layout(&mut lctx, &BoxConstraints::tight(Size::new(94.0, 29.0)));
3070        let mut scene = NullScene;
3071        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(94.0, 29.0));
3072        element.paint(&mut pctx, &mut scene);
3073        let frames = pctx.take_platform_views();
3074        assert_eq!(frames.len(), 1);
3075        assert_eq!(frames[0].view_type, VIEW_TYPE);
3076        assert_eq!(frames[0].params_json, expected_params);
3077        assert!(
3078            frames[0].interactive,
3079            "a stepper slot forwards native input"
3080        );
3081    }
3082
3083    #[test]
3084    fn stepper_with_an_arm_still_honours_the_translucency_refusal() {
3085        let view = native_stepper(0, 0, 10).build_for_arm(
3086            5,
3087            ResolvedSurfaceMode::RefusedTranslucent,
3088            None,
3089            true,
3090        );
3091        let mut element = build_any(view);
3092        let mut scene = NullScene;
3093        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(94.0, 29.0));
3094        element.paint(&mut pctx, &mut scene);
3095        assert!(pctx.take_platform_views().is_empty());
3096    }
3097
3098    // --- tab bar ------------------------------------------------------------
3099
3100    fn symbol(name: &str) -> TabIcon {
3101        TabIcon::AppleSymbol(name.into())
3102    }
3103
3104    fn two_tabs() -> Vec<TabItem> {
3105        vec![
3106            TabItem::new("home", "Home", symbol("house")).selected_icon(symbol("house.fill")),
3107            TabItem::new("inbox", "Inbox", symbol("tray"))
3108                .badge("3")
3109                .enabled(false),
3110        ]
3111    }
3112
3113    /// Lay `element` out under `bc` in a window carrying `bottom` px of
3114    /// bottom system-bar inset — the shape a shell pushes for the home
3115    /// indicator.
3116    fn layout_with_bottom_inset(
3117        element: &mut Box<dyn Widget>,
3118        bc: &BoxConstraints,
3119        bottom: f64,
3120    ) -> Size {
3121        use frust_core::{WindowEdgeInsets, WindowInsets};
3122        let insets = WindowInsets::new(
3123            WindowEdgeInsets::new(0.0, 0.0, 0.0, bottom),
3124            WindowEdgeInsets::ZERO,
3125        );
3126        let mut text_ctx = TextContext::new();
3127        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3128        lctx.with_window_insets(insets, |ctx| element.layout(ctx, bc))
3129    }
3130
3131    #[test]
3132    fn tab_bar_params_snapshot() {
3133        let view = native_tab_bar(two_tabs(), "inbox");
3134        assert_eq!(
3135            view.params_for(8, None, 0),
3136            "{\"__frustControl\":\"tab_bar\",\"__frustSlot\":8,\"itemCount\":2,\
3137             \"tab0Title\":\"Home\",\"tab0Enabled\":true,\"tab0Symbol\":\"house\",\
3138             \"tab0SelectedSymbol\":\"house.fill\",\"tab1Title\":\"Inbox\",\
3139             \"tab1Enabled\":false,\"tab1Badge\":\"3\",\"tab1Symbol\":\"tray\",\
3140             \"selected\":1,\"iconsRev\":0,\"dark\":false}"
3141        );
3142    }
3143
3144    #[test]
3145    fn an_unknown_selected_id_encodes_no_selection() {
3146        let raw = native_tab_bar(two_tabs(), "settings").params_for(8, None, 0);
3147        assert!(!raw.contains("\"selected\""), "{raw}");
3148    }
3149
3150    #[test]
3151    fn tab_bar_params_round_trip_through_the_control_decoder() {
3152        use crate::controls::tab_bar::{IconSource, TabBarProps};
3153        use crate::runtime::Params;
3154
3155        let bytes: Arc<[u8]> = Arc::from(vec![9u8, 9, 9].into_boxed_slice());
3156        let view = native_tab_bar(
3157            vec![
3158                TabItem::new("a", "A", TabIcon::Bytes(Arc::clone(&bytes))),
3159                TabItem::new("b", "B", symbol("gear")),
3160            ],
3161            "b",
3162        );
3163        let slot = 7_701;
3164        let rev = tab_bar::publish_icon_bytes(slot, view.icon_bytes());
3165        let raw = view.params_for(slot, Some(dark_tokens()), rev);
3166        let props = TabBarProps::decode(&Params::new(&raw)).expect("decodes");
3167        assert_eq!(props.items.len(), 2);
3168        assert_eq!(props.items[0].icon, Some(IconSource::Bytes(bytes)));
3169        assert_eq!(props.items[1].icon, Some(IconSource::Symbol("gear".into())));
3170        assert_eq!(props.selected, Some(1));
3171        assert_eq!(props.tint, Some(dark_tokens().accent_ink as i32));
3172        assert_eq!(props.unselected_tint, Some(dark_tokens().muted as i32));
3173        assert_eq!(props.background, Some(dark_tokens().surface_bg as i32));
3174        tab_bar::retire_icon_bytes(slot);
3175    }
3176
3177    #[test]
3178    fn tab_bar_folds_accent_ink_muted_and_surface_from_the_theme() {
3179        let tokens = light_tokens();
3180        let raw = native_tab_bar(two_tabs(), "home").params_for(3, Some(tokens), 0);
3181        assert!(raw.ends_with(&format!(
3182            "\"dark\":false,\"tint\":{},\"unselectedTint\":{},\"backgroundColor\":{}}}",
3183            tokens.accent_ink, tokens.muted, tokens.surface_bg
3184        )));
3185    }
3186
3187    #[test]
3188    fn the_tab_bar_arm_gate_is_exactly_ios() {
3189        assert_eq!(
3190            TAB_BAR_ARM,
3191            cfg!(target_os = "ios"),
3192            "macOS (no idiom), Android (Material, D2) and every host render the banner"
3193        );
3194        assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("macOS"));
3195        assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("Android"));
3196        assert!(TAB_BAR_UNAVAILABLE_DESCRIPTION.contains("Material"));
3197    }
3198
3199    #[test]
3200    fn tab_bar_without_a_platform_arm_paints_the_banner_in_the_inset_aware_slot() {
3201        for mode in [
3202            ResolvedSurfaceMode::Unknown,
3203            ResolvedSurfaceMode::Opaque,
3204            ResolvedSurfaceMode::RefusedTranslucent,
3205        ] {
3206            let view = native_tab_bar(two_tabs(), "home").build_for_arm(3, mode, None, false);
3207            let mut element = build_any(view);
3208            let laid = layout_with_bottom_inset(
3209                &mut element,
3210                &BoxConstraints::new(Size::ZERO, Size::new(390.0, f64::INFINITY)),
3211                34.0,
3212            );
3213            assert_eq!(laid, Size::new(390.0, 49.0 + 34.0), "{mode:?}");
3214            let mut rec = BoundsRecorder::default();
3215            let mut pctx = PaintCtx::new(Point::ZERO, laid);
3216            element.paint(&mut pctx, &mut rec);
3217            assert!(
3218                pctx.take_platform_views().is_empty(),
3219                "{mode:?}: no arm must publish no native platform_view frame"
3220            );
3221            assert!(rec.glyph_runs >= 1, "{mode:?}: the banner must paint");
3222        }
3223    }
3224
3225    #[test]
3226    fn tab_bar_with_a_platform_arm_publishes_one_interactive_slot_under_the_home_indicator() {
3227        let view = native_tab_bar(two_tabs(), "home");
3228        let expected_params = view.params_for(4, None, 0);
3229        let built = view.build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
3230        let mut element = build_any(built);
3231        let laid = layout_with_bottom_inset(
3232            &mut element,
3233            &BoxConstraints::new(Size::ZERO, Size::new(390.0, 844.0)),
3234            34.0,
3235        );
3236        assert_eq!(
3237            laid,
3238            Size::new(390.0, 83.0),
3239            "49pt plus the 34pt home-indicator inset, full width"
3240        );
3241        let mut scene = NullScene;
3242        let mut pctx = PaintCtx::new(Point::ZERO, laid);
3243        element.paint(&mut pctx, &mut scene);
3244        let frames = pctx.take_platform_views();
3245        assert_eq!(frames.len(), 1);
3246        assert_eq!(frames[0].view_type, VIEW_TYPE);
3247        assert_eq!(frames[0].params_json, expected_params);
3248        assert!(
3249            frames[0].interactive,
3250            "a tab bar slot forwards native input"
3251        );
3252    }
3253
3254    #[test]
3255    fn an_opted_out_tab_bar_is_the_bare_bar_height() {
3256        let built = native_tab_bar(two_tabs(), "home")
3257            .safe_area(false)
3258            .build_for_arm(4, ResolvedSurfaceMode::Opaque, None, true);
3259        let mut element = build_any(built);
3260        let laid = layout_with_bottom_inset(
3261            &mut element,
3262            &BoxConstraints::new(Size::ZERO, Size::new(320.0, 600.0)),
3263            34.0,
3264        );
3265        assert_eq!(laid, Size::new(320.0, 49.0));
3266    }
3267
3268    #[test]
3269    fn tab_bar_with_an_arm_still_honours_the_translucency_refusal() {
3270        let view = native_tab_bar(two_tabs(), "home").build_for_arm(
3271            5,
3272            ResolvedSurfaceMode::RefusedTranslucent,
3273            None,
3274            true,
3275        );
3276        let mut element = build_any(view);
3277        let mut scene = NullScene;
3278        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(390.0, 49.0));
3279        element.paint(&mut pctx, &mut scene);
3280        assert!(pctx.take_platform_views().is_empty());
3281    }
3282
3283    // --- theme ladder L2: token folding, one snapshot per colour-bearing
3284    // control (dark + light, via `theme::resolve`) -------------------------
3285
3286    fn dark_tokens() -> ResolvedTheme {
3287        theme::resolve(&Theme::neutral().with_brightness(frust::Brightness::Dark))
3288    }
3289
3290    fn light_tokens() -> ResolvedTheme {
3291        theme::resolve(&Theme::neutral().with_brightness(frust::Brightness::Light))
3292    }
3293
3294    #[test]
3295    fn button_folds_background_text_radius_and_size_from_a_dark_theme() {
3296        let view = native_button("Save");
3297        let tokens = dark_tokens();
3298        assert_eq!(
3299            view.params_for(7, Some(tokens)),
3300            format!(
3301                "{{\"__frustControl\":\"button\",\"__frustSlot\":7,\"text\":\"Save\",\"enabled\":\
3302                 true,\"dark\":true,\"textColor\":{},\"backgroundColor\":{},\"cornerRadiusDp\":{},\
3303                 \"textSizeSp\":{},\"typeface\":\"{}\"}}",
3304                tokens.on_accent_fill,
3305                tokens.accent_fill,
3306                tokens.corner_radius_dp,
3307                tokens.button_text_size_sp,
3308                tokens.button_typeface.wire()
3309            )
3310        );
3311    }
3312
3313    #[test]
3314    fn button_folds_a_light_theme_distinctly_from_dark() {
3315        let view = native_button("Save");
3316        let dark = view.params_for(7, Some(dark_tokens()));
3317        let light = view.params_for(7, Some(light_tokens()));
3318        assert_ne!(
3319            dark, light,
3320            "a brightness flip must reach the folded params — both the `dark` \
3321             flag and every colour role the button folds change with it"
3322        );
3323        assert!(light.contains("\"dark\":false"));
3324        assert!(dark.contains("\"dark\":true"));
3325    }
3326
3327    #[test]
3328    fn label_folds_body_text_colour_and_size() {
3329        let view = native_label("42 fps");
3330        let tokens = dark_tokens();
3331        assert_eq!(
3332            view.params_for(3, Some(tokens)),
3333            format!(
3334                "{{\"__frustControl\":\"label\",\"__frustSlot\":3,\"text\":\"42 fps\",\"enabled\":\
3335                 true,\"dark\":true,\"backgroundColor\":{},\"textColor\":{},\"textSizeSp\":{},\
3336                 \"typeface\":\"{}\"}}",
3337                tokens.surface_bg,
3338                tokens.body_text,
3339                tokens.body_text_size_sp,
3340                tokens.body_typeface.wire()
3341            )
3342        );
3343    }
3344
3345    #[test]
3346    fn switch_folds_thumb_and_track_tint_but_never_a_background() {
3347        // `Switch` deliberately folds no background — the thumb/track
3348        // tints already carry the theme, and an explicit `backgroundColor`
3349        // would replace `?attr/selectableItemBackgroundBorderless`'s ripple
3350        // (`crate::api::theme`'s module doc's *Explicit backgrounds*
3351        // section).
3352        let view = native_switch(true);
3353        let tokens = dark_tokens();
3354        let params = view.params_for(11, Some(tokens));
3355        assert_eq!(
3356            params,
3357            format!(
3358                "{{\"__frustControl\":\"switch\",\"__frustSlot\":11,\"checked\":true,\"enabled\":\
3359                 true,\"dark\":true,\"thumbTint\":{},\"trackTint\":{},\
3360                 \"typeface\":\"{}\"}}",
3361                tokens.accent_ink,
3362                tokens.accent_fill,
3363                tokens.body_typeface.wire()
3364            )
3365        );
3366        assert!(
3367            !params.contains("backgroundColor"),
3368            "Switch must never fold an explicit background — it would defeat the ripple"
3369        );
3370    }
3371
3372    #[test]
3373    fn slider_folds_progress_and_thumb_tint_but_never_a_background() {
3374        // Same rationale as the Switch test above — `AbsSeekBar` also
3375        // carries `?attr/selectableItemBackgroundBorderless`.
3376        let view = native_slider(25, 0, 50);
3377        let tokens = dark_tokens();
3378        let params = view.params_for(5, Some(tokens));
3379        assert_eq!(
3380            params,
3381            format!(
3382                "{{\"__frustControl\":\"slider\",\"__frustSlot\":5,\"value\":25,\"min\":0,\"max\":\
3383                 50,\"enabled\":true,\"dark\":true,\"progressTint\":{},\
3384                 \"thumbTint\":{}}}",
3385                tokens.accent_fill, tokens.accent_ink
3386            )
3387        );
3388        assert!(
3389            !params.contains("backgroundColor"),
3390            "Slider must never fold an explicit background — it would defeat the ripple"
3391        );
3392    }
3393
3394    #[test]
3395    fn progress_folds_progress_tint_only() {
3396        let view = native_progress(30, 10, 110);
3397        let tokens = dark_tokens();
3398        assert_eq!(
3399            view.params_for(2, Some(tokens)),
3400            format!(
3401                "{{\"__frustControl\":\"progress\",\"__frustSlot\":2,\"value\":30,\"min\":10,\
3402                 \"max\":110,\"indeterminate\":false,\"dark\":true,\"backgroundColor\":{},\
3403                 \"progressTint\":{}}}",
3404                tokens.surface_bg, tokens.accent_fill
3405            )
3406        );
3407    }
3408
3409    #[test]
3410    fn image_folds_only_the_dark_flag_never_a_tint() {
3411        // An app-supplied photo is arbitrary content — theming it would
3412        // corrupt the image, not style a control (module doc on
3413        // `NativeImageView::params_for`).
3414        let view = native_image(Arc::from(vec![1u8].into_boxed_slice()));
3415        assert_eq!(
3416            view.params_for(900, 42, Some(dark_tokens())),
3417            "{\"__frustControl\":\"image\",\"__frustSlot\":900,\"imageRev\":42,\"fit\":\"contain\",\
3418             \"dark\":true}"
3419        );
3420    }
3421
3422    #[test]
3423    fn spinner_folds_accent_ink_as_its_tint() {
3424        let view = native_spinner(false);
3425        let tokens = dark_tokens();
3426        assert_eq!(
3427            view.params_for(6, Some(tokens)),
3428            format!(
3429                "{{\"__frustControl\":\"spinner\",\"__frustSlot\":6,\"animating\":false,\
3430                 \"sizeClass\":\"medium\",\"enabled\":true,\"dark\":true,\"tint\":{}}}",
3431                tokens.accent_ink
3432            )
3433        );
3434    }
3435
3436    #[test]
3437    fn stepper_folds_accent_ink_as_its_tint() {
3438        // `UIStepper.tintColor` reads the same accent-ink role Switch/Slider's
3439        // thumb tint and Spinner's own tint already do — `NSStepper` simply
3440        // has no AppKit property to apply it through (module doc's *Tint*
3441        // section on `crate::controls::stepper`), a platform gap this fold
3442        // does not need to know about at the params level.
3443        let view = native_stepper(0, 0, 10);
3444        let tokens = dark_tokens();
3445        assert_eq!(
3446            view.params_for(6, Some(tokens)),
3447            format!(
3448                "{{\"__frustControl\":\"stepper\",\"__frustSlot\":6,\"value\":0,\"min\":0,\
3449                 \"max\":10,\"step\":1,\"wraps\":false,\"enabled\":true,\"dark\":true,\"tint\":{}}}",
3450                tokens.accent_ink
3451            )
3452        );
3453    }
3454
3455    #[test]
3456    fn date_picker_folds_accent_ink_tint_and_body_text_colour() {
3457        let view = native_date_picker(civil(2026, 9, 29));
3458        let tokens = dark_tokens();
3459        assert_eq!(
3460            view.params_for(6, Some(tokens)),
3461            format!(
3462                "{{\"__frustControl\":\"date_picker\",\"__frustSlot\":6,\"date\":{},\
3463                 \"style\":\"compact\",\"enabled\":true,\"dark\":true,\"tint\":{},\
3464                 \"textColor\":{}}}",
3465                (2026 << 16) | (9 << 8) | 29,
3466                tokens.accent_ink,
3467                tokens.body_text
3468            )
3469        );
3470    }
3471
3472    // --- the zero-FFI property: an unchanged theme yields PartialEq-equal
3473    // Props (acceptance criterion) -------------------------------------------
3474
3475    #[test]
3476    fn an_unchanged_theme_yields_partial_eq_equal_button_props() {
3477        use crate::controls::button::ButtonProps;
3478        use crate::runtime::Params;
3479
3480        let view = native_button("Save").content_description("Save the note");
3481        let tokens = dark_tokens();
3482        let a = ButtonProps::decode(&Params::new(&view.params_for(7, Some(tokens)))).unwrap();
3483        let b = ButtonProps::decode(&Params::new(&view.params_for(7, Some(tokens)))).unwrap();
3484        assert_eq!(
3485            a, b,
3486            "the SAME resolved theme, folded twice, must decode to PartialEq-equal \
3487             Props — this is what keeps the runtime's diff gate from crossing the FFI \
3488             boundary on a rebuild the theme didn't actually change"
3489        );
3490
3491        // A genuinely different theme (light) must NOT compare equal.
3492        let c =
3493            ButtonProps::decode(&Params::new(&view.params_for(7, Some(light_tokens())))).unwrap();
3494        assert_ne!(a, c);
3495    }
3496
3497    // --- the translucency-refused fallback ----------------------------------
3498
3499    #[test]
3500    fn a_refused_slot_publishes_no_platform_view_frame() {
3501        for (name, view) in [
3502            (
3503                "Button",
3504                native_button("Save").build_with_mode(
3505                    1,
3506                    ResolvedSurfaceMode::RefusedTranslucent,
3507                    None,
3508                ),
3509            ),
3510            (
3511                "Switch",
3512                native_switch(true).build_with_mode(
3513                    2,
3514                    ResolvedSurfaceMode::RefusedTranslucent,
3515                    None,
3516                ),
3517            ),
3518        ] {
3519            let mut element = build_any(view);
3520            let mut scene = NullScene;
3521            let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
3522            element.paint(&mut pctx, &mut scene);
3523            assert!(
3524                pctx.take_platform_views().is_empty(),
3525                "{name}: a refused slot must render no native platform_view frame"
3526            );
3527        }
3528    }
3529
3530    #[test]
3531    fn an_unrefused_slot_still_builds_the_native_platform_view() {
3532        for mode in [
3533            ResolvedSurfaceMode::Unknown,
3534            ResolvedSurfaceMode::Opaque,
3535            ResolvedSurfaceMode::Translucent,
3536        ] {
3537            let btn = native_button("Save").size(120.0, 44.0);
3538            let expected_params = btn.params_for(9, None);
3539            let view = btn.build_with_mode(9, mode, None);
3540            let mut element = build_any(view);
3541            let mut lctx = LayoutCtx::new();
3542            element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 44.0)));
3543            let mut scene = NullScene;
3544            let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
3545            element.paint(&mut pctx, &mut scene);
3546            let frames = pctx.take_platform_views();
3547            assert_eq!(frames.len(), 1, "{mode:?}");
3548            assert_eq!(frames[0].view_type, VIEW_TYPE);
3549            assert_eq!(frames[0].params_json, expected_params);
3550            assert!(frames[0].interactive, "a Button slot is interactive");
3551        }
3552    }
3553
3554    #[test]
3555    fn a_display_only_control_never_sets_interactive() {
3556        let view = native_label("hi").build_with_mode(4, ResolvedSurfaceMode::Opaque, None);
3557        let mut element = build_any(view);
3558        let mut scene = NullScene;
3559        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 30.0));
3560        element.paint(&mut pctx, &mut scene);
3561        let frames = pctx.take_platform_views();
3562        assert_eq!(frames.len(), 1);
3563        assert!(!frames[0].interactive, "Label never forwards native input");
3564    }
3565
3566    // --- The refusal placeholder's prose must never paint outside its own
3567    // slot rect -------------------------------------------------------------
3568
3569    /// A recording [`PaintScene`] that actually honours the clip stack
3570    /// (unlike [`NullScene`] above), so it models what a real backend would
3571    /// visually produce: every primitive's own reported bounds get
3572    /// intersected against whatever clip is active *at paint time* before
3573    /// being recorded. With no clip pushed (the pre-fix shape), a
3574    /// primitive's raw, unclamped bounds are recorded as-is — which is
3575    /// exactly what makes this test capable of catching the original
3576    /// overflow rather than trivially passing by construction.
3577    #[derive(Default)]
3578    struct BoundsRecorder {
3579        clip_stack: Vec<Rect>,
3580        /// Every painted primitive's bounds, already clipped against
3581        /// whatever was active when it painted.
3582        painted: Vec<Rect>,
3583        /// How many glyph runs carrying at least one glyph reached the scene,
3584        /// counted *before* the clip intersection — the "the banner really
3585        /// paints its warning text" half of the contract, which the clipped
3586        /// bounds above cannot answer on their own (a fully-clipped run
3587        /// records nothing).
3588        glyph_runs: usize,
3589    }
3590
3591    impl BoundsRecorder {
3592        fn record(&mut self, rect: Rect) {
3593            let bounded = match self.clip_stack.last() {
3594                Some(clip) => rect.intersect(*clip),
3595                None => rect,
3596            };
3597            // A fully-clipped-away rect never actually paints a pixel —
3598            // skip it rather than recording a degenerate zero-size rect.
3599            if bounded.width() > 0.0 && bounded.height() > 0.0 {
3600                self.painted.push(bounded);
3601            }
3602        }
3603    }
3604
3605    impl PaintScene for BoundsRecorder {
3606        fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
3607            self.record(Rect::from_origin_size(origin, size));
3608        }
3609
3610        fn draw_text(&mut self, _origin: Point, _text: &str) {}
3611
3612        fn fill_rounded_rect(&mut self, origin: Point, size: Size, _radius: f64, _color: Color) {
3613            self.record(Rect::from_origin_size(origin, size));
3614        }
3615
3616        fn stroke_path(
3617            &mut self,
3618            origin: Point,
3619            path: &kurbo::BezPath,
3620            width: f64,
3621            _brush: &Brush,
3622        ) {
3623            let bbox = path.bounding_box() + origin.to_vec2();
3624            // Pad by the stroke width so the border's own ink is covered,
3625            // not just its centerline path.
3626            self.record(bbox.inflate(width, width));
3627        }
3628
3629        fn push_clip(&mut self, origin: Point, size: Size) {
3630            let rect = Rect::from_origin_size(origin, size);
3631            let bounded = match self.clip_stack.last() {
3632                Some(prev) => rect.intersect(*prev),
3633                None => rect,
3634            };
3635            self.clip_stack.push(bounded);
3636        }
3637
3638        fn pop_clip(&mut self) {
3639            self.clip_stack.pop();
3640        }
3641
3642        fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
3643            let Some(first) = run.glyphs.first() else {
3644                return;
3645            };
3646            self.glyph_runs += 1;
3647            // The run's transform is a pure translation baked from the
3648            // paint-time origin (`frust_text::TextLayout::to_scene_runs`) —
3649            // `.translation()` recovers it directly. Glyph x/y are local
3650            // (pre-transform) positions; no font-metrics access exists at
3651            // this layer, so a generous `font_size`-wide margin around the
3652            // glyphs' local extent stands in for real ascent/descent —
3653            // over-approximating is fine here, since this test only needs
3654            // to catch genuine overflow, not measure exact ink bounds.
3655            let base = run.transform.translation();
3656            let margin = run.font_size as f64;
3657            let (min_x, max_x) = run
3658                .glyphs
3659                .iter()
3660                .map(|g| g.x as f64)
3661                .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| {
3662                    (lo.min(x), hi.max(x))
3663                });
3664            let rect = Rect::new(
3665                base.x + min_x - margin,
3666                base.y + first.y as f64 - margin,
3667                base.x + max_x + margin,
3668                base.y + first.y as f64 + margin,
3669            );
3670            self.record(rect);
3671        }
3672    }
3673
3674    fn rect_fits_within(outer: Rect, inner: Rect) -> bool {
3675        const TOLERANCE: f64 = 0.01;
3676        inner.x0 >= outer.x0 - TOLERANCE
3677            && inner.y0 >= outer.y0 - TOLERANCE
3678            && inner.x1 <= outer.x1 + TOLERANCE
3679            && inner.y1 <= outer.y1 + TOLERANCE
3680    }
3681
3682    #[test]
3683    fn placeholder_paints_visible_text_within_its_slot_rect_at_every_slot_size() {
3684        // A range of slot sizes the app-facing builders actually allow,
3685        // including `native_switch`'s own deliberately small box
3686        // (`examples/native-widgets-demo/src/pages/controls.rs`) and an
3687        // even smaller one to stress the invariant further.
3688        for (label, (w, h)) in [
3689            ("native_switch's own box (70x40)", (70.0, 40.0)),
3690            ("native_progress's own box (260x24)", (260.0, 24.0)),
3691            ("native_button's own box (160x48)", (160.0, 48.0)),
3692            ("a deliberately tiny box (40x16)", (40.0, 16.0)),
3693        ] {
3694            let view: AnyView<()> = placeholder(Some((w, h)), "Switch");
3695            let mut element = build_any(view);
3696
3697            let mut text_ctx = TextContext::new();
3698            let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3699            let bc = BoxConstraints::tight(Size::new(w, h));
3700            let laid = element.layout(&mut lctx, &bc);
3701            assert_eq!(
3702                laid,
3703                Size::new(w, h),
3704                "{label}: the placeholder's own reported size must stay exactly the \
3705                 slot's declared size"
3706            );
3707
3708            let mut rec = BoundsRecorder::default();
3709            let mut pctx = PaintCtx::new(Point::ZERO, laid);
3710            element.paint(&mut pctx, &mut rec);
3711
3712            assert!(
3713                !rec.painted.is_empty(),
3714                "{label}: expected the placeholder to paint something"
3715            );
3716            // The visible half of the refusal contract: a sighted user must
3717            // see the warning wording, not just the warning fill — the
3718            // `Role::Alert` node alone is not a substitute.
3719            assert!(
3720                rec.glyph_runs >= 2,
3721                "{label}: expected the banner's label AND description to paint as glyph \
3722                 runs, saw {} — a fill-only banner leaves a sighted user with no warning",
3723                rec.glyph_runs
3724            );
3725            // ... and the clip wrapper must keep every one of those runs
3726            // (plus the fill/border) inside the slot at the same time.
3727            let slot_rect = Rect::from_origin_size(Point::ZERO, laid);
3728            for bounds in &rec.painted {
3729                assert!(
3730                    rect_fits_within(slot_rect, *bounds),
3731                    "{label}: painted bounds {bounds:?} escaped the slot rect {slot_rect:?}"
3732                );
3733            }
3734        }
3735    }
3736
3737    /// The original refusal test must still pass unchanged after the clip wrapper
3738    /// — re-asserted here (mirrors `a_refused_slot_publishes_no_platform_view_frame`
3739    /// above) against the `BoundsRecorder`'s clip-aware scene too, so both
3740    /// scenes agree the refusal path never touches platform-view frames.
3741    #[test]
3742    fn a_refused_slot_still_publishes_no_platform_view_frame_through_the_clip_wrapper() {
3743        let view =
3744            native_switch(true).build_with_mode(2, ResolvedSurfaceMode::RefusedTranslucent, None);
3745        let mut element = build_any(view);
3746        let mut text_ctx = TextContext::new();
3747        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx as &mut dyn Any);
3748        element.layout(&mut lctx, &BoxConstraints::tight(Size::new(70.0, 40.0)));
3749        let mut rec = BoundsRecorder::default();
3750        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(70.0, 40.0));
3751        element.paint(&mut pctx, &mut rec);
3752        assert!(
3753            pctx.take_platform_views().is_empty(),
3754            "a refused slot must render no native platform_view frame, even through the \
3755             clip wrapper"
3756        );
3757    }
3758}