# frust-native-widgets
Render **real OS controls** from pure Rust — `native_button("Save")`,
`native_label(...)`, `native_switch(checked)`, `native_slider(...)`,
`native_progress(...)`, `native_image(bytes)`, `native_spinner(animating)`,
`native_date_picker(date)`, `native_segmented(labels, selected)` (iOS/macOS only),
`native_stepper(value, min, max)` (iOS/macOS only),
`native_tab_bar(items, selected)` (iOS/iPadOS only) — composed straight
into a frust `View` tree like any other widget. Each control is backed by a
genuine Android `View` (`Button`/`TextView`/`Switch`/`SeekBar`/`ProgressBar`/`ImageView`/a
circular-style `ProgressBar`/`DatePicker`), UIKit view (`UIButton`/`UILabel`/`UISwitch`/
`UISlider`/`UIProgressView`/`UIImageView`/`UIActivityIndicatorView`/`UIDatePicker`/
`UISegmentedControl`/`UIStepper`/a bare `UITabBar`), or
AppKit view on macOS (`NSButton`/`NSTextField`/`NSSwitch`/`NSSlider`/
`NSProgressIndicator`/`NSImageView`/an `NSProgressIndicator` in its
`Spinning` style/`NSDatePicker`/`NSSegmentedControl`/`NSStepper`), hosted as a **platform-view slot**
(`docs/ARCHITECTURE.md`'s Platform-view flow) — frust paints nothing for it,
the OS composites it in place.
Like every frust **platform plugin**, this crate is added to your app's own
`Cargo.toml` alongside `frust` (the pubspec model) — the `frust` facade does
not re-export it.
> **Templates stay clean.** A generated frust project ships **no**
> native-widgets code, permissions, or plist keys. You add exactly the lines
> below by hand — or let the frust TUI's **Add Plugin** dialog apply them for
> you (it automates every step in this document). On Android the plugin's
> Kotlin ships as its own Gradle library module rather than as files copied
> into your app, so there is nothing to keep in sync by hand; on iOS there is
> nothing to add at all beyond the dependency.
**Platform support:** Android, iOS and macOS. Other targets (desktop preview, web) have no native-control backend.
More about Frust: <https://frust.dev> and <https://github.com/frust-rs/frust>.
---
## 1. What you get
Eleven controls, one Rust API, no Kotlin or Swift to write for any of them:
| `native_button(text)` | `Button` | `UIButton` | `NSButton` |
| `native_label(text)` | `TextView` | `UILabel` | `NSTextField` (label) |
| `native_switch(checked)` | `Switch` | `UISwitch` | `NSSwitch` |
| `native_slider(value, min, max)` | `SeekBar` | `UISlider` | `NSSlider` |
| `native_progress(value, min, max)` | `ProgressBar` | `UIProgressView` | `NSProgressIndicator` |
| `native_image(bytes)` | `ImageView` | `UIImageView` | `NSImageView` |
| `native_spinner(animating)` | `ProgressBar` (circular style) | `UIActivityIndicatorView` | `NSProgressIndicator` (`Spinning` style) |
| `native_date_picker(date)` | `DatePicker` | `UIDatePicker` (date mode) | `NSDatePicker` (year/month/day) |
| `native_segmented(labels, selected)` | **none — renders a refusal banner** (see below) | `UISegmentedControl` | `NSSegmentedControl` (select-one) |
| `native_stepper(value, min, max)` | **none — renders a refusal banner** (see below) | `UIStepper` | `NSStepper` |
| `native_tab_bar(items, selected)` | **none — renders a refusal banner** (see below) | a bare `UITabBar` (no `UITabBarController`) | **none — renders a refusal banner** |
`native_segmented` and `native_stepper` are **iOS/iPadOS and macOS only** in
this release. Android's framework has no segmented control — the stock one is
Material's `MaterialButtonToggleGroup` — and no increment/decrement stepper
control either; this plugin never assumes a Material/AndroidX dependency your
app did not add — so on Android (and on every desktop-preview host) each
builder is resolved **at compile time** to the same frust-drawn warning
banner the translucency-refused fallback uses, naming the missing arm, rather
than an empty slot. A Material-backed segmented Android arm and a composite
stepper Android arm (two `ImageButton`s plus a `TextView`) are both follow-up
plans. `native_segmented` is controlled like `native_switch`:
`.on_select(|index| …)` reports the *requested* segment, and the app confirms
it by feeding `selected` back; `.momentary(true)` makes a tap flash its
segment instead of selecting it. The selected segment wears the theme's
`accent_fill`. `native_stepper` is controlled like `native_slider`:
`.on_change(|value| …)` reports the *requested* value on a tap, and the app
confirms it by feeding `value` back; `.step(n)` sets the increment (default
`1`) and `.wraps(true)` wraps the value from `max` back to `min` instead of
clamping. The control wears the theme's `accent_ink` as its tint on iOS only
— `NSStepper` has no tint property AppKit exposes at all, so the fold is a
silent no-op there, logged at debug (same shape as the AppKit tint gaps in §4
below).
`native_tab_bar` is **iOS/iPadOS only**: macOS has no bottom-tab-bar idiom,
and Android's `BottomNavigationView` needs Material, which this plugin never
assumes — both render the refusal banner, naming both reasons. It is a
**bare** `UITabBar`, never a `UITabBarController`: frust keeps screen
ownership and routing, so a tap never navigates by itself. Each `TabItem`
carries a stable app-chosen `TabId`, a title, an icon — `TabIcon::AppleSymbol`
(an SF Symbol name) or `TabIcon::Bytes` (encoded image bytes, normalized to a
25pt box; supply 75×75px for a crisp 3x icon) — and optionally a
`.selected_icon(…)`, a `.badge(…)` and `.enabled(false)`. `.on_select(|id| …)`
reports the *requested* tab: route there and feed the id back as `selected`
(controlled, like `native_segmented`). `.on_reselect(|id| …)` fires when the
tab the app last confirmed was tapped again — the conventional "scroll to
top / pop to root" — not merely the one UIKit is highlighting: a tap you
reject (no write-back) retapped still reports as another `.on_select`, even
though the OS has already moved the highlight to it. The same tracking works
in reverse too: tap back on the tab the app still confirms and that retap
fires `.on_reselect`, even though the OS's own highlight had just moved to
the rejected tab a moment before, not to the one you tapped back — an app
that pops to root on `.on_reselect` should feed the confirmed selection back
promptly so its highlight and `selected` never keep diverging. The bar
sizes itself to 49pt plus the window's bottom safe-area inset,
so its background runs under the home indicator: put it last in a `Column`
docked to the bottom edge, and if you wrap it in `frust::safe_area` for
horizontal cutouts use `.top(false).bottom(false)` (the way `examples/huddle`
docks Material's `navigation_bar`); `.safe_area(false)` gives the bare 49pt
for a bar that isn't docked to the bottom. The selected item wears the
theme's `accent_ink`, unselected items its `on_surface_variant`, and the bar
its `surface` colour (an opaque appearance, set for both the standard and the
scroll-edge state so iOS 15+ never shows a transparent bar). On iPadOS the
bare bar stays at the bottom and gets no Liquid Glass — the iPadOS 18 top tab
bar and the floating glass bar are `UITabBarController` features.
```rust
use frust::{RwSignal, Get, Set, RouteNavigator};
use frust_native_widgets::{native_tab_bar, TabIcon, TabId, TabItem};
// The router owns navigation; the tab bar only reports the request.
fn bottom_tabs(nav: RouteNavigator, tab: RwSignal<String>) -> impl frust::View<AppState> {
native_tab_bar(
vec![
TabItem::new("home", "Home", TabIcon::AppleSymbol("house".into()))
.selected_icon(TabIcon::AppleSymbol("house.fill".into())),
TabItem::new("inbox", "Inbox", TabIcon::AppleSymbol("tray".into())).badge("3"),
],
TabId::new(tab.get()),
)
.on_select(move |id| {
tab.set(id.to_string()); // feed the confirmed id back next rebuild
nav.go(format!("/{id}")); // Router::go through its Send + Sync navigator
})
.on_reselect(|id| log::info!("scroll {id} to top"))
}
```
`native_date_picker` picks a **date only** (no time) and is controlled like
`native_switch`: `.on_change(|date| …)` reports the *requested* `CivilDate`
(`{ year, month, day }`, month 1-based, validated — build one with
`CivilDate::new(2026, 9, 29)`), and the app confirms it by feeding it back
as `date`. `.min(date)`/`.max(date)` bound the range (a `date` outside it is
shown and reported clamped), and `.style(NativeDatePickerStyle::…)` picks the
presentation: `Compact` (default — iOS's compact button, a macOS text field
with a click-to-open calendar, Android's spinner mode), `Wheels` (iOS wheels,
the macOS text field and stepper, Android's spinner mode) or `Inline` (an
always-visible calendar on all three). Android fixes the mode when the picker
is created, so a later `.style(…)` change applies on iOS/macOS only (logged
once on Android). The picker wears the theme's `accent_ink` as its tint on iOS
and the theme's body-text colour as its text colour on macOS; Android's
`DatePicker` has no tint or text-colour API — its colours come only from the
(light/dark) theme it is built against — and `NSDatePicker` has no tint, so
those folds are silent no-ops, logged at debug.
…plus `native_component`, the generic mounting seam for a control this crate
does not ship: `impl NativeComponent` (with your own typed `Props`) →
`register_component::<C>(KIND)` → `native_component(KIND, c, props)`, mounted
through the same one factory and the same runtime the built-in builders use,
with no per-component Kotlin or Swift anywhere in it. See the `api::mount`
module docs.
A component hears its own views the way the built-in builders do: it attaches the
platform's one listener to any view it built with
`ComponentCtx::attach_listener(view, ListenerKinds::CLICK)` (or `TOGGLED` /
`VALUE_CHANGED`), its `NativeComponent::on_event` receives each event, and
whatever that answers reaches the app on the mounted view's `.on_event(...)`
hook — the same events-as-signals idiom as `.on_press`:
```rust
native_component(KIND, MyCard, props)
.interactive()
.on_event(move |event| if event.is_click() { taps.set(taps.get_untracked() + 1) })
```
> **One limit on that seam — read it before you plan around it.** An app
> crate cannot implement `NativeComponent` today (it needs raw
> `jni`/`objc2-ui-kit` dependencies this crate does not re-export). It is
> spelled out in §5, and does not apply to the built-in builders above.
Every control follows frust's **controlled-component** convention where the
platform allows it: a switch/slider reports the *requested* value through its
`on_...` callback, and the value you pass back down on the next rebuild is
what actually shows — the same contract `Checkbox`/`Slider` follow in
`frust-widgets` (`docs/CODE_STANDARDS.md`'s Interaction Semantics).
A native control's tap/toggle/drag bypasses `frust-core`'s `EventCtx`
entirely — it fires straight from the platform's own listener
(`View.OnClickListener` on Android, target-action on iOS) into a registered
Rust callback. That means none of `frust-core`'s interaction contract
(capture, focus, fire-on-up-inside, `Cancel`-never-mutates-state) applies to
a hosted native control — this is inherent to hosting real platform widgets,
not a gap.
```rust
use frust::RwSignal;
use frust_native_widgets::native_button;
// A native control's callback takes no app-state parameter — it fires
// straight from the platform's own listener, so it closes over a signal
// instead (the events-as-signals idiom every builder follows):
fn save_button(saved: RwSignal<bool>) -> impl frust::View<AppState> {
native_button("Save")
.size(160.0, 48.0)
.on_press(move || saved.set(true))
}
```
---
## 1b. Native presentations (alerts, sheets)
A native **alert** is not a control in the tree: it is a request answered by
exactly one outcome — the chosen action's id, `Cancelled`, `Dismissed` (you
took it down with `present::dismiss(&handle)`) or `HostLost` (the presenting
host went away first — the iOS/iPadOS window scene, the macOS presenting
window closing, or the Android hosting `Activity` being destroyed). One
presentation is live per process; a second request while one is up is
refused `PresentError::Busy` at once, never queued. Nothing ever blocks
waiting for the user.
| iOS / iPadOS | `UIAlertController` — `AlertStyle::Alert` centered, `AlertStyle::ActionSheet` from the bottom (iPhone) or as a popover pointing at `anchor` (iPad; required there, see below) |
| macOS | `NSAlert` presented as a window sheet on the key/main window — Return resolves the first action, Escape resolves the `Cancel`-role action; no click-away, so `Cancelled` is never produced; `style`/`anchor` ignored |
| Android | framework `android.app.AlertDialog` over the resumed `Activity` — the back key / an outside tap resolve `Cancelled`, only when `cancelable`; `style`/`anchor` ignored |
| desktop preview, web, every other target | `PresentError::Unsupported` |
Up to three actions, each with a unique non-empty `id` and a role —
`ActionRole::Default`, `Cancel` (at most one; UIKit places it itself) or
`Destructive`: iOS styles it red, macOS sets `NSButton.hasDestructiveAction`,
and Android has no severity styling for it — plain, sharing `Default`'s
button-slot preference. Two shapes:
```rust
use frust::{RwSignal, Set};
use frust_native_widgets::{
ActionRole, AlertOutcome, AlertSpec, AlertStyle, AnchorRect, native_button, show_native_alert,
show_native_alert_into,
};
fn delete_spec() -> AlertSpec {
AlertSpec::new("Delete draft?", "This cannot be undone.")
.with_action("keep", "Keep", ActionRole::Cancel)
.with_action("delete", "Delete", ActionRole::Destructive)
}
// 1. Awaitable — from any async context (`frust::spawn_local` on the UI thread);
// `deleted` is an app `RwSignal<bool>`:
frust::spawn_local(async move {
if let Ok(AlertOutcome::Action(id)) = show_native_alert(delete_spec()).await
&& id == "delete"
{
deleted.set(true);
}
});
// 2. Into a signal — no async block. Writes `Some(outcome)` exactly once;
// you read it on a later rebuild and reset it to `None` yourself:
let outcome: RwSignal<Option<AlertOutcome>> = RwSignal::new(None);
log::warn!("no alert: {err}"); // Busy, InvalidSpec, Unsupported…
}
})
```
`show_native_alert_into` returns synchronous refusals (`Busy`, an invalid
spec, `Unsupported`) as `Err` and never writes the signal for them; an error
found only later on the main thread (`NoHost`) is logged, not written — await
`show_native_alert` when you need to tell those apart. Dropping the awaited
future frees the one-at-a-time slot but leaves the alert on screen until the
user answers it (that answer is discarded).
**The iPad anchor rule.** An action sheet on iPad is a popover and must point
at something: `AlertStyle::ActionSheet` with no `anchor` is refused
`PresentError::InvalidSpec` on iPad before anything is shown (UIKit would
crash). Pass an `AnchorRect` in **logical window coordinates** — frust's
logical pixels are iOS points, so the rect a native control's
`platform_view` slot paints at, or any frust widget's window-space rect, is
already right; a zero-size rect points at a tap location. iPhone ignores the
anchor.
```rust
let mut spec = delete_spec();
spec.style = AlertStyle::ActionSheet;
spec.anchor = Some(AnchorRect { x: 24.0, y: 600.0, width: 120.0, height: 44.0 });
```
`cancelable` matters only where the platform has a non-action dismissal.
**iOS:** on iPad, `cancelable: false` stops an outside tap from closing the
action-sheet popover; UIKit alerts (and iPhone action sheets) have none, so
there it removes nothing. On an iPad popover with a `Cancel`-role action,
UIKit hides that button and an outside tap reports it (`Action("keep")`
above) rather than `Cancelled`. **Android:** `cancelable` gates both the back
key and an outside tap, either resolving `Cancelled`. **macOS:** no analog —
a window sheet has no click-away dismissal, so `cancelable` is ignored there.
### Native sheets (iOS / iPadOS)
A **sheet** is the same kind of request: a `SheetSpec` answered by exactly
one `SheetOutcome` — `Action(id)` (the user tapped a row; the sheet has
already slid away), `Dismissed(DismissReason::User)` (swiped down),
`Dismissed(DismissReason::Programmatic)` (you called `handle.dismiss()`) or
`HostLost`. It shares the one-at-a-time slot with alerts: while an alert is
up, a sheet request is refused `PresentError::Busy`, and the reverse.
| iOS / iPadOS | a page sheet under `UISheetPresentationController`: detents, grabber, swipe-to-dismiss |
| macOS, Android, desktop preview, web | `PresentError::Unsupported` (an `NSPopover` / `BottomSheetDialog` arm is follow-up work) |
The content is a **constrained native schema**, not frust widgets: an
optional title, an optional message, optional image bytes and up to three
action rows (system buttons — `Default` wears the tint, `Destructive` red,
`Cancel` the secondary label colour), stacked top to bottom. An empty sheet
is refused `InvalidSpec`, as are more than three actions or a
`Detent::Custom(f)` outside `0 < f <= 1`.
```rust
use frust::RwSignal;
use frust_native_widgets::{
ActionRole, Detent, SheetContent, SheetOutcome, SheetSpec, native_button, show_native_sheet,
show_native_sheet_into,
};
fn share_spec() -> SheetSpec {
SheetSpec::new(
SheetContent::new()
.with_title("Share draft")
.with_message("Anyone with the link can read it.")
.with_action("copy", "Copy link", ActionRole::Default)
.with_action("stop", "Stop sharing", ActionRole::Destructive),
)
.with_detents([Detent::Medium, Detent::Large])
.with_theme(&theme) // tint + light/dark from the active frust theme
}
// Awaitable, with the handle taken first (moves it, or takes it down):
}));
let handle = presentation.sheet_handle();
frust::spawn_local(async move {
if let Ok(SheetOutcome::Action(id)) = presentation.await {
chosen.set(Some(id));
}
});
// Into a signal — the same controlled contract as the alert form:
let outcome: RwSignal<Option<SheetOutcome>> = RwSignal::new(None);
native_button("Share").on_press(move || {
if let Err(err) = show_native_sheet_into(share_spec(), outcome) {
log::warn!("no sheet: {err}"); // Busy, InvalidSpec, Unsupported…
}
})
```
`dismissible: false` (`.with_dismissible(false)`) stops the swipe-down, so
only a row or `handle.dismiss()` ends the sheet. `Detent::Custom` needs
iOS 16; on iOS 15 it becomes the nearest of medium/large (logged once).
**iPad in regular width ignores detents.** UIKit presents a page sheet in a
full-width (or large Split View) iPad window as a centered form sheet at a
fixed size — no medium height, no detent changes; only an edge-attached sheet
(iPhone, or an iPad window in compact width such as Slide Over) rests at its
detents. Design the content to read well at both sizes; never rely on detent
parity across idioms.
---
## 2. Add the dependency (always)
```toml
# app Cargo.toml — [dependencies]
frust-native-widgets = "0.5"
```
`<frust>` is the path to your frust checkout — derive it from the `frust = {
path = "…" }` line the scaffold already wrote.
This is the **only** step iOS needs — see §4.
---
## 3. Android setup — include the plugin's Gradle module
**One addition: include this plugin's Android library module.** It carries
both of the plugin's Kotlin classes — `dev.frust.nativewidgets`'s
`FrustNativeControlFactory` (the ONE platform-view factory every control
resolves through) and `FrustNativeListener` (the ONE listener class every
control's events funnel through) — plus the two R8 keep rules they need, in
its own `consumer-rules.pro`. Your `AndroidManifest.xml` and
`proguard-rules.pro` stay untouched, and there is no file to copy, so nothing
can drift.
The TUI Add Plugin dialog (`frust tui` → Add Plugin → native-widgets) makes
both edits below for you, idempotently. By hand:
1. `android/settings.gradle.kts` — include the module, and redirect
its build directory under your app:
```kotlin
include(":frust-native-widgets")
project(":frust-native-widgets").projectDir = frustLocalDir("frust.plugin.frust-native-widgets.dir")
gradle.lifecycle.beforeProject {
if (path == ":frust-native-widgets") {
layout.buildDirectory.set(rootDir.resolve("../build/android/frust-native-widgets"))
}
}
```
The module's directory is machine-local: its key sits in the gitignored
`android/local.properties`, written by `frust run`/`frust build` from the
crate cargo resolves, and read by the generated `frustLocalDir` helper.
2. `android/app/build.gradle.kts` — depend on it:
```kotlin
dependencies {
implementation(project(":frust-native-widgets"))
}
```
No permission is required: hosting an `android.widget` view needs none.
**Both class names are hard contracts.**
`dev.frust.nativewidgets.FrustNativeControlFactory` is the platform-view
`viewType` string this crate publishes (the embedding's `FrustViewHost`
resolves it reflectively through the application classloader, which is also
why the package keeps its `dev.frust.` prefix), and both class names are
baked into this plugin's four JNI export symbols
(`Java_dev_frust_nativewidgets_*`). If the module isn't wired in, the host
cannot resolve the factory and every native control renders as an empty slot.
**Integrated this plugin before the module existed?** Both class names and all
four export symbols changed — see §7 before you rebuild.
---
## 4. iOS and macOS setup — nothing either
Neither Apple platform needs anything beyond §2's Cargo dependency: no Swift
package reference, no Xcode project edit, no `Info.plist` key, and on macOS
no explicit "install" call either.
### iOS — zero Swift, by design
The factory that would be a Swift class on every other plugin's Apple arm is
instead a Rust `objc2` `define_class!` type, registered straight into the
Objective-C runtime from this crate's own code and resolved by
`NSClassFromString` under the bare runtime name `FrustNativeControlFactory`
(`docs/CODE_STANDARDS.md`'s Naming Conventions: iOS factories carry no
package prefix — the opposite convention from Android's fully-qualified
FQCN). This has held for every control added since the first.
### macOS — the desktop Mode-A host registers the factory lazily
The desktop preview/bundle needs no app-side wiring either: this crate's
AppKit factory (a Rust `frust_plugin::desktop::DesktopViewFactory`) registers
itself with the shell's platform-view registry the first time any builder in
this crate encodes a slot's params — there is nothing for your app to call.
Two things differ from the mobile arms, both by design:
- **The theme ladder follows the app, not the Mac.** Every control's
`NSAppearance` is pinned to the active `frust::Theme`'s brightness
(`DarkAqua`/`Aqua`), re-applied on every update — so a Mac running Dark
Mode still draws a Light-themed app's controls light, and vice versa.
- **A handful of AppKit gaps are logged, not papered over**: `Fit::Cover`
has no true fill-and-crop on `NSImageView`, so it letterboxes like
`Fit::Contain` rather than cropping; a slider emits no drag-start/drag-end
event (AppKit target-action carries no gesture phase, only the final
value); `NSSwitch`'s track and `NSProgressIndicator`'s fill have no tint
API at all, so `thumbTint`/`trackTint`/`progressTint` on `native_switch`/
`native_progress` — the spinner's own tint on `native_spinner`, which
also draws through `NSProgressIndicator` — and `native_stepper`'s tint,
since `NSStepper` exposes no tint property either — and `native_date_picker`'s
tint, `NSDatePicker` having none (its calendar draws in the system accent
colour) — are silent no-ops
logged at debug (`NSSwitch`/`NSProgressIndicator` still draw in the system
accent colour regardless; `NSStepper`'s own `-`/`+` glyphs simply keep
their default appearance);
`native_image`'s tint marks the image as a template and sets
`NSImageView.contentTintColor` — a silhouette in the tint colour, like Android's SRC_IN and iOS's template rendering — and clearing
the tint restores the original image; and `native_image` decodes through
ImageIO, so a shell environment whose `DYLD_LIBRARY_PATH` shadows one of
ImageIO's private codec dylibs (e.g. Homebrew's `/opt/homebrew/lib` on a
machine with `libpng`/`libjpeg` installed) leaves every image slot empty
with one logged warning rather than decoding or crashing.
---
## 5. Caveats
- **`frust create --overwrite` destroys these additions.** `--overwrite`
re-renders the generated project wholesale, silently dropping the Gradle
include and the module dependency. Both additions are **idempotent** — just
re-run the Add Plugin dialog (or re-apply §3) to restore them. The Kotlin
classes themselves live in the plugin's own module, so they survive — but a
project that no longer includes the module can't reach them.
- **A wrong or missing module wiring fails at runtime, not at build time.**
Nothing in the Rust build references the Kotlin classes, so a missing
`include(...)` compiles cleanly and surfaces on device as a blank slot
where the control should be (the host logs an unresolvable factory). A stale
pre-module hand copy produces the same blank slot — see §7.
- **No cargo gate compiles Kotlin.** `cargo check --target
aarch64-linux-android -p frust-native-widgets` type-checks the Rust half
only; a typo in the module's `.kt` files surfaces solely in a real Gradle
build (`docs/DEVELOPMENT.md`). `plugins/native-widgets/tests/
kotlin_conformance.rs` covers the one thing a source scan can: that the
Kotlin listener's `KIND_*` constants and value-packing arithmetic still
agree with `src/events.rs`'s.
- **Events bypass frust's event pipeline** — see §1. A native control is not
reachable by frust's pointer capture/focus machinery, and a frust widget
drawn over an interactive slot needs an explicit input `shield(...)`
(`docs/ARCHITECTURE.md`'s Platform-view flow).
- **An app crate cannot implement `NativeComponent` today** (§1's
`native_component` seam only). `create` has to construct real native views,
which means naming `jni::objects::JObject` on Android and `objc2-ui-kit`'s
classes on iOS *in your own crate*; this plugin re-exports neither FFI
crate, and `docs/CODE_STANDARDS.md` sanctions a
`frust-core`/`kurbo`/`peniko` escape hatch only for an `examples/*` app. So
the practical audience today is **plugin authors, not app authors** — the
only implementor in this repo is this crate's own non-default
`demo-components` composite. Closing the gap (re-exporting a curated
view-construction surface, or the FFI crates themselves) is a separate,
unscheduled decision. The built-in builders in §1 are unaffected: they are
ordinary Rust calls needing no FFI dependency of yours.
- **A `NativeComponent`'s events come only from listeners it attached.**
`ComponentCtx::attach_listener` binds the platform's one listener class to
the component's own slot id — which the context never hands the component —
and the runtime's bridge delivers only the event families the slot attached,
so a view the component attached nothing to still behaves natively (a
button highlights) but reports nothing. Two children attached for the same
family are indistinguishable in `on_event`: a click carries its slot, not
which child fired, so give each child its own family or its own slot. On
macOS an `NSControl` carries one target/action pair, so it takes exactly one
family (`CLICK`, `TOGGLED` or `VALUE_CHANGED`), and AppKit reports no drag
edges. The listener is released when the `ListenerHandle` the component
keeps in its state drops.
---
## 6. The TUI Add Plugin dialog automates all of this
Everything in §2 and §3 — the Cargo.toml dependency and the
`:frust-native-widgets` Gradle include plus its app-module dependency — is
applied for you, idempotently, by the frust TUI's **Add Plugin** dialog
(select `native-widgets`). This README is the manual contract that dialog
encodes.
---
## 7. Migrating from the v1 hand copy (breaking, one time)
Before this plugin shipped its own Gradle module, its two generic Kotlin
classes lived in the **bare `dev.frust` package** and the documented
integration was to **hand-copy** `FrustNativeControlFactory.kt` and
`FrustNativeListener.kt` into your own app module, under
`app/src/main/kotlin/dev/frust/`. That shape is gone, and the rename it
required is breaking:
| Kotlin package | `dev.frust` | `dev.frust.nativewidgets` |
| factory `viewType` | `dev.frust.FrustNativeControlFactory` | `dev.frust.nativewidgets.FrustNativeControlFactory` |
| JNI export symbols | `Java_dev_frust_FrustNative*` | `Java_dev_frust_nativewidgets_FrustNative*` |
| how the Kotlin ships | copied into your app module | this plugin's own Gradle library module (§3) |
**Why.** The bare `dev.frust` package belongs exclusively to the embedding
module (`docs/CODE_STANDARDS.md`'s Plugin Conventions); this plugin held a
time-boxed exception only because a Kotlin package is baked verbatim into a
JNI export's mangled symbol name. Shipping a real Gradle library module — the
shape every other plugin already uses — ended the exception, and moving the
package *had* to move all four export symbols with it.
**What to do**, in this order:
1. **Delete your copies** of `FrustNativeControlFactory.kt` and
`FrustNativeListener.kt` (plus the `dev/frust/` directory they sat in, if
nothing else of yours lives there). They are dead weight now, and worse
than dead — see the symptom below.
2. **Apply §3's module wiring**: the `include(":frust-native-widgets")` +
`projectDir` lines in `android/settings.gradle.kts` and the
`implementation(project(":frust-native-widgets"))` line in
`android/app/build.gradle.kts`. The TUI's Add Plugin dialog makes both edits
idempotently.
3. Rebuild. Nothing of yours ever referenced either class by name, so there is
nothing else to change — no manifest edit, and no keep rule to add (the
module ships its own `consumer-rules.pro` for the new names). A keep rule
*you* added for the old `dev.frust.FrustNative*` classes can go too; it
matches nothing once the copies are deleted.
iOS is unaffected: there was never anything to copy there (§4).
**The symptom if you skip this.** Nothing fails at build time — Kotlin happily
compiles an `external fun` whose native symbol does not exist, and no cargo
gate compiles Kotlin at all (§5) — so it lands on device the first time a
native control is created, and it looks like **a blank slot with no obvious
cause**:
- **Copies kept, module not wired:** the host resolves the factory by the new
FQCN, your app only has the old `dev.frust.` one, so `Class.forName` fails —
`logcat` (tag `frust`) shows `platform-view factory
'dev.frust.nativewidgets.FrustNativeControlFactory' failed to resolve` and
the slot is marked dead. Every native control is an empty hole.
- **Anything that still reaches a stale copy** dies with `UnsatisfiedLinkError:
No implementation found for … Java_dev_frust_FrustNativeControlFactory_nativeCreateControl`:
the JVM resolves a `native`/`external` method by mangled name alone, and that
name is no longer in the `.so`. The host wraps every factory call in `catch
(Throwable)`, so this too becomes a dead slot plus one `logcat` warning
rather than a crash — which is exactly why the cause is not obvious from the
screen.
Both failures are silent by design (a misbehaving platform-view factory must
never take down the frame loop), so `adb logcat -s frust` is the place to
confirm which one you have.
## License
Licensed under either of MIT or Apache-2.0 (SPDX: `MIT OR Apache-2.0`), at your
option. See `LICENSE-MIT` and `LICENSE-APACHE` beside this README.