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
48 changes: 48 additions & 0 deletions Sources/TUIkit/App/RenderLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@ private struct EnvironmentSnapshot: Equatable {
}
}

/// Per-subtree render-affecting environment values included in cache keys.
///
/// The subtree analog of ``EnvironmentSnapshot``'s global palette/appearance
/// clear: these values change a cached subtree's visual output without
/// changing its `Equatable` view value, so `EquatableView` snapshots them
/// per position and `RenderCache` misses on mismatch.
///
/// Members come from the issue #14 audit: `foregroundStyle` colors any text
/// below the wrapper; `focusIndicatorColor` drives the pulsing border
/// indicator inside an active focus section (pulse-derived, so entries
/// inside an active section refresh every tick instead of freezing).
/// Everything else read by renderables is either effect-bearing content
/// (bypassed by classification), globally snapshotted, or deliberately
/// excluded (`pulsePhase` itself).
private struct StyleEnvironmentFingerprint: Equatable, Sendable {
/// The foreground color set via `.foregroundStyle(_:)`.
let foregroundStyle: Color?

/// The pulse-lerped focus indicator color of the enclosing section.
let focusIndicatorColor: Color?

/// Snapshots the fingerprint members from an environment.
init(from environment: EnvironmentValues) {
self.foregroundStyle = environment.foregroundStyle
self.focusIndicatorColor = environment.focusIndicatorColor
}
}

/// ANSI background codes for each render surface in a frame.
///
/// Keeping these grouped avoids accidentally rendering every surface
Expand Down Expand Up @@ -349,6 +377,11 @@ extension RenderLoop {
/// All other services (state storage, lifecycle, focus queries, palette,
/// …) stay on the live runtime.
///
/// The environment also carries the pass's effect-registration probe:
/// a closure summing every effect sink of this pass, which
/// `EquatableView` snapshots around a cache-miss rendering to decide
/// whether the subtree is effect-free and safe to memoize.
///
/// - Parameters:
/// - base: The frame's live environment.
/// - collectors: The scratch collectors of the current pass.
Expand All @@ -363,6 +396,21 @@ extension RenderLoop {
environment.statusBar = collectors.statusBar
environment.appHeader = collectors.appHeader
environment.pendingFrameEffects = collectors.pendingEffects
let keyEventDispatcher = collectors.keyEventDispatcher
let preferences = collectors.preferences
let statusBar = collectors.statusBar
let pendingEffects = collectors.pendingEffects
let focusManager = focusManager
environment.effectRegistrationProbe = {
keyEventDispatcher.handlerCount
+ preferences.writeCount
+ statusBar.passRegistrationCount
+ pendingEffects.deferredEffectCount
+ focusManager.stagedRegistrationCount
}
environment.environmentFingerprintProbe = { environment in
EnvironmentFingerprint(StyleEnvironmentFingerprint(from: environment))
}
return environment
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/TUIkit/Environment/TUIContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ extension TUIContext {
for root in pendingEffects.activeSubtreeRoots {
stateStorage.markSubtreeActive(root)
observationRegistry.markSubtreeActive(root)
renderCache.markActive(root)
renderCache.markSubtreeActive(root)
}
}

Expand Down
12 changes: 12 additions & 0 deletions Sources/TUIkit/Focus/Focus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ public final class FocusManager: @unchecked Sendable {
/// semantics.
private var isCollectingPass = false

/// Number of sections and focusables staged by the current collecting
/// pass.
///
/// Snapshot around a subtree rendering to detect focus registrations
/// (see `EnvironmentValues.effectRegistrationProbe`). Sections count
/// even without focusables: an empty section still affects committed
/// Tab cycling and must reach the commit every frame. Zero outside a
/// collecting pass.
var stagedRegistrationCount: Int {
stagedSections.reduce(stagedSections.count) { $0 + $1.focusables.count }
}

/// Creates a new focus manager instance.
public init() {}

Expand Down
6 changes: 6 additions & 0 deletions Sources/TUIkit/StatusBar/StatusBarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ public final class StatusBarState: @unchecked Sendable {
/// Declarative registrations collected during the current render pass.
private(set) var passRegistrations: [PassRegistration] = []

/// Number of declarative registrations recorded by the current pass.
///
/// Snapshot around a subtree rendering to detect status-bar
/// declarations (see `EnvironmentValues.effectRegistrationProbe`).
var passRegistrationCount: Int { passRegistrations.count }

/// The focus manager used to determine the active section.
///
/// Set by `RenderLoop` at the start of each render pass.
Expand Down
49 changes: 40 additions & 9 deletions Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,11 +436,40 @@ While the view tree is reconstructed each frame, ``EquatableView`` allows **indi

When a view is wrapped in `.equatable()`, the rendering system:

1. Looks up the cached ``FrameBuffer`` for this view's `ViewIdentity`
2. Compares the **current view value** with the cached snapshot via `Equatable.==`
3. Checks that the available **width and height** haven't changed
4. On **cache hit**: returns the cached buffer and preserves the subtree's State and Observation liveness without evaluating its body
5. On **cache miss**: renders normally and stores the result
1. Skips the cache entirely in `.measure` traversals: effect sites are inert during sizing, so a buffer stored there could let the same frame's output pass hit a subtree whose effects never mounted
2. Looks up the cached ``FrameBuffer`` for this view's `ViewIdentity` — identities classified as **effect-bearing** always miss (see below)
3. Compares the **current view value** with the cached snapshot via `Equatable.==`
4. Checks that the available **width and height** and the **environment fingerprint** (foreground style, focus indicator color) haven't changed
5. On **cache hit**: returns the cached buffer and preserves the subtree's State, Observation, and nested cache-entry liveness without evaluating its body
6. On **cache miss**: renders normally, classifies the content via the pass's effect-registration probe, and stores the result only when the rendering registered no effects

### Effect-Bearing Subtrees

Content that registers per-pass effects while rendering — key handlers,
focus registrations (`Button`, `Toggle`, …), focus sections, status-bar
declarations, preference writes, or lifetime-effect records (`onAppear`,
`.task`, …) — must reach the frame's collectors on **every** frame; a
cached buffer would silently drop those registrations at the frame commit.

`EquatableView` therefore snapshots the pass's effect-registration probe
around every cache-miss rendering. Any delta flags the identity in the
`RenderCache`: flagged identities never produce hits, so the subtree
renders each frame and behaves exactly as if it were unwrapped (including
pulse-animated focus indicators). The classification refreshes on every
miss, so content that becomes effect-free re-enables caching automatically.

This makes `.equatable()` **safe to apply anywhere**: only provably
effect-free output is ever served from the cache. On effect-bearing
subtrees the wrapper simply has no effect.

### Liveness Guarantees

A cache hit keeps every runtime record below the cached root alive without
traversing the subtree: `@State` boxes, Observation registrations, and
nested cache entries survive via subtree marking at the frame commit.
Effect-bearing records (lifecycle slots, tasks, handlers, status-bar items,
focus registrations) need no such marking — subtrees owning them never hit
the cache and re-register on every frame.

```swift
// A static info box: title and subtitle are the only inputs.
Expand Down Expand Up @@ -471,7 +500,8 @@ The runtime applies pending cache invalidations before rendering:
| `@State` or `@AppStorage` change | Invalidates the owning view subtree |
| Observed model change | Invalidates the structural subtree that read the dependency |
| Language change | Requests a full runtime cache clear |
| Environment change | `RenderLoop` compares an `EnvironmentSnapshot` (palette ID + appearance ID) each frame and clears on mismatch |
| Global environment change | `RenderLoop` compares an `EnvironmentSnapshot` (palette ID + appearance ID) each frame and clears on mismatch |
| Style environment change | Entries store an environment fingerprint (foreground style, focus indicator color) captured at their tree position; a lookup with a differing fingerprint misses and re-renders |

Each runtime owns its own `RenderCache`, so invalidation in one application cannot
evict another application's cached output. Between invalidations, for example
Expand All @@ -485,11 +515,12 @@ during Spinner animation frames, static subtrees are reused across frames.
| Complex container hierarchies | Many nested views that produce the same output |
| Views next to animated siblings | Spinner/Pulse re-renders the whole tree; static siblings benefit from caching |

| Bad candidates | Why |
|---------------|-----|
| Pointless candidates | Why |
|---------------------|-----|
| Views that read `@State` directly | State lives in a reference-type box: the view struct compares as equal even when state changed |
| Views that change every frame | Cache overhead with no benefit |
| Tiny views (single `Text`) | Rendering cost is already minimal |
| Effect-bearing subtrees (interactive controls, lifecycle modifiers, status-bar declarations) | Automatically detected and bypassed — safe, but the wrapper adds nothing |

### Which Types Support `.equatable()`

Expand All @@ -503,7 +534,7 @@ The following types have `Equatable` conformance, enabling `.equatable()` on vie

**Supporting types:** `TextStyle`, `Alignment`, `ContainerConfig`, `ContainerStyle`

> Note: `Button` cannot be `Equatable` because it stores a closure (`action: () -> Void`). Views containing buttons are not candidates for `.equatable()`.
> Note: `Button` cannot be `Equatable` because it stores a closure (`action: () -> Void`). A custom `Equatable` view may still contain buttons — its focus registrations then classify the subtree as effect-bearing, and the cache is bypassed instead of dropping the button's interactivity.

### Debug Logging

Expand Down
9 changes: 9 additions & 0 deletions Sources/TUIkitCore/Environment/PreferenceKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ public final class PreferenceStorage: @unchecked Sendable {
/// Stack of preference values for nested rendering.
private var stack: [PreferenceValues] = [PreferenceValues()]

/// Monotonic count of preference writes over this storage's lifetime.
///
/// Never reset between passes: per-pass scratch storages start at zero,
/// so snapshotting the counter around a subtree rendering reveals
/// whether that subtree declared any preference (see
/// `EnvironmentValues.effectRegistrationProbe`).
package private(set) var writeCount = 0

/// Creates a new preference storage.
public init() {}

Expand Down Expand Up @@ -140,6 +148,7 @@ public extension PreferenceStorage {

/// Sets a preference value.
func setValue<K: PreferenceKey>(_ value: K.Value, forKey key: K.Type) {
writeCount += 1
var currentValues = current
K.reduce(value: &currentValues[key]) { value }
current = currentValues
Expand Down
87 changes: 70 additions & 17 deletions Sources/TUIkitView/Core/EquatableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,39 @@ import TUIkitCore
/// so the view struct compares as equal even when state changed)
/// - Views that change every frame (the cache overhead adds no value)
/// - Views that depend on environment values that change frequently
/// - **Views containing focused interactive elements** (Button, Toggle, Slider,
/// etc.) whose focus indicator animates via pulse phase. The cached buffer
/// would show a frozen pulse animation.
///
/// ## Effect Bypass
///
/// Content that registers per-pass effects while rendering — key handlers,
/// focus registrations (Button, Toggle, …), status-bar declarations,
/// preference writes, or lifetime-effect records (`onAppear`, `.task`, …) —
/// must reach the frame's collectors on EVERY frame; serving it from a
/// cache would silently drop those registrations at the frame commit.
///
/// Inside `RenderLoop` frames the wrapper therefore classifies its content
/// on every cache miss: it snapshots the pass's effect-registration probe
/// around the rendering, and any delta flags the identity as effect-bearing
/// in the ``RenderCache``. Flagged identities never produce hits, so the
/// subtree renders each frame and behaves exactly as if it were unwrapped
/// (including pulse-animated focus indicators). Only provably effect-free
/// output is ever served from the cache, making `.equatable()` safe to
/// apply anywhere — on effect-bearing subtrees it simply has no effect.
///
/// Measurement passes (`RenderPhase.measure`) stay out of the cache
/// entirely: effect sites are inert there, so a buffer stored during
/// sizing would let the same frame's output pass hit a subtree whose
/// effects were never recorded, and any classification would measure an
/// inert traversal.
///
/// On the live path (no `RenderLoop`, e.g. `ViewRenderer` or test
/// harnesses) caching keeps its historical semantics without
/// classification.
///
/// ## Cache Invalidation
///
/// The render cache is selectively cleared when `@State` values change:
/// only cache entries in the ancestor/descendant path of the changed state
/// are invalidated. Sibling subtrees retain their cached buffers.
/// Pulse animation changes do **not** invalidate the cache, which is why
/// subtrees containing focused interactive views should not be wrapped.
///
/// - SeeAlso: ``View/equatable()``
public struct EquatableView<Content: View & Equatable>: View {
Expand Down Expand Up @@ -94,12 +116,30 @@ extension EquatableView: Renderable {
cache.markActive(identity)
}

// Cache hit: view unchanged and context size matches
// Measurement passes stay out of the cache entirely: effect sites
// are inert in `.measure`, so a buffer stored here would let the
// same frame's output pass hit a subtree whose effects were never
// recorded, and the delta classification below would measure an
// inert traversal.
if context.phase == .measure {
return TUIkitView.renderToBuffer(content, context: context)
}

// Fingerprint of the render-affecting environment at this position
// (foreground style, focus indicator, …). Part of the cache key so
// an environment change above the wrapper can never serve a stale
// buffer. `nil` on the live path.
let environmentFingerprint = context.environment.environmentFingerprintProbe?(context.environment)

// Cache hit: view unchanged, size and environment match, and the
// identity is classified effect-free (effect-bearing identities
// never hit).
if let cached = cache.lookup(
identity: identity,
view: content,
contextWidth: context.availableWidth,
contextHeight: context.availableHeight
contextHeight: context.availableHeight,
environmentFingerprint: environmentFingerprint
) {
// Still need to keep runtime records inside the cached subtree
// active even though its body is not evaluated.
Expand All @@ -108,16 +148,27 @@ extension EquatableView: Renderable {
return cached
}

// Cache miss: render normally and store result
// Cache miss: render and classify. Any effect registration during
// this rendering flags the identity — an effect-bearing subtree
// must render every frame so its registrations reach the frame's
// collectors. Only provably effect-free output is stored. Without
// a probe (live path) the historical semantics apply: always store.
let probe = context.environment.effectRegistrationProbe
let registrationsBeforeRender = probe?() ?? 0
let buffer = TUIkitView.renderToBuffer(content, context: context)
let carriesEffects = probe.map { $0() != registrationsBeforeRender } ?? false

cache.store(
identity: identity,
view: content,
buffer: buffer,
contextWidth: context.availableWidth,
contextHeight: context.availableHeight
)
cache.setCarriesEffects(carriesEffects, for: identity)
if !carriesEffects {
cache.store(
identity: identity,
view: content,
buffer: buffer,
contextWidth: context.availableWidth,
contextHeight: context.availableHeight,
environmentFingerprint: environmentFingerprint
)
}

return buffer
}
Expand All @@ -129,14 +180,16 @@ private extension EquatableView {
/// Marks the content's runtime records as active for end-of-pass cleanup.
///
/// When returning a cached buffer, the subtree's views aren't visited.
/// Their state and Observation identities must still be marked active —
/// per pass inside a RenderLoop frame, directly on the live path.
/// Their state, Observation, and nested cache identities must still be
/// marked active — per pass inside a RenderLoop frame, directly on the
/// live path.
func markSubtreeActive(context: RenderContext) {
if let pendingEffects = context.environment.pendingFrameEffects {
pendingEffects.markSubtreeActive(context.identity)
} else {
context.environment.stateStorage!.markSubtreeActive(context.identity)
context.environment.observationRegistry?.markSubtreeActive(context.identity)
context.environment.renderCache!.markSubtreeActive(context.identity)
}
}
}
Expand Down
35 changes: 35 additions & 0 deletions Sources/TUIkitView/Rendering/EffectRegistrationProbe.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 🖥️ TUIKit — Terminal UI Kit for Swift
// EffectRegistrationProbe.swift
//
// License: MIT

import TUIkitCore

// MARK: - Effect Registration Probe

/// Environment key for the per-pass effect-registration probe.
private struct EffectRegistrationProbeKey: EnvironmentKey {
static let defaultValue: (@Sendable () -> Int)? = nil
}

extension EnvironmentValues {
/// Counts the effect registrations collected by the current render pass.
///
/// Installed per pass by the render loop, the closure sums every
/// per-pass effect sink: key handlers, preference writes, status-bar
/// declarations, staged focus registrations, and deferred
/// lifetime-effect records. The absolute value is meaningless; callers
/// snapshot it around a subtree rendering and compare — any delta means
/// the subtree registered at least one effect during that rendering.
///
/// ``EquatableView`` uses this to classify content on a cache miss:
/// only subtrees that render without a delta are provably effect-free
/// and safe to memoize.
///
/// `nil` outside `RenderLoop` frames (standalone `ViewRenderer`, test
/// harnesses), where caching keeps its historical live-path semantics.
package var effectRegistrationProbe: (@Sendable () -> Int)? {
get { self[EffectRegistrationProbeKey.self] }
set { self[EffectRegistrationProbeKey.self] = newValue }
}
}
Loading
Loading