From 9554dd998fa41009f46a2a56354dce9ad6bc5db1 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Thu, 23 Jul 2026 22:35:34 -0400 Subject: [PATCH 1/3] =?UTF-8?q?Conditionals=20=E2=80=94=20EitherView=20and?= =?UTF-8?q?=20OptionalView=20with=20reactive=20branch=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Example/ExampleApp.swift | 18 +- Sources/Portico/Containers/EitherView.swift | 70 ++++++++ .../GtkRevealer+SingleChildContainer.swift | 9 + Sources/Portico/Containers/OptionalView.swift | 41 +++++ Sources/Portico/Core/ViewBuilder.swift | 7 +- Tests/PorticoTests/ConditionalTests.swift | 168 ++++++++++++++++++ 6 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 Sources/Portico/Containers/EitherView.swift create mode 100644 Sources/Portico/Containers/GtkRevealer+SingleChildContainer.swift create mode 100644 Sources/Portico/Containers/OptionalView.swift create mode 100644 Tests/PorticoTests/ConditionalTests.swift diff --git a/Sources/Example/ExampleApp.swift b/Sources/Example/ExampleApp.swift index 80a65ef..ca85166 100644 --- a/Sources/Example/ExampleApp.swift +++ b/Sources/Example/ExampleApp.swift @@ -14,13 +14,27 @@ struct Counter: View { } } +struct ToggleDemo: View { + @State private var show = true + + var body: some View { + VStack(spacing: 12) { + Button("Toggle") { show.toggle() } + EitherView($show, + first: { Label("First branch — visible") }, + second: { Label("Second branch — hidden") } + ) + } + } +} + @main struct ExampleApp: App { var applicationId: String? { "dev.bscubed.PorticoExample" } var body: some Scene { - Window { _ in Counter() } - .title("Portico P1") + Window { _ in ToggleDemo() } + .title("Portico P2") .defaultSize(width: 1280, height: 800) } } diff --git a/Sources/Portico/Containers/EitherView.swift b/Sources/Portico/Containers/EitherView.swift new file mode 100644 index 0000000..d32ab29 --- /dev/null +++ b/Sources/Portico/Containers/EitherView.swift @@ -0,0 +1,70 @@ +import Gtk + +/// A view that switches between two branches based on a `Binding` 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 + 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, + @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() + + return stack + } +} diff --git a/Sources/Portico/Containers/GtkRevealer+SingleChildContainer.swift b/Sources/Portico/Containers/GtkRevealer+SingleChildContainer.swift new file mode 100644 index 0000000..9c3edc3 --- /dev/null +++ b/Sources/Portico/Containers/GtkRevealer+SingleChildContainer.swift @@ -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) + } +} diff --git a/Sources/Portico/Containers/OptionalView.swift b/Sources/Portico/Containers/OptionalView.swift new file mode 100644 index 0000000..f2f6f8a --- /dev/null +++ b/Sources/Portico/Containers/OptionalView.swift @@ -0,0 +1,41 @@ +import Gtk + +/// A view that shows or hides its child based on a `Binding` 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 + 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, + @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() + return revealer + } +} diff --git a/Sources/Portico/Core/ViewBuilder.swift b/Sources/Portico/Core/ViewBuilder.swift index ee79288..f93f17a 100644 --- a/Sources/Portico/Core/ViewBuilder.swift +++ b/Sources/Portico/Core/ViewBuilder.swift @@ -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` and mount branches via `Gtk.Stack`/`Gtk.Revealer`. @resultBuilder public enum ViewBuilder { /// Wraps a single ``View`` in an ``AnyView``. public static func buildExpression(_ v: V) -> [AnyView] { [AnyView(v)] } diff --git a/Tests/PorticoTests/ConditionalTests.swift b/Tests/PorticoTests/ConditionalTests.swift new file mode 100644 index 0000000..d426261 --- /dev/null +++ b/Tests/PorticoTests/ConditionalTests.swift @@ -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: true→false→true→false + 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") + } +} From 730ca58b4e7b623fd6ee4d6feca1b7a7efd80933 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Thu, 23 Jul 2026 23:29:52 -0400 Subject: [PATCH 2/3] Complete native drop-in --- Sources/Portico/Core/GtkWidget+View.swift | 18 ++ Sources/Portico/Core/View+Modifiers.swift | 289 ++++++++++++++++++++++ Tests/PorticoTests/ModifierTests.swift | 248 +++++++++++++++++++ 3 files changed, 555 insertions(+) create mode 100644 Sources/Portico/Core/GtkWidget+View.swift create mode 100644 Sources/Portico/Core/View+Modifiers.swift create mode 100644 Tests/PorticoTests/ModifierTests.swift diff --git a/Sources/Portico/Core/GtkWidget+View.swift b/Sources/Portico/Core/GtkWidget+View.swift new file mode 100644 index 0000000..2199767 --- /dev/null +++ b/Sources/Portico/Core/GtkWidget+View.swift @@ -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 } +} diff --git a/Sources/Portico/Core/View+Modifiers.swift b/Sources/Portico/Core/View+Modifiers.swift new file mode 100644 index 0000000..f793473 --- /dev/null +++ b/Sources/Portico/Core/View+Modifiers.swift @@ -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( + gType: UInt, + setup: (UnsafeMutablePointer) -> Void, + body: (UnsafeMutablePointer) 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, _: UInt) + +@_silgen_name("g_value_unset") +private nonisolated func portico_g_value_unset(_: UnsafeMutablePointer) + +@_silgen_name("g_value_set_string") +private nonisolated func portico_g_value_set_string(_: UnsafeMutablePointer, _: UnsafePointer?) + +@_silgen_name("g_value_set_int") +private nonisolated func portico_g_value_set_int(_: UnsafeMutablePointer, _: Int32) + +@_silgen_name("g_value_set_boolean") +private nonisolated func portico_g_value_set_boolean(_: UnsafeMutablePointer, _: Int32) + +@_silgen_name("g_value_set_double") +private nonisolated func portico_g_value_set_double(_: UnsafeMutablePointer, _: 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) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let (_, curH) = w.getSizeRequest() + w.setSizeRequest(width: width.wrappedValue, height: curH) + _ = 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) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let (curW, _) = w.getSizeRequest() + w.setSizeRequest(width: curW, height: height.wrappedValue) + _ = 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) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.setHexpand(expand: expand.wrappedValue) + _ = 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) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.setVexpand(expand: expand.wrappedValue) + _ = 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) -> 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) + _ = 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) -> 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 + _ = 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, 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))) + } + } + _ = 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, 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))) + } + _ = 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, 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))) + } + _ = 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, 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))) + } + _ = 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 + }) + } +} diff --git a/Tests/PorticoTests/ModifierTests.swift b/Tests/PorticoTests/ModifierTests.swift new file mode 100644 index 0000000..ea620c4 --- /dev/null +++ b/Tests/PorticoTests/ModifierTests.swift @@ -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(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(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(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(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(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(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(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(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(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) + } +} From 649dd9e552c4aeaecdcafb5eda443b15502d4b9a Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Fri, 24 Jul 2026 00:07:02 -0400 Subject: [PATCH 3/3] Implement collections and teardown --- Sources/Example/ExampleApp.swift | 36 ++-- Sources/Portico/App/Window.swift | 4 + Sources/Portico/Containers/EitherView.swift | 2 +- Sources/Portico/Containers/ForEach.swift | 107 +++++++++++ Sources/Portico/Containers/OptionalView.swift | 1 + Sources/Portico/Core/MountContext.swift | 25 ++- Sources/Portico/Core/NodeRegistry.swift | 49 +++++ Sources/Portico/Core/View+Modifiers.swift | 40 ++-- Sources/Portico/State/DependencyTracker.swift | 14 ++ Sources/Portico/State/StateBox.swift | 6 +- Sources/Portico/Widgets/Button.swift | 7 +- Sources/Portico/Widgets/Label.swift | 5 +- Tests/PorticoTests/ForEachTests.swift | 179 ++++++++++++++++++ Tests/PorticoTests/TeardownTests.swift | 57 ++++++ 14 files changed, 476 insertions(+), 56 deletions(-) create mode 100644 Sources/Portico/Containers/ForEach.swift create mode 100644 Sources/Portico/Core/NodeRegistry.swift create mode 100644 Tests/PorticoTests/ForEachTests.swift create mode 100644 Tests/PorticoTests/TeardownTests.swift diff --git a/Sources/Example/ExampleApp.swift b/Sources/Example/ExampleApp.swift index ca85166..04cef9c 100644 --- a/Sources/Example/ExampleApp.swift +++ b/Sources/Example/ExampleApp.swift @@ -1,29 +1,23 @@ 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) } - } - } -} - -struct ToggleDemo: View { - @State private var show = true - - var body: some View { - VStack(spacing: 12) { - Button("Toggle") { show.toggle() } - EitherView($show, - first: { Label("First branch — visible") }, - second: { Label("Second branch — hidden") } - ) } } } @@ -33,8 +27,8 @@ struct ExampleApp: App { var applicationId: String? { "dev.bscubed.PorticoExample" } var body: some Scene { - Window { _ in ToggleDemo() } - .title("Portico P2") + Window { _ in ListDemo() } + .title("Portico P4 — ForEach") .defaultSize(width: 1280, height: 800) } } diff --git a/Sources/Portico/App/Window.swift b/Sources/Portico/App/Window.swift index 11b4e7f..a16923e 100644 --- a/Sources/Portico/App/Window.swift +++ b/Sources/Portico/App/Window.swift @@ -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() } } diff --git a/Sources/Portico/Containers/EitherView.swift b/Sources/Portico/Containers/EitherView.swift index d32ab29..2af5a8a 100644 --- a/Sources/Portico/Containers/EitherView.swift +++ b/Sources/Portico/Containers/EitherView.swift @@ -64,7 +64,7 @@ import Gtk } } tracker.run() - + ctx.registry.add(tracker) return stack } } diff --git a/Sources/Portico/Containers/ForEach.swift b/Sources/Portico/Containers/ForEach.swift new file mode 100644 index 0000000..4c0dc3f --- /dev/null +++ b/Sources/Portico/Containers/ForEach.swift @@ -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: View { + private let data: Binding<[Element]> + private let id: KeyPath + 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, + 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() + 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 + } +} diff --git a/Sources/Portico/Containers/OptionalView.swift b/Sources/Portico/Containers/OptionalView.swift index f2f6f8a..2a0ccc8 100644 --- a/Sources/Portico/Containers/OptionalView.swift +++ b/Sources/Portico/Containers/OptionalView.swift @@ -36,6 +36,7 @@ import Gtk revealer.setRevealChild(revealChild: condition.wrappedValue) } tracker.run() + ctx.registry.add(tracker) return revealer } } diff --git a/Sources/Portico/Core/MountContext.swift b/Sources/Portico/Core/MountContext.swift index 73b148c..e46b63c 100644 --- a/Sources/Portico/Core/MountContext.swift +++ b/Sources/Portico/Core/MountContext.swift @@ -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) + } } diff --git a/Sources/Portico/Core/NodeRegistry.swift b/Sources/Portico/Core/NodeRegistry.swift new file mode 100644 index 0000000..d180695 --- /dev/null +++ b/Sources/Portico/Core/NodeRegistry.swift @@ -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() + } +} diff --git a/Sources/Portico/Core/View+Modifiers.swift b/Sources/Portico/Core/View+Modifiers.swift index f793473..f730c36 100644 --- a/Sources/Portico/Core/View+Modifiers.swift +++ b/Sources/Portico/Core/View+Modifiers.swift @@ -57,10 +57,10 @@ extension View { let w = AnyView(self).makeWidget(ctx) let (_, curH) = w.getSizeRequest() w.setSizeRequest(width: width.wrappedValue, height: curH) - _ = width.subscribe { [w] newW in + ctx.registry.add(width.subscribe { [w] newW in let (_, curH) = w.getSizeRequest() w.setSizeRequest(width: newW, height: curH) - } + }) return w }) } @@ -85,10 +85,10 @@ extension View { let w = AnyView(self).makeWidget(ctx) let (curW, _) = w.getSizeRequest() w.setSizeRequest(width: curW, height: height.wrappedValue) - _ = height.subscribe { [w] newH in + ctx.registry.add(height.subscribe { [w] newH in let (curW, _) = w.getSizeRequest() w.setSizeRequest(width: curW, height: newH) - } + }) return w }) } @@ -111,9 +111,9 @@ extension View { AnyView(makeWidget: { ctx in let w = AnyView(self).makeWidget(ctx) w.setHexpand(expand: expand.wrappedValue) - _ = expand.subscribe { [w] v in + ctx.registry.add(expand.subscribe { [w] v in w.setHexpand(expand: v) - } + }) return w }) } @@ -136,9 +136,9 @@ extension View { AnyView(makeWidget: { ctx in let w = AnyView(self).makeWidget(ctx) w.setVexpand(expand: expand.wrappedValue) - _ = expand.subscribe { [w] v in + ctx.registry.add(expand.subscribe { [w] v in w.setVexpand(expand: v) - } + }) return w }) } @@ -168,12 +168,12 @@ extension View { w.setMarginEnd(margin: initial) w.setMarginTop(margin: initial) w.setMarginBottom(margin: initial) - _ = m.subscribe { [w] v in + 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 }) } @@ -203,14 +203,14 @@ extension View { w.addCssClass(cssClass: initial) } var prev = initial - _ = name.subscribe { [w] newName in + 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 }) } @@ -228,13 +228,13 @@ extension View { w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) } } - _ = value.subscribe { [w] newV in + 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 }) } @@ -246,11 +246,11 @@ extension View { withGValue(gType: gTypeInt, setup: { portico_g_value_set_int($0, value.wrappedValue) }) { ptr in w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) } - _ = value.subscribe { [w] newV in + 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 }) } @@ -262,11 +262,11 @@ extension View { 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))) } - _ = value.subscribe { [w] newV in + 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 }) } @@ -278,11 +278,11 @@ extension View { withGValue(gType: gTypeDouble, setup: { portico_g_value_set_double($0, value.wrappedValue) }) { ptr in w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) } - _ = value.subscribe { [w] newV in + 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 }) } diff --git a/Sources/Portico/State/DependencyTracker.swift b/Sources/Portico/State/DependencyTracker.swift index a62d846..0500047 100644 --- a/Sources/Portico/State/DependencyTracker.swift +++ b/Sources/Portico/State/DependencyTracker.swift @@ -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() + } } diff --git a/Sources/Portico/State/StateBox.swift b/Sources/Portico/State/StateBox.swift index 4676afe..9813219 100644 --- a/Sources/Portico/State/StateBox.swift +++ b/Sources/Portico/State/StateBox.swift @@ -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 } diff --git a/Sources/Portico/Widgets/Button.swift b/Sources/Portico/Widgets/Button.swift index 740cd1c..a210b55 100644 --- a/Sources/Portico/Widgets/Button.swift +++ b/Sources/Portico/Widgets/Button.swift @@ -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 } } diff --git a/Sources/Portico/Widgets/Label.swift b/Sources/Portico/Widgets/Label.swift index 2ef579d..7d87af0 100644 --- a/Sources/Portico/Widgets/Label.swift +++ b/Sources/Portico/Widgets/Label.swift @@ -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 } diff --git a/Tests/PorticoTests/ForEachTests.swift b/Tests/PorticoTests/ForEachTests.swift new file mode 100644 index 0000000..c855c0c --- /dev/null +++ b/Tests/PorticoTests/ForEachTests.swift @@ -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 + let counter: FireCounter + var body: Never { fatalError() } + + init(key: Int, box: StateBox, 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) + } +} diff --git a/Tests/PorticoTests/TeardownTests.swift b/Tests/PorticoTests/TeardownTests.swift new file mode 100644 index 0000000..43af95c --- /dev/null +++ b/Tests/PorticoTests/TeardownTests.swift @@ -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() + } +}