Compare commits
3 commits
ebc7980e27
...
649dd9e552
| Author | SHA1 | Date | |
|---|---|---|---|
| 649dd9e552 | |||
| 730ca58b4e | |||
| 9554dd998f |
19 changed files with 1302 additions and 24 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
Sources/Portico/Containers/EitherView.swift
Normal file
70
Sources/Portico/Containers/EitherView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
107
Sources/Portico/Containers/ForEach.swift
Normal file
107
Sources/Portico/Containers/ForEach.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
42
Sources/Portico/Containers/OptionalView.swift
Normal file
42
Sources/Portico/Containers/OptionalView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
18
Sources/Portico/Core/GtkWidget+View.swift
Normal file
18
Sources/Portico/Core/GtkWidget+View.swift
Normal 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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
Sources/Portico/Core/NodeRegistry.swift
Normal file
49
Sources/Portico/Core/NodeRegistry.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
289
Sources/Portico/Core/View+Modifiers.swift
Normal file
289
Sources/Portico/Core/View+Modifiers.swift
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)] }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
168
Tests/PorticoTests/ConditionalTests.swift
Normal file
168
Tests/PorticoTests/ConditionalTests.swift
Normal 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: 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")
|
||||
}
|
||||
}
|
||||
179
Tests/PorticoTests/ForEachTests.swift
Normal file
179
Tests/PorticoTests/ForEachTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
248
Tests/PorticoTests/ModifierTests.swift
Normal file
248
Tests/PorticoTests/ModifierTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
57
Tests/PorticoTests/TeardownTests.swift
Normal file
57
Tests/PorticoTests/TeardownTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue