feat: Add ContainerView with Header/Body/Footer architecture

- Add ContainerView as unified base for container views
- Add ContainerStyle for separator and border configuration
- Add containerBackground and containerHeaderBackground to Theme
- Standard appearances: Title in top border, Footer as separate section
- Block appearance: Separate Header section with darker background
- Migrate Panel, Card, Alert, Dialog to use ContainerView
- All views now support optional footer with separator
This commit is contained in:
phranck
2026-01-28 23:24:35 +01:00
parent fc0be05e90
commit 37cd4b88f5
6 changed files with 881 additions and 175 deletions
+16
View File
@@ -104,6 +104,14 @@ public protocol Theme: Sendable {
/// Status bar shortcut highlight color.
var statusBarHighlight: Color { get }
// MARK: - Container Colors (for block appearance)
/// Container body background (used in block appearance).
var containerBackground: Color { get }
/// Container header/footer background (darker than body, used in block appearance).
var containerHeaderBackground: Color { get }
}
// MARK: - Default Theme Implementation
@@ -124,6 +132,8 @@ extension Theme {
public var statusBarBackground: Color { backgroundSecondary }
public var statusBarForeground: Color { foreground }
public var statusBarHighlight: Color { accent }
public var containerBackground: Color { backgroundSecondary }
public var containerHeaderBackground: Color { backgroundTertiary }
}
// MARK: - Theme Environment Key
@@ -252,6 +262,12 @@ public enum ThemeColors {
/// Status bar highlight.
public static var statusBarHighlight: Color { current.statusBarHighlight }
/// Container body background (for block appearance).
public static var containerBackground: Color { current.containerBackground }
/// Container header/footer background (for block appearance).
public static var containerHeaderBackground: Color { current.containerHeaderBackground }
}
// MARK: - Theme Modifier
+67 -38
View File
@@ -2,7 +2,7 @@
// Alert.swift
// TUIKit
//
// A modal alert view with title, message, and optional actions.
// A modal alert view with title, message, and optional action buttons.
//
/// A modal alert view that displays a title, message, and optional action buttons.
@@ -10,16 +10,22 @@
/// `Alert` is designed to be shown as an overlay on top of other content.
/// Use it together with `.overlay()` and `.dimmed()` for a modal effect.
///
/// # Example
/// ## Structure
///
/// - **Header**: Title (in border for standard appearances, separate section for block)
/// - **Body**: Message
/// - **Footer**: Action buttons (separated by optional separator line)
///
/// ## Examples
///
/// ```swift
/// // Simple alert
/// Alert(title: "Warning", message: "Are you sure?")
///
/// // Alert with custom actions
/// // Alert with action buttons
/// Alert(title: "Confirm", message: "Delete this item?") {
/// Text("[Yes]")
/// Text("[No]")
/// Button("Yes") { }
/// Button("No") { }
/// }
///
/// // Modal overlay pattern
@@ -44,8 +50,11 @@ public struct Alert<Actions: View>: View {
/// The title color.
public let titleColor: Color?
/// Whether to show a separator before the action buttons.
public let showFooterSeparator: Bool
/// The action views (typically buttons or styled text).
/// The action views (typically buttons).
public let actions: Actions
/// Creates an alert with custom action views.
@@ -56,13 +65,15 @@ public struct Alert<Actions: View>: View {
/// - borderStyle: The border style (default: appearance borderStyle).
/// - borderColor: The border color (default: theme border).
/// - titleColor: The title color (default: theme foreground).
/// - actions: The action views to display below the message.
/// - showFooterSeparator: Whether to show separator before actions (default: true).
/// - actions: The action views to display in the footer.
public init(
title: String,
message: String,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil,
titleColor: Color? = nil,
showFooterSeparator: Bool = true,
@ViewBuilder actions: () -> Actions
) {
self.title = title
@@ -70,32 +81,57 @@ public struct Alert<Actions: View>: View {
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = titleColor
self.showFooterSeparator = showFooterSeparator
self.actions = actions()
}
public var body: some View {
VStack(spacing: 1) {
// Title
if let color = titleColor {
Text(title)
.bold()
.foregroundColor(color)
} else {
Text(title)
.bold()
public var body: Never {
fatalError("Alert renders via Renderable")
}
}
// MARK: - Rendering
extension Alert: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
let containerStyle = ContainerStyle(
showHeaderSeparator: true,
showFooterSeparator: showFooterSeparator,
borderStyle: borderStyle,
borderColor: borderColor
)
// Check if actions is EmptyView (no actions)
let hasActions = !(actions is EmptyView)
if hasActions {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: containerStyle,
padding: EdgeInsets(horizontal: 2, vertical: 1)
) {
Text(message)
} footer: {
actions
}
// Message
Text(message)
// Empty line between message and actions
Text("")
// Actions (if any)
actions
return container.renderToBuffer(context: context)
} else {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: ContainerStyle(
showHeaderSeparator: true,
showFooterSeparator: false,
borderStyle: borderStyle,
borderColor: borderColor
),
padding: EdgeInsets(horizontal: 2, vertical: 1)
) {
Text(message)
}
return container.renderToBuffer(context: context)
}
.padding(EdgeInsets(horizontal: 2, vertical: 1))
.border(borderStyle, color: borderColor)
}
}
@@ -107,13 +143,13 @@ extension Alert where Actions == EmptyView {
/// - Parameters:
/// - title: The alert title.
/// - message: The alert message.
/// - borderStyle: The border style (default: .rounded).
/// - borderStyle: The border style (default: appearance default).
/// - borderColor: The border color (default: nil).
/// - titleColor: The title color (default: nil).
public init(
title: String,
message: String,
borderStyle: BorderStyle = .rounded,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil,
titleColor: Color? = nil
) {
@@ -122,6 +158,7 @@ extension Alert where Actions == EmptyView {
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = titleColor
self.showFooterSeparator = false
self.actions = EmptyView()
}
}
@@ -144,7 +181,6 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .yellow,
titleColor: .yellow,
actions: actions
@@ -166,7 +202,6 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .red,
titleColor: .red,
actions: actions
@@ -188,7 +223,6 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .cyan,
titleColor: .cyan,
actions: actions
@@ -210,7 +244,6 @@ extension Alert {
Alert<A>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .green,
titleColor: .green,
actions: actions
@@ -226,7 +259,6 @@ extension Alert where Actions == EmptyView {
Alert<EmptyView>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .yellow,
titleColor: .yellow
)
@@ -237,7 +269,6 @@ extension Alert where Actions == EmptyView {
Alert<EmptyView>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .red,
titleColor: .red
)
@@ -248,7 +279,6 @@ extension Alert where Actions == EmptyView {
Alert<EmptyView>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .cyan,
titleColor: .cyan
)
@@ -259,7 +289,6 @@ extension Alert where Actions == EmptyView {
Alert<EmptyView>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .green,
titleColor: .green
)
+165 -22
View File
@@ -2,44 +2,198 @@
// Card.swift
// TUIKit
//
// A styled container view with border, background, and padding.
// A styled container view with optional header, content, and footer.
//
/// A container view that displays content in a card-like appearance.
///
/// `Card` combines border, background, and padding into a single
/// convenient container. It's useful for grouping related content.
/// convenient container. It supports optional title (header) and footer.
///
/// # Example
/// ## Structure
///
/// - **Header**: Optional title (in border for standard appearances, separate section for block)
/// - **Body**: Main content
/// - **Footer**: Optional, typically buttons
///
/// ## Examples
///
/// ```swift
/// // Simple card (no title)
/// Card {
/// Text("Card Title")
/// .bold()
/// Text("Card content goes here")
/// }
///
/// // Card with title
/// Card(title: "Card Title") {
/// Text("Card content")
/// }
///
/// // Card with title and footer
/// Card(title: "User Info") {
/// Text("Name: John")
/// Text("Email: john@example.com")
/// } footer: {
/// Button("Edit") { }
/// }
///
/// // Styled card
/// Card(borderStyle: .doubleLine, borderColor: .cyan) {
/// Text("Styled Card")
/// }
/// ```
public struct Card<Content: View>: View {
public struct Card<Content: View, Footer: View>: View {
/// The card title (optional).
public let title: String?
/// The content of the card.
public let content: Content
/// The footer content (optional).
public let footer: Footer?
/// The border style (nil uses appearance default).
public let borderStyle: BorderStyle?
/// The border color.
public let borderColor: Color?
/// The title color.
public let titleColor: Color?
/// The background color (nil for transparent).
public let backgroundColor: Color?
/// The padding inside the card.
public let padding: EdgeInsets
/// Whether to show a separator before the footer.
public let showFooterSeparator: Bool
/// Creates a card with the specified styling.
/// Creates a card with all options including footer.
///
/// - Parameters:
/// - title: The title (optional).
/// - borderStyle: The border style (default: appearance borderStyle).
/// - borderColor: The border color (default: theme border).
/// - titleColor: The title color (default: theme accent).
/// - backgroundColor: The background color (default: nil).
/// - padding: The inner padding (default: 1 on all sides).
/// - showFooterSeparator: Whether to show separator before footer (default: true).
/// - content: The content of the card.
/// - footer: The footer content.
public init(
title: String? = nil,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil,
titleColor: Color? = nil,
backgroundColor: Color? = nil,
padding: EdgeInsets = EdgeInsets(all: 1),
showFooterSeparator: Bool = true,
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.title = title
self.content = content()
self.footer = footer()
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = titleColor
self.backgroundColor = backgroundColor
self.padding = padding
self.showFooterSeparator = showFooterSeparator
}
public var body: Never {
fatalError("Card renders via Renderable")
}
}
// MARK: - Rendering
extension Card: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
let containerStyle = ContainerStyle(
showHeaderSeparator: true,
showFooterSeparator: showFooterSeparator,
borderStyle: borderStyle,
borderColor: borderColor
)
// Wrap content with background if specified
let bodyContent: AnyView
if let bgColor = backgroundColor {
bodyContent = AnyView(content.background(bgColor))
} else {
bodyContent = AnyView(content)
}
if let footerView = footer {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: containerStyle,
padding: padding
) {
bodyContent
} footer: {
footerView
}
return container.renderToBuffer(context: context)
} else {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: containerStyle,
padding: padding
) {
bodyContent
}
return container.renderToBuffer(context: context)
}
}
}
// MARK: - Convenience Initializer (no footer)
extension Card where Footer == EmptyView {
/// Creates a card without a footer.
///
/// - Parameters:
/// - title: The title (optional).
/// - borderStyle: The border style (default: appearance borderStyle).
/// - borderColor: The border color (default: theme border).
/// - titleColor: The title color (default: theme accent).
/// - backgroundColor: The background color (default: nil).
/// - padding: The inner padding (default: 1 on all sides).
/// - content: The content of the card.
public init(
title: String? = nil,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil,
titleColor: Color? = nil,
backgroundColor: Color? = nil,
padding: EdgeInsets = EdgeInsets(all: 1),
@ViewBuilder content: () -> Content
) {
self.title = title
self.content = content()
self.footer = nil
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = titleColor
self.backgroundColor = backgroundColor
self.padding = padding
self.showFooterSeparator = false
}
}
// MARK: - Convenience Initializer (no title, no footer - backward compatible)
extension Card where Footer == EmptyView {
/// Creates a simple card without title or footer.
///
/// This is the most basic card form, just wrapping content in a border.
///
/// - Parameters:
/// - borderStyle: The border style (default: appearance borderStyle).
@@ -54,25 +208,14 @@ public struct Card<Content: View>: View {
padding: EdgeInsets = EdgeInsets(all: 1),
@ViewBuilder content: () -> Content
) {
self.title = nil
self.content = content()
self.footer = nil
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = nil
self.backgroundColor = backgroundColor
self.padding = padding
}
public var body: some View {
// Build the card by composing modifiers
// Note: .border() with nil style uses appearance default
if let bgColor = backgroundColor {
content
.padding(padding)
.background(bgColor)
.border(borderStyle, color: borderColor)
} else {
content
.padding(padding)
.border(borderStyle, color: borderColor)
}
self.showFooterSeparator = false
}
}
+403
View File
@@ -0,0 +1,403 @@
//
// 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
for line in bodyBuffer.lines {
let paddedLine = line.padToVisibleWidth(innerWidth)
lines.append(leftBorder + paddedLine + 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
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 filled backgrounds (block appearance).
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
let bodyBackground = Color.theme.containerBackground
let headerFooterBackground = Color.theme.containerHeaderBackground
// Vertical border character
let verticalBorder = String(borderStyle.vertical)
// Helper to render a line with background
func renderLine(_ content: String, background: Color) -> String {
let paddedContent = content.padToVisibleWidth(innerWidth)
let leftBorder = colorize(verticalBorder, with: borderColor, backgroundColor: background)
let rightBorder = colorize(verticalBorder, with: borderColor, backgroundColor: background)
let styledContent = applyBackground(paddedContent, background: background)
return leftBorder + styledContent + reset + rightBorder
}
// Helper to render a full-width separator
func renderSeparator() -> String {
colorize(
String(repeating: borderStyle.horizontal, count: innerWidth + 2),
with: borderColor,
backgroundColor: bodyBackground
)
}
// Top border
let topLine = colorize(
String(repeating: borderStyle.horizontal, count: innerWidth + 2),
with: borderColor,
backgroundColor: headerFooterBackground
)
lines.append(topLine)
// Header section (if title present)
if let titleText = title {
let titleStyled = colorize(" \(titleText) ", with: titleColor ?? Color.theme.accent, bold: true)
let paddedTitle = titleStyled.padToVisibleWidth(innerWidth)
lines.append(renderLine(paddedTitle, background: headerFooterBackground))
// Header separator
if style.showHeaderSeparator {
lines.append(renderSeparator())
}
}
// Body lines
for line in bodyBuffer.lines {
lines.append(renderLine(line, background: bodyBackground))
}
// Footer section (if present)
if let footerBuf = footerBuffer, !footerBuf.isEmpty {
// Footer separator
if style.showFooterSeparator {
lines.append(renderSeparator())
}
// Footer lines
for line in footerBuf.lines {
lines.append(renderLine(line, background: headerFooterBackground))
}
}
// Bottom border
let bottomBackground = (footer != nil && footerBuffer?.isEmpty == false) ? headerFooterBackground : bodyBackground
let bottomLine = colorize(
String(repeating: borderStyle.horizontal, count: innerWidth + 2),
with: borderColor,
backgroundColor: bottomBackground
)
lines.append(bottomLine)
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 (preserving existing foreground styling).
private func applyBackground(_ string: String, background: Color) -> String {
// This is a simplified version - in reality we'd need to parse
// existing ANSI codes and inject background. For now, we wrap.
let bgCode = ANSIRenderer.backgroundCode(for: background)
return "\u{1B}[\(bgCode)m" + string
}
}
+112 -31
View File
@@ -2,15 +2,21 @@
// Dialog.swift
// TUIKit
//
// A modal dialog view with title and custom content.
// A modal dialog view with title, custom content, and optional footer.
//
/// A modal dialog view with a title and customizable content.
/// A modal dialog view with a title, customizable content, and optional footer.
///
/// `Dialog` is more flexible than `Alert` it accepts any content,
/// making it suitable for forms, selections, or complex interactions.
///
/// # Example
/// ## Structure
///
/// - **Header**: Title (in border for standard appearances, separate section for block)
/// - **Body**: Custom content
/// - **Footer**: Optional, typically buttons (separated by optional separator line)
///
/// ## Examples
///
/// ```swift
/// // Simple dialog
@@ -19,12 +25,15 @@
/// Text("Option 2: Disabled")
/// }
///
/// // Dialog with custom styling
/// Dialog(title: "User Profile", borderStyle: .doubleLine, titleColor: .cyan) {
/// // Dialog with footer buttons
/// Dialog(title: "User Profile") {
/// Text("Name: John Doe")
/// Text("Email: john@example.com")
/// Divider()
/// Text("[Edit] [Close]")
/// } footer: {
/// ButtonRow {
/// Button("Edit") { }
/// Button("Close") { }
/// }
/// }
///
/// // Modal overlay pattern
@@ -33,20 +42,23 @@
/// .overlay {
/// Dialog(title: "Confirm Action") {
/// Text("Are you sure you want to proceed?")
/// HStack {
/// Text("[Yes]").foregroundColor(.green)
/// Spacer()
/// Text("[No]").foregroundColor(.red)
/// } footer: {
/// ButtonRow {
/// Button("Yes") { }
/// Button("No") { }
/// }
/// }
/// }
/// ```
public struct Dialog<Content: View>: View {
public struct Dialog<Content: View, Footer: View>: View {
/// The dialog title.
public let title: String
/// The dialog content.
public let content: Content
/// The footer content (typically buttons).
public let footer: Footer?
/// The border style (nil uses appearance default).
public let borderStyle: BorderStyle?
@@ -59,8 +71,87 @@ public struct Dialog<Content: View>: View {
/// The inner padding.
public let padding: EdgeInsets
/// Whether to show a separator before the footer.
public let showFooterSeparator: Bool
/// Creates a dialog with the specified options.
/// Creates a dialog with content and footer.
///
/// - Parameters:
/// - title: The dialog title.
/// - borderStyle: The border style (default: appearance borderStyle).
/// - borderColor: The border color (default: theme border).
/// - titleColor: The title color (default: theme foreground).
/// - padding: The inner padding (default: horizontal 2, vertical 1).
/// - showFooterSeparator: Whether to show separator before footer (default: true).
/// - content: The dialog content.
/// - footer: The footer content.
public init(
title: String,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil,
titleColor: Color? = nil,
padding: EdgeInsets = EdgeInsets(horizontal: 2, vertical: 1),
showFooterSeparator: Bool = true,
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.title = title
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = titleColor
self.padding = padding
self.showFooterSeparator = showFooterSeparator
self.content = content()
self.footer = footer()
}
public var body: Never {
fatalError("Dialog renders via Renderable")
}
}
// MARK: - Rendering
extension Dialog: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
let containerStyle = ContainerStyle(
showHeaderSeparator: true,
showFooterSeparator: showFooterSeparator,
borderStyle: borderStyle,
borderColor: borderColor
)
if let footerView = footer {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: containerStyle,
padding: padding
) {
content
} footer: {
footerView
}
return container.renderToBuffer(context: context)
} else {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: containerStyle,
padding: padding
) {
content
}
return container.renderToBuffer(context: context)
}
}
}
// MARK: - Convenience Initializer (no footer)
extension Dialog where Footer == EmptyView {
/// Creates a dialog without a footer.
///
/// - Parameters:
/// - title: The dialog title.
@@ -82,25 +173,15 @@ public struct Dialog<Content: View>: View {
self.borderColor = borderColor
self.titleColor = titleColor
self.padding = padding
self.showFooterSeparator = false
self.content = content()
}
public var body: some View {
Panel(
title,
borderStyle: borderStyle,
borderColor: borderColor,
titleColor: titleColor,
padding: padding
) {
content
}
self.footer = nil
}
}
// MARK: - Convenience Extensions
extension Dialog {
extension Dialog where Footer == EmptyView {
/// Creates a dialog with a double-line border style.
///
/// - Parameters:
@@ -114,8 +195,8 @@ extension Dialog {
borderColor: Color? = nil,
titleColor: Color? = nil,
@ViewBuilder content: () -> C
) -> Dialog<C> {
Dialog<C>(
) -> Dialog<C, EmptyView> {
Dialog<C, EmptyView>(
title: title,
borderStyle: .doubleLine,
borderColor: borderColor,
@@ -137,8 +218,8 @@ extension Dialog {
borderColor: Color? = nil,
titleColor: Color? = nil,
@ViewBuilder content: () -> C
) -> Dialog<C> {
Dialog<C>(
) -> Dialog<C, EmptyView> {
Dialog<C, EmptyView>(
title: title,
borderStyle: .heavy,
borderColor: borderColor,
@@ -156,7 +237,7 @@ extension View {
/// This is a convenience method that combines `.dimmed()` and `.overlay()`
/// with center alignment.
///
/// # Example
/// ## Example
///
/// ```swift
/// mainContent.modal {
+118 -84
View File
@@ -2,33 +2,56 @@
// Panel.swift
// TUIKit
//
// A titled container view with a header.
// A titled container view with optional footer.
//
/// A bordered container with a title in the top border.
/// A bordered container with a title and optional footer.
///
/// `Panel` is useful for grouping content with a visible label,
/// similar to a fieldset in HTML or a group box in desktop UIs.
///
/// # Example
/// ## Behavior by Appearance
///
/// - **Standard appearances** (line, rounded, doubleLine, heavy):
/// Title is rendered IN the top border.
///
/// - **Block appearance**:
/// Title becomes a separate header section with darker background.
///
/// ## Examples
///
/// ```swift
/// // Simple panel
/// Panel("Settings") {
/// Text("Option 1")
/// Text("Option 2")
/// }
///
/// Panel("User Info", borderStyle: .doubleLine, titleColor: .cyan) {
/// // Panel with footer
/// Panel("User Info") {
/// Text("Name: John")
/// Text("Age: 30")
/// } footer: {
/// ButtonRow {
/// Button("Save") { }
/// Button("Cancel") { }
/// }
/// }
///
/// // Customized panel
/// Panel("Settings", borderStyle: .doubleLine, titleColor: .cyan) {
/// Text("Content")
/// }
/// ```
public struct Panel<Content: View>: View {
/// The title displayed in the top border.
public struct Panel<Content: View, Footer: View>: View {
/// The title displayed in the header/border.
public let title: String
/// The content of the panel.
public let content: Content
/// The footer content (typically buttons).
public let footer: Footer?
/// The border style (nil uses appearance default).
public let borderStyle: BorderStyle?
@@ -39,10 +62,66 @@ public struct Panel<Content: View>: View {
/// The title color.
public let titleColor: Color?
/// The padding inside the panel.
/// The padding inside the panel body.
public let padding: EdgeInsets
/// Whether to show a separator before the footer.
public let showFooterSeparator: Bool
/// Creates a panel with the specified options.
/// Creates a panel with content and footer.
///
/// - Parameters:
/// - title: The title to display.
/// - borderStyle: The border style (default: appearance borderStyle).
/// - borderColor: The border color (default: theme border).
/// - titleColor: The title color (default: theme accent).
/// - padding: The inner padding (default: horizontal 1, vertical 0).
/// - showFooterSeparator: Whether to show separator before footer (default: true).
/// - content: The main content of the panel.
/// - footer: The footer content.
public init(
_ title: String,
borderStyle: BorderStyle? = nil,
borderColor: Color? = nil,
titleColor: Color? = nil,
padding: EdgeInsets = EdgeInsets(horizontal: 1, vertical: 0),
showFooterSeparator: Bool = true,
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.title = title
self.content = content()
self.footer = footer()
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = titleColor
self.padding = padding
self.showFooterSeparator = showFooterSeparator
}
public var body: some View {
ContainerView(
title: title,
titleColor: titleColor,
style: ContainerStyle(
showHeaderSeparator: true,
showFooterSeparator: showFooterSeparator,
borderStyle: borderStyle,
borderColor: borderColor
),
padding: padding
) {
content
} footer: {
footer!
}
}
}
// MARK: - Convenience Initializer (no footer)
extension Panel where Footer == EmptyView {
/// Creates a panel without a footer.
///
/// - Parameters:
/// - title: The title to display in the top border.
@@ -61,14 +140,12 @@ public struct Panel<Content: View>: View {
) {
self.title = title
self.content = content()
self.footer = nil
self.borderStyle = borderStyle
self.borderColor = borderColor
self.titleColor = titleColor
self.padding = padding
}
public var body: Never {
fatalError("Panel renders via Renderable")
self.showFooterSeparator = true
}
}
@@ -76,79 +153,36 @@ public struct Panel<Content: View>: View {
extension Panel: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
// Resolve border style - use explicit or fall back to appearance default
let effectiveBorderStyle = borderStyle ?? context.environment.appearance.borderStyle
// Create the ContainerView and render it
let containerStyle = ContainerStyle(
showHeaderSeparator: true,
showFooterSeparator: showFooterSeparator,
borderStyle: borderStyle,
borderColor: borderColor
)
// Render the content first
let paddedContent = content.padding(padding)
let contentBuffer = TUIKit.renderToBuffer(paddedContent, context: context)
guard !contentBuffer.isEmpty else {
return FrameBuffer()
if let footerView = footer {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: containerStyle,
padding: padding
) {
content
} footer: {
footerView
}
return container.renderToBuffer(context: context)
} else {
let container = ContainerView(
title: title,
titleColor: titleColor,
style: containerStyle,
padding: padding
) {
content
}
return container.renderToBuffer(context: context)
}
// Title with spaces: " Title "
let titleText = " \(title) "
let titleLength = titleText.count
// Inner width must fit both content and title (plus corner + one horizontal on each side)
// Top line structure: Title
// So minimum inner width = titleLength + 2 (for the on each side of title)
let innerWidth = max(contentBuffer.width, titleLength + 2)
// Build top border with title
// Format: Title
let titleStyled = colorize(titleText, with: titleColor ?? borderColor)
// Left part: corner + one horizontal
let leftPart = colorize(
String(effectiveBorderStyle.topLeft) + String(effectiveBorderStyle.horizontal),
with: borderColor
)
// Right part: remaining horizontals + corner
// Total top line width (excluding corners) = innerWidth
// Used by: 1 (left horizontal) + titleLength + rightPartLength = innerWidth
let rightPartLength = max(0, innerWidth - 1 - titleLength)
let rightPart = colorize(
String(repeating: effectiveBorderStyle.horizontal, count: rightPartLength) + String(effectiveBorderStyle.topRight),
with: borderColor
)
let topLine = leftPart + titleStyled + rightPart
// Build bottom border (innerWidth horizontals between corners)
let bottomLine = colorize(
String(effectiveBorderStyle.bottomLeft)
+ String(repeating: effectiveBorderStyle.horizontal, count: innerWidth)
+ String(effectiveBorderStyle.bottomRight),
with: borderColor
)
// Build result
var lines: [String] = []
lines.append(topLine)
// Content lines with side borders
// Important: Add reset before right border to prevent color bleeding
let reset = "\u{1B}[0m"
let leftBorder = colorize(String(effectiveBorderStyle.vertical), with: borderColor)
let rightBorder = colorize(String(effectiveBorderStyle.vertical), with: borderColor)
for line in contentBuffer.lines {
let paddedLine = line.padToVisibleWidth(innerWidth)
lines.append(leftBorder + paddedLine + reset + rightBorder)
}
lines.append(bottomLine)
return FrameBuffer(lines: lines)
}
/// Applies color to a string, using theme border color as default.
private func colorize(_ string: String, with color: Color?) -> String {
var style = TextStyle()
style.foregroundColor = color ?? Color.theme.border
return ANSIRenderer.render(string, with: style)
}
}