NativeDesktop

Widgets

Which element to use, virtual lists, and app-defined native widgets that never echo.

Which element to reach for, how lists work, and how to add a widget the framework does not ship. Rules in short: donts.md.

Use a built-in first

Every built-in maps to the platform's own control where one exists (an NSButton, an NSSlider, an NSOutlineView) and draws a matching control elsewhere. Pick by intent:

NeedElement
Window structurescaffold, toolbar, sidebar, inspector, titlebar, window_controls, split, tab_view
Navigationsource_list (sections of items), source_tree (flat id/parent nodes, expansion, row actions), tab_strip, command_palette
Buttonsbutton, icon_button, link, button_group
Text inputtext_field, secure_field, search_field, text_area, combo_box
Choicesswitch, checkbox, radio_group, segmented, select, slider, stepper, date_picker, color_well
Settingsform, form_section, form_row, settings_group, switch_row, sheet_layout
Feedbackprogress, spinner, toast, banner, status_page, window.alert(..)
Datalist, table, grid, tree (virtual, see below)
Mediaimage, avatar, seek_bar, player_bar, lyrics_view, shelf, chat_bubble, composer
Surfacesglass, glass_group, webview (feature webview), native_surface

Icons are the Icon enum (Lucide names, SF Symbols on macOS). No inline SVG (inline-svg).

Stateful elements take an id in their constructor: text_field("title"), list("notes", ..), webview("page", &page). The id keeps the native view, its focus, IME state and scroll position when a sibling appears or disappears above it. Give every interactive element an id or a label: the id is its test id, and the label is its accessible name, so tests and screen readers both find it.

Lists: virtual, keyed, never a scroll div

Anything that can pass 50 rows is a list() (or table, grid, tree) over a ListState<K>. Rows render only near the viewport; on macOS the presenter is a real NSTableView.

pub struct Inbox {
    messages: Entity<Store>,
    list: ListState<MessageId>,
}

impl Inbox {
    fn new(store: Entity<Store>, cx: &mut Context<Self>) -> Self {
        let mut list = ListState::new(cx).follow(Follow::Tail).estimated_row_height(px(44.));
        list.set_items_versioned(store.read(cx).rows(), cx);
        cx.observe(&store, |this, store, cx| {
            let rows = store.read(cx).rows();
            this.list.set_items_versioned(rows, cx);
        }).detach();
        Self { messages: store, list }
    }

    fn row(&mut self, id: MessageId, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let message = self.messages.read(cx).get(id);
        text(message.subject.clone())
    }
}

// in render:
list("messages", &self.list, cx.processor(Self::row))
    .selection(Selection::single())
    .on_select(cx.listener(|this, e: &SelectEvent<MessageId>, _, cx| this.open(e.key, cx)))

Don't:

div().overflow_y_scroll().children(messages.iter().map(|m| row(m)))

That builds every row every render, loses the scroll anchor on updates and fails scroll-list.

Sorting stays the app's: table(..) reports SortChanged, the app re-sorts its keys and calls set_items again. Expansion in tree/source_tree is app state too, so an unrelated render never collapses a branch the user opened.

App-defined native widgets

A widget the framework does not ship (a map, a gauge, a platform picker) implements the same NativeWidget trait every built-in uses, and is called like any built-in:

pub struct MapWidget;

impl NativeWidget for MapWidget {
    type Props = MapProps;   // Clone + PartialEq: only changed props cross to the backend
    type Event = MapEvent;   // std::convert::Infallible when it emits nothing

    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
    }
}

Status: the trait and the element shape are fixed. Backends do not mount app-defined widgets yet (nd::native is at its call-site stage), so check the widget in a real window before relying on it.

Native callbacks never call app code

AppKit fires delegates from inside setters (setRegion calls regionDidChange, selectRowIndexes calls selectionDidChange). In an nd widget:

// update(): set only when the control does not already hold the value.
state.viewport.borrow_mut().apply(&(new.center, new.zoom), |&(center, zoom)| {
    cx.programmatic(|| unsafe { map.setRegion_animated(region_for(center, zoom), true) });
    read_back(map) // what the control holds now, after it normalised the value
});

// delegate: record what the user produced, then emit it.
self.ivars().viewport.borrow_mut().report((center, zoom));
self.ivars().emit.emit(MapEvent::RegionChanged(RegionChanged { center, zoom }));

apply skips the setter when the value equals what the control holds (last applied or last reported), and returns the normalised value when the control changed it (a map fitting a region to its aspect ratio, a slider snapping to a step) so the widget can report it. An app that feeds the reported value back therefore causes no second set and no loop. Test it: set a value the control normalises from inside the widget's own event handler and assert one event, one set.

Native surfaces

webview(id, &page) shows a WebView the app owns; unmounting parks the page instead of reloading it. native_surface(id, SurfaceSource::..) hosts other platform views. Both sit in the same layout and z-order as everything else, and overlays above them get the input, never the page.