luminate/Sources/LuminateUI/Preferences/Preference.swift

81 lines
3 KiB
Swift

//
// Preference.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import LuminateCore
import Portico
/// Shared reference storage for a wrapper's mount-time slot resolution.
@MainActor private final class ResolvedPreferences<Value: PreferenceValue> {
var coordinator: Preferences?
var slot: PreferenceSlot<Value>?
}
/// Reads and writes one persisted preference like Portico's `@State`.
///
/// A missing key reads as `nil` without a fallback. Reads inside reactive closure modifiers track the
/// key, writes update every reader, and persistence is queued asynchronously off the main actor.
@propertyWrapper @MainActor package struct Preference<Value: PreferenceValue>: DynamicProperty {
private let key: PreferenceKey<Value>
private let resolved = ResolvedPreferences<Value>()
/// Creates a preference declaration for `key`.
///
/// - Parameter key: The stable typed preference identifier.
package init(_ key: PreferenceKey<Value>) {
self.key = key
}
/// Resolves the declaration against the mounting environment.
///
/// - Parameter environment: The environment supplied by the mounted view tree.
package func _resolve(in environment: EnvironmentValues) {
let preferences = environment.preferences
if let previous = resolved.coordinator, previous !== preferences {
fatalError(
"@Preference: view instance mounted into two different environment scopes. "
+ "A view instance resolves its preferences once; build a fresh instance per mount."
)
}
resolved.coordinator = preferences
resolved.slot = preferences.slot(for: key)
}
/// The current persisted value, or `nil` when the key is unset.
package var wrappedValue: Value? {
get { slot.value }
nonmutating set { slot.value = newValue }
}
/// A live binding to the optional preference value.
package var projectedValue: Binding<Value?> {
Binding(slot, \PreferenceSlot<Value>.value)
}
/// The slot bound at mount.
///
/// Traps rather than inventing storage when read before `_resolve`.
private var slot: PreferenceSlot<Value> {
guard let slot = resolved.slot else {
fatalError("@Preference read before its view was mounted.")
}
return slot
}
}