Spread ForEach rows into dynamic containers
This commit is contained in:
parent
6772b805b7
commit
facca97f73
28 changed files with 609 additions and 88 deletions
22
Sources/Portico/Core/AdwCarousel+DynamicChildHost.swift
Normal file
22
Sources/Portico/Core/AdwCarousel+DynamicChildHost.swift
Normal 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.
|
||||
}
|
||||
24
Sources/Portico/Core/AdwWrapBox+DynamicChildHost.swift
Normal file
24
Sources/Portico/Core/AdwWrapBox+DynamicChildHost.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
Sources/Portico/Core/ChildLedger.swift
Normal file
111
Sources/Portico/Core/ChildLedger.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
40
Sources/Portico/Core/DynamicViewGroup.swift
Normal file
40
Sources/Portico/Core/DynamicViewGroup.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Sources/Portico/Core/GtkBox+DynamicChildHost.swift
Normal file
13
Sources/Portico/Core/GtkBox+DynamicChildHost.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
18
Sources/Portico/Core/GtkFlowBox+DynamicChildHost.swift
Normal file
18
Sources/Portico/Core/GtkFlowBox+DynamicChildHost.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
18
Sources/Portico/Core/GtkListBox+DynamicChildHost.swift
Normal file
18
Sources/Portico/Core/GtkListBox+DynamicChildHost.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: " ",
|
||||
|
|
|
|||
197
Tests/PorticoTests/ForEachSpreadTests.swift
Normal file
197
Tests/PorticoTests/ForEachSpreadTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue