Files
TUIkit/Sources/TUIKit/Views/ContainerView.swift
T
phranck 2232fefc44 feat: Improve StatusBar, Buttons, and Theme styling for block appearance
- Add StatusBar.bordered style with theme colors and block appearance support
- Pass current environment to StatusBar rendering for dynamic theme updates
- Implement ButtonRow right-alignment with calculated left padding
- Add buttonBackground theme color for lighter buttons in block appearance
- Remove button borders and focus indicators for primary buttons in block appearance
- Add theme background colors to container body and button content
- Adjust theme colors: foregroundSecondary, statusBarForeground, buttonBackground
- Update Amber theme header/footer background to #1E110E
- Refactor Overlays demo to use real Button components with VStack layout
- Center alert modal with HStack and Spacer elements
2026-01-29 02:24:52 +01:00

419 lines
16 KiB
Swift

//
// ContainerView.swift
// TUIKit
//
// A unified container component with Header/Body/Footer architecture.
//
// MARK: - Container Style
/// Configuration options for container appearance.
///
/// Controls separators, backgrounds, and other visual aspects of containers.
public struct ContainerStyle: Sendable {
/// Whether to show a separator line between header and body.
///
/// Note: Only applies to `Appearance.block`. For other appearances,
/// the title is rendered in the top border.
public var showHeaderSeparator: Bool
/// Whether to show a separator line between body and footer.
public var showFooterSeparator: Bool
/// The border style (nil uses appearance default).
public var borderStyle: BorderStyle?
/// The border color (nil uses theme default).
public var borderColor: Color?
/// Creates a container style with the specified options.
///
/// - Parameters:
/// - showHeaderSeparator: Show separator after header (default: true).
/// - showFooterSeparator: Show separator before footer (default: true).
/// - borderStyle: The border style (default: appearance default).
/// - borderColor: The border color (default: theme border).
public init(
showHeaderSeparator: Bool = true,
showFooterSeparator: Bool = true,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil
) {
self.showHeaderSeparator = showHeaderSeparator
self.showFooterSeparator = showFooterSeparator
self.borderStyle = borderStyle
self.borderColor = borderColor
}
/// Default container style.
public static let `default` = ContainerStyle()
}
// MARK: - Container View
/// A unified container with optional header, body, and footer sections.
///
/// `ContainerView` provides a consistent structure for all container-type views
/// like Panel, Card, Alert, and Dialog. It handles the rendering logic for
/// borders, separators, and section backgrounds.
///
/// ## Behavior by Appearance
///
/// - **Standard appearances** (line, rounded, doubleLine, heavy):
/// Title is rendered IN the top border. Footer is a separate section.
///
/// - **Block appearance**:
/// Header is a separate section with darker background.
/// Body and border share the same background color.
/// Footer has darker background like header.
///
/// ## Example
///
/// ```swift
/// ContainerView(
/// title: "Settings",
/// style: ContainerStyle(showFooterSeparator: true)
/// ) {
/// Text("Option 1")
/// Text("Option 2")
/// } footer: {
/// ButtonRow {
/// Button("Save") { }
/// Button("Cancel") { }
/// }
/// }
/// ```
public struct ContainerView<Content: View, Footer: View>: View {
/// The container title (rendered in border or header section).
public let title: String?
/// The title color.
public let titleColor: Color?
/// The main content.
public let content: Content
/// The footer content (typically buttons).
public let footer: Footer?
/// The container style configuration.
public let style: ContainerStyle
/// The inner padding for the body.
public let padding: EdgeInsets
/// Creates a container with all options.
///
/// - Parameters:
/// - title: The title (optional).
/// - titleColor: The title color (default: theme accent).
/// - style: The container style configuration.
/// - padding: Inner padding for body content.
/// - content: The main content.
/// - footer: The footer content (optional).
public init(
title: String? = nil,
titleColor: Color? = nil,
style: ContainerStyle = .default,
padding: EdgeInsets = EdgeInsets(horizontal: 1, vertical: 0),
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.title = title
self.titleColor = titleColor
self.style = style
self.padding = padding
self.content = content()
self.footer = footer()
}
public var body: Never {
fatalError("ContainerView renders via Renderable")
}
}
// MARK: - Convenience Initializer (no footer)
extension ContainerView where Footer == EmptyView {
/// Creates a container without a footer.
///
/// - Parameters:
/// - title: The title (optional).
/// - titleColor: The title color (default: theme accent).
/// - style: The container style configuration.
/// - padding: Inner padding for body content.
/// - content: The main content.
public init(
title: String? = nil,
titleColor: Color? = nil,
style: ContainerStyle = .default,
padding: EdgeInsets = EdgeInsets(horizontal: 1, vertical: 0),
@ViewBuilder content: () -> Content
) {
self.title = title
self.titleColor = titleColor
self.style = style
self.padding = padding
self.content = content()
self.footer = nil
}
}
// MARK: - Rendering
extension ContainerView: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
let appearance = context.environment.appearance
let isBlockAppearance = appearance.id == .block
let effectiveBorderStyle = style.borderStyle ?? appearance.borderStyle
let borderColor = style.borderColor ?? Color.theme.border
// Render body content
let paddedContent = content.padding(padding)
let bodyBuffer = TUIKit.renderToBuffer(paddedContent, context: context)
// Render footer if present
let footerBuffer: FrameBuffer?
if let footerView = footer {
let paddedFooter = footerView.padding(EdgeInsets(horizontal: 1, vertical: 0))
footerBuffer = TUIKit.renderToBuffer(paddedFooter, context: context)
} else {
footerBuffer = nil
}
// Calculate inner width
let titleWidth = title.map { $0.count + 4 } ?? 0 // " Title " + borders
let bodyWidth = bodyBuffer.width
let footerWidth = footerBuffer?.width ?? 0
let innerWidth = max(titleWidth, bodyWidth, footerWidth)
if isBlockAppearance {
return renderBlockStyle(
bodyBuffer: bodyBuffer,
footerBuffer: footerBuffer,
innerWidth: innerWidth,
borderStyle: effectiveBorderStyle,
borderColor: borderColor,
context: context
)
} else {
return renderStandardStyle(
bodyBuffer: bodyBuffer,
footerBuffer: footerBuffer,
innerWidth: innerWidth,
borderStyle: effectiveBorderStyle,
borderColor: borderColor,
context: context
)
}
}
// MARK: - Standard Style Rendering
/// Renders with title in top border (line, rounded, doubleLine, heavy).
private func renderStandardStyle(
bodyBuffer: FrameBuffer,
footerBuffer: FrameBuffer?,
innerWidth: Int,
borderStyle: BorderStyle,
borderColor: Color,
context: RenderContext
) -> FrameBuffer {
var lines: [String] = []
let reset = "\u{1B}[0m"
// Top border (with title if present)
let topLine: String
if let titleText = title {
let titleStyled = colorize(" \(titleText) ", with: titleColor ?? Color.theme.accent, bold: true)
let leftPart = colorize(
String(borderStyle.topLeft) + String(borderStyle.horizontal),
with: borderColor
)
let rightPartLength = max(0, innerWidth - 1 - titleText.count - 2)
let rightPart = colorize(
String(repeating: borderStyle.horizontal, count: rightPartLength) + String(borderStyle.topRight),
with: borderColor
)
topLine = leftPart + titleStyled + rightPart
} else {
topLine = colorize(
String(borderStyle.topLeft)
+ String(repeating: borderStyle.horizontal, count: innerWidth)
+ String(borderStyle.topRight),
with: borderColor
)
}
lines.append(topLine)
// Vertical border characters
let leftBorder = colorize(String(borderStyle.vertical), with: borderColor)
let rightBorder = colorize(String(borderStyle.vertical), with: borderColor)
// Body lines with theme background
let bodyBg = context.environment.theme.containerBackground
for line in bodyBuffer.lines {
let paddedLine = line.padToVisibleWidth(innerWidth)
let styledContent = applyBackground(paddedLine, background: bodyBg)
lines.append(leftBorder + styledContent + reset + rightBorder)
}
// Footer section (if present)
if let footerBuf = footerBuffer, !footerBuf.isEmpty {
// Footer separator
if style.showFooterSeparator {
let separatorLine = colorize(
String(borderStyle.leftT)
+ String(repeating: borderStyle.horizontal, count: innerWidth)
+ String(borderStyle.rightT),
with: borderColor
)
lines.append(separatorLine)
}
// Footer lines (no background - footer has its own styling)
for line in footerBuf.lines {
let paddedLine = line.padToVisibleWidth(innerWidth)
lines.append(leftBorder + paddedLine + reset + rightBorder)
}
}
// Bottom border
let bottomLine = colorize(
String(borderStyle.bottomLeft)
+ String(repeating: borderStyle.horizontal, count: innerWidth)
+ String(borderStyle.bottomRight),
with: borderColor
)
lines.append(bottomLine)
return FrameBuffer(lines: lines)
}
// MARK: - Block Style Rendering
/// Renders with half-block characters for smooth visual edges.
///
/// Block style design:
/// ```
/// ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ← Top: ▄, FG = header BG, BG = App BG (transparent)
/// █ HEADER █ ← Sides: █, FG = header BG, content has header BG
/// ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ← Separator: ▀, FG = header BG, BG = body BG
/// █ BODY █ ← Body has container BG (slightly brighter)
/// █ █
/// ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ← Footer sep: ▄, FG = footer BG, BG = body BG
/// █ FOOTER █ ← Sides: █, FG = footer BG, content has footer BG
/// ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ← Bottom: ▀, FG = footer BG, BG = App BG (transparent)
/// ```
private func renderBlockStyle(
bodyBuffer: FrameBuffer,
footerBuffer: FrameBuffer?,
innerWidth: Int,
borderStyle: BorderStyle,
borderColor: Color,
context: RenderContext
) -> FrameBuffer {
var lines: [String] = []
let reset = "\u{1B}[0m"
// Get theme colors for block appearance
// Header/Footer = darker background
// Body = lighter background (containerBackground)
let headerFooterBg = Color.theme.containerHeaderBackground
let bodyBg = Color.theme.containerBackground
let hasHeader = title != nil
let hasFooter = footerBuffer != nil && !(footerBuffer?.isEmpty ?? true)
// === TOP BORDER ===
// ▄▄▄: FG = header/body BG, BG = transparent (App BG shows through)
let topLine = String(repeating: "▄", count: innerWidth + 2)
if hasHeader {
lines.append(colorize(topLine, with: headerFooterBg))
} else {
lines.append(colorize(topLine, with: bodyBg))
}
// === HEADER SECTION (if title present) ===
if let titleText = title {
// █ TITLE █: FG = header BG for █, content has header BG
let titleStyled = colorize(" \(titleText) ", with: titleColor ?? Color.theme.accent, bold: true)
let paddedTitle = titleStyled.padToVisibleWidth(innerWidth)
let sideBorder = colorize("█", with: headerFooterBg)
let styledContent = applyBackground(paddedTitle, background: headerFooterBg)
lines.append(sideBorder + styledContent + reset + sideBorder)
// Header/Body separator: ▀▀▀
// FG = header BG, BG = body BG (creates smooth transition)
if style.showHeaderSeparator {
let sepLine = String(repeating: "▀", count: innerWidth + 2)
lines.append(colorize(sepLine, with: headerFooterBg, backgroundColor: bodyBg))
}
}
// === BODY LINES ===
// █ Content █: FG = body BG for █, content has body BG
for line in bodyBuffer.lines {
let paddedLine = line.padToVisibleWidth(innerWidth)
let sideBorder = colorize("█", with: bodyBg)
let styledContent = applyBackground(paddedLine, background: bodyBg)
lines.append(sideBorder + styledContent + reset + sideBorder)
}
// === FOOTER SECTION (if present) ===
if let footerBuf = footerBuffer, !footerBuf.isEmpty {
// Body/Footer separator: ▄▄▄
// FG = footer BG, BG = body BG (creates smooth transition)
if style.showFooterSeparator {
let sepLine = String(repeating: "▄", count: innerWidth + 2)
lines.append(colorize(sepLine, with: headerFooterBg, backgroundColor: bodyBg))
}
// █ Footer █: FG = footer BG for █, content has footer BG
for line in footerBuf.lines {
let paddedLine = line.padToVisibleWidth(innerWidth)
let sideBorder = colorize("█", with: headerFooterBg)
let styledContent = applyBackground(paddedLine, background: headerFooterBg)
lines.append(sideBorder + styledContent + reset + sideBorder)
}
}
// === BOTTOM BORDER ===
// ▀▀▀: FG = footer/body BG, BG = transparent (App BG shows through)
let bottomLine = String(repeating: "▀", count: innerWidth + 2)
if hasFooter {
lines.append(colorize(bottomLine, with: headerFooterBg))
} else {
lines.append(colorize(bottomLine, with: bodyBg))
}
return FrameBuffer(lines: lines)
}
// MARK: - Helper Methods
/// Colorizes a string with foreground color and optional background.
private func colorize(
_ string: String,
with color: Color,
bold: Bool = false,
backgroundColor: Color? = nil
) -> String {
var style = TextStyle()
style.foregroundColor = color
style.backgroundColor = backgroundColor
style.isBold = bold
return ANSIRenderer.render(string, with: style)
}
/// Applies background color to a string, re-applying after any resets.
private func applyBackground(_ string: String, background: Color) -> String {
// ANSIRenderer.backgroundCode already returns a complete ANSI sequence
let bgCode = ANSIRenderer.backgroundCode(for: background)
// Replace any reset codes with reset + background to maintain the background
let resetCode = "\u{1B}[0m"
let stringWithPersistentBg = string.replacingOccurrences(of: resetCode, with: resetCode + bgCode)
return bgCode + stringWithPersistentBg
}
}