mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
BREAKING CHANGE: Package name changed due to name collision with existing rensbreur/SwiftTUI package. Changes: - Rename package from SwiftTUI to TUIKit in Package.swift - Rename Sources/SwiftTUI to Sources/TUIKit - Rename Sources/SwiftTUIExample to Sources/TUIKitExample - Rename Tests/SwiftTUITests to Tests/TUIKitTests - Rename SwiftTUI.swift to TUIKit.swift - Update all imports: import SwiftTUI -> import TUIKit - Update all code references: SwiftTUI.renderToBuffer -> TUIKit.renderToBuffer - Update documentation comments - Rename swiftTUIVersion to tuiKitVersion All 181 tests passing.
248 lines
8.3 KiB
Swift
248 lines
8.3 KiB
Swift
//
|
|
// FrameBuffer.swift
|
|
// TUIKit
|
|
//
|
|
// A 2D text buffer for off-screen rendering before terminal output.
|
|
//
|
|
|
|
/// A 2D text buffer that views render into before flushing to the terminal.
|
|
///
|
|
/// `FrameBuffer` enables a two-pass rendering approach:
|
|
/// 1. Each view renders into its own buffer (measuring its size)
|
|
/// 2. Layout containers combine child buffers (horizontally, vertically, or layered)
|
|
/// 3. The final root buffer is flushed to the terminal
|
|
///
|
|
/// Each line in the buffer is a string that may contain ANSI escape codes.
|
|
public struct FrameBuffer {
|
|
/// The lines of rendered content (may contain ANSI escape codes).
|
|
public var lines: [String]
|
|
|
|
/// The width of the buffer (the length of the longest line in visible characters).
|
|
public var width: Int {
|
|
lines.map { $0.strippedLength }.max() ?? 0
|
|
}
|
|
|
|
/// The height of the buffer (number of lines).
|
|
public var height: Int {
|
|
lines.count
|
|
}
|
|
|
|
/// Whether the buffer is empty.
|
|
public var isEmpty: Bool {
|
|
lines.isEmpty || lines.allSatisfy { $0.isEmpty }
|
|
}
|
|
|
|
/// Creates an empty buffer.
|
|
public init() {
|
|
self.lines = []
|
|
}
|
|
|
|
/// Creates a buffer from an array of lines.
|
|
///
|
|
/// - Parameter lines: The text lines.
|
|
public init(lines: [String]) {
|
|
self.lines = lines
|
|
}
|
|
|
|
/// Creates a buffer containing a single line.
|
|
///
|
|
/// - Parameter text: The text content.
|
|
public init(text: String) {
|
|
self.lines = [text]
|
|
}
|
|
|
|
/// Creates an empty buffer with the specified height.
|
|
///
|
|
/// - Parameter height: The number of empty lines.
|
|
public init(emptyWithHeight height: Int) {
|
|
self.lines = Array(repeating: "", count: height)
|
|
}
|
|
|
|
// MARK: - Combining Buffers
|
|
|
|
/// Stacks another buffer below this one with optional spacing.
|
|
///
|
|
/// - Parameters:
|
|
/// - other: The buffer to append below.
|
|
/// - spacing: Number of empty lines between the two buffers.
|
|
public mutating func appendVertically(_ other: FrameBuffer, spacing: Int = 0) {
|
|
if !lines.isEmpty && !other.isEmpty && spacing > 0 {
|
|
lines.append(contentsOf: Array(repeating: "", count: spacing))
|
|
}
|
|
lines.append(contentsOf: other.lines)
|
|
}
|
|
|
|
/// Places another buffer to the right of this one with optional spacing.
|
|
///
|
|
/// - Parameters:
|
|
/// - other: The buffer to append to the right.
|
|
/// - spacing: Number of space characters between the two buffers.
|
|
public mutating func appendHorizontally(_ other: FrameBuffer, spacing: Int = 0) {
|
|
let maxHeight = max(height, other.height)
|
|
let myWidth = width
|
|
let spacer = String(repeating: " ", count: spacing)
|
|
|
|
var result: [String] = []
|
|
for row in 0..<maxHeight {
|
|
let left = row < lines.count ? lines[row] : ""
|
|
let right = row < other.lines.count ? other.lines[row] : ""
|
|
|
|
// Pad the left side to consistent visible width
|
|
let leftPadded = left.padToVisibleWidth(myWidth)
|
|
result.append(leftPadded + spacer + right)
|
|
}
|
|
lines = result
|
|
}
|
|
|
|
/// Layers another buffer on top of this one (ZStack behavior).
|
|
///
|
|
/// Non-empty characters in the overlay replace characters in the base.
|
|
/// For simplicity, this just overlays line by line.
|
|
///
|
|
/// - Parameter overlay: The buffer to overlay on top.
|
|
public mutating func overlay(_ overlay: FrameBuffer) {
|
|
let maxHeight = max(height, overlay.height)
|
|
var result: [String] = []
|
|
for row in 0..<maxHeight {
|
|
if row < overlay.lines.count && !overlay.lines[row].isEmpty {
|
|
result.append(overlay.lines[row])
|
|
} else if row < lines.count {
|
|
result.append(lines[row])
|
|
} else {
|
|
result.append("")
|
|
}
|
|
}
|
|
lines = result
|
|
}
|
|
|
|
/// Creates a new buffer with another buffer composited on top at the specified position.
|
|
///
|
|
/// This performs character-level compositing: overlay characters replace base characters
|
|
/// only where the overlay has visible content (non-space characters).
|
|
///
|
|
/// - Parameters:
|
|
/// - overlay: The buffer to composite on top.
|
|
/// - position: The (x, y) offset where the overlay should be placed.
|
|
/// - Returns: A new buffer with the overlay composited.
|
|
public func composited(with overlay: FrameBuffer, at position: (x: Int, y: Int)) -> FrameBuffer {
|
|
guard !overlay.isEmpty else { return self }
|
|
|
|
let resultWidth = max(width, position.x + overlay.width)
|
|
let resultHeight = max(height, position.y + overlay.height)
|
|
|
|
var result: [String] = []
|
|
|
|
for row in 0..<resultHeight {
|
|
// Get the base line (padded to result width)
|
|
var baseLine: String
|
|
if row < lines.count {
|
|
baseLine = lines[row].padToVisibleWidth(resultWidth)
|
|
} else {
|
|
baseLine = String(repeating: " ", count: resultWidth)
|
|
}
|
|
|
|
// Check if this row has overlay content
|
|
let overlayRow = row - position.y
|
|
if overlayRow >= 0 && overlayRow < overlay.lines.count {
|
|
let overlayLine = overlay.lines[overlayRow]
|
|
if !overlayLine.isEmpty {
|
|
// Insert overlay content at the x position
|
|
baseLine = insertOverlay(
|
|
base: baseLine,
|
|
overlay: overlayLine,
|
|
atColumn: position.x
|
|
)
|
|
}
|
|
}
|
|
|
|
result.append(baseLine)
|
|
}
|
|
|
|
return FrameBuffer(lines: result)
|
|
}
|
|
|
|
/// Inserts overlay text into base text at the specified column position.
|
|
///
|
|
/// - Parameters:
|
|
/// - base: The base text line.
|
|
/// - overlay: The overlay text to insert.
|
|
/// - column: The column position (0-based).
|
|
/// - Returns: The composited line.
|
|
private func insertOverlay(base: String, overlay: String, atColumn column: Int) -> String {
|
|
// For ANSI-safe insertion, we work with visible characters
|
|
let baseChars = Array(base.stripped)
|
|
let overlayStripped = overlay.stripped
|
|
|
|
// Build result: characters before overlay position + overlay + characters after
|
|
var result = ""
|
|
|
|
// Add characters before the overlay position
|
|
if column > 0 {
|
|
let prefixEnd = min(column, baseChars.count)
|
|
result += String(baseChars[0..<prefixEnd])
|
|
// Pad if needed
|
|
if prefixEnd < column {
|
|
result += String(repeating: " ", count: column - prefixEnd)
|
|
}
|
|
}
|
|
|
|
// Add the overlay (with its ANSI codes intact)
|
|
result += overlay
|
|
|
|
// Add characters after the overlay
|
|
let afterOverlayColumn = column + overlayStripped.count
|
|
if afterOverlayColumn < baseChars.count {
|
|
result += String(baseChars[afterOverlayColumn...])
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - Combining Arrays
|
|
|
|
/// Creates a vertically stacked buffer from an array of buffers.
|
|
///
|
|
/// TupleViews use this to combine their children vertically by default
|
|
/// (the parent stack then decides the actual layout direction).
|
|
///
|
|
/// - Parameter buffers: The buffers to stack vertically.
|
|
public init(verticallyStacking buffers: [FrameBuffer]) {
|
|
self.init()
|
|
for buffer in buffers {
|
|
appendVertically(buffer)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - String Helpers
|
|
|
|
extension String {
|
|
/// The visible length of the string, excluding ANSI escape codes.
|
|
var strippedLength: Int {
|
|
stripped.count
|
|
}
|
|
|
|
/// The string with all ANSI escape codes removed.
|
|
var stripped: String {
|
|
replacingOccurrences(
|
|
of: "\u{1B}\\[[0-9;]*[a-zA-Z]",
|
|
with: "",
|
|
options: .regularExpression
|
|
)
|
|
}
|
|
|
|
/// Pads the string to the specified visible width using spaces.
|
|
///
|
|
/// ANSI codes are excluded from the width calculation.
|
|
///
|
|
/// - Parameter targetWidth: The desired visible width.
|
|
/// - Returns: The padded string.
|
|
func padToVisibleWidth(_ targetWidth: Int) -> String {
|
|
let currentWidth = strippedLength
|
|
if currentWidth >= targetWidth {
|
|
return self
|
|
}
|
|
return self + String(repeating: " ", count: targetWidth - currentWidth)
|
|
}
|
|
}
|