mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
Merge pull request #18 from phranck/feature/document-rendering-contract
docs: Document dual rendering system contract (H.2)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user