Swift
Add Swift to your app's macOS build when you need it, with a SwiftUI gauge as the worked example.
Most of AppKit needs no Swift. Rust calls it through objc2, and the map widget works that way. Add Swift only in these cases:
- The control you want exists only in Swift or SwiftUI, such as SwiftUI's
Gaugeor Swift Charts. - It's a SwiftUI control that Apple restyles with each OS release, so hosting it keeps your app current.
The rest of the app stays in Rust, and on other platforms, nd uses the widget's drawn version.
The Places example has a circular gauge written in SwiftUI and shown through NSHostingView.
Wire it up
To connect your app's Swift package to the Rust build:
- List the package in
nativedesktop.toml:
[macos]
swift_packages = ["swift/Widgets"]
minimum_system_version = "14.0"
entitlements = ["network.client"] # map tiles
From examples/native-widget/nativedesktop.toml, line 8.
- Call
nd::build::app()frombuild.rs, withndas a build dependency that uses thebuildfeature.nd newwrites this line into every template. On other targets, it does nothing.
nd::build::app();
From examples/native-widget/build.rs, line 5.
- Register the Swift view next to the drawn one with
Backends::swift:
pub struct GaugeWidget;
impl NativeWidget for GaugeWidget {
type Props = GaugeProps;
type Event = Infallible;
fn semantics(props: &GaugeProps) -> Semantics {
Semantics::new(Role::ProgressIndicator).label(props.label.clone()).value(SemanticValue::range(
props.value,
props.min,
props.max,
))
}
fn drawn(props: &GaugeProps, _emit: Emit<Infallible>, _window: &mut Window, cx: &mut App) -> AnyElement {
let theme = cx.theme();
let fraction = ((props.value - props.min) / (props.max - props.min)).clamp(0.0, 1.0) as f32;
let (track, fill) = (theme.border.subtle, theme.accent);
v_flex()
.items_center()
.gap_1()
.child(
div()
.relative()
.size(px(48.))
.flex()
.items_center()
.justify_center()
.child(
canvas(move |bounds, paint, _cx| {
let radius = bounds.size.width.min(bounds.size.height) / 2. - px(3.);
paint.stroke_arc(bounds.center(), radius, 0.0, 1.0, px(5.), track);
paint.stroke_arc(bounds.center(), radius, 0.0, fraction, px(5.), fill);
})
.absolute()
.inset_0(),
)
.child(text(format!("{:.0}", props.value)).font_weight(FontWeight::SEMIBOLD).tabular_nums()),
)
.child(text(props.label.clone()).text_size(theme.type_scale.caption).text_color(theme.text.secondary))
.into_any_element()
}
fn backends() -> Backends<Self> {
let backends = Backends::drawn_only();
#[cfg(target_os = "macos")]
let backends = backends.swift(SwiftWidget::named("Gauge"));
backends
}
}
From examples/native-widget/src/gauge/mod.rs, line 19.
nd::build::app() collects the framework's Swift packages and your app's own, and generates one aggregate SwiftPM package in OUT_DIR. It builds one static library and links it into the binary. It writes nothing into your source tree. Your app's Package.swift declares a plain static library with no dependencies. The build script passes the framework's own package (NDSwift) to the aggregate through ND_SWIFT_PATH.
The Swift view
The Swift side exports C functions with @_cdecl, in the same shape that the framework's own Swift uses:
createreturns a +1 retained view built from JSON props.updateapplies new props.measurereports the fitting size.
Props cross as JSON. That's fine, because a widget's props cross only when they change. GaugeProps has the same field names on both sides, and the Rust props derive serde::Serialize, which Backends::swift requires.
The name that you give SwiftWidget::named picks the symbols. "Gauge" becomes nd_widget_gauge_create, nd_widget_gauge_update, and nd_widget_gauge_measure. A name of several words joins them with underscores: "MapView" becomes nd_widget_map_view_create. The AppKit backend looks up the three symbols in the running binary the first time the widget mounts. If they're missing, because the package isn't in swift_packages or build.rs doesn't call nd::build::app(), the backend prints the symbol it wanted and draws the widget instead.
struct GaugeBody: View {
let props: GaugeProps
var body: some View {
Gauge(value: props.value, in: props.min...props.max) {
Text(props.label)
} currentValueLabel: {
Text(props.value, format: .number.precision(.fractionLength(0)))
}
.gaugeStyle(.accessoryCircularCapacity)
}
}
private func decode(_ json: UnsafePointer<CChar>) -> GaugeProps {
let data = Data(String(cString: json).utf8)
return (try? JSONDecoder().decode(GaugeProps.self, from: data))
?? GaugeProps(value: 0, min: 0, max: 100, label: "")
}
@MainActor
private func hosting(_ view: UnsafeMutableRawPointer) -> NSHostingView<GaugeBody> {
Unmanaged<NSHostingView<GaugeBody>>.fromOpaque(view).takeUnretainedValue()
}
/// Returns a +1 retained `NSHostingView`; the caller releases it.
@MainActor @_cdecl("nd_widget_gauge_create")
public func placesGaugeCreate(_ props: UnsafePointer<CChar>) -> UnsafeMutableRawPointer {
Unmanaged.passRetained(NSHostingView(rootView: GaugeBody(props: decode(props)))).toOpaque()
}
@MainActor @_cdecl("nd_widget_gauge_update")
public func placesGaugeUpdate(_ view: UnsafeMutableRawPointer, _ props: UnsafePointer<CChar>) {
hosting(view).rootView = GaugeBody(props: decode(props))
}
@MainActor @_cdecl("nd_widget_gauge_measure")
public func placesGaugeMeasure(
_ view: UnsafeMutableRawPointer, _ width: UnsafeMutablePointer<Double>,
_ height: UnsafeMutablePointer<Double>
) {
let size = hosting(view).fittingSize
width.pointee = size.width
height.pointee = size.height
}
From examples/native-widget/swift/Widgets/Sources/Widgets/Gauge.swift, line 14.
Pass universal state (enabled, tooltip, focus) into SwiftUI state yourself, because a hosting view isn't the control that the user sees.
Events don't cross from Swift yet. The C functions take no emitter, so a Swift widget's Event is Infallible. If your widget reports user input, write its AppKit side in objc2, like the map.
Toolchain
Swift and the macOS SDK come from the system Xcode or Command Line Tools, never from Nix. crates/nd/swift-toolchain.toml pins the minimum versions (Swift 6, SDK 26). When the installed major versions are lower, nd::build::app() fails with a message. The Swift runtime is the OS's own in /usr/lib/swift, which is present from macOS 14 on, so the example declares minimum_system_version = "14.0".
Inside the Nix dev shell, the build resolves the SDK with xcrun --sdk macosx --show-sdk-path and links with /usr/bin/clang, so you don't need to set SDKROOT.
When not to use Swift
- For a control that AppKit already has (
NSButton,NSTableView,MKMapView), use objc2. It needs no second toolchain, and the call goes straight from Rust. - For a look that you can compose from elements, write a composed widget, which runs on every OS.
- For anything that must also run on Linux or Windows, write the drawn version anyway. Swift only adds the macOS implementation.