From 37cd4b88f513dca37bee729a84c2fca100729508 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 28 Jan 2026 23:24:35 +0100 Subject: [PATCH] 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 --- Sources/TUIKit/Core/Theme.swift | 16 + Sources/TUIKit/Views/Alert.swift | 105 +++--- Sources/TUIKit/Views/Card.swift | 187 +++++++++-- Sources/TUIKit/Views/ContainerView.swift | 403 +++++++++++++++++++++++ Sources/TUIKit/Views/Dialog.swift | 143 ++++++-- Sources/TUIKit/Views/Panel.swift | 202 +++++++----- 6 files changed, 881 insertions(+), 175 deletions(-) create mode 100644 Sources/TUIKit/Views/ContainerView.swift diff --git a/Sources/TUIKit/Core/Theme.swift b/Sources/TUIKit/Core/Theme.swift index 24063c7..77bdd79 100644 --- a/Sources/TUIKit/Core/Theme.swift +++ b/Sources/TUIKit/Core/Theme.swift @@ -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 diff --git a/Sources/TUIKit/Views/Alert.swift b/Sources/TUIKit/Views/Alert.swift index 086ff0e..b40cd4a 100644 --- a/Sources/TUIKit/Views/Alert.swift +++ b/Sources/TUIKit/Views/Alert.swift @@ -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: 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: 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: 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( title: title, message: message, - borderStyle: .rounded, borderColor: .yellow, titleColor: .yellow, actions: actions @@ -166,7 +202,6 @@ extension Alert { Alert( title: title, message: message, - borderStyle: .rounded, borderColor: .red, titleColor: .red, actions: actions @@ -188,7 +223,6 @@ extension Alert { Alert( title: title, message: message, - borderStyle: .rounded, borderColor: .cyan, titleColor: .cyan, actions: actions @@ -210,7 +244,6 @@ extension Alert { Alert( title: title, message: message, - borderStyle: .rounded, borderColor: .green, titleColor: .green, actions: actions @@ -226,7 +259,6 @@ extension Alert where Actions == EmptyView { Alert( title: title, message: message, - borderStyle: .rounded, borderColor: .yellow, titleColor: .yellow ) @@ -237,7 +269,6 @@ extension Alert where Actions == EmptyView { Alert( title: title, message: message, - borderStyle: .rounded, borderColor: .red, titleColor: .red ) @@ -248,7 +279,6 @@ extension Alert where Actions == EmptyView { Alert( title: title, message: message, - borderStyle: .rounded, borderColor: .cyan, titleColor: .cyan ) @@ -259,7 +289,6 @@ extension Alert where Actions == EmptyView { Alert( title: title, message: message, - borderStyle: .rounded, borderColor: .green, titleColor: .green ) diff --git a/Sources/TUIKit/Views/Card.swift b/Sources/TUIKit/Views/Card.swift index 9bb0999..bbd3d75 100644 --- a/Sources/TUIKit/Views/Card.swift +++ b/Sources/TUIKit/Views/Card.swift @@ -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: View { +public struct Card: 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: 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 } } diff --git a/Sources/TUIKit/Views/ContainerView.swift b/Sources/TUIKit/Views/ContainerView.swift new file mode 100644 index 0000000..cc57db5 --- /dev/null +++ b/Sources/TUIKit/Views/ContainerView.swift @@ -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: 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 + } +} diff --git a/Sources/TUIKit/Views/Dialog.swift b/Sources/TUIKit/Views/Dialog.swift index e1f3f41..27325a0 100644 --- a/Sources/TUIKit/Views/Dialog.swift +++ b/Sources/TUIKit/Views/Dialog.swift @@ -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: View { +public struct Dialog: 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: 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: 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 { - Dialog( + ) -> Dialog { + Dialog( title: title, borderStyle: .doubleLine, borderColor: borderColor, @@ -137,8 +218,8 @@ extension Dialog { borderColor: Color? = nil, titleColor: Color? = nil, @ViewBuilder content: () -> C - ) -> Dialog { - Dialog( + ) -> Dialog { + Dialog( 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 { diff --git a/Sources/TUIKit/Views/Panel.swift b/Sources/TUIKit/Views/Panel.swift index 973bb14..66e03df 100644 --- a/Sources/TUIKit/Views/Panel.swift +++ b/Sources/TUIKit/Views/Panel.swift @@ -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: View { - /// The title displayed in the top border. +public struct Panel: 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: 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: 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: 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) } }