portico/Sources/Portico/Core/PropertyBinding.swift

126 lines
5.3 KiB
Swift

@_spi(SGTKInternal) import GObject
/// Applies `binding` to a widget property in one direction only: binding -> widget.
///
/// Selected by overload resolution when `Value` is not `Equatable`. Without `==`
/// there is no way to compare before writing, so a write-back would echo forever.
///
/// - Parameters:
/// - object: The widget the property lives on. Unused here; present so both
/// overloads share one signature.
/// - binding: The value source. Read once at mount, then subscribed to.
/// - registry: The mounting subtree's registry; the subscription is cancelled
/// when it tears down.
/// - notifyDetail: The candidate GObject property name. Unused in this
/// one-way overload; present so both overloads share one signature.
/// - read: Reads the widget's current value. Unused in this overload.
/// - write: Pushes a value into the widget.
@_spi(Portico) @MainActor
public func bindProperty<Value>(
_ object: GObject.Object,
_ binding: Binding<Value>,
registry: NodeRegistry,
notifyDetail: String,
read: @escaping () -> Value?,
write: @escaping (Value) -> Void
) {
write(binding.untrackedValue)
registry.add(binding.subscribe { v in write(v) })
}
/// Applies `binding` to a widget property in both directions.
///
/// Pushes the binding's value into the widget at mount, then keeps the two in
/// sync: the widget's `notify::<property>` signal writes the widget's current
/// value back into the binding, and the binding's subscription writes new
/// values into the widget. Each direction compares before writing, which
/// terminates the echo after one hop.
///
/// `notifyDetail` is resolved against the widget's GObject class. If the
/// candidate is not an installed property, the handler falls back to the
/// undetailed `notify` signal. This preserves write-back for wrapper accessors
/// whose Swift name differs from the underlying GObject property name.
///
/// - Parameters:
/// - object: The widget the property lives on; `notify` is connected here.
/// - binding: The value source and write-back destination.
/// - registry: The mounting subtree's registry; the signal handle and the
/// subscription are released when it tears down.
/// - notifyDetail: A candidate canonical GObject property name.
/// - read: Reads the widget's current value. `nil` suppresses the write-back
/// for that emission.
/// - write: Pushes a value into the widget.
///
@_spi(Portico) @MainActor
public func bindProperty<Value: Equatable>(
_ object: GObject.Object,
_ binding: Binding<Value>,
registry: NodeRegistry,
notifyDetail: String,
read: @escaping () -> Value?,
write: @escaping (Value) -> Void
) {
write(binding.untrackedValue)
registry.add(object.connectNotify(detail: resolvedNotifyDetail(object, notifyDetail)) { _, _ in
guard let current = read(), current != binding.untrackedValue else { return }
binding.wrappedValue = current
})
registry.add(binding.subscribe { v in
if let current = read(), current == v { return }
write(v)
})
}
/// Returns `candidate` when the object's class installs a GObject property
/// with that name, and `nil` otherwise.
///
/// The class pointer is the first word of every `GTypeInstance`, which is what
/// the `G_OBJECT_GET_CLASS` macro reads. Interface-installed properties are in
/// the class's param spec pool and resolve through the class lookup.
@MainActor
private func resolvedNotifyDetail(_ object: GObject.Object, _ candidate: String) -> String? {
let klass = object.pointer.load(as: UnsafeMutableRawPointer.self)
let found = candidate.withCString { portico_g_object_class_find_property(klass, $0) }
return found == nil ? nil : candidate
}
@_silgen_name("g_object_class_find_property")
private nonisolated func portico_g_object_class_find_property(
_ objectClass: UnsafeMutableRawPointer,
_ propertyName: UnsafePointer<CChar>
) -> UnsafeMutableRawPointer?
/// Writes `text` into a widget property once, and keeps it live when the text has
/// deferred segments.
///
/// A literal with no interpolation is written once and no tracker is retained. Otherwise a
/// ``DependencyTracker`` re-evaluates the text on every change to the state it reads and
/// pushes the result through `write`. The tracker is registered with `registry` only if it
/// actually captured a dependency, so interpolating a non-state value costs nothing after
/// mount.
@_spi(Portico) @MainActor
public func bindInterpolation(
_ text: InterpolatedText,
registry: NodeRegistry,
write: @escaping (String) -> Void
) {
if let staticText = text.staticText { write(staticText); return }
let tracker = DependencyTracker { write(text.evaluate()) }
tracker.run()
if tracker.hasDependencies || tracker.didArmObservation { registry.add(tracker) }
}
/// ``bindInterpolation(_:registry:write:)`` for a nullable property; a `nil` text writes
/// `nil` once.
@_spi(Portico) @MainActor
public func bindOptionalInterpolation(
_ text: InterpolatedText?,
registry: NodeRegistry,
write: @escaping (String?) -> Void
) {
guard let text else { write(nil); return }
if let staticText = text.staticText { write(staticText); return }
let tracker = DependencyTracker { write(text.evaluate()) }
tracker.run()
if tracker.hasDependencies || tracker.didArmObservation { registry.add(tracker) }
}