NativeDesktop

Patterns

How an nd app is put together: system structure, entities, actions, controlled values, overlays, and time.

How an nd app is put together, and the shape that every new screen should take. The short list of hard rules is in donts.md.

Let the OS draw its own structure

Big structural pieces are intents. You say what the window has, and each backend maps it to the highest-level component that its OS offers. The next OS release restyles the app with no change to your code.

You writemacOS drawsLinux and Windows draw
scaffold().sidebar(sidebar())NSSplitViewItem sidebar with a source listGPUI sidebar on the skin's sidebar surface
scaffold().inspector(inspector())the inspector split itema right pane
toolbar()NSToolbar with system itemsa header row
source_list(id) / source_tree(id)source-list NSOutlineViewdrawn tree on the sidebar surface
titlebar() + window_controls()real traffic lights in the slotcaption buttons, or nothing on Hyprland
.context_menu(..)NSMenumenu in the overlay layer
window.open_sheet(..)a window sheeta dialog over a scrim
window.alert(..), window.confirm(..)NSAlerttask dialog or portal
glass()NSGlassEffectView (26+), NSVisualEffectView belowMica, Acrylic or compositor blur

Do:

scaffold()
    .toolbar(toolbar().title("Inbox").sidebar_toggle()
        .item(icon_button("new", Icon::SquarePen).tooltip("New Message").dispatch(NewMessage)))
    .sidebar(sidebar().min_w(px(180.)).child(
        source_list("folders")
            .section("Mailboxes", [source_item("inbox", "Inbox").icon(Icon::Inbox).badge("12")])
            .selected(self.folder.clone())
            .on_select(cx.listener(|this, e: &SourceSelected, _, cx| {
                this.folder = e.id.clone();
                cx.notify();
            })),
    ))
    .content(self.detail.clone())

Don't:

// A sidebar replica: a fixed-width div with a hand-picked grey and hand-drawn rows.
h_flex()
    .child(v_flex().w(px(220.)).bg(rgb(0xf2f2f7)).children(folders.iter().map(row)))
    .child(detail)

The replica never picks up the system sidebar material, selection style, keyboard behavior, or accessibility, and it trips colour-literal and scroll-list in nd check.

A custom titlebar is WindowOptions::main("main").titlebar(Titlebar::Custom) plus a titlebar() element. titlebar() reserves the side and size of the OS controls as insets (traffic lights on the left on macOS, caption buttons on the right on Windows, nothing on Hyprland), so you never pad content past the controls by hand. To move the traffic lights, use window_controls().at(point(px(18.), px(20.))). To let toolbar content carry them, use window_controls().embedded().

State lives in entities

Actions, keys and menus

Commands are actions. One action drives the button, the shortcut, and the menu item, so they never disagree.

actions!(mail, [NewMessage, DeleteMessage]);

cx.bind_keys([KeyBinding::new("secondary-n", NewMessage, Some("Mail"))]);

div().key_context("Mail").on_action(cx.listener(|this, _: &NewMessage, window, cx| this.compose(window, cx)))

secondary is Cmd on macOS and Ctrl elsewhere. Buttons fire actions with .dispatch(Action), and set_menus(cx, ..) builds the menu bar from the same actions.

Controlled values

The app owns every value that a control shows. A control reports a change as an event, and the app stores the value and renders it back. Feeding the reported value straight back is always safe. nd doesn't set a native control to the value that it already holds, and it drops events that fire while it applies a value. You get no echo loop and need no "ignore the next change" flags.

slider("volume", self.volume).range(0.0..=1.0).on_change(cx.listener(|this, e: &SliderChanged, _, cx| {
    this.volume = e.value;
    cx.notify();
}))

To refuse a change, leave the state alone. The node emitted an event, so nd reaffirms it on the next frame, and the control shows the app's value again. If you write a native widget yourself, you keep the same contract with Synced<T>. See widgets.md.

Text fields are native-owned while they have focus. TextFieldState::set_text from the app waits for IME composition to finish, and TextFieldState::last_event() says whether the user (TextEvent::Edited) or the app (TextEvent::Replaced) made the last change.

Floating things are overlays

Popovers, panels, and tooltips are elements in the tree: popover(id), panel(id), and tooltip(). The parent is the anchor. The overlay takes no room in the parent's layout and closes with it. The app owns dismissal: on_dismiss receives Dismissed { reason }, and the app stops rendering the overlay.

.when(self.popover, |d| d.child(
    popover("filters").p_3().child(filters)
        .on_dismiss(cx.listener(|this, _: &Dismissed, _, cx| {
            this.popover = false;
            cx.notify();
        })),
))

Modality decides what happens to input outside the overlay. LightDismiss (the popover default) closes on an outside press and consumes it. Modal (the panel default) consumes it and stays open. Passive (tooltips) takes no input. Input inside an overlay never reaches what's under it, including web views. Menus, the command palette, toasts, and sheets are built-ins on the same layer. An absolutely positioned div, or GPUI's deferred/anchored used as a popup, leaks clicks and hover into what's beneath it and fails floating-element.

Async and time

Platform differences

Use intents and the .on_macos(..) family, never cfg!(target_os) in view code. When a capability might be missing on a target, ask: nd::platform::has(Feature::MultiWindow). cfg belongs in native widget modules (src/native/, or files named appkit.rs, swift.rs, win32.rs, wayland.rs). Anywhere else, it needs a reason comment (cfg-target-os).

Files and processes

Code and scripts delete only what they created, by a path that they built and checked. Never run rm -rf "$DIR/" or remove_dir_all(dir) on a variable that can be empty or point outside a directory that you own. Keep one helper that refuses an empty path and anything outside your root, the way nd's own CLI only clears directories under target/nd. Never run pkill -f <pattern>, because it matches other agents' processes too. Stop what you started by its pid.