NativeDesktop

Native widgets

How an app defines its own widget with the NativeWidget trait: a drawn version for every OS and a native view where the OS has one.

Write a widget as a plain function or RenderOnce struct when div, text and canvas are enough. Implement NativeWidget when the OS has a real control you want, such as a map, or when the widget must be a native view to behave like one. The framework lays out, focuses and z-orders both kinds the same way, and a call site cannot tell your widget from a built-in. Every built-in control in nd-components is written against this same trait.

The Places example has a map and a gauge. On macOS the map is MapKit's MKMapView through objc2 and the gauge is a SwiftUI Gauge (Swift). Elsewhere both are drawn.

On AppKit a widget with an objc2 or Swift implementation mounts as its own view, which the backend frames, measures and updates like a built-in control. Everywhere else, and on AppKit when a Swift widget's symbols are not in the binary, the element mounts its drawn tree inside a container that takes the element's id, layout style and semantics.

Example: Places. App-defined native widgets: an objc2 MapKit view and a Swift-hosted SwiftUI gauge, each with a drawn fallback. Source in examples/native-widget.
Places on macOS

Captured on macOS 27.0, 2026-10-14.

The trait

A widget names its props, its events, what assistive technology and automation should see, and its implementations:

pub struct MapWidget;

impl NativeWidget for MapWidget {
    type Props = MapProps;
    type Event = MapEvent;

    fn semantics(props: &MapProps) -> Semantics {
        Semantics::new(Role::Group).label("Map").value(format!("{} places", props.pins.len()))
    }

    fn drawn(props: &MapProps, emit: Emit<MapEvent>, window: &mut Window, cx: &mut App) -> AnyElement {
        drawn::render(props, emit, window, cx)
    }

    fn backends() -> Backends<Self> {
        let backends = Backends::drawn_only();
        #[cfg(target_os = "macos")]
        let backends = backends.appkit(appkit::MapKit);
        backends
    }
}

From examples/native-widget/src/map_view/mod.rs, line 49.

An element type wraps the props and implements NativeElement (base_mut and into_parts). NativeBase<W> carries the id, layout style and event handlers, and #[derive(IntoElement)] mounts it. Builder methods such as .zoom(..) and .pins(..) set props, and .on_region_change(..) registers an event handler. See map_view/mod.rs for the full set; the call site reads like any built-in:

.content(
    map_view("map", self.center)
        .zoom(self.zoom)
        .pins(pins)
        .size_full()
        .on_region_change(cx.listener(|this, e: &RegionChanged, _window, cx| {
            this.center = e.center;
            this.zoom = e.zoom;
            cx.notify();
        }))
        .on_pin_select(cx.listener(|this, e: &PinSelected, _window, cx| {
            if let Some(place) = this.places.iter_mut().find(|p| p.name == e.title.as_ref()) {
                place.visited = true;
                cx.notify();
            }
        })),
)

From examples/native-widget/src/main.rs, line 65.

The drawn fallback

The fallback in drawn.rs paints a 3x3 block of OpenStreetMap tiles around the centre with a canvas, draws the pins on top and pans on drag. It is a real map, with the attribution OpenStreetMap requires, and it is the version the web and Linux builds ship. Write it first: it defines the props and events, and the native version then has to match them.

The AppKit side

AppKitWidget creates an NSView, updates it when props change and reports its size:

fn create(&self, props: &MapProps, cx: &mut AppKitCx<MapWidget>) -> (Retained<MKMapView>, Self::State) {
    let map = unsafe { MKMapView::new(cx.mtm()) };
    let delegate = MapDelegate::new(cx.emitter(), cx.mtm());
    // MKMapView holds its delegate weakly; the node state keeps it alive.
    unsafe { map.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
    apply(&map, &delegate, None, props, cx);
    (map, delegate)
}

From examples/native-widget/src/map_view/appkit.rs, line 31.

AppKitCx gives the main-thread marker, emitter() for delegates the view installs, and programmatic(..). State is whatever you want kept beside the view; here it is the delegate, because MKMapView holds its delegate weakly. update receives the old and new props and applies the difference:

fn apply(
    map: &MKMapView,
    delegate: &MapDelegate,
    old: Option<&MapProps>,
    new: &MapProps,
    cx: &mut AppKitCx<MapWidget>,
) {
    let viewport: Viewport = (new.center, new.zoom);
    delegate.ivars().viewport.borrow_mut().apply(&viewport, |&(center, zoom)| {
        let region = MKCoordinateRegion { center: to_cl(center), span: span_for_zoom(zoom) };
        // Programmatic: the regionDidChange this triggers is reported, not emitted.
        cx.programmatic(|| unsafe { map.setRegion_animated(region, old.is_some()) });
        shown(map)
    });
    if old.is_none_or(|o| o.pins != new.pins) {
        set_pins(map, &new.pins);
    }
}

From examples/native-widget/src/map_view/appkit.rs, line 59.

measure returns the view's natural size. A map has none, so it returns zero and the app sizes it with flex or explicit dimensions.

Callbacks and controlled values

Two rules keep a native view from fighting the app's state.

No native callback calls app code. AppKit invokes delegates inside setters and layout: setRegion fires regionDidChange before it returns, while the app's view is mid-update. Emit queues the event, and the queue drains after the update, outside any borrow of the app's state. Call emit(..) from a delegate and never run app code there.

define_class!(
    #[unsafe(super(NSObject))]
    #[thread_kind = MainThreadOnly]
    #[ivars = MapIvars]
    pub struct MapDelegate;

    unsafe impl NSObjectProtocol for MapDelegate {}

    unsafe impl MKMapViewDelegate for MapDelegate {
        #[unsafe(method(mapView:regionDidChangeAnimated:))]
        fn region_did_change(&self, map: &MKMapView, _animated: bool) {
            let (center, zoom) = shown(map);
            // Busy while `apply` runs the setter, which records what the map shows itself.
            if let Ok(mut viewport) = self.ivars().viewport.try_borrow_mut() {
                viewport.report((center, zoom));
            }
            // Queued, never delivered inside this callback; dropped if a programmatic set caused it.
            self.ivars().emit.emit(MapEvent::RegionChanged(RegionChanged { center, zoom }));
        }

        #[unsafe(method(mapView:didSelectAnnotation:))]
        fn did_select(&self, _map: &MKMapView, annotation: &ProtocolObject<dyn MKAnnotation>) {
            if let Some(title) = unsafe { annotation.title() } {
                self.ivars().emit.emit(MapEvent::PinSelected(PinSelected { title: title.to_string().into() }));
            }
        }
    }
);

From examples/native-widget/src/map_view/appkit.rs, line 97.

A controlled value never echoes. The map is controlled: the app owns center and zoom, and the user's pan emits RegionChanged, which the app feeds back in. Without care that loops, and MapKit makes it worse by fitting any region to its aspect ratio, so the region it reports is never the one you set. Synced<T> holds what the native control holds. apply calls the setter only when the new value differs from the last one applied or reported, report records what the control produced, and the setter runs inside cx.programmatic(..) so the callback it triggers is dropped. Feeding the reported region back then causes no second setRegion.

/// What the map shows, in the props' own terms. MKMapView fits any region to its aspect ratio,
/// so the region it reports differs from the one we set; `Synced` remembers the reported one and
/// the app feeding it back causes no second `setRegion`.
type Viewport = (LatLng, f64);

From examples/native-widget/src/map_view/appkit.rs, line 20.

Do the same for any prop the user can also change: a slider's step snapping and a split divider's clamp are the same case.

Build and run

The map crate depends on objc2-map-kit only for macOS, and the native-widget package is named places with an m6 feature that gates the binary:

cargo run -p places --features m6

objc2 types are re-exported under nd::native::appkit, so no extra dependency is needed for AppKit itself.