Skip to content

lua-objc detailed project reference

This is opt-in reference material, not a start page. Use the repository README.md and src/README.md to locate the relevant heading or symbol before reading a section.

SwiftUI-like declarative UI from Lua scripts, backed by AppKit (NSView/NSWindow). Edit .lua files — no recompilation needed.

Goal: 100% SwiftUI coverage via Lua. Every control should be a proper AppKit-backed widget, never a hack (no Text "----" for separators or Text "[ ]" for checkboxes). If something looks like a placeholder, the missing widget must be built.

Apple UI design quality bar

Working is necessary but not sufficient. Every example and widget must look and behave like a polished macOS app. Use native AppKit controls, system metrics, semantic colors, system fonts, and SF Symbols; do not imitate system UI with text, emoji, hard-coded colors, or custom drawing when a native control exists. These rules summarize Apple's current Human Interface Guidelines:

  • Design principles: keep the interface focused, establish a clear hierarchy, use concise wording, and refine visual details instead of treating them as optional polish.
  • Designing for macOS: use the large display for useful content, maintain comfortable information density, support precise pointer and keyboard interaction, and let people resize and configure windows.
  • Layout: put the most important content near the top and leading edge, align related elements, use consistent spacing, respect the content area, and adapt gracefully throughout the supported window-size range.

Placement and hierarchy

  • The primary content must dominate the window and consume the remaining flexible space. Never leave a large empty region unless it communicates an intentional empty state.
  • Stacks add spacing between siblings, not implicit outer margins. Add explicit padding only around content that needs separation. Primary scrollable content such as a table normally reaches the content-area edges.
  • Put persistent, window-wide actions in the toolbar. Put actions that operate on one row or local section next to that content or in its context menu. Avoid duplicating the same action in the toolbar and body.
  • Keep passive status near the content it describes. Keep alerts, validation, and progress feedback close to the initiating action. Don't place critical controls only at the bottom of a macOS window, because that edge can be off screen.
  • Preserve reading order: top to bottom, then leading to trailing. Use visual weight, alignment, and grouping to communicate importance before adding decoration.

Native components

  • Toolbars: use NSToolbar and NSToolbarItem. Use a recognizable SF Symbol plus a concise label and accessibility description. Let the native toolbar render the item; don't replace an icon item with a custom text button.
  • Lists and tables: use succinct row text, descriptive noun or short noun-phrase headings, visible selection feedback, resizable columns, sorting when valuable, and alternating row backgrounds for wide multicolumn data. Cells must use native reusable cell views and align their content consistently. Table styles: plain / fullWidth (default) produce edge-to-edge tables for primary data content — they fill the container without padding and the standard column header provides the vertical baseline. sourceList is for navigation sidebars: hide the column header, add a section label above the table as a separate Text, and set style = "sourceList" for the vibrant blur-background appearance. inset adds rounded corners and automatic padding for grouped/settings-style lists that sit inside a larger form. Never use inset or sourceList for the main content table of a window. Never use plain / fullWidth inside a settings panel when inset is available.
  • Buttons: use native roles and bezel styles. Make the most likely safe action primary; never make a destructive action primary. Use precise verb labels.
  • Progress indicators: show progress only while work is occurring, prefer determinate progress when duration is known, keep indeterminate indicators moving, and generally avoid labeling a spinner. Refresh automatically when appropriate while still allowing an explicit refresh action when useful. Table loading state: When a list or table is fetching data, show a centered indeterminate NSProgressIndicator (spinner) in the scroll view's content area — this is the HIG-recommended pattern. Use showLoading() before data fetches begin and hideLoading() after the last row arrives. Never insert artificial ui.sleep() delays to simulate animation; let network latency provide the natural progressive-loading rhythm. Each table must activate the content-area spinner independently of the toolbar progress indicator, because the toolbar item is easy to miss while the empty content area communicates "nothing here yet."
  • Use menus, separators, split views, sidebars, search fields, toggles, and other real AppKit widgets for their intended roles. A visual approximation is a missing framework feature, not an acceptable sample implementation.
  • Let native container controls own the geometry they are designed to manage. In particular, NSSplitView owns split-pane frames, divider dragging, and proportional resizing. Do not reapply pane frames from the custom stack layout engine, seed dividers from hard-coded pixel widths, or subclass/override native split behavior without a demonstrated platform limitation. Lua layout may arrange content inside a pane after Cocoa resizes it, but it must not compete with Cocoa for ownership of the pane itself.

Typography, color, and accessibility

  • Typography: prefer system fonts and standard control font variants. Use size and weight sparingly to establish hierarchy; don't hard-code a custom font merely for decoration.
  • Color: use semantic dynamic system colors, preserve their meanings, and verify light, dark, and increased-contrast appearances. Never rely on color alone to convey state.
  • Accessibility: provide meaningful accessibility labels, keyboard navigation, sufficient contrast, and comfortable targets. On macOS, aim for the 28×28 pt default control size (20×20 pt minimum) and 13 pt default text (10 pt minimum). Verify the accessibility tree with Accessibility Inspector for new controls.

Required visual QA

Before declaring UI work complete:

  1. Launch every affected example and inspect an actual screenshot.
  2. Resize each window substantially smaller and larger; content must reflow or clip intentionally, never drift, overlap, or create accidental voids.
  3. Exercise loading, loaded, empty, selected, disabled, error, and long-text states that the component supports.
  4. Check toolbar icons and labels, table alignment and truncation, native selection appearance, focus behavior, and keyboard access.
  5. Check both light and dark appearance and avoid hard-coded geometry that breaks with longer text or localization.

Architecture

lua script  -->  require("AppKit")  -->  AppKit.dylib  -->  AppKit objects
                                        native API +     (NSWindow, NSView, etc.)
                                        embedded Lua

For iOS, require("UIKit") runs inside the Simulator host (LuaRuntime), which statically links the UIKit translation unit and streams UIKit.lua plus application Lua/assets from a Mac packager. build/UIKit.dylib is a compile-check, not the process that displays the phone UI. Both modules expose the same SwiftUI-like API but use framework-appropriate native controls. See iOS host and hot reload: a save reloads the running app without quitting.

  1. src/host.c — Tiny executable loader. Loads AppKit.dylib and invokes its lua_objc_main host entry point. macOS only.

  2. build/AppKit.dylib — Self-contained macOS Lua module and runtime. src/main.m includes focused bridge fragments from src/appkit/; together they own the ObjC bridge, layout engine, rendering services, and embedded lua/embedded/AppKit.lua declarative layer.

  3. iOS Simulator host — ios/LuaRuntime + luaopen_UIKitNative. No app Lua inside the .app. Packager streams Lua, templates, and assets; the host reloads in process. build/UIKit.dylib remains an optional SDK compile-check.
  4. apps/<app>/ — Lua Model, Controller, and views. No compilation step for Lua/asset changes.

The embedded AppKit layer provides SwiftUI-like functions (Window, VStack, HStack, Text, Image, Spacer, List). It translates to private AppKitNative._* calls that create real ObjC objects. The UIKit counterpart has the same API shape with UIKit-appropriate names (Label for UILabel, ImageView for UIImageView, etc.).

Data-driven apps

Apps may be described as data, after Lapis: an app.xml manifest, resources.xml, Lapis-style models over a bound store, routes whose data(state) the etlua views draw, flows of shared action code and a generic page controller. The full reference is data-driven.md; the modules are lua/data/manifest.lua, app.lua, model.lua, routes.lua, flow.lua, pagecontroller.lua and lua/ui/resources.lua.

Declarative components: the escape hatch beyond the eager native tree

The current API is declarative in syntax but eager in execution. Text, VStack, and the other constructors immediately create native objects, and a container immediately attaches the already-created child NSViews. This is a useful small architecture, but it is not the part of SwiftUI that makes view structure reactive.

In Swift, struct Foo: View does not inherit from a View class: a struct cannot inherit. It conforms to the View protocol and supplies a body. SwiftUI retains the description produced by body, reevaluates it when its dependencies change, and reconciles the new description with the mounted native view hierarchy. some View hides the concrete nested result type, while @ViewBuilder translates conditionals and other supported control flow into that result.

The corresponding first-class composition mechanism in Lua should be an ordinary function. It permits normal if, for, early returns, local values, and decomposition without inventing an inheritance system:

local function PresenterWindow(props)
    local request = props.request
    local session = request and PresentationSession(request)

    if session then
        return PresenterWindowView { session = session }
    end

    return InvalidPresenterCleanup()
end

local function PeopleList(props)
    local children = {}
    for _, person in ipairs(props.people) do
        table.insert(children, (PersonRow { person = person }))
    end
    return VStack(children)
end

Window {
    PresenterWindow { request = request },
    PeopleList { people = people },
}

A Lua table constructor is evaluated at runtime, so a tree written with table syntax is not inherently compile-time static. It can already contain expression conditions such as loading and Spinner() or Content(). Lua cannot, however, place statement-form if or for blocks directly inside a table constructor. Component functions are the simple, idiomatic answer. React follows the same model: a function uses JavaScript control flow and returns an element description; JSX itself is only syntax for that description.

The eager renderer already provides ForEach and Group for data-driven initial composition:

ns.VStack {
    ns.ForEach(people, function(person)
        return PersonRow { person = person }
    end),
}

ns.ForEach eagerly invokes its content function once per array item. ns.Group returns flattenable siblings when one item needs to produce several views. This removes repeated view declarations during initial construction, but it does not provide identity, diffing, or reactive reevaluation. A future If must receive lazy callbacks or Lua will eagerly build both branches.

Text / <Label> accepts wrapping="word" (default) or wrapping="character" for unbroken paths and identifiers. truncation remains independent and takes precedence when specified. lines="0" allows unlimited lines. AppKit retains the declared break mode through text changes and viewport resizing.

Retained etlua template boundaries

Application screens use etlua templates and partials. require("ui.template") provides an explicit retained boundary for state-dependent presentation:

local Template = require("ui.template")
local panel = Template.new(refs.panelHost, "apps/example/views/Panel.etlua", ns)
panel:update({items = model:items(), actions = {review = review}})

The host is a ref from another template. update retains the evaluated XML and plain bindings. Identical descriptions preserve native controls and route actions to the current callbacks. Changed descriptions render inside a fresh ns.Scope before replacing the old subtree and disposing its callbacks. A render failure preserves the previously mounted view. dispose() is idempotent; a current parent Scope also owns the mount. Root templates for mounts must produce a view, not a Window.

panel:child("slotRef", path) mounts a nested retained template into one of the panel's refs, inside the panel's scope, so a structural re-render of the panel disposes the child too. Controllers never reach into template.scope.

Controllers that restyle retained refs use public values rather than bridge internals: ns.Font { size = 17, weight = "bold", design = "serif" } and ns.Color("secondary") / ns.Color("#F5E8D1"). Slider.value is the position on both platforms.

xml.describe, xml.describeFile, and xml.renderDescription separate etlua evaluation from native construction. Template evaluation does not mutate the caller's data. Application controllers own plain data, refs and actions; this shared renderer owns container replacement and lifetime.

This is explicit reconciliation at a whole-template boundary. It does not provide keyed child reuse or automatic reactive dependency tracking. ui.viewdesc.apply remains experimental and is not this renderer. Keep state that must survive changed boundaries in models or a stable outer template.

Know when the eager model has reached its limit

For initial construction, reusable component functions returning native userdata are sufficient. Prefer them over adding a nominal View base class or metatable solely for organization: Lua does not need Swift's static protocol machinery.

The crucial limitation is that such a function currently runs once. If its input or state changes later, the framework does not reevaluate the function. Callbacks must mutate native controls manually, and state-dependent structural changes require ad hoc removal and rebuilding. A View { body = ... } wrapper that merely invokes body once does not solve this limitation; it is only a more elaborate spelling of a function.

When implementing SwiftUI behavior, treat any of the following as evidence that the eager native-tree architecture may have hit the wall this section predicts:

  • view structure must change when state, bindings, or environment values change;
  • the same component must preserve local state while its body is reevaluated;
  • conditional branches or collections must insert, remove, move, or reuse native controls without bespoke imperative code for that one feature;
  • modifiers, lifecycle, environment, preferences, or navigation need to propagate through a logical component hierarchy;
  • implementing the feature would require every application callback to know and manually synchronize the underlying AppKit subtree.

At that point, do not keep extending the design with feature-specific mutation hooks merely to avoid an architectural change. Reconsider the representation: component functions should return lightweight, unevaluated descriptions rather than mounted NSViews, and a renderer should own evaluation and reconciliation. The intended progression is:

  1. Use ordinary Lua functions for reusable components and unrestricted initial if/for construction.
  2. Evolve eager ForEach/Group into lazy keyed descriptions, and add lazy If, when inline tree composition needs reactive identity.
  3. Separate logical view descriptions from mounted native AppKit objects.
  4. Add observable state/bindings and dependency invalidation so the appropriate component function is reevaluated.
  5. Add stable identity and keyed reconciliation so native widgets and component state survive updates where their logical identity is unchanged.
  6. Layer environment, lifecycle, preferences, navigation, and animation on that retained logical graph.

This is not a mandate to build a virtual tree for every small missing control. A new leaf widget with stable structure should still use the existing direct AppKit bridge. It is a decision rule for preserving the project's 100% SwiftUI coverage goal: when SwiftUI semantics require reevaluating structure, the framework must evolve toward function components plus a retained description and reconciliation engine instead of declaring the behavior impossible under the current eager tree.

Bridge (C ↔ ObjC)

Each ObjC object is wrapped in a Lua full userdata with a metatable (nsview or nswindow). The __gc metamethod calls CFRelease to balance the CFBridgingRetain done at creation time — ARC-compatible memory management.

Key bridge functions

Function Creates / acts on
_window(title, w, h) NSWindow
_vstack() NSView (vertical layout)
_hstack() NSView (horizontal layout)
_text(str, size, weight) NSTextField (non-editable label, optional font)
_image(path) NSImageView
_button(title, callback) NSButton (push button, stores callback in registry)
_toggle(label, is_on, callback) NSButton (checkbox, stores callback in registry)
_slider(min, max, value, callback?) NSSlider
_stepper(min, max, increment, value, callback?) NSStepper
_picker(options, selectedIndex, callback?) NSPopUpButton
_actionButton(title, subtitle, symbol, style, detail, callback) LuaActionButton (compound button)
_systemImage(symbol, description, size, weight, color) NSImageView with SF Symbol
_textView() NSScrollView wrapping NSTextView + SyntaxTextStorage
_textViewGetText(textView) Reads NSTextView.string
_textViewSetText(textView, str) Sets NSTextView.string
_textViewOnChange(textView, callback) Registers NSTextDidChangeNotification observer
_textViewSetLanguage(textView, lang) Sets syntax highlighting language
_textViewSetWrapMode(textView, wrap) Toggles word wrap; default is OFF (horizontally scrollable)
_separator() NSBox (separator line)
_add(parent, child) calls addSubview:
_layout(view, width) recursive frame-based layout in C
_tableview(columns, w, h) NSScrollView wrapping NSTableView + LuaTableViewSource
_timer_after(delay, callback) one-shot NSTimer
_toolbar_item(window, id) Finds an NSToolbarItem for generic property access
_show(window) orders window front (run loop in main())

Layout

Layout is done recursively in C (layout_recursive). Containers tagged via objc_setAssociatedObject ("vstack" / "hstack") lay out their children top-to-bottom or left-to-right with 8pt sibling spacing and no implicit outer padding. Ordinary HStack, VStack and ZStack use intrinsic content size, including zero size for empty stacks. Flexibility is inherited from children on each axis; Spacer grows along its parent stack's main axis, and native scroll/tab containers consume available space. Use fillWidth, fillHeight, or flexGrow explicitly when content must expand. flexGrow distributes space along the parent HStack's horizontal axis or VStack's vertical axis; it does not stretch the cross axis. Use fillWidth or fillHeight for explicit expansion on that axis. HStack centers children vertically by default, including nested text stacks at their intrinsic height. Hidden children do not contribute sibling spacing. Stack overlays can use allowsHitTesting = false to let input reach controls underneath; native hit testing still owns the remaining hierarchy. On UIKit, as in SwiftUI, a stack's hit shape is its content: visible children such as Text and Image claim touches, while empty padding passes them to siblings behind the stack unless the stack has a background or gesture.

Text measures the current native content under the parent's width proposal, including wrapping. Previous frames are never an intrinsic minimum. HStack negotiates constrained widths and native scroll containers measure their documents independently from the viewport. Explicit zero dimensions remain zero. AppKit uses bottom-left native coordinates internally; asymmetric padding still has the same top/bottom meaning as UIKit. paddingLeading and paddingTrailing override paddingHorizontal independently (including explicit zero), resolve with the native layout direction, and participate in measurement and placement.

NavigationStack { content = view, title = "Title" } owns native page history: NSPageController on AppKit and UINavigationController on UIKit. Push with navigation:push(ns.HostingController(destination), title) and return with navigation:pop(). depth and currentController expose native state. UIKit's hidesNavigationBar = true hides the root page's bar; pushed pages receive native navigation titles and Back behavior. Application components must not create a new window to navigate.

For a pushed UIKit screen that owns the full bottom edge, pass { hidesTabBar = true } as the third argument to ns.HostingController(view, onDisappear, options) before calling navigation:push(...). This sets the destination controller's native hidesBottomBarWhenPushed property.

For model-driven routes, require("ui.navigation").Path stores plain values and maps each value.type to a destination builder. Pass the path and builders to NavigationStack { path = path, destinations = { message = function(value) ... end } }. Calling path:push(value), path:pop(), path:replace(value), or path:reset(values) drives native screen pushes and pops; each builder returns the destination view for that value. Use push when Back should return to the previous screen, replace for a one-way transition, and a sheet for a short task.

UIKit TabView accepts minimizeBehavior = "automatic", "never", "onScrollDown", or "onScrollUp" to set the native tab bar minimization behavior. AppKit window tabs continue to use public NSWindow tabbing APIs. UIKit tabs are built with the UITab API; <Tab role="search"> (SwiftUI Tab(role: .search)) becomes a UISearchTab, which iOS 26 sets apart as its own Liquid Glass button.

<TabAccessory hidden="true"><VStack id="nowReading" /></TabAccessory> inside a <TabView> is SwiftUI's tabViewBottomAccessory: UIKit's UITabBarController.bottomAccessory, a glass capsule above the tab bar that moves inline beside the minimized bar while content scrolls. Toggle it with tabs.accessoryHidden = false (tabViewBottomAccessory(isEnabled:)); the content keeps its state and refs while hidden. macOS has no tab-bar accessory, so AppKit retains the view for its refs without showing it.

ns._documentRead(name) / ns._documentWrite(name, text) keep small UTF-8 documents (settings, autosaves) under a relative name that cannot escape the root: the app sandbox's Documents on iOS, ~/Library/Application Support/lua-objc/Documents on the Mac. ns._jsonEncode(value) is available on both platforms (src/shared/lua_documents.m). ns.applicationSupportDirectory(name) (AppKit) returns an app's own folder in Application Support, created on first use, as NSFileManager resolves it (a sandboxed build gets its container). Use it for data an app keeps between launches that is neither a document nor regenerable; name is one path component.

TextField { value = "", placeholder = "Command", onChange = function(value, field) end, onCommand = function(command, field) return command == "submit" end } uses native editing events on both platforms. submit represents keyboard Return; return true to handle the command. Programmatic .text updates do not synthesize user edits.

Lua API — Views

Each function returns an ObjC userdata. Lua's parenthesis-free calling convention makes the SwiftUI-like syntax possible: fn "arg" == fn("arg"), and fn { ... } == fn({...}).

Scripts start with local ns = require("AppKit") (macOS) or local ui = require("UIKit") (iOS). All widget functions are namespaced under the module name.

Window{...}

Creates an NSWindow. Table keys:

Key Type Default Description
title string "Window" Window title
width number 480 Content width in points
height number 360 Content height in points
transparentTitlebar bool false Full-size content, transparent title bar
hideTitle bool =transparentTitlebar Hide title text in transparent mode
sidebar view none Semantic NSSplitViewItem sidebar; requires content
content view none Main view paired with sidebar in NSSplitViewController
detail view none Optional detail pane added as a third native split item
detailWidth number none Fixed width of the detail pane (an inspector)
contentWidth number none Width of the content column when the detail pane is flexible (SwiftUI navigationSplitViewColumnWidth)
contentAccessory view none macOS 26 top accessory spanning only the content split
sidebarWidth number 240 Preferred native sidebar width
orientation "landscape" "portrait" every orientation the app allows UIKit: locks the screen to it (supportedInterfaceOrientations); AppKit ignores it
tabbingMode "automatic" "preferred" "disallowed" AppKit default Native NSWindow tab behavior
tabbingIdentifier string AppKit default Groups compatible native window tabs
toolbar {{id, label, icon?, tooltip?, action?}} none Native NSToolbar items; id = "toggleSidebar" uses AppKit's standard sidebar command
toolbarLabels bool false Show labels below toolbar icons
toolbarContentDividerAfter string none Continue a vertical content split divider through the toolbar after this item ID

Toolbar item descriptors also accept visibilityPriority to control native overflow ordering. Use id = "flexibleSpace" (or <ToolbarSpacer /> in XML) for AppKit's standard flexible space item; the native toolbar distributes space and handles overflow. Glass buttons use the system button configuration, and GlassEffect wraps content in the platform's native glass effect.

The array part of the table holds child views (added to a VStack inside the window's content view). After building the view tree, calls bridge._show(win) — the run loop starts in main() after the script returns.

When sidebar and content are present, Window instead installs a native NSSplitViewController. AppKit gives the semantic sidebar its current system appearance—including floating glass on macOS 26—and a contentAccessory occupies only the content column. Supplying detail adds a third sibling split item, allowing a tracking toolbar separator to create a native titlebar section boundary between content and detail. In this form, transparentTitlebar defaults to true. A toolbar item with id = "toggleSidebar" uses AppKit's standard first-responder action. The workspace pairs it with a native tracking separator so the button follows the semantic sidebar divider.

Use a semantic window sidebar for navigation, and put the source-list table inside a small native stack when the sidebar needs a search field or heading. The window owns the sidebar width and split geometry; the table only owns its rows and selection.

local search = ns.SearchField {
    placeholder = "Search",
    accessibilityLabel = "Search items",
    controlSize = "extraLarge",
    onChange = function(query) filterRows(query) end,
}
local sourceList = ns.List {
    flexGrow = 1,
    style = "sourceList",
    header = false,
    columns = {{ id = "name", title = "Name" }},
}
local sidebar = ns.VStack {
    spacing = 12,
    padding = 12,
    search,
    ns.Text { "Navigation", weight = "bold" },
    sourceList,
}
local window = ns.Window {
    sidebar = sidebar,
    content = mainContent,
    sidebarWidth = 240,
    toolbar = {{ id = "toggleSidebar" }},
}

SearchField.controlSize accepts "mini", "small", "regular", "large", or macOS 26's native "extraLarge" size.

This is the pattern used by demo/mail and apps/stocks. Keep style = "sourceList" limited to navigation; primary data belongs in a plain or fullWidth table in the content column.

ns.Window {
    title = "My App",
    width = 600,
    height = 400,
    toolbar = {
        { id = "new",  label = "New" },
        { id = "save", label = "Save", action = function() print("saved") end },
    },
    ns.Text "Hello",
    ns.List { columns = ... },
}

ns.Window{...} returns its native window userdata. Use ns.ToolbarItem(window, id) when an item needs dynamic native properties, or ns.ToolbarProgress(window, id) to replace an action's symbol with a real indeterminate NSProgressIndicator while work is active:

local progress = ns.ToolbarProgress(window, "refresh")
progress:start("Refreshing data")
-- asynchronous work
progress:stop("Refresh data — last updated just now")

Use ns.addTabbedWindow(window, tabbedWindow, order?) to put two complete document windows in a native AppKit tab group. order is "above" (the default) or "below". ns.windowTabCount(window) reports the number of windows in the group, and ns.selectWindowTab(window) selects an existing document window in its group. Window tabs own complete content trees; use an inner NSSplitView when one document contains an editor and preview. The public NSWindow API owns all tab presentation; applications must not instantiate Finder's private NSTabBar or NSTabButton implementation classes.

Inline toolbar views measure their content automatically, including stacked title and subtitle labels. No fixed width or height is required. After changing nested text, call the toolbar root view’s layout() to update its measured size. Native AppKit owns item placement and overflow. Inline SearchField controls use NSSearchToolbarItem, including its native bezel and focus behavior.

Toolbar state such as enabled, view, label, and toolTip uses the native object's KVC-compliant properties directly. Semantic aliases live as accessors on exported Objective-C subclasses.

VStack{...} / HStack{...}

Layout containers. Children listed in the array part are stacked vertically or horizontally with 8pt sibling spacing and no implicit outer padding. Set padding = number explicitly when a group needs margins. Returns an NSView userdata that can be added as a child of any other container.

ns.VStack {
    ns.Text "Line 1",
    ns.Text "Line 2",
    ns.HStack {
        ns.Text "Left",
        ns.Spacer(),
        ns.Text "Right",
    }
}

A two-child HStack can set trailingMaxWidthFraction (0–1). When the trailing child's natural native width exceeds that fraction of the available inner width, both children are laid out vertically. The decision is shared by measurement and placement and recalculated on resize; hidden children are excluded. Zero disables the rule. Give the trailing group fillWidth = true for full-width placement below, and use FlowStack when its controls should wrap. Available on AppKit and UIKit, including XML and retained updates.

FlowStack{...}

Packs native child views into horizontal rows and wraps when the next child cannot fit the available width. Rows start at the leading edge; children are vertically centered within each row. spacing applies between items and rows, and padding adds explicit outer margins. Hidden children consume no space. Use fillWidth = true to wrap against the parent's available width. Resizing reflows the existing controls without recreating them. Available on AppKit and UIKit, including the <FlowStack> etlua tag. maxRows = 1 keeps only complete leading items that fit on one line; overflow items return when the view widens.

Lazy stacks, grids, and native reordering

<LazyVStack> and <LazyVGrid> use NSCollectionView on macOS and UICollectionView on iOS. Their etlua children are parsed up front, while native child views are made only as cells become visible. Both tags accept rowHeight for a fixed item height; <LazyVGrid> also accepts columns (default 2). Set spacing and normal layout attributes on the collection.

<LazyVGrid columns="3" rowHeight="64" maxWidth="infinity"
           reorderable="true" reorderContainer="reorderItems">
  <% for _, item in ipairs(items) do %>
  <Label text="<%= item.title %>" />
  <% end %>
</LazyVGrid>

reorderable="true" and reorderContainer="actionName" also work on ordinary <VStack>, <Grid>, and <FlowStack> containers. The named controller action receives a ui.reorder.Difference with one-based indexes. The model applies the difference, then the controller updates its retained etlua template. Native table/collection drag and drop provide previews and move animations; ordinary containers use platform drag interactions. Empty and one-item containers are valid. See demo/container-reorder/ and demo/lazy-reorder/ for complete MVC examples.

ScrollView{...}

Creates a native scroll view around one content view. It scrolls vertically by default, matching SwiftUI's ScrollView; set horizontal = true for a horizontal document (which disables vertical scrolling unless vertical = true is also supplied). Set contentWidth larger than the viewport to preserve the document width while the scroll view fills its parent. Use it for fixed-format rows such as a seven-day forecast.

ns.ScrollView {
  content = ns.HStack { fixedWidth = 772, ... },
  contentWidth = 772,
  contentHeight = 164,
  horizontal = true,
  fixedHeight = 176,
  fillWidth = true,
}

Carousels: containerRelativeWidth and scrollTargetBehavior

SwiftUI's containerRelativeFrame(.horizontal) and scrollTargetBehavior(.viewAligned) for App Store-style card carousels:

<ScrollView horizontal="true" vertical="false" scrollTargetBehavior="viewAligned">
  <HStack spacing="12" paddingHorizontal="16">
    <Button containerRelativeWidth="1" ...>…</Button>   <!-- one card per screen -->
    <VStack containerRelativeWidth="0.5" />             <!-- two per screen -->
  </HStack>
</ScrollView>

containerRelativeWidth is a fraction of the horizontal scroll view's visible width, less the content stack's own horizontal padding; it is re-resolved on every layout, so cards follow window and rotation changes. Outside a horizontal scroll view it has no effect. scrollTargetBehavior="viewAligned" settles a released drag on a child of the content stack (a flick advances at least one card); "paging" is UIKit paging by the visible width. Mac wheel and trackpad scrolling stays continuous, as in SwiftUI on macOS. Horizontal shelves deliver touches immediately (delaysContentTouches = NO) and hide iOS 26 scroll edge effects, which belong to the page's own scroll view.

scrollDismissesKeyboard is SwiftUI's modifier of the same name: interactively lets a downward drag pull the keyboard away with the finger, as Messages does, and suits any scroll view above a composer; immediately dismisses it as a drag begins; automatic (the default) and never leave it up. Views pinned to the keyboard layout guide follow the interactive drag. The Mac has no on-screen keyboard, so AppKit ignores it, as SwiftUI does.

scroll:scrollTo(target, animated, anchor) is SwiftUI's ScrollViewReader.scrollTo(_:anchor:). target is "top", "bottom", or the id of a view in the content; anchor is the viewport edge that view is brought to, "bottom" (the default) or "top". The offset is clamped to the content, so a top-anchored view near the end rests as far up as the content allows. The target is measured after pending layout, so a scroll issued right after new content is written travels to where that content is. An edge target without animation is also re-applied by the next layout, for a scroll view that has no size yet.

The first vertical scroll view in a page is registered as its content scroll view (setContentScrollView:forEdge:), so iOS 26 draws the scroll edge effect under the navigation and tab bars and collapses large titles while scrolling.

ForEach(data, content) / Group{...}

Use ForEach whenever sibling views share the same structure and differ only by data. The content function receives (item, index, count) and returns a native view or a Group of sibling views. Containers flatten the result without inserting an extra placeholder NSView.

local actions = {
    { title = "New", icon = "plus" },
    { title = "Open", icon = "folder" },
}

ns.VStack {
    ns.ForEach(actions, function(item)
        return ns.Button {
            title = item.title,
            systemImage = item.icon,
        }
    end),
}

ForEach is currently eager: it constructs native views once and does not diff, reuse, or reevaluate them when the source table changes.

HSplit{...}

Horizontal split view (NSSplitView with thin vertical divider). Children are divided equally across the available width. Useful for sidebar + content layouts.

ns.HSplit {
    ns.VStack { ns.Text "Sidebar" },
    ns.VStack { ns.Text "Content" },
}

Text "string" / Text{"string"}

Creates a non-editable, bezel-less NSTextField label. Both forms work, with an optional size and weight key for custom fonts:

ns.Text "Hello"                          -- default font
ns.Text { "Hello", size = 16 }           -- 16pt system font
ns.Text { "Hello", size = 18, weight = "bold" }  -- 18pt bold
ns.Text { "W", size = 58, fontName = "SnellRoundhand-Bold", design = "serif" }

fontName selects a face the OS ships by PostScript or family name (for example Snell Roundhand, Didot, Baskerville). It is typography, not a custom control: use it for display text such as an illuminated initial. An unknown name falls back to the system font with the given design, so text never disappears on a platform that lacks the face. In XML: <Label text="W" size="58" fontName="SnellRoundhand-Bold" design="serif" />. ns.Font accepts the same fontName key.

smallCaps="true" (ns.Font { smallCaps = true }) is SwiftUI's Font.smallCaps(): the OpenType smcp feature, for running heads and chapter labels. Faces without small capitals keep their lowercase.

accessibilityLabel on <Label> replaces what VoiceOver reads, for example "You: open mailbox" for a command set as a stage direction.

Paragraph{...} — long-form prose

Label is for UI text. Paragraph sets prose as a book does: selectable (for Look Up and copy), with explicit leading, optional justification and hyphenation, and an optional figure: a square picture at the leading edge that the first lines wrap around, as Zork Zero set each room's picture beside its description.

<Paragraph text="You are standing in an open field…" size="18" design="serif"
  lineSpacing="6" alignment="justified" hyphenation="true"
  figure="books/blackwood-horror/icons/gate.png" figureLines="3" />
Attribute Meaning
text the paragraph (view.text reads and writes it)
size, weight, design, fontName, italic, smallCaps, color body typography
lineSpacing extra points between lines (SwiftUI lineSpacing)
alignment leading, center, trailing or justified
hyphenation hyphenates at line ends (use with justified)
figure, figureLines (default 3) an image path set as a square N lines tall beside the first lines
revealedCharacters typewriter reveal: characters shown so far, counted as utf8.len counts them; -1 (default) shows everything
linkColor colour of linked words and the thicker dotted rule under them; defaults to the tint
<Hyperlink location length label> children the words a reader can act on; see Links below

The figure runs from the first line's top to the figureLinesth line's bottom and is as wide as it is tall, filled by its image; exactly those lines wrap beside it, keeping a gap, and a shorter paragraph still reserves them. The figure is an image view the paragraph places itself (view.figureView); it never joins the layout tree. It appears with the first revealed character. Empty text takes no space. Implementation: a non-scrolling UITextView / non-editable NSTextView on TextKit 1 with NSTextContainer.exclusionPaths (src/uikit/paragraph.m, src/appkit/paragraph.m).

Links. <Hyperlink> children mark runs of the text the reader can act on, as WPF sets Hyperlink inside a TextBlock. location (from 0) and length count characters as utf8.len does; <MenuItem> children are what the link offers. On UIKit, a tap opens the native horizontal edit-menu bar; the first three actions appear directly and extra actions sit behind a native chevron submenu. A short tap emits one light haptic (respecting Reduce Motion); long presses use native text selection and its standard editing menu. UIKit owns the bar height, material and page navigation:

<Paragraph text="A corroded brass plaque hangs askew on the gate.">
  <Hyperlink location="11" length="12" label="plaque">
    <MenuItem title="Examine plaque" action="examinePlaque" />
    <MenuItem title="Read plaque" action="readPlaque" />
  </Hyperlink>
</Paragraph>

The words are set in linkColor and take a thicker dotted underline in it; a tap opens the menu at the words. On UIKit a link is a tagged text item (UITextItemTagAttributeName) whose menu the text view presents itself; on AppKit it is an NSLinkAttributeName run whose click pops up an NSMenu. Links follow the typewriter reveal: a link is ruled, and can be tapped, only once typing has reached it. A retained template gives new links to the same native view. Headless tests read links with bridge._paragraphLinks(view) and choose an item with bridge._paragraphPerformLink(view, link, item). The shared link model, reveal and bridge are in src/shared/paragraph.m.

Typewriter reveal. revealedCharacters is what SwiftUI typewriter effects build with TextRenderer: the whole paragraph is laid out once, so words never jump between lines as they appear, and unrevealed characters are drawn clear. The paragraph measures as its whole text however little is revealed (0 included), so a page makes room for new prose once and a reveal never re-lays anything out: writing it only recolours the text, and retained templates patch it in place. Adventure Arena's reader drives it from SessionController:typeNext; when a command is sent the page grows by the whole answer and scrolls once to the command's line with scrollTo(id, true, "top").

Title "string"

Shorthand for Text{string, size = 22, weight = "bold"}. Useful for window headers.

Image "path"

Creates an NSImageView. The image is scaled proportionally, max width 400px. Paths can be absolute (/Library/Desktop Pictures/Beach.jpg) or relative to the working directory. darkPath names the image drawn under a dark appearance, like an asset catalog's dark variant; the view follows appearance changes: <Image path="tour/map-light.png" darkPath="tour/map-dark.png" />.

Button{...}

Creates an NSButton (rounded push button). The optional action key stores a Lua callback in the registry, fired via LuaButtonTarget + target-action. Callbacks receive the sender NSView as the first argument — Lua functions silently ignore extra arguments, so function() ... end and function(btn) ... end both work.

ns.Button { title = "Create New Script" }
ns.Button {
    title = "Create New Script",
    action = function() print("clicked") end
}

Button size and weight set the native title font, including in XML: <Button title="Applications" style="link" size="11" />. Omitting them preserves the standard system button typography. symbolSize sets an SF Symbol's point size independently of title text. weight applies to the title and symbol. Explicit symbol sizes use the standard medium symbol scale on both platforms; UIKit button configurations must not inherit a larger icon scale.

UIKit buttons use the native unbordered system appearance when style is omitted. Set style="bordered" or style="borderedProminent" when the design calls for a native button surface; style="plain" intentionally has no border. The XML renderer forwards these styles to UIButton.Configuration. An image-only action can use foregroundStyle="accent" for the system tint. For a circular send control, use the native arrow.up.circle.fill symbol with style="plain" and symbolSize="31"; the symbol supplies the circle without adding a bordered button platter inside the glass input capsule.

Use style="glass" for the native system glass button treatment. The UIKit bridge uses UIButtonConfiguration.glassButtonConfiguration; AppKit keeps the NSButton and embeds it in NSGlassEffectView.

GlassEffect{...}

Wrap content in the current system Liquid Glass effect. AppKit uses NSGlassEffectView; UIKit uses UIGlassEffect through UIVisualEffectView. Both platforms support style = "regular" or "clear". Both platforms support cornerRadius. UIKit configures the material with UICornerConfiguration; omitting the radius uses its native capsule shape. The effect does not clip its child content. Use clipsToBounds explicitly when clipping is part of the design. These APIs require macOS 26 or iOS 26. Do not replace them with blur and opacity layers.

<GlassEffectContainer spacing="14"> groups nearby glass surfaces using NSGlassEffectContainerView or UIGlassContainerEffect, matching SwiftUI's GlassEffectContainer. Its spacing controls when the surfaces begin to merge.

Inside a glass input capsule, use <TextField style="plain" /> so its native bezel and background do not draw a second border. The default text field keeps its platform bezel for standalone use.

<GlassEffect style="regular" cornerRadius="18">
  <VStack padding="16"><Label text="Native glass" /></VStack>
</GlassEffect>

<Menu> presents native UIMenu actions on UIKit and an NSPopUpButton menu on AppKit. Use systemImage and style="glass" for an icon-only glass trigger; imagePath can use an image file for the trigger, and for a <MenuItem>; each <MenuItem action="name" /> resolves name through the template's actions table.

The macOS menu bar is declared on the app's <Window>, like SwiftUI's .commands on a scene. The first window installs the standard menus even without <Commands>; a window that declares them replaces the menu bar.

<Window title="Diskmap">
  <Commands appName="Diskmap">
    <CommandGroup replacing="appSettings">
      <MenuItem title="Settings…" keyEquivalent="," action="settings" />
    </CommandGroup>
    <CommandMenu title="Storage">
      <MenuItem title="Refresh" keyEquivalent="r" action="refresh" validate="canRefresh" />
      <Separator />
      <MenuItem title="Empty Trash…" keyEquivalent="delete" modifiers="command,shift" action="emptyTrash" />
      <MenuItem title="Sort By">
        <MenuItem title="Size" action="sortSize" checked="true" />
      </MenuItem>
    </CommandMenu>
    <HelpTopic title="Empty the Trash" keywords="delete space" action="helpTrash" />
  </Commands>
</Window>
  • appName names the app menu, About, Hide and Quit, and the process. When omitted it comes from the entry folder (apps/adventure-arena → "Adventure Arena"). A bundled app's CFBundleName should match it.
  • Standard menus, in HIG order: app (About, Settings, Services, Hide, Hide Others, Show All, Quit), File (Close), Edit (Undo, Redo, pasteboard, Find), View (Show Toolbar, Customize Toolbar, Show Sidebar, Enter Full Screen), Window (Minimize, Zoom, Bring All to Front, window list) and Help (search field, "App Help"). Standard items send AppKit selectors through the responder chain, so AppKit validates and retitles them.
  • <CommandGroup replacing|before|after="placement"> edits a group named after SwiftUI's CommandGroupPlacement: appInfo, appSettings, systemServices, appVisibility, appTermination, newItem, saveItem, importExport, printItem, undoRedo, pasteboard, textEditing, toolbar, sidebar, fullScreen, windowSize, windowArrangement, help. Emptied File, Edit and View menus disappear.
  • <CommandMenu title> adds a top-level menu between View and Window.
  • <MenuItem> takes keyEquivalent (a character, or delete, forwardDelete, return, escape, tab, space, left, right, up, down), modifiers (default command), systemImage, checked, disabled and validate: an action returning enabled, checked, called whenever AppKit validates the item. Nested <MenuItem>s form a submenu; <Separator /> divides items.
  • <HelpTopic title keywords action> entries appear in the Help menu's search field beside matching menu items (NSUserInterfaceItemSearching).

Headless tests read the installed bar with bridge._mainMenuSnapshot(), choose an item with bridge._performMainMenuItem("Menu", "Item", ...) and query help search with bridge._searchHelp(query[, perform]).

WebView{...} and ui.webpage

WebView embeds the platform's WKWebView. Pass a URL directly or bind a WebPage from ui.webpage:

local WebPage = require("ui.webpage")
local page = WebPage.new("file:///path/to/content.html")
local view = ns.WebView { page = page, flexGrow = 1 }
page:loadURL("https://example.com")
page:evaluateJavaScript("document.title", function(result, err) end)
page:find("Example", { backwards = false, caseSensitive = false, wraps = true },
  function(found) end)
page:setPageZoom(1.25)

The page reports URL, title, progress, loading, and native back/forward state through page:observe(callback). It exposes loadURL, goBack, goForward, reload, stop, evaluateJavaScript, find, and setPageZoom. On AppKit, setMagnification controls viewport scale and <WebView allowsMagnification="true" /> enables native magnify gestures. find uses WebKit's page search and reports whether a match was found. XML templates can bind an object in render data with <WebView page="page" />. Use an embedded view for a real in-app browsing workflow; open a normal external link in the system browser.

SectorChart{...} and Gauge{...}

<SectorChart> is SwiftUI Charts' SectorMark: a pie or donut composed from native Arcs in a ZStack, on AppKit and UIKit alike (lua/ui/sectors.lua). Each <SectorMark value="…" color="…" label="…" /> becomes one arc; the first starts at 12 o'clock and marks advance clockwise in data order. innerRadius is the hole as a fraction of the outer radius (0 draws a pie) and angularInset is SwiftUI's: each sector gives up that many points at each side, so neighbours are twice it apart (1.5 makes a 3pt gap), as in SectorMark. The gap has parallel sides and stays that many points at any chart size, so a large chart does not grow wide wedges; sunburst rings are as far apart, at least 2pt. A mark's cornerRadius rounds its corners in points, like SwiftUI's .cornerRadius on a SectorMark. With an inset or a corner radius each Arc fills its sector (Arc inset and cornerRadius, src/shared/sector_path.m); without either it is a plain stroke. Non-positive values occupy no angle, a lone mark is a closed ring, and a chart without positive values draws its empty ring in quaternaryLabel. Any other child view is centered over the chart, typically a total in the hole. A donut offers that content the side of the square inscribed in its hole as maxWidth, so a label set at its largest size with minimumScaleFactor fits any hole:

<SectorChart width="196" height="196" innerRadius="0.7" angularInset="1.5" accessibilityLabel="Storage by category">
  <SectorMark value="58" color="systemBlue" label="Applications" />
  <SectorMark value="16" color="systemPurple" label="Developer" />
  <Label text="337 GB" size="34" minimumScaleFactor="0.38" lines="1" weight="semibold" design="rounded" monospacedDigit="true" />
</SectorChart>

A chart can be scalable="true": its sectors are laid out in diameter units (default 360) and the view takes the room it is given. Every arc fills the chart and draws its circle in those units (Arc diameter and fitDiameter), so the rings are centered on the view's narrower side, keep their aspect and grow with the container (flexGrow, maxWidth/maxHeight="infinity"). Pointer positions are mapped back to the same units. The Storage Map and Folder Map scale their rings to their pane this way.

Hovering selects as Apple's SectorMark sample does (WWDC23 "Explore pie charts and interactivity in Swift Charts", sample code Visualizing your app's data, StylesDetailsChart): the hovered sector keeps its opacity and every other sector fades to 0.3 of its own, without animation on macOS. In a sunburst the hovered sector's parents and children stay with it. The chart reports the sector to onHover(id), and the page names it in the hole, as the sample's center text does. Only the pointer and keyboard highlight a sector; a list beside the chart is a separate widget and never drives it. Changed marks, innerRadius, or angularInset rebuild the chart with fresh arcs in their final geometry. An unchanged chart stays in place. Shape and hover changes apply immediately without explicit or implicit layer animations (see retained-templates.md).

<Gauge value="0.4" tint="systemBlue" /> is SwiftUI Gauge with the linear capacity style: a read-only continuous-capacity NSLevelIndicator on AppKit (the control Finder and Disk Utility use for storage) and a tinted UIProgressView on UIKit. minValue/maxValue default to 0 and 1; values are clamped. thickness="9" draws the bar as SwiftUI's linear capacity Gauge does, a capsule track with a capsule fill that many points tall; AppKit's cell is 18 points at every control size, so the capsule is drawn by LuaLevelIndicatorCell while the view stays an NSLevelIndicator. UIKit's progress view is already a thin capsule and ignores it.

Marks can form a sunburst: <SectorMark id="xcode" parent="developer" ring="2" value="…" /> draws inside its parent's angle, sized by its share of the parent's value, on a ring outside the parent's; opacity lightens deeper rings. Give the chart onSelect="action" (action(id, clickCount)), onHover (action(id), nil on exit), onCenter (a click in the hole) and onBack (Delete: up a level, as on Treemap) to make it interactive, by pointer and by keyboard: arrows and Tab move the focus, which reports through onHover, and Return activates the focused sector through onSelect. Hit testing uses the same geometry that drew the arcs (Sectors.hit), through a transparent PointerView on top.

SceneView{...}: SceneKit scenes and games

<SceneView> (AppKit and UIKit, src/shared/scene_view.m) is SwiftUI's SceneView: an SCNView whose scene graph is <Node>, <Camera> and <Light> records in an etlua template. Records reconcile by id through the schema's updateRecords hook; game state moves nodes every frame through view.nodeStates poses; onFrame(view, dt), onKey(view, key, pressed), onSwipe(view, direction) and onTap(view) drive a game loop on the Mac and the iPhone. SceneKit scenes and games documents the attributes, the reconciliation and pose contracts, the test hooks and the architecture for games; apps/coin-quest is the reference game.

Treemap{...}

<Treemap> draws a squarified treemap from <TreemapNode id parent value color label detail hatched /> records (lua/ui/treemap.lua lays it out; the native LuaTreemapView only paints). Every cell keeps a margin (Treemap.metrics.margin, 2 pt) so no two rectangles touch and a child sits inside its parent's margin; a treemap with flexGrow and maxHeight="infinity" fills its container. Children nest inside their parent below a one-line header band holding the parent's label and right-aligned detail; a parent too short for the band shows no label. The layout emits each cell's labelFrame/detailFrame and omits labels that would not fit, so labels never overlap. Colors are muted semantic colors that lighten with depth, hatched marks reclaimable space with diagonal lines, and the selected or hovered cell gets an accent outline. onSelect(id, clickCount) and onHover(id) report the deepest cell under the pointer. It fills the offered width; give it a height (or a width and height); it relays out whenever its size changes.

<Treemap height="360" onSelect="drill" onHover="describe">
  <TreemapNode id="dev" value="39" color="systemPurple" label="Developer" detail="39 GB" />
  <TreemapNode id="derived" parent="dev" value="8" color="systemPurple" label="DerivedData" hatched="true" />
</Treemap>

Waveform{...}

<Waveform source="…"> draws a sound file (anything AVAudioFile opens) as its waveform under a tempo grid, with <WaveformMarker id time /> records as markers (src/appkit/waveform.m, LuaWaveformView). The file is decoded once to mono; each draw reduces only the dirty columns to min/max, so inside a horizontal ScrollView it costs what is visible. bpm, beatsPerBar (4) and division (snap steps per beat, 4; 0 places freely) set the grid: bar lines with numbers in a ruler band, beat and step lines when they are at least 6 pt apart. gridOffset is where bar 1 starts. Every other slice between markers is shaded and numbered from the file's start, skipping empty slices. Pressing empty space calls onAdd(seconds) snapped to the grid; pressing a marker selects it (onSelect(id)) and dragging moves it, snapped live, calling onMove(id, seconds) on release. onKey(key) gets keys while it has focus. playFrom, playTo and playState (stopped, playing, paused) show a playhead that Core Animation moves across the span in real time (no timer, no Lua per frame); pausing stops it where it is. A vertical wheel or a pinch calls onZoom(factor); the app sets the new width, and the view lays it out and scrolls so the time under the pointer stays under it (horizontal scrolling stays the scroll view's). selected, the grid and playback attributes patch in place and marker records update in place. It has no natural width: give it maxWidth="infinity" to fit or a width to zoom inside a horizontal ScrollView. apps/slicer uses it with the AudioFile plugin (src/plugins/audio/README.md).

<ScrollView horizontal="true" vertical="false" maxWidth="infinity" maxHeight="infinity">
  <Waveform source="loop.wav" bpm="140" division="4" maxWidth="infinity" maxHeight="infinity" onAdd="addCut" onMove="moveCut">
    <WaveformMarker id="a" time="1.714" />
  </Waveform>
</ScrollView>

Motion

There is no animation engine: no withAnimation, no transition. An Arc animates its own path, the welcome tour uses the system's push transition (ns._pushTransition), and everything else applies at once. See retained-templates.md for what patches in place, what rebuilds, layout after structural changes and keeping live updates steady.

Components

A new XML tag is an etlua template: <Tag>.etlua in the app's components/ folder, or in the framework's lua/components/. Before a template compiles, each component tag is replaced by the elements its template renders, so retained templates reconcile and animate a component's views like any others. An optional <Tag>.lua beside it declares props, records and a data(props, records, attrs) function; it never touches ns. The framework bundles ActivityRings, BarChart, CapacityBar and HeatmapGrid. See components.md and demo/component-gallery.

PointerView{...} and volumeCapacity

ns.PointerView { onClick = fn(view, x, y, clicks), onHover = fn(view, x, y) } is a transparent view reporting top-left coordinates, for charts that hit test in Lua. ns.volumeCapacity(path) returns total, available (free), important (free plus purgeable storage macOS releases on demand) and opportunistic, in bytes.

SwiftUI modifiers on XML views

Attribute SwiftUI AppKit UIKit
help="…" on any view .help(_:) toolTip ignored
maxWidth="…" on any view .frame(maxWidth:) proposes at most that width to its content when measuring same
monospacedDigit="true" on <Label> (with size) .monospacedDigit() monospacedDigitSystemFont monospacedDigitSystemFont
minimumScaleFactor="0.5" on <Label> (with size) .minimumScaleFactor(_:) the layout engine offers the label its width and it draws in a smaller size of its font, down to size × factor adjustsFontSizeToFitWidth + minimumScaleFactor
subtitle="…" on <Window> .navigationSubtitle(_:) NSWindow.subtitle; assign window.subtitle to update ignored
style="borderedProminent" on <Button> .buttonStyle(.borderedProminent) accent bezelColor prominent button configuration
controlSize (mini, small, regular, large) on <Button> .controlSize(_:) NSControlSize ignored
style="glass" / style="glassProminent" on <Button> .buttonStyle(.glass) / .glassProminent glass effect / prominent push button glassButtonConfiguration / prominentGlassButtonConfiguration (white type)
tint="…" on <Button> .tint(_:) prominent fill or content tint tintColor
tint="…" on any view .tint(_:) ignored tintColor, inherited by accent-drawn descendants
padding* on a leaf (<Label>, <Paragraph>, <Image>) .padding(_:) wrapped in a padded stack; the id still names the leaf same
color="#FBF9F4\|#1C1B1F" (any colour attribute) asset colour with a Dark variant dynamic NSColor dynamic UIColor

Colour values accept light|dark pairs of hex or semantic names, resolved against the drawing appearance. Use them for paper-and-ink themes and for tints that must stay legible on dark pages. | badgeKey on a list <Column> | .badge(_:) | trailing secondary text in the row | ignored |

<DisclosureGroup label="…" expanded="false"> draws AppKit's disclosure triangle beside a clickable label; both toggle the content. labelWeight and labelSize style the label. In a list of rows that lead with a symbol, indicatorWidth (with indicatorSpacing) names the rows' symbol column: the triangle is centered in it, and the label and the disclosed content start at the rows' text edge.

Toggle{...}

Creates an NSButton checkbox (a UISwitch row on UIKit). Keys: label (string), is_on (bool), action (function, optional), onChange (function, optional). action callbacks receive the sender NSButton — read btn.state == 1 to get the current toggle state. onChange (XML onChange="name") receives the new state as a boolean on both platforms.

ns.Toggle { label = "Show on launch", is_on = true }
ns.Toggle {
    label = "Show on launch",
    is_on = true,
    action = function(btn) print("toggled", btn.state) end
}

The native KVC state property returns 1 (on) or 0 (off); assign toggle.state = 1 to turn it on.

Slider{...}

Creates a native NSSlider. Keys: min (number, default 0), max (number, default 1), value (number), tickMarks (integer), allowsTickMarkValuesOnly (bool), and action (function, optional). Callbacks receive the sender, whose live value is slider.doubleValue.

ns.Slider {
  min = 0,
  max = 100,
  value = 60,
  tickMarks = 6,
  action = function(slider)
    print(slider.doubleValue)
  end,
}

When allowsTickMarkValuesOnly = true, AppKit quantizes doubleValue to the nearest configured tick.

Stepper{...}

Creates a native NSStepper. Keys: min (number, default 0), max (number, default 100), increment (number, default 1), value (number), wraps (bool), autorepeat (bool, defaults to AppKit's enabled behavior), and action (function, optional). Callbacks receive the sender; read stepper.doubleValue for the current value.

ns.Stepper {
  min = 0,
  max = 20,
  value = 4,
  increment = 1,
  wraps = true,
}

Picker{...}

Creates a native NSPopUpButton. options is a required array of display strings and value is the selected zero-based index (default 0). The optional action receives the picker; read picker.indexOfSelectedItem and picker.titleOfSelectedItem to identify its selection.

ns.Picker {
  options = { "Low", "Medium", "High" },
  value = 1,
  action = function(picker)
    print(picker.titleOfSelectedItem)
  end,
}

Separator()

Creates an NSBox with NSBoxSeparator style — a thin horizontal line. Use between sections instead of faking it with Text "---".

Spacer()

Empty 10×10 view used to push siblings apart in HStack/VStack.

PathView{...}

Creates a LuaPathView — a custom NSView subclass backed by an NSBezierPath that renders in drawRect:. The coordinate system is flipped (y-axis goes top-to-bottom) to match standard UI coordinate space. Path operations are persistent; call clear() to start a new path.

local p = ns.PathView { width = 200, height = 150 }
p:moveTo(10, 10)
p:lineTo(100, 80)
p:lineTo(180, 30)
p:setStrokeColor(0.2, 0.6, 1.0, 1.0)  -- r, g, b, a
p:setLineWidth(2)
Method Arguments Description
:moveTo(x, y) number, number Begin a new subpath at point
:lineTo(x, y) number, number Draw a straight line to point
:curveTo(cp1x, cp1y, cp2x, cp2y, x, y) 6 numbers Cubic Bezier curve to end point with two control points
:closePath() none Close the current subpath (line from current point to start)
:clear() none Remove all points from the path
:setStrokeColor(r, g, b[, a]) 3–4 numbers Set stroke color (0.0–1.0, alpha defaults to 1.0)
:setFillColor(r, g, b[, a]) 3–4 numbers Set fill color (0.0–1.0, alpha defaults to 1.0)
:setLineWidth(w) number Set line width in points

Curve{...}

Convenience constructor that builds a PathView and draws a line chart from a flat array of y-values. Values are auto-scaled to the view bounds. Supports optional area fill below the line.

ns.Curve {
    data          = {10, 12, 8, 14, 11, 16, 9},  -- y-values, left to right
    width         = 400,
    height        = 200,
    strokeColor   = {0.2, 0.8, 0.4},              -- r, g, b (alpha = 1)
    lineWidth     = 2,
    fillArea      = true,
    fillColor     = {0.2, 0.8, 0.4, 0.15},        -- optional explicit fill
    chartPadding  = 8,                             -- inset on all sides
}
Key Type Default Description
data {number} required Flat array of y-values to plot
width number 100 View width in points
height number 100 View height in points
strokeColor {r,g,b[,a]} accent Line color as RGBA table
lineWidth number 2 Stroke width in points
fillArea bool false Fill region below the line
fillColor {r,g,b[,a]} auto Fill color; defaults to stroke with alpha 0.15
chartPadding number 0 Inner margin on all sides

IDE example

The IDE example intentionally stays small: a native source-list OutlineView shows folder contents in the sidebar, and a native TextEditor displays the selected file in the content pane. See demo/ide/ and the agent-facing quickstart for the supported workflow.

CodeView

CodeView displays read-only source in a native text view. On UIKit it applies Lua-provided syntax rules to UITextView.textStorage; rules use ICU regular expressions and semantic UIKit color names. Matches are resolved from the earliest source position, with the longest token winning when two matches begin at the same character. regions let a language embed another language, as etlua embeds Lua between <% and %>.

local rules = {
  colors = { keyword = "systemPurple", string = "systemRed" },
  languages = {
    lua = { matches = {
      { pattern = "\\b(local|function|return)\\b", color = "keyword" },
      { pattern = '"[^"\\n]*"', color = "string" },
    } },
  },
}

Pass the rules from template data with syntaxRules:

<CodeView text="<%= source %>" language="lua" syntaxRules="rules"
  wrapMode="true" maxWidth="infinity" maxHeight="infinity" />

The rules value must be in the root render data. A match has pattern and color; a region has start, stop, nested rules, and an optional delimiter color key. UIKit’s implementation uses the public TextKit storage on UITextView, which Apple documents as the text backing store and styling surface. AppKit CodeView uses its existing SyntaxTextStorage language highlighting.

Lua API — List (NSTableView)

The List widget is the most architecturally significant part of lua-objc. It wraps NSTableView with a native ObjC data source, providing incremental row insertion without full-table reloads.

Design rationale

We evaluated five approaches for bridging NSTableView to Lua:

# Approach Reload on add? Extensibility Verdict
1 Push full array, reloadData Yes Low Violates the "no full reload" requirement
2 NSArrayController + Cocoa Bindings No (automatic) Medium Obscure KVO setup, hard to debug
3 Lua callback for every numberOfRows/viewForColumn No High Heavy cross-language traffic per cell
4 Lua metatable proxy intercepting writes No Medium Fragile, leaks ObjC/Lua duality
5 Native ObjC source + key-based cells and native-bound templates No High Clean, idiomatic, fast

We chose #5 because:

  • Data lives in ObjC (NSMutableArray<NSDictionary *>) — fast random access for tableView:viewForTableColumn:row:. No Lua round-trips during scrolling.
  • insertRowsAtIndexes:withAnimation: — rows slide in one at a time instead of flashing the whole table. ObjC gives us this for free; we just need to wrap it.
  • Column id → row dictionary key — minimal but extensible. Each column's id maps directly to a key in the row's NSDictionary. Cells default to native NSTextFields; key attributes (subtitleKey, levelKey, ...) select the table's other native cell kinds and name the row fields that fill them. A column hosts no view tree and nothing binds per attribute.
  • Methods via nsview metatable __index — instead of returning a Lua wrapper table (which can't be added as a subview), we attach the data source to the scroll view via objc_setAssociatedObject. The nsview metatable's __index function checks for this association and returns C functions for addRow, removeRow, clearRows, rowCount. This means the userdata returned by List{...} is both a valid NSView (addable to any container) and a method-bearing object.
List { ... }  returns  NSScrollView userdata
                              │
              objc_setAssociatedObject(scrollView, &kTableSourceKey, dataSource)
                              │
              tv:addRow{} → nsview.__index → bridge_tableview_add()
                              │
                     [dataSource addRow:]
                     [tableView insertRowsAtIndexes:withAnimation:]

List{...}

Creates an NSScrollView wrapping a view-based NSTableView with alternating row colors, a header, and a vertical scroller. Table keys:

Key Type Default Description
columns {{id, title, width?, minWidth?, alignment?}} (required) Array of column specs
width number 400 Scroll view width
height number 200 Scroll view height
header bool true Show the native table header
bordered bool false Draw a bezel around the scroll view
style "plain" "fullWidth" "inset" "sourceList" automatic NSTableView.Style (SwiftUI: .tableStyle())
alternatingRows bool true Alternating row background colors (SwiftUI: .alternatingRowBackgrounds())
gridLines "none" "horizontal" "vertical" "both" "none" Grid line style
data {{key=val}} {} Initial rows (array of string-keyed tables)
rowHeight number native default Height of each native table row
refresh function(list) nil See SwiftUI-like async refresh below
onSwipeLeading, onSwipeTrailing function(index, row) nil Native row action callbacks with one-based indices
swipeLeadingTitle, swipeTrailingTitle string Archive, Delete Native action labels
swipeLeadingRole, swipeTrailingRole "normal" or "destructive" normal, destructive Native action style
fullSwipe bool false UIKit first action triggers on a full swipe
List {
    width = 620,
    height = 350,
    columns = {
        { id = "name", title = "Name", width = 240 },
        { id = "role", title = "Role", width = 180 },
        { id = "dept", title = "Department", alignment = "trailing" },
    },
    data = {
        { name = "Alice Chen",   role = "Engineer",  dept = "Core" },
        { name = "Bob Martinez", role = "Designer",  dept = "UX" },
        { name = "Carol Park",   role = "Manager",   dept = "Eng" },
    }
}

A row with section = true is a SwiftUI Section header: a native group row showing its title (or name), styled by the list and never selectable. Use it for sidebar sections in sourceList lists; UIKit styles it as a footnote header.

Each column id must match a key in the row data tables. Cells render the string value of row[id]; numbers are converted to strings automatically. A <Column> takes no child XML; see "Extending: native cell kinds". Use <SwipeRow> for one native swipeable row inside a VStack. Both platforms use their table row action APIs, so the row keeps system gesture behavior and appearance. See docs/swipe_actions.md for XML and model callbacks. Column alignment can be "leading" (default), "center", or "trailing"; the header and reusable native cells use the same alignment. drawsBackground="false" on <List> or <OutlineView> lets the owning native container supply its background, for example inside a <GroupBox>. For tables and outlines, <Column controlSize="small"> uses the small system font. <Column loadingKey="calculating"> binds a boolean row field to a native spinning progress indicator beside the cell text. Supply the loading label in the column's usual row value; the spinner and label share the column alignment. Lua column specs use cell = {controlSize = "small", loading = "calculating"}. In a level column (levelKey), the spinner sits before the meter's valueKey text instead; see "Measured storage cells" in tableview_swiftui.md. lines="2" lets a text cell wrap onto a second line before it truncates.

Column sizing:

  • width — explicit column width in points. The column keeps this size.
  • No width — the column becomes a stretch column that fills the remaining viewport space. Multiple stretch columns split the space equally.
  • minWidth — minimum width in points (default 40). When the viewport is too narrow for all fixed columns at their minWidth, a horizontal scroller appears.
columns = {
    { id = "from",    title = "From",    width = 120 },
    { id = "subject", title = "Subject" },               -- stretches
    { id = "date",    title = "Date",    width = 60, minWidth = 40 },
}

List methods

These are available on the userdata returned by List{...}, exposed via the nsview metatable __index:

tv:addRow{ key = "value", ... }       -- inserts with slide-down animation
tv:removeRow(2)                        -- removes row at 0-based index
tv:clearRows()                         -- removes all rows (reloadData)
tv:replaceRows(rows)                   -- atomically replaces the datasource
tv:selectRow(0)                        -- selects and reveals a 0-based row
tv:activateRow(0)                      -- invokes native row activation
tv:onRowSelect(callback)               -- selection callback
tv:onRowActivate(callback)             -- double-click activation callback
n = tv:rowCount                        -- returns number of rows (read-only property)
tv:showLoading()                       -- shows centered spinner in content area
tv:hideLoading()                       -- removes the spinner
tv:refresh()                           -- auto-managed loading + refresh callback
tv:refresh(onDone)                     -- with optional completion callback

tv:refresh() wraps the refresh callback set on List{...}. When called: 1. Shows a centered NSProgressIndicator (content-area spinner) 2. Clears all existing rows via reloadData 3. Invokes the refresh callback in a coroutine — the callback yields for each async operation (e.g. fetch_json) and auto-resumes when data arrives 4. Hides the spinner when the coroutine completes

This is modeled on SwiftUI's .refreshable { ... } — the developer provides only the data-loading logic; the framework manages the loading indicator.

local list = ns.List {
    columns = { ... },
    refresh = function(l)
        for _, sym in ipairs(symbols) do
            l:addRow{ symbol = sym, price = fetch_price(sym) }
        end
    end,
}

-- Trigger refresh from toolbar:
{ id = "refresh", label = "Refresh", action = function() list:refresh() end },

-- Or call imperatively:
list:refresh()

If an optional on_done callback is passed to list:refresh(on_done), the callback fires after hideLoading() — useful for coordinating with ns.ToolbarProgress or other UI updates.

Methods can be called before ns.Window{...} shows the app (e.g. in initial setup) or during runtime (e.g. from a timer/event handler — see below).

Complete example

ns.Window {
    title = "Employee Directory",
    width = 640,
    height = 420,
    ns.VStack {
        ns.Text "Employees",
        ns.List {
            width = 620,
            height = 350,
            columns = {
                { id = "name", title = "Name" },
                { id = "role", title = "Role" },
                { id = "dept", title = "Dept" },
            },
            data = {
                { name = "Alice Chen",    role = "Engineer",   dept = "Core" },
                { name = "Bob Martinez",  role = "Designer",   dept = "UX" },
                { name = "Carol Park",    role = "Manager",    dept = "Eng" },
                { name = "Dave Johnson",  role = "Engineer",   dept = "Core" },
                { name = "Eve Williams",  role = "Analyst",    dept = "Data" },
                { name = "Frank Brown",   role = "Intern",     dept = "Eng" },
            }
        }
    }
}

Extending: native cell kinds

A cell is one of the table's native kinds, selected by the column's key attributes; a column hosts no view tree. A new appearance is a new kind in src/appkit/table_data_source.m: build its controls once in the reusable LuaTableCellView, fill them from the row dictionary, and add the key attributes to Column in lua/ui/xml.lua. The meter (levelKey, levelColorKey, valueKey) is the model; see "Cells are native kinds" in tableview_swiftui.md.

Dynamic updates at runtime

Since Window{...} calls [NSApp run] which blocks, dynamic data updates need an event source. A simple approach is an NSTimer exposed as a bridge function:

static int bridge_timer(lua_State *L) {
    double interval = luaL_checknumber(L, 1);  // seconds
    luaL_checktype(L, 2, LUA_TFUNCTION);
    int ref = luaL_ref(L, LUA_REGISTRYINDEX);

    [NSTimer scheduledTimerWithTimeInterval:interval repeats:YES block:^(NSTimer *t) {
        lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
        lua_pcall(L, 0, 0, 0);
    }];

    return 0;
}

Then users can write:

local tv = ns.List { columns = {...} }

ns.Timer(2.0, function()
    tv:addRow{ name = "New User", role = "Engineer", dept = "Core" }
end)

ns.Window { title = "Live Data", width = 600, height = 400, tv }

Conventions

CamelCase naming. New method names and property keys use camelCase to match SwiftUI conventions: addRow, clearRows, fixedWidth, flexGrow, transparentTitlebar. A few shipped compatibility names remain snake_case, including fetch_json and Toggle.is_on; do not silently rename them while touching unrelated code.

Data-driven repeated views. Never duplicate sibling view declarations whose structure is the same and whose content or styling varies only by values. Put those values in an array and render them with ns.ForEach; extract the repeated view into a component function when it improves clarity. Use ns.Group when one data item must emit multiple siblings, such as a row plus a conditional divider. Direct duplication is acceptable only when the views have genuinely different structure or behavior that a shared component would obscure.

Tab indentation. All source files (.m, .lua) use tabs for leading indentation, not spaces. Set your editor to display tabs at 4 columns wide.

# To convert spaces to tabs in any file:
perl -i -pe '1 while s/^(\t*)    /$1\t/' file

No magic numbers. Every numeric literal that controls visual appearance or layout must be a named #define constant in the constants section at the top of src/main.m. This includes spacing, font sizes, dimensions, corner radii, alpha values, point sizes, and default widths/heights. Group new constants with the existing category and follow the k prefix naming convention. When tuning a value, change the constant — never hard-code a number in layout code. Loop bounds, array indices, arithmetic factors (e.g. division by 2 for centering), and system API constants (e.g. kFSEventStreamEventIdSinceNow) are exempt.

// existing constant categories:
//  Table / Outline cells   kTableCellImageWidth    kTableColumnMinWidth
//  ActionButton             kActionBtnSymbolSize   kActionBtnCornerRadius
//  Spacer & Separator       kSpacerSize            kSeparatorSize
//  Image                    kDefaultImageMaxWidth  kImageViewerMinZoomScale
//  System Image / SF        kDefaultSymbolPointSize
//  Code Editor              kEditorFontSize        kEditorDefaultWidth
//  Symbol Toggle            kSymbolToggleSize      kSymbolTogglePointSize
//  Loading Spinner          kLoadingSpinnerSize
//  Layout Engine            kLayoutDefaultWidth    kMinLeafWidth  kFlexEpsilon
//  Preview / Render         kRenderDefaultWidth    kRenderDefaultHeight
//  Misc                     kFallbackBackingScale  kFSWatcherLatency

Code documentation. Always explain the why behind non-trivial decisions — design rationale, edge cases being handled, why a particular approach was chosen over alternatives. Comments should tell a future maintainer what problem was solved, not restate what the code does. Skip comments for self-explanatory mechanics (e.g. calling a well-known AppKit method, standard Lua idioms).

When adding new code, also document what infrastructure already exists: capabilities the framework already provides, patterns already in use, and prior art within the codebase that shows how similar problems were solved. This avoids reinvention and keeps the codebase consistent.

Building

Requires: - macOS (AppKit) - Lua 5.4 (brew install lua)

make          # build ./lua-objc
make run      # run demo/hello.lua
make run ARGS="demo/hello.lua"
make run ARGS="demo/list.lua"
make clean

Testing

Tests are .lua files that exercise the framework headlessly — no window showing, no run loop. ns.Window{visible = false} creates a window without ordering it front.

make test      # runs all tests/*.test.lua

The lua/TestKit.lua module provides assertions:

local ns = require("AppKit")
local t = require("TestKit")

local win = ns.Window {
    title = "Test",
    width = 400,
    height = 300,
    visible = false,
}

t.assertEqual(win.title, "Test", "window title")
t.expect(ns.fetch ~= nil, "fetch exists")
t.assertEqual(list.rowCount, 0, "new list is empty")
t.assertThrows(function() ns.List{columns = {}} end, "missing columns")

Tests run in a single lua_State and os.exit(0) on success, os.exit(1) on failure. Add .test.lua files under tests/ and they're automatically picked up by make test.

Smoke-testing examples. Set _G.__headless = true before loading any example to suppress bridge._show(win). This lets you verify every example parses and constructs its view hierarchy without popping windows. A script that leaves __headless set exits when it returns, even without --test. The tests/examples.test.lua smoke test uses this pattern — add new examples to its list when you create them.

Debugging computed AppKit layout

The runtime can launch an app headlessly, force native layout, write an agent-readable XML hierarchy, and exit without diagnostic code in the app:

./lua-objc --dump-layout=/tmp/layout.xml apps/stocks/init.lua
rg -n 'cropped="true"|outsideParent="true"|contentClipped="true"' /tmp/layout.xml

# Override content size to exercise a compact layout.
./lua-objc --dump-layout=/tmp/layout-small.xml --width=760 --height=468 \
  apps/stocks/init.lua

Every <View> records the Objective-C class, computed frame, intrinsic/fitting size, clipping state, and text where relevant. Tables also emit <Column> and visible <Cell> records; cells compare the native label's intrinsic width with its final text frame and expose the result as cropped. This output comes from the post-layout AppKit hierarchy in Objective-C, so applications do not manually generate or maintain a second diagnostic tree.

ObjC tricks that make Lua binding shorter (vs C++ or C)

ObjC's dynamic runtime eliminates a lot of binding boilerplate compared to statically-typed languages. These are the tricks we use — and the ones we should add next.

Already in use

Trick How it helps
objc_setAssociatedObject Attach Lua callback refs, layout tags, data sources directly to ObjC objects — no wrapper structs or side tables. We use it for kAxisKey (layout), kTableSourceKey (table view), kCallbackKey (button actions).
CFBridgingRetain / CFRelease Seamless ARC ↔ C pointer ownership. One call to retain when crossing into Lua, one to release on __gc. No retain/release bookkeeping.
isKindOfClass: Type-safe downcasting. check_objc returns id; bridge functions check [obj isKindOfClass:[NSWindow class]] before casting. Prevents crashes on wrong types.
Blocks (^ { ... }) ObjC closures capture variables by value. NSTimer and NSNotificationCenter accept blocks — no C function pointers or void * contexts. We use this for timer callbacks and window-close handlers.
id dynamic typing id accepts any ObjC object without static type info. Bridge functions receive id and decide at runtime which class to cast to.
Static lookup tables + block prop parsers Replace chained if/strcmp/isEqualToString: with data arrays. NameValueEntry maps compile-time string constants to enum values via a 4-line lookup loop. TablePropParser arrays make option-table parsing declarative — each Lua key → block entry. Adding a property is one line; the parsing loop never changes. See GridLinesMap / TableStyleMap / AlignmentMap + the propParsers[] array in bridge_tableview / bridge_outlineview.

Dynamic runtime tools

Trick How it helps Effort
KVC (setValue:forKey:) Used for Cocoa properties and inherited bridge properties. Exported subclasses own semantic aliases and custom accessors. In use
performSelector: Rejected for the public bridge: specialized operations use explicit native method bindings. —
class_copyMethodList Enumerate all methods on NSView/NSWindow at runtime, auto-register them as Lua-callable bridge functions. One-time setup, then new AppKit methods work for free. Medium
Generic Lua↔NSDictionary A recursive converter: Lua table → NSDictionary/NSArray, ObjC collection → Lua table. Eliminates lua_table_to_dict and enables passing complex data anywhere. Medium
KVO observeValueForKeyPath:ofObject:change:context: → Lua callback. Enables reactive UI: change a property in ObjC, Lua gets notified automatically. Medium
NSInvocation Rejected for ordinary public calls in favor of explicit native method bindings. —
NSProxy + forwardingTargetForSelector: Create a Lua-backed proxy object that responds to any selector by calling a Lua function. The Lua side defines the interface; ObjC forwards everything. High

C++ things that don't apply here

C++ concept Why it doesn't help
RTTI (dynamic_cast) ObjC has isKindOfClass: — simpler, no type_info needed
Templates No equivalent in ObjC; use id + runtime checks instead
Function pointers ObjC uses blocks or performSelector: — type-safe, no raw pointers
std::bind / lambdas ObjC blocks are the equivalent, with automatic memory management
RAII ARC handles memory; use CFBridgingRetain/CFRelease at the boundary

Gotchas to avoid

Gotcha Why it happens Fix
Resolving ownership from a coroutine L in a coroutine bridge call differs from the root state's pointer, so pointer-keyed owner maps miss. Use owner_for_state(L), which reads the inherited owner pointer from lua_getextraspace; do not restore the deleted "bridge_main" registry workaround.
lua_tostring mutating numbers on the stack Calling lua_tostring on a number changes the stack slot to a string, breaking lua_next iteration. Use lua_pushvalue before conversion, or check lua_type first.
sizeToFit on non-NSControl views NSScrollView, NSSplitView don't implement it — crash. Guard with respondsToSelector:@selector(sizeToFit).
ARC and lua_State* lifetime ARC manages Objective-C references, not the C allocation behind lua_State*; __weak cannot manage that pointer. Designate one explicit closer: a closing LuaStateOwner on macOS or LRTApplicationController on iOS. See ownership.
Blocks capturing lua_State* in async work A raw pointer can outlive its state, especially during host reload. Capture LuaStateOwner strongly, check cancellation, and resume through its live state. UI callbacks use LuaReg against that owner.

File layout

lua-objc/
├── AGENTS.md
├── Makefile
├── src/
│   ├── host.c              # tiny AppKit.dylib loader executable
│   ├── main.m              # constants, fragment includes, registration + host
│   ├── appkit/             # focused bridge fragments in the same translation unit
│   ├── uikit/              # focused UIKit bridge fragments
│   ├── shared/             # shared Lua state, async, HTTP, JSON, error helpers
│   └── uikit_module.m      # self-contained UIKit Lua module
├── lua/
│   ├── embedded/
│   │   ├── AppKit.lua      # embedded declarative macOS layer
│   │   ├── UIKit.lua       # embedded declarative iOS layer
│   │   └── IDEKit.lua      # embedded IDE component layer
│   └── TestKit.lua         # Assertion helpers for testing
└── tests/
│   └── bridge.test.lua      # Framework tests
└── apps/
    ├── hello.lua           # Window + Text + Image demo
    ├── list.lua            # Window + List (NSTableView) demo
    ├── live.lua            # Stock ticker with coroutines + spinner
    ├── weather.lua         # Real network fetch (wttr.in)
    ├── welcome.lua         # Xcode-style welcome window
    └── mail.lua            # Sidebar + toolbar + split view

XML view templates

lua/ui/xml.lua provides a cross-platform XML-to-view renderer. It sits between the app controller layer and the ns.* APIs. The same XML file produces NSTextField on AppKit and UILabel on UIKit with no conditionals in the template, because the platform module (ns) is injected by the caller.

Templates use the .etlua extension to reflect that they contain etlua (Lua embedded in XML) rather than plain XML.

How it works

  1. etlua pass — the XML source is treated as an etlua template. <%= expr %> substitutes a value; <% stmt %> runs Lua code (conditionals, loops). The data table passed to render is the template's environment.
  2. XML parse — a pure-Lua SAX-style parser turns the rendered string into a node tree. No external XML dependency.
  3. Tag dispatch — each element tag is looked up in the registry and called as handler(ns, attrs, children) → view. Unknown tags raise an error at render time, not at template load time.

Tag vocabulary

XML tag AppKit result UIKit result
<Label text="…"> / <Text> ns.Text (NSTextField) ns.Text (UILabel)
<Title text="…"> ns.Title ns.Title
<TextField> ns.TextField ns.TextField
<CodeView> NSTextView with SyntaxTextStorage UITextView with rule-driven TextKit colors
<Button title="…"> ns.Button ns.Button
<GlassEffect> NSGlassEffectView UIVisualEffectView + UIGlassEffect
<GlassEffectContainer> NSGlassEffectContainerView UIVisualEffectView + UIGlassContainerEffect
<VStack> / <HStack> flex containers flex containers
<FlowStack> wrapping rows of native views wrapping rows of native views
<HSplit> ns.HSplit (NSSplitView) —
<Slider> ns.Slider (NSSlider) —
<Stepper> ns.Stepper (NSStepper) —
<Picker> + <Option> children ns.Picker (NSPopUpButton) —
<Spacer> / <Divider> layout helpers layout helpers
<Image src="…"> ns.Image ns.Image
<Image symbol="…"> / <SystemImage> ns.SystemImage ns.SystemImage
<Toggle> / <Switch> ns.Toggle ns.Toggle
<List> + <Column> children ns.List (NSTableView) —
<SectorChart> + <SectorMark> children native Arcs in a ZStack native Arcs in a ZStack
<Gauge> NSLevelIndicator (continuous capacity) UIProgressView
<Treemap> + <TreemapNode> children LuaTreemapView (squarified, drawn natively) —
<Waveform> + <WaveformMarker> children LuaWaveformView (sound file, tempo grid, draggable markers) —
<ActivityRings>, <BarChart>, <CapacityBar>, <HeatmapGrid> etlua components (lua/components/) of native Arcs and stacks same templates
<Window> window config table window config table
<Toolbar> + <ToolbarItem> toolbar items toolbar items

All layout attributes (flexGrow, padding, width, height, maxWidth="infinity", etc.) are accepted on every tag and forwarded to applyLayout.

Implicit flexibility follows SwiftUI. A view hugs its content unless SwiftUI makes it flexible without a frame modifier. Do not write maxWidth="infinity" for these; they take the width they are offered: <Spacer>, <Divider>, <GroupBox>, <ScrollView> (across, even with a flexBasis), <List>, <Table>, <TextField>, <SearchField>, <Slider>, <Gauge>, <Treemap>, a determinate <ProgressView> (a spinner keeps its size) and <DisclosureGroup>. A <VStack>/<HStack>/<ZStack> is flexible on an axis when any child is, so the attribute is never repeated down a chain of containers. <Label> and other text hug, and wrap when offered less width. Write maxWidth="infinity" on the leaf that must fill (SwiftUI's .frame(maxWidth: .infinity, alignment: .leading)), for example a label that pushes trailing controls to the row's edge. tests/flexible_defaults.test.lua holds the contract.

<List> attributes:

Attribute Type Default Description
style "plain" "sourceList" "inset" automatic NSTableView style
header bool true Show the column header
alternatingRows bool true Alternating row colors
bordered bool false Bezel around the scroll view
gridLines string — "horizontal" "vertical" "both"

<Column> attributes: id (required, matches row dict key), title, width, minWidth, alignment, and sortable (enables the native header click callback on AppKit). A list registers onColumnSort(callback) to receive the clicked column ID and can set its native arrow with setSortIndicator(columnId, ascending). buttonSymbol renders a borderless SF Symbol button in that column; onColumnButton(callback) receives the list, column id, and row. On AppKit, a button column can use imageKey for a per-row SF Symbol, helpKey for its tooltip and accessibility action label, enabledKey for a boolean row field controlling whether the native button is enabled, and visibleKey for a boolean row field controlling whether it is shown. A <List rowMenu="action"> gives every row a native contextual menu (SwiftUI .contextMenu): the action receives (list, index, row) when the menu opens and returns {title, action, systemImage, disabled} and {separator = true} records. A column with buttonSymbol="ellipsis.circle" buttonMenu="true" opens the same menu from each row's "More" button, so row actions need no buttons under the list and a scrollDisabled list can scroll with its page. Tests read and perform row menus with bridge._tableRowMenu(list, row[, item]) (1-based).

<Slider> attributes: min, max, value, tickMarks, allowsTickMarkValuesOnly.

<Stepper> attributes: min, max, value, increment, wraps, autorepeat.

<Picker> attributes: value is a zero-based selected index. It requires one or more <Option title="…" /> children:

<Picker value="1">
  <Option title="Low" />
  <Option title="Medium" />
  <Option title="High" />
</Picker>

style="segmented" renders the options as an NSSegmentedControl (SwiftUI .pickerStyle(.segmented)); the default is the pop-up menu. Both report the zero-based index to onChange.

Slider, Stepper, and Picker are currently AppKit-only XML tags. Using them with UIKit raises the normal unsupported-constructor error.

id attribute (rendered views): Storing id="name" on a rendered view causes xml.renderFile to return it as refs["name"]. The renderer also sets the native accessibility identifier. Descriptor tags such as Column and ToolbarItem continue to use id for their own configuration:

<List id="messageList" style="plain" header="false">
    <Column id="from" title="From" />
</List>
local layout, refs = xml.renderFile("views/Window.etlua")
refs.messageList:replaceRows(rows)
refs.messageList:onRowSelect(function(_, _, row) ... end)

Window config from XML (NIB-style)

The <Window> tag captures window configuration declaratively, similar to Xcode NIB/Storyboard files. xml.renderFile detects a <Window> root and returns (config, refs) instead of (view, refs):

<Window title="Mail" width="940" height="520" minWidth="640" minHeight="400">
    <Toolbar>
        <ToolbarItem id="compose" label="Compose"
                     icon="square.and.pencil" tooltip="New Message" />
    </Toolbar>
    <HSplit>
        <List id="mailboxList" ... />
    </HSplit>
</Window>

Window attributes: title, width, height, minWidth, minHeight, maxWidth, maxHeight, appearance, orientation, tabbingMode, tabbingIdentifier, toolbarLabels, visible, sidebarWidth, toolbarContentDividerAfter.

ToolbarItem attributes: id, label, icon, tooltip, action, bordered, visibilityPriority, badge (a count the system draws on the item, macOS 26; 0 shows none). <ToolbarSpacer /> creates the native flexible space item. A ToolbarItem accepts at most one view child, installed by AppKit.Window as the item's custom control — toolbar content is declared in etlua, never assembled in controller code:

<ToolbarItem id="search" label="Search">
    <SearchField id="search" placeholder="Filter results…"
                 width="210" height="28" onChange="search" />
</ToolbarItem>

A toolbar's content follows state the SwiftUI way: the template says which button an item is, and the controller describes the window again when that state changes. Matching item IDs keep their native items and custom views, including a focused search field. The description may add, remove or reorder items; AppKit owns placement and overflow. IDs must be unique, except for flexibleSpace. Labels, symbols, tooltips, actions and validation update in place.

<% if scanning then %>
<ToolbarItem id="measure" label="Stop" icon="stop.circle" action="cancel" />
<% else %>
<ToolbarItem id="measure" label="Refresh" icon="arrow.clockwise" action="refresh" />
<% end %>
-- On scan start and finish, not per progress tick:
window:updateToolbar(xml.toolbarFile("app/views/layouts/Window.etlua", data))

xml.toolbarFile makes no views: an item's view child (a search field) is left out and the window keeps its own.

Whether a button is enabled is asked, not told. validate names an action returning whether the item is enabled; AppKit calls it on each window update (after events, never on a timer), as NSToolbarItemValidation does and as a <MenuItem validate> does for the menu bar. This is SwiftUI's .disabled(!canGoBack) without the controller having to redescribe the toolbar:

<ToolbarItem id="back" label="Back" icon="chevron.left" action="back" validate="canGoBack" />

Toolbar action strings are resolved to Controller methods via an ACTIONS table:

local ACTIONS = {
    compose = function(self) self:compose() end,
    refresh = function(self) self:refresh() end,
}
for _, item in ipairs(cfg.toolbar or {}) do
    if item.action and ACTIONS[item.action] then
        local fn = ACTIONS[item.action]
        item.action = function() fn(self) end
    end
end

Template inheritance (Blade/Twig style)

Child templates can extend a parent layout, defining blocks that the parent renders via yield(). This is similar to PHP's Blade/Twig template inheritance.

Parent template (views/AppWindow.etlua):

<Window title="<%= title %>" width="<%= width or 640 %>" height="<%= height or 420 %>">
    <% if toolbar then %>
    <Toolbar>
        <% for _, item in ipairs(toolbar) do %>
        <ToolbarItem id="<%= item.id %>"
                     <% if item.label then %>label="<%= item.label %>"<% end %>
                     <% if item.icon then %>icon="<%= item.icon %>"<% end %>
                     <% if item.tooltip then %>tooltip="<%= item.tooltip %>"<% end %>
                     />
        <% end %>
    </Toolbar>
    <% end %>
    <%- yield("content") %>
</Window>

Child template (demo/hello/views/Window.etlua):

<% extends("views/AppWindow.etlua", { title = "Hello", width = 480, height = 420 }) %>
<% block("content", [[
    <VStack flexGrow="1" padding="24" spacing="12" alignment="leading">
        <Label text="Hello from Lua" size="24" weight="bold" />
        <Label text="A SwiftUI-like API backed by native AppKit controls." size="14" />
    </VStack>
]]) %>

API: - extends(path, data) — load parent template with data context - block(name, content) — define a named content section (content is a Lua string) - yield(name) — emit block content in parent template (returns empty string if undefined)

Relative paths are resolved from the child template's directory.

Partials

Include reusable sub-templates with partial(). This is similar to PHP's @include or Rails' render partial:

<%= partial("views/partials/SimpleList.etlua", {
    id = "employeeList",
    columns = {
        { id = "name", title = "Name" },
        { id = "role", title = "Role" },
    },
}) %>

Partials receive their own data context and resolve paths relative to their own file location.

Example partial (views/partials/SimpleList.etlua):

<List id="<%= id or 'list' %>" flexGrow="1"
      <% if style then %>style="<%= style %>"<% end %>
      header="false" alternatingRows="false">
    <% for _, col in ipairs(columns) do %>
    <Column id="<%= col.id %>"
            <% if col.title then %>title="<%= col.title %>"<% end %>
            <% if col.width then %>width="<%= col.width %>"<% end %>
            <% if col.alignment then %>alignment="<%= col.alignment %>"<% end %>
            />
    <% end %>
</List>

View description format

lua/ui/viewdesc.lua compiles templates to plain-table descriptions and computes positional diffs. Its apply function is currently a no-op, so this is an experimental description API, not a working native renderer. Retained evaluation, keyed reuse, and unmount cleanup remain implementation work:

local viewdesc = require("ui.viewdesc")

-- Generate description from template
local desc = viewdesc.fromFile("views/Window.etlua", data)

-- Later, with new data
local newDesc = viewdesc.fromFile("views/Window.etlua", newData)

-- Diff (returns list of patches)
local patches = viewdesc.diff(desc, newDesc)

-- Apply to live views (stub — implementation depends on native bridge)
viewdesc.apply(liveView, patches)

Description format:

{
    tag = "VStack",           -- component name
    props = { padding = 24 }, -- layout props
    children = { ... },       -- child descriptions
    key = "unique-id",        -- for stable identity during diffing
}

Extending the registry

local xml = require("ui.xml")
xml.registry["Avatar"] = function(ns, attrs, children)
    -- attrs.src, attrs.size available
    return ns.Image { attrs.src, fixedWidth = tonumber(attrs.size) or 32 }
end

API

local xml = require("ui.xml")

-- Render from a string (data optional, ns defaults to require("AppKit"))
local view = xml.render(xmlString, data, ns)

-- Render from a file path (relative to cwd)
local view = xml.renderFile("demo/mail/views/Window.etlua", rowData, ns)

-- When XML root is <Window>, returns (config, refs) instead of (view, refs)
local cfg, refs = xml.renderFile("demo/mail/views/Window.etlua")

-- Decode data XML into Lua tables via schema
local data = xml.decode(xmlString, schema)

-- Decode from file path
local data = xml.decodeFile("demo/mail/share/messages.xml", schema)

The returned value is a single view userdata. When the XML root has multiple sibling elements, they are wrapped in a ns.VStack.

Schema-based XML data decoding

xml.decode and xml.decodeFile parse XML into Lua tables based on a schema, so app models can describe structure/types once instead of re-implementing attribute parsing, entity decoding, arrays, and CDATA handling.

Supported schema primitives:

  • root = "a/b" — select the document root path before decoding fields.
  • fields = { ... } — object mapping.
  • array = true + path = "x/y" — collect many child elements.
  • attr = "name" — read an attribute value.
  • text = true — read element text content.
  • type = "string" | "number" | "integer" | "boolean" — type coercion.
  • default = value — fallback when value is missing or coercion fails.

String field shorthands:

  • "@id" is equivalent to { attr = "id" }.
  • "#text" is equivalent to { text = true }.
  • "body/title" is equivalent to { path = "body/title", text = true }.

Example (mail data):

local xml = require("ui.xml")

local schema = {
  root = "mail",
  fields = {
    mailboxes = {
      path = "mailboxes/mailbox",
      array = true,
      fields = {
        id = "@id",
        name = "@name",
        count = { attr = "count", type = "number", default = 0 },
      },
    },
    messages = {
      path = "messages/message",
      array = true,
      fields = {
        id = { attr = "id", type = "number" },
        unread = { attr = "unread", type = "boolean", default = false },
        body = { path = "body", text = true, default = "" },
      },
    },
  },
}

local data = xml.decodeFile("demo/mail/share/messages.xml", schema)

This API decodes XML entities in attributes/text and preserves multiline CDATA content when extracting text fields.

MVP app structure

Every standalone example app follows the same three-layer structure:

apps/<appname>/
  init.lua        — entry point only: WindowController.new():createWindow()
  Model.lua       — data, queries, mutations; no ns.* calls
  Controller.lua  — defines WindowController class; one ns.Window call
  views/          — *.etlua templates; all view construction happens here

Rules

  • init.lua constructs one WindowController instance and calls createWindow(). It must not contain UI logic.
  • Model.lua must not call any ns.* APIs. It is testable headlessly without AppKit.
  • Controller.lua defines a WindowController class (metatable + __index). It is the only file that calls ns.Window { ... }. All other view construction is delegated to XML templates via xml.renderFile.
  • Controllers must not call ns.VStack, ns.HStack, ns.List, or any other view constructor. Use xml.renderFile and wire behaviour through refs.
  • XML templates in views/ express the complete view hierarchy. A rendered view's id="name" surfaces it to the controller for callback wiring.

Example: mail app

-- demo/mail/init.lua
local WindowController = require("demo.mail.Controller")
return WindowController.new():createWindow()
-- demo/mail/Controller.lua  (abridged)
local WindowController = {}
WindowController.__index = WindowController

function WindowController.new()
    return setmetatable({ selectedMailbox = Model.mailboxes[1] }, WindowController)
end

function WindowController:createWindow()
    local cfg, refs = xml.renderFile("views/Window.etlua")
    self.messageList = refs.messageList
    self.detailPane  = refs.detailPane
    self.messageList:replaceRows(Model.byMailbox(self.selectedMailbox.id))
    self.messageList:onRowSelect(function(_, _, row)
        self:showDetail(Model.find(row._id))
    end)
    return ns.Window(cfg)
end
end
<!-- demo/mail/views/Window.etlua -->
<HSplit>
    <List id="mailboxList" width="180" style="sourceList" header="false">
        <Column id="name" title="Mailbox" />
    </List>
    <List id="messageList" width="280" style="plain" header="false">
        <Column id="from" title="From" />
    </List>
    <VStack id="detailPane" flexGrow="1" />
</HSplit>
<!-- demo/mail/views/MessageDetail.etlua -->
<VStack flexGrow="1" padding="24" spacing="16" alignment="leading">
    <Label text="<%= subject %>" size="18" weight="semibold" />
    <HStack spacing="8">
        <Label text="<%= from %>" size="13" weight="medium" />
        <Label text="<%= date %>"  size="12" color="secondary" />
    </HStack>
    <Divider />
    <Label text="<%= body %>" size="13" flexGrow="1" />
</VStack>

Native outline subtitle cells and layout updates

<OutlineView> accepts the same <Column> records as <List>, including subtitleKey, imageKey, imageColorKey, imageSize, and fileIconKey. AppKit shares native cells between both controls. Rows with stable id fields preserve expansion and selection through replaceRows; expanded = true initially expands a new group. forceExpanded = true reveals descendants even when a previously loaded group was collapsed (for example, search results). Empty child collections have no disclosure.

A column's labelStyle="iconOnly" shows each row's symbol alone, with the text kept as tooltip and VoiceOver label. In a trailing column the symbol sits beside the text, or at the trailing edge when shown alone. helpKey names a row field used as the cell's tooltip, SwiftUI's .help.

Calling view:layout() without an explicit width remeasures its enclosing layout owner up to the native split pane, so content growth moves siblings. view:scrollIntoView() reveals a view through its enclosing native scroll view. GroupBox uses NSBox, and ProgressView { value = fraction } is determinate; omitting a value retains spinner behavior.

Symbol badges and application artwork (AppKit)

SystemImage accepts badgeColor (semantic color) and appIcon (bundle ID). Badges have square intrinsic geometry with rounded corners and white symbols. An installed application's NSWorkspace artwork replaces the badge without tint; an unavailable bundle retains the symbol. No application path is required. Column uses badgeColorKey and appIconKey to bind the same presentation to row data in native tables and outlines. Reused cells clear previous icon state.

Native management sheets

<Sheet width="880" height="620">…</Sheet> / AppKit.Sheet(props) create an ordinary AppKit panel with an opaque semantic content background. Both platforms share one presentation API:

self.sheet, self.refs = ns.presentSheet(function()
    return xml.renderFile("views/Settings.etlua", data, ns)
end, { parent = self.window, detents = { "medium", "large" } })
-- later
ns.dismiss(self.sheet)

Pass a builder so every callback, timer and retained Template it creates is scoped to the sheet and released by ns.dismiss; presentSheet returns the builder's results (sheet, refs). AppKit requires parent; UIKit ignores it and reads detents. AppKit owns sheet attachment, frame, corners and shadow. Headless mode constructs content without presentation. Keyboard behavior is declarative: <Button keyboardShortcut="defaultAction"> (Return, drawn as the default button) or "cancelAction" (Escape), and defaultFocus="true" on a TextField/SearchField focuses it when the sheet appears. Floating Panel presentation keeps its separate native material.

Automatic layout

Controllers never call layout(). A Lua write that changes a view's measured size (text, title, font, image, hidden, padding, spacing, fixed/min/max sizes, flex) and add/clearContainer mark the view dirty. AppKit relays out each dirty view's layout owner once per run-loop turn, just before the loop sleeps, and Lua geometry reads (frame, bounds, size, fittingSize) flush first, so tests observe the layout their writes imply. UIKit marks the view and its ancestors setNeedsLayout; the hosting controller lays the root out at its real width. Paint-only writes (colors, alpha, offsetX/offsetY, enabled) never schedule layout, so per-frame gesture updates stay cheap. view:layout(width) remains for framework code and tests that lay out a detached tree at an explicit width.

<List scrollDisabled="true"> (SwiftUI .scrollDisabled) keeps its rows in place and is as tall as all of them, re-measuring when rows change; place it in a scrolling page that owns the overflow.

A pushed screen is a <Page>: one content view plus an optional <Toolbar>, equivalent to SwiftUI's .navigationTitle and .toolbar on a destination.

<Page title="<%= gameTitle %>" hidesTabBar="true" titleDisplayMode="inline"
      backButtonDisplayMode="minimal" onDisappear="disappear">
  <Toolbar>
    <ToolbarItem id="heading" placement="principal" label="<%= gameTitle %>">
      <%- partial("SessionTitle.etlua", { gameTitle = gameTitle, progress = progress }) %>
    </ToolbarItem>
    <ToolbarItem id="settings" placement="primaryAction" icon="textformat.size"
                 label="Reading settings" action="readingSettings" />
  </Toolbar>
  <VStack id="session">…</VStack>
</Page>

Controllers render the page and push it: navigation:push(page). Placements are principal, primaryAction/topBarTrailing/confirmationAction (trailing) and topBarLeading/cancellationAction (leading). UIKit maps them onto the navigation item; AppKit inserts them, with a navigational back item, into the window toolbar while the page is visible and restores the window's own items afterwards. onDisappear runs once when the page leaves the stack, whether a controller pops it or the user presses the system back button.

placement="keyboard" (SwiftUI ToolbarItemPlacement.keyboard) puts the item's one view child in a bar above the keyboard while a text input on the page is editing: UIKit sets it as the input's inputAccessoryView. Items share one row; an item with maxWidth="infinity" takes the width the others leave. A hidden item leaves the row, and a row with nothing visible collapses, so hiding the view (as Adventure Arena hides its completions while the command field is empty) hides the bar. AppKit, whose keyboards have no such bar, leaves keyboard items out, as SwiftUI does on a Mac without a Touch Bar.

List and TabView events bind controller actions from XML like any other control: <List onSelect="select" onActivate="open" onSort="sort"> and <TabView onChange="tabChanged">. When actions are supplied, a misspelt name fails at render time. Native List/Outline selection and activation callbacks preserve row boolean, numeric and structured values rather than stringifying them.

Other additions: ns.watch(paths, callback, {since}) (FSEvents, Scope owned), ns.notifications (UNUserNotificationCenter; unavailable without an app bundle), ns.bookmark/ns.resolveBookmark (security-scoped bookmarks), List dragKey, stack onDrop (with dropExternalOnly="true" to take only drags from other apps, such as the Finder; onFileDragChanged observes enter/exit of any file drag so a hidden staging target can appear without accepting an internal drop), chart dragItem and keyboard navigation, and <Window onBack onForward> for mouse buttons and swipes. ns.onOpenFiles(handler) receives folders and files dropped on the Dock icon or opened with the app from the Finder (application:openURLs:, queued until a handler is set); ns.quickLook(paths, index) shows the system Quick Look panel; ns.moveItem(path, folder, completion) moves an item off the main thread, across disks too, never replacing an existing one. ns.runningApplications(), ns.applicationPath(bundleId) (Launch Services), ns.fileIdentity(path) (lstat: inode, device, symlink) and ns.relaunch(onFailure) (start a new instance, quit once it runs; it ends attached sheets first, as AppKit will not quit while one shows) support checks before destructive actions and permission changes. ns.codeSignatures(paths, completion) reads each bundle's team identifier and app groups from its code signature off the main thread and completes with {[path] = {team, groups}}; unsigned bundles are left out. ns.homeDirectory() is the person's home folder from the user database: inside the App Sandbox HOME names the app's container. ns.pickFolder(title, {directory, message, prompt}) is the folder open panel; a sandboxed app reaches only what the person chooses there, so a whole-disk tool opens it at / and keeps the choice with ns.bookmark. <PageControl numberOfPages currentPage onChange> shows page dots: UIKit's UIPageControl, and on AppKit, which has no such class, SF Symbol dot buttons with the same attributes (currentPage zero-based, settable later).