Mirror the shared Swift rules canon (rules-swift, commit 4ebb892) into docs/rules-swift/ as a read-only reference alongside the repo's tailored docs/rules/. PROVENANCE.md records the source, commit, and what was included or excluded; the upstream repo's own VCS, CI, and lint tooling is omitted as machinery rather than rule content. Exclude the vendored tree from the style gate (scripts/check-style.sh) and the pre-commit hook: the canon's enforcement docs quote the forbidden attribution phrases to define them, the same reason the repo's own enforcement files are already excluded.
9.6 KiB
AppKit Views
How a Tiledown-style native editor writes its AppKit renderer: NSViewController
and NSView subclasses that are purely presentational over the framework-free
model (see pre-ui-layer.md). The renderer reads Surface values, forwards every
user action as perform(_ intent:), and re-renders on the model's change signal,
observed through withObservationTracking. It holds ZERO business logic and owns
ZERO state that is not view-local.
Core rules
- The renderer renders Surface state only: no business logic, no API calls, no domain decisions.
- Every user action (target/action, menu item, delegate callback) becomes a
perform(_ intent:)call; the renderer reports, it never decides. - Observe the model with
withObservationTracking(the AppKit idiom frompre-ui-layer.md), re-registering after each change, then re-pull the dirtySet<SurfaceArea>. - All layout and menus in code; no Storyboards, no XIBs, no
MainMenu.xib(buildNSMenuin the app delegate). - Use a diffable data source (
NSCollectionViewDiffableDataSource/NSTableViewDiffableDataSource) keyed by stable Surface identifiers. - Every control sets an accessibility label (
setAccessibilityLabel(_:)). - Appearance happens in one
color(for: token)map returningNSColor; never hard-code inline. - AppKit views recycle (
makeView(withIdentifier:),itemForRepresentedObjectAt:); reconfigure fully, stash no per-row state.
Decision tree: code belongs in the AppKit renderer?
Visual presentation (views, layout, animation) → YES
View-local UI state (drag in progress, split position) → YES
Reading a Surface to populate views → YES
Translating an action/menu/delegate callback into an Intent → YES (perform(intent))
Data transformation → NO (domain service, in the model)
Business logic, validation, enable/disable → NO (engine; reflect the surface flag)
Navigation / window routing decision → NO (engine sets a destination surface; the controller reflects it)
Network / persistence → NO (data layer, behind a seam)
Patterns
Entry point and menus in code
A code-only AppKit app has no storyboard and no @main, so the entry point is
main.swift and several steps the Xcode template normally hides must be written by
hand. This is the cumbersome part; you write it once per app.
// main.swift: AppKit with no storyboard needs an explicit entry point (no @main).
import AppKit
let app = NSApplication.shared
app.setActivationPolicy(.regular) // without this a tool-launched app has no dock icon or menu bar
app.mainMenu = buildMainMenu() // programmatic NSMenu; there is no MainMenu.xib
let window = TileListWindow.make(model: model)
window.makeKeyAndOrderFront(nil)
model.perform(.appDidLaunch) // lifecycle is an intent, not entry-point logic
app.activate() // bring the app to the front
app.run()
For lifecycle hooks (applicationDidFinishLaunching, termination) set an
NSApplicationDelegate via app.delegate before run(); keep it thin, it raises
intents and owns no behavior.
Binding the model with withObservationTracking
final class TileListViewController: NSViewController {
private let model: TileListRendererModel
private lazy var dataSource = makeDataSource()
init(model: TileListRendererModel) {
self.model = model
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable) required init?(coder: NSCoder) { fatalError("code-only UI") }
override func loadView() { view = NSView() } // all hierarchy built in code
override func viewDidLoad() {
super.viewDidLoad()
configureHierarchy()
observe()
}
// Re-register after every change: withObservationTracking fires once per registration.
private func observe() {
withObservationTracking {
render(model.listSurface, model.statusSurface)
} onChange: { [weak self] in
DispatchQueue.main.async { self?.observe() }
}
}
}
Action / menu / delegate to Intent
// CORRECT: target/action reports an intent
@objc private func reload(_ sender: NSToolbarItem) {
model.perform(.run(.reload))
}
// Menu validation reflects a surface flag the engine computed; it does not decide.
func validateMenuItem(_ item: NSMenuItem) -> Bool {
model.menuSurface.isEnabled(item.action)
}
// NSTableView selection to intent
func tableViewSelectionDidChange(_ notification: Notification) {
guard let id = dataSource.itemIdentifier(for: tableView.selectedRow) else { return }
model.perform(.select(id))
}
Lists via a diffable data source
private func makeDataSource() -> NSCollectionViewDiffableDataSource<Section, SurfaceID> {
NSCollectionViewDiffableDataSource(collectionView: collectionView) { [model] cv, indexPath, id in
let item = cv.makeItem(withIdentifier: .tile, for: indexPath) as! TileItem
item.render(model.tileSurface(id)) // item reflects a surface, owns no state
return item
}
}
private func applyListSnapshot(_ surface: ListSurface) {
var snapshot = NSDiffableDataSourceSnapshot<Section, SurfaceID>()
snapshot.appendSections([.main])
snapshot.appendItems(surface.orderedIDs, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)
}
Navigation / windows reflect a destination surface
private func render(_ list: ListSurface, _ status: StatusSurface) {
applyListSnapshot(list)
statusLabel.stringValue = status.message
switch model.navigationSurface.destination {
case .none: detailWindow?.close()
case .detail(let detail): presentDetail(detail) // reflect, do not decide
}
}
Accessibility
reloadButton.setAccessibilityLabel("Reload tiles")
reloadButton.setAccessibilityHelp("Fetches the latest tiles")
App setup (no storyboard, no XIB, and the network entitlement)
A code-only app drops the template scaffolding, so do by hand what the storyboard did implicitly, and grant network access in the right place:
-
No storyboard, no XIB, no
MainMenu.xib. Remove the storyboard from the target and build both the window and the main menu in code (app.mainMenu = buildMainMenu()). UI is created in code, always. -
main.swift, not@main. With no@NSApplicationMain/@main, the entry point ismain.swift: share the application, set the activation policy to.regular, install the menu, make the window key,activate(), thenrun()(see the entry-point pattern above). -
Network access is an entitlement, not Info.plist. If the app enables App Sandbox, network is denied by default. Grant it in the app's
.entitlementsfile, which Xcode's "Signing & Capabilities → App Sandbox → Network" checkboxes write, never in Info.plist:<key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.network.client</key> <!-- outgoing: you make requests --> <true/> <key>com.apple.security.network.server</key> <!-- incoming: you listen / accept --> <true/>network.clientis outgoing,network.serveris incoming; add only the direction the app actually uses. A non-sandboxed Mac app needs neither key.
Responder chain (route, do not decide)
AppKit's responder chain is plumbing, not logic. A first-responder handler translates the action into an intent and forwards it; it does not implement the behavior.
- An
@objcaction reaching a controller via the responder chain callsmodel.perform(...)and returns; the engine owns what happens. - Use
validateMenuItem/validateUserInterfaceItemto reflect engine-computed enablement from a surface flag, never to compute it inline. - Field-editor edits (
NSTextField/NSText) forward each commit as an.editintent over the document surface the model owns; the renderer keeps no separate copy of the text.
Identity and reuse (the AppKit trap)
NSTableView / NSCollectionView recycle views and items; treat each as a pure
function of the surface it renders.
- An item/cell MUST fully reconfigure from its surface in
render(_:); never assume residual state. - Cancel in-flight async in the reuse hook before the view is handed out again.
- Drive identity from stable
SurfaceIDs in the diffable snapshot, never from row indices. - Keep view-local state (a drag in progress, a split-view position) on the view, never in the model.
Validation checklist
- Zero business logic; every decision reflects a surface the engine set
- Every action/menu/delegate callback forwarded as
perform(_ intent:) - Updates driven by
withObservationTrackingre-registration over the change signal - All layout and menus in code; no Storyboards, XIBs, or MainMenu.xib
main.swiftentry point (not@main) withsetActivationPolicy(.regular)andactivate()- If sandboxed, network granted via
.entitlements(network.client/network.server), not Info.plist - Lists use a diffable data source keyed by stable Surface IDs
- Items/cells reconfigure fully on reuse; async cancelled before reuse
- Accessibility labels on every control
- Appearance only through the
color(for:)map (NSColor) - This is one of several renderers over the same model (see
pre-ui-layer.md)
Companion rules
pre-ui-layer.md: the framework-free model this renderer adapts; AppKit observes the change signal viawithObservationTracking.swiftui-views.md,uikit-views.md: the sibling renderers over the same model; build all three and compare (see../domain-first.md).view-models.md: responsibilities of the presentation layer the renderer binds to.colors.md,fonts.md: thecolor(for:)and font sources the renderer reflects.