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 write | macOS draws | Linux and Windows draw |
|---|---|---|
scaffold().sidebar(sidebar()) | NSSplitViewItem sidebar with a source list | GPUI sidebar on the skin's sidebar surface |
scaffold().inspector(inspector()) | the inspector split item | a right pane |
toolbar() | NSToolbar with system items | a header row |
source_list(id) / source_tree(id) | source-list NSOutlineView | drawn tree on the sidebar surface |
titlebar() + window_controls() | real traffic lights in the slot | caption buttons, or nothing on Hyprland |
.context_menu(..) | NSMenu | menu in the overlay layer |
window.open_sheet(..) | a window sheet | a dialog over a scrim |
window.alert(..), window.confirm(..) | NSAlert | task dialog or portal |
glass() | NSGlassEffectView (26+), NSVisualEffectView below | Mica, 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
- Create one entity for each piece of state that has its own lifetime (
cx.new(|cx| ..)). A view reads state inrender, changes it in handlers, and then callscx.notify(). cx.notify()re-renders only that view. Child entities are boundaries: split a screen into views so that typing in the editor doesn't rebuild the sidebar.cx.observe(&entity, |this, entity, cx| ..)reacts to another entity'snotify. Keep the returnedSubscriptionin the view, or.detach()it on purpose.- Handlers come from
cx.listener(|this, event: &E, window, cx| ..). Row renderers for lists come fromcx.processor(Self::row).
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
- To work off the main thread, use
nd::system::spawn(future, |output, cx| ..). The future runs on a worker. The closure runs on the main thread and can touch entities. - Time in a view comes from the frame clock:
window.now()(seconds, the frame's target present time) orcx.now(). Tier 1 tests move it withcx.advance_clock(1. / 60.), so anything driven by it is deterministic. - For wall-clock timestamps, use
nd::time::SystemTime, which also works in a browser. - Don't use
Instant::now,thread::sleep, orsmol::Timerin UI code (clock-in-ui). A sleep on the main thread freezes the window, and a wall clock in a view makes its test flaky.
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.