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
This commit is contained in:
phranck
2026-02-14 13:13:24 +01:00
parent d39e02722b
commit 3fb4944472
66 changed files with 396 additions and 222 deletions
+16 -15
View File
@@ -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)
+2 -2
View File
@@ -76,7 +76,7 @@ public struct EquatableView<Content: View & Equatable>: 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)
}
}
+1 -1
View File
@@ -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"))
+2 -2
View File
@@ -25,7 +25,7 @@ struct PreferenceModifier<Content: View, K: PreferenceKey>: 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)
@@ -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 }
}
}
+2 -2
View File
@@ -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)
+3 -3
View File
@@ -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<String> = 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.
@@ -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)
@@ -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)
@@ -29,7 +29,7 @@ public struct KeyPressModifier<Content: View>: 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 {
@@ -26,7 +26,7 @@ struct OnAppearModifier<Content: View>: 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<Content: View>: 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<Content: View>: 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)
@@ -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)
@@ -75,7 +75,7 @@ struct NavigationSplitViewColumnWidthView<Content: View>: 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)
@@ -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,
@@ -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
+35 -48
View File
@@ -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 (01) 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
}
+2 -2
View File
@@ -183,7 +183,7 @@ func renderToBuffer<V: View>(_ 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<V: View>(_ 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)
}
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -325,8 +325,8 @@ private struct _ContainerViewCore<Content: View, Footer: View>: 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)
+1 -1
View File
@@ -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
@@ -251,15 +251,15 @@ private struct _NavigationSplitViewCore<Sidebar: View, Content: View, Detail: Vi
// Create a context with the active focus section
var sectionContext = columnContext
sectionContext.activeFocusSectionID = sectionID
sectionContext.environment.activeFocusSectionID = sectionID
// If this section is active, set the focus indicator color for borders (never active during measurement)
if !columnContext.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
}
let buffer = renderColumn(column, context: sectionContext)
+2 -2
View File
@@ -207,7 +207,7 @@ private struct _RadioButtonGroupCore<Value: Hashable>: 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<AnyHashable>(
@@ -323,7 +323,7 @@ private struct _RadioButtonGroupCore<Value: Hashable>: 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
+2 -2
View File
@@ -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
)
+2 -2
View File
@@ -285,7 +285,7 @@ private struct _SliderCore<Label: View, ValueLabel: View>: 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<Label: View, ValueLabel: View>: View, Renderable, Lay
fraction: fraction,
isFocused: isFocused,
palette: palette,
pulsePhase: context.pulsePhase,
pulsePhase: context.environment.pulsePhase,
trackWidth: trackWidth
)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -304,7 +304,7 @@ private struct _StepperCore<Label: View>: 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<Label: View>: View, Renderable {
let content = buildContent(
isFocused: isFocused,
palette: palette,
pulsePhase: context.pulsePhase
pulsePhase: context.environment.pulsePhase
)
return FrameBuffer(text: content)
+2 -2
View File
@@ -191,7 +191,7 @@ private struct _TableCore<Value: Identifiable & Sendable>: 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<Value: Identifiable & Sendable>: 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)
+2 -2
View File
@@ -253,7 +253,7 @@ private struct _TextFieldCore<Label: View>: 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<Label: View>: View, Renderable, Layoutable {
isFocused: isFocused,
palette: palette,
cursorStyle: cursorStyle,
cursorTimer: context.cursorTimer,
cursorTimer: context.environment.cursorTimer,
contentWidth: contentWidth
)
+1 -1
View File
@@ -271,7 +271,7 @@ private struct _ToggleCore<Label: View>: 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)
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -28,7 +28,7 @@ struct _ListCore<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
func renderToBuffer(context: RenderContext) -> 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<SelectionValue: Hashable & Sendable, Content: View, Footer: Vie
case .content:
if isFocused && isSelected {
let dimAccent = palette.accent.opacity(ViewConstants.focusPulseMin)
return Color.lerp(dimAccent, palette.accent.opacity(ViewConstants.focusPulseMax), phase: context.pulsePhase)
return Color.lerp(dimAccent, palette.accent.opacity(ViewConstants.focusPulseMax), phase: context.environment.pulsePhase)
} else if isFocused {
return palette.focusBackground
} else if isSelected {
@@ -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: - BackgroundModifier Tests
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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()
)
}
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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()
+14 -7
View File
@@ -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)
+23 -9
View File
@@ -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)
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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
+7 -7
View File
@@ -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)
+1 -1
View File
@@ -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
@@ -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()
)
}
+2 -1
View File
@@ -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()
)
}
@@ -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
@@ -16,7 +16,6 @@ private func testContext(width: Int = 80, height: Int = 24) -> RenderContext {
RenderContext(
availableWidth: width,
availableHeight: height,
environment: EnvironmentValues(),
tuiContext: TUIContext()
)
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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()
)
}
@@ -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.
@@ -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")
+26 -26
View File
@@ -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)
@@ -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()
)
}
+2 -1
View File
@@ -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()
)
}
+1 -1
View File
@@ -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
@@ -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()
)
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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: "")
)
+18 -14
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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()
)
}
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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()
)
}
@@ -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")
+2 -2
View File
@@ -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")