Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions Sources/TUIkit/App/RenderLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -523,12 +527,23 @@ private extension RenderLoop {
lastEnvironmentSnapshot = currentSnapshot
}

/// Renders a scene by delegating to `SceneRenderable`.
func renderScene<S: Scene>(_ 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<Content>`).
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<S: SceneRenderable>(_ scene: S, base: RenderContext) -> RenderContext {
base.withChildIdentity(type: S.self)
}

/// Renders the app header at the specified terminal row.
Expand Down
120 changes: 120 additions & 0 deletions Sources/TUIkit/App/Scene+Environment.swift
Original file line number Diff line number Diff line change
@@ -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<Content: Scene>: 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<V>(
_ keyPath: WritableKeyPath<EnvironmentValues, V>,
_ 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)
}
}
25 changes: 24 additions & 1 deletion Sources/TUIkit/App/Scene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -65,6 +78,11 @@ public struct WindowGroup<Content: View>: 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
Expand Down Expand Up @@ -101,4 +119,9 @@ public struct SceneBuilder {
public static func buildBlock<Content: Scene>(_ content: Content) -> Content {
content
}

/// Converts a single scene expression into a scene component.
public static func buildExpression<Content: Scene>(_ content: Content) -> Content {
content
}
}
43 changes: 43 additions & 0 deletions Sources/TUIkit/App/ScenePhase.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
14 changes: 14 additions & 0 deletions Sources/TUIkit/TUIkit.docc/Articles/ThemingGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions Tests/TUIkitTests/SceneCompositionTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading