diff --git a/Sources/TUIKit/App/App.swift b/Sources/TUIKit/App/App.swift index 1f14180..84a74d1 100644 --- a/Sources/TUIKit/App/App.swift +++ b/Sources/TUIKit/App/App.swift @@ -178,11 +178,28 @@ internal final class AppRunner { // MARK: - Scene Rendering Protocol -/// Internal protocol for renderable scenes. +/// Bridge from the `Scene` hierarchy to the `View` rendering system. +/// +/// `SceneRenderable` sits outside the `View`/`Renderable` dual system. +/// It connects the `App.body` (which produces a `Scene`) to the view +/// tree rendering via ``renderToBuffer(_:context:)``. +/// +/// `RenderLoop` calls `renderScene(context:)` on the scene returned +/// by `App.body`. The scene (typically ``WindowGroup``) then invokes +/// the free function `renderToBuffer` on its content view, entering +/// the standard `Renderable`-or-`body` dispatch. internal protocol SceneRenderable { + /// Renders the scene's content to the terminal. + /// + /// - Parameter context: The rendering context with layout constraints. func renderScene(context: RenderContext) } +/// Renders the window group's content view to the terminal. +/// +/// This is the bridge from `Scene` to `View` rendering: +/// calls ``renderToBuffer(_:context:)`` on `content`, writes the +/// resulting ``FrameBuffer`` line-by-line with persistent background. extension WindowGroup: SceneRenderable { func renderScene(context: RenderContext) { let buffer = renderToBuffer(content, context: context) diff --git a/Sources/TUIKit/App/RenderLoop.swift b/Sources/TUIKit/App/RenderLoop.swift index 3f274ee..65849cc 100644 --- a/Sources/TUIKit/App/RenderLoop.swift +++ b/Sources/TUIKit/App/RenderLoop.swift @@ -10,13 +10,32 @@ /// Manages the full rendering pipeline for each frame. /// -/// Responsibilities: -/// - Assembling the ``EnvironmentValues`` from all subsystems -/// - Rendering the main scene content +/// `RenderLoop` is owned by ``AppRunner`` and called once per frame. +/// It orchestrates the complete render pass from `App.body` to +/// terminal output. +/// +/// ## Pipeline steps (per frame) +/// +/// ``` +/// render() +/// 1. Clear per-frame state (key handlers, preferences, focus) +/// 2. Begin lifecycle tracking +/// 3. Build EnvironmentValues from all subsystems +/// 4. Create RenderContext with layout constraints +/// 5. Resolve App.body → Scene (WindowGroup) +/// 6. Call SceneRenderable.renderScene() → view tree traversal +/// └── renderToBuffer() dispatches each view (Renderable or body) +/// └── FrameBuffer lines written to terminal with background fill +/// 7. End lifecycle tracking (fires onDisappear for removed views) +/// 8. Render status bar separately (own context, never dimmed) +/// ``` +/// +/// ## Responsibilities +/// +/// - Assembling ``EnvironmentValues`` from all subsystems +/// - Rendering the main scene content via ``SceneRenderable`` /// - Rendering the status bar separately (never dimmed) /// - Coordinating lifecycle tracking (appear/disappear) -/// -/// `RenderLoop` is owned by ``AppRunner`` and called once per frame. internal struct RenderLoop { /// The user's app instance (provides `body`). let app: A diff --git a/Sources/TUIKit/Core/Environment.swift b/Sources/TUIKit/Core/Environment.swift index f0119e0..c90e247 100644 --- a/Sources/TUIKit/Core/Environment.swift +++ b/Sources/TUIKit/Core/Environment.swift @@ -179,6 +179,12 @@ public struct Environment: @unchecked Sendable { // MARK: - Environment Modifier /// A modifier that injects a value into the environment for child views. +/// +/// `EnvironmentModifier` conforms to both `View` and ``Renderable``. +/// Because ``renderToBuffer(_:context:)`` checks `Renderable` first, +/// the `body` property below is **never called during rendering**. +/// It exists only to satisfy the `View` protocol requirement. +/// All actual work happens in `renderToBuffer(context:)`. public struct EnvironmentModifier: View { /// The content view. let content: Content @@ -189,6 +195,7 @@ public struct EnvironmentModifier: View { /// The value to inject. let value: V + /// Not used during rendering — ``Renderable`` conformance takes priority. public var body: some View { content } diff --git a/Sources/TUIKit/Core/View.swift b/Sources/TUIKit/Core/View.swift index 67f040f..c5b09c5 100644 --- a/Sources/TUIKit/Core/View.swift +++ b/Sources/TUIKit/Core/View.swift @@ -13,7 +13,20 @@ /// Every View defines a `body` composed of other Views. /// This enables a hierarchical, declarative UI description. /// -/// # Example +/// ## Dual Rendering System +/// +/// TUIKit uses two rendering paths: +/// +/// - **Composite views** implement `body` to compose other views. +/// The rendering system recurses into `body` to resolve the tree. +/// - **Primitive views** additionally conform to ``Renderable`` and +/// produce a ``FrameBuffer`` directly. They set `body: Never` +/// (which `fatalError`s if called) because their `body` is never used. +/// +/// The free function ``renderToBuffer(_:context:)`` checks `Renderable` +/// first, then falls back to `body`. See ``Renderable`` for details. +/// +/// ## Creating a composite view /// /// ```swift /// struct MyView: View { @@ -22,16 +35,35 @@ /// } /// } /// ``` +/// +/// ## Creating a primitive view +/// +/// ```swift +/// struct MyPrimitive: View { +/// var body: Never { fatalError() } +/// } +/// +/// extension MyPrimitive: Renderable { +/// func renderToBuffer(context: RenderContext) -> FrameBuffer { +/// FrameBuffer(text: "output") +/// } +/// } +/// ``` public protocol View { /// The type of the body view. /// /// Swift automatically infers this type from the `body` implementation. + /// Primitive views that conform to ``Renderable`` set this to `Never`. associatedtype Body: View /// The content and behavior of this view. /// - /// Implement this property to define the structure of your view. - /// The body consists of other Views that together form the UI. + /// Implement this property to define the structure of your view + /// by composing other `View` types. + /// + /// For primitive views that conform to ``Renderable``, set this + /// to `Never` with a `fatalError` body. The rendering system will + /// call ``Renderable/renderToBuffer(context:)`` instead. @ViewBuilder var body: Body { get } } diff --git a/Sources/TUIKit/Core/ViewModifier.swift b/Sources/TUIKit/Core/ViewModifier.swift index 09d61ae..079296e 100644 --- a/Sources/TUIKit/Core/ViewModifier.swift +++ b/Sources/TUIKit/Core/ViewModifier.swift @@ -37,6 +37,12 @@ public protocol ViewModifier { /// /// This is the return type of modifier methods like `.frame()` and `.padding()`. /// It is created automatically — users don't instantiate this directly. +/// +/// `ModifiedView` is a **primitive view**: it declares `body: Never` +/// and conforms to ``Renderable``. The rendering system calls +/// ``Renderable/renderToBuffer(context:)`` which first renders the +/// wrapped `content`, then applies the modifier's transformation. +/// The `body` property is never called. public struct ModifiedView: View { /// The original view. public let content: Content @@ -44,6 +50,7 @@ public struct ModifiedView: View { /// The modifier to apply. public let modifier: Modifier + /// Never called — rendering is handled by ``Renderable`` conformance. public var body: Never { fatalError("ModifiedView renders via Renderable") } diff --git a/Sources/TUIKit/Rendering/Renderable.swift b/Sources/TUIKit/Rendering/Renderable.swift index d6e7cb8..d978e99 100644 --- a/Sources/TUIKit/Rendering/Renderable.swift +++ b/Sources/TUIKit/Rendering/Renderable.swift @@ -5,16 +5,56 @@ // Protocol for views that can render themselves directly. // -/// A protocol for views that can render themselves into a `FrameBuffer`. +/// A protocol for views that produce terminal output directly. /// -/// Primitive views implement this protocol to produce their text output -/// as a buffer. Layout containers then combine child buffers to produce -/// the final output. +/// TUIKit uses a **dual rendering system** inspired by SwiftUI: +/// +/// - **`View.body`** — Compositional path: views declare *what* they +/// are made of by composing other `View` types. +/// - **`Renderable.renderToBuffer`** — Primitive path: views define +/// *how* they look by producing a ``FrameBuffer`` directly. +/// +/// When the free function ``renderToBuffer(_:context:)`` encounters a +/// view, it checks `Renderable` conformance **first**. If the view +/// conforms, `renderToBuffer(context:)` is called and `body` is never +/// consulted. Only if the view is *not* `Renderable` does the function +/// recurse into `body`. +/// +/// ## Who conforms to Renderable? +/// +/// - **Leaf views**: `Text`, `EmptyView`, `Spacer`, `Divider` +/// - **Layout containers**: `VStack`, `HStack`, `ZStack` +/// - **ViewBuilder glue**: `TupleView`, `ConditionalView`, `ViewArray` +/// - **Interactive views**: `Button`, `ButtonRow`, `Menu`, `StatusBar` +/// - **Containers**: `Panel`, `ContainerView`, `Alert`, `Dialog`, `Card` +/// - **Modifiers**: `ModifiedView`, `BorderedView`, `DimmedModifier`, etc. +/// +/// All of these declare `body: Never` (which `fatalError`s) because +/// their rendering is fully handled by `Renderable`. +/// +/// ## Composite views (body only) +/// +/// Views that do **not** conform to `Renderable` use `body` to compose +/// other views. Example: ``Box`` returns `content.border(...)` from its +/// `body`, delegating rendering to `BorderedView` which *is* `Renderable`. +/// +/// ## Adding a new view type +/// +/// - If your view composes other views → implement `body`, skip `Renderable`. +/// - If your view produces terminal output directly → conform to `Renderable` +/// and set `body: Never`. +/// - **Warning**: A view with `body: Never` that does *not* conform to +/// `Renderable` will silently render as empty. There is no runtime error. public protocol Renderable { - /// Renders this view into a `FrameBuffer`. + /// Renders this view into a ``FrameBuffer``. /// - /// - Parameter context: The rendering context with available size info. - /// - Returns: A buffer containing the rendered output. + /// Called by the free function ``renderToBuffer(_:context:)`` when + /// the view conforms to `Renderable`. The `body` property is never + /// consulted in this case. + /// + /// - Parameter context: The rendering context with layout constraints, + /// environment values, and the ``TUIContext``. + /// - Returns: A buffer containing the rendered terminal output. func renderToBuffer(context: RenderContext) -> FrameBuffer } @@ -79,24 +119,52 @@ public struct RenderContext { } } -// MARK: - Rendering Helper +// MARK: - Rendering Dispatch -/// Renders any View into a FrameBuffer by checking for Renderable conformance -/// or recursively rendering the body. +/// Renders any `View` into a ``FrameBuffer`` using the dual rendering system. +/// +/// This is the **single entry point** for all view rendering in TUIKit. +/// Every recursive call in the view tree passes through this function. +/// +/// ## Decision order +/// +/// 1. **Renderable** — If the view conforms to ``Renderable``, call +/// `renderToBuffer(context:)` directly. The `body` property is +/// never accessed. +/// 2. **Body recursion** — If the view does *not* conform to `Renderable` +/// and its `Body` type is not `Never`, recurse into `view.body`. +/// 3. **Empty fallback** — If neither applies (`Body` is `Never` and no +/// `Renderable` conformance), return an empty ``FrameBuffer``. +/// This is a silent no-op — no error, no warning. +/// +/// ## Example flow +/// +/// ``` +/// renderToBuffer(Box { Text("Hi") }) +/// → Box is NOT Renderable, Body != Never +/// → recurse into Box.body → BorderedView +/// → BorderedView IS Renderable +/// → calls BorderedView.renderToBuffer(context:) +/// → internally calls renderToBuffer(Text("Hi"), context:) +/// → Text IS Renderable → produces FrameBuffer +/// ``` /// /// - Parameters: /// - view: The view to render. -/// - context: The rendering context. -/// - Returns: A FrameBuffer with the rendered content. +/// - context: The rendering context with layout constraints. +/// - Returns: A ``FrameBuffer`` containing the rendered terminal output. public func renderToBuffer(_ view: V, context: RenderContext) -> FrameBuffer { + // Priority 1: Direct rendering via Renderable protocol if let renderable = view as? Renderable { return renderable.renderToBuffer(context: context) } - // Composite view: render its body + // Priority 2: Composite view — recurse into body if V.Body.self != Never.self { return renderToBuffer(view.body, context: context) } + // Priority 3: No rendering path — return empty buffer silently. + // This happens for types with body: Never that forgot Renderable conformance. return FrameBuffer() } diff --git a/Sources/TUIKit/Rendering/ViewRenderer.swift b/Sources/TUIKit/Rendering/ViewRenderer.swift index 58b309e..4b0440c 100644 --- a/Sources/TUIKit/Rendering/ViewRenderer.swift +++ b/Sources/TUIKit/Rendering/ViewRenderer.swift @@ -7,11 +7,22 @@ import Foundation -/// Renders Views to terminal output. +/// Convenience class for standalone view rendering. /// -/// The `ViewRenderer` uses a two-pass approach: -/// 1. Render the entire view tree into a `FrameBuffer` -/// 2. Flush the buffer to the terminal at the correct position +/// `ViewRenderer` wraps the free function ``renderToBuffer(_:context:)`` +/// with terminal cursor positioning. It is a thin wrapper — the actual +/// rendering dispatch happens in `renderToBuffer`, not here. +/// +/// The main app uses ``RenderLoop`` instead, which owns the full +/// pipeline (environment assembly, lifecycle, status bar). Use +/// `ViewRenderer` for one-off rendering outside the main loop. +/// +/// ## Renderable conformances +/// +/// This file also contains ``Renderable`` extensions for core view +/// types (`Text`, `VStack`, `HStack`, etc.). These live here rather +/// than in each type's file to keep the rendering logic centralized +/// and the view type files focused on public API and data. public final class ViewRenderer { /// The terminal to render to. private let terminal: Terminal @@ -79,6 +90,13 @@ func makeChildInfo(for view: V, context: RenderContext) -> ChildInfo { ) } +// MARK: - Renderable Conformances +// +// The following extensions provide Renderable conformance for all +// core view types. Each type declares body: Never (fatalError) and +// relies on renderToBuffer(context:) for output. They are grouped +// here to keep rendering logic centralized. + // MARK: - Text Rendering extension Text: Renderable { diff --git a/Sources/TUIKit/Views/Box.swift b/Sources/TUIKit/Views/Box.swift index d165fa0..2185b66 100644 --- a/Sources/TUIKit/Views/Box.swift +++ b/Sources/TUIKit/Views/Box.swift @@ -78,6 +78,13 @@ /// - If content has a fixed size, `Box` will be that size plus border /// - If content is flexible, `Box` expands to fill available space /// - Content inside `Box` respects its layout constraints +/// +/// # Rendering +/// +/// `Box` is a **composite view** — it does not conform to ``Renderable``. +/// Instead, it uses `body` to delegate to `content.border(...)`, which +/// produces a `BorderedView` that *is* `Renderable`. This is intentional: +/// `Box` is purely compositional sugar and carries no rendering logic. public struct Box: View { /// The content of the box. public let content: Content diff --git a/Sources/TUIKit/Views/ForEach.swift b/Sources/TUIKit/Views/ForEach.swift index 79c1228..e9997e8 100644 --- a/Sources/TUIKit/Views/ForEach.swift +++ b/Sources/TUIKit/Views/ForEach.swift @@ -11,6 +11,17 @@ /// element. The collection elements must be `Identifiable` or an /// explicit ID key path must be provided. /// +/// ## Rendering +/// +/// `ForEach` has **no standalone rendering capability**. It declares +/// `body: Never` but does *not* conform to ``Renderable``. On its own, +/// it would produce an empty ``FrameBuffer``. +/// +/// In practice, `ForEach` is always used inside a `@ViewBuilder` block +/// (e.g. within `VStack` or `HStack`). The builder's `buildArray` +/// method flattens it into a ``ViewArray``, which *is* `Renderable`. +/// This is the same pattern SwiftUI uses. +/// /// # Example with Identifiable /// /// ```swift @@ -65,8 +76,10 @@ public struct ForEach self.content = content } + /// Never called — `ForEach` is flattened into a ``ViewArray`` by + /// `@ViewBuilder.buildArray` before rendering occurs. public var body: Never { - fatalError("ForEach renders its children directly") + fatalError("ForEach has no standalone rendering; use inside a @ViewBuilder block") } }