Adds SwiftUI-style @Environment property wrapper with key-path and object-injection flavors, an @Observable bridge that flushes on a GLib idle source at highIdle priority (ahead of GTK redraw), targeted widget updates driven by Observation tracking, and two-way observable bindings that ride the existing bindProperty path. New library files (4): - State/ObservationBridge.swift -- coalesced flush bridge - State/EnvironmentValues.swift -- scope-local key-to-box map - State/Environment.swift -- @Environment wrapper, DynamicProperty - Core/View+Environment.swift -- .environment(...) modifiers Modified library files (8): - DependencyTracker: import Observation, observing: param, didArmObservation - EitherView/ForEach: opt container trackers out of Observation - NodeRegistry: isEmpty for retention check - PropertyBinding: hasDependencies || didArmObservation gate - Binding: readOnly init, observable key-path init - MountContext: environment field, withEnvironment, inheritance - AnyView: _resolveDynamicProperties hook before user body eval New tests (21 across 3 files): - EnvironmentTests (7): scoping, nesting, projections, ForEach, lazy branches, reuse - ObservableTests (11): acceptance case, write-back, coalescing, teardown, binding echo, re-arm de-dupe, retention, flush ordering - StateTests (+3): Observation arming unit tests Example: EnvironmentDemoPage demonstrates the feature end to end.
183 lines
5.8 KiB
Swift
183 lines
5.8 KiB
Swift
import Testing
|
|
@_spi(Portico) import Portico
|
|
@_spi(SGTKInternal) import Gtk
|
|
|
|
// MARK: - Environment keys
|
|
|
|
private enum AccentKey: EnvironmentKey {
|
|
static let defaultValue = "default-accent"
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
fileprivate var accent: String {
|
|
get { self[AccentKey.self] }
|
|
set { self[AccentKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
private enum ThemeKey: EnvironmentKey {
|
|
static let defaultValue = "light"
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
fileprivate var theme: String {
|
|
get { self[ThemeKey.self] }
|
|
set { self[ThemeKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
private enum TagKey: EnvironmentKey {
|
|
static let defaultValue = "none"
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
fileprivate var tag: String {
|
|
get { self[TagKey.self] }
|
|
set { self[TagKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - Probes
|
|
|
|
private struct AccentLabel: View {
|
|
@Environment(\.accent) private var accent
|
|
var body: some View {
|
|
Label(str: "").label { accent }
|
|
}
|
|
}
|
|
|
|
private struct ThemeLabel: View {
|
|
@Environment(\.theme) private var theme
|
|
var body: some View { Label(str: "").label { theme } }
|
|
}
|
|
|
|
private struct TagLabel: View {
|
|
@Environment(\.tag) private var tag
|
|
var body: some View { Label(str: "").label { tag } }
|
|
}
|
|
|
|
/// Collects a container's children as `Gtk.Label`.
|
|
@MainActor private func labels(of widget: Gtk.Widget) -> [Gtk.Label] {
|
|
var result: [Gtk.Label] = []
|
|
var next = widget.getFirstChild()
|
|
while let child = next {
|
|
result.append(Gtk.Label(retaining: child.pointer))
|
|
next = child.getNextSibling()
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor @Suite struct EnvironmentTests {
|
|
|
|
/// Injection is scoped to the subtree the modifier wraps: the wrapped view
|
|
/// sees the override, a sibling outside it sees the key's default.
|
|
@Test func injectionIsSubtreeScoped() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let inside = AnyView(AccentLabel().environment(\.accent, "blue"))
|
|
.makeWidget(MountContext()) as! Gtk.Label
|
|
let outside = AnyView(AccentLabel()).makeWidget(MountContext()) as! Gtk.Label
|
|
|
|
#expect(inside.getText() == "blue")
|
|
#expect(outside.getText() == "default-accent")
|
|
}
|
|
|
|
/// Nested injection shadows the outer scope within one tree.
|
|
@Test func nestedInjectionShadowsOuter() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let box = AnyView(
|
|
VStack {
|
|
AccentLabel()
|
|
AccentLabel().environment(\.accent, "inner")
|
|
}
|
|
.environment(\.accent, "outer")
|
|
).makeWidget(MountContext()) as! Gtk.Box
|
|
|
|
#expect(labels(of: box).map { $0.getText() } == ["outer", "inner"])
|
|
}
|
|
|
|
/// `@Environment` is read-only, and its projected binding ignores writes:
|
|
/// the slot's value belongs to the ancestor that injected it.
|
|
@Test func projectedBindingReadsButDoesNotWrite() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
struct Host: View {
|
|
@Environment(\.accent) var accent
|
|
var binding: Portico.Binding<String> { $accent }
|
|
var body: some View { Label(str: "").label($accent) }
|
|
}
|
|
|
|
let host = Host()
|
|
let label = AnyView(host.environment(\.accent, "injected"))
|
|
.makeWidget(MountContext()) as! Gtk.Label
|
|
#expect(label.getText() == "injected")
|
|
|
|
host.binding.wrappedValue = "ignored"
|
|
#expect(label.getText() == "injected")
|
|
}
|
|
|
|
/// `ForEach` rows inherit the environment of the scope that mounted them,
|
|
/// including rows inserted after the initial mount.
|
|
@Test func forEachRowsInheritEnvironment() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let items = StateBox([1])
|
|
let box = AnyView(
|
|
ForEach(Portico.Binding(items), id: \.self) { _ in
|
|
AccentLabel()
|
|
}
|
|
.environment(\.accent, "scoped")
|
|
).makeWidget(MountContext()) as! Gtk.Box
|
|
|
|
#expect(labels(of: box).map { $0.getText() } == ["scoped"])
|
|
items.set([1, 2])
|
|
#expect(labels(of: box).map { $0.getText() } == ["scoped", "scoped"])
|
|
}
|
|
|
|
/// A branch mounted lazily, after the initial mount pass, still resolves
|
|
/// against the scope that created it.
|
|
@Test func lazilyMountedBranchInheritsEnvironment() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let flag = StateBox(false)
|
|
let ctx = MountContext()
|
|
let stack = AnyView(
|
|
EitherView(Binding(flag), first: { TagLabel() }, second: { Label(str: "off") })
|
|
.environment(\.tag, "scoped")
|
|
).makeWidget(ctx) as! Gtk.Stack
|
|
defer { ctx.registry.teardown() }
|
|
|
|
#expect(labels(of: stack).map { $0.getText() } == ["off"])
|
|
flag.set(true) // mounts the `true` branch for the first time
|
|
#expect(labels(of: stack).map { $0.getText() }.contains("scoped"))
|
|
}
|
|
|
|
/// Reusing one instance twice inside a single scope resolves identically
|
|
/// and must stay legal.
|
|
@Test func sameInstanceTwiceInOneScopeIsFine() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let shared = ThemeLabel()
|
|
let box = AnyView(
|
|
VStack { shared; shared }.environment(\.theme, "dark")
|
|
).makeWidget(MountContext()) as! Gtk.Box
|
|
|
|
#expect(labels(of: box).map { $0.getText() } == ["dark", "dark"])
|
|
}
|
|
|
|
/// Distinct instances of the same view type in different scopes stay
|
|
/// isolated - the intended pattern.
|
|
@Test func freshInstancePerScopeIsIsolated() {
|
|
guard Gtk.initCheck() else { return }
|
|
|
|
let a = AnyView(ThemeLabel().environment(\.theme, "one"))
|
|
.makeWidget(MountContext()) as! Gtk.Label
|
|
let b = AnyView(ThemeLabel().environment(\.theme, "two"))
|
|
.makeWidget(MountContext()) as! Gtk.Label
|
|
#expect(a.getText() == "one")
|
|
#expect(b.getText() == "two")
|
|
}
|
|
}
|