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
NSToolbarandNSToolbarItem. 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.sourceListis for navigation sidebars: hide the column header, add a section label above the table as a separateText, and setstyle = "sourceList"for the vibrant blur-background appearance.insetadds rounded corners and automatic padding for grouped/settings-style lists that sit inside a larger form. Never useinsetorsourceListfor the main content table of a window. Never useplain/fullWidthinside a settings panel wheninsetis 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. UseshowLoading()before data fetches begin andhideLoading()after the last row arrives. Never insert artificialui.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,
NSSplitViewowns 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:
- Launch every affected example and inspect an actual screenshot.
- Resize each window substantially smaller and larger; content must reflow or clip intentionally, never drift, overlap, or create accidental voids.
- Exercise loading, loaded, empty, selected, disabled, error, and long-text states that the component supports.
- Check toolbar icons and labels, table alignment and truncation, native selection appearance, focus behavior, and keyboard access.
- 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.
-
src/host.c— Tiny executable loader. LoadsAppKit.dyliband invokes itslua_objc_mainhost entry point. macOS only. -
build/AppKit.dylib— Self-contained macOS Lua module and runtime.src/main.mincludes focused bridge fragments fromsrc/appkit/; together they own the ObjC bridge, layout engine, rendering services, and embeddedlua/embedded/AppKit.luadeclarative layer. - 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.dylibremains an optional SDK compile-check. 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:
- Use ordinary Lua functions for reusable components and unrestricted initial
if/forconstruction. - Evolve eager
ForEach/Groupinto lazy keyed descriptions, and add lazyIf, when inline tree composition needs reactive identity. - Separate logical view descriptions from mounted native AppKit objects.
- Add observable state/bindings and dependency invalidation so the appropriate component function is reevaluated.
- Add stable identity and keyed reconciliation so native widgets and component state survive updates where their logical identity is unchanged.
- 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.
Sidebar recipe
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.
Menu bar: <Commands>
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>
appNamenames 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'sCFBundleNameshould 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'sCommandGroupPlacement: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>takeskeyEquivalent(a character, ordelete,forwardDelete,return,escape,tab,space,left,right,up,down),modifiers(defaultcommand),systemImage,checked,disabledandvalidate: an action returningenabled, 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 fortableView: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'sidmaps directly to a key in the row'sNSDictionary. Cells default to nativeNSTextFields; 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
nsviewmetatable__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 viaobjc_setAssociatedObject. Thensviewmetatable's__indexfunction checks for this association and returns C functions foraddRow,removeRow,clearRows,rowCount. This means the userdata returned byList{...}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 (default40). When the viewport is too narrow for all fixed columns at theirminWidth, 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
- 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 torenderis the template's environment. - XML parse — a pure-Lua SAX-style parser turns the rendered string into a node tree. No external XML dependency.
- 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.luaconstructs oneWindowControllerinstance and callscreateWindow(). It must not contain UI logic.Model.luamust not call anyns.*APIs. It is testable headlessly without AppKit.Controller.luadefines aWindowControllerclass (metatable +__index). It is the only file that callsns.Window { ... }. All other view construction is delegated to XML templates viaxml.renderFile.- Controllers must not call
ns.VStack,ns.HStack,ns.List, or any other view constructor. Usexml.renderFileand wire behaviour throughrefs. - XML templates in
views/express the complete view hierarchy. A rendered view'sid="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.
Navigation pages
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).