From d177a8523672f8afb23498930289cbb93d1012d7 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Thu, 23 Jul 2026 22:35:34 -0400 Subject: [PATCH] Conditionals: EitherView and OptionalView with reactive branch visibility --- 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") + } +}