Spread ForEach rows into dynamic containers

This commit is contained in:
Brendan Szymanski 2026-08-08 20:22:35 -04:00
parent 6772b805b7
commit facca97f73
28 changed files with 609 additions and 88 deletions

View file

@ -0,0 +1,22 @@
import Adw
import Gtk
@_spi(Portico) extension Adw.Carousel: DynamicChildHost {
/// Inserts a page at the requested carousel position.
public func insertChild(_ child: Gtk.Widget, at index: Int, after sibling: Gtk.Widget?) {
insert(child: child, position: Int32(index))
}
/// Appends a page to the end of the carousel.
public func appendChild(_ child: Gtk.Widget) {
append(child: child)
}
/// Removes a page from the carousel.
public func removeChild(_ child: Gtk.Widget) {
remove(child: child)
}
/// Uses remove-then-insert because the carousel reorder index's detach semantics
/// are not unambiguous at this abstraction boundary.
}

View file

@ -0,0 +1,24 @@
import Adw
import Gtk
@_spi(Portico) extension Adw.WrapBox: DynamicChildHost {
/// Inserts a child using Adw.WrapBox's sibling-based primitive.
public func insertChild(_ child: Gtk.Widget, at index: Int, after sibling: Gtk.Widget?) {
insertChildAfter(child: child, sibling: sibling)
}
/// Appends a child to the end of the wrap box.
public func appendChild(_ child: Gtk.Widget) {
append(child: child)
}
/// Removes a child from the wrap box.
public func removeChild(_ child: Gtk.Widget) {
remove(child: child)
}
/// Reorders a child using Adw.WrapBox's sibling-based primitive.
public func moveChild(_ child: Gtk.Widget, to index: Int, after sibling: Gtk.Widget?) {
reorderChildAfter(child: child, sibling: sibling)
}
}

View file

@ -11,12 +11,20 @@ public struct AnyView: View {
/// given a mount context.
@_spi(Portico) public let makeWidget: (MountContext) -> Gtk.Widget
/// A group mount operation when this view can spread several widgets into
/// an ordered dynamic parent; otherwise `nil`.
@_spi(Portico) public let group: (any DynamicViewGroup)?
public var body: Never { fatalError() }
/// Wraps a pre-built mount thunk directly; used by container mounts
/// that already hold a thunk.
@_spi(Portico) public init(makeWidget: @escaping (MountContext) -> Gtk.Widget) {
@_spi(Portico) public init(
makeWidget: @escaping (MountContext) -> Gtk.Widget,
group: (any DynamicViewGroup)? = nil
) {
self.makeWidget = makeWidget
self.group = group
}
/// Creates a type-erased view wrapping the given view.
@ -26,8 +34,10 @@ public struct AnyView: View {
public init<V: View>(_ view: V) {
if let any = view as? AnyView {
self.makeWidget = any.makeWidget
self.group = any.group
} else if let m = view as? Mountable {
self.makeWidget = { ctx in m.mount(ctx) }
self.group = view as? any DynamicViewGroup
} else {
// User struct: bind its environment-backed properties to the
// mounting scope, then evaluate body once.
@ -35,6 +45,7 @@ public struct AnyView: View {
_resolveDynamicProperties(view, in: ctx.environment)
return AnyView(view.body).makeWidget(ctx)
}
self.group = view as? any DynamicViewGroup
}
}
}
}

View file

@ -0,0 +1,111 @@
import Gtk
/// Tracks static children and independently mutable dynamic regions in one host.
///
/// A ledger converts a region-local position into the host's absolute position and
/// supplies the preceding sibling required by sibling-based widget APIs.
@_spi(Portico) @MainActor public final class ChildLedger {
private let host: any DynamicChildHost
fileprivate var regions: [[Gtk.Widget]] = []
private var staticTail: Int?
/// Creates a ledger for a freshly mounted dynamic child host.
@_spi(Portico) public init(host: any DynamicChildHost) {
self.host = host
}
/// Records a widget already appended directly to the host.
@_spi(Portico) public func appendStatic(_ child: Gtk.Widget) {
if staticTail == nil {
regions.append([])
staticTail = regions.count - 1
}
regions[staticTail!].append(child)
}
/// Opens an independently mutable region at the current tail.
@_spi(Portico) public func addRegion() -> ChildRegion {
regions.append([])
staticTail = nil
return ChildRegion(ledger: self, index: regions.count - 1)
}
fileprivate func base(of regionIndex: Int) -> Int {
regions[..<regionIndex].reduce(0) { $0 + $1.count }
}
fileprivate func widget(before absolute: Int) -> Gtk.Widget? {
guard absolute > 0 else { return nil }
var offset = absolute - 1
for region in regions {
if offset < region.count {
return region[offset]
}
offset -= region.count
}
return nil
}
fileprivate func insert(_ child: Gtk.Widget, at position: Int, in regionIndex: Int) {
regions[regionIndex].insert(child, at: position)
let absolute = base(of: regionIndex) + position
host.insertChild(child, at: absolute, after: widget(before: absolute))
}
fileprivate func remove(at position: Int, in regionIndex: Int) {
let child = regions[regionIndex].remove(at: position)
host.removeChild(child)
}
/// Moves a contiguous range to a region-local destination.
///
/// Widgets are removed from the host before any are inserted, so index-based
/// hosts (ListBox, FlowBox, Carousel) that use remove-then-insert see stable
/// absolute positions for each insertion. The ledger is updated to the final
/// order before insertion, so the sibling computed from `widget(before:)`
/// is already in its final host position for sibling-based hosts (Box, WrapBox).
fileprivate func move(from: Int, count: Int, to: Int, in regionIndex: Int) {
guard count != 0, from != to else { return }
let moved = Array(regions[regionIndex][from..<(from + count)])
for child in moved { host.removeChild(child) }
regions[regionIndex].removeSubrange(from..<(from + count))
regions[regionIndex].insert(contentsOf: moved, at: to)
let base = base(of: regionIndex)
for (offset, child) in moved.enumerated() {
let absolute = base + to + offset
host.insertChild(child, at: absolute, after: widget(before: absolute))
}
}
}
/// A mutable contiguous region owned by a ``ChildLedger``.
@_spi(Portico) @MainActor public struct ChildRegion {
private let ledger: ChildLedger
private let index: Int
/// Creates a region handle for the ledger's current region.
fileprivate init(ledger: ChildLedger, index: Int) {
self.ledger = ledger
self.index = index
}
/// The number of widgets currently held by this region.
@_spi(Portico) public var count: Int {
ledger.regions[index].count
}
/// Inserts a widget at a region-local position.
@_spi(Portico) public func insert(_ child: Gtk.Widget, at position: Int) {
ledger.insert(child, at: position, in: index)
}
/// Removes the widget at a region-local position.
@_spi(Portico) public func remove(at position: Int) {
ledger.remove(at: position, in: index)
}
/// Moves a contiguous range to a region-local destination.
@_spi(Portico) public func move(from: Int, count: Int, to: Int) {
ledger.move(from: from, count: count, to: to, in: index)
}
}

View file

@ -0,0 +1,40 @@
import Gtk
/// SPI protocol for views that can mount multiple widgets into one parent region.
@_spi(Portico) @MainActor public protocol DynamicViewGroup {
/// Mounts the group's widgets into `region` and registers its reactive resources.
func mountRegion(_ region: ChildRegion, ctx: MountContext)
}
/// Mounts static views and dynamic groups into an ordered parent container.
///
/// A ledger is allocated only when at least one view can spread and the parent
/// conforms to ``DynamicChildHost``. Otherwise every view uses the ordinary
/// single-widget mount path.
@_spi(Portico) @MainActor
public func mountChildren(
_ views: [AnyView],
into host: Gtk.Widget,
_ ctx: MountContext,
append: (Gtk.Widget) -> Void
) {
guard views.contains(where: { $0.group != nil }),
let dynamicHost = host as? any DynamicChildHost
else {
for view in views {
append(view.makeWidget(ctx))
}
return
}
let ledger = ChildLedger(host: dynamicHost)
for view in views {
if let group = view.group {
group.mountRegion(ledger.addRegion(), ctx: ctx)
} else {
let widget = view.makeWidget(ctx)
append(widget)
ledger.appendStatic(widget)
}
}
}

View file

@ -0,0 +1,13 @@
import Gtk
@_spi(Portico) extension Gtk.Box: DynamicChildHost {
/// Inserts a child using Gtk.Box's sibling-based primitive.
public func insertChild(_ child: Gtk.Widget, at index: Int, after sibling: Gtk.Widget?) {
insertChildAfter(child: child, sibling: sibling)
}
/// Reorders a child using Gtk.Box's sibling-based primitive.
public func moveChild(_ child: Gtk.Widget, to index: Int, after sibling: Gtk.Widget?) {
reorderChildAfter(child: child, sibling: sibling)
}
}

View file

@ -0,0 +1,18 @@
@_spi(SGTKInternal) import Gtk
@_spi(Portico) extension Gtk.FlowBox: DynamicChildHost {
/// Inserts a child at the requested flow-box position.
public func insertChild(_ child: Gtk.Widget, at index: Int, after sibling: Gtk.Widget?) {
insert(widget: child, position: Int32(index))
}
/// Appends a child to the end of the flow box.
public func appendChild(_ child: Gtk.Widget) {
append(child: child)
}
public func removeChild(_ child: Gtk.Widget) {
let target = child.getParent().flatMap { $0.pointer == pointer ? nil : $0 } ?? child
remove(widget: target)
}
}

View file

@ -0,0 +1,18 @@
@_spi(SGTKInternal) import Gtk
@_spi(Portico) extension Gtk.ListBox: DynamicChildHost {
/// Inserts a child at the requested list position.
public func insertChild(_ child: Gtk.Widget, at index: Int, after sibling: Gtk.Widget?) {
insert(child: child, position: Int32(index))
}
/// Appends a child to the end of the list box.
public func appendChild(_ child: Gtk.Widget) {
append(child: child)
}
public func removeChild(_ child: Gtk.Widget) {
let target = child.getParent().flatMap { $0.pointer == pointer ? nil : $0 } ?? child
remove(child: target)
}
}

View file

@ -11,3 +11,24 @@ import Gtk
/// Removes a previously added child widget.
func removeChild(_ child: Gtk.Widget)
}
/// SPI capability for containers that support indexed insertion and movement.
///
/// `index` and `sibling` describe the same target position: `index` is the child's
/// absolute zero-based position after the operation, while `sibling` is the child
/// that immediately precedes it (`nil` means the first position). Implementations
/// use whichever representation their native widget API requires.
@_spi(Portico) @MainActor public protocol DynamicChildHost: SequentialContainer {
/// Inserts a child at the target position.
func insertChild(_ child: Gtk.Widget, at index: Int, after sibling: Gtk.Widget?)
/// Moves a child to the target position.
func moveChild(_ child: Gtk.Widget, to index: Int, after sibling: Gtk.Widget?)
}
@_spi(Portico) extension DynamicChildHost {
/// Moves a child using the universally available remove-then-insert operation.
@_spi(Portico) public func moveChild(_ child: Gtk.Widget, to index: Int, after sibling: Gtk.Widget?) {
removeChild(child)
insertChild(child, at: index, after: sibling)
}
}

View file

@ -1,5 +1,15 @@
import Gtk
/// Forwards a group's region mount through a derived environment context.
@MainActor private struct EnvironmentGroup: DynamicViewGroup {
let base: any DynamicViewGroup
let derive: (MountContext) -> MountContext
func mountRegion(_ region: ChildRegion, ctx: MountContext) {
base.mountRegion(region, ctx: derive(ctx))
}
}
extension View {
/// Injects `value` into the environment slot named by `keyPath` for this
/// view and everything it mounts.
@ -17,9 +27,14 @@ extension View {
_ keyPath: WritableKeyPath<EnvironmentValues, Value>,
_ value: Value
) -> AnyView {
AnyView(makeWidget: { ctx in
AnyView(self).makeWidget(ctx.withEnvironment { $0[keyPath: keyPath] = value })
})
let inner = AnyView(self)
let derive: (MountContext) -> MountContext = {
$0.withEnvironment { $0[keyPath: keyPath] = value }
}
return AnyView(
makeWidget: { ctx in inner.makeWidget(derive(ctx)) },
group: inner.group.map { EnvironmentGroup(base: $0, derive: derive) }
)
}
/// Injects `object`, keyed by its dynamic type, for this view's subtree.
@ -27,8 +42,13 @@ extension View {
/// Read it back with `@Environment(T.self)`. Change notification for an
/// `@Observable` object comes from Observation, not from the environment.
public func environment<T: AnyObject>(_ object: T) -> AnyView {
AnyView(makeWidget: { ctx in
AnyView(self).makeWidget(ctx.withEnvironment { $0.setObject(object) })
})
let inner = AnyView(self)
let derive: (MountContext) -> MountContext = {
$0.withEnvironment { $0.setObject(object) }
}
return AnyView(
makeWidget: { ctx in inner.makeWidget(derive(ctx)) },
group: inner.group.map { EnvironmentGroup(base: $0, derive: derive) }
)
}
}

View file

@ -67,6 +67,7 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter orientation: The `orientation` value forwarded to `Gtk.Box`.
/// - Parameter spacing: The amount of space between children.
@ -75,7 +76,7 @@ import Gdk
let childrenViews = children()
make = { _ in Gtk.Box(orientation: orientation, spacing: spacing) }
configure.append { w, ctx in
for v in childrenViews { w.append(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in w.append(child: c) } }
}
}

View file

@ -41,13 +41,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Adw.Carousel() }
configure.append { w, ctx in
for v in childrenViews { w.append(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in w.append(child: c) } }
}
}

View file

@ -85,13 +85,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Gtk.FlowBox() }
configure.append { w, ctx in
for v in childrenViews { w.append(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in w.append(child: c) } }
}
}

View file

@ -101,13 +101,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Gtk.InfoBar() }
configure.append { w, ctx in
for v in childrenViews { w.addChild(widget: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in w.addChild(widget: c) } }
}
}

View file

@ -56,13 +56,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Adw.Leaflet() }
configure.append { w, ctx in
for v in childrenViews { _ = w.append(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in _ = w.append(child: c) } }
}
}

View file

@ -92,13 +92,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Gtk.ListBox() }
configure.append { w, ctx in
for v in childrenViews { w.append(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in w.append(child: c) } }
}
}

View file

@ -61,6 +61,7 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
/// - Parameter headerSuffix: A `ViewBuilder` closure whose first view is mounted into the `headerSuffix` slot.
@ -69,7 +70,7 @@ import Gdk
let childrenViews = children()
make = { _ in Adw.PreferencesGroup() }
configure.append { w, ctx in
for v in childrenViews { w.add(child: v.makeWidget(ctx)) }
Portico.mountChildren(childrenViews, into: w, ctx) { c in w.add(child: c) }
if let v = headerSuffixViews.first { w.setHeaderSuffix(suffix: v.makeWidget(ctx)) }
}
}

View file

@ -47,13 +47,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Adw.Squeezer() }
configure.append { w, ctx in
for v in childrenViews { _ = w.add(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in _ = w.add(child: c) } }
}
}

View file

@ -76,13 +76,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Gtk.Stack() }
configure.append { w, ctx in
for v in childrenViews { _ = w.addChild(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in _ = w.addChild(child: c) } }
}
}

View file

@ -66,13 +66,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Adw.TabView() }
configure.append { w, ctx in
for v in childrenViews { _ = w.append(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in _ = w.append(child: c) } }
}
}

View file

@ -87,13 +87,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Adw.ViewStack() }
configure.append { w, ctx in
for v in childrenViews { _ = w.add(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in _ = w.add(child: c) } }
}
}

View file

@ -82,13 +82,14 @@ import Gdk
///
/// Each closure is evaluated once; children are added in order and slot closures mount their first view.
/// An empty closure adds no children and leaves slots unset.
/// A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.
///
/// - Parameter children: A `ViewBuilder` closure whose views are added in order.
public init(@ViewBuilder children: () -> [AnyView]) {
let childrenViews = children()
make = { _ in Adw.WrapBox() }
configure.append { w, ctx in
for v in childrenViews { w.append(child: v.makeWidget(ctx)) } }
Portico.mountChildren(childrenViews, into: w, ctx) { c in w.append(child: c) } }
}
}

View file

@ -1,107 +1,123 @@
import Gtk
/// A view that renders one row per element of a reactive array, keyed by a
/// stable identifier, and applies a keyed diff on change appending new rows,
/// stable identifier, and applies a keyed diff on change - appending new rows,
/// removing deleted rows, and reordering moved rows. Surviving rows are never
/// rebuilt (widget identity and subscriptions are preserved).
/// rebuilt, so widget identity and subscriptions are preserved.
///
/// Backed by a `Gtk.Box`. Reactivity requires a state-backed `Binding`
/// (e.g. `$items`); a custom `Binding(get:set:)` does not track and will not
/// update. Rows are built from an element value snapshot at insert time.
/// When placed in a dynamic ordered container's `children:` builder, rows spread
/// directly into that container. Supported parents are `Gtk.Box`, `HStack`,
/// `VStack`, `Gtk.ListBox`, `Gtk.FlowBox`, `Adw.WrapBox`, and `Adw.Carousel`.
/// In a single-widget slot, modifier closure, unsupported container, or after a
/// non-`.environment` modifier, it falls back to its own vertical `Gtk.Box`.
/// Reactivity requires a state-backed `Binding` (for example, `$items`); a custom
/// `Binding(get:set:)` does not track and will not update. Rows are built from an
/// element value snapshot at insert time.
@MainActor public struct ForEach<Element, ID: Hashable>: View {
private let data: Binding<[Element]>
private let id: KeyPath<Element, ID>
private let orientation: Gtk.Orientation
private let spacing: Int32
private let row: (Element) -> [AnyView]
public var body: Never { fatalError() }
/// Creates a keyed reactive collection of row views.
///
/// - Parameters:
/// - data: The reactive array driving the rows.
/// - id: Key path to each element's stable identifier. IDs MUST be
/// unique; on a duplicate the first occurrence wins and later ones are
/// ignored for that diff.
/// - orientation: Backing box orientation (default `.vertical`).
/// - spacing: Spacing between rows in pixels (default 0).
/// - id: Key path to each element's stable identifier. IDs MUST be unique;
/// on a duplicate the first occurrence wins and later ones are ignored
/// for that diff.
/// - row: Builds one row's views from an element value snapshot.
public init(
_ data: Binding<[Element]>,
id: KeyPath<Element, ID>,
orientation: Gtk.Orientation = .vertical,
spacing: Int32 = 0,
@ViewBuilder row: @escaping (Element) -> [AnyView]
) {
self.data = data
self.id = id
self.orientation = orientation
self.spacing = spacing
self.row = row
}
}
@_spi(Portico) extension ForEach: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: orientation, spacing: spacing)
// Per-key on-screen state, persisted across diffs in the tracker closure.
@_spi(Portico) extension ForEach: DynamicViewGroup {
@_spi(Portico) public func mountRegion(_ region: ChildRegion, ctx: MountContext) {
// Per-key on-screen state persists across diffs in the tracker closure.
var rows: [ID: (widgets: [Gtk.Widget], registry: NodeRegistry)] = [:]
var order: [ID] = []
/// Returns the local widget offset of the first widget in row `position`.
func start(_ position: Int) -> Int {
order[0..<position].reduce(0) { $0 + (rows[$1]?.widgets.count ?? 0) }
}
func applyDiff() {
// Deduplicate by id (first occurrence wins), preserving input order.
// Deduplicate by ID, keeping the first occurrence and input order.
var ordered: [(ID, Element)] = []
var seen = Set<ID>()
for e in data.wrappedValue {
let key = e[keyPath: id]
if seen.insert(key).inserted { ordered.append((key, e)) }
}
let newOrder = ordered.map { $0.0 }
let newSet = Set(newOrder)
// Removals: detach widgets, tear down the row's registry, drop it.
for key in order where !newSet.contains(key) {
if let r = rows[key] {
for w in r.widgets { box.remove(child: w) }
r.registry.teardown()
ctx.registry.removeChild(r.registry)
rows[key] = nil
for element in data.wrappedValue {
let key = element[keyPath: id]
if seen.insert(key).inserted {
ordered.append((key, element))
}
}
let target = Set(ordered.map(\.0))
// Insert new rows and enforce target order via running sibling.
var prev: Gtk.Widget? = nil
for (key, element) in ordered {
let entry: (widgets: [Gtk.Widget], registry: NodeRegistry)
if let existing = rows[key] {
entry = existing
var sib = prev
for w in entry.widgets {
box.reorderChildAfter(child: w, sibling: sib)
sib = w
}
// Detach removed rows and tear down only their child registries.
var position = 0
while position < order.count {
let key = order[position]
if target.contains(key) {
position += 1
continue
}
let entry = rows[key]!
let offset = start(position)
for _ in entry.widgets.indices {
region.remove(at: offset)
}
entry.registry.teardown()
ctx.registry.removeChild(entry.registry)
rows[key] = nil
order.remove(at: position)
}
// Place rows left to right. The processed prefix already matches the
// target prefix, so local offsets are valid for each operation.
for (position, (key, element)) in ordered.enumerated() {
if position < order.count && order[position] == key {
continue
}
if let current = order.firstIndex(of: key) {
let count = rows[key]!.widgets.count
region.move(from: start(current), count: count, to: start(position))
order.remove(at: current)
order.insert(key, at: position)
} else {
let child = ctx.makeChild()
let widgets = row(element).map { $0.makeWidget(child) }
entry = (widgets, child.registry)
rows[key] = entry
var sib = prev
for w in entry.widgets {
box.insertChildAfter(child: w, sibling: sib)
sib = w
var offset = start(position)
for widget in widgets {
region.insert(widget, at: offset)
offset += 1
}
rows[key] = (widgets, child.registry)
order.insert(key, at: position)
}
prev = entry.widgets.last ?? prev
}
order = newOrder
}
// Initial mount + dependency registration happen inside the tracker
// run (same pattern as EitherView/OptionalView): applyDiff reads
// data.wrappedValue, registering this tracker with the backing box.
let tracker = DependencyTracker(observing: false) { applyDiff() }
tracker.run()
ctx.registry.add(tracker)
}
}
@_spi(Portico) extension ForEach: Mountable {
/// Mounts rows into a vertical box when no dynamic parent can spread them.
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: .vertical, spacing: 0)
let ledger = ChildLedger(host: box)
mountRegion(ledger.addRegion(), ctx: ctx)
return box
}
}

View file

@ -24,9 +24,7 @@ import Gtk
@_spi(Portico) extension HStack: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: .horizontal, spacing: spacing)
for child in children {
box.appendChild(child.makeWidget(ctx))
}
mountChildren(children, into: box, ctx) { box.appendChild($0) }
return box
}
}

View file

@ -24,9 +24,7 @@ import Gtk
@_spi(Portico) extension VStack: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: .vertical, spacing: spacing)
for child in children {
box.appendChild(child.makeWidget(ctx))
}
mountChildren(children, into: box, ctx) { box.appendChild($0) }
return box
}
}

View file

@ -495,7 +495,7 @@ func generateContentInit(widget: WidgetModel, baseParams: [Param], structName: S
let qualifiedArgs = baseArgs.joined(separator: ", ")
let childLines: [String] = primary.map { adder in
let discard = adder.returnsValue ? "_ = " : ""
return [" for v in childrenViews { \(discard)w.\(adder.methodName)(\(adder.label): v.makeWidget(ctx)) }"]
return [" Portico.mountChildren(childrenViews, into: w, ctx) { c in \(discard)w.\(adder.methodName)(\(adder.label): c) }"]
} ?? []
func emitInit(_ params: String, _ args: String, interpolationBind: String? = nil, generic: Bool = false) -> String {
let genericPrefix = generic ? "<S: StringProtocol>" : ""
@ -511,10 +511,13 @@ func generateContentInit(widget: WidgetModel, baseParams: [Param], structName: S
out += " }\n }\n"
return out
}
let baseNotes = [
var baseNotes = [
"Each closure is evaluated once; children are added in order and slot closures mount their first view.",
"An empty closure adds no children and leaves slots unset.",
]
if primary != nil {
baseNotes.append("A `ForEach` in the `children:` closure spreads its rows directly into this widget, with no wrapper, when the widget supports ordered insertion.")
}
func contentDoc(chain: [String], notes: [String]) -> String {
docBlock(
indent: " ",

View file

@ -0,0 +1,197 @@
import Testing
@_spi(SGTKInternal) import Adw
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
private enum AccentKey: EnvironmentKey {
static let defaultValue = "default-accent"
}
extension EnvironmentValues {
fileprivate var accent: String {
get { self[AccentKey.self] }
set { self[AccentKey.self] = newValue }
}
}
private struct AccentLabel: View {
@Environment(\.accent) private var accent
var body: some View {
Label(str: "").label { accent }
}
}
@MainActor @Suite(.serialized) struct ForEachSpreadTests {
private func directLabels(_ widget: Gtk.Widget) -> [String] {
var result: [String] = []
var child = widget.getFirstChild()
while let current = child {
if current.cssName == "label" {
result.append(Gtk.Label(retaining: current.pointer).getText())
} else {
result.append("<\(current.cssName)>")
}
child = current.getNextSibling()
}
return result
}
private func wrappedLabels(_ widget: Gtk.Widget) -> [String] {
var result: [String] = []
var child = widget.getFirstChild()
while let current = child {
if let inner = current.getFirstChild() {
result.append(Gtk.Label(retaining: inner.pointer).getText())
}
child = current.getNextSibling()
}
return result
}
@Test func spreadsIntoWrapBoxWithoutWrapper() {
guard Gtk.initCheck() else { return }
let items = StateBox([(1, "Nachos"), (2, "Tacos")])
let view = WrapBox {
Label(str: "Header")
ForEach(Binding(items), id: \.0) { Label(str: $0.1) }
}
let wrap = AnyView(view).makeWidget(MountContext()) as! Adw.WrapBox
#expect(directLabels(wrap) == ["Header", "Nachos", "Tacos"])
}
@Test func wrapBoxDiffRespectsLeadingStaticChild() {
guard Gtk.initCheck() else { return }
let items = StateBox([(1, "Nachos"), (2, "Tacos")])
let view = WrapBox {
Label(str: "Header")
ForEach(Binding(items), id: \.0) { Label(str: $0.1) }
}
let wrap = AnyView(view).makeWidget(MountContext()) as! Adw.WrapBox
items.set([(1, "Nachos"), (2, "Tacos"), (3, "Enchiladas")])
#expect(directLabels(wrap) == ["Header", "Nachos", "Tacos", "Enchiladas"])
items.set([(2, "Tacos"), (3, "Enchiladas")])
#expect(directLabels(wrap) == ["Header", "Tacos", "Enchiladas"])
items.set([(3, "Enchiladas"), (2, "Tacos")])
#expect(directLabels(wrap) == ["Header", "Enchiladas", "Tacos"])
}
@Test func spreadsIntoListBoxAndRemovesWrappedRow() {
guard Gtk.initCheck() else { return }
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
let view = ListBox {
ForEach(Binding(items), id: \.0) { Label(str: $0.1) }
}
let list = AnyView(view).makeWidget(MountContext()) as! Gtk.ListBox
#expect(wrappedLabels(list) == ["One", "Two", "Three"])
items.set([(1, "One"), (3, "Three")])
#expect(wrappedLabels(list) == ["One", "Three"])
}
@Test func spreadsIntoCarousel() {
guard Gtk.initCheck() else { return }
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
let view = Carousel {
ForEach(Binding(items), id: \.0) { Label(str: $0.1) }
}
let carousel = AnyView(view).makeWidget(MountContext()) as! Adw.Carousel
#expect(carousel.getNPages() == 3)
items.set([(1, "One"), (3, "Three")])
#expect(carousel.getNPages() == 2)
}
@Test func twoForEachRegionsStayIndependent() {
guard Gtk.initCheck() else { return }
let first = StateBox([(1, "A1"), (2, "A2")])
let second = StateBox([(3, "B1"), (4, "B2")])
let view = WrapBox {
ForEach(Binding(first), id: \.0) { Label(str: $0.1) }
ForEach(Binding(second), id: \.0) { Label(str: $0.1) }
}
let wrap = AnyView(view).makeWidget(MountContext()) as! Adw.WrapBox
first.set([(1, "A1"), (2, "A2"), (5, "A3")])
#expect(directLabels(wrap) == ["A1", "A2", "A3", "B1", "B2"])
}
@Test func groupSurvivesEnvironmentModifier() {
guard Gtk.initCheck() else { return }
let items = StateBox([1, 2])
let view = WrapBox {
ForEach(Binding(items), id: \.self) { _ in AccentLabel() }
.environment(\.accent, "scoped")
}
let wrap = AnyView(view).makeWidget(MountContext()) as! Adw.WrapBox
#expect(directLabels(wrap) == ["scoped", "scoped"])
}
@Test func ledgerDownwardRangeMoveOnIndexHost() {
guard Gtk.initCheck() else { return }
let list = Gtk.ListBox()
let ledger = ChildLedger(host: list)
let region = ledger.addRegion()
for name in ["A", "B", "C", "D"] {
region.insert(Gtk.Label(str: name), at: region.count)
}
#expect(wrappedLabels(list) == ["A", "B", "C", "D"])
region.move(from: 1, count: 2, to: 2)
#expect(wrappedLabels(list) == ["A", "D", "B", "C"])
region.move(from: 0, count: 1, to: 3)
#expect(wrappedLabels(list) == ["D", "B", "C", "A"])
}
@Test func ledgerDownwardRangeMoveOnSiblingHost() {
guard Gtk.initCheck() else { return }
let box = Gtk.Box(orientation: .vertical, spacing: 0)
let ledger = ChildLedger(host: box)
let region = ledger.addRegion()
for name in ["A", "B", "C", "D"] {
region.insert(Gtk.Label(str: name), at: region.count)
}
#expect(directLabels(box) == ["A", "B", "C", "D"])
region.move(from: 1, count: 2, to: 2)
#expect(directLabels(box) == ["A", "D", "B", "C"])
region.move(from: 0, count: 1, to: 3)
#expect(directLabels(box) == ["D", "B", "C", "A"])
}
@Test func ledgerUpwardRangeMoveOnSiblingHost() {
guard Gtk.initCheck() else { return }
let box = Gtk.Box(orientation: .vertical, spacing: 0)
let ledger = ChildLedger(host: box)
let region = ledger.addRegion()
for name in ["A", "B", "C", "D"] {
region.insert(Gtk.Label(str: name), at: region.count)
}
region.move(from: 2, count: 2, to: 0)
#expect(directLabels(box) == ["C", "D", "A", "B"])
region.move(from: 3, count: 1, to: 1)
#expect(directLabels(box) == ["C", "B", "D", "A"])
}
@Test func fallsBackToBoxInSingleWidgetPosition() {
guard Gtk.initCheck() else { return }
let items = StateBox([(1, "One"), (2, "Two")])
let view = ScrolledWindow {
ForEach(Binding(items), id: \.0) { Label(str: $0.1) }
}
let scrolled = AnyView(view).makeWidget(MountContext()) as! Gtk.ScrolledWindow
var child = scrolled.getChild()!
while child.cssName != "box" {
child = child.getFirstChild()!
}
let box = Gtk.Box(retaining: child.pointer)
#expect(directLabels(box) == ["One", "Two"])
}
}

View file

@ -48,8 +48,7 @@ import Testing
#expect(dots.getCarousel() == nil)
items.set([1])
let rows = Gtk.Box(retaining: first.getNextSibling()!.pointer)
let carousel = Adw.Carousel(retaining: rows.getFirstChild()!.pointer)
let carousel = Adw.Carousel(retaining: first.getNextSibling()!.pointer)
#expect(dots.getCarousel()?.pointer == carousel.pointer)
}