From dc34b0fa550782b4e796fc9b6ecc39ee5cfef0db Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 2 Feb 2026 21:33:43 +0100 Subject: [PATCH] =?UTF-8?q?Refactor:=20Line-level=20diff=20rendering=20?= =?UTF-8?q?=E2=80=94=20only=20write=20changed=20terminal=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce FrameDiffWriter that stores the previous frame's output and compares line-by-line on each render. Only lines that actually differ are written to the terminal, reducing I/O by ~94% for mostly-static UI. - Extract output line building from WindowGroup.renderScene into FrameDiffWriter.buildOutputLines() pure function - WindowGroup.renderScene now returns FrameBuffer instead of writing directly to terminal - RenderLoop (now final class) owns FrameDiffWriter for state tracking - Content and status bar are diffed independently - SIGWINCH invalidates diff cache for full repaint on resize - SignalManager gains separate consumeResizeFlag() for resize detection - 13 new tests for diff computation and output line building --- Sources/TUIkit/App/App.swift | 56 ++-- Sources/TUIkit/App/RenderLoop.swift | 152 ++++++++--- Sources/TUIkit/App/SignalManager.swift | 24 +- .../TUIkit/Rendering/FrameDiffWriter.swift | 210 +++++++++++++++ Tests/TUIkitTests/FrameDiffWriterTests.swift | 242 ++++++++++++++++++ 5 files changed, 605 insertions(+), 79 deletions(-) create mode 100644 Sources/TUIkit/Rendering/FrameDiffWriter.swift create mode 100644 Tests/TUIkitTests/FrameDiffWriterTests.swift diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index ce11577..97dce82 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -136,6 +136,12 @@ internal final class AppRunner { break } + // Invalidate diff cache on terminal resize so every line + // is rewritten with the new dimensions. + if signals.consumeResizeFlag() { + renderer.invalidateDiffCache() + } + // Check if terminal was resized or state changed if signals.consumeRerenderFlag() || appState.needsRender { appState.didRender() @@ -180,50 +186,24 @@ internal final class AppRunner { /// 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. + /// Renders the scene's content into a ``FrameBuffer``. + /// + /// The caller (``RenderLoop``) is responsible for writing the buffer + /// to the terminal via ``FrameDiffWriter``. /// /// - Parameter context: The rendering context with layout constraints. - func renderScene(context: RenderContext) + /// - Returns: The rendered frame buffer. + func renderScene(context: RenderContext) -> FrameBuffer } -/// Renders the window group's content view to the terminal. +/// Renders the window group's content view into a ``FrameBuffer``. /// /// This is the bridge from `Scene` to `View` rendering: -/// calls ``renderToBuffer(_:context:)`` on `content`, writes the -/// resulting ``FrameBuffer`` line-by-line with persistent background. +/// calls ``renderToBuffer(_:context:)`` on `content` and returns the +/// resulting ``FrameBuffer``. Terminal output (diffing, writing) is +/// handled by ``RenderLoop`` via ``FrameDiffWriter``. extension WindowGroup: SceneRenderable { - func renderScene(context: RenderContext) { - let buffer = renderToBuffer(content, context: context) - let terminal = context.terminal - let terminalWidth = terminal.width - let terminalHeight = context.availableHeight - - // Get background color from palette - let bgColor = context.environment.palette.background - let bgCode = ANSIRenderer.backgroundCode(for: bgColor) - let reset = ANSIRenderer.reset - - // Write buffer to terminal, ensuring consistent background color - for row in 0.. FrameBuffer { + renderToBuffer(content, context: context) } } diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index ca72379..7857dd8 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -24,20 +24,30 @@ /// 4. Create RenderContext with layout constraints /// 5. Evaluate App.body fresh → Scene (WindowGroup) /// @State values survive because State.init self-hydrates from StateStorage -/// 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) +/// 6. Call SceneRenderable.renderScene() → FrameBuffer +/// 7. Convert FrameBuffer to terminal-ready output lines +/// 8. Diff against previous frame, write only changed lines +/// 9. End lifecycle tracking (fires onDisappear for removed views) +/// 10. Render status bar (with its own diff tracking) /// ``` /// +/// ## Diff-Based Rendering +/// +/// `RenderLoop` uses a ``FrameDiffWriter`` to compare each frame's output +/// with the previous frame. Only lines that actually changed are written +/// to the terminal, reducing I/O by ~94% for mostly-static UIs. +/// +/// On terminal resize (SIGWINCH), the diff cache is invalidated to force +/// a full repaint. +/// /// ## 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) -internal struct RenderLoop { +/// - Diff-based terminal output via ``FrameDiffWriter`` +internal final class RenderLoop { /// The user's app instance (provides `body`). let app: A @@ -59,6 +69,27 @@ internal struct RenderLoop { /// The central dependency container (lifecycle, key dispatch, preferences). let tuiContext: TUIContext + /// The diff writer that tracks previous frames and writes only changed lines. + private let diffWriter = FrameDiffWriter() + + init( + app: A, + terminal: Terminal, + statusBar: StatusBarState, + focusManager: FocusManager, + paletteManager: ThemeManager, + appearanceManager: ThemeManager, + tuiContext: TUIContext + ) { + self.app = app + self.terminal = terminal + self.statusBar = statusBar + self.focusManager = focusManager + self.paletteManager = paletteManager + self.appearanceManager = appearanceManager + self.tuiContext = tuiContext + } + // MARK: - Rendering /// Performs a full render pass: scene content + status bar. @@ -66,9 +97,10 @@ internal struct RenderLoop { /// Each call: /// 1. Clears key event handlers and focus state /// 2. Begins lifecycle tracking - /// 3. Renders the scene into the terminal - /// 4. Ends lifecycle tracking (triggers `onDisappear` for removed views) - /// 5. Renders the status bar at the bottom + /// 3. Renders the scene into a ``FrameBuffer`` + /// 4. Diffs against the previous frame and writes only changed lines + /// 5. Ends lifecycle tracking (triggers `onDisappear` for removed views) + /// 6. Renders the status bar at the bottom (with its own diff tracking) func render() { // Clear per-frame state before re-rendering tuiContext.keyEventDispatcher.clearHandlers() @@ -81,20 +113,22 @@ internal struct RenderLoop { // Calculate available height (reserve space for status bar) let statusBarHeight = statusBar.height - let contentHeight = terminal.height - statusBarHeight + let terminalWidth = terminal.width + let terminalHeight = terminal.height + let contentHeight = terminalHeight - statusBarHeight // Create render context with environment let environment = buildEnvironment() let context = RenderContext( terminal: terminal, - availableWidth: terminal.width, + availableWidth: terminalWidth, availableHeight: contentHeight, environment: environment, tuiContext: tuiContext ) - // Render main content (background fill happens in renderScene). + // Render main content into a FrameBuffer. // app.body is evaluated fresh each frame. @State values survive // because State.init self-hydrates from StateStorage. // @@ -113,19 +147,49 @@ internal struct RenderLoop { StateRegistration.activeContext = nil tuiContext.stateStorage.markActive(rootIdentity) - renderScene(scene, context: context.withChildIdentity(type: type(of: scene))) + let buffer = renderScene(scene, context: context.withChildIdentity(type: type(of: scene))) + + // Build terminal-ready output lines and write only changes + let bgColor = environment.palette.background + let bgCode = ANSIRenderer.backgroundCode(for: bgColor) + let reset = ANSIRenderer.reset + + let outputLines = diffWriter.buildOutputLines( + buffer: buffer, + terminalWidth: terminalWidth, + terminalHeight: contentHeight, + bgCode: bgCode, + reset: reset + ) + diffWriter.writeContentDiff( + newLines: outputLines, + terminal: terminal, + startRow: 1 + ) // End lifecycle tracking - triggers onDisappear for removed views. // End state tracking - removes state for views no longer in the tree. tuiContext.lifecycle.endRenderPass() tuiContext.stateStorage.endRenderPass() - // Render status bar separately (never dimmed) + // Render status bar separately (never dimmed, own diff tracking) if statusBar.hasItems { - renderStatusBar(atRow: terminal.height - statusBarHeight + 1) + renderStatusBar( + atRow: terminalHeight - statusBarHeight + 1, + terminalWidth: terminalWidth, + bgCode: bgCode, + reset: reset + ) } } + /// Invalidates the diff cache, forcing a full repaint on the next render. + /// + /// Call this when the terminal is resized (SIGWINCH). + func invalidateDiffCache() { + diffWriter.invalidate() + } + // MARK: - Environment Assembly /// Builds a complete ``EnvironmentValues`` with all managed subsystems. @@ -152,18 +216,33 @@ internal struct RenderLoop { // MARK: - Private Helpers /// Renders a scene by delegating to ``SceneRenderable``. - private func renderScene(_ scene: S, context: RenderContext) { + /// + /// - Returns: The rendered ``FrameBuffer``, or an empty buffer if the + /// scene does not conform to ``SceneRenderable``. + private func renderScene(_ scene: S, context: RenderContext) -> FrameBuffer { if let renderable = scene as? SceneRenderable { - renderable.renderScene(context: context) + return renderable.renderScene(context: context) } + return FrameBuffer() } /// Renders the status bar at the specified terminal row. /// /// The status bar gets its own render context because its available - /// height differs from the main content area. Theme background is - /// applied line-by-line with ANSI code injection. - private func renderStatusBar(atRow row: Int) { + /// height differs from the main content area. Output is diffed + /// independently from the main content via ``FrameDiffWriter``. + /// + /// - Parameters: + /// - row: The 1-based terminal row where the status bar starts. + /// - terminalWidth: The current terminal width. + /// - bgCode: The ANSI background color code. + /// - reset: The ANSI reset code. + private func renderStatusBar( + atRow row: Int, + terminalWidth: Int, + bgCode: String, + reset: String + ) { // Use palette colors for status bar (if not explicitly overridden) let palette = buildEnvironment().palette let highlightColor = @@ -186,7 +265,7 @@ internal struct RenderLoop { let context = RenderContext( terminal: terminal, - availableWidth: terminal.width, + availableWidth: terminalWidth, availableHeight: statusBarView.height, environment: environment, tuiContext: tuiContext @@ -194,23 +273,18 @@ internal struct RenderLoop { let buffer = renderToBuffer(statusBarView, context: context) - // Get background color from palette - let bgColor = paletteManager.currentPalette?.background ?? .black - let bgCode = ANSIRenderer.backgroundCode(for: bgColor) - let reset = ANSIRenderer.reset - let terminalWidth = terminal.width - - // Write status bar with theme background - for (index, line) in buffer.lines.enumerated() { - terminal.moveCursor(toRow: row + index, column: 1) - - let visibleWidth = line.strippedLength - let padding = max(0, terminalWidth - visibleWidth) - - // Replace all reset codes with "reset + restore background" - let lineWithBg = line.replacingOccurrences(of: reset, with: reset + bgCode) - let paddedLine = bgCode + lineWithBg + String(repeating: " ", count: padding) + reset - terminal.write(paddedLine) - } + // Build terminal-ready output lines and write only changes + let outputLines = diffWriter.buildOutputLines( + buffer: buffer, + terminalWidth: terminalWidth, + terminalHeight: buffer.lines.count, + bgCode: bgCode, + reset: reset + ) + diffWriter.writeStatusBarDiff( + newLines: outputLines, + terminal: terminal, + startRow: row + ) } } diff --git a/Sources/TUIkit/App/SignalManager.swift b/Sources/TUIkit/App/SignalManager.swift index f505e25..a2d4908 100644 --- a/Sources/TUIkit/App/SignalManager.swift +++ b/Sources/TUIkit/App/SignalManager.swift @@ -25,6 +25,12 @@ import Foundation /// module would be cleaner but requires macOS 15+. nonisolated(unsafe) private var signalNeedsRerender = false +/// Flag set by the SIGWINCH signal handler to indicate a terminal resize. +/// +/// Separate from `signalNeedsRerender` because resize requires additional +/// work (invalidating the frame diff cache) beyond just re-rendering. +nonisolated(unsafe) private var signalTerminalResized = false + /// Flag set by the SIGINT signal handler to request a graceful shutdown. /// /// The actual cleanup (disabling raw mode, restoring cursor, exiting @@ -57,19 +63,32 @@ internal struct SignalManager { signalNeedsShutdown } - /// Checks and resets the rerender flag (SIGWINCH). + /// Checks and resets the rerender flag (SIGWINCH or state change). /// /// Returns `true` if a re-render was requested since the last call, /// then resets the flag. This consume-on-read pattern prevents /// redundant renders. /// - /// - Returns: `true` if a terminal resize triggered a rerender request. + /// - Returns: `true` if a rerender was requested. mutating func consumeRerenderFlag() -> Bool { guard signalNeedsRerender else { return false } signalNeedsRerender = false return true } + /// Checks and resets the terminal resize flag (SIGWINCH). + /// + /// Returns `true` if the terminal was resized since the last call, + /// then resets the flag. Used by ``AppRunner`` to invalidate the + /// frame diff cache on resize. + /// + /// - Returns: `true` if a terminal resize occurred. + mutating func consumeResizeFlag() -> Bool { + guard signalTerminalResized else { return false } + signalTerminalResized = false + return true + } + /// Requests a re-render programmatically. /// /// Called by the `AppState` observer to signal that application @@ -91,6 +110,7 @@ internal struct SignalManager { } signal(SIGWINCH) { _ in signalNeedsRerender = true + signalTerminalResized = true } } } diff --git a/Sources/TUIkit/Rendering/FrameDiffWriter.swift b/Sources/TUIkit/Rendering/FrameDiffWriter.swift new file mode 100644 index 0000000..97c1f5a --- /dev/null +++ b/Sources/TUIkit/Rendering/FrameDiffWriter.swift @@ -0,0 +1,210 @@ +// +// FrameDiffWriter.swift +// TUIkit +// +// Converts FrameBuffers to terminal-ready output lines and writes +// only the lines that changed since the previous frame. +// + +// MARK: - Frame Diff Writer + +/// Compares rendered frames and writes only changed lines to the terminal. +/// +/// `FrameDiffWriter` is the core of TUIKit's render optimization. Instead +/// of rewriting every terminal line on every frame, it stores the previous +/// frame's output and only writes lines that actually differ. +/// +/// For a mostly-static UI (e.g. a menu with one animating spinner), this +/// reduces terminal writes from ~50 lines per frame to just 1–3 lines +/// (~94% reduction). +/// +/// ## Usage +/// +/// ```swift +/// let writer = FrameDiffWriter() +/// +/// // Each frame: +/// let outputLines = writer.buildOutputLines(buffer: buffer, ...) +/// writer.writeDiff(newLines: outputLines, terminal: terminal, startRow: 1) +/// +/// // On terminal resize: +/// writer.invalidate() +/// ``` +final class FrameDiffWriter { + /// The previous frame's content lines (terminal-ready strings with ANSI codes). + private var previousContentLines: [String] = [] + + /// The previous frame's status bar lines. + private var previousStatusBarLines: [String] = [] + + // MARK: - Output Line Building + + /// Converts a ``FrameBuffer`` into terminal-ready output lines. + /// + /// Each output line includes: + /// - Background color applied through ANSI reset codes + /// - Padding to fill the terminal width + /// - Reset code at the end + /// + /// Lines beyond the buffer's content are filled with background-colored + /// spaces. The returned array always has exactly `terminalHeight` entries. + /// + /// This is a **pure function** — it has no side effects and produces + /// the same output for the same inputs. + /// + /// - Parameters: + /// - buffer: The rendered frame buffer. + /// - terminalWidth: The terminal width in characters. + /// - terminalHeight: The number of rows to fill. + /// - bgCode: The ANSI background color code. + /// - reset: The ANSI reset code. + /// - Returns: An array of terminal-ready strings, one per row. + func buildOutputLines( + buffer: FrameBuffer, + terminalWidth: Int, + terminalHeight: Int, + bgCode: String, + reset: String + ) -> [String] { + var lines: [String] = [] + lines.reserveCapacity(terminalHeight) + + let emptyLine = bgCode + String(repeating: " ", count: terminalWidth) + reset + + for row in 0.. [Int] { + var changedRows: [Int] = [] + for row in 0..= previousLines.count || previousLines[row] != newLines[row] { + changedRows.append(row) + } + } + return changedRows + } + + // MARK: - Private + + /// Compares two line arrays and writes only the differing lines. + /// + /// - Parameters: + /// - newLines: The current frame's lines. + /// - previousLines: The previous frame's lines. + /// - terminal: The terminal to write to. + /// - startRow: The 1-based terminal row offset. + private func writeDiff( + newLines: [String], + previousLines: [String], + terminal: Terminal, + startRow: Int + ) { + for row in 0.. newLines.count { + for row in newLines.count..