This file collects findings about how SwiftUI's Table maps to AppKit's
NSTableView, so we can keep the Lua bridge faithful to platform behaviour
without rediscovering the same details every time.
On macOS, SwiftUI's Table renders as a view-based NSTableView inside an
NSScrollView. The mapping is straightforward:
| SwiftUI | AppKit |
|---|---|
Table |
NSTableView (view-based, NSTableViewStyle.fullWidth by default) |
TableColumn |
NSTableColumn with identifier, title, optional width |
.tableStyle(.inset) |
NSTableView.Style.inset |
.alternatingRowBackgrounds() |
usesAlternatingRowBackgroundColors = true |
.contextMenu() |
NSTableView.menu |
SwiftUI sets NSTableView.columnAutoresizingStyle to
NSTableViewUniformColumnAutoresizingStyle. This means all columns resize
proportionally when the table view's frame changes — not just the last column.
| Constant | Behaviour |
|---|---|
NSTableViewNoColumnAutoresizing |
Columns never resize |
NSTableViewUniformColumnAutoresizingStyle |
All columns resize proportionally (SwiftUI's choice) |
NSTableViewSequentialColumnAutoresizingStyle |
Leftmost columns resize first |
NSTableViewReverseSequentialColumnAutoresizingStyle |
Rightmost columns resize first |
NSTableViewLastColumnOnlyAutoresizingStyle |
Only the last column (AppKit default) |
NSTableViewFirstColumnOnlyAutoresizingStyle |
Only the first column |
NSTableViewUniformColumnAutoresizingStyle — so columns shrink/grow
together when the parent container resizes the view, matching what SwiftUI
apps see.
SwiftUI's Table does not automatically gain horizontal scrolling.
If columns are wider than the available space, they are compressed
proportionally (unless explicit minWidth constraints prevent further
shrink). To get horizontal scrolling, the developer must wrap the Table
in an explicit ScrollView(.horizontal).
NSScrollView wrapping NSTableView supports horizontal scrolling via
hasHorizontalScroller = YES. The table view as the document view can be
wider than the scroll view's clip view; when it is, the horizontal
scroller appears automatically.
The key: do not clamp _tableView.frame.size.width to the viewport
width when columns overflow. Instead, let the table frame be the sum of
column widths. Cocoa's scroll machinery handles the rest.
In updateTableFrame (called on every row insertion and every layout pass):
When any column omits width (flex column):
- Columns with an explicit
widthare treated as fixed — they keep their specified width. - Columns without
widthare flexible — they split the remaining viewport space equally among themselves. - If the viewport is narrower than the sum of all fixed-column widths,
all columns shrink to their
minWidthand a horizontal scroller appears.
When every column has an explicit width (legacy, no flex columns):
- Compute
totalColumnWidthfromtableColumns - If
totalColumnWidth > viewport.width:hasHorizontalScroller = YES_tableView.frame.width = totalColumnWidth(table is wider than visible area)- Do not call
sizeLastColumnToFit(columns keep their explicit widths)
- If columns fit:
hasHorizontalScroller = NO_tableView.frame.width = viewport.width- Call
sizeLastColumnToFit→ autoresizing distributes remaining space
This gives us the SwiftUI-equivalent behaviour: columns with explicit
width/minWidth get a horizontal scrollbar when the view is too narrow;
columns without width stretch to fill the available space.
Omitting width on a column makes it a stretch column that fills the
remaining viewport space. This follows the same contract as other lua-objc
controls: no explicit size means "consume available space."
Multiple stretch columns split the remaining space equally. To prevent
a stretch column from collapsing below a readable size, set minWidth:
columns = {
{ id = "from", title = "From", width = 120 },
{ id = "subject", title = "Subject" }, -- stretch column
{ id = "date", title = "Date", width = 60, minWidth = 40 },
}When the viewport is too narrow for all fixed columns at their minWidth,
a horizontal scroller appears — keeping every column at least its
minWidth and restoring the scrollable overflow behaviour.
When every column has an explicit width, the old sizeLastColumnToFit
behaviour is preserved: the last column stretches to fill the viewport,
or a horizontal scroller appears when columns overflow.
After setting NSScrollView.frame, the internal clip-view and scroller
geometry may be stale until the next display cycle. In our layout engine,
we call [(NSScrollView *)view tile] before reading clipView.bounds
in updateTableFrame so the viewport width reflects the new frame.
-[NSScrollView tile] is a synchronous layout pass that recalculates the
clip view and scroller positions. Without it, clipView.bounds.size may
return the previous viewport dimensions, causing columns to be sized
for a width the scroll view no longer occupies.
| SwiftUI modifier | NSTableView equivalent | Status |
|---|---|---|
TableColumn.width(min:ideal:max:) |
NSTableColumn.minWidth / width / maxWidth |
width mapped |
TableColumn.width(_:) |
NSTableColumn.width |
Mapped |
.tableStyle(_:) |
NSTableView.style |
Mapped |
.alternatingRowBackgrounds(_:) |
usesAlternatingRowBackgroundColors |
Mapped |
.tableColumnHeaders(.hidden) |
headerView = nil |
Mapped |
TableRow.init(_:) with selection binding |
NSTableView.selectedRow + delegate |
Not yet mapped |
.onDeleteCommand / .onInsertCommand |
NSTableView row actions | Not yet mapped |
DisclosureTableColumn / .disclosureTableColumn |
NSTableView.indentationPerLevel |
OutlineView exists |
Column sorting (SortDescriptor) |
NSTableColumn.sortDescriptorPrototype |
Not yet mapped |
.searchable |
NSSearchField in toolbar/header |
Not yet mapped |
TableColumn with custom content |
viewForTableColumn:row: returning custom NSView |
Not mapped: cells are native kinds; see "Cells are native kinds" |
| Drag-to-reorder rows | NSTableViewDataSource drag methods |
<List reorderable="true" reorderContainer="actionName"> sends a ui.reorder.Difference |
- Always use
NSTableViewUniformColumnAutoresizingStyle— it's what SwiftUI uses and keeps all columns visible during resize. - Only enable horizontal scrolling when columns explicitly overflow their minimum widths — don't preemptively show a scroller.
- Call
[NSScrollView tile]before reading clip-view geometry after a frame change, or bounds will be stale. sizeLastColumnToFit+ UniformAuto = all columns resize, not just the last one. The method name is misleading with this style.- SwiftUI Table wraps itself in vertical-only
ScrollViewby default. Horizontal scroll requires explicit.horizontalmodifier. OurNSScrollViewhandles both axes natively.
A column never hosts a view tree. Its cell is one of the table's own native
kinds, chosen by the column's key attributes, and each key names the row
field that fills it. A <Column> takes no child XML, and attributes have no
binding syntax: rows are data.
| Attributes | Cell |
|---|---|
| none | Text: row[id] |
subtitleKey, imageKey, imageColorKey, badgeColorKey, appIconKey, fileIconKey, imageSize, badgeKey |
Text with a subtitle, a leading symbol or icon and a trailing badge |
lines="2" |
Text that wraps onto that many lines before it truncates |
loadingKey |
A native spinner beside the text while the field is true |
labelStyle="iconOnly" |
The row's symbol alone; the text is its tooltip and VoiceOver label |
buttonSymbol, buttonMenu, enabledKey, visibleKey |
A row button, or one that opens the row's menu; boolean row fields control its enabled and visible states |
levelKey, levelColorKey, valueKey |
A meter (below) |
The cell is built once per reusable NSTableCellView and filled from the
row's NSDictionary in tableView:viewForTableColumn:row:. Scrolling runs
no Lua. A new cell appearance is a new native kind in
src/appkit/table_data_source.m with its key attributes in Column in
lua/ui/xml.lua.
levelKey names a row's fraction (0–1) and levelColorKey its semantic
colour, making the cell one meter, like Spectrum's Meter or SwiftUI's
Gauge. valueKey names the value (for example 52.3 GB), which leads on a
line above the bar; the column's id supplies the share (for example 39%),
which trails in secondary colour.
<Column id="shareText" title="Size" width="200" levelKey="relative" levelColorKey="color"
valueKey="size" loadingKey="calculating" imageKey="sizeIcon" imageColorKey="sizeColor" />The bar is a LuaLevelIndicator capsule half the height of AppKit's capacity
cell. It is always drawn: a row without a fraction leaves it empty and
disabled, so a value never floats in an empty cell; out-of-range values are
clamped, and a row without a colour takes the accent colour. loadingKey
puts the native spinner before the value while it is measured; otherwise
imageKey/imageColorKey put a state symbol in the spinner's square and
tint the value to match (Diskmap's "No access"). The share keeps its width;
the value truncates first. VoiceOver reads the column's title, the value and
the share together. List.rowHeight sets the row height.
tests/table_level_text.test.lua covers the layout.
UIKit has no meter cell yet; a level column shows its row text there.