frust-native-widgets 0.5.2

Real native controls (Android Views, UIKit, AppKit) for Frust apps, written from pure Rust and hosted as platform views.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# 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:

| Builder | Android view | iOS view | macOS view |
|---|---|---|---|
| `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.

| Platform | Today |
|---|---|
| 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);
native_button("Delete").on_press(move || {
    if let Err(err) = show_native_alert_into(delete_spec(), outcome) {
        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.

| Platform | Today |
|---|---|
| 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 presentation = show_native_sheet(share_spec().on_detent(move |detent| {
    expanded.set(detent == Detent::Large); // user drags stream here; not outcomes
}));
let handle = presentation.sheet_handle();
frust::spawn_local(async move {
    if let Ok(SheetOutcome::Action(id)) = presentation.await {
        chosen.set(Some(id));
    }
});
// later: handle.map(|h| h.select_detent(Detent::Large));

// 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:

| | v1 (hand copy) | now |
|---|---|---|
| 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.