diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 4d5767b..51644e7 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -127,7 +127,6 @@ internal final class RenderLoop { let environment = buildEnvironment() let context = RenderContext( - terminal: terminal, availableWidth: terminalWidth, availableHeight: contentHeight, environment: environment, @@ -275,7 +274,6 @@ internal final class RenderLoop { ) let context = RenderContext( - terminal: terminal, availableWidth: terminalWidth, availableHeight: statusBarView.height, environment: environment, diff --git a/Sources/TUIkit/Rendering/ChildInfo.swift b/Sources/TUIkit/Rendering/ChildInfo.swift new file mode 100644 index 0000000..0e2e4fb --- /dev/null +++ b/Sources/TUIkit/Rendering/ChildInfo.swift @@ -0,0 +1,69 @@ +// +// ChildInfo.swift +// TUIkit +// +// Layout metadata for child views within stack containers. +// + +// MARK: - Child Info + +/// Describes a child view within a stack for layout purposes. +struct ChildInfo { + /// The rendered buffer of this child (nil for spacers, computed later). + let buffer: FrameBuffer? + + /// Whether this child is a Spacer. + let isSpacer: Bool + + /// The minimum length of this spacer (only relevant if isSpacer is true). + let spacerMinLength: Int? +} + +// MARK: - Child Info Provider + +/// Internal protocol that allows stack containers to extract individual +/// child info from their content (which is typically a TupleView). +protocol ChildInfoProvider { + /// Returns an array of ChildInfo, one per child view. + func childInfos(context: RenderContext) -> [ChildInfo] +} + +/// Creates a ChildInfo for a single view. +/// +/// If the view is a ``Spacer``, the returned info marks it as such +/// with its minimum length. Otherwise the view is rendered into a +/// ``FrameBuffer`` via ``renderToBuffer(_:context:)``. +/// +/// - Parameters: +/// - view: The child view. +/// - context: The rendering context. +/// - Returns: A ``ChildInfo`` describing the view. +func makeChildInfo(for view: V, context: RenderContext) -> ChildInfo { + if let spacer = view as? Spacer { + return ChildInfo(buffer: nil, isSpacer: true, spacerMinLength: spacer.minLength) + } + return ChildInfo( + buffer: renderToBuffer(view, context: context), + isSpacer: false, + spacerMinLength: nil + ) +} + +// MARK: - Child Info Resolution + +/// Resolves child infos from a view's content. +/// +/// If the content conforms to ``ChildInfoProvider`` (e.g. TupleViews), +/// it returns individual child infos. Otherwise it returns the content +/// as a single-element array. +/// +/// - Parameters: +/// - content: The content view. +/// - context: The rendering context. +/// - Returns: An array of ``ChildInfo``. +func resolveChildInfos(from content: V, context: RenderContext) -> [ChildInfo] { + if let provider = content as? ChildInfoProvider { + return provider.childInfos(context: context) + } + return [makeChildInfo(for: content, context: context)] +} diff --git a/Sources/TUIkit/Rendering/Renderable.swift b/Sources/TUIkit/Rendering/Renderable.swift index 3fec77f..5a14a8c 100644 --- a/Sources/TUIkit/Rendering/Renderable.swift +++ b/Sources/TUIkit/Rendering/Renderable.swift @@ -60,9 +60,13 @@ protocol Renderable { /// The context for rendering a view. /// -/// Contains layout constraints, terminal information, environment values, -/// and the central `TUIContext` that views need to determine their size, -/// content, and access framework services. +/// Contains layout constraints, environment values, and the central +/// `TUIContext` that views need to determine their size, content, and +/// access framework services. +/// +/// `RenderContext` is a pure data container — it does not hold a reference +/// to `Terminal`. All terminal I/O happens in ``RenderLoop`` after the +/// view tree has been rendered into a ``FrameBuffer``. /// /// - Important: This is framework infrastructure passed to /// ``ViewModifier/modify(buffer:context:)``. Most developers only need @@ -77,9 +81,6 @@ public struct RenderContext { /// The environment values for this render pass. public var environment: EnvironmentValues - /// The target terminal. - let terminal: Terminal - /// The central dependency container for framework services. /// /// Provides access to lifecycle tracking, key event dispatch, @@ -96,29 +97,26 @@ public struct RenderContext { /// Creates a new RenderContext. /// /// - Parameters: - /// - terminal: The target terminal. - /// - availableWidth: The available width (defaults to terminal width). - /// - availableHeight: The available height (defaults to terminal height). + /// - availableWidth: The available width in characters. + /// - availableHeight: The available height in lines. /// - environment: The environment values (defaults to empty). /// - tuiContext: The TUI context (defaults to a fresh instance). /// - identity: The view identity path (defaults to root). init( - terminal: Terminal = Terminal(), - availableWidth: Int? = nil, - availableHeight: Int? = nil, + availableWidth: Int, + availableHeight: Int, environment: EnvironmentValues = EnvironmentValues(), tuiContext: TUIContext = TUIContext(), identity: ViewIdentity = ViewIdentity(path: "") ) { - self.terminal = terminal - self.availableWidth = availableWidth ?? terminal.width - self.availableHeight = availableHeight ?? terminal.height + self.availableWidth = availableWidth + self.availableHeight = availableHeight self.environment = environment self.tuiContext = tuiContext self.identity = identity } - /// Creates a new context with the same terminal and size but different environment. + /// Creates a new context with the same size but different environment. /// /// - Parameter environment: The new environment values. /// - Returns: A new RenderContext with the updated environment. diff --git a/Sources/TUIkit/Rendering/ViewRenderer.swift b/Sources/TUIkit/Rendering/ViewRenderer.swift index fb1c386..9e4d1f5 100644 --- a/Sources/TUIkit/Rendering/ViewRenderer.swift +++ b/Sources/TUIkit/Rendering/ViewRenderer.swift @@ -5,17 +5,23 @@ // Renders Views to terminal output via FrameBuffer. // -import Foundation - -/// Convenience class for standalone view rendering. +/// Convenience class for standalone one-off view rendering. /// /// `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. +/// This class is **not** part of the main render pipeline. The main +/// pipeline is: +/// +/// ``` +/// AppRunner → RenderLoop.render() → renderToBuffer() → FrameDiffWriter → Terminal +/// ``` +/// +/// `ViewRenderer` is used by the ``renderOnce(_:)`` convenience API +/// for simple CLI tools that don't need a full ``App``. It bypasses +/// `RenderLoop`, `FrameDiffWriter`, environment, lifecycle tracking, +/// and diff-based rendering. final class ViewRenderer { /// The terminal to render to. private let terminal: Terminal @@ -29,12 +35,19 @@ final class ViewRenderer { /// Renders a view to the terminal. /// + /// Queries the terminal size, renders the view into a ``FrameBuffer``, + /// and writes the result line-by-line to the terminal. + /// /// - Parameters: /// - view: The view to render. /// - row: The starting row (1-based, default: 1). /// - column: The starting column (1-based, default: 1). func render(_ view: V, atRow row: Int = 1, column: Int = 1) { - let context = RenderContext(terminal: terminal) + let size = terminal.getSize() + let context = RenderContext( + availableWidth: size.width, + availableHeight: size.height + ) let buffer = renderToBuffer(view, context: context) flush(buffer, atRow: row, column: column) } @@ -47,57 +60,3 @@ final class ViewRenderer { } } } - -// MARK: - Child Info - -/// Describes a child view within a stack for layout purposes. -struct ChildInfo { - /// The rendered buffer of this child (nil for spacers, computed later). - let buffer: FrameBuffer? - - /// Whether this child is a Spacer. - let isSpacer: Bool - - /// The minimum length of this spacer (only relevant if isSpacer is true). - let spacerMinLength: Int? -} - -// MARK: - Child Info Provider - -/// Internal protocol that allows stack containers to extract individual -/// child info from their content (which is typically a TupleView). -protocol ChildInfoProvider { - /// Returns an array of ChildInfo, one per child view. - func childInfos(context: RenderContext) -> [ChildInfo] -} - -/// Creates a ChildInfo for a single view. -func makeChildInfo(for view: V, context: RenderContext) -> ChildInfo { - if let spacer = view as? Spacer { - return ChildInfo(buffer: nil, isSpacer: true, spacerMinLength: spacer.minLength) - } - return ChildInfo( - buffer: renderToBuffer(view, context: context), - isSpacer: false, - spacerMinLength: nil - ) -} - -// MARK: - Child Info Resolution - -/// Resolves child infos from a view's content. -/// -/// If the content conforms to `ChildInfoProvider` (e.g. TupleViews), -/// it returns individual child infos. Otherwise it returns the content -/// as a single-element array. -/// -/// - Parameters: -/// - content: The content view. -/// - context: The rendering context. -/// - Returns: An array of ChildInfo. -func resolveChildInfos(from content: V, context: RenderContext) -> [ChildInfo] { - if let provider = content as? ChildInfoProvider { - return provider.childInfos(context: context) - } - return [makeChildInfo(for: content, context: context)] -} diff --git a/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md b/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md index d6ef44c..da842bc 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md @@ -114,45 +114,26 @@ Built-in key bindings that apply when no handler consumed the event: ## Render Pipeline -Each frame follows 8 steps inside `RenderLoop.render()`: +Each frame follows 12 steps inside `RenderLoop.render()`: -### Step 1: Clear Per-Frame State +| Step | What | +|------|------| +| 1 | Clear per-frame state (key handlers, preferences, focus) | +| 2 | Begin lifecycle and state tracking | +| 3 | Build ``EnvironmentValues`` from subsystem state | +| 4 | Create ``RenderContext`` with layout constraints | +| 5 | Evaluate `app.body` → ``WindowGroup`` | +| 6 | Render view tree → ``FrameBuffer`` | +| 7 | Build terminal-ready output lines | +| 8 | Begin buffered frame (`Terminal.beginFrame()`) | +| 9 | Diff against previous frame, write only changed lines | +| 10 | Render status bar into same buffer | +| 11 | Flush entire frame in one `write()` syscall (`Terminal.endFrame()`) | +| 12 | End lifecycle tracking (fires `onDisappear` for removed views) | -Key handlers, preference callbacks, and focus registrations are cleared. Views re-register them during the render pass. +Steps 8–11 are the output optimization layer: line-level diffing reduces writes by ~94% for static UIs, and frame buffering reduces syscalls from ~40+ to exactly 1. -### Step 2: Begin Lifecycle Tracking - -The `LifecycleManager` prepares to track which views appear in this frame by clearing its current-frame token set. - -### Step 3: Build Environment - -``EnvironmentValues`` are assembled from the current subsystem state — palette, appearance, focus manager, status bar, and both theme managers. - -### Step 4: Create Render Context - -A ``RenderContext`` is created with the environment, terminal dimensions (minus status bar height), and the `TUIContext`. This context threads through the entire view tree. - -### Step 5: Evaluate Scene - -`app.body` is called, producing a ``WindowGroup`` that wraps the root view. - -### Step 6: Render View Tree - -The ``WindowGroup`` calls the free function `renderToBuffer()` on its content. This triggers the dual rendering dispatch: - -1. If the view conforms to `Renderable` → call `renderToBuffer(context:)` directly -2. If the view has a `body` → recursively render the body -3. Otherwise → return an empty ``FrameBuffer`` - -The resulting buffer lines are written to the terminal with ANSI escape codes. - -### Step 7: End Lifecycle Tracking - -Views that were visible last frame but not this frame have their `onDisappear` callbacks fired. - -### Step 8: Render Status Bar - -The status bar renders separately with its own ``RenderContext`` (full terminal width, own height). It is never affected by view dimming or overlays. +> For full details on each step, see . ## Cleanup diff --git a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md index d7536e8..9b86831 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md @@ -4,7 +4,7 @@ Understand how TUIkit turns your view tree into terminal output — one frame at ## Overview -Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → render the view tree → track lifecycle → render the status bar**. There is no double buffering, no async scheduling, and no diffing — each frame is a full top-to-bottom traversal that writes directly to the terminal. +Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → render the view tree → diff against previous frame → flush to terminal → track lifecycle**. The view tree is fully re-evaluated each frame, but only **changed terminal lines** are written — and all writes are collected in a frame buffer and flushed as a **single `write()` syscall**. ## What Triggers a Frame @@ -22,7 +22,7 @@ All triggers converge on boolean flags that the main loop checks each iteration. Each call to `RenderLoop.render()` executes these steps in order: -@Image(source: "render-cycle-pipeline.png", alt: "Diagram showing the 8-step render pipeline: clear per-frame state, begin lifecycle tracking, build environment, create render context, evaluate scene, render view tree, end lifecycle tracking, render status bar.") +@Image(source: "render-cycle-pipeline.png", alt: "Diagram showing the 12-step render pipeline: clear per-frame state, begin lifecycle tracking, build environment, create render context, evaluate scene, render view tree, build output lines, begin buffered frame, diff and write changed lines, render status bar into same buffer, flush frame, end lifecycle tracking.") ### Step 1: Clear Per-Frame State @@ -61,13 +61,14 @@ A ``RenderContext`` bundles everything a view needs to render: | Property | What | |----------|------| -| `terminal` | The `Terminal` instance for size queries | | `availableWidth` | Terminal width (mutable — containers reduce this for children) | | `availableHeight` | Terminal height minus status bar (mutable) | | `environment` | The ``EnvironmentValues`` from step 3 | | `tuiContext` | The `TUIContext` (lifecycle, key dispatch, preferences, state storage) | | `identity` | The current view's structural identity (``ViewIdentity``) | +`RenderContext` is a pure data container — it does not hold a reference to `Terminal`. All terminal I/O happens after the view tree has been rendered into a ``FrameBuffer``. + The context is passed down the view tree. Each view can create a modified copy for its children — for example, a border reduces `availableWidth` by 2 before rendering its content. Container views extend the `identity` path for each child. ### Step 5: Evaluate Scene @@ -80,11 +81,34 @@ The context is passed down the view tree. Each view can create a modified copy f This is where the dual rendering system kicks in. ``WindowGroup`` calls the free function `renderToBuffer()` on its content, which recursively traverses the entire view tree and produces a ``FrameBuffer``. -The buffer lines are then written to the terminal row by row — each line padded to full terminal width with a persistent background color. - > See below for details on how views are dispatched. -### Step 7: End Lifecycle and State Tracking +### Step 7: Build Output Lines + +The ``FrameBuffer`` is converted into terminal-ready output lines by `FrameDiffWriter.buildOutputLines()`: + +1. Lines with content get their ANSI reset codes replaced with `reset + backgroundColor` (persistent background) +2. Each line is padded to full terminal width +3. Empty rows are filled with the background color +4. The total output is exactly `terminalHeight` lines + +### Step 8: Begin Buffered Frame + +`Terminal.beginFrame()` activates output buffering. From this point, all `Terminal.write()` calls append to an internal `[UInt8]` buffer instead of issuing syscalls. + +### Step 9: Diff and Write Changed Lines + +`FrameDiffWriter.writeContentDiff()` compares the new output lines with the previous frame and writes **only changed lines** to the terminal buffer. For mostly-static UIs, this reduces writes by ~94%. + +### Step 10: Render Status Bar + +The status bar renders in a separate pass (see below) but writes into the **same frame buffer**, so content and status bar are flushed together. + +### Step 11: Flush Frame + +`Terminal.endFrame()` writes the entire collected buffer to `STDOUT_FILENO` in a **single `write()` syscall**, then resets the buffer. This reduces per-frame syscalls from ~40+ to exactly 1. + +### Step 12: End Lifecycle and State Tracking The `LifecycleManager` compares the current frame's tokens with the previous frame's: @@ -95,16 +119,17 @@ The `StateStorage` performs garbage collection: any state whose view identity wa All state changes inside the lifecycle manager are `NSLock`-protected. Callbacks execute **outside** the lock to prevent deadlocks. -### Step 8: Render Status Bar +### Status Bar Rendering (Step 10) -The status bar renders in a completely separate pass: +The status bar renders in a separate pass but within the same buffered frame: 1. A ``StatusBar`` view is created with resolved palette colors 2. A dedicated ``RenderContext`` is created with `availableHeight` set to the status bar's height 3. `renderToBuffer()` runs on the status bar view — same dispatch as the main content -4. The buffer is written starting at row `terminal.height - statusBarHeight + 1` +4. `FrameDiffWriter.writeStatusBarDiff()` diffs the status bar independently from the main content +5. Changed lines are written into the same frame buffer as the content -The status bar is **never affected** by view dimming or overlays. It always renders last, at the bottom of the terminal. +The status bar is **never affected** by view dimming or overlays. It always renders at the bottom of the terminal. ## The Dual Rendering System @@ -182,14 +207,15 @@ Layout containers combine child buffers using `FrameBuffer` methods: | `overlay(_:)` | `ZStack` | Line-by-line overlay, non-empty lines replace base | | `composited(with:at:)` | Overlay modifier | Character-level compositing at (x, y) position | -### Writing to Terminal +### Diff-Based Output -``WindowGroup`` iterates over the buffer and writes each line to the terminal: +After the view tree produces a ``FrameBuffer``, the `FrameDiffWriter` prepares terminal-ready output: 1. Lines with content get their ANSI reset codes replaced with `reset + backgroundColor` (persistent background) 2. Each line is padded to full terminal width 3. Empty lines are filled with the background color -4. `Terminal.moveCursor()` + `Terminal.write()` per line + +The diff writer then compares each output line with the previous frame. Only lines that actually changed are written to the terminal via `Terminal.moveCursor()` + `Terminal.write()`. All writes are collected in a frame buffer and flushed as a single syscall. ## Environment Flow @@ -283,12 +309,24 @@ The `TaskModifier` (created by `.task()`) combines appearance tracking with asyn 2. Registers a disappear callback that cancels the task 3. If the view reappears, a new task starts -## Why No Double Buffer +## Output Optimization -TUIkit writes each frame directly to the terminal — there is no previous-frame comparison or minimal-update optimization. This is intentional: +TUIkit uses three techniques to minimize terminal I/O: -1. **Terminal rendering is fast** — ANSI writes are cheap for typical TUI sizes (< 200×60 characters) -2. **No layout diffing needed** — the view tree is fully re-evaluated each frame, so there's nothing to diff against -3. **Simplicity** — single-pass rendering eliminates entire categories of bugs (stale state, partial updates, layout thrashing) +### Line-Level Diffing + +`FrameDiffWriter` stores the previous frame's output lines and compares them with the new frame. Only lines that actually changed are written to the terminal. For mostly-static UIs (where only a few elements change per frame), this reduces terminal writes by ~94%. + +### Frame Buffering + +All terminal writes during a frame are collected in an internal `[UInt8]` buffer via `Terminal.beginFrame()` / `Terminal.endFrame()`. The entire frame is flushed to `STDOUT_FILENO` in a **single `write()` syscall**, reducing per-frame syscalls from ~40+ to exactly 1. + +### Width Caching + +``FrameBuffer`` caches its `width` as a stored property, recomputed only when `lines` is mutated. This eliminates hundreds of redundant ANSI-stripping regex runs per frame. The `strippedLength` property also avoids intermediate string allocations. + +### What Is NOT Diffed + +The **view tree** is always fully re-evaluated each frame — there is no virtual DOM or subtree memoization. This keeps the architecture simple and eliminates stale-state bugs. The diffing happens only at the terminal output level. The alternate screen buffer (entered during setup) ensures that the user's previous terminal content is preserved and restored on exit. diff --git a/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md b/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md index b43eabe..178f169 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md @@ -136,5 +136,4 @@ Views that disappear from the tree (e.g., a conditional branch switches) have th automatically cleaned up at the end of each render pass. `ConditionalView` also immediately invalidates the inactive branch's state to prevent stale values. -This is simple and predictable — no diffing, no virtual DOM, just full re-renders with -persistent state. +This is simple and predictable — the view tree is fully re-evaluated each frame (no virtual DOM), with persistent state. Terminal output is then diffed at the line level — only changed lines are written. See for details on the output optimization pipeline. diff --git a/Tests/TUIkitTests/StateStorageIdentityTests.swift b/Tests/TUIkitTests/StateStorageIdentityTests.swift index cbf4811..931a83f 100644 --- a/Tests/TUIkitTests/StateStorageIdentityTests.swift +++ b/Tests/TUIkitTests/StateStorageIdentityTests.swift @@ -208,7 +208,12 @@ struct StateStorageIdentityTests { func stateSurvivesRenderToBuffer() { let (_, _) = testEnvironment() let tuiContext = TUIContext() - let context = RenderContext(tuiContext: tuiContext, identity: ViewIdentity(path: "")) + let context = RenderContext( + availableWidth: 80, + availableHeight: 24, + tuiContext: tuiContext, + identity: ViewIdentity(path: "") + ) // First render: creates state with default 0, body sets it to 42 let buffer1 = renderToBuffer(CounterView(), context: context) @@ -223,7 +228,12 @@ struct StateStorageIdentityTests { func nestedViewsIndependentState() { let (_, _) = testEnvironment() let tuiContext = TUIContext() - let context = RenderContext(tuiContext: tuiContext, identity: ViewIdentity(path: "")) + let context = RenderContext( + availableWidth: 80, + availableHeight: 24, + tuiContext: tuiContext, + identity: ViewIdentity(path: "") + ) // Render a parent with two child views that each have @State let buffer = renderToBuffer(ParentWithTwoCounters(), context: context)