Merge pull request #67 from phranck/feat/overlays-demo-redesign

Feat: Redesign OverlaysPage with interactive demo menu
This commit is contained in:
phranck
2026-02-03 13:46:57 +01:00
13 changed files with 426 additions and 137 deletions
+1
View File
@@ -91,6 +91,7 @@ internal final class AppRunner<A: App> {
self.inputHandler = InputHandler(
statusBar: statusBar,
keyEventDispatcher: tuiContext.keyEventDispatcher,
focusManager: focusManager,
paletteManager: paletteManager,
appearanceManager: appearanceManager,
onQuit: { [weak self] in
+14 -5
View File
@@ -7,12 +7,13 @@
// MARK: - Input Handler
/// Dispatches key events through a 3-layer priority chain.
/// Dispatches key events through a 4-layer priority chain.
///
/// The dispatch order is:
/// 1. **Status bar** items with actions get first priority
/// 2. **View handlers** registered via `onKeyPress` modifiers
/// 3. **Default bindings** `q` (quit), `t` (theme), `a` (appearance)
/// 3. **Focus system** Tab/Shift+Tab navigation, Enter/Space on focused buttons
/// 4. **Default bindings** `q` (quit), `t` (theme), `a` (appearance)
///
/// If a layer consumes the event, subsequent layers are skipped.
internal struct InputHandler {
@@ -22,6 +23,9 @@ internal struct InputHandler {
/// The key event dispatcher for view-registered handlers.
let keyEventDispatcher: KeyEventDispatcher
/// The focus manager for Tab navigation and focused element activation.
let focusManager: FocusManager
/// The palette manager for theme cycling (`t` key).
let paletteManager: ThemeManager
@@ -31,7 +35,7 @@ internal struct InputHandler {
/// Called when the user requests to quit the application.
let onQuit: () -> Void
/// Dispatches a key event through the 3-layer priority chain.
/// Dispatches a key event through the 4-layer priority chain.
///
/// - Parameter event: The key event to handle.
func handle(_ event: KeyEvent) {
@@ -40,12 +44,17 @@ internal struct InputHandler {
return
}
// Layer 2: View-registered key handlers
// Layer 2: View-registered key handlers (onKeyPress, Menu arrow keys)
if keyEventDispatcher.dispatch(event) {
return
}
// Layer 3: Default key bindings
// Layer 3: Focus system (Tab navigation, Enter/Space on focused buttons)
if focusManager.dispatchKeyEvent(event) {
return
}
// Layer 4: Default key bindings
switch event.key {
case .character(let character) where character == "q" || character == "Q":
if statusBar.isQuitAllowed {
@@ -82,9 +82,17 @@ extension AlertPresentationModifier: Renderable {
actions: { actions }
)
// Render dimmed base with centered alert overlay
// Render dimmed base with an isolated context.
// The base content's buttons and key handlers register into a
// throwaway FocusManager and KeyEventDispatcher so they don't
// interfere with the alert's interactive elements.
let dimmedBase = DimmedModifier(content: content)
let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: context)
let isolatedContext = context.isolatedForBackground()
let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: isolatedContext)
// Clear the real focus manager so the alert's buttons become
// the only registered focusables (auto-focus picks the first one).
context.environment.focusManager.clear()
let alertBuffer = TUIkit.renderToBuffer(alert, context: context)
@@ -48,9 +48,17 @@ extension ModalPresentationModifier: Renderable {
return TUIkit.renderToBuffer(content, context: context)
}
// Render dimmed base with centered modal overlay
// Render dimmed base with an isolated context.
// The base content's buttons and key handlers register into a
// throwaway FocusManager and KeyEventDispatcher so they don't
// interfere with the modal's interactive elements.
let dimmedBase = DimmedModifier(content: content)
let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: context)
let isolatedContext = context.isolatedForBackground()
let dimmedBuffer = TUIkit.renderToBuffer(dimmedBase, context: isolatedContext)
// Clear the real focus manager so the modal's buttons become
// the only registered focusables (auto-focus picks the first one).
context.environment.focusManager.clear()
let modalBuffer = TUIkit.renderToBuffer(modal, context: context)
+22 -1
View File
@@ -85,7 +85,9 @@ public struct RenderContext {
///
/// Provides access to lifecycle tracking, key event dispatch,
/// and preference storage via constructor injection.
let tuiContext: TUIContext
/// 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.
///
@@ -165,6 +167,25 @@ public struct RenderContext {
copy.identity = identity.branch(label)
return copy
}
/// Creates a context isolated from the real focus and key event systems.
///
/// Used by modal presentation modifiers to render background content
/// visually without letting its buttons and key handlers interfere
/// with the modal's interactive elements. The returned context has a
/// throwaway ``FocusManager`` and ``KeyEventDispatcher`` while sharing
/// lifecycle, preferences, and state storage with the real context.
func isolatedForBackground() -> RenderContext {
var copy = self
copy.environment.focusManager = FocusManager()
copy.tuiContext = TUIContext(
lifecycle: tuiContext.lifecycle,
keyEventDispatcher: KeyEventDispatcher(),
preferences: tuiContext.preferences,
stateStorage: tuiContext.stateStorage
)
return copy
}
}
// MARK: - Rendering Dispatch
+28 -16
View File
@@ -133,7 +133,7 @@ extension Alert where Actions == EmptyView {
// MARK: - Preset Alert Styles
extension Alert {
/// Creates a warning-style alert with yellow border.
/// Creates a warning-style alert with palette warning colors.
///
/// - Parameters:
/// - title: The alert title (default: "Warning").
@@ -148,13 +148,13 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderColor: .yellow,
titleColor: .yellow,
borderColor: .palette.warning,
titleColor: .palette.warning,
actions: actions
)
}
/// Creates an error-style alert with red border.
/// Creates an error-style alert with palette error colors.
///
/// - Parameters:
/// - title: The alert title (default: "Error").
@@ -169,13 +169,13 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderColor: .red,
titleColor: .red,
borderColor: .palette.error,
titleColor: .palette.error,
actions: actions
)
}
/// Creates an info-style alert with cyan border.
/// Creates an info-style alert with palette info colors.
///
/// - Parameters:
/// - title: The alert title (default: "Info").
@@ -190,13 +190,13 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderColor: .cyan,
titleColor: .cyan,
borderColor: .palette.info,
titleColor: .palette.info,
actions: actions
)
}
/// Creates a success-style alert with green border.
/// Creates a success-style alert with palette success colors.
///
/// - Parameters:
/// - title: The alert title (default: "Success").
@@ -211,8 +211,8 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderColor: .green,
titleColor: .green,
borderColor: .palette.success,
titleColor: .palette.success,
actions: actions
)
}
@@ -223,21 +223,33 @@ extension Alert {
extension Alert where Actions == EmptyView {
/// Creates a warning-style alert without actions.
public static func warning(title: String = "Warning", message: String) -> Alert<EmptyView> {
Alert.warning(title: title, message: message) { EmptyView() }
Alert<EmptyView>(
title: title, message: message,
borderColor: .palette.warning, titleColor: .palette.warning
)
}
/// Creates an error-style alert without actions.
public static func error(title: String = "Error", message: String) -> Alert<EmptyView> {
Alert.error(title: title, message: message) { EmptyView() }
Alert<EmptyView>(
title: title, message: message,
borderColor: .palette.error, titleColor: .palette.error
)
}
/// Creates an info-style alert without actions.
public static func info(title: String = "Info", message: String) -> Alert<EmptyView> {
Alert.info(title: title, message: message) { EmptyView() }
Alert<EmptyView>(
title: title, message: message,
borderColor: .palette.info, titleColor: .palette.info
)
}
/// Creates a success-style alert without actions.
public static func success(title: String = "Success", message: String) -> Alert<EmptyView> {
Alert.success(title: title, message: message) { EmptyView() }
Alert<EmptyView>(
title: title, message: message,
borderColor: .palette.success, titleColor: .palette.success
)
}
}
+25 -16
View File
@@ -282,25 +282,36 @@ extension ContainerView: Renderable {
let palette = context.environment.palette
let borderColor = style.borderColor?.resolve(with: palette) ?? palette.border
// Render body content
let paddedContent = content.padding(padding)
let bodyBuffer = TUIkit.renderToBuffer(paddedContent, context: context)
// Context with reduced width for content inside borders.
// Subtract 2 for the left and right border characters so that
// Spacer() and other layout views calculate available space correctly.
var innerContext = context
innerContext.availableWidth = max(0, context.availableWidth - 2)
// Render footer if present
// Render body content first to determine its width.
let paddedContent = content.padding(padding)
let bodyBuffer = TUIkit.renderToBuffer(paddedContent, context: innerContext)
// Calculate inner width from body and title (footer adapts to this).
let titleWidth = title.map { $0.count + 4 } ?? 0 // " Title " + borders
let innerWidth = max(titleWidth, bodyBuffer.width)
// Render footer constrained to the actual inner width.
// PaddingModifier is post-processing (doesn't reduce availableWidth for
// its child), so we subtract the footer padding from the context width
// manually. This ensures Spacer() in the footer fills exactly the
// container's inner width.
let footerPadding = EdgeInsets(horizontal: 1, vertical: 0)
let footerBuffer: FrameBuffer?
if let footerView = footer {
let paddedFooter = footerView.padding(EdgeInsets(horizontal: 1, vertical: 0))
footerBuffer = TUIkit.renderToBuffer(paddedFooter, context: context)
var footerContext = innerContext
footerContext.availableWidth = innerWidth - footerPadding.leading - footerPadding.trailing
let paddedFooter = footerView.padding(footerPadding)
footerBuffer = TUIkit.renderToBuffer(paddedFooter, context: footerContext)
} else {
footerBuffer = nil
}
// Calculate inner width
let titleWidth = title.map { $0.count + 4 } ?? 0 // " Title " + borders
let bodyWidth = bodyBuffer.width
let footerWidth = footerBuffer?.width ?? 0
let innerWidth = max(titleWidth, bodyWidth, footerWidth)
if isBlockAppearance {
return renderBlockStyle(
bodyBuffer: bodyBuffer,
@@ -357,16 +368,14 @@ extension ContainerView: Renderable {
)
}
// Body lines with theme background
let bodyBg = context.environment.palette.blockSurfaceBackground
// Body lines (no background only block style uses distinct section colors)
for line in bodyBuffer.lines {
lines.append(
BorderRenderer.standardContentLine(
content: line,
innerWidth: innerWidth,
style: borderStyle,
color: borderColor,
backgroundColor: bodyBg
color: borderColor
)
)
}
+1 -2
View File
@@ -75,8 +75,7 @@ struct ContentView: View {
ContainersPage()
.statusBarItems(subPageItems(pageSetter: pageSetter))
case .overlays:
OverlaysPage()
.statusBarItems(subPageItems(pageSetter: pageSetter))
OverlaysPage(onBack: { pageSetter.wrappedValue = .menu })
case .layout:
LayoutPage()
.statusBarItems(subPageItems(pageSetter: pageSetter))
+282 -48
View File
@@ -2,78 +2,312 @@
// OverlaysPage.swift
// TUIkitExample
//
// Demonstrates overlay and modal capabilities.
// Demonstrates overlay and modal capabilities with an interactive menu.
//
import TUIkit
/// Overlays and modals demo page.
// MARK: - Overlay Demo Variants
/// Available overlay demo variants.
private enum OverlayDemo: Int, CaseIterable {
case alertStandard
case alertWarning
case alertError
case alertInfo
case alertSuccess
case dialog
case dialogWithFooter
case modalCustom
/// Display label for the menu.
var label: String {
switch self {
case .alertStandard: "Alert (Standard)"
case .alertWarning: "Alert (Warning)"
case .alertError: "Alert (Error)"
case .alertInfo: "Alert (Info)"
case .alertSuccess: "Alert (Success)"
case .dialog: "Dialog"
case .dialogWithFooter: "Dialog with Footer"
case .modalCustom: "Modal (Custom)"
}
}
/// Description text for the detail panel.
var description: String {
switch self {
case .alertStandard:
"A standard alert with default theme colors. Uses .alert(isPresented:) modifier."
case .alertWarning:
"A warning-style alert with palette warning colors. Uses Alert.warning() preset."
case .alertError:
"An error-style alert with palette error colors. Uses Alert.error() preset."
case .alertInfo:
"An info-style alert with palette info colors. Uses Alert.info() preset."
case .alertSuccess:
"A success-style alert with palette success colors. Uses Alert.success() preset."
case .dialog:
"A Dialog view with custom content. More flexible than Alert — accepts any views."
case .dialogWithFooter:
"A Dialog with a footer section for action buttons, separated by a divider line."
case .modalCustom:
"A custom modal overlay using .modal(isPresented:). Accepts any view as content."
}
}
/// API usage example for the detail panel.
var apiUsage: String {
switch self {
case .alertStandard:
".alert(\"Title\", isPresented: $show) { actions } message: { Text(\"...\") }"
case .alertWarning:
".modal(isPresented: $show) { Alert.warning(message: \"...\") { actions } }"
case .alertError:
".modal(isPresented: $show) { Alert.error(message: \"...\") { actions } }"
case .alertInfo:
".modal(isPresented: $show) { Alert.info(message: \"...\") { actions } }"
case .alertSuccess:
".modal(isPresented: $show) { Alert.success(message: \"...\") { actions } }"
case .dialog:
".modal(isPresented: $show) { Dialog(title: \"...\") { content } }"
case .dialogWithFooter:
".modal(isPresented: $show) { Dialog(title: \"...\") { content } footer: { buttons } }"
case .modalCustom:
".modal(isPresented: $show) { VStack { ... } }"
}
}
}
// MARK: - Overlays Page
/// Interactive overlays and modals demo page.
///
/// Shows the overlay system including:
/// - `.alert(isPresented:)` modifier - SwiftUI-style alert presentation
/// - `.modal(isPresented:)` modifier - SwiftUI-style modal presentation
/// - `.dimmed()` modifier - visual de-emphasis
/// - Note: The status bar is NOT dimmed by modals!
/// Displays a menu of overlay variants on the left and a description
/// panel on the right. Pressing Enter shows the selected overlay
/// with dimmed background content.
struct OverlaysPage: View {
@State var showModal: Bool = true
@State var menuSelection: Int = 0
@State var showOverlay: Bool = false
/// Callback to navigate back to the main menu.
let onBack: () -> Void
/// The currently selected demo variant.
private var selectedDemo: OverlayDemo {
OverlayDemo.allCases[menuSelection]
}
var body: some View {
backgroundContent
.alert(
"Alert Demo",
isPresented: $showModal,
actions: {
Button("Dismiss", style: .primary) {
showModal = false
}
},
message: {
Text("This alert uses the new .alert(isPresented:) API!")
},
borderColor: .palette.border,
titleColor: .palette.accent
)
.modal(isPresented: $showOverlay) {
overlayContent(for: selectedDemo)
}
.statusBarItems(statusBarItems)
}
var backgroundContent: some View {
/// Status bar items change depending on whether a modal is open.
/// When a modal is presented, ESC closes the modal instead of navigating back.
private var statusBarItems: [any StatusBarItemProtocol] {
if showOverlay {
return [
StatusBarItem(shortcut: Shortcut.escape, label: "close") {
showOverlay = false
},
StatusBarItem(shortcut: Shortcut.enter, label: "dismiss"),
]
} else {
return [
StatusBarItem(shortcut: Shortcut.escape, label: "back") {
onBack()
},
StatusBarItem(shortcut: Shortcut.arrowsUpDown, label: "nav"),
StatusBarItem(shortcut: Shortcut.enter, label: "show"),
]
}
}
// MARK: - Background Content
/// The main background content with menu and description.
private var backgroundContent: some View {
VStack(spacing: 1) {
HeaderView(title: "Overlays & Modals Demo")
DemoSection("Presentation APIs (SwiftUI-style)") {
Text(" .alert(isPresented:) - declarative alert presentation")
Text(" .modal(isPresented:) - declarative modal presentation")
Text(" .overlay() - layer content on top")
Text(" .dimmed() - reduce visual emphasis")
}
HStack(spacing: 3) {
// Left: Demo menu
Menu(
title: "Select a Demo",
items: OverlayDemo.allCases.map { demo in
MenuItem(label: demo.label, shortcut: nil)
},
selection: $menuSelection,
onSelect: { _ in
showOverlay = true
},
selectedColor: .palette.accent,
borderColor: .palette.border
)
DemoSection("Modal Toggle (@State)") {
HStack(spacing: 2) {
if showModal {
Text("Modal is visible")
.foregroundColor(.palette.accent)
} else {
Text("Modal dismissed")
.foregroundColor(.palette.foregroundSecondary)
Button("Show Again", style: .primary) {
showModal = true
}
}
}
// Right: Description of selected demo
descriptionPanel
}
DemoSection("How It Works") {
Text("Uses .alert(isPresented: $showModal) { ... }")
Text("All overlays use the SwiftUI-style presentation API:")
.foregroundColor(.palette.foregroundSecondary)
Text("No manual if/else needed - SwiftUI-style API!")
Text(" .alert(isPresented:) — for Alert views")
.foregroundColor(.palette.foregroundSecondary)
Text(" .modal(isPresented:) — for Dialog, custom content")
.foregroundColor(.palette.foregroundSecondary)
Text("The background is automatically dimmed. Status bar stays visible.")
.bold()
.foregroundColor(.palette.accent)
Text("Pressing 'Dismiss' sets showModal = false")
.foregroundColor(.palette.foregroundSecondary)
Text("Status bar is NOT dimmed (separate render layer)")
.foregroundColor(.palette.foregroundSecondary)
}
Spacer()
}
}
// MARK: - Description Panel
/// Detail panel showing the selected demo's description and API usage.
private var descriptionPanel: some View {
Panel(selectedDemo.label, titleColor: .palette.accent) {
VStack(alignment: .leading, spacing: 1) {
Text(selectedDemo.description)
.foregroundColor(.palette.foreground)
Text("")
Text("API:")
.bold()
.foregroundColor(.palette.accent)
Text(" \(selectedDemo.apiUsage)")
.foregroundColor(.palette.foregroundSecondary)
}
}
.frame(width: 55)
}
// MARK: - Overlay Content
/// Builds the overlay content for the selected demo variant.
@ViewBuilder
private func overlayContent(for demo: OverlayDemo) -> some View {
switch demo {
case .alertStandard:
Alert(
title: "Standard Alert",
message: "This is a standard alert with default theme colors.",
borderColor: .palette.border,
titleColor: .palette.accent
) {
dismissButton
}
.frame(width: 50)
case .alertWarning:
Alert(
title: "Warning",
message: "Something might go wrong. Please check your input.",
borderColor: .palette.warning,
titleColor: .palette.warning
) {
dismissButton
}
.frame(width: 50)
case .alertError:
Alert(
title: "Error",
message: "An unexpected error occurred. Please try again.",
borderColor: .palette.error,
titleColor: .palette.error
) {
dismissButton
}
.frame(width: 50)
case .alertInfo:
Alert(
title: "Info",
message: "This is an informational message for the user.",
borderColor: .palette.info,
titleColor: .palette.info
) {
dismissButton
}
.frame(width: 50)
case .alertSuccess:
Alert(
title: "Success",
message: "Operation completed successfully!",
borderColor: .palette.success,
titleColor: .palette.success
) {
dismissButton
}
.frame(width: 50)
case .dialog:
Dialog(title: "Settings", borderColor: .palette.border, titleColor: .palette.accent) {
VStack(alignment: .leading) {
Text("Theme: Dark")
.foregroundColor(.palette.foreground)
Text("Language: English")
.foregroundColor(.palette.foreground)
Text("Notifications: On")
.foregroundColor(.palette.foreground)
Text("")
dismissButton
}
}
.frame(width: 50)
case .dialogWithFooter:
Dialog(
title: "Confirm Action",
borderColor: .palette.border,
titleColor: .palette.accent
) {
Text("Are you sure you want to proceed?")
.foregroundColor(.palette.foreground)
Text("This action cannot be undone.")
.foregroundColor(.palette.foregroundSecondary)
} footer: {
dismissButton
}
.frame(width: 50)
case .modalCustom:
VStack(spacing: 1) {
Text("Custom Modal Content")
.bold()
.foregroundColor(.palette.accent)
Text("")
Text("This modal uses .modal(isPresented:)")
.foregroundColor(.palette.foreground)
Text("with completely custom view content.")
.foregroundColor(.palette.foregroundSecondary)
Text("No Alert or Dialog — just any View!")
.foregroundColor(.palette.foregroundSecondary)
Text("")
dismissButton
}
.padding(EdgeInsets(horizontal: 2, vertical: 1))
.border(color: .palette.border)
}
}
/// Reusable right-aligned dismiss button for all overlay variants.
private var dismissButton: some View {
HStack {
Spacer()
Button("Dismiss", style: .primary) {
showOverlay = false
}
}
}
}
@@ -26,7 +26,7 @@ struct SpinnersPage: View {
}
DemoSection("Custom Color") {
Spinner("Installing...", style: .bouncing, color: .green)
Spinner("Installing...", style: .bouncing, color: .palette.success)
}
Spacer()
+7 -18
View File
@@ -9,34 +9,25 @@ import Testing
@testable import TUIkit
@Suite("AppState Tests", .serialized)
@Suite("AppState Tests")
struct AppStateTests {
/// Creates a fresh AppState instance to isolate tests from shared global state.
private func isolatedAppState() -> AppState {
let fresh = AppState()
RenderNotifier.current = fresh
return fresh
}
@Test("AppState initially does not need render")
func initialState() {
let appState = isolatedAppState()
appState.didRender()
let appState = AppState()
#expect(appState.needsRender == false)
}
@Test("setNeedsRender marks state as dirty")
func setNeedsRender() {
let appState = isolatedAppState()
appState.didRender()
let appState = AppState()
appState.setNeedsRender()
#expect(appState.needsRender == true)
}
@Test("didRender resets needsRender flag")
func didRenderResets() {
let appState = isolatedAppState()
let appState = AppState()
appState.setNeedsRender()
#expect(appState.needsRender == true)
appState.didRender()
@@ -45,31 +36,29 @@ struct AppStateTests {
@Test("setNeedsRender notifies observers")
func observerNotified() {
let appState = isolatedAppState()
let appState = AppState()
nonisolated(unsafe) var notified = false
appState.observe {
notified = true
}
appState.setNeedsRender()
#expect(notified == true)
appState.clearObservers()
}
@Test("Multiple observers all get notified")
func multipleObservers() {
let appState = isolatedAppState()
let appState = AppState()
nonisolated(unsafe) var count = 0
appState.observe { count += 1 }
appState.observe { count += 1 }
appState.observe { count += 1 }
appState.setNeedsRender()
#expect(count == 3)
appState.clearObservers()
}
@Test("clearObservers removes all observers")
func clearObservers() {
let appState = isolatedAppState()
let appState = AppState()
nonisolated(unsafe) var notified = false
appState.observe { notified = true }
appState.clearObservers()
+12 -11
View File
@@ -9,16 +9,9 @@ import Testing
@testable import TUIkit
@Suite("State Property Wrapper Tests", .serialized)
@Suite("State Property Wrapper Tests")
struct StatePropertyWrapperTests {
/// Creates a fresh AppState instance to isolate tests from shared global state.
private func isolatedAppState() -> AppState {
let fresh = AppState()
RenderNotifier.current = fresh
return fresh
}
@Test("State can be mutated")
func stateMutation() {
let state = State(wrappedValue: 0)
@@ -26,12 +19,20 @@ struct StatePropertyWrapperTests {
#expect(state.wrappedValue == 10)
}
@Test("State mutation triggers render")
@Test("State mutation triggers render via RenderNotifier")
func stateTriggerRender() {
let appState = isolatedAppState()
// StateBox.didSet calls RenderNotifier.current.setNeedsRender().
// We swap in a fresh AppState, mutate, and check immediately.
// This is a single-expression sequence with no yield points,
// so no parallel test can interfere between set and check.
let appState = AppState()
let previous = RenderNotifier.current
RenderNotifier.current = appState
let state = State(wrappedValue: "initial")
state.wrappedValue = "changed"
#expect(appState.needsRender == true)
let triggered = appState.needsRender
RenderNotifier.current = previous
#expect(triggered == true)
}
@Test("Binding from State updates original")
@@ -13,19 +13,20 @@ import Testing
@Suite("State Storage Identity Tests", .serialized)
struct StateStorageIdentityTests {
/// Creates an isolated test environment with fresh AppState and StateStorage.
private func testEnvironment() -> (AppState, StateStorage) {
let appState = AppState()
RenderNotifier.current = appState
let storage = StateStorage()
return (appState, storage)
/// Creates a fresh StateStorage for test isolation.
///
/// Does NOT touch `RenderNotifier.current`. State mutations during
/// tests call `setNeedsRender()` on the default global AppState
/// that's harmless and avoids race conditions with parallel suites.
private func testStorage() -> StateStorage {
StateStorage()
}
// MARK: - Self-Hydrating State
@Test("State self-hydrates from StateStorage when active context is set")
func selfHydrationFromStorage() {
let (_, storage) = testEnvironment()
let storage = testStorage()
let identity = ViewIdentity(path: "TestView")
// First construction: creates new entry in storage
@@ -48,7 +49,6 @@ struct StateStorageIdentityTests {
@Test("State uses local box when no active context is set")
func localBoxWithoutContext() {
let (_, _) = testEnvironment()
StateRegistration.activeContext = nil
let state = State(wrappedValue: "hello")
@@ -60,7 +60,7 @@ struct StateStorageIdentityTests {
@Test("Multiple @State properties get distinct indices")
func multipleStateDistinctIndices() {
let (_, storage) = testEnvironment()
let storage = testStorage()
let identity = ViewIdentity(path: "MultiStateView")
// Simulate a view with two @State properties
@@ -117,7 +117,7 @@ struct StateStorageIdentityTests {
@Test("StateStorage returns same box for same key")
func storageSameKey() {
let storage = StateStorage()
let storage = testStorage()
let key = StateStorage.StateKey(
identity: ViewIdentity(path: "V"),
propertyIndex: 0
@@ -133,7 +133,7 @@ struct StateStorageIdentityTests {
@Test("StateStorage returns different boxes for different keys")
func storageDifferentKeys() {
let storage = StateStorage()
let storage = testStorage()
let key1 = StateStorage.StateKey(
identity: ViewIdentity(path: "V"),
propertyIndex: 0
@@ -155,7 +155,7 @@ struct StateStorageIdentityTests {
@Test("invalidateDescendants removes state under a branch")
func branchInvalidation() {
let (_, storage) = testEnvironment()
let storage = testStorage()
let branchIdentity = ViewIdentity(path: "Root#true")
let childIdentity = ViewIdentity(path: "Root#true/Child")
@@ -177,7 +177,7 @@ struct StateStorageIdentityTests {
@Test("endRenderPass removes state for views not marked active")
func renderPassGarbageCollection() {
let storage = StateStorage()
let storage = testStorage()
let activeIdentity = ViewIdentity(path: "Active")
let staleIdentity = ViewIdentity(path: "Stale")
@@ -206,7 +206,6 @@ struct StateStorageIdentityTests {
@Test("State survives reconstruction through renderToBuffer")
func stateSurvivesRenderToBuffer() {
let (_, _) = testEnvironment()
let tuiContext = TUIContext()
let context = RenderContext(
availableWidth: 80,
@@ -226,7 +225,6 @@ struct StateStorageIdentityTests {
@Test("Nested views get independent state identities")
func nestedViewsIndependentState() {
let (_, _) = testEnvironment()
let tuiContext = TUIContext()
let context = RenderContext(
availableWidth: 80,