From 26e667f2dcf7eee9d8a9b626b6dafbf3d6db622d Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 9 Feb 2026 17:57:27 +0100 Subject: [PATCH] Feat: Add LazyVStack and LazyHStack with lazy rendering --- Sources/TUIkit/Views/LazyStacks.swift | 304 ++++++++++++++++++ Tests/TUIkitTests/LazyStacksTests.swift | 229 +++++++++++++ .../2026-02-07-view-architecture-refactor.md | 6 +- 3 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 Sources/TUIkit/Views/LazyStacks.swift create mode 100644 Tests/TUIkitTests/LazyStacksTests.swift diff --git a/Sources/TUIkit/Views/LazyStacks.swift b/Sources/TUIkit/Views/LazyStacks.swift new file mode 100644 index 0000000..faf307b --- /dev/null +++ b/Sources/TUIkit/Views/LazyStacks.swift @@ -0,0 +1,304 @@ +// TUIKit - Terminal UI Kit for Swift +// LazyStacks.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - LazyVStack + +/// A view that arranges its children in a line that grows vertically, +/// creating items only as needed. +/// +/// Unlike ``VStack``, which renders all views immediately, `LazyVStack` +/// only renders views when they become visible. In a terminal context, +/// this means views outside the available height are not rendered. +/// +/// Use `LazyVStack` when you have a large number of items or want to +/// defer rendering of offscreen content. +/// +/// # Example +/// +/// ```swift +/// ScrollView { +/// LazyVStack { +/// ForEach(1...1000, id: \.self) { i in +/// Text("Row \(i)") +/// } +/// } +/// } +/// ``` +/// +/// - Note: In TUIKit's terminal context, lazy rendering is based on +/// `availableHeight` in the render context. Items beyond this height +/// are not rendered until they scroll into view. +public struct LazyVStack: View { + /// The horizontal alignment of the children. + public let alignment: HorizontalAlignment + + /// The vertical spacing between children. + public let spacing: Int + + /// The content of the stack. + public let content: Content + + /// Creates a lazy vertical stack with the specified options. + /// + /// - Parameters: + /// - alignment: The horizontal alignment of children (default: .center). + /// - spacing: The spacing between children in lines (default: 0). + /// - content: A ViewBuilder that defines the children. + public init( + alignment: HorizontalAlignment = .center, + spacing: Int = 0, + @ViewBuilder content: () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + public var body: some View { + _LazyVStackCore(alignment: alignment, spacing: spacing, content: content) + } +} + +// MARK: - Internal LazyVStack Core + +/// Internal view that handles the actual rendering of LazyVStack. +private struct _LazyVStackCore: View, Renderable { + let alignment: HorizontalAlignment + let spacing: Int + let content: Content + + var body: Never { + fatalError("_LazyVStackCore renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + let infos = resolveChildInfos(from: content, context: context) + + // Lazy rendering: only render items that fit within availableHeight + let availableHeight = context.availableHeight + + // Spacer distribution (same as VStack) + let spacerCount = infos.filter(\.isSpacer).count + let fixedHeight = infos.compactMap(\.buffer).reduce(0) { $0 + $1.height } + let totalSpacing = max(0, infos.count - 1) * spacing + + let availableForSpacers = max(0, availableHeight - fixedHeight - totalSpacing) + let spacerHeight = spacerCount > 0 ? availableForSpacers / spacerCount : 0 + let spacerRemainder = spacerCount > 0 ? availableForSpacers % spacerCount : 0 + + let childMaxWidth = infos.compactMap(\.buffer).map(\.width).max() ?? 0 + let maxWidth = spacerCount > 0 ? context.availableWidth : childMaxWidth + + var result = FrameBuffer() + var currentHeight = 0 + var spacerIndex = 0 + + for (index, info) in infos.enumerated() { + let spacingToApply = index > 0 ? spacing : 0 + + if info.isSpacer { + let extraHeight = spacerIndex < spacerRemainder ? 1 : 0 + let height = max(info.spacerMinLength ?? 0, spacerHeight + extraHeight) + + // Lazy: check if spacer fits + if currentHeight + spacingToApply + height > availableHeight { + break + } + + result.appendVertically(FrameBuffer(emptyWithHeight: height), spacing: spacingToApply) + currentHeight += spacingToApply + height + spacerIndex += 1 + } else if let buffer = info.buffer { + // Lazy: check if item fits + if currentHeight + spacingToApply + buffer.height > availableHeight { + break + } + + let alignedBuffer = alignBuffer(buffer, toWidth: maxWidth, alignment: alignment) + result.appendVertically(alignedBuffer, spacing: spacingToApply) + currentHeight += spacingToApply + buffer.height + } + } + + return result + } + + /// Aligns a buffer horizontally within the given width. + private func alignBuffer(_ buffer: FrameBuffer, toWidth width: Int, alignment: HorizontalAlignment) -> FrameBuffer { + guard buffer.width < width else { return buffer } + + var alignedLines: [String] = [] + + let bufferOffset: Int + switch alignment { + case .leading: + bufferOffset = 0 + case .center: + bufferOffset = (width - buffer.width) / 2 + case .trailing: + bufferOffset = width - buffer.width + } + + let leftPadding = String(repeating: " ", count: bufferOffset) + let rightPaddingCount = width - bufferOffset - buffer.width + + for line in buffer.lines { + let lineWidth = line.strippedLength + let paddedLine = line + String(repeating: " ", count: max(0, buffer.width - lineWidth)) + alignedLines.append(leftPadding + paddedLine + String(repeating: " ", count: max(0, rightPaddingCount))) + } + + return FrameBuffer(lines: alignedLines) + } +} + +// MARK: - LazyHStack + +/// A view that arranges its children in a line that grows horizontally, +/// creating items only as needed. +/// +/// Unlike ``HStack``, which renders all views immediately, `LazyHStack` +/// only renders views when they become visible. In a terminal context, +/// this means views outside the available width are not rendered. +/// +/// Use `LazyHStack` when you have a large number of items or want to +/// defer rendering of offscreen content. +/// +/// # Example +/// +/// ```swift +/// ScrollView(.horizontal) { +/// LazyHStack { +/// ForEach(1...1000, id: \.self) { i in +/// Text("Column \(i)") +/// } +/// } +/// } +/// ``` +/// +/// - Note: In TUIKit's terminal context, lazy rendering is based on +/// `availableWidth` in the render context. Items beyond this width +/// are not rendered until they scroll into view. +public struct LazyHStack: View { + /// The vertical alignment of the children. + public let alignment: VerticalAlignment + + /// The horizontal spacing between children. + public let spacing: Int + + /// The content of the stack. + public let content: Content + + /// Creates a lazy horizontal stack with the specified options. + /// + /// - Parameters: + /// - alignment: The vertical alignment of children (default: .center). + /// - spacing: The spacing between children in characters (default: 1). + /// - content: A ViewBuilder that defines the children. + public init( + alignment: VerticalAlignment = .center, + spacing: Int = 1, + @ViewBuilder content: () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + public var body: some View { + _LazyHStackCore(alignment: alignment, spacing: spacing, content: content) + } +} + +// MARK: - Internal LazyHStack Core + +/// Internal view that handles the actual rendering of LazyHStack. +private struct _LazyHStackCore: View, Renderable { + let alignment: VerticalAlignment + let spacing: Int + let content: Content + + var body: Never { + fatalError("_LazyHStackCore renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + let infos = resolveChildInfos(from: content, context: context) + + // Lazy rendering: only render items that fit within availableWidth + let availableWidth = context.availableWidth + + // Spacer distribution (same as HStack) + let spacerCount = infos.filter(\.isSpacer).count + let fixedWidth = infos.compactMap(\.buffer).reduce(0) { $0 + $1.width } + let totalSpacing = max(0, infos.count - 1) * spacing + + let availableForSpacers = max(0, availableWidth - fixedWidth - totalSpacing) + let spacerWidth = spacerCount > 0 ? availableForSpacers / spacerCount : 0 + let spacerRemainder = spacerCount > 0 ? availableForSpacers % spacerCount : 0 + + var result = FrameBuffer() + var currentWidth = 0 + var spacerIndex = 0 + + for (index, info) in infos.enumerated() { + let spacingToApply = index > 0 ? spacing : 0 + + if info.isSpacer { + let extraWidth = spacerIndex < spacerRemainder ? 1 : 0 + let width = max(info.spacerMinLength ?? 0, spacerWidth + extraWidth) + + // Lazy: check if spacer fits + if currentWidth + spacingToApply + width > availableWidth { + break + } + + let maxHeight = infos.compactMap(\.buffer).map(\.height).max() ?? 1 + let spacerBuffer = FrameBuffer( + lines: Array( + repeating: String(repeating: " ", count: width), + count: maxHeight + ) + ) + result.appendHorizontally(spacerBuffer, spacing: spacingToApply) + currentWidth += spacingToApply + width + spacerIndex += 1 + } else if let buffer = info.buffer { + // Lazy: check if item fits + if currentWidth + spacingToApply + buffer.width > availableWidth { + break + } + + result.appendHorizontally(buffer, spacing: spacingToApply) + currentWidth += spacingToApply + buffer.width + } + } + + return result + } +} + +// MARK: - Equatable Conformances + +extension LazyVStack: Equatable where Content: Equatable { + nonisolated public static func == (lhs: LazyVStack, rhs: LazyVStack) -> Bool { + MainActor.assumeIsolated { + lhs.alignment == rhs.alignment && + lhs.spacing == rhs.spacing && + lhs.content == rhs.content + } + } +} + +extension LazyHStack: Equatable where Content: Equatable { + nonisolated public static func == (lhs: LazyHStack, rhs: LazyHStack) -> Bool { + MainActor.assumeIsolated { + lhs.alignment == rhs.alignment && + lhs.spacing == rhs.spacing && + lhs.content == rhs.content + } + } +} diff --git a/Tests/TUIkitTests/LazyStacksTests.swift b/Tests/TUIkitTests/LazyStacksTests.swift new file mode 100644 index 0000000..cbf1d80 --- /dev/null +++ b/Tests/TUIkitTests/LazyStacksTests.swift @@ -0,0 +1,229 @@ +// TUIKit - Terminal UI Kit for Swift +// LazyStacksTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Testing + +@testable import TUIkit + +// MARK: - Test Helpers + +@MainActor +private func testContext(width: Int = 40, height: Int = 24) -> RenderContext { + RenderContext(availableWidth: width, availableHeight: height) +} + +// MARK: - LazyVStack Tests + +@MainActor +@Suite("LazyVStack Tests") +struct LazyVStackTests { + + @Test("LazyVStack renders children vertically") + func rendersVertically() { + let stack = LazyVStack { + Text("Line 1") + Text("Line 2") + Text("Line 3") + } + + let context = testContext() + let buffer = renderToBuffer(stack, context: context) + + #expect(buffer.height == 3) + #expect(buffer.lines[0].contains("Line 1")) + #expect(buffer.lines[1].contains("Line 2")) + #expect(buffer.lines[2].contains("Line 3")) + } + + @Test("LazyVStack respects spacing") + func respectsSpacing() { + let stack = LazyVStack(spacing: 1) { + Text("A") + Text("B") + } + + let context = testContext() + let buffer = renderToBuffer(stack, context: context) + + // 1 line + 1 spacing + 1 line = 3 lines + #expect(buffer.height == 3) + } + + @Test("LazyVStack respects alignment") + func respectsAlignment() { + let stackLeading = LazyVStack(alignment: .leading) { + Text("Short") + Text("Much Longer") + } + + let stackTrailing = LazyVStack(alignment: .trailing) { + Text("Short") + Text("Much Longer") + } + + let context = testContext() + let leadingBuffer = renderToBuffer(stackLeading, context: context) + let trailingBuffer = renderToBuffer(stackTrailing, context: context) + + // Leading: "Short" starts at same position as "Much Longer" + let leadingLine1 = leadingBuffer.lines[0].stripped + let leadingLine2 = leadingBuffer.lines[1].stripped + #expect(!leadingLine1.hasPrefix(" ") || leadingLine1.hasPrefix(leadingLine2.prefix(1))) + + // Trailing: "Short" ends at same position as "Much Longer" + let trailingLine1 = trailingBuffer.lines[0].stripped + #expect(trailingLine1.hasSuffix("Short")) + } + + @Test("LazyVStack truncates at availableHeight") + func truncatesAtAvailableHeight() { + let stack = LazyVStack { + Text("Line 1") + Text("Line 2") + Text("Line 3") + Text("Line 4") + Text("Line 5") + } + + // Only 3 lines available + let context = testContext(height: 3) + let buffer = renderToBuffer(stack, context: context) + + // Should only render 3 lines + #expect(buffer.height == 3) + #expect(buffer.lines[0].contains("Line 1")) + #expect(buffer.lines[1].contains("Line 2")) + #expect(buffer.lines[2].contains("Line 3")) + } + + @Test("LazyVStack with empty content returns empty buffer") + func emptyContent() { + let stack = LazyVStack { + EmptyView() + } + + let context = testContext() + let buffer = renderToBuffer(stack, context: context) + + #expect(buffer.isEmpty) + } +} + +// MARK: - LazyHStack Tests + +@MainActor +@Suite("LazyHStack Tests") +struct LazyHStackTests { + + @Test("LazyHStack renders children horizontally") + func rendersHorizontally() { + let stack = LazyHStack { + Text("A") + Text("B") + Text("C") + } + + let context = testContext() + let buffer = renderToBuffer(stack, context: context) + + #expect(buffer.height == 1) + let line = buffer.lines[0].stripped + #expect(line.contains("A")) + #expect(line.contains("B")) + #expect(line.contains("C")) + } + + @Test("LazyHStack respects spacing") + func respectsSpacing() { + let stackNoSpacing = LazyHStack(spacing: 0) { + Text("A") + Text("B") + } + + let stackWithSpacing = LazyHStack(spacing: 3) { + Text("A") + Text("B") + } + + let context = testContext() + let noSpacingBuffer = renderToBuffer(stackNoSpacing, context: context) + let withSpacingBuffer = renderToBuffer(stackWithSpacing, context: context) + + // With spacing should be wider + #expect(withSpacingBuffer.width > noSpacingBuffer.width) + } + + @Test("LazyHStack truncates at availableWidth") + func truncatesAtAvailableWidth() { + let stack = LazyHStack(spacing: 1) { + Text("AAA") + Text("BBB") + Text("CCC") + Text("DDD") + Text("EEE") + } + + // Only 10 chars available (AAA + space + BBB = 7, can't fit CCC) + let context = testContext(width: 10) + let buffer = renderToBuffer(stack, context: context) + + let line = buffer.lines[0].stripped + #expect(line.contains("AAA")) + #expect(line.contains("BBB")) + #expect(!line.contains("CCC")) + } + + @Test("LazyHStack with empty content returns empty buffer") + func emptyContent() { + let stack = LazyHStack { + EmptyView() + } + + let context = testContext() + let buffer = renderToBuffer(stack, context: context) + + #expect(buffer.isEmpty) + } +} + +// MARK: - Equatable Tests + +@MainActor +@Suite("LazyStack Equatable Tests") +struct LazyStackEquatableTests { + + @Test("LazyVStack is Equatable when content is Equatable") + func lazyVStackEquatable() { + let stack1 = LazyVStack(alignment: .leading, spacing: 2) { + Text("Hello") + } + let stack2 = LazyVStack(alignment: .leading, spacing: 2) { + Text("Hello") + } + let stack3 = LazyVStack(alignment: .trailing, spacing: 2) { + Text("Hello") + } + + #expect(stack1 == stack2) + #expect(stack1 != stack3) + } + + @Test("LazyHStack is Equatable when content is Equatable") + func lazyHStackEquatable() { + let stack1 = LazyHStack(alignment: .top, spacing: 3) { + Text("World") + } + let stack2 = LazyHStack(alignment: .top, spacing: 3) { + Text("World") + } + let stack3 = LazyHStack(alignment: .bottom, spacing: 3) { + Text("World") + } + + #expect(stack1 == stack2) + #expect(stack1 != stack3) + } +} diff --git a/plans/open/2026-02-07-view-architecture-refactor.md b/plans/open/2026-02-07-view-architecture-refactor.md index d03cbcf..c62659c 100644 --- a/plans/open/2026-02-07-view-architecture-refactor.md +++ b/plans/open/2026-02-07-view-architecture-refactor.md @@ -320,9 +320,9 @@ Controls that need StateStorage/FocusManager: - [x] Tests pass for each (757 tests) ### Add LazyStacks -- [ ] LazyVStack: Implement with lazy rendering -- [ ] LazyHStack: Implement with lazy rendering -- [ ] Add tests for LazyStacks +- [x] LazyVStack: Implement with lazy rendering +- [x] LazyHStack: Implement with lazy rendering +- [x] Add tests for LazyStacks (11 tests) ### Performance Verification - [ ] Benchmark render performance before/after