Compare commits

...

3 commits

19 changed files with 1302 additions and 24 deletions

View file

@ -1,14 +1,22 @@
import Portico
struct Counter: View {
@State private var count = 0
private struct Item: Identifiable { let id: Int; let title: String }
struct ListDemo: View {
@State private var items = [Item(id: 1, title: "One"),
Item(id: 2, title: "Two"),
Item(id: 3, title: "Three")]
@State private var next = 4
var body: some View {
VStack(spacing: 12) {
Label { "Count: \(count)" }
HStack(spacing: 12) {
Button("-") { count -= 1 }
Button("+") { count += 1 }
Button("Add") { items.append(Item(id: next, title: "Item \(next)")); next += 1 }
Button("Remove first") { if !items.isEmpty { items.removeFirst() } }
Button("Reverse") { items.reverse() }
}
ForEach($items, id: \.id) { item in
Label(item.title)
}
}
}
@ -19,8 +27,8 @@ struct ExampleApp: App {
var applicationId: String? { "dev.bscubed.PorticoExample" }
var body: some Scene {
Window { _ in Counter() }
.title("Portico P1")
Window { _ in ListDemo() }
.title("Portico P4 — ForEach")
.defaultSize(width: 1280, height: 800)
}
}

View file

@ -83,6 +83,10 @@ public extension Window {
let ctx = MountContext()
let child = AnyView(content(window)).makeWidget(ctx)
window.setContent(content: child)
_ = window.connectCloseRequest { [registry = ctx.registry] _ in
registry.teardown()
return false // allow the default close to proceed
}
window.present()
}
}

View file

@ -0,0 +1,70 @@
import Gtk
/// A view that switches between two branches based on a `Binding<Bool>` condition.
///
/// Internally uses `Gtk.Stack` with named children `"true"` and `"false"`.
/// Each branch's widgets are mounted at most once the first time that
/// branch becomes active. Subsequent condition changes only call
/// `setVisibleChildName`.
@MainActor public struct EitherView: View {
private let condition: Binding<Bool>
private let firstContent: [AnyView]
private let secondContent: [AnyView]
public var body: Never { fatalError() }
/// - Parameters:
/// - condition: The binding that selects the visible branch.
/// - first: The view for the `true` branch (mounted once, lazily).
/// - second: The view for the `false` branch (mounted once, lazily).
public init(
_ condition: Binding<Bool>,
@ViewBuilder first: () -> [AnyView],
@ViewBuilder second: () -> [AnyView]
) {
self.condition = condition
self.firstContent = first()
self.secondContent = second()
}
}
@_spi(Portico) extension EitherView: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let stack = Gtk.Stack()
// Branch widget caches nil until the branch is first mounted.
var trueWidgets: [Gtk.Widget]? = nil
var falseWidgets: [Gtk.Widget]? = nil
// Mount the initially-active branch first so it's visible immediately.
if condition.wrappedValue {
trueWidgets = firstContent.map { $0.makeWidget(ctx) }
for w in trueWidgets! { _ = stack.addNamed(child: w, name: "true") }
} else {
falseWidgets = secondContent.map { $0.makeWidget(ctx) }
for w in falseWidgets! { _ = stack.addNamed(child: w, name: "false") }
}
// Track changes re-evaluate condition, lazily mount the other
// branch on first flip.
let tracker = DependencyTracker { [stack] in
let isTrue = condition.wrappedValue
if isTrue {
if trueWidgets == nil {
trueWidgets = firstContent.map { $0.makeWidget(ctx) }
for w in trueWidgets! { _ = stack.addNamed(child: w, name: "true") }
}
stack.setVisibleChildName(name: "true")
} else {
if falseWidgets == nil {
falseWidgets = secondContent.map { $0.makeWidget(ctx) }
for w in falseWidgets! { _ = stack.addNamed(child: w, name: "false") }
}
stack.setVisibleChildName(name: "false")
}
}
tracker.run()
ctx.registry.add(tracker)
return stack
}
}

View file

@ -0,0 +1,107 @@
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,
/// removing deleted rows, and reordering moved rows. Surviving rows are never
/// rebuilt (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.
@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() }
/// - 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).
/// - 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.
var rows: [ID: (widgets: [Gtk.Widget], registry: NodeRegistry)] = [:]
var order: [ID] = []
func applyDiff() {
// Deduplicate by id (first occurrence wins), preserving 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
}
}
// 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
}
} 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
}
}
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 { applyDiff() }
tracker.run()
ctx.registry.add(tracker)
return box
}
}

View file

@ -0,0 +1,9 @@
import Gtk
@_spi(Portico) extension Gtk.Revealer: SingleChildContainer {
/// Delegates to `Gtk.Revealer.setChild(child:)` with the distinct SPI
/// name to support nullable child semantics (nil detaches).
@_spi(Portico) public func attachChild(_ child: Gtk.Widget?) {
setChild(child: child)
}
}

View file

@ -0,0 +1,42 @@
import Gtk
/// A view that shows or hides its child based on a `Binding<Bool>` condition.
///
/// Internally uses `Gtk.Revealer`. The child widget is mounted exactly once;
/// subsequent condition changes only toggle `setRevealChild`.
@MainActor public struct OptionalView: View {
private let condition: Binding<Bool>
private let content: [AnyView]
public var body: Never { fatalError() }
/// - Parameters:
/// - condition: The binding that controls visibility.
/// - content: The view to conditionally reveal (mounted once).
public init(
_ condition: Binding<Bool>,
@ViewBuilder content: () -> [AnyView]
) {
self.condition = condition
self.content = content()
}
}
@_spi(Portico) extension OptionalView: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let revealer = Gtk.Revealer()
// Mount child once build-once invariant.
for child in content {
revealer.attachChild(child.makeWidget(ctx))
}
// Apply initial state.
revealer.setRevealChild(revealChild: condition.wrappedValue)
// Track changes.
let tracker = DependencyTracker { [revealer] in
revealer.setRevealChild(revealChild: condition.wrappedValue)
}
tracker.run()
ctx.registry.add(tracker)
return revealer
}
}

View file

@ -0,0 +1,18 @@
import Gtk
/// Enables raw `Gtk.Widget` instances inside `@ViewBuilder` closures.
///
/// Any generated widget (`Gtk.Label`, `Gtk.Box`, ) can be used directly
/// as a view without an explicit Portico wrapper:
/// ```swift
/// VStack {
/// Gtk.Label(str: "Hello") // raw widget drop-in
/// }
/// ```
extension Gtk.Widget: View {
public var body: Never { fatalError() }
}
@_spi(Portico) extension Gtk.Widget: Mountable {
public func mount(_: MountContext) -> Gtk.Widget { self }
}

View file

@ -1,9 +1,20 @@
import Gtk
/// Context threaded through the mount pipeline.
///
/// Empty in P0; reserved for the P4 signal/subscription registry needed
/// for per-node teardown on unmount.
/// Context threaded through the mount pipeline. Carries the ``NodeRegistry``
/// that collects reactive resources for teardown.
@_spi(Portico) @MainActor public final class MountContext {
@_spi(Portico) public init() {}
/// The registry for the current subtree; reactive sites register here.
@_spi(Portico) public let registry: NodeRegistry
/// Creates a root context with a fresh registry.
@_spi(Portico) public init() { self.registry = NodeRegistry() }
private init(registry: NodeRegistry) { self.registry = registry }
/// Returns a child context whose registry is an independently-torn-down
/// child of this context's registry. Used per `ForEach` row so removing a
/// row releases only that row's resources.
@_spi(Portico) public func makeChild() -> MountContext {
let child = NodeRegistry()
registry.addChild(child)
return MountContext(registry: child)
}
}

View file

@ -0,0 +1,49 @@
import Gtk
/// Collects the reactive resources created while mounting one subtree so
/// they can be released together when that subtree unmounts.
///
/// Holds subscription tokens, signal handles, and dependency trackers, plus
/// child registries for independently-unmountable sub-subtrees (e.g. each
/// `ForEach` row). `teardown()` cancels tokens, disconnects handles, tears
/// down trackers, and recurses into children.
@_spi(Portico) @MainActor public final class NodeRegistry {
private var tokens: [SubscriptionToken] = []
private var handles: [SignalHandle] = []
private var trackers: [DependencyTracker] = []
private var children: [NodeRegistry] = []
@_spi(Portico) public init() {}
/// Registers a subscription token to cancel on teardown.
@_spi(Portico) public func add(_ token: SubscriptionToken) { tokens.append(token) }
/// Registers a GObject signal handle to disconnect on teardown.
@_spi(Portico) public func add(_ handle: SignalHandle) { handles.append(handle) }
/// Registers a dependency tracker to detach from its state boxes on teardown.
@_spi(Portico) public func add(_ tracker: DependencyTracker) { trackers.append(tracker) }
/// Registers a child registry for an independently-unmountable sub-subtree.
@_spi(Portico) public func addChild(_ child: NodeRegistry) { children.append(child) }
/// Removes a child registry (called after the child has been torn down,
/// e.g. a removed `ForEach` row) so this registry no longer retains it.
@_spi(Portico) public func removeChild(_ child: NodeRegistry) {
children.removeAll { $0 === child }
}
/// Releases every registered resource and recurses into child registries.
/// Safe to call more than once (token cancel and handle disconnect are
/// idempotent).
@_spi(Portico) public func teardown() {
for t in tokens { t.cancel() }
tokens.removeAll()
for i in handles.indices { handles[i].disconnect() }
handles.removeAll()
for tr in trackers { tr.teardown() }
trackers.removeAll()
for c in children { c.teardown() }
children.removeAll()
}
}

View file

@ -0,0 +1,289 @@
import Gtk
@_spi(SGTKInternal) import GObject
import CGObject
// MARK: - C GValue helpers (private)
/// Allocates a zero-initialized C `GValue` of the given type, calls `setup`,
/// passes it to `body`, then unsets it.
/// - Note: Inline helper to avoid `deinit` isolation issues under
/// `StrictConcurrency=complete` with module-wide `@MainActor`.
private func withGValue<R>(
gType: UInt,
setup: (UnsafeMutablePointer<GValue>) -> Void,
body: (UnsafeMutablePointer<GValue>) throws -> R
) rethrows -> R {
var gvalue = GValue()
portico_g_value_init(&gvalue, gType)
defer { portico_g_value_unset(&gvalue) }
setup(&gvalue)
return try body(&gvalue)
}
@_silgen_name("g_value_init")
private nonisolated func portico_g_value_init(_: UnsafeMutablePointer<GValue>, _: UInt)
@_silgen_name("g_value_unset")
private nonisolated func portico_g_value_unset(_: UnsafeMutablePointer<GValue>)
@_silgen_name("g_value_set_string")
private nonisolated func portico_g_value_set_string(_: UnsafeMutablePointer<GValue>, _: UnsafePointer<CChar>?)
@_silgen_name("g_value_set_int")
private nonisolated func portico_g_value_set_int(_: UnsafeMutablePointer<GValue>, _: Int32)
@_silgen_name("g_value_set_boolean")
private nonisolated func portico_g_value_set_boolean(_: UnsafeMutablePointer<GValue>, _: Int32)
@_silgen_name("g_value_set_double")
private nonisolated func portico_g_value_set_double(_: UnsafeMutablePointer<GValue>, _: Double)
// MARK: - Preferred width
extension View {
/// Sets the preferred width (static, applied once at mount).
public func preferredWidth(_ width: Int32) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
let (_, curH) = w.getSizeRequest()
w.setSizeRequest(width: width, height: curH)
return w
})
}
/// Sets the preferred width (reactive, updates on binding change).
public func preferredWidth(_ width: Binding<Int32>) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
let (_, curH) = w.getSizeRequest()
w.setSizeRequest(width: width.wrappedValue, height: curH)
ctx.registry.add(width.subscribe { [w] newW in
let (_, curH) = w.getSizeRequest()
w.setSizeRequest(width: newW, height: curH)
})
return w
})
}
}
// MARK: - Preferred height
extension View {
/// Sets the preferred height (static, applied once at mount).
public func preferredHeight(_ height: Int32) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
let (curW, _) = w.getSizeRequest()
w.setSizeRequest(width: curW, height: height)
return w
})
}
/// Sets the preferred height (reactive, updates on binding change).
public func preferredHeight(_ height: Binding<Int32>) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
let (curW, _) = w.getSizeRequest()
w.setSizeRequest(width: curW, height: height.wrappedValue)
ctx.registry.add(height.subscribe { [w] newH in
let (curW, _) = w.getSizeRequest()
w.setSizeRequest(width: curW, height: newH)
})
return w
})
}
}
// MARK: - Horizontal expand
extension View {
/// Sets whether the widget expands horizontally (static).
public func hexpand(_ expand: Bool) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.setHexpand(expand: expand)
return w
})
}
/// Sets whether the widget expands horizontally (reactive).
public func hexpand(_ expand: Binding<Bool>) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.setHexpand(expand: expand.wrappedValue)
ctx.registry.add(expand.subscribe { [w] v in
w.setHexpand(expand: v)
})
return w
})
}
}
// MARK: - Vertical expand
extension View {
/// Sets whether the widget expands vertically (static).
public func vexpand(_ expand: Bool) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.setVexpand(expand: expand)
return w
})
}
/// Sets whether the widget expands vertically (reactive).
public func vexpand(_ expand: Binding<Bool>) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.setVexpand(expand: expand.wrappedValue)
ctx.registry.add(expand.subscribe { [w] v in
w.setVexpand(expand: v)
})
return w
})
}
}
// MARK: - Margin
extension View {
/// Sets the margin on all four sides to the same value (static).
public func margin(_ m: Int32) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.setMarginStart(margin: m)
w.setMarginEnd(margin: m)
w.setMarginTop(margin: m)
w.setMarginBottom(margin: m)
return w
})
}
/// Sets the margin on all four sides to the same value (reactive).
public func margin(_ m: Binding<Int32>) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
let initial = m.wrappedValue
w.setMarginStart(margin: initial)
w.setMarginEnd(margin: initial)
w.setMarginTop(margin: initial)
w.setMarginBottom(margin: initial)
ctx.registry.add(m.subscribe { [w] v in
w.setMarginStart(margin: v)
w.setMarginEnd(margin: v)
w.setMarginTop(margin: v)
w.setMarginBottom(margin: v)
})
return w
})
}
}
// MARK: - CSS class
extension View {
/// Adds a CSS class to the widget (static).
public func cssClass(_ name: String) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.addCssClass(cssClass: name)
return w
})
}
/// Adds a CSS class to the widget (reactive).
///
/// When the binding changes, the old class is removed and the new one
/// is added.
public func cssClass(_ name: Binding<String>) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
let initial = name.wrappedValue
if !initial.isEmpty {
w.addCssClass(cssClass: initial)
}
var prev = initial
ctx.registry.add(name.subscribe { [w] newName in
guard newName != prev else { return }
w.removeCssClass(cssClass: prev)
if !newName.isEmpty {
w.addCssClass(cssClass: newName)
}
prev = newName
})
return w
})
}
}
// MARK: - .bind escape hatch
extension View {
/// Binds a string-typed value to a named GObject property.
public func bind(_ value: Binding<String>, to property: String) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
value.wrappedValue.withCString { cstr in
withGValue(gType: gTypeString, setup: { portico_g_value_set_string($0, cstr) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
}
ctx.registry.add(value.subscribe { [w] newV in
newV.withCString { cstr in
withGValue(gType: gTypeString, setup: { portico_g_value_set_string($0, cstr) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
}
})
return w
})
}
/// Binds an int32-typed value to a named GObject property.
public func bind(_ value: Binding<Int32>, to property: String) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
withGValue(gType: gTypeInt, setup: { portico_g_value_set_int($0, value.wrappedValue) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
ctx.registry.add(value.subscribe { [w] newV in
withGValue(gType: gTypeInt, setup: { portico_g_value_set_int($0, newV) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
})
return w
})
}
/// Binds a boolean value to a named GObject property.
public func bind(_ value: Binding<Bool>, to property: String) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
withGValue(gType: gTypeBoolean, setup: { portico_g_value_set_boolean($0, value.wrappedValue ? 1 : 0) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
ctx.registry.add(value.subscribe { [w] newV in
withGValue(gType: gTypeBoolean, setup: { portico_g_value_set_boolean($0, newV ? 1 : 0) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
})
return w
})
}
/// Binds a double value to a named GObject property.
public func bind(_ value: Binding<Double>, to property: String) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
withGValue(gType: gTypeDouble, setup: { portico_g_value_set_double($0, value.wrappedValue) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
ctx.registry.add(value.subscribe { [w] newV in
withGValue(gType: gTypeDouble, setup: { portico_g_value_set_double($0, newV) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
})
return w
})
}
}

View file

@ -1,8 +1,9 @@
/// Result builder for composing ``View`` hierarchies.
///
/// Conditionals (`buildOptional`/`buildEither`) are passthrough in P0
/// (they collapse to plain arrays) correct for the static-only scope.
/// P2 replaces them to emit `OptionalView`/`EitherView` nodes.
/// `buildOptional`/`buildEither` are static-only passthroughs
/// they collapse to plain arrays. Dynamic conditionals use the
/// explicit ``EitherView`` and ``OptionalView`` types which accept
/// a `Binding<Bool>` and mount branches via `Gtk.Stack`/`Gtk.Revealer`.
@resultBuilder public enum ViewBuilder {
/// Wraps a single ``View`` in an ``AnyView``.
public static func buildExpression<V: View>(_ v: V) -> [AnyView] { [AnyView(v)] }

View file

@ -9,6 +9,7 @@
@_spi(Portico) public static var current: DependencyTracker?
private let body: () -> Void
private var deregistrations: [() -> Void] = []
@_spi(Portico) public init(_ body: @escaping () -> Void) {
self.body = body
@ -25,4 +26,17 @@
/// Re-evaluates the body; called by a state box when its value changes.
@_spi(Portico) public func reevaluate() { run() }
/// Records how to detach this tracker from a state box it registered with.
/// Called by ``StateBox/get()`` on first registration.
@_spi(Portico) public func addDeregistration(_ deregister: @escaping () -> Void) {
deregistrations.append(deregister)
}
/// Detaches this tracker from every state box it depends on, so it stops
/// re-evaluating. Idempotent.
@_spi(Portico) public func teardown() {
for d in deregistrations { d() }
deregistrations.removeAll()
}
}

View file

@ -18,7 +18,11 @@
/// as a dependent, then returns the value.
@_spi(Portico) public func get() -> Value {
if let t = DependencyTracker.current {
dependents[ObjectIdentifier(t)] = t
let oid = ObjectIdentifier(t)
if dependents[oid] == nil {
dependents[oid] = t
t.addDeregistration { [weak self] in self?.dependents[oid] = nil }
}
}
return value
}

View file

@ -3,9 +3,8 @@ import Gtk
/// A button with a click action, backed by `Gtk.Button`.
///
/// Supports text labels and icon-name variants. The `connectClicked`
/// ``SignalHandle`` is intentionally discarded in P0 the closure box
/// is retained by GObject for the widget's lifetime. Per-node teardown
/// is wired in P4.
/// ``SignalHandle`` is registered with the mount context's ``NodeRegistry``
/// for teardown on unmount.
@MainActor public struct Button: View {
private enum Kind {
case label(String)
@ -38,7 +37,7 @@ import Gtk
case .icon(let s): button = Gtk.Button(iconName: s)
}
let action = self.action
_ = button.connectClicked { _ in action() }
ctx.registry.add(button.connectClicked { _ in action() })
return button
}
}

View file

@ -45,14 +45,15 @@ import Gtk
label.setText(str: s)
case .binding(let b):
label.setText(str: b.wrappedValue)
_ = b.subscribe { [label] v in
ctx.registry.add(b.subscribe { [label] v in
label.setText(str: v)
}
})
case .dynamic(let make):
let tracker = DependencyTracker { [label] in
label.setText(str: make())
}
tracker.run()
ctx.registry.add(tracker)
}
return label
}

View file

@ -0,0 +1,168 @@
import Testing
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
@MainActor @Suite(.serialized) struct ConditionalTests {
// MARK: - EitherView
@Test func eitherViewInitiallyTrueBranch() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(true)
let binding = Binding(box)
let view = EitherView(binding,
first: { FirstProbeLabel() },
second: { SecondProbeLabel() }
)
let widget = AnyView(view).makeWidget(MountContext())
let stack = widget as! Gtk.Stack
// Initially, only the "true" branch is mounted and visible.
#expect(stack.getVisibleChildName() == "true")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 0)
// Toggle to false lazy-mounts the "false" branch.
box.set(false)
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
// Toggle back to true no new mounts.
box.set(true)
#expect(stack.getVisibleChildName() == "true")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
}
@Test func eitherViewInitiallyFalseBranch() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(false)
let binding = Binding(box)
let view = EitherView(binding,
first: { FirstProbeLabel() },
second: { SecondProbeLabel() }
)
let widget = AnyView(view).makeWidget(MountContext())
let stack = widget as! Gtk.Stack
// Initially, only the "false" branch is mounted and visible.
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 0)
#expect(ConditionProbe.secondCount == 1)
// Toggle to true lazy-mounts the "true" branch.
box.set(true)
#expect(stack.getVisibleChildName() == "true")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
// Toggle back to false no new mounts.
box.set(false)
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
}
@Test func eitherViewTracksConditionChanges() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(true)
let binding = Binding(box)
let view = EitherView(binding,
first: { FirstProbeLabel() },
second: { SecondProbeLabel() }
)
let widget = AnyView(view).makeWidget(MountContext())
let stack = widget as! Gtk.Stack
// Three toggles: truefalsetruefalse
box.set(false)
box.set(true)
box.set(false)
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
}
// MARK: - OptionalView
@Test func optionalViewMountsChildOnce() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(true)
let binding = Binding(box)
let view = OptionalView(binding) { FirstProbeLabel() }
let widget = AnyView(view).makeWidget(MountContext())
let revealer = widget as! Gtk.Revealer
// Initially revealed, child mounted once.
#expect(revealer.getRevealChild() == true)
#expect(ConditionProbe.firstCount == 1)
// Toggle to false child still mounted, just hidden.
box.set(false)
#expect(revealer.getRevealChild() == false)
#expect(ConditionProbe.firstCount == 1)
// Toggle back to true still same child.
box.set(true)
#expect(revealer.getRevealChild() == true)
#expect(ConditionProbe.firstCount == 1)
}
@Test func optionalViewHidesWhenConditionFalse() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(false)
let binding = Binding(box)
let view = OptionalView(binding) { FirstProbeLabel() }
let widget = AnyView(view).makeWidget(MountContext())
let revealer = widget as! Gtk.Revealer
// Initially hidden, but child WAS mounted (build-once).
#expect(revealer.getRevealChild() == false)
#expect(ConditionProbe.firstCount == 1)
// Toggle to true child visible, no remount.
box.set(true)
#expect(revealer.getRevealChild() == true)
#expect(ConditionProbe.firstCount == 1)
}
}
// MARK: - Test helpers
@MainActor fileprivate enum ConditionProbe {
static var firstCount = 0
static var secondCount = 0
static func reset() {
firstCount = 0
secondCount = 0
}
}
@MainActor fileprivate struct FirstProbeLabel: View, Mountable {
var body: Never { fatalError() }
func mount(_ ctx: MountContext) -> Gtk.Widget {
ConditionProbe.firstCount += 1
return Gtk.Label(str: "first")
}
}
@MainActor fileprivate struct SecondProbeLabel: View, Mountable {
var body: Never { fatalError() }
func mount(_ ctx: MountContext) -> Gtk.Widget {
ConditionProbe.secondCount += 1
return Gtk.Label(str: "second")
}
}

View file

@ -0,0 +1,179 @@
import Testing
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
// MARK: - Probe types
/// A label that records how many times it was mounted per key,
/// for verifying ForEach rebuild-avoidance.
private final class MountCounter {
var counts: [Int: Int] = [:]
func inc(_ key: Int) { counts[key, default: 0] += 1 }
}
private struct ProbeRow: View {
let key: Int
let label: String
let counter: MountCounter
var body: Never { fatalError() }
init(key: Int, label: String, counter: MountCounter) {
self.key = key; self.label = label; self.counter = counter
}
}
@_spi(Portico) extension ProbeRow: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
counter.inc(key)
return Gtk.Label(str: label)
}
}
/// A shared mutable dictionary for tracking per-key subscription fires.
private final class FireCounter {
var fires: [Int: Int] = [:]
func inc(_ key: Int) { fires[key, default: 0] += 1 }
}
/// A row that subscribes to a StateBox so we can verify the subscription
/// is cancelled on row removal.
private struct SubscribingRow: View {
let key: Int
let box: StateBox<Int>
let counter: FireCounter
var body: Never { fatalError() }
init(key: Int, box: StateBox<Int>, counter: FireCounter) {
self.key = key; self.box = box; self.counter = counter
}
}
@_spi(Portico) extension SubscribingRow: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
ctx.registry.add(box.subscribe { [key, counter] _ in
counter.inc(key)
})
return Gtk.Label(str: "row \(key)")
}
}
// MARK: - Tests
@MainActor @Suite(.serialized) struct ForEachTests {
// Helper: collect visible child labels from a Gtk.Box in order.
private func childLabels(_ box: Gtk.Box) -> [String] {
var result: [String] = []
var child = box.getFirstChild()
while let w = child {
// getFirstChild returns base Widget; reconstruct Label from pointer.
let lbl = Gtk.Label(retaining: w.pointer)
result.append(lbl.getText())
child = w.getNextSibling()
}
return result
}
// Helper: count the children of a Gtk.Box.
private func childCount(_ box: Gtk.Box) -> Int {
var n = 0
var child = box.getFirstChild()
while let w = child { n += 1; child = w.getNextSibling() }
return n
}
@Test func initialMountRendersRowsInOrder() {
guard Gtk.initCheck() else { return }
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
let binding = Binding(items)
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
Label(title)
}
let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
#expect(childCount(box) == 3)
#expect(childLabels(box) == ["One", "Two", "Three"])
}
@Test func appendAddsRowWithoutRebuild() {
guard Gtk.initCheck() else { return }
let counter = MountCounter()
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
let binding = Binding(items)
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
ProbeRow(key: id, label: title, counter: counter)
}
let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
#expect(childCount(box) == 3)
#expect(counter.counts[1] == 1)
#expect(counter.counts[2] == 1)
#expect(counter.counts[3] == 1)
var arr = items.get()
arr.append((4, "Four"))
items.set(arr)
#expect(childCount(box) == 4)
// Previously-mounted keys still counted once (never rebuilt).
#expect(counter.counts[1] == 1)
#expect(counter.counts[2] == 1)
#expect(counter.counts[3] == 1)
#expect(counter.counts[4] == 1)
}
@Test func removeDropsRowAndTearsDownSubscription() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let fires = FireCounter()
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
let binding = Binding(items)
// Use actual Gtk.Labels with subscriptions that increment fires.
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
SubscribingRow(key: id, box: box, counter: fires)
}
let container = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
#expect(childCount(container) == 3)
// Fire all: every row's subscription fires.
box.set(1)
#expect(fires.fires[1] == 1 && fires.fires[2] == 1 && fires.fires[3] == 1)
// Remove the middle element.
var arr = items.get()
arr.remove(at: 1) // remove key 2
items.set(arr)
#expect(childCount(container) == 2)
#expect(childLabels(container) == ["row 1", "row 3"])
// Fire again: removed row 2's subscription must NOT respond.
box.set(2)
#expect(fires.fires[1] == 2)
#expect(fires.fires[2] == 1) // unchanged subscription was cancelled
#expect(fires.fires[3] == 2)
}
@Test func reorderPreservesWidgetsNoRebuild() {
guard Gtk.initCheck() else { return }
let counter = MountCounter()
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
let binding = Binding(items)
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
ProbeRow(key: id, label: title, counter: counter)
}
let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
#expect(childLabels(box) == ["One", "Two", "Three"])
#expect(counter.counts[1] == 1)
#expect(counter.counts[2] == 1)
#expect(counter.counts[3] == 1)
// Reverse the array.
var arr = items.get()
arr.reverse()
items.set(arr)
#expect(childLabels(box) == ["Three", "Two", "One"])
// Mount counts unchanged identity preserved.
#expect(counter.counts[1] == 1)
#expect(counter.counts[2] == 1)
#expect(counter.counts[3] == 1)
}
}

View file

@ -0,0 +1,248 @@
import Testing
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
@MainActor @Suite(.serialized) struct ModifierTests {
// MARK: - Static modifiers
@Test func staticPreferredWidth() {
guard Gtk.initCheck() else { return }
let w = AnyView(Label("x").preferredWidth(42)).makeWidget(MountContext())
#expect(w.getSizeRequest().width == 42)
}
@Test func staticPreferredHeight() {
guard Gtk.initCheck() else { return }
let w = AnyView(Label("x").preferredHeight(99)).makeWidget(MountContext())
#expect(w.getSizeRequest().height == 99)
}
@Test func staticHexpand() {
guard Gtk.initCheck() else { return }
let w = AnyView(Label("x").hexpand(true)).makeWidget(MountContext())
#expect(w.getHexpand() == true)
let w2 = AnyView(Label("x").hexpand(false)).makeWidget(MountContext())
#expect(w2.getHexpand() == false)
}
@Test func staticVexpand() {
guard Gtk.initCheck() else { return }
let w = AnyView(Label("x").vexpand(true)).makeWidget(MountContext())
#expect(w.getVexpand() == true)
}
@Test func staticMargin() {
guard Gtk.initCheck() else { return }
let w = AnyView(Label("x").margin(7)).makeWidget(MountContext())
#expect(w.getMarginStart() == 7)
#expect(w.getMarginEnd() == 7)
#expect(w.getMarginTop() == 7)
#expect(w.getMarginBottom() == 7)
}
@Test func staticCssClass() {
guard Gtk.initCheck() else { return }
let w = AnyView(Label("x").cssClass("myclass")).makeWidget(MountContext())
#expect(w.hasCssClass(cssClass: "myclass"))
}
// MARK: - Reactive modifiers
@Test func reactivePreferredWidth() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(40)
let w = AnyView(Label("x").preferredWidth(Binding(box))).makeWidget(
MountContext())
defer { box.set(0) }
#expect(w.getSizeRequest().width == 40)
box.set(60)
#expect(w.getSizeRequest().width == 60)
}
@Test func reactiveCssClass() {
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let w = AnyView(Label("x").cssClass(Binding(box))).makeWidget(
MountContext())
defer { box.set("") }
#expect(w.hasCssClass(cssClass: "a"))
box.set("b")
#expect(!w.hasCssClass(cssClass: "a"))
#expect(w.hasCssClass(cssClass: "b"))
}
@Test func reactivePreferredHeight() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(99)
let w = AnyView(Label("x").preferredHeight(Binding(box))).makeWidget(
MountContext())
_ = box
#expect(w.getSizeRequest().height == 99)
box.set(150)
#expect(w.getSizeRequest().height == 150)
}
@Test func reactiveHexpand() {
guard Gtk.initCheck() else { return }
let box = StateBox<Bool>(false)
let w = AnyView(Label("x").hexpand(Binding(box))).makeWidget(
MountContext())
_ = box
#expect(w.getHexpand() == false)
box.set(true)
#expect(w.getHexpand() == true)
}
@Test func reactiveVexpand() {
guard Gtk.initCheck() else { return }
let box = StateBox<Bool>(false)
let w = AnyView(Label("x").vexpand(Binding(box))).makeWidget(
MountContext())
_ = box
#expect(w.getVexpand() == false)
box.set(true)
#expect(w.getVexpand() == true)
}
@Test func reactiveMargin() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(5)
let w = AnyView(Label("x").margin(Binding(box))).makeWidget(
MountContext())
_ = box
#expect(w.getMarginStart() == 5)
#expect(w.getMarginEnd() == 5)
#expect(w.getMarginTop() == 5)
#expect(w.getMarginBottom() == 5)
box.set(12)
#expect(w.getMarginStart() == 12)
#expect(w.getMarginEnd() == 12)
#expect(w.getMarginTop() == 12)
#expect(w.getMarginBottom() == 12)
}
// MARK: - Axis preservation
@Test func axisPreservation() {
guard Gtk.initCheck() else { return }
let w = AnyView(
Label("x")
.preferredWidth(42)
.preferredHeight(99)
).makeWidget(MountContext())
let sr = w.getSizeRequest()
#expect(sr.width == 42)
#expect(sr.height == 99)
}
@Test func reactiveWidthPreservesHeight() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(50)
let w = AnyView(
Label("x")
.preferredWidth(100) // static height 0 (unset)
.preferredHeight(200) // static width 100 preserved
.preferredWidth(Binding(box)) // reactive width
).makeWidget(MountContext())
defer { box.set(0) }
// After static: width=100, height=200
// After reactive initial: width=50, height=200 (width overwritten, height preserved)
let sr = w.getSizeRequest()
#expect(sr.height == 200)
box.set(80)
let sr2 = w.getSizeRequest()
#expect(sr2.width == 80)
#expect(sr2.height == 200)
}
// MARK: - .bind modifier
@Test func bindBoolToVisible() {
guard Gtk.initCheck() else { return }
let box = StateBox<Bool>(true)
let w = AnyView(
Label("x").bind(Binding(box), to: "visible")
).makeWidget(MountContext())
defer { box.set(false) }
#expect(w.getVisible() == true)
box.set(false)
#expect(w.getVisible() == false)
}
@Test func bindStringToTooltip() {
guard Gtk.initCheck() else { return }
let box = StateBox("hello")
let w = AnyView(
Label("x").bind(Binding(box), to: "tooltip-text")
).makeWidget(MountContext())
defer { box.set("") }
#expect(w.getTooltipText() == "hello")
box.set("world")
#expect(w.getTooltipText() == "world")
}
@Test func bindIntToWidthRequest() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(50)
let w = AnyView(
Label("x").bind(Binding(box), to: "width-request")
).makeWidget(MountContext())
_ = box
#expect(w.getSizeRequest().width == 50)
box.set(120)
#expect(w.getSizeRequest().width == 120)
}
@Test func bindDoubleToOpacity() {
guard Gtk.initCheck() else { return }
let box = StateBox<Double>(1.0)
let w = AnyView(
Label("x").bind(Binding(box), to: "opacity")
).makeWidget(MountContext())
_ = box
#expect(w.getOpacity() == 1.0)
box.set(0.0)
#expect(w.getOpacity() == 0.0)
}
// MARK: - Raw widget drop-in
@Test func rawWidgetInVStack() {
guard Gtk.initCheck() else { return }
let raw = Gtk.Label(str: "raw")
let container = AnyView(VStack(spacing: 0) { raw }).makeWidget(
MountContext())
// VStack is backed by Gtk.Box verify the raw label is a child
// GTK wrappers create new Swift objects for the same GObject, so
// compare the underlying object pointers instead of using ===.
var found = false
var cur = container.getFirstChild()
while let c = cur {
if c.pointer == raw.pointer { found = true; break }
cur = c.getNextSibling()
}
#expect(found)
}
@Test func rawWidgetModifierChain() {
guard Gtk.initCheck() else { return }
let raw = Gtk.Label(str: "chained")
let w = AnyView(raw.preferredWidth(100).hexpand(true)).makeWidget(
MountContext())
#expect(w.getSizeRequest().width == 100)
#expect(w.getHexpand() == true)
}
}

View file

@ -0,0 +1,57 @@
import Testing
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
@MainActor @Suite struct TeardownTests {
// MARK: - No GTK needed
@Test func nodeRegistryCancelsTokens() {
let box = StateBox(0)
var count = 0
let registry = NodeRegistry()
registry.add(box.subscribe { _ in count += 1 })
box.set(1)
#expect(count == 1)
registry.teardown()
box.set(2)
#expect(count == 1)
}
@Test func trackerTeardownStopsReevaluation() {
let box = StateBox(0)
var evaluateCount = 0
let tracker = DependencyTracker { _ = box.get(); evaluateCount += 1 }
tracker.run()
#expect(evaluateCount == 1)
box.set(1)
#expect(evaluateCount == 2)
tracker.teardown()
box.set(2)
#expect(evaluateCount == 2)
}
@Test func nodeRegistryTeardownIsIdempotent() {
let box = StateBox(0)
var count = 0
let registry = NodeRegistry()
registry.add(box.subscribe { _ in count += 1 })
box.set(1)
#expect(count == 1)
registry.teardown()
registry.teardown() // must not crash
box.set(2)
#expect(count == 1)
}
// MARK: - GTK-dependent
@Test(.serialized) func nodeRegistryDisconnectsSignalHandle() {
guard Gtk.initCheck() else { return }
let button = Gtk.Button(label: "test")
let ctx = MountContext()
ctx.registry.add(button.connectClicked { _ in })
// Teardown must not crash with a real SignalHandle.
ctx.registry.teardown()
}
}