Feat: Add LazyVStack and LazyHStack with lazy rendering

This commit is contained in:
phranck
2026-02-09 17:57:27 +01:00
parent 7e7ad673ca
commit 26e667f2dc
3 changed files with 536 additions and 3 deletions
+304
View File
@@ -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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content>, rhs: LazyVStack<Content>) -> 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<Content>, rhs: LazyHStack<Content>) -> Bool {
MainActor.assumeIsolated {
lhs.alignment == rhs.alignment &&
lhs.spacing == rhs.spacing &&
lhs.content == rhs.content
}
}
}
+229
View File
@@ -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)
}
}
@@ -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