Files
TUIkit/Sources/TUIKit/Modifiers/FrameModifier.swift
T
phranck 45e73faafb refactor: Rename package from SwiftTUI to TUIKit
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.
2026-01-28 19:32:09 +01:00

326 lines
10 KiB
Swift

//
// FrameModifier.swift
// TUIKit
//
// The .frame() modifier for setting explicit size constraints.
//
// MARK: - Frame Dimension
/// Represents a frame dimension that can be a fixed value or infinity.
public enum FrameDimension: Equatable, Sendable {
/// A fixed size in characters/lines.
case fixed(Int)
/// Expand to fill all available space.
case infinity
/// The special infinity value for frame constraints.
public static let max: FrameDimension = .infinity
}
// MARK: - Flexible Frame View
/// A view that applies flexible frame constraints to its content.
///
/// This view handles min/max constraints and renders content with
/// the appropriate available space.
public struct FlexibleFrameView<Content: View>: View {
let content: Content
let minWidth: Int?
let idealWidth: Int?
let maxWidth: FrameDimension?
let minHeight: Int?
let idealHeight: Int?
let maxHeight: FrameDimension?
let alignment: Alignment
public var body: Never {
fatalError("FlexibleFrameView renders via Renderable")
}
}
extension FlexibleFrameView: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
// Calculate the target width based on constraints
let targetWidth: Int
if let maxW = maxWidth {
switch maxW {
case .infinity:
targetWidth = context.availableWidth
case .fixed(let value):
targetWidth = min(value, context.availableWidth)
}
} else if let ideal = idealWidth {
targetWidth = min(ideal, context.availableWidth)
} else {
// No max constraint - render with available width, then size to content
targetWidth = context.availableWidth
}
// Calculate the target height based on constraints
let targetHeight: Int?
if let maxH = maxHeight {
switch maxH {
case .infinity:
targetHeight = context.availableHeight
case .fixed(let value):
targetHeight = min(value, context.availableHeight)
}
} else if let ideal = idealHeight {
targetHeight = min(ideal, context.availableHeight)
} else {
targetHeight = nil // Use intrinsic height
}
// Create context for content with constrained width
var contentContext = context
contentContext.availableWidth = targetWidth
if let height = targetHeight {
contentContext.availableHeight = height
}
// Render content
let buffer = TUIKit.renderToBuffer(content, context: contentContext)
// Apply minimum constraints
var finalWidth = buffer.width
var finalHeight = buffer.height
if let minW = minWidth {
finalWidth = max(finalWidth, minW)
}
if let minH = minHeight {
finalHeight = max(finalHeight, minH)
}
// Apply maximum constraints (expand to fill if infinity)
if let maxW = maxWidth, case .infinity = maxW {
finalWidth = context.availableWidth
}
if let maxH = maxHeight, case .infinity = maxH {
finalHeight = context.availableHeight
}
// If size matches buffer, return as-is
if finalWidth == buffer.width && finalHeight == buffer.height {
return buffer
}
// Otherwise, align content within the frame
return alignBuffer(buffer, toWidth: finalWidth, height: finalHeight)
}
/// Aligns buffer content within the target frame size.
private func alignBuffer(_ buffer: FrameBuffer, toWidth targetWidth: Int, height targetHeight: Int) -> FrameBuffer {
var result: [String] = []
// Calculate vertical offset for alignment
let verticalOffset: Int
switch alignment.vertical {
case .top:
verticalOffset = 0
case .center:
verticalOffset = max(0, (targetHeight - buffer.height) / 2)
case .bottom:
verticalOffset = max(0, targetHeight - buffer.height)
}
for row in 0..<targetHeight {
let contentRow = row - verticalOffset
let line: String
if contentRow >= 0 && contentRow < buffer.lines.count {
line = buffer.lines[contentRow]
} else {
line = ""
}
// Align horizontally within the frame
let aligned = alignHorizontally(line, toWidth: targetWidth)
result.append(aligned)
}
return FrameBuffer(lines: result)
}
/// Aligns a single line within the given width.
private func alignHorizontally(_ line: String, toWidth targetWidth: Int) -> String {
let visibleWidth = line.strippedLength
if visibleWidth >= targetWidth {
return line
}
let padding = targetWidth - visibleWidth
switch alignment.horizontal {
case .leading:
return line + String(repeating: " ", count: padding)
case .center:
let left = padding / 2
let right = padding - left
return String(repeating: " ", count: left) + line + String(repeating: " ", count: right)
case .trailing:
return String(repeating: " ", count: padding) + line
}
}
}
// MARK: - Fixed Frame Modifier (Legacy)
/// A modifier that constrains a view to a specific width and/or height.
///
/// Content is aligned within the frame according to the specified alignment.
public struct FrameModifier: ViewModifier {
/// The desired width (nil means intrinsic width).
public let width: Int?
/// The desired height (nil means intrinsic height).
public let height: Int?
/// The alignment of the content within the frame.
public let alignment: Alignment
public func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer {
let targetWidth = width ?? buffer.width
let targetHeight = height ?? buffer.height
var result: [String] = []
// Calculate vertical offset for alignment
let verticalOffset: Int
switch alignment.vertical {
case .top:
verticalOffset = 0
case .center:
verticalOffset = max(0, (targetHeight - buffer.height) / 2)
case .bottom:
verticalOffset = max(0, targetHeight - buffer.height)
}
for row in 0..<targetHeight {
let contentRow = row - verticalOffset
let line: String
if contentRow >= 0 && contentRow < buffer.lines.count {
line = buffer.lines[contentRow]
} else {
line = ""
}
// Align horizontally within the frame
let aligned = alignHorizontally(
line,
toWidth: targetWidth,
alignment: alignment.horizontal
)
result.append(aligned)
}
return FrameBuffer(lines: result)
}
/// Aligns a single line within the given width.
private func alignHorizontally(
_ line: String,
toWidth targetWidth: Int,
alignment: HorizontalAlignment
) -> String {
let visibleWidth = line.strippedLength
if visibleWidth >= targetWidth {
return line
}
let padding = targetWidth - visibleWidth
switch alignment {
case .leading:
return line + String(repeating: " ", count: padding)
case .center:
let left = padding / 2
let right = padding - left
return String(repeating: " ", count: left) + line + String(repeating: " ", count: right)
case .trailing:
return String(repeating: " ", count: padding) + line
}
}
}
// MARK: - View Extension
extension View {
/// Sets an explicit frame size for this view.
///
/// The content is aligned within the frame according to the specified alignment.
///
/// # Example
///
/// ```swift
/// Text("Hello")
/// .frame(width: 20, alignment: .center)
/// ```
///
/// - Parameters:
/// - width: The desired width in characters (nil preserves intrinsic width).
/// - height: The desired height in lines (nil preserves intrinsic height).
/// - alignment: The alignment within the frame (default: .topLeading).
/// - Returns: A view constrained to the specified frame.
public func frame(
width: Int? = nil,
height: Int? = nil,
alignment: Alignment = .topLeading
) -> ModifiedView<Self, FrameModifier> {
modifier(FrameModifier(width: width, height: height, alignment: alignment))
}
/// Sets flexible frame constraints for this view.
///
/// Use `.infinity` for maxWidth/maxHeight to expand to fill available space.
///
/// # Examples
///
/// ```swift
/// // Expand to full width
/// Text("Hello")
/// .frame(maxWidth: .infinity)
///
/// // Expand to full size
/// Color.blue
/// .frame(maxWidth: .infinity, maxHeight: .infinity)
///
/// // Minimum size with expansion
/// Text("Button")
/// .frame(minWidth: 10, maxWidth: .infinity)
/// ```
///
/// - Parameters:
/// - minWidth: Minimum width in characters.
/// - idealWidth: Preferred width (used when no max is set).
/// - maxWidth: Maximum width, or `.infinity` to fill available space.
/// - minHeight: Minimum height in lines.
/// - idealHeight: Preferred height (used when no max is set).
/// - maxHeight: Maximum height, or `.infinity` to fill available space.
/// - alignment: The alignment within the frame (default: .center).
/// - Returns: A view with flexible frame constraints.
public func frame(
minWidth: Int? = nil,
idealWidth: Int? = nil,
maxWidth: FrameDimension? = nil,
minHeight: Int? = nil,
idealHeight: Int? = nil,
maxHeight: FrameDimension? = nil,
alignment: Alignment = .center
) -> some View {
FlexibleFrameView(
content: self,
minWidth: minWidth,
idealWidth: idealWidth,
maxWidth: maxWidth,
minHeight: minHeight,
idealHeight: idealHeight,
maxHeight: maxHeight,
alignment: alignment
)
}
}