From 3fb49444725590d3be30bac9072b2f81e31d59ff Mon Sep 17 00:00:00 2001 From: phranck Date: Sat, 14 Feb 2026 13:13:24 +0100 Subject: [PATCH] Refactor: Move runtime services from RenderContext to EnvironmentValues - Add ServiceEnvironment.swift with 9 EnvironmentKeys for runtime services (stateStorage, lifecycle, keyEventDispatcher, renderCache, preferenceStorage, pulsePhase, cursorTimer, focusIndicatorColor, activeFocusSectionID) - Remove tuiContext, pulsePhase, cursorTimer, focusIndicatorColor, and activeFocusSectionID as direct RenderContext properties - Inject all services through EnvironmentValues in RenderLoop.buildEnvironment() - Add convenience RenderContext init that accepts TUIContext and auto-injects services into the environment - Simplify isolatedForBackground() to only swap environment values - Migrate ~49 access sites in ~25 source files from context.tuiContext.X and context.pulsePhase/cursorTimer to context.environment.X - Update 38 test files to use the new convenience init --- Sources/TUIkit/App/RenderLoop.swift | 31 ++--- Sources/TUIkit/Core/EquatableView.swift | 4 +- Sources/TUIkit/Core/PrimitiveViews.swift | 2 +- Sources/TUIkit/Environment/Preferences.swift | 4 +- .../Environment/ServiceEnvironment.swift | 127 ++++++++++++++++++ Sources/TUIkit/Environment/TUIContext.swift | 4 +- Sources/TUIkit/Focus/FocusRegistration.swift | 6 +- .../Modifiers/AlertPresentationModifier.swift | 4 +- .../Modifiers/FocusSectionModifier.swift | 6 +- .../TUIkit/Modifiers/KeyPressModifier.swift | 2 +- .../TUIkit/Modifiers/LifecycleModifier.swift | 8 +- .../Modifiers/ModalPresentationModifier.swift | 2 +- ...vigationSplitViewColumnWidthModifier.swift | 2 +- .../Modifiers/StatusBarItemsModifier.swift | 2 +- .../NotificationHostModifier.swift | 2 +- Sources/TUIkit/Rendering/RenderContext.swift | 83 +++++------- Sources/TUIkit/Rendering/Renderable.swift | 4 +- Sources/TUIkit/Views/Button.swift | 4 +- Sources/TUIkit/Views/ContainerView.swift | 4 +- Sources/TUIkit/Views/Menu.swift | 2 +- .../TUIkit/Views/NavigationSplitView.swift | 6 +- Sources/TUIkit/Views/RadioButton.swift | 4 +- Sources/TUIkit/Views/SecureField.swift | 4 +- Sources/TUIkit/Views/Slider.swift | 4 +- Sources/TUIkit/Views/Spinner.swift | 4 +- Sources/TUIkit/Views/Stepper.swift | 4 +- Sources/TUIkit/Views/Table.swift | 4 +- Sources/TUIkit/Views/TextField.swift | 4 +- Sources/TUIkit/Views/Toggle.swift | 2 +- Sources/TUIkit/Views/_ImageCore.swift | 4 +- Sources/TUIkit/Views/_ListCore.swift | 4 +- .../TUIkitTests/BackgroundModifierTests.swift | 2 +- Tests/TUIkitTests/BorderModifierTests.swift | 2 +- Tests/TUIkitTests/ButtonTests.swift | 3 +- Tests/TUIkitTests/ComponentViewTests.swift | 2 +- Tests/TUIkitTests/ContainerViewTests.swift | 6 +- Tests/TUIkitTests/EnvironmentTests.swift | 21 ++- Tests/TUIkitTests/EquatableViewTests.swift | 32 +++-- Tests/TUIkitTests/FrameBufferTests.swift | 6 +- Tests/TUIkitTests/FrameModifierTests.swift | 2 +- Tests/TUIkitTests/LayoutTests.swift | 14 +- Tests/TUIkitTests/LazyStacksTests.swift | 2 +- Tests/TUIkitTests/ListRowSeparatorTests.swift | 3 +- Tests/TUIkitTests/ListTests.swift | 3 +- .../ModifierPropagationTests.swift | 2 +- .../NavigationSplitViewTests.swift | 1 - Tests/TUIkitTests/PaddingModifierTests.swift | 2 +- Tests/TUIkitTests/ProgressViewTests.swift | 2 +- Tests/TUIkitTests/RadioButtonTests.swift | 3 +- Tests/TUIkitTests/RenderBottleneckTests.swift | 2 +- .../TUIkitTests/RenderPerformanceTests.swift | 4 +- Tests/TUIkitTests/RenderingTests.swift | 52 +++---- .../SectionListIntegrationTests.swift | 3 +- Tests/TUIkitTests/SectionTests.swift | 3 +- Tests/TUIkitTests/SecureFieldTests.swift | 2 +- .../TUIkitTests/SelectionDisabledTests.swift | 3 +- Tests/TUIkitTests/SliderTests.swift | 2 +- Tests/TUIkitTests/SpinnerTests.swift | 2 +- .../StateStorageIdentityTests.swift | 16 ++- Tests/TUIkitTests/StatusBarViewTests.swift | 32 +++-- Tests/TUIkitTests/StepperTests.swift | 2 +- Tests/TUIkitTests/TableTests.swift | 3 +- Tests/TUIkitTests/TextFieldTests.swift | 2 +- Tests/TUIkitTests/ToggleTests.swift | 3 +- .../TUIkitTests/TupleViewEquatableTests.swift | 28 +++- Tests/TUIkitTests/ViewTests.swift | 4 +- 66 files changed, 396 insertions(+), 222 deletions(-) create mode 100644 Sources/TUIkit/Environment/ServiceEnvironment.swift diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 5df40eca..96afde9e 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -188,7 +188,9 @@ extension RenderLoop { let terminalHeight = terminalSize.height // Create render context with environment - let environment = buildEnvironment() + var environment = buildEnvironment() + environment.pulsePhase = pulsePhase + environment.cursorTimer = cursorTimer invalidateCacheIfEnvironmentChanged(environment: environment) invalidateCacheIfPulsePhaseChanged(pulsePhase: pulsePhase) @@ -216,8 +218,7 @@ extension RenderLoop { let measureContext = RenderContext( availableWidth: terminalWidth, availableHeight: terminalHeight - statusBarHeight, - environment: environment, - tuiContext: tuiContext + environment: environment ) _ = renderScene(scene, context: measureContext.withChildIdentity(type: type(of: scene))) appHeaderHeight = appHeader.height @@ -233,13 +234,10 @@ extension RenderLoop { var context = RenderContext( availableWidth: terminalWidth, availableHeight: contentHeight, - environment: environment, - tuiContext: tuiContext + environment: environment ) context.hasExplicitWidth = true // Terminal has a fixed width context.hasExplicitHeight = true // Terminal has a fixed height - context.pulsePhase = pulsePhase - context.cursorTimer = cursorTimer // Render main content into a FrameBuffer. // app.body is evaluated fresh each frame. @State values survive @@ -261,13 +259,10 @@ extension RenderLoop { var correctedContext = RenderContext( availableWidth: terminalWidth, availableHeight: actualContentHeight, - environment: environment, - tuiContext: tuiContext + environment: environment ) correctedContext.hasExplicitWidth = true correctedContext.hasExplicitHeight = true - correctedContext.pulsePhase = pulsePhase - correctedContext.cursorTimer = cursorTimer buffer = renderScene(scene, context: correctedContext.withChildIdentity(type: type(of: scene))) } @@ -356,6 +351,14 @@ extension RenderLoop { environment.appearance = appearance } environment.notificationService = NotificationService.current + + // Runtime services (previously accessed via context.tuiContext) + environment.stateStorage = tuiContext.stateStorage + environment.lifecycle = tuiContext.lifecycle + environment.keyEventDispatcher = tuiContext.keyEventDispatcher + environment.renderCache = tuiContext.renderCache + environment.preferenceStorage = tuiContext.preferences + return environment } } @@ -410,8 +413,7 @@ private extension RenderLoop { let context = RenderContext( availableWidth: terminalWidth, availableHeight: appHeader.height, - environment: environment, - tuiContext: tuiContext + environment: environment ) let buffer = renderToBuffer(headerView, context: context) @@ -449,8 +451,7 @@ private extension RenderLoop { let context = RenderContext( availableWidth: terminalWidth, availableHeight: statusBarView.height, - environment: environment, - tuiContext: tuiContext + environment: environment ) let buffer = renderToBuffer(statusBarView, context: context) diff --git a/Sources/TUIkit/Core/EquatableView.swift b/Sources/TUIkit/Core/EquatableView.swift index eeb6b93f..ca96237c 100644 --- a/Sources/TUIkit/Core/EquatableView.swift +++ b/Sources/TUIkit/Core/EquatableView.swift @@ -76,7 +76,7 @@ public struct EquatableView: View { extension EquatableView: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { - let cache = context.tuiContext.renderCache + let cache = context.environment.renderCache! let identity = context.identity cache.markActive(identity) @@ -119,7 +119,7 @@ private extension EquatableView { /// Their state identities must still be marked active to prevent /// StateStorage from garbage-collecting them. func markSubtreeActive(context: RenderContext) { - context.tuiContext.stateStorage.markActive(context.identity) + context.environment.stateStorage!.markActive(context.identity) } } diff --git a/Sources/TUIkit/Core/PrimitiveViews.swift b/Sources/TUIkit/Core/PrimitiveViews.swift index 44cfbc47..7da32fe7 100644 --- a/Sources/TUIkit/Core/PrimitiveViews.swift +++ b/Sources/TUIkit/Core/PrimitiveViews.swift @@ -130,7 +130,7 @@ extension EmptyView: Renderable { extension ConditionalView: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! switch self { case .trueContent(let content): stateStorage.invalidateDescendants(of: context.identity.branch("false")) diff --git a/Sources/TUIkit/Environment/Preferences.swift b/Sources/TUIkit/Environment/Preferences.swift index 3c9644d2..e74c444d 100644 --- a/Sources/TUIkit/Environment/Preferences.swift +++ b/Sources/TUIkit/Environment/Preferences.swift @@ -25,7 +25,7 @@ struct PreferenceModifier: View { extension PreferenceModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { // Set the preference value - context.tuiContext.preferences.setValue(value, forKey: K.self) + context.environment.preferenceStorage!.setValue(value, forKey: K.self) // Render content return TUIkit.renderToBuffer(content, context: context) @@ -50,7 +50,7 @@ where K.Value: Equatable { extension OnPreferenceChangeModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { - let prefs = context.tuiContext.preferences + let prefs = context.environment.preferenceStorage! // Register callback for preference changes prefs.onPreferenceChange(K.self, callback: action) diff --git a/Sources/TUIkit/Environment/ServiceEnvironment.swift b/Sources/TUIkit/Environment/ServiceEnvironment.swift new file mode 100644 index 00000000..f7fc1561 --- /dev/null +++ b/Sources/TUIkit/Environment/ServiceEnvironment.swift @@ -0,0 +1,127 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// ServiceEnvironment.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - State Storage + +/// EnvironmentKey for the persistent `@State` value storage. +private struct StateStorageKey: EnvironmentKey { + static let defaultValue: StateStorage? = nil +} + +// MARK: - Lifecycle Manager + +/// EnvironmentKey for view lifecycle tracking (appear/disappear/task). +private struct LifecycleKey: EnvironmentKey { + static let defaultValue: LifecycleManager? = nil +} + +// MARK: - Key Event Dispatcher + +/// EnvironmentKey for key event handler registration and dispatch. +private struct KeyEventDispatcherKey: EnvironmentKey { + static let defaultValue: KeyEventDispatcher? = nil +} + +// MARK: - Render Cache + +/// EnvironmentKey for memoized subtree rendering results. +private struct RenderCacheKey: EnvironmentKey { + static let defaultValue: RenderCache? = nil +} + +// MARK: - Preference Storage + +/// EnvironmentKey for preference value collection during rendering. +private struct PreferenceStorageKey: EnvironmentKey { + static let defaultValue: PreferenceStorage? = nil +} + +// MARK: - Pulse Phase + +/// EnvironmentKey for the focus indicator breathing animation phase. +private struct PulsePhaseKey: EnvironmentKey { + static let defaultValue: Double = 0 +} + +// MARK: - Cursor Timer + +/// EnvironmentKey for TextField/SecureField cursor blink animation. +private struct CursorTimerKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: CursorTimer? = nil +} + +// MARK: - Focus Indicator Color + +/// EnvironmentKey for the focus indicator color in the current subtree. +private struct FocusIndicatorColorKey: EnvironmentKey { + static let defaultValue: Color? = nil +} + +// MARK: - Active Focus Section + +/// EnvironmentKey for the focus section that child views should register in. +private struct ActiveFocusSectionKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +// MARK: - EnvironmentValues Extensions + +extension EnvironmentValues { + + /// The persistent `@State` value storage indexed by `ViewIdentity`. + var stateStorage: StateStorage? { + get { self[StateStorageKey.self] } + set { self[StateStorageKey.self] = newValue } + } + + /// View lifecycle tracking (appear, disappear, task management). + var lifecycle: LifecycleManager? { + get { self[LifecycleKey.self] } + set { self[LifecycleKey.self] = newValue } + } + + /// Key event handler registration and dispatch. + var keyEventDispatcher: KeyEventDispatcher? { + get { self[KeyEventDispatcherKey.self] } + set { self[KeyEventDispatcherKey.self] = newValue } + } + + /// Cache for memoized subtree rendering results. + var renderCache: RenderCache? { + get { self[RenderCacheKey.self] } + set { self[RenderCacheKey.self] = newValue } + } + + /// Preference value collection during rendering. + var preferenceStorage: PreferenceStorage? { + get { self[PreferenceStorageKey.self] } + set { self[PreferenceStorageKey.self] = newValue } + } + + /// The current breathing animation phase (0-1) for the focus indicator. + var pulsePhase: Double { + get { self[PulsePhaseKey.self] } + set { self[PulsePhaseKey.self] = newValue } + } + + /// The cursor timer for TextField/SecureField animations. + var cursorTimer: CursorTimer? { + get { self[CursorTimerKey.self] } + set { self[CursorTimerKey.self] = newValue } + } + + /// The focus indicator color for the first border encountered in this subtree. + var focusIndicatorColor: Color? { + get { self[FocusIndicatorColorKey.self] } + set { self[FocusIndicatorColorKey.self] = newValue } + } + + /// The ID of the focus section that child views should register in. + var activeFocusSectionID: String? { + get { self[ActiveFocusSectionKey.self] } + set { self[ActiveFocusSectionKey.self] = newValue } + } +} diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index 8ba9769f..587659db 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -190,12 +190,12 @@ extension LifecycleManager { /// /// ## Usage /// -/// View modifiers access the context through `RenderContext`: +/// View modifiers access services through `RenderContext.environment`: /// /// ```swift /// extension MyModifier: Renderable { /// func renderToBuffer(context: RenderContext) -> FrameBuffer { -/// context.tuiContext.keyEventDispatcher.addHandler { event in +/// context.environment.keyEventDispatcher!.addHandler { event in /// // handle key /// } /// return TUIkit.renderToBuffer(content, context: context) diff --git a/Sources/TUIkit/Focus/FocusRegistration.swift b/Sources/TUIkit/Focus/FocusRegistration.swift index b8683ce0..7f182a37 100644 --- a/Sources/TUIkit/Focus/FocusRegistration.swift +++ b/Sources/TUIkit/Focus/FocusRegistration.swift @@ -89,7 +89,7 @@ struct FocusRegistration { defaultPrefix: String, propertyIndex: Int ) -> String { - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! let defaultID = explicitFocusID ?? "\(defaultPrefix)-\(context.identity.path)" let key = StateStorage.StateKey(identity: context.identity, propertyIndex: propertyIndex) let box: StateBox = stateStorage.storage(for: key, default: defaultID) @@ -105,8 +105,8 @@ struct FocusRegistration { /// - handler: The focusable handler to register. static func register(context: RenderContext, handler: Focusable) { guard !context.isMeasuring else { return } - context.environment.focusManager.register(handler, inSection: context.activeFocusSectionID) - context.tuiContext.stateStorage.markActive(context.identity) + context.environment.focusManager.register(handler, inSection: context.environment.activeFocusSectionID) + context.environment.stateStorage!.markActive(context.identity) } /// Determines whether the given focusID currently has focus. diff --git a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift index bd48a72b..b3afe692 100644 --- a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift @@ -105,7 +105,7 @@ extension AlertPresentationModifier: Renderable { // Register ESC handler to dismiss the alert let isPresentedBinding = isPresented - context.tuiContext.keyEventDispatcher.addHandler { event in + context.environment.keyEventDispatcher!.addHandler { event in if event.key == .escape { isPresentedBinding.wrappedValue = false return true @@ -116,7 +116,7 @@ extension AlertPresentationModifier: Renderable { // Set the alert section in the context so child focusables // (buttons in the alert) register in the alert section. var alertContext = context - alertContext.activeFocusSectionID = sectionID + alertContext.environment.activeFocusSectionID = sectionID let alertBuffer = TUIkit.renderToBuffer(alert, context: alertContext) diff --git a/Sources/TUIkit/Modifiers/FocusSectionModifier.swift b/Sources/TUIkit/Modifiers/FocusSectionModifier.swift index c0a58fc0..9ee170c4 100644 --- a/Sources/TUIkit/Modifiers/FocusSectionModifier.swift +++ b/Sources/TUIkit/Modifiers/FocusSectionModifier.swift @@ -51,7 +51,7 @@ extension FocusSectionModifier: Renderable { // Create a child context with the active section ID set, // so that focusable children (buttons, menus) register in this section. var sectionContext = context - sectionContext.activeFocusSectionID = sectionID + sectionContext.environment.activeFocusSectionID = sectionID // If this section is active, compute the breathing indicator color. // The first border view in the subtree will consume this and render ●. @@ -59,9 +59,9 @@ extension FocusSectionModifier: Renderable { if !context.isMeasuring && focusManager.isActiveSection(sectionID) { let accentColor = context.environment.palette.accent let dimColor = accentColor.opacity(ViewConstants.focusBorderDim) - sectionContext.focusIndicatorColor = Color.lerp(dimColor, accentColor, phase: context.pulsePhase) + sectionContext.environment.focusIndicatorColor = Color.lerp(dimColor, accentColor, phase: context.environment.pulsePhase) } else { - sectionContext.focusIndicatorColor = nil + sectionContext.environment.focusIndicatorColor = nil } return TUIkit.renderToBuffer(content, context: sectionContext) diff --git a/Sources/TUIkit/Modifiers/KeyPressModifier.swift b/Sources/TUIkit/Modifiers/KeyPressModifier.swift index f8517dae..65769bc4 100644 --- a/Sources/TUIkit/Modifiers/KeyPressModifier.swift +++ b/Sources/TUIkit/Modifiers/KeyPressModifier.swift @@ -29,7 +29,7 @@ public struct KeyPressModifier: View { extension KeyPressModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { // Register the key handler - context.tuiContext.keyEventDispatcher.addHandler { [keys, handler] event in + context.environment.keyEventDispatcher!.addHandler { [keys, handler] event in // Check if we should handle this key if let allowedKeys = keys { guard allowedKeys.contains(event.key) else { diff --git a/Sources/TUIkit/Modifiers/LifecycleModifier.swift b/Sources/TUIkit/Modifiers/LifecycleModifier.swift index 824bc352..c87ec649 100644 --- a/Sources/TUIkit/Modifiers/LifecycleModifier.swift +++ b/Sources/TUIkit/Modifiers/LifecycleModifier.swift @@ -26,7 +26,7 @@ struct OnAppearModifier: View { extension OnAppearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { // Record appearance and execute action if first time - _ = context.tuiContext.lifecycle.recordAppear(token: token, action: action) + _ = context.environment.lifecycle!.recordAppear(token: token, action: action) // Render content return TUIkit.renderToBuffer(content, context: context) @@ -54,10 +54,10 @@ struct OnDisappearModifier: View { extension OnDisappearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { // Register the disappear callback - context.tuiContext.lifecycle.registerDisappear(token: token, action: action) + context.environment.lifecycle!.registerDisappear(token: token, action: action) // Mark as visible in current render - _ = context.tuiContext.lifecycle.recordAppear(token: token, action: {}) + _ = context.environment.lifecycle!.recordAppear(token: token, action: {}) // Render content return TUIkit.renderToBuffer(content, context: context) @@ -89,7 +89,7 @@ struct TaskModifier: View { extension TaskModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { - let lifecycle = context.tuiContext.lifecycle + let lifecycle = context.environment.lifecycle! // Start task on first appearance let isFirstAppear = !lifecycle.hasAppeared(token: token) diff --git a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift index a419b965..4f8a4afa 100644 --- a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift @@ -73,7 +73,7 @@ extension ModalPresentationModifier: Renderable { // Set the modal section in the context so child focusables // (buttons in the modal) register in the modal section. var modalContext = context - modalContext.activeFocusSectionID = sectionID + modalContext.environment.activeFocusSectionID = sectionID let modalBuffer = TUIkit.renderToBuffer(modal, context: modalContext) diff --git a/Sources/TUIkit/Modifiers/NavigationSplitViewColumnWidthModifier.swift b/Sources/TUIkit/Modifiers/NavigationSplitViewColumnWidthModifier.swift index b66a0510..192b96b5 100644 --- a/Sources/TUIkit/Modifiers/NavigationSplitViewColumnWidthModifier.swift +++ b/Sources/TUIkit/Modifiers/NavigationSplitViewColumnWidthModifier.swift @@ -75,7 +75,7 @@ struct NavigationSplitViewColumnWidthView: View { extension NavigationSplitViewColumnWidthView: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { // Set the preference for NavigationSplitView to read - context.tuiContext.preferences.setValue(columnWidth, forKey: NavigationSplitViewColumnWidthKey.self) + context.environment.preferenceStorage!.setValue(columnWidth, forKey: NavigationSplitViewColumnWidthKey.self) // Render content return TUIkit.renderToBuffer(content, context: context) diff --git a/Sources/TUIkit/Modifiers/StatusBarItemsModifier.swift b/Sources/TUIkit/Modifiers/StatusBarItemsModifier.swift index f795bd15..16d6bdd7 100644 --- a/Sources/TUIkit/Modifiers/StatusBarItemsModifier.swift +++ b/Sources/TUIkit/Modifiers/StatusBarItemsModifier.swift @@ -65,7 +65,7 @@ extension StatusBarItemsModifier: Renderable { // Register items with the focus section's composition strategy. // If inside a focus section, items are associated with that section. // Otherwise, they become global items. - if let sectionID = renderContext.activeFocusSectionID { + if let sectionID = renderContext.environment.activeFocusSectionID { statusBar.registerSectionItems( sectionID: sectionID, items: items, diff --git a/Sources/TUIkit/Notification/NotificationHostModifier.swift b/Sources/TUIkit/Notification/NotificationHostModifier.swift index ce12ee33..62bef40b 100644 --- a/Sources/TUIkit/Notification/NotificationHostModifier.swift +++ b/Sources/TUIkit/Notification/NotificationHostModifier.swift @@ -54,7 +54,7 @@ extension NotificationHostModifier: Renderable { // Start the animation timer if not already running. startAnimationTask( entries: activeEntries, - lifecycle: context.tuiContext.lifecycle + lifecycle: context.environment.lifecycle! ) let now = Date().timeIntervalSinceReferenceDate diff --git a/Sources/TUIkit/Rendering/RenderContext.swift b/Sources/TUIkit/Rendering/RenderContext.swift index b44af8b5..7264ef10 100644 --- a/Sources/TUIkit/Rendering/RenderContext.swift +++ b/Sources/TUIkit/Rendering/RenderContext.swift @@ -6,9 +6,10 @@ /// The context for rendering a view. /// -/// Contains layout constraints, environment values, and the central -/// `TUIContext` that views need to determine their size, content, and -/// access framework services. +/// Contains layout constraints, environment values, and the view's +/// structural identity. Runtime services (state storage, lifecycle, +/// key dispatch, etc.) are accessed through ``environment`` using +/// `EnvironmentKey`-based properties. /// /// `RenderContext` is a pure data container — it does not hold a reference /// to `Terminal`. All terminal I/O happens in `RenderLoop` after the @@ -27,14 +28,6 @@ public struct RenderContext { /// The environment values for this render pass. public var environment: EnvironmentValues - /// The central dependency container for framework services. - /// - /// Provides access to lifecycle tracking, key event dispatch, - /// and preference storage via constructor injection. - /// Mutable to allow modal presentation to substitute an isolated - /// context for background content rendering. - var tuiContext: TUIContext - /// The current view's structural identity in the render tree. /// /// Built incrementally as `renderToBuffer` traverses the view hierarchy. @@ -42,34 +35,6 @@ public struct RenderContext { /// Used by `StateStorage` to persist `@State` values across render passes. var identity: ViewIdentity - /// The ID of the focus section that child views should register in. - /// - /// Set by `FocusSectionModifier` during rendering. Focusable children - /// (buttons, menus) read this to register in the correct section. - /// When nil, elements register in the active or default section. - var activeFocusSectionID: String? - - /// The current breathing animation phase (0–1) for the focus indicator. - /// - /// Set by `RenderLoop` from the `PulseTimer` at the start of each frame. - /// Read by `BorderRenderer` to interpolate the ● indicator color. - /// A value of 0 means dimmest, 1 means brightest. - var pulsePhase: Double = 0 - - /// The cursor timer for TextField/SecureField animations. - /// - /// Set by `RenderLoop` at the start of each frame. - /// Read by text fields to compute blink and pulse phases. - var cursorTimer: CursorTimer? - - /// The focus indicator color for the first border encountered in this subtree. - /// - /// Set by `FocusSectionModifier` when the section is active. - /// The first view that renders a border (Panel, Box, `.border()`) reads - /// this color, renders the ● indicator, and sets it to nil so that - /// nested borders don't also show the indicator. - var focusIndicatorColor: Color? - /// Whether an explicit frame width constraint has been set. /// /// Set by `FlexibleFrameView` when a fixed width is specified. @@ -96,19 +61,46 @@ public struct RenderContext { /// - availableWidth: The available width in characters. /// - availableHeight: The available height in lines. /// - environment: The environment values (defaults to empty). - /// - tuiContext: The TUI context (defaults to a fresh instance). /// - identity: The view identity path (defaults to root). init( availableWidth: Int, availableHeight: Int, environment: EnvironmentValues = EnvironmentValues(), - tuiContext: TUIContext = TUIContext(), identity: ViewIdentity = ViewIdentity(path: "") ) { self.availableWidth = availableWidth self.availableHeight = availableHeight self.environment = environment - self.tuiContext = tuiContext + self.identity = identity + } + + /// Creates a new RenderContext with runtime services from a `TUIContext`. + /// + /// Injects all services from the `TUIContext` into `EnvironmentValues`, + /// making them accessible via `context.environment.stateStorage`, etc. + /// + /// - Parameters: + /// - availableWidth: The available width in characters. + /// - availableHeight: The available height in lines. + /// - environment: The environment values (defaults to empty). + /// - tuiContext: The TUI context whose services are injected into the environment. + /// - identity: The view identity path (defaults to root). + init( + availableWidth: Int, + availableHeight: Int, + environment: EnvironmentValues = EnvironmentValues(), + tuiContext: TUIContext, + identity: ViewIdentity = ViewIdentity(path: "") + ) { + var env = environment + env.stateStorage = tuiContext.stateStorage + env.lifecycle = tuiContext.lifecycle + env.keyEventDispatcher = tuiContext.keyEventDispatcher + env.renderCache = tuiContext.renderCache + env.preferenceStorage = tuiContext.preferences + self.availableWidth = availableWidth + self.availableHeight = availableHeight + self.environment = env self.identity = identity } @@ -172,12 +164,7 @@ public struct RenderContext { func isolatedForBackground() -> Self { var copy = self copy.environment.focusManager = FocusManager() - copy.tuiContext = TUIContext( - lifecycle: tuiContext.lifecycle, - keyEventDispatcher: KeyEventDispatcher(), - preferences: tuiContext.preferences, - stateStorage: tuiContext.stateStorage - ) + copy.environment.keyEventDispatcher = KeyEventDispatcher() return copy } diff --git a/Sources/TUIkit/Rendering/Renderable.swift b/Sources/TUIkit/Rendering/Renderable.swift index de0597c2..7f50d661 100644 --- a/Sources/TUIkit/Rendering/Renderable.swift +++ b/Sources/TUIkit/Rendering/Renderable.swift @@ -183,7 +183,7 @@ func renderToBuffer(_ view: V, context: RenderContext) -> FrameBuffer { // Activate hydration: @State.init will use this to look up persistent storage. StateRegistration.activeContext = HydrationContext( identity: context.identity, - storage: context.tuiContext.stateStorage + storage: context.environment.stateStorage! ) StateRegistration.counter = 0 @@ -192,7 +192,7 @@ func renderToBuffer(_ view: V, context: RenderContext) -> FrameBuffer { // Restore previous hydration state and mark this identity as active for GC. StateRegistration.activeContext = previousContext StateRegistration.counter = previousCounter - context.tuiContext.stateStorage.markActive(context.identity) + context.environment.stateStorage!.markActive(context.identity) return renderToBuffer(body, context: childContext) } diff --git a/Sources/TUIkit/Views/Button.swift b/Sources/TUIkit/Views/Button.swift index 2d7685f2..5ddfa75e 100644 --- a/Sources/TUIkit/Views/Button.swift +++ b/Sources/TUIkit/Views/Button.swift @@ -315,7 +315,7 @@ private struct _ButtonCore: View, Renderable { // Plain: pulsing dot prefix + label, no brackets let focusPrefix = BorderRenderer.focusIndicatorPrefix( isFocused: isFocused && !isDisabled, - pulsePhase: context.pulsePhase, + pulsePhase: context.environment.pulsePhase, palette: palette ) let styledLabel = ANSIRenderer.render(paddedLabel, with: textStyle) @@ -340,7 +340,7 @@ private struct _ButtonCore: View, Renderable { if isDisabled { resolvedCapColor = buttonBg } else if isFocused { - resolvedCapColor = Color.lerp(buttonBg, palette.accent.opacity(ViewConstants.buttonCapPulseBright), phase: context.pulsePhase) + resolvedCapColor = Color.lerp(buttonBg, palette.accent.opacity(ViewConstants.buttonCapPulseBright), phase: context.environment.pulsePhase) } else { resolvedCapColor = buttonBg } diff --git a/Sources/TUIkit/Views/ContainerView.swift b/Sources/TUIkit/Views/ContainerView.swift index f4e25503..70f7787d 100644 --- a/Sources/TUIkit/Views/ContainerView.swift +++ b/Sources/TUIkit/Views/ContainerView.swift @@ -325,8 +325,8 @@ private struct _ContainerViewCore: View, Renderable var innerContext = context.forBorderedContent() // Consume focus indicator so nested containers don't also show it. - let indicatorColor = context.focusIndicatorColor - innerContext.focusIndicatorColor = nil + let indicatorColor = context.environment.focusIndicatorColor + innerContext.environment.focusIndicatorColor = nil // Render body content first to determine its natural width. let paddedContent = content.padding(padding) diff --git a/Sources/TUIkit/Views/Menu.swift b/Sources/TUIkit/Views/Menu.swift index d67b6d96..43f1886b 100644 --- a/Sources/TUIkit/Views/Menu.swift +++ b/Sources/TUIkit/Views/Menu.swift @@ -294,7 +294,7 @@ private struct _MenuCore: View, Renderable { let menuItems = items let selectCallback = onSelect - context.tuiContext.keyEventDispatcher.addHandler { event in + context.environment.keyEventDispatcher!.addHandler { event in switch event.key { case .up: // Move selection up diff --git a/Sources/TUIkit/Views/NavigationSplitView.swift b/Sources/TUIkit/Views/NavigationSplitView.swift index 93afbd48..b9d8ce0d 100644 --- a/Sources/TUIkit/Views/NavigationSplitView.swift +++ b/Sources/TUIkit/Views/NavigationSplitView.swift @@ -251,15 +251,15 @@ private struct _NavigationSplitViewCore: View, Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let palette = context.environment.palette - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! // Create type-erased selection binding and item values let erasedSelection = Binding( @@ -323,7 +323,7 @@ private struct _RadioButtonGroupCore: View, Renderable { } else if isFocused { // Focused: pulsing accent (whether selected or not) let dimAccent = palette.accent.opacity(ViewConstants.focusPulseMin) - indicatorColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase) + indicatorColor = Color.lerp(dimAccent, palette.accent, phase: context.environment.pulsePhase) } else if isSelected { // Selected but not focused: solid accent indicatorColor = palette.accent diff --git a/Sources/TUIkit/Views/SecureField.swift b/Sources/TUIkit/Views/SecureField.swift index 28344718..8eb138e9 100644 --- a/Sources/TUIkit/Views/SecureField.swift +++ b/Sources/TUIkit/Views/SecureField.swift @@ -244,7 +244,7 @@ private struct _SecureFieldCore: View, Renderable, Layoutable { } func renderToBuffer(context: RenderContext) -> FrameBuffer { - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! let palette = context.environment.palette let cursorStyle = context.environment.textCursorStyle @@ -295,7 +295,7 @@ private struct _SecureFieldCore: View, Renderable, Layoutable { isFocused: isFocused, palette: palette, cursorStyle: cursorStyle, - cursorTimer: context.cursorTimer, + cursorTimer: context.environment.cursorTimer, contentWidth: contentWidth ) diff --git a/Sources/TUIkit/Views/Slider.swift b/Sources/TUIkit/Views/Slider.swift index c3a0491c..8fcbf299 100644 --- a/Sources/TUIkit/Views/Slider.swift +++ b/Sources/TUIkit/Views/Slider.swift @@ -285,7 +285,7 @@ private struct _SliderCore: View, Renderable, Lay } func renderToBuffer(context: RenderContext) -> FrameBuffer { - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! let palette = context.environment.palette // Slider expands to fill available width (with minimum) @@ -330,7 +330,7 @@ private struct _SliderCore: View, Renderable, Lay fraction: fraction, isFocused: isFocused, palette: palette, - pulsePhase: context.pulsePhase, + pulsePhase: context.environment.pulsePhase, trackWidth: trackWidth ) diff --git a/Sources/TUIkit/Views/Spinner.swift b/Sources/TUIkit/Views/Spinner.swift index 95f89bcd..2212486a 100644 --- a/Sources/TUIkit/Views/Spinner.swift +++ b/Sources/TUIkit/Views/Spinner.swift @@ -270,8 +270,8 @@ private struct _SpinnerCore: View, Renderable { } func renderToBuffer(context: RenderContext) -> FrameBuffer { - let lifecycle = context.tuiContext.lifecycle - let stateStorage = context.tuiContext.stateStorage + let lifecycle = context.environment.lifecycle! + let stateStorage = context.environment.stateStorage! // Retrieve or create persistent start time for this spinner. let timeKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) diff --git a/Sources/TUIkit/Views/Stepper.swift b/Sources/TUIkit/Views/Stepper.swift index c1688806..138fbd6d 100644 --- a/Sources/TUIkit/Views/Stepper.swift +++ b/Sources/TUIkit/Views/Stepper.swift @@ -304,7 +304,7 @@ private struct _StepperCore: View, Renderable { } func renderToBuffer(context: RenderContext) -> FrameBuffer { - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! let palette = context.environment.palette let persistedFocusID = FocusRegistration.persistFocusID( @@ -343,7 +343,7 @@ private struct _StepperCore: View, Renderable { let content = buildContent( isFocused: isFocused, palette: palette, - pulsePhase: context.pulsePhase + pulsePhase: context.environment.pulsePhase ) return FrameBuffer(text: content) diff --git a/Sources/TUIkit/Views/Table.swift b/Sources/TUIkit/Views/Table.swift index 6cd84c92..06ef4df7 100644 --- a/Sources/TUIkit/Views/Table.swift +++ b/Sources/TUIkit/Views/Table.swift @@ -191,7 +191,7 @@ private struct _TableCore: View, Renderable wher func renderToBuffer(context: RenderContext) -> FrameBuffer { let palette = context.environment.palette - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! // Calculate available width inside container (subtract border + padding) let innerWidth = max(0, context.availableWidth - 4) @@ -410,7 +410,7 @@ private struct _TableCore: View, Renderable wher ) -> (indicator: String, indicatorColor: Color, backgroundColor: Color?) { if isFocused && isSelected { let dimAccent = palette.accent.opacity(ViewConstants.focusPulseMin) - let bg = Color.lerp(dimAccent, palette.accent.opacity(ViewConstants.focusPulseMax), phase: context.pulsePhase) + let bg = Color.lerp(dimAccent, palette.accent.opacity(ViewConstants.focusPulseMax), phase: context.environment.pulsePhase) return ("●", palette.accent, bg) } else if isFocused { return (" ", palette.foregroundTertiary, palette.focusBackground) diff --git a/Sources/TUIkit/Views/TextField.swift b/Sources/TUIkit/Views/TextField.swift index 05e7d024..1f163e0f 100644 --- a/Sources/TUIkit/Views/TextField.swift +++ b/Sources/TUIkit/Views/TextField.swift @@ -253,7 +253,7 @@ private struct _TextFieldCore: View, Renderable, Layoutable { } func renderToBuffer(context: RenderContext) -> FrameBuffer { - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! let palette = context.environment.palette let cursorStyle = context.environment.textCursorStyle @@ -306,7 +306,7 @@ private struct _TextFieldCore: View, Renderable, Layoutable { isFocused: isFocused, palette: palette, cursorStyle: cursorStyle, - cursorTimer: context.cursorTimer, + cursorTimer: context.environment.cursorTimer, contentWidth: contentWidth ) diff --git a/Sources/TUIkit/Views/Toggle.swift b/Sources/TUIkit/Views/Toggle.swift index 1e872da4..9c4d0a30 100644 --- a/Sources/TUIkit/Views/Toggle.swift +++ b/Sources/TUIkit/Views/Toggle.swift @@ -271,7 +271,7 @@ private struct _ToggleCore: View, Renderable { bracketColor = palette.foregroundTertiary.opacity(ViewConstants.disabledForeground) } else if isFocused { let dimAccent = palette.accent.opacity(ViewConstants.focusPulseMin) - bracketColor = Color.lerp(dimAccent, palette.accent, phase: context.pulsePhase) + bracketColor = Color.lerp(dimAccent, palette.accent, phase: context.environment.pulsePhase) } else { bracketColor = palette.foregroundTertiary.opacity(ViewConstants.disabledForeground) } diff --git a/Sources/TUIkit/Views/_ImageCore.swift b/Sources/TUIkit/Views/_ImageCore.swift index 120c5701..a4dec9a0 100644 --- a/Sources/TUIkit/Views/_ImageCore.swift +++ b/Sources/TUIkit/Views/_ImageCore.swift @@ -43,8 +43,8 @@ struct _ImageCore: View, Renderable, Layoutable { // MARK: - Renderable func renderToBuffer(context: RenderContext) -> FrameBuffer { - let stateStorage = context.tuiContext.stateStorage - let lifecycle = context.tuiContext.lifecycle + let stateStorage = context.environment.stateStorage! + let lifecycle = context.environment.lifecycle! let identity = context.identity let width = context.availableWidth diff --git a/Sources/TUIkit/Views/_ListCore.swift b/Sources/TUIkit/Views/_ListCore.swift index bf50ca45..74419712 100644 --- a/Sources/TUIkit/Views/_ListCore.swift +++ b/Sources/TUIkit/Views/_ListCore.swift @@ -28,7 +28,7 @@ struct _ListCore FrameBuffer { let palette = context.environment.palette let style = context.environment.listStyle - let stateStorage = context.tuiContext.stateStorage + let stateStorage = context.environment.stateStorage! // Extract rows from content let rows = extractRows(from: content, context: context) @@ -367,7 +367,7 @@ struct _ListCore RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - BackgroundModifier Tests diff --git a/Tests/TUIkitTests/BorderModifierTests.swift b/Tests/TUIkitTests/BorderModifierTests.swift index 66aeab03..308c455b 100644 --- a/Tests/TUIkitTests/BorderModifierTests.swift +++ b/Tests/TUIkitTests/BorderModifierTests.swift @@ -12,7 +12,7 @@ import Testing /// Creates a default render context for testing. private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - BorderModifier Tests diff --git a/Tests/TUIkitTests/ButtonTests.swift b/Tests/TUIkitTests/ButtonTests.swift index c993cbdc..86f4bbeb 100644 --- a/Tests/TUIkitTests/ButtonTests.swift +++ b/Tests/TUIkitTests/ButtonTests.swift @@ -19,7 +19,8 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/ComponentViewTests.swift b/Tests/TUIkitTests/ComponentViewTests.swift index f4c3122d..38a53858 100644 --- a/Tests/TUIkitTests/ComponentViewTests.swift +++ b/Tests/TUIkitTests/ComponentViewTests.swift @@ -12,7 +12,7 @@ import Testing /// Creates a default render context for testing. private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - Border via ContainerView Tests diff --git a/Tests/TUIkitTests/ContainerViewTests.swift b/Tests/TUIkitTests/ContainerViewTests.swift index 3c161a82..fbc1d4dd 100644 --- a/Tests/TUIkitTests/ContainerViewTests.swift +++ b/Tests/TUIkitTests/ContainerViewTests.swift @@ -15,7 +15,7 @@ struct AlertTests { @Test("Alert renders with border") func alertRendering() { let alert = Alert(title: "Warning", message: "Something happened") - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(alert, context: context) #expect(buffer.height > 2) // Should have border characters @@ -34,7 +34,7 @@ struct DialogTests { let dialog = Dialog(title: "Test Dialog") { Text("Content here") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(dialog, context: context) #expect(buffer.height > 1) // Should contain title and content @@ -87,7 +87,7 @@ struct MenuTests { MenuItem(label: "Second"), ] ) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(menu, context: context) #expect(buffer.height >= 3) // border + items + border let allContent = buffer.lines.joined() diff --git a/Tests/TUIkitTests/EnvironmentTests.swift b/Tests/TUIkitTests/EnvironmentTests.swift index 74f41faf..1ccb007a 100644 --- a/Tests/TUIkitTests/EnvironmentTests.swift +++ b/Tests/TUIkitTests/EnvironmentTests.swift @@ -92,7 +92,8 @@ struct EnvironmentModifierTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: EnvironmentValues() + environment: EnvironmentValues(), + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) @@ -114,7 +115,8 @@ struct EnvironmentModifierTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: EnvironmentValues() + environment: EnvironmentValues(), + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) @@ -134,7 +136,8 @@ struct EnvironmentModifierTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: EnvironmentValues() + environment: EnvironmentValues(), + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) @@ -200,7 +203,8 @@ struct ForegroundStylePropagationTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: EnvironmentValues() + environment: EnvironmentValues(), + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) @@ -223,7 +227,8 @@ struct ForegroundStylePropagationTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: EnvironmentValues() + environment: EnvironmentValues(), + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) @@ -243,7 +248,8 @@ struct ForegroundStylePropagationTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: EnvironmentValues() + environment: EnvironmentValues(), + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) @@ -261,7 +267,8 @@ struct ForegroundStylePropagationTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: EnvironmentValues() + environment: EnvironmentValues(), + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) diff --git a/Tests/TUIkitTests/EquatableViewTests.swift b/Tests/TUIkitTests/EquatableViewTests.swift index d05f390c..7e891cc7 100644 --- a/Tests/TUIkitTests/EquatableViewTests.swift +++ b/Tests/TUIkitTests/EquatableViewTests.swift @@ -21,16 +21,23 @@ private struct LabelView: View, Equatable { @Suite("EquatableView Tests", .serialized) struct EquatableViewTests { - /// Creates a test context with a fresh TUIContext. + /// Creates a test context with a fresh environment including render cache. private func testContext( width: Int = 80, height: Int = 24, identity: ViewIdentity = ViewIdentity(path: "Root") ) -> RenderContext { - RenderContext( + let tuiContext = TUIContext() + var env = EnvironmentValues() + env.stateStorage = tuiContext.stateStorage + env.lifecycle = tuiContext.lifecycle + env.keyEventDispatcher = tuiContext.keyEventDispatcher + env.renderCache = tuiContext.renderCache + env.preferenceStorage = tuiContext.preferences + return RenderContext( availableWidth: width, availableHeight: height, - tuiContext: TUIContext(), + environment: env, identity: identity ) } @@ -45,7 +52,7 @@ struct EquatableViewTests { let buffer = renderToBuffer(view, context: context) #expect(buffer.lines[0].stripped == "Hello") - #expect(context.tuiContext.renderCache.count == 1) + #expect(context.environment.renderCache!.count == 1) } // MARK: - Cache Hit @@ -63,7 +70,7 @@ struct EquatableViewTests { let buffer2 = renderToBuffer(view2, context: context) #expect(buffer1.lines == buffer2.lines) - #expect(context.tuiContext.renderCache.count == 1) + #expect(context.environment.renderCache!.count == 1) } // MARK: - Cache Miss on Changed Content @@ -91,21 +98,28 @@ struct EquatableViewTests { let tuiContext = TUIContext() let identity = ViewIdentity(path: "Root") - // First render at 80×24 + var env = EnvironmentValues() + env.stateStorage = tuiContext.stateStorage + env.lifecycle = tuiContext.lifecycle + env.keyEventDispatcher = tuiContext.keyEventDispatcher + env.renderCache = tuiContext.renderCache + env.preferenceStorage = tuiContext.preferences + + // First render at 80x24 let context1 = RenderContext( availableWidth: 80, availableHeight: 24, - tuiContext: tuiContext, + environment: env, identity: identity ) let view = EquatableView(content: LabelView(text: "Size")) _ = renderToBuffer(view, context: context1) - // Second render at 120×40 — should miss + // Second render at 120x40 -- should miss let context2 = RenderContext( availableWidth: 120, availableHeight: 40, - tuiContext: tuiContext, + environment: env, identity: identity ) let buffer2 = renderToBuffer(view, context: context2) diff --git a/Tests/TUIkitTests/FrameBufferTests.swift b/Tests/TUIkitTests/FrameBufferTests.swift index bd95d269..21252b3e 100644 --- a/Tests/TUIkitTests/FrameBufferTests.swift +++ b/Tests/TUIkitTests/FrameBufferTests.swift @@ -91,7 +91,7 @@ struct OverlayTests { .overlay(alignment: .center) { Text("Top") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) // The overlay "Top" should be centered on "Base Content" #expect(buffer.height >= 1) @@ -102,7 +102,7 @@ struct OverlayTests { @Test("Dimmed modifier strips styling and applies uniform palette colors") func dimmedRendering() { let view = Text("Dimmed text").dimmed() - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) #expect(buffer.height == 1) // Should not use ANSI dim — uses palette-based flat coloring now @@ -117,7 +117,7 @@ struct OverlayTests { .modal { Text("Modal") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) // The result should contain both the dimmed background and the modal overlay #expect(buffer.height == 1) diff --git a/Tests/TUIkitTests/FrameModifierTests.swift b/Tests/TUIkitTests/FrameModifierTests.swift index 7d62e2ca..77df4d4a 100644 --- a/Tests/TUIkitTests/FrameModifierTests.swift +++ b/Tests/TUIkitTests/FrameModifierTests.swift @@ -12,7 +12,7 @@ import Testing /// Creates a default render context for testing. private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - FrameModifier Tests diff --git a/Tests/TUIkitTests/LayoutTests.swift b/Tests/TUIkitTests/LayoutTests.swift index cde32c88..3f8349f3 100644 --- a/Tests/TUIkitTests/LayoutTests.swift +++ b/Tests/TUIkitTests/LayoutTests.swift @@ -110,7 +110,7 @@ struct LayoutableTests { @Test("Text sizeThatFits returns content size") func textSizeThatFits() { let text = Text("Hello") - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let size = text.sizeThatFits(proposal: .unspecified, context: context) @@ -123,7 +123,7 @@ struct LayoutableTests { @Test("Text sizeThatFits wraps with proposed width") func textSizeThatFitsWraps() { let text = Text("Hello World") - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) // With narrow proposed width, text should wrap let size = text.sizeThatFits(proposal: ProposedSize(width: 6, height: nil), context: context) @@ -135,7 +135,7 @@ struct LayoutableTests { @Test("Spacer sizeThatFits is flexible") func spacerSizeThatFits() { let spacer = Spacer() - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let size = spacer.sizeThatFits(proposal: .unspecified, context: context) @@ -148,7 +148,7 @@ struct LayoutableTests { @Test("Spacer with minLength has minimum size") func spacerWithMinLength() { let spacer = Spacer(minLength: 5) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let size = spacer.sizeThatFits(proposal: .unspecified, context: context) @@ -161,7 +161,7 @@ struct LayoutableTests { @Test("Divider sizeThatFits is width-flexible") func dividerSizeThatFits() { let divider = Divider() - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let size = divider.sizeThatFits(proposal: .unspecified, context: context) @@ -178,7 +178,7 @@ struct LayoutableTests { Text("Search:") TextField("Search", text: binding, prompt: Text("Enter search term...")) } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(hstack, context: context) #expect(buffer.width == 80, "HStack should fill exactly available width, got \(buffer.width)") @@ -190,7 +190,7 @@ struct LayoutableTests { var text = "" let binding = Binding(get: { text }, set: { text = $0 }) let textField = TextField("Test", text: binding) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let size = measureChild(textField, proposal: .unspecified, context: context) diff --git a/Tests/TUIkitTests/LazyStacksTests.swift b/Tests/TUIkitTests/LazyStacksTests.swift index cbf1d807..3d70d66a 100644 --- a/Tests/TUIkitTests/LazyStacksTests.swift +++ b/Tests/TUIkitTests/LazyStacksTests.swift @@ -12,7 +12,7 @@ import Testing @MainActor private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - LazyVStack Tests diff --git a/Tests/TUIkitTests/ListRowSeparatorTests.swift b/Tests/TUIkitTests/ListRowSeparatorTests.swift index 74763489..89921feb 100644 --- a/Tests/TUIkitTests/ListRowSeparatorTests.swift +++ b/Tests/TUIkitTests/ListRowSeparatorTests.swift @@ -89,6 +89,7 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/ListTests.swift b/Tests/TUIkitTests/ListTests.swift index 237e9784..ab21de80 100644 --- a/Tests/TUIkitTests/ListTests.swift +++ b/Tests/TUIkitTests/ListTests.swift @@ -19,7 +19,8 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/ModifierPropagationTests.swift b/Tests/TUIkitTests/ModifierPropagationTests.swift index dbc8fc13..ea98c79e 100644 --- a/Tests/TUIkitTests/ModifierPropagationTests.swift +++ b/Tests/TUIkitTests/ModifierPropagationTests.swift @@ -13,7 +13,7 @@ import Testing /// Creates a default render context for testing. @MainActor private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - Modifier Propagation Tests diff --git a/Tests/TUIkitTests/NavigationSplitViewTests.swift b/Tests/TUIkitTests/NavigationSplitViewTests.swift index 582a0321..b37a5d1c 100644 --- a/Tests/TUIkitTests/NavigationSplitViewTests.swift +++ b/Tests/TUIkitTests/NavigationSplitViewTests.swift @@ -16,7 +16,6 @@ private func testContext(width: Int = 80, height: Int = 24) -> RenderContext { RenderContext( availableWidth: width, availableHeight: height, - environment: EnvironmentValues(), tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/PaddingModifierTests.swift b/Tests/TUIkitTests/PaddingModifierTests.swift index 1446b303..71ad2686 100644 --- a/Tests/TUIkitTests/PaddingModifierTests.swift +++ b/Tests/TUIkitTests/PaddingModifierTests.swift @@ -12,7 +12,7 @@ import Testing /// Creates a default render context for testing. private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - PaddingModifier Tests diff --git a/Tests/TUIkitTests/ProgressViewTests.swift b/Tests/TUIkitTests/ProgressViewTests.swift index da230569..f0714a00 100644 --- a/Tests/TUIkitTests/ProgressViewTests.swift +++ b/Tests/TUIkitTests/ProgressViewTests.swift @@ -12,7 +12,7 @@ import Testing /// Creates a default render context for testing. private func testContext(width: Int = 30, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - ProgressView Rendering Tests diff --git a/Tests/TUIkitTests/RadioButtonTests.swift b/Tests/TUIkitTests/RadioButtonTests.swift index e3268031..0eac9678 100644 --- a/Tests/TUIkitTests/RadioButtonTests.swift +++ b/Tests/TUIkitTests/RadioButtonTests.swift @@ -18,7 +18,8 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/RenderBottleneckTests.swift b/Tests/TUIkitTests/RenderBottleneckTests.swift index 5e71c310..d9645d2c 100644 --- a/Tests/TUIkitTests/RenderBottleneckTests.swift +++ b/Tests/TUIkitTests/RenderBottleneckTests.swift @@ -17,7 +17,7 @@ import Testing struct RenderBottleneckTests { private func testContext(width: Int = 80, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } /// Measures execution time of a block over multiple iterations. diff --git a/Tests/TUIkitTests/RenderPerformanceTests.swift b/Tests/TUIkitTests/RenderPerformanceTests.swift index c419cc9a..7ae78f20 100644 --- a/Tests/TUIkitTests/RenderPerformanceTests.swift +++ b/Tests/TUIkitTests/RenderPerformanceTests.swift @@ -24,7 +24,7 @@ struct RenderPerformanceTests { // MARK: - Test Helpers private func testContext(width: Int = 80, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } /// Measures the time to render a view multiple times. @@ -282,7 +282,7 @@ struct RenderPerformanceTests { struct RenderPerformanceStatistics { private func testContext(width: Int = 80, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } @Test("Print render performance statistics") diff --git a/Tests/TUIkitTests/RenderingTests.swift b/Tests/TUIkitTests/RenderingTests.swift index 89152cb8..0baa2ff1 100644 --- a/Tests/TUIkitTests/RenderingTests.swift +++ b/Tests/TUIkitTests/RenderingTests.swift @@ -15,7 +15,7 @@ struct RenderingTests { @Test("Text renders to single line buffer") func textBuffer() { let text = Text("Hello") - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(text, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0].stripped == "Hello") @@ -24,7 +24,7 @@ struct RenderingTests { @Test("EmptyView renders to empty buffer") func emptyViewBuffer() { let empty = EmptyView() - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(empty, context: context) #expect(buffer.isEmpty) } @@ -35,7 +35,7 @@ struct RenderingTests { Text("Line 1") Text("Line 2") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(stack, context: context) #expect(buffer.height == 2) #expect(buffer.lines[0].stripped.contains("Line 1")) @@ -48,7 +48,7 @@ struct RenderingTests { Text("A") Text("B") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(stack, context: context) #expect(buffer.height == 3) #expect(buffer.lines[0].stripped.contains("A")) @@ -62,7 +62,7 @@ struct RenderingTests { Text("Left") Text("Right") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(stack, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0].stripped == "Left Right") @@ -74,7 +74,7 @@ struct RenderingTests { Text("A") Text("B") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(stack, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0].stripped == "A B") @@ -86,7 +86,7 @@ struct RenderingTests { Text("Label:") Text("Value") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(layout, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0].stripped == "Label: Value") @@ -104,7 +104,7 @@ struct RenderingTests { } let view = MyView() - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) #expect(buffer.height == 2) #expect(buffer.lines[0].stripped.contains("Hello")) @@ -114,7 +114,7 @@ struct RenderingTests { @Test("Divider renders to full width") func dividerBuffer() { let divider = Divider() - let context = RenderContext(availableWidth: 20, availableHeight: 24) + let context = RenderContext(availableWidth: 20, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(divider, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0] == String(repeating: "─", count: 20)) @@ -123,7 +123,7 @@ struct RenderingTests { @Test("Spacer renders empty lines") func spacerBuffer() { let spacer = Spacer(minLength: 3) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(spacer, context: context) #expect(buffer.height == 3) } @@ -135,7 +135,7 @@ struct RenderingTests { Text("Visible") EmptyView() } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(stack, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0].stripped == "Visible") @@ -147,7 +147,7 @@ struct RenderingTests { Text("AB") Text("CD") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(stack, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0].stripped == "ABCD") @@ -165,7 +165,7 @@ struct RenderingTests { } Text("D") } - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(layout, context: context) #expect(buffer.height == 3) // Last line contains "D", possibly with trailing spaces from VStack alignment @@ -179,7 +179,7 @@ struct RenderingTests { Spacer() Text("Bottom") } - let context = RenderContext(availableWidth: 40, availableHeight: 10) + let context = RenderContext(availableWidth: 40, availableHeight: 10, tuiContext: TUIContext()) // Debug: Check child infos let infos = resolveChildInfos(from: stack.content, context: context) @@ -216,7 +216,7 @@ struct RenderingTests { Spacer() Text("R") } - var context = RenderContext(availableWidth: 20, availableHeight: 10) + var context = RenderContext(availableWidth: 20, availableHeight: 10, tuiContext: TUIContext()) context.hasExplicitWidth = true // Simulate terminal/frame constraint let buffer = renderToBuffer(stack, context: context) @@ -229,7 +229,7 @@ struct RenderingTests { @Test("Text wraps at availableWidth") func textWrapsAtWidth() { let text = Text("This is a long text that should wrap") - let context = RenderContext(availableWidth: 15, availableHeight: 10) + let context = RenderContext(availableWidth: 15, availableHeight: 10, tuiContext: TUIContext()) let buffer = renderToBuffer(text, context: context) // Text should wrap into multiple lines @@ -244,7 +244,7 @@ struct RenderingTests { func frameConstrainsWidth() { let view = Text("This is a long text that should be wrapped because of the frame modifier") .frame(width: 20) - let context = RenderContext(availableWidth: 100, availableHeight: 10) + let context = RenderContext(availableWidth: 100, availableHeight: 10, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) // Text should wrap at frame width (20), not available width (100) @@ -260,7 +260,7 @@ struct RenderingTests { Text("X") Spacer() } - var context = RenderContext(availableWidth: 11, availableHeight: 1) + var context = RenderContext(availableWidth: 11, availableHeight: 1, tuiContext: TUIContext()) context.hasExplicitWidth = true // Simulate terminal/frame constraint let buffer = renderToBuffer(view, context: context) @@ -288,7 +288,7 @@ struct RenderingTests { } Spacer() } - var context = RenderContext(availableWidth: 20, availableHeight: 5) + var context = RenderContext(availableWidth: 20, availableHeight: 5, tuiContext: TUIContext()) context.hasExplicitWidth = true // Simulate terminal/frame constraint let buffer = renderToBuffer(view, context: context) @@ -317,7 +317,7 @@ struct RenderingTests { Text("Hi") Spacer() } - let context = RenderContext(availableWidth: 20, availableHeight: 10) + let context = RenderContext(availableWidth: 20, availableHeight: 10, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) // VStack height should fill available height due to spacers @@ -338,7 +338,7 @@ struct RenderingTests { Text("Short") Text("Longer text here") } - let context = RenderContext(availableWidth: 40, availableHeight: 10) + let context = RenderContext(availableWidth: 40, availableHeight: 10, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) // With default .center alignment (like SwiftUI), shorter text should be centered @@ -356,7 +356,7 @@ struct RenderingTests { Text("Hi") Text("Hello World") } - let context = RenderContext(availableWidth: 40, availableHeight: 10) + let context = RenderContext(availableWidth: 40, availableHeight: 10, tuiContext: TUIContext()) let buffer = renderToBuffer(view, context: context) // "Hello World" is 11 chars, "Hi" is 2 chars @@ -392,7 +392,7 @@ struct RenderingTests { // Test through WindowGroup like the real app let windowGroup = WindowGroup { contentView } - let context = RenderContext(availableWidth: 80, availableHeight: 30) + let context = RenderContext(availableWidth: 80, availableHeight: 30, tuiContext: TUIContext()) let buffer = windowGroup.renderScene(context: context) print("Buffer height: \(buffer.height)") @@ -429,7 +429,7 @@ struct RenderingTests { Text("End") }.border() - var context = RenderContext(availableWidth: 80, availableHeight: 10) + var context = RenderContext(availableWidth: 80, availableHeight: 10, tuiContext: TUIContext()) context.hasExplicitWidth = true let buffer = renderToBuffer(view, context: context) @@ -461,7 +461,7 @@ struct RenderingTests { } } - var context = RenderContext(availableWidth: 80, availableHeight: 10) + var context = RenderContext(availableWidth: 80, availableHeight: 10, tuiContext: TUIContext()) context.hasExplicitWidth = true context.hasExplicitHeight = true let buffer = renderToBuffer(view, context: context) @@ -496,7 +496,7 @@ struct RenderingTests { Text("End") }.border() - var context = RenderContext(availableWidth: 80, availableHeight: 10) + var context = RenderContext(availableWidth: 80, availableHeight: 10, tuiContext: TUIContext()) context.hasExplicitWidth = true let buffer = renderToBuffer(view, context: context) diff --git a/Tests/TUIkitTests/SectionListIntegrationTests.swift b/Tests/TUIkitTests/SectionListIntegrationTests.swift index 015ff89c..cd0a4303 100644 --- a/Tests/TUIkitTests/SectionListIntegrationTests.swift +++ b/Tests/TUIkitTests/SectionListIntegrationTests.swift @@ -19,7 +19,8 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/SectionTests.swift b/Tests/TUIkitTests/SectionTests.swift index 4fff7f28..e736ba47 100644 --- a/Tests/TUIkitTests/SectionTests.swift +++ b/Tests/TUIkitTests/SectionTests.swift @@ -19,7 +19,8 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/SecureFieldTests.swift b/Tests/TUIkitTests/SecureFieldTests.swift index 141e973a..d1a863d3 100644 --- a/Tests/TUIkitTests/SecureFieldTests.swift +++ b/Tests/TUIkitTests/SecureFieldTests.swift @@ -15,7 +15,7 @@ import Testing struct SecureFieldTests { private func testContext(width: Int = 80, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - Initialization diff --git a/Tests/TUIkitTests/SelectionDisabledTests.swift b/Tests/TUIkitTests/SelectionDisabledTests.swift index 30e3c0ca..68fbb6af 100644 --- a/Tests/TUIkitTests/SelectionDisabledTests.swift +++ b/Tests/TUIkitTests/SelectionDisabledTests.swift @@ -73,6 +73,7 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/SliderTests.swift b/Tests/TUIkitTests/SliderTests.swift index b7a38800..4e8f6ed6 100644 --- a/Tests/TUIkitTests/SliderTests.swift +++ b/Tests/TUIkitTests/SliderTests.swift @@ -10,7 +10,7 @@ import Testing /// Creates a default render context for testing. private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } @MainActor diff --git a/Tests/TUIkitTests/SpinnerTests.swift b/Tests/TUIkitTests/SpinnerTests.swift index d4fee413..6eed621b 100644 --- a/Tests/TUIkitTests/SpinnerTests.swift +++ b/Tests/TUIkitTests/SpinnerTests.swift @@ -12,7 +12,7 @@ import Testing /// Creates a render context for spinner testing. private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - SpinnerStyle Tests diff --git a/Tests/TUIkitTests/StateStorageIdentityTests.swift b/Tests/TUIkitTests/StateStorageIdentityTests.swift index f62bd4c3..1142633e 100644 --- a/Tests/TUIkitTests/StateStorageIdentityTests.swift +++ b/Tests/TUIkitTests/StateStorageIdentityTests.swift @@ -207,10 +207,16 @@ struct StateStorageIdentityTests { @Test("State survives reconstruction through renderToBuffer") func stateSurvivesRenderToBuffer() { let tuiContext = TUIContext() + var env = EnvironmentValues() + env.stateStorage = tuiContext.stateStorage + env.lifecycle = tuiContext.lifecycle + env.keyEventDispatcher = tuiContext.keyEventDispatcher + env.renderCache = tuiContext.renderCache + env.preferenceStorage = tuiContext.preferences let context = RenderContext( availableWidth: 80, availableHeight: 24, - tuiContext: tuiContext, + environment: env, identity: ViewIdentity(path: "") ) @@ -226,10 +232,16 @@ struct StateStorageIdentityTests { @Test("Nested views get independent state identities") func nestedViewsIndependentState() { let tuiContext = TUIContext() + var env = EnvironmentValues() + env.stateStorage = tuiContext.stateStorage + env.lifecycle = tuiContext.lifecycle + env.keyEventDispatcher = tuiContext.keyEventDispatcher + env.renderCache = tuiContext.renderCache + env.preferenceStorage = tuiContext.preferences let context = RenderContext( availableWidth: 80, availableHeight: 24, - tuiContext: tuiContext, + environment: env, identity: ViewIdentity(path: "") ) diff --git a/Tests/TUIkitTests/StatusBarViewTests.swift b/Tests/TUIkitTests/StatusBarViewTests.swift index d243c352..c9fe8830 100644 --- a/Tests/TUIkitTests/StatusBarViewTests.swift +++ b/Tests/TUIkitTests/StatusBarViewTests.swift @@ -23,7 +23,7 @@ struct StatusBarViewTests { style: .compact ) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) #expect(buffer.height == 1) @@ -42,7 +42,7 @@ struct StatusBarViewTests { ) // Use default appearance (rounded) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) #expect(buffer.height == 3) @@ -58,7 +58,7 @@ struct StatusBarViewTests { func emptyStatusBar() { let statusBar = StatusBar(items: []) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) #expect(buffer.isEmpty) @@ -71,7 +71,7 @@ struct StatusBarViewTests { StatusBarItem(shortcut: "b", label: "beta"), ]) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) let content = buffer.lines.joined() @@ -91,7 +91,7 @@ struct StatusBarViewTests { #expect(statusBar.alignment == .leading) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) // Content should start near the beginning (after padding) @@ -112,7 +112,7 @@ struct StatusBarViewTests { #expect(statusBar.alignment == .trailing) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) // Content should be at the end @@ -133,7 +133,7 @@ struct StatusBarViewTests { #expect(statusBar.alignment == .center) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) // Content should be centered @@ -155,7 +155,7 @@ struct StatusBarViewTests { #expect(statusBar.alignment == .justified) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) // All items should be present @@ -179,7 +179,7 @@ struct StatusBarViewTests { #expect(statusBar.style == .bordered) #expect(statusBar.alignment == .center) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) #expect(buffer.height == 3) @@ -199,7 +199,7 @@ struct StatusBarAlignmentTests { alignment: .justified ) - let context = RenderContext(availableWidth: 40, availableHeight: 24) + let context = RenderContext(availableWidth: 40, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(statusBar, context: context) // Single item should be centered in justified mode @@ -233,7 +233,8 @@ struct StatusBarItemsModifierTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: environment + environment: environment, + tuiContext: TUIContext() ) _ = renderToBuffer(view, context: context) @@ -266,7 +267,8 @@ struct StatusBarItemsModifierTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: environment + environment: environment, + tuiContext: TUIContext() ) _ = renderToBuffer(view, context: context) @@ -297,7 +299,8 @@ struct StatusBarItemsModifierTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: environment + environment: environment, + tuiContext: TUIContext() ) let buffer = renderToBuffer(view, context: context) @@ -330,7 +333,8 @@ struct StatusBarItemsModifierTests { let context = RenderContext( availableWidth: 80, availableHeight: 24, - environment: environment + environment: environment, + tuiContext: TUIContext() ) _ = renderToBuffer(outerView, context: context) diff --git a/Tests/TUIkitTests/StepperTests.swift b/Tests/TUIkitTests/StepperTests.swift index cda42f77..2dfaff4c 100644 --- a/Tests/TUIkitTests/StepperTests.swift +++ b/Tests/TUIkitTests/StepperTests.swift @@ -10,7 +10,7 @@ import Testing /// Creates a default render context for testing. private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } @MainActor diff --git a/Tests/TUIkitTests/TableTests.swift b/Tests/TUIkitTests/TableTests.swift index d0641f86..29971551 100644 --- a/Tests/TUIkitTests/TableTests.swift +++ b/Tests/TUIkitTests/TableTests.swift @@ -34,7 +34,8 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/TextFieldTests.swift b/Tests/TUIkitTests/TextFieldTests.swift index eabab00f..7f70a245 100644 --- a/Tests/TUIkitTests/TextFieldTests.swift +++ b/Tests/TUIkitTests/TextFieldTests.swift @@ -15,7 +15,7 @@ import Testing struct TextFieldTests { private func testContext(width: Int = 80, height: Int = 24) -> RenderContext { - RenderContext(availableWidth: width, availableHeight: height) + RenderContext(availableWidth: width, availableHeight: height, tuiContext: TUIContext()) } // MARK: - Initialization diff --git a/Tests/TUIkitTests/ToggleTests.swift b/Tests/TUIkitTests/ToggleTests.swift index eed9c273..23896fdc 100644 --- a/Tests/TUIkitTests/ToggleTests.swift +++ b/Tests/TUIkitTests/ToggleTests.swift @@ -19,7 +19,8 @@ private func createTestContext(width: Int = 80, height: Int = 24) -> RenderConte return RenderContext( availableWidth: width, availableHeight: height, - environment: environment + environment: environment, + tuiContext: TUIContext() ) } diff --git a/Tests/TUIkitTests/TupleViewEquatableTests.swift b/Tests/TUIkitTests/TupleViewEquatableTests.swift index 18e991fb..f806602c 100644 --- a/Tests/TUIkitTests/TupleViewEquatableTests.swift +++ b/Tests/TUIkitTests/TupleViewEquatableTests.swift @@ -12,16 +12,23 @@ import Testing @Suite("TupleView Equatable Tests", .serialized) struct TupleViewEquatableTests { - /// Creates a test context with a fresh TUIContext. + /// Creates a test context with a fresh environment including render cache. private func testContext( width: Int = 80, height: Int = 24, identity: ViewIdentity = ViewIdentity(path: "Root") ) -> RenderContext { - RenderContext( + let tuiContext = TUIContext() + var env = EnvironmentValues() + env.stateStorage = tuiContext.stateStorage + env.lifecycle = tuiContext.lifecycle + env.keyEventDispatcher = tuiContext.keyEventDispatcher + env.renderCache = tuiContext.renderCache + env.preferenceStorage = tuiContext.preferences + return RenderContext( availableWidth: width, availableHeight: height, - tuiContext: TUIContext(), + environment: env, identity: identity ) } @@ -93,7 +100,7 @@ struct TupleViewEquatableTests { @Test("VStack with equatable content gets cache hit on second render") func cacheHitForEqualVStack() { let context = testContext() - let cache = context.tuiContext.renderCache + let cache = context.environment.renderCache! let stack1 = VStack { Text("Static A") @@ -119,7 +126,7 @@ struct TupleViewEquatableTests { @Test("VStack with changed content causes cache miss") func cacheMissForChangedVStack() { let context = testContext() - let cache = context.tuiContext.renderCache + let cache = context.environment.renderCache! let stack1 = VStack { Text("Before") @@ -146,13 +153,20 @@ struct TupleViewEquatableTests { let tuiContext = TUIContext() let cache = tuiContext.renderCache + var env = EnvironmentValues() + env.stateStorage = tuiContext.stateStorage + env.lifecycle = tuiContext.lifecycle + env.keyEventDispatcher = tuiContext.keyEventDispatcher + env.renderCache = tuiContext.renderCache + env.preferenceStorage = tuiContext.preferences + let innerIdentity = ViewIdentity(path: "Root/Inner") let outerIdentity = ViewIdentity(path: "Root/Outer") // Render inner let innerContext = RenderContext( availableWidth: 80, availableHeight: 24, - tuiContext: tuiContext, identity: innerIdentity + environment: env, identity: innerIdentity ) let inner = EquatableView(content: HStack { Text("Left") @@ -163,7 +177,7 @@ struct TupleViewEquatableTests { // Render outer let outerContext = RenderContext( availableWidth: 80, availableHeight: 24, - tuiContext: tuiContext, identity: outerIdentity + environment: env, identity: outerIdentity ) let outer = EquatableView(content: VStack { Text("Top") diff --git a/Tests/TUIkitTests/ViewTests.swift b/Tests/TUIkitTests/ViewTests.swift index 170fc5b5..979c6a35 100644 --- a/Tests/TUIkitTests/ViewTests.swift +++ b/Tests/TUIkitTests/ViewTests.swift @@ -16,7 +16,7 @@ struct AnyViewTests { func anyViewWrapping() { let text = Text("Hello") let anyView = AnyView(text) - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(anyView, context: context) #expect(buffer.lines[0].stripped == "Hello") } @@ -24,7 +24,7 @@ struct AnyViewTests { @Test("asAnyView extension works") func asAnyViewExtension() { let anyView = Text("Test").bold().asAnyView() - let context = RenderContext(availableWidth: 80, availableHeight: 24) + let context = RenderContext(availableWidth: 80, availableHeight: 24, tuiContext: TUIContext()) let buffer = renderToBuffer(anyView, context: context) #expect(buffer.height == 1) #expect(buffer.lines[0].stripped == "Test")