diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index 97dce82..c73b153 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -91,6 +91,7 @@ internal final class AppRunner { self.inputHandler = InputHandler( statusBar: statusBar, keyEventDispatcher: tuiContext.keyEventDispatcher, + focusManager: focusManager, paletteManager: paletteManager, appearanceManager: appearanceManager, onQuit: { [weak self] in diff --git a/Sources/TUIkit/App/InputHandler.swift b/Sources/TUIkit/App/InputHandler.swift index e9b0bb7..62a142d 100644 --- a/Sources/TUIkit/App/InputHandler.swift +++ b/Sources/TUIkit/App/InputHandler.swift @@ -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 { diff --git a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift index 1bf8f4a..f4c3115 100644 --- a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift @@ -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) diff --git a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift index 6e9792c..856a724 100644 --- a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift @@ -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) diff --git a/Sources/TUIkit/Rendering/Renderable.swift b/Sources/TUIkit/Rendering/Renderable.swift index 5a14a8c..4b0fc01 100644 --- a/Sources/TUIkit/Rendering/Renderable.swift +++ b/Sources/TUIkit/Rendering/Renderable.swift @@ -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 diff --git a/Sources/TUIkit/Views/Alert.swift b/Sources/TUIkit/Views/Alert.swift index 039de7d..e34425b 100644 --- a/Sources/TUIkit/Views/Alert.swift +++ b/Sources/TUIkit/Views/Alert.swift @@ -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( 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( 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( 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( 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 { - Alert.warning(title: title, message: message) { EmptyView() } + Alert( + 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 { - Alert.error(title: title, message: message) { EmptyView() } + Alert( + 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 { - Alert.info(title: title, message: message) { EmptyView() } + Alert( + 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 { - Alert.success(title: title, message: message) { EmptyView() } + Alert( + title: title, message: message, + borderColor: .palette.success, titleColor: .palette.success + ) } } diff --git a/Sources/TUIkit/Views/ContainerView.swift b/Sources/TUIkit/Views/ContainerView.swift index 7ab8fc3..f95c33b 100644 --- a/Sources/TUIkit/Views/ContainerView.swift +++ b/Sources/TUIkit/Views/ContainerView.swift @@ -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 ) ) } diff --git a/Sources/TUIkitExample/ContentView.swift b/Sources/TUIkitExample/ContentView.swift index 3a86447..7572775 100644 --- a/Sources/TUIkitExample/ContentView.swift +++ b/Sources/TUIkitExample/ContentView.swift @@ -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)) diff --git a/Sources/TUIkitExample/Pages/OverlaysPage.swift b/Sources/TUIkitExample/Pages/OverlaysPage.swift index 6dce563..005481e 100644 --- a/Sources/TUIkitExample/Pages/OverlaysPage.swift +++ b/Sources/TUIkitExample/Pages/OverlaysPage.swift @@ -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 + } + } + } } diff --git a/Sources/TUIkitExample/Pages/SpinnersPage.swift b/Sources/TUIkitExample/Pages/SpinnersPage.swift index 814c4a7..9da9c5e 100644 --- a/Sources/TUIkitExample/Pages/SpinnersPage.swift +++ b/Sources/TUIkitExample/Pages/SpinnersPage.swift @@ -26,7 +26,7 @@ struct SpinnersPage: View { } DemoSection("Custom Color") { - Spinner("Installing...", style: .bouncing, color: .green) + Spinner("Installing...", style: .bouncing, color: .palette.success) } Spacer() diff --git a/Tests/TUIkitTests/AppStateTests.swift b/Tests/TUIkitTests/AppStateTests.swift index 64ff20e..e4c6083 100644 --- a/Tests/TUIkitTests/AppStateTests.swift +++ b/Tests/TUIkitTests/AppStateTests.swift @@ -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() diff --git a/Tests/TUIkitTests/StatePropertyTests.swift b/Tests/TUIkitTests/StatePropertyTests.swift index 41290d0..ce54c43 100644 --- a/Tests/TUIkitTests/StatePropertyTests.swift +++ b/Tests/TUIkitTests/StatePropertyTests.swift @@ -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") diff --git a/Tests/TUIkitTests/StateStorageIdentityTests.swift b/Tests/TUIkitTests/StateStorageIdentityTests.swift index 931a83f..64a7272 100644 --- a/Tests/TUIkitTests/StateStorageIdentityTests.swift +++ b/Tests/TUIkitTests/StateStorageIdentityTests.swift @@ -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,