Skip to content

Latest commit

 

History

History
212 lines (165 loc) · 10 KB

tableview_swiftui.md

File metadata and controls

212 lines (165 loc) · 10 KB

NSTableView / SwiftUI Table — architecture & behaviour notes

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.

SwiftUI Table is NSTableView under the hood

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

Column autoresizing

SwiftUI sets NSTableView.columnAutoresizingStyle to NSTableViewUniformColumnAutoresizingStyle. This means all columns resize proportionally when the table view's frame changes — not just the last column.

Styles (for reference)

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

What we use

NSTableViewUniformColumnAutoresizingStyle — so columns shrink/grow together when the parent container resizes the view, matching what SwiftUI apps see.

Horizontal scrolling when columns don't fit

SwiftUI behaviour

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).

AppKit equivalent

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.

Our implementation

In updateTableFrame (called on every row insertion and every layout pass):

When any column omits width (flex column):

  1. Columns with an explicit width are treated as fixed — they keep their specified width.
  2. Columns without width are flexible — they split the remaining viewport space equally among themselves.
  3. If the viewport is narrower than the sum of all fixed-column widths, all columns shrink to their minWidth and a horizontal scroller appears.

When every column has an explicit width (legacy, no flex columns):

  1. Compute totalColumnWidth from tableColumns
  2. 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)
  3. 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.

Column widths

Stretch column (no width)

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.

Legacy table (all columns have width)

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.

Scroll view geometry (tile and bounds staleness)

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 table modifiers (not yet mapped)

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

Key takeaways

  1. Always use NSTableViewUniformColumnAutoresizingStyle — it's what SwiftUI uses and keeps all columns visible during resize.
  2. Only enable horizontal scrolling when columns explicitly overflow their minimum widths — don't preemptively show a scroller.
  3. Call [NSScrollView tile] before reading clip-view geometry after a frame change, or bounds will be stale.
  4. sizeLastColumnToFit + UniformAuto = all columns resize, not just the last one. The method name is misleading with this style.
  5. SwiftUI Table wraps itself in vertical-only ScrollView by default. Horizontal scroll requires explicit .horizontal modifier. Our NSScrollView handles both axes natively.

Cells are native kinds (AppKit)

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.

Measured storage cells

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.