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
90 changes: 68 additions & 22 deletions Sources/TUIkit/App/RenderLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,22 +79,29 @@ internal struct RenderBackgroundCodes: Equatable {
/// 12. End lifecycle tracking (fires onDisappear for removed views)
/// ```
///
/// ## Render phases
/// ## Render phases and per-pass collectors
///
/// A frame can traverse the view tree up to three times. Each traversal
/// carries a ``RenderPhase`` on its `RenderContext`:
/// carries a ``RenderPhase`` on its `RenderContext` and writes its
/// frame-scoped effects into fresh ``RenderPassCollectors``:
///
/// - **First-frame header sizing** runs in `.measure`: it only discovers the
/// app header height, so phase-guarded effect sites (lifecycle, task,
/// focus registration) stay inert. `AppHeaderModifier` intentionally
/// remains unguarded — the header buffer IS the measured output.
/// app header height. Phase-guarded effect sites (lifecycle, task, focus
/// registration) stay inert, and the header buffer it renders lives in a
/// scratch collector that is dropped right after the height is read.
/// - **Main pass** and, when the header height changed, the **correction
/// pass** run in `.render` and produce the frame's candidate buffers.
/// A superseded main pass is discarded together with its collectors and
/// staged focus registrations.
/// - **Commit**: the FINAL pass's collectors are adopted into the live
/// managers (key handlers, preferences, status bar, header) and the
/// staged focus registrations are committed — the only point in a frame
/// where per-frame effect state reaches the live runtime.
///
/// Not yet guaranteed (tracked by issues #56/#57): the correction pass still
/// re-registers per-frame effects (e.g. key handlers) on top of the main
/// pass, and lifetime effects still apply during traversal instead of at a
/// single commit point. See ``RenderPhase`` for the target model.
/// Not yet guaranteed (tracked by issue #57): lifetime effects (`onAppear`,
/// `.task`, `onChange`, `onPreferenceChange` actions, GC liveness) still
/// apply during traversal instead of at the commit point. See
/// ``RenderPhase`` for the target model.
///
/// ## Diff-Based Rendering
///
Expand Down Expand Up @@ -225,54 +232,69 @@ extension RenderLoop {
// This prevents visible content jumping.
let appHeaderHeight: Int
if isFirstFrame {
// This traversal only exists to size the app header — it runs in
// the measure phase (guarded effect sites stay inert) and writes
// into its own collectors, which are dropped right after the
// height is read. Live state is never touched.
let measureCollectors = RenderPassCollectors(appState: tuiContext.appState)
var measureContext = RenderContext(
availableWidth: terminalWidth,
availableHeight: terminalHeight - statusBarHeight,
environment: environment
environment: passEnvironment(environment, collectors: measureCollectors)
)
// This traversal only exists to size the app header — run it in
// the measure phase so guarded effect sites (lifecycle, task,
// focus) stay inert. Deliberate exception: AppHeaderModifier is
// NOT phase-guarded, because writing the header buffer is the
// very output this pass measures. Isolating that write into a
// discardable per-pass collector is issue #56.
measureContext.phase = .measure
focusManager.beginPass()
_ = renderScene(scene, context: measureContext.withChildIdentity(type: type(of: scene)))
appHeaderHeight = appHeader.height
focusManager.discardPass()
appHeaderHeight = measureCollectors.appHeader.height
isFirstFrame = false
} else {
appHeaderHeight = appHeader.estimatedHeight
}

let contentHeight = terminalHeight - statusBarHeight - appHeaderHeight

// Main pass: evaluate the frame's candidate tree into fresh
// collectors. Nothing reaches live state until the commit below.
var collectors = RenderPassCollectors(appState: tuiContext.appState)
var context = RenderContext(
availableWidth: terminalWidth,
availableHeight: contentHeight,
environment: environment
environment: passEnvironment(environment, collectors: collectors)
)
context.hasExplicitWidth = true
context.hasExplicitHeight = true

focusManager.beginPass()
var buffer = renderScene(scene, context: context.withChildIdentity(type: type(of: scene)))

// If the header height changed after rendering, re-render with the
// correct height so centering is accurate.
let actualHeaderHeight = appHeader.height
// correct height so centering is accurate. The superseded main
// pass's collectors and staged focus registrations are discarded;
// the correction pass starts from clean scratch state.
let actualHeaderHeight = collectors.appHeader.height
if actualHeaderHeight != appHeaderHeight {
diffWriter.invalidate()
focusManager.discardPass()
collectors = RenderPassCollectors(appState: tuiContext.appState)
let actualContentHeight = terminalHeight - statusBarHeight - actualHeaderHeight
var correctedContext = RenderContext(
availableWidth: terminalWidth,
availableHeight: actualContentHeight,
environment: environment
environment: passEnvironment(environment, collectors: collectors)
)
correctedContext.hasExplicitWidth = true
correctedContext.hasExplicitHeight = true
focusManager.beginPass()
buffer = renderScene(scene, context: correctedContext.withChildIdentity(type: type(of: scene)))
}

focusManager.endRenderPass()
// COMMIT (step 6a): the single point where per-frame effect state
// reaches the live runtime — the FINAL pass's collectors replace the
// live managers' state, and the staged focus registrations are
// committed with their deferred side effects.
collectors.adoptIntoLiveManagers(of: tuiContext)
focusManager.commitPass()

writeFrame(
buffer: buffer,
Expand All @@ -299,6 +321,30 @@ extension RenderLoop {
func buildEnvironment() -> EnvironmentValues {
tuiContext.environmentValues()
}

/// Derives a per-pass environment that routes frame-scoped effect sinks
/// into the pass's scratch collectors.
///
/// Effect sites keep writing to `context.environment.keyEventDispatcher`
/// and friends; only the instance behind those keys changes per pass.
/// All other services (state storage, lifecycle, focus queries, palette,
/// …) stay on the live runtime.
///
/// - Parameters:
/// - base: The frame's live environment.
/// - collectors: The scratch collectors of the current pass.
/// - Returns: The environment to render this pass with.
private func passEnvironment(
_ base: EnvironmentValues,
collectors: RenderPassCollectors
) -> EnvironmentValues {
var environment = base
environment.keyEventDispatcher = collectors.keyEventDispatcher
environment.preferenceStorage = collectors.preferences
environment.statusBar = collectors.statusBar
environment.appHeader = collectors.appHeader
return environment
}
}

// MARK: - Private Helpers
Expand Down
87 changes: 87 additions & 0 deletions Sources/TUIkit/App/RenderPassCollectors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 🖥️ TUIKit — Terminal UI Kit for Swift
// RenderPassCollectors.swift
//
// Created by LAYERED.work
// License: MIT

// MARK: - Render Pass Collectors

/// Per-pass scratch sinks for effects that do **not** outlive the frame.
///
/// A frame may traverse the view tree several times (first-frame header
/// measurement, main pass, header-correction pass), but only ONE traversal
/// produces the frame's output. Effect sites must therefore never write
/// per-frame registrations directly into the live managers — a discarded
/// pass would leave ghost handlers, items, or buffers behind.
///
/// `RenderPassCollectors` solves this with the collector half of the effect
/// classification rule (see ``RenderPhase``):
///
/// > Does the effect outlive the frame? **No → pass collector (this type).**
/// > Yes → pending diff (issue #57).
///
/// ## How it works
///
/// `RenderLoop` creates one `RenderPassCollectors` per traversal and injects
/// its members into that pass's environment. Effect sites stay completely
/// unchanged — they keep writing to `context.environment.keyEventDispatcher`
/// and friends; the environment simply hands them the scratch instance of
/// the current pass instead of the live manager.
///
/// At frame commit, `RenderLoop` adopts the FINAL pass's collectors into the
/// live managers (`adopt(from:)` on each manager type). Collectors of
/// discarded passes are dropped without further ceremony: because nothing
/// live was ever touched, discarding requires no rollback.
///
/// ## What is collected here
///
/// - Key handlers (`KeyEventDispatcher`)
/// - Preference values and change callbacks (`PreferenceStorage`)
/// - Declarative status-bar registrations (`StatusBarState`)
/// - The app-header buffer (`AppHeaderState`) — the first-frame measurement
/// pass reads the header height from its scratch instance, which is then
/// discarded, so sizing never mutates live state
///
/// Focus registrations are the deliberate exception: focus queries
/// (`isFocused`) must read live state during traversal, so `FocusManager`
/// stages registrations internally instead of using a scratch instance
/// (see `FocusManager.beginPass()`).
@MainActor
struct RenderPassCollectors {
/// Scratch sink for key handlers registered by this pass's tree.
let keyEventDispatcher = KeyEventDispatcher()

/// Scratch sink for preference values and change callbacks.
let preferences = PreferenceStorage()

/// Scratch sink for declarative status-bar registrations.
let statusBar: StatusBarState

/// Scratch sink for the app-header buffer.
let appHeader = AppHeaderState()

/// Creates fresh collectors for one render pass.
///
/// - Parameter appState: The runtime's render-state sink; required by
/// `StatusBarState` for its (unsupported but non-crashing) imperative
/// re-render paths.
init(appState: AppState) {
self.statusBar = StatusBarState(appState: appState)
}

/// Adopts this pass's collected registrations into the live managers.
///
/// This is commit step "6a" of the frame choreography (see `RenderLoop`):
/// the single point where per-frame effect state reaches the live
/// runtime, called exactly once per frame with the FINAL pass's
/// collectors.
///
/// - Parameter tuiContext: The runtime whose live managers adopt the
/// collected state.
func adoptIntoLiveManagers(of tuiContext: TUIContext) {
tuiContext.keyEventDispatcher.adopt(from: keyEventDispatcher)
tuiContext.preferences.adopt(from: preferences)
tuiContext.statusBar.adoptPassRegistrations(from: statusBar)
tuiContext.appHeader.adopt(from: appHeader)
}
}
18 changes: 18 additions & 0 deletions Sources/TUIkit/AppHeader/AppHeaderState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ extension AppHeaderState {
previousHeight = height
contentBuffer = nil
}

/// Replaces the content buffer with the one collected by a per-pass
/// scratch instance.
///
/// The header buffer does not outlive the frame: `AppHeaderModifier`
/// re-renders it on every traversal. At frame commit the live state
/// therefore adopts the FINAL pass's buffer (which may be `nil` when the
/// final tree declares no header); buffers written by discarded passes —
/// including the first-frame measurement pass, whose buffer only exists
/// to size the header — are dropped with their collector.
///
/// The ``estimatedHeight`` bookkeeping stays on the live instance, so
/// the estimate always reflects the last COMMITTED frame.
///
/// - Parameter collector: The scratch state of the frame's final pass.
func adopt(from collector: AppHeaderState) {
contentBuffer = collector.contentBuffer
}
}

// MARK: - Environment Key
Expand Down
Loading
Loading