Merge pull request #18 from phranck/feature/document-rendering-contract

docs: Document dual rendering system contract (H.2)
This commit is contained in:
phranck
2026-01-30 16:57:40 +01:00
9 changed files with 215 additions and 27 deletions
+18 -1
View File
@@ -178,11 +178,28 @@ internal final class AppRunner<A: App> {
// 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)
+24 -5
View File
@@ -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<A: App> {
/// The user's app instance (provides `body`).
let app: A
+7
View File
@@ -179,6 +179,12 @@ public struct Environment<Value>: @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<Content: View, V>: View {
/// The content view.
let content: Content
@@ -189,6 +195,7 @@ public struct EnvironmentModifier<Content: View, V>: View {
/// The value to inject.
let value: V
/// Not used during rendering — ``Renderable`` conformance takes priority.
public var body: some View {
content
}
+35 -3
View File
@@ -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 }
}
+7
View File
@@ -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<Content: View, Modifier: ViewModifier>: View {
/// The original view.
public let content: Content
@@ -44,6 +50,7 @@ public struct ModifiedView<Content: View, Modifier: ViewModifier>: 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")
}
+81 -13
View File
@@ -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<V: View>(_ 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()
}
+22 -4
View File
@@ -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<V: View>(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 {
+7
View File
@@ -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<Content: View>: View {
/// The content of the box.
public let content: Content
+14 -1
View File
@@ -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<Data: RandomAccessCollection, ID: Hashable, Content: View>
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")
}
}