mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user