diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index bf858732..b237db29 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -351,6 +351,7 @@ extension RenderLoop { environment.keyEventDispatcher = tuiContext.keyEventDispatcher environment.renderCache = tuiContext.renderCache environment.preferenceStorage = tuiContext.preferences + environment.renderNotifier = RenderNotifier.current return environment } diff --git a/Sources/TUIkit/Environment/ServiceEnvironment.swift b/Sources/TUIkit/Environment/ServiceEnvironment.swift index 896b8627..4b25ad4b 100644 --- a/Sources/TUIkit/Environment/ServiceEnvironment.swift +++ b/Sources/TUIkit/Environment/ServiceEnvironment.swift @@ -4,6 +4,14 @@ // Created by LAYERED.work // License: MIT +// MARK: - Render Notifier (AppState) + +/// EnvironmentKey for the AppState reference used to trigger re-renders. +/// Render-time consumers (Spinner, NotificationHostModifier) read from here. +private struct RenderNotifierKey: EnvironmentKey { + static let defaultValue: AppState? = nil +} + // MARK: - Lifecycle Manager /// EnvironmentKey for view lifecycle tracking (appear/disappear/task). @@ -57,6 +65,13 @@ private struct ActiveFocusSectionKey: EnvironmentKey { extension EnvironmentValues { + /// The AppState reference for triggering re-renders. + /// Used by render-time consumers (Spinner, NotificationHostModifier) to signal render needs. + var renderNotifier: AppState? { + get { self[RenderNotifierKey.self] } + set { self[RenderNotifierKey.self] = newValue } + } + /// View lifecycle tracking (appear, disappear, task management). var lifecycle: LifecycleManager? { get { self[LifecycleKey.self] } diff --git a/Sources/TUIkit/Notification/NotificationHostModifier.swift b/Sources/TUIkit/Notification/NotificationHostModifier.swift index 62bef40b..7053203b 100644 --- a/Sources/TUIkit/Notification/NotificationHostModifier.swift +++ b/Sources/TUIkit/Notification/NotificationHostModifier.swift @@ -54,7 +54,8 @@ extension NotificationHostModifier: Renderable { // Start the animation timer if not already running. startAnimationTask( entries: activeEntries, - lifecycle: context.environment.lifecycle! + lifecycle: context.environment.lifecycle!, + renderNotifier: context.environment.renderNotifier ) let now = Date().timeIntervalSinceReferenceDate @@ -149,7 +150,8 @@ private extension NotificationHostModifier { /// The task stops automatically when no notifications are active. func startAnimationTask( entries: [NotificationEntry], - lifecycle: LifecycleManager + lifecycle: LifecycleManager, + renderNotifier: AppState? ) { let token = "notification-host-animation" @@ -171,12 +173,12 @@ private extension NotificationHostModifier { } try? await Task.sleep(nanoseconds: triggerNanos) guard !Task.isCancelled else { break } - RenderNotifier.current.setNeedsRender() + renderNotifier?.setNeedsRender() } // Final render to clear expired notifications. lifecycle.resetAppearance(token: token) - RenderNotifier.current.setNeedsRender() + renderNotifier?.setNeedsRender() } } } diff --git a/Sources/TUIkit/Notification/NotificationService.swift b/Sources/TUIkit/Notification/NotificationService.swift index bc2e467d..f1475ac0 100644 --- a/Sources/TUIkit/Notification/NotificationService.swift +++ b/Sources/TUIkit/Notification/NotificationService.swift @@ -115,7 +115,8 @@ extension NotificationService { lock.lock() entries.append(entry) lock.unlock() - RenderNotifier.current.setNeedsRender() + // Property wrapper setters lack render context, so fall back to global + RenderNotifier.current?.setNeedsRender() } /// Returns a snapshot of all currently active notifications. diff --git a/Sources/TUIkit/State/AppStorage.swift b/Sources/TUIkit/State/AppStorage.swift index 65481a63..53fd777e 100644 --- a/Sources/TUIkit/State/AppStorage.swift +++ b/Sources/TUIkit/State/AppStorage.swift @@ -281,7 +281,8 @@ public struct AppStorage: @unchecked Sendable { } nonmutating set { storage.setValue(newValue, forKey: key) - RenderNotifier.current.setNeedsRender() + // Property wrapper setters lack render context, so fall back to global + RenderNotifier.current?.setNeedsRender() } } diff --git a/Sources/TUIkit/Views/Spinner.swift b/Sources/TUIkit/Views/Spinner.swift index 2212486a..59c07be0 100644 --- a/Sources/TUIkit/Views/Spinner.swift +++ b/Sources/TUIkit/Views/Spinner.swift @@ -283,11 +283,12 @@ private struct _SpinnerCore: View, Renderable { _ = lifecycle.recordAppear(token: token) {} let triggerNanos: UInt64 = 28_000_000 // 28ms — matches run loop poll rate (~35 FPS) + let renderNotifier = context.environment.renderNotifier lifecycle.startTask(token: token, priority: .medium) { while !Task.isCancelled { try? await Task.sleep(nanoseconds: triggerNanos) guard !Task.isCancelled else { break } - RenderNotifier.current.setNeedsRender() + renderNotifier?.setNeedsRender() } } } else { diff --git a/Sources/TUIkitView/State/State.swift b/Sources/TUIkitView/State/State.swift index 201eff89..a6565eb6 100644 --- a/Sources/TUIkitView/State/State.swift +++ b/Sources/TUIkitView/State/State.swift @@ -141,7 +141,9 @@ public enum RenderNotifier { /// No concurrent access is possible: they are initialized once and then /// read/written only during rendering. /// - public nonisolated(unsafe) static var current = AppState() + /// Optional fallback for property wrapper setters that lack render context. + /// Render-time consumers should read from EnvironmentValues.renderNotifier instead. + public nonisolated(unsafe) static var current: AppState? /// The active render cache for subtree memoization. /// diff --git a/Sources/TUIkitView/State/StateStorage.swift b/Sources/TUIkitView/State/StateStorage.swift index a1bd67f7..e0c7f701 100644 --- a/Sources/TUIkitView/State/StateStorage.swift +++ b/Sources/TUIkitView/State/StateStorage.swift @@ -158,7 +158,8 @@ public final class StateBox: @unchecked Sendable { } else { RenderNotifier.renderCache?.clearAll() } - RenderNotifier.current.setNeedsRender() + // Property wrapper setters lack render context, so fall back to global + RenderNotifier.current?.setNeedsRender() } } diff --git a/Tests/TUIkitTests/StatePropertyTests.swift b/Tests/TUIkitTests/StatePropertyTests.swift index cce31476..69397113 100644 --- a/Tests/TUIkitTests/StatePropertyTests.swift +++ b/Tests/TUIkitTests/StatePropertyTests.swift @@ -30,7 +30,7 @@ struct StatePropertyWrapperTests { @Test("State mutation triggers render via RenderNotifier") func stateTriggerRender() { - // StateBox.didSet calls RenderNotifier.current.setNeedsRender(). + // StateBox.didSet calls RenderNotifier.current?.setNeedsRender() (optional chaining). // We swap in a fresh AppState, mutate, and check immediately. // This is a single-expression sequence with no yield points, // so no parallel test can interfere between set and check.