From d406bfda718e0732008cda4a1a58e0d095642414 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 14:30:00 +0200 Subject: [PATCH 1/3] Feat: Add composable Scene modifiers with environment propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Give Scene the SwiftUI protocol shape (associated Body, SceneBuilder body) with Never as the primitive terminator - Add the EnvironmentModifiedScene layer with Scene.environment(_:_:), .palette(_:), and .appearance(_:) modifiers; SceneResolution unwinds modifier chains outside-in into the frame environment (inner values win) and returns the renderable core - Resolve scenes in RenderLoop before the view-level root palette override so scene values reach all views and out-of-tree surfaces; identity paths stay byte-identical for unwrapped scenes - Add ScenePhase with an environment key (active by default); phase actions arrive with the application-actions work - Fixes the documented WindowGroup { … }.palette(…) shape from issue #2 --- Sources/TUIkit/App/RenderLoop.swift | 35 +++-- Sources/TUIkit/App/Scene+Environment.swift | 120 ++++++++++++++++++ Sources/TUIkit/App/Scene.swift | 25 +++- Sources/TUIkit/App/ScenePhase.swift | 43 +++++++ Tests/TUIkitTests/SceneCompositionTests.swift | 97 ++++++++++++++ 5 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 Sources/TUIkit/App/Scene+Environment.swift create mode 100644 Sources/TUIkit/App/ScenePhase.swift create mode 100644 Tests/TUIkitTests/SceneCompositionTests.swift diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 2592b456a..e3a1a6c9d 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -254,7 +254,11 @@ extension RenderLoop { // replay after the window and stay legitimate. tuiContext.appState.beginTraversal() - let scene = evaluateAppBody(environment: environment) + // Scene modifiers apply first (outside-in); a view-level root + // palette (`.palette` inside the window content) still wins for + // out-of-tree surfaces like the status bar and app header. + let evaluatedScene = evaluateAppBody(environment: environment) + let scene = SceneResolution.resolve(evaluatedScene, applyingTo: &environment) if let paletteOverrideScene = scene as? any RootPaletteOverrideProvidingScene, let paletteOverride = paletteOverrideScene.rootPaletteOverride() { environment.palette = paletteOverride @@ -278,7 +282,7 @@ extension RenderLoop { ) measureContext.phase = .measure focusManager.beginPass() - _ = renderScene(scene, context: measureContext.withChildIdentity(type: type(of: scene))) + _ = renderScene(scene, context: sceneContext(scene, base: measureContext)) focusManager.discardPass() appHeaderHeight = measureCollectors.appHeader.height isFirstFrame = false @@ -300,7 +304,7 @@ extension RenderLoop { context.hasExplicitHeight = true focusManager.beginPass() - var buffer = renderScene(scene, context: context.withChildIdentity(type: type(of: scene))) + var buffer = renderScene(scene, context: sceneContext(scene, base: context)) // If the header height changed after rendering, re-render with the // correct height so centering is accurate. The superseded main @@ -320,7 +324,7 @@ extension RenderLoop { correctedContext.hasExplicitWidth = true correctedContext.hasExplicitHeight = true focusManager.beginPass() - buffer = renderScene(scene, context: correctedContext.withChildIdentity(type: type(of: scene))) + buffer = renderScene(scene, context: sceneContext(scene, base: correctedContext)) } tuiContext.appState.endTraversal() @@ -523,12 +527,23 @@ private extension RenderLoop { lastEnvironmentSnapshot = currentSnapshot } - /// Renders a scene by delegating to `SceneRenderable`. - func renderScene(_ scene: S, context: RenderContext) -> FrameBuffer { - if let renderable = scene as? SceneRenderable { - return renderable.renderScene(context: context) - } - return FrameBuffer() + /// Renders a resolved scene core. + func renderScene(_ scene: (any SceneRenderable)?, context: RenderContext) -> FrameBuffer { + scene?.renderScene(context: context) ?? FrameBuffer() + } + + /// Extends the identity path with the resolved scene's concrete type. + /// + /// Opening the existential keeps identity paths byte-identical to the + /// pre-modifier era for unwrapped scenes (e.g. `WindowGroup`). + func sceneContext(_ scene: (any SceneRenderable)?, base: RenderContext) -> RenderContext { + guard let scene else { return base } + return openedSceneContext(scene, base: base) + } + + /// Opens the scene existential so the child identity uses its dynamic type. + private func openedSceneContext(_ scene: S, base: RenderContext) -> RenderContext { + base.withChildIdentity(type: S.self) } /// Renders the app header at the specified terminal row. diff --git a/Sources/TUIkit/App/Scene+Environment.swift b/Sources/TUIkit/App/Scene+Environment.swift new file mode 100644 index 000000000..61c57f8fe --- /dev/null +++ b/Sources/TUIkit/App/Scene+Environment.swift @@ -0,0 +1,120 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// Scene+Environment.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - Environment-Modified Scene + +/// A scene wrapping another scene with an environment transform. +/// +/// Scene modifiers compose by nesting these wrappers; ``SceneResolution`` +/// unwinds the chain outside-in when the runtime resolves the frame's +/// environment. This layer is also the extension point for future +/// scene-level configuration (commands, app-wide actions). +struct EnvironmentModifiedScene: Scene { + /// The wrapped scene. + let content: Content + + /// The environment mutation this modifier applies. + let transform: (inout EnvironmentValues) -> Void + + /// Never called — rendering runs through the internal scene pipeline. + var body: Never { + fatalError("EnvironmentModifiedScene renders via SceneResolution") + } +} + +// MARK: - Type-Erased Traversal + +/// Type-erased access used by ``SceneResolution`` to unwind modifier chains. +@MainActor +private protocol EnvironmentTransformingScene { + /// The environment mutation to apply at this level. + var anyTransform: (inout EnvironmentValues) -> Void { get } + + /// The scene wrapped by this modifier. + var anyContent: any Scene { get } +} + +extension EnvironmentModifiedScene: EnvironmentTransformingScene { + fileprivate var anyTransform: (inout EnvironmentValues) -> Void { transform } + fileprivate var anyContent: any Scene { content } +} + +// MARK: - Scene Resolution + +/// Resolves a scene hierarchy into its renderable core and environment. +/// +/// Modifier wrappers are unwound outside-in: outer transforms apply first, +/// so inner (closer to the window) values win on duplicate keys — matching +/// view-environment semantics. +@MainActor +enum SceneResolution { + /// Unwinds scene modifiers into the environment and returns the + /// renderable core scene. + /// + /// - Parameters: + /// - scene: The scene returned by `App.body`. + /// - environment: The frame environment receiving scene values. + /// - Returns: The renderable core, or `nil` for non-renderable scenes. + static func resolve( + _ scene: any Scene, + applyingTo environment: inout EnvironmentValues + ) -> (any SceneRenderable)? { + var current: any Scene = scene + + while let modifier = current as? any EnvironmentTransformingScene { + modifier.anyTransform(&environment) + current = modifier.anyContent + } + + return current as? any SceneRenderable + } +} + +// MARK: - Scene Environment Modifiers + +extension Scene { + /// Sets an environment value for all views in this scene. + /// + /// - Parameters: + /// - keyPath: The key path of the environment value to set. + /// - value: The value to inject. + /// - Returns: A scene with the modified environment. + public func environment( + _ keyPath: WritableKeyPath, + _ value: V + ) -> some Scene { + EnvironmentModifiedScene(content: self) { environment in + environment[keyPath: keyPath] = value + } + } + + /// Applies a color palette to all views in this scene. + /// + /// This is the scene-level counterpart of the view modifier, so the + /// documented `WindowGroup { … }.palette(…)` shape works and also aligns + /// out-of-tree surfaces (status bar, app header): + /// + /// ```swift + /// WindowGroup { + /// ContentView() + /// } + /// .palette(SystemPalette(.amber)) + /// ``` + /// + /// - Parameter palette: The palette to apply. + /// - Returns: A scene with the palette applied. + public func palette(_ palette: any Palette) -> some Scene { + environment(\.palette, palette) + } + + /// Applies a border appearance to all views in this scene. + /// + /// - Parameter appearance: The appearance to apply. + /// - Returns: A scene with the appearance applied. + public func appearance(_ appearance: Appearance) -> some Scene { + environment(\.appearance, appearance) + } +} diff --git a/Sources/TUIkit/App/Scene.swift b/Sources/TUIkit/App/Scene.swift index 27e9f1165..3e4450a9d 100644 --- a/Sources/TUIkit/App/Scene.swift +++ b/Sources/TUIkit/App/Scene.swift @@ -39,8 +39,21 @@ /// /// ### Built-in Scenes /// - ``WindowGroup`` +@preconcurrency @MainActor -public protocol Scene {} +public protocol Scene { + /// The type of scene produced by the body. + associatedtype Body: Scene + + /// The content and behavior of the scene. + /// + /// Primitive scenes (``WindowGroup``, modifier wrappers) declare + /// `Never` and render through the internal scene pipeline instead. + @SceneBuilder + var body: Self.Body { get } +} + +extension Never: Scene {} // MARK: - WindowGroup @@ -65,6 +78,11 @@ public struct WindowGroup: Scene { public init(@ViewBuilder content: () -> Content) { self.content = content() } + + /// Never called — rendering runs through the internal scene pipeline. + public var body: Never { + fatalError("WindowGroup renders via SceneRenderable") + } } // MARK: - SceneBuilder @@ -101,4 +119,9 @@ public struct SceneBuilder { public static func buildBlock(_ content: Content) -> Content { content } + + /// Converts a single scene expression into a scene component. + public static func buildExpression(_ content: Content) -> Content { + content + } } diff --git a/Sources/TUIkit/App/ScenePhase.swift b/Sources/TUIkit/App/ScenePhase.swift new file mode 100644 index 000000000..8f8da8b94 --- /dev/null +++ b/Sources/TUIkit/App/ScenePhase.swift @@ -0,0 +1,43 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// ScenePhase.swift +// +// Created by LAYERED.work +// License: MIT + +import TUIkitCore + +// MARK: - Scene Phase + +/// An indication of a scene's operational state. +/// +/// A terminal session has exactly one scene: it is ``active`` while the +/// runtime owns the terminal and processes events. The ``inactive`` and +/// ``background`` phases exist for SwiftUI compatibility and future +/// suspension semantics; multiwindow phase transitions have no terminal +/// meaning. Phase-driven actions (`onChange(of: scenePhase)`) arrive with +/// the application-actions work (#33). +public enum ScenePhase: Comparable, Hashable, Sendable { + /// The scene is not currently visible. + case background + + /// The scene is visible but not responding to input. + case inactive + + /// The scene is visible and responding to input. + case active +} + +// MARK: - Environment Key + +/// Environment key carrying the current scene phase. +private struct ScenePhaseKey: EnvironmentKey { + static let defaultValue: ScenePhase = .active +} + +extension EnvironmentValues { + /// The current operational phase of the scene. + public var scenePhase: ScenePhase { + get { self[ScenePhaseKey.self] } + set { self[ScenePhaseKey.self] = newValue } + } +} diff --git a/Tests/TUIkitTests/SceneCompositionTests.swift b/Tests/TUIkitTests/SceneCompositionTests.swift new file mode 100644 index 000000000..94a63d5ed --- /dev/null +++ b/Tests/TUIkitTests/SceneCompositionTests.swift @@ -0,0 +1,97 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// SceneCompositionTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Testing + +@testable import TUIkit + +@MainActor +@Suite("Scene Composition") +struct SceneCompositionTests { + + /// Creates an isolated render context. + private func testContext(width: Int = 30, height: Int = 5) -> RenderContext { + RenderContext( + availableWidth: width, + availableHeight: height, + tuiContext: TUIContext() + ) + } + + // MARK: - Scene Modifiers + + @Test("The README palette-on-WindowGroup shape compiles and resolves") + func paletteOnWindowGroupResolves() { + let scene = WindowGroup { + Text("content") + } + .palette(SystemPalette(.amber)) + + var environment = EnvironmentValues() + let resolved = SceneResolution.resolve(scene, applyingTo: &environment) + + #expect(resolved != nil) + #expect(environment.palette.id == SystemPalette(.amber).id) + } + + @Test("Nested scene modifiers compose outside-in") + func nestedSceneModifiersCompose() { + let scene = WindowGroup { + Text("content") + } + .environment(\.pulsePhase, 0.75) + .palette(SystemPalette(.amber)) + + var environment = EnvironmentValues() + _ = SceneResolution.resolve(scene, applyingTo: &environment) + + #expect(environment.pulsePhase == 0.75) + #expect(environment.palette.id == SystemPalette(.amber).id) + } + + @Test("The innermost scene value wins over outer duplicates") + func innermostSceneValueWins() { + let scene = WindowGroup { + Text("content") + } + .environment(\.pulsePhase, 0.25) + .environment(\.pulsePhase, 0.99) + + var environment = EnvironmentValues() + _ = SceneResolution.resolve(scene, applyingTo: &environment) + + #expect(environment.pulsePhase == 0.25) + } + + @Test("A resolved scene still renders its content") + func resolvedSceneRenders() { + let scene = WindowGroup { + Text("rendered") + } + .palette(SystemPalette(.amber)) + + var environment = EnvironmentValues() + let resolved = SceneResolution.resolve(scene, applyingTo: &environment) + let buffer = resolved?.renderScene(context: testContext()) + + #expect(buffer?.lines.contains { $0.contains("rendered") } == true) + } + + // MARK: - Scene Phase + + @Test("The environment exposes an active scene phase by default") + func scenePhaseDefaultsToActive() { + let environment = EnvironmentValues() + + #expect(environment.scenePhase == .active) + } + + @Test("Scene phases order background below active") + func scenePhaseOrdering() { + #expect(ScenePhase.background < ScenePhase.inactive) + #expect(ScenePhase.inactive < ScenePhase.active) + } +} From 25dce3a7467f26dbfb47959fe713c542b520bfed Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 14:31:43 +0200 Subject: [PATCH 2/3] Docs: Cover scene-level palettes and extend the scene compile fixture - Add the scene-level palette section to the theming guide - Extend the scene fixture with the issue-2 palette shape, composed modifiers, and an App body using scene modifiers --- .../TUIkit.docc/Articles/ThemingGuide.md | 14 ++++++++++ .../CompileContracts/ScenePositive.swift | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/Sources/TUIkit/TUIkit.docc/Articles/ThemingGuide.md b/Sources/TUIkit/TUIkit.docc/Articles/ThemingGuide.md index 47557cc9c..5ba033ab0 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/ThemingGuide.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/ThemingGuide.md @@ -49,6 +49,20 @@ ContentView() .palette(SystemPalette(.amber)) ``` +### At the Scene Level + +Apply a palette to the whole application, including out-of-tree surfaces +like the status bar and app header, with the scene modifier: + +```swift +var body: some Scene { + WindowGroup { + ContentView() + } + .palette(SystemPalette(.green)) +} +``` + ### Palette Colors in Views Use ``Color/palette`` to access the current palette's colors: diff --git a/Tools/APICompatibility/Configuration/CompileContracts/ScenePositive.swift b/Tools/APICompatibility/Configuration/CompileContracts/ScenePositive.swift index a82b16b66..0b59c35cf 100644 --- a/Tools/APICompatibility/Configuration/CompileContracts/ScenePositive.swift +++ b/Tools/APICompatibility/Configuration/CompileContracts/ScenePositive.swift @@ -6,3 +6,31 @@ func terminalScene() -> some Scene { Text("Scene") } } + +// The documented issue-2 shape: scene-level palette on a WindowGroup. +@MainActor +func paletteScene() -> some Scene { + WindowGroup { + Text("Scene") + } + .palette(SystemPalette(.green)) +} + +@MainActor +func composedScene() -> some Scene { + WindowGroup { + Text("Scene") + } + .environment(\.scenePhase, .active) + .appearance(.rounded) +} + +@MainActor +private struct SceneApp: App { + var body: some Scene { + WindowGroup { + Text("App") + } + .palette(SystemPalette(.amber)) + } +} From 921e2d9d81a7f4d12c020cf015551e7eb2259ce8 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 14:44:05 +0200 Subject: [PATCH 3/3] Chore: Regenerate the API compatibility manifest for the scene surface - Add overrides for the Scene protocol body contract, the scene environment modifiers, WindowGroup's primitive body, ScenePhase, and the scenePhase environment key - Regenerate the manifest with TUIkitAPICheck against fresh 6.0.3 macOS and Linux snapshots --- .../Configuration/compatibility-manifest.json | 60 +++++++++++++++++++ .../Configuration/review-policy.json | 60 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/Tools/APICompatibility/Configuration/compatibility-manifest.json b/Tools/APICompatibility/Configuration/compatibility-manifest.json index cd307b06f..fd61b0196 100644 --- a/Tools/APICompatibility/Configuration/compatibility-manifest.json +++ b/Tools/APICompatibility/Configuration/compatibility-manifest.json @@ -337481,6 +337481,11 @@ "ownerIssue" : "#35", "symbolID" : "s:10TUIkitCore17EnvironmentValuesV0A0E10appearance0A7Styling10AppearanceVvp" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitCore17EnvironmentValuesV0A0E10scenePhaseAD05SceneF0Ovp" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -341396,6 +341401,26 @@ "ownerIssue" : "#35", "symbolID" : "s:6TUIkit10LazyVStackVAASQRzrlE2eeoiySbACyxG_AEtFZ" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit10ScenePhaseO" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit10ScenePhaseO10backgroundyA2CmF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit10ScenePhaseO6activeyA2CmF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit10ScenePhaseO8inactiveyA2CmF" + }, { "classification" : "tuiSpecific", "ownerIssue" : "#35", @@ -341666,6 +341691,11 @@ "ownerIssue" : "#35", "symbolID" : "s:6TUIkit11WindowGroupV" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit11WindowGroupV4bodys5NeverOvp" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -341896,6 +341926,11 @@ "ownerIssue" : "#35", "symbolID" : "s:6TUIkit12SceneBuilderV10buildBlockyxxAA0B0RzlFZ" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit12SceneBuilderV15buildExpressionyxxAA0B0RzlFZ" + }, { "classification" : "tuiSpecific", "ownerIssue" : "#35", @@ -344456,6 +344491,31 @@ "ownerIssue" : "#35", "symbolID" : "s:6TUIkit5SceneP" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit5SceneP4BodyQa" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit5SceneP4body4BodyQzvp" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit5ScenePAAE10appearanceyQr0A7Styling10AppearanceVF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit5ScenePAAE11environmentyQrs15WritableKeyPathCy0A4Core17EnvironmentValuesVqd__G_qd__tlF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit5ScenePAAE7paletteyQr0A7Styling7Palette_pF" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", diff --git a/Tools/APICompatibility/Configuration/review-policy.json b/Tools/APICompatibility/Configuration/review-policy.json index 78fc25b7d..cf92a67da 100644 --- a/Tools/APICompatibility/Configuration/review-policy.json +++ b/Tools/APICompatibility/Configuration/review-policy.json @@ -26147,6 +26147,66 @@ "action": "implementationLeak", "ownerIssue": "#35", "symbolID": "s:6TUIkit10AppStorageV_5storeACyqd__SgGSS_AA0C7Backend_pSgtcAERszSYRd__Si8RawValueRtd__lufc" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:10TUIkitCore17EnvironmentValuesV0A0E10scenePhaseAD05SceneF0Ovp" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit10ScenePhaseO" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit10ScenePhaseO10backgroundyA2CmF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit10ScenePhaseO6activeyA2CmF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit10ScenePhaseO8inactiveyA2CmF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit11WindowGroupV4bodys5NeverOvp" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit12SceneBuilderV15buildExpressionyxxAA0B0RzlFZ" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit5SceneP4BodyQa" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit5SceneP4body4BodyQzvp" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit5ScenePAAE10appearanceyQr0A7Styling10AppearanceVF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit5ScenePAAE11environmentyQrs15WritableKeyPathCy0A4Core17EnvironmentValuesVqd__G_qd__tlF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit5ScenePAAE7paletteyQr0A7Styling7Palette_pF" } ] }