frust_native_widgets/lib.rs
1//! `frust-native-widgets`: render REAL platform widgets (Android `View`s /
2//! UIKit views / AppKit views) from pure Rust — `native_button("Save")`,
3//! `native_switch(...)` etc. compose the framework's `platform_view`
4//! slots for placement, while this plugin creates and mutates the actual
5//! native views through direct, same-thread FFI: `with_jni_env` + hand-curated
6//! JNI bindings on Android, `objc2-ui-kit` on iOS (the factory itself a Rust
7//! `define_class!` class — zero Swift), and `objc2-app-kit` on macOS (a Rust
8//! `frust_plugin::desktop::DesktopViewFactory` the desktop Mode-A host
9//! resolves by `view_type`). This crate holds the
10//! retained-handle [`registry`] every control's create/update/dispose path is
11//! built over, the `runtime` those paths dispatch through, the `controls` —
12//! eight shared (`Button`, `Label`, `Switch`, `Slider`, `ProgressBar`, `Image`,
13//! `Spinner`, `DatePicker`), the two iOS/macOS-only ones (`Segmented`,
14//! `Stepper`) and the iOS-only `TabBar` —
15//! that runtime serves, the typed `events` vocabulary their listeners decode
16//! into, the app-facing `api` builders, and the three platform arms' factory
17//! glue. Each shared control carries one platform half per arm (`Segmented`/
18//! `Stepper` have no Android half and `TabBar` has only the iOS one — their
19//! builders render a refusal banner elsewhere) — a
20//! `#[cfg(target_os = "android")] mod platform`, a
21//! `#[cfg(target_os = "ios")] mod platform` and a
22//! `#[cfg(target_os = "macos")] mod platform`, side by side in the same file,
23//! executing the same shared setter plan and
24//! reporting a tap/toggle/drag back to the app through the same event
25//! dispatch on every platform (`crate::apple::events`'s and
26//! `crate::appkit::events`' Rust target-action objects, mirroring Android's
27//! shared listener). The macOS arm registers every kind iOS does except the
28//! iOS-only `TabBar` (macOS has no bottom-tab-bar idiom).
29//! The theme ladder's two Apple arms are in: L1 pins brightness per view
30//! (`crate::apple::theme`'s `overrideUserInterfaceStyle`,
31//! `crate::appkit::theme`'s `NSAppearance`), re-pinned on every update; L2
32//! applies the same folded `Props` tokens through typed
33//! `objc2-ui-kit`/`objc2-app-kit`/`CALayer` setters, including a themed
34//! background's corner radius; L3 (`crate::coretext`, shared by both arms)
35//! resolves the embedded Glyph faces to a real `CTFont` via CoreText,
36//! degrading to the system font (one logged warning) on any resolution
37//! failure.
38//!
39//! # One factory, one listener, N controls
40//!
41//! Adding a control never adds Kotlin or Swift — and
42//! that holds for **your** components too: `NativeComponent` is the public
43//! trait a plugin author implements to drive a native view (or view
44//! hierarchy) from pure Rust, registered with `register_component` and served
45//! by the very same runtime, factory and listener as the built-in
46//! controls (see the `component` module's own doc for the lifecycle contract;
47//! it ships with the `frust-api` feature, like the builders above).
48//!
49//! One limit that doc states in full, repeated here because it decides
50//! whether the trait is for you at all. **An app crate cannot implement it
51//! today**: `create` has to name `jni::objects::JObject` on Android and
52//! `objc2-ui-kit`'s classes on iOS *in the implementing crate*, and this
53//! plugin re-exports neither FFI crate, so the practical audience today is
54//! plugin authors, not app authors (the only implementing type here is this crate's
55//! own non-default `demo-components` composite).
56//!
57//! A component hears its own views the way the built-in controls do: it
58//! attaches the platform's one listener to any view it built (root or child)
59//! with `ComponentCtx::attach_listener`, the listener is bound to the slot's
60//! own id by the context (never handed to the component), and the event
61//! reaches `NativeComponent::on_event` through the same slot-id routing —
62//! whose answer the app hears on `NativeComponentView::on_event`, the
63//! builders' events-as-signals idiom (the `component` module doc's *Listener
64//! attachment*).
65//!
66//! Every control is a Rust
67//! `NativeWidget` impl registered under a kind string in the plugin-internal
68//! `runtime`, and the platform side is fixed forever at ONE generic factory
69//! class plus ONE generic listener class (Android:
70//! `plugins/native-widgets/platform/android/`, driven by this crate's four
71//! JNI exports; iOS: a Rust `define_class!` factory registered straight into
72//! the Objective-C runtime, `crate::apple::factory` — zero Swift, and target
73//! -action instead of a listener class; macOS: a Rust `DesktopViewFactory`
74//! registered with `frust_plugin::desktop`, `crate::appkit::factory`, plus one
75//! target-action class). Which control a `platform_view` slot
76//! means travels in that slot's `params_json`, under two reserved keys the api
77//! layer injects — the same payload that carries the differ's slot id across a
78//! factory contract that does not pass it.
79//!
80//! # Native presentations
81//!
82//! Modal platform UI — an alert today — is not a `platform_view` slot: the
83//! differ owns a slot's lifetime and knows no modal stacking, while a modal
84//! belongs to the host (the resumed Android `Activity`, the topmost iOS view
85//! controller, the macOS key window). The [`present`] module therefore serves
86//! it imperatively: [`present::show_alert`] answers a [`Presentation`] future
87//! resolving exactly one [`AlertOutcome`], one presentation is live per
88//! process (a second request resolves [`PresentError::Busy`]), and
89//! [`present::dismiss`] takes one down. It needs no `frust-api` feature — a
90//! plain [`core::future::Future`] pollable from any executor. All three arms
91//! are built: iOS/iPadOS presents a `UIAlertController` (an iPad action
92//! sheet anchored as a popover on [`AnchorRect`]); macOS presents an
93//! `NSAlert` window sheet; Android presents a framework
94//! `android.app.AlertDialog` over the resumed `Activity` (one more Kotlin
95//! object, `FrustNativePresenter`, in the same Gradle module, tracking the
96//! resumed `Activity` from a manifest-declared init provider) — every other
97//! target answers [`PresentError::Unsupported`]. A **sheet**
98//! ([`present::show_sheet`]: a [`SheetSpec`]'s constrained native content —
99//! title, message, image, up to three action rows — resolving one
100//! [`SheetOutcome`], with detent changes streamed to a callback) is built
101//! on iOS/iPadOS only, a page sheet under `UISheetPresentationController`
102//! (an iPad in regular width shows a form sheet and ignores detents); every
103//! other target answers [`PresentError::Unsupported`]. Alerts and sheets
104//! share the one-at-a-time slot. With `frust-api` on, `show_native_alert` /
105//! `show_native_sheet` (the awaitable forms) and `show_native_alert_into` /
106//! `show_native_sheet_into` (writing the outcome into an `RwSignal`, spawned
107//! on `frust::spawn_local`) are the app-facing front door, re-exported at the
108//! crate root like the builders.
109//!
110//! # Charter: a platform plugin
111//!
112//! Like [`frust-camera`](../frust_camera/index.html) and
113//! [`frust-secure-storage`](../frust_secure_storage/index.html), this is a
114//! **platform plugin** (see `docs/ARCHITECTURE.md`'s Module Structure): it
115//! depends on `frust-plugin` plus FFI crates only, and carries **no other
116//! `frust-*` framework dependency**. An app adds this crate to its own
117//! `Cargo.toml` alongside `frust`; the facade does not depend on or
118//! re-export it.
119//!
120//! # Placement doctrine
121//!
122//! Placement is **static-first**: app bars, bottom bars, and full-body
123//! surfaces have no desync by construction, while a *scroll-hosted* native
124//! widget is a documented, degraded tier (an earlier device measurement of a
125//! scrolled native camera preview found cross-pipeline desync only while a
126//! slot's rect moves relative to frust content). Nothing in v1
127//! may *require* hosting a native widget inside a scroller; the headline use
128//! case is a high-rate native surface (a live chart, a streaming dashboard)
129//! updating at panel rate while frust's own frame loop idles.
130//!
131//! # Native-widget events bypass `RenderRoot::event`
132//!
133//! A native control's interaction is entirely platform-owned: a tap fires
134//! the platform's own listener (Android's `View.OnClickListener`, iOS/macOS
135//! target-action), which this crate's plugin-private JNI/ObjC exports
136//! deliver straight into a registered Rust callback — never through
137//! `frust-core`'s `EventCtx`. That means every `frust-core`/`frust-widgets`
138//! interaction convention (`docs/CODE_STANDARDS.md`'s Interaction Semantics:
139//! capture, focus, fire-on-up-inside, `Cancel`-never-mutates-state,
140//! `reduce_motion`/state-layer conventions) simply does not apply to a
141//! native control — this is a defining property of hosting real platform
142//! widgets, not a gap to close. The app-facing API wraps a
143//! native event straight into a signal write, which is what wakes exactly
144//! one frust frame.
145
146// The app-facing builders: default-on so an app that just adds this
147// crate to its `Cargo.toml` gets `native_button`/`native_label`/etc. for
148// free, but fully feature-gated — see `api`'s module doc and `Cargo.toml`'s
149// comment on why the crate stays a pure platform plugin without it.
150#[cfg(feature = "frust-api")]
151pub mod api;
152// Flat re-export at the crate root, mirroring the `frust` facade's own
153// flatten-every-widget convention — `native_button(...)` rather than
154// `api::native_button(...)`.
155#[cfg(feature = "frust-api")]
156pub use api::*;
157
158// The public `NativeComponent` trait — see its module doc.
159// Behind the same `frust-api` gate as the builders above, for one reason: a
160// component is only *mountable* through a `platform_view` slot, which is
161// facade glue this crate only has with the feature on. With it off the crate
162// is a bare platform plugin (`frust-plugin` + FFI crates, the charter line
163// `cargo tree -p frust-native-widgets --no-default-features -e normal`
164// checks) and there would be nothing to hand a component to.
165#[cfg(feature = "frust-api")]
166pub mod component;
167// Flat re-export, same convention as `api` above.
168#[cfg(feature = "frust-api")]
169pub use component::{
170 ComponentCtx, ListenerHandle, ListenerKinds, NativeChild, NativeComponent, NativeEvent,
171 NativeRoot, register_component,
172};
173
174// The demo composite — ONE `NativeComponent` owning a real
175// native subtree, behind the NON-default `demo-components` feature (which
176// enables `frust-api` above, since a component is only mountable through that
177// facade glue). Three real arms build it — Android (`LinearLayout` + `TextView`
178// + `Button`s), iOS (`UIView` + `UILabel` + `UIButton`s) and macOS (`NSView` +
179// `NSTextField` + `NSButton`s) — and a Linux/Windows/web host compiles a
180// recorded stand-in its host tests assert against. It lives in this crate
181// rather than in an example app because an app crate cannot implement the trait
182// without raw `jni`/`objc2-ui-kit`/`objc2-app-kit` deps of its own; see the
183// module's own doc for what that does and does not prove.
184#[cfg(feature = "demo-components")]
185pub mod demo;
186// Flat re-export, same convention as `api`/`component` above.
187#[cfg(feature = "demo-components")]
188pub use demo::{
189 DEMO_CARD_CHILDREN, DEMO_CARD_HEIGHT, DEMO_CARD_KIND, DEMO_CARD_WIDTH, DemoCard, DemoCardProps,
190 DemoCardState, register_demo_components,
191};
192
193// Native presentations (alerts, sheets): imperative, host-owned modal UI resolving
194// one outcome through a plain `Future` — see the crate doc's *Native
195// presentations*. Deliberately outside the `frust-api` gate: it names no
196// framework type, so the bare platform plugin serves it too.
197pub mod present;
198// Flat re-export of the presentation vocabulary, same convention as `api`
199// above; the two entry points stay namespaced (`present::show_alert`,
200// `present::dismiss`).
201pub use present::{
202 ActionRole, AlertAction, AlertOutcome, AlertSpec, AlertStyle, AnchorRect, Detent,
203 DismissReason, PresentError, Presentation, PresentationHandle, SheetAction, SheetContent,
204 SheetHandle, SheetOutcome, SheetSpec,
205};
206
207#[cfg(target_os = "android")]
208mod android;
209// The Apple arm: ONE Rust `define_class!` factory class conforming to
210// the embedding's `FrustPlatformViewFactory` protocol — no Swift, no exports.
211// iOS only, not `target_vendor = "apple"`: see `Cargo.toml`'s comment on why
212// (UIKit doesn't exist on macOS).
213#[cfg(target_os = "ios")]
214mod apple;
215// The macOS arm: ONE Rust `DesktopViewFactory` registered with
216// `frust_plugin::desktop` under the api layer's `VIEW_TYPE` (the desktop
217// Mode-A host resolves it by that string) plus ONE target-action class —
218// AppKit, not UIKit, so its own module rather than a widened `apple` gate.
219#[cfg(target_os = "macos")]
220mod appkit;
221// Theme ladder L3's CoreText half — descriptor-from-bytes, the first-publish
222// latch and its caches — shared by both Apple arms: it touches neither UIKit
223// nor AppKit, so it sits beside them rather than inside `apple` (which stays
224// iOS-only, see above). Its only caller of `set_glyph_bytes` is the
225// `frust-api` feature's `api::theme`, hence the `allow` without that feature.
226#[cfg(any(target_os = "ios", target_os = "macos"))]
227#[cfg_attr(not(feature = "frust-api"), allow(dead_code))]
228mod coretext;
229// The v1 controls (eight shared, the two Apple-only ones, `Segmented` and
230// `Stepper`, and the iOS-only `TabBar`). Compiled on every target on
231// purpose: each control's
232// props/decode/diff half is platform-agnostic and host-tested, and only its
233// `NativeWidget` impls (one per platform arm) are `#[cfg(target_os = ...)]`
234// — which is also why the modules live here rather than under a platform
235// directory. The `allow` matches `runtime`'s below: on a host with no arm
236// (Linux/Windows/web) the whole props/plan surface has no caller outside the
237// tests.
238#[allow(dead_code)]
239mod controls;
240// The typed event vocabulary (`EventPayload`) and the kind/detail codec every
241// interactive control's listener decodes through — platform-agnostic and
242// host-tested for the same reason `controls` is (see that module's `allow`).
243#[allow(dead_code)]
244mod events;
245mod registry;
246// The runtime's surface is consumed by the platform arms — this crate's JNI
247// exports, the Apple `define_class!` factory, the macOS desktop factory, the
248// controls and their listeners — plus its own host tests, which a plain
249// (non-test) build does not count. On a host with no platform arm none of
250// those compile, so much of the surface is legitimately uncalled there; the
251// attribute stays for that host build rather than growing per-item `allow`s.
252#[allow(dead_code)]
253mod runtime;
254
255pub use registry::{Registry, SlotId};
256
257#[cfg(target_os = "android")]
258pub use registry::android::AndroidHandle;
259// One handle per Apple arm, each gated on its own `target_os` rather than
260// `target_vendor = "apple"` — see `Cargo.toml`'s comment on why (UIKit doesn't
261// exist on macOS): `AppKitHandle` (macOS, `Retained<NSView>`) and
262// `AppleHandle` (iOS, `Retained<UIView>`).
263#[cfg(target_os = "macos")]
264pub use registry::appkit::AppKitHandle;
265#[cfg(target_os = "ios")]
266pub use registry::apple::AppleHandle;
267
268/// Errors from a native-widgets operation.
269///
270/// `thiserror`-derived per `docs/CODE_STANDARDS.md`: callers match on the
271/// variant (e.g. distinguishing [`Self::PlatformNotInitialized`] from a
272/// generic [`Self::Platform`] failure) rather than only displaying it.
273#[derive(thiserror::Error, Debug)]
274#[non_exhaustive]
275pub enum NativeWidgetError {
276 /// This platform has no native-widgets backend (desktop preview, wasm),
277 /// or an Android scaffold predating `nativeInitPlatform` — the same
278 /// fail-soft contract every other platform plugin uses
279 /// (`docs/CODE_STANDARDS.md`'s Plugin Conventions), never a panic.
280 #[error("native-widgets platform not initialized")]
281 PlatformNotInitialized,
282
283 /// A slot's `params_json` could not be read: the reserved identity keys
284 /// are missing, a required field is absent or malformed, or the params
285 /// name a different control than the live instance. Distinguishable from
286 /// [`Self::Platform`] because it is an api-layer/runtime contract
287 /// violation, never a platform failure.
288 #[error("native-widgets params error: {0}")]
289 Params(String),
290
291 /// No `NativeWidget` is registered under the control kind a slot's params
292 /// name — a control the app's build never registered (or a params payload
293 /// from a different plugin version).
294 #[error("native-widgets: no control registered as '{0}'")]
295 UnknownControl(String),
296
297 /// A backend-specific failure not covered by a more specific variant
298 /// (a JNI error, an unexpected ObjC runtime failure).
299 #[error("native-widgets platform error: {0}")]
300 Platform(String),
301}
302
303/// The un-contexted fallback conversion, so a JNI error can ride `?` through
304/// a helper that has no operation name to attach (the local-frame wrapper is
305/// the one such path). **Prefer `NativeCtx::run_jni`**, which names the
306/// failing operation and converts a pending Java exception into a message —
307/// this impl exists for the plumbing that cannot.
308#[cfg(target_os = "android")]
309impl From<jni::errors::Error> for NativeWidgetError {
310 fn from(error: jni::errors::Error) -> Self {
311 Self::Platform(format!("jni: {error}"))
312 }
313}