From 7ddac7067daa99ae84750a98f4e62d5ba65cb508 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 28 Jan 2026 15:35:55 +0100 Subject: [PATCH] feat: Complete SwiftTUI foundation with overlay system and menu Core Framework: - TView protocol with TViewBuilder result builder (up to 10 children) - Full ANSI rendering pipeline (Terminal, ANSIRenderer, FrameBuffer) - TApp/TScene/WindowGroup app lifecycle with SIGWINCH handling - Color system (ANSI, bright, 256-palette, RGB, hex, semantic colors) - Text styling (bold, italic, underline, strikethrough, dim, blink, inverted) Container Views: - VStack, HStack, ZStack with alignment and spacing - Card (bordered container with padding/background) - Box (simple bordered container) - Panel (titled container with title in border) - ForEach for dynamic content Modifiers: - .padding(), .frame(), .border() (8 border styles), .background() - .overlay() with alignment, .dimmed(), .modal() helper Overlay System: - Alert view with title, message, actions, and presets (warning/error/info/success) - Dialog view for flexible modal content - FrameBuffer character-level compositing Menu View: - Menu with items, selection indicator, shortcuts - MenuItem model with id, label, shortcut - AnyView type-erased wrapper Example App: - Menu-based navigation with multiple demo pages - Text Styles, Colors, Containers, Overlays, Layout demos Tests: - 58 tests across 12 suites, all passing --- Package.swift | 19 +- README.md | 76 +++ Sources/SwiftTUI/App/TApp.swift | 151 +++++ Sources/SwiftTUI/App/TScene.swift | 48 ++ Sources/SwiftTUI/Core/BorderStyle.swift | 187 ++++++ Sources/SwiftTUI/Core/Color.swift | 179 +++++ Sources/SwiftTUI/Core/PrimitiveViews.swift | 100 +++ Sources/SwiftTUI/Core/TView.swift | 37 ++ Sources/SwiftTUI/Core/TViewBuilder.swift | 192 ++++++ Sources/SwiftTUI/Core/TupleViews.swift | 141 ++++ Sources/SwiftTUI/Core/ViewModifier.swift | 71 ++ .../Modifiers/BackgroundModifier.swift | 81 +++ .../SwiftTUI/Modifiers/BorderModifier.swift | 106 +++ .../SwiftTUI/Modifiers/DimmedModifier.swift | 87 +++ .../SwiftTUI/Modifiers/FrameModifier.swift | 112 ++++ .../SwiftTUI/Modifiers/OverlayModifier.swift | 104 +++ .../SwiftTUI/Modifiers/PaddingModifier.swift | 164 +++++ Sources/SwiftTUI/Rendering/ANSIRenderer.swift | 203 ++++++ Sources/SwiftTUI/Rendering/FrameBuffer.swift | 247 +++++++ Sources/SwiftTUI/Rendering/Renderable.swift | 72 ++ Sources/SwiftTUI/Rendering/Terminal.swift | 210 ++++++ Sources/SwiftTUI/Rendering/ViewRenderer.swift | 403 ++++++++++++ Sources/SwiftTUI/SwiftTUI.swift | 42 +- Sources/SwiftTUI/Views/Alert.swift | 267 ++++++++ Sources/SwiftTUI/Views/Box.swift | 56 ++ Sources/SwiftTUI/Views/Card.swift | 77 +++ Sources/SwiftTUI/Views/Dialog.swift | 178 +++++ Sources/SwiftTUI/Views/ForEach.swift | 107 +++ Sources/SwiftTUI/Views/Menu.swift | 201 ++++++ Sources/SwiftTUI/Views/Panel.swift | 138 ++++ Sources/SwiftTUI/Views/Spacer.swift | 89 +++ Sources/SwiftTUI/Views/Stacks.swift | 230 +++++++ Sources/SwiftTUI/Views/Text.swift | 174 +++++ Sources/SwiftTUIExample/main.swift | 477 ++++++++++++++ Tests/SwiftTUITests/TViewTests.swift | 618 ++++++++++++++++++ 35 files changed, 5638 insertions(+), 6 deletions(-) create mode 100644 README.md create mode 100644 Sources/SwiftTUI/App/TApp.swift create mode 100644 Sources/SwiftTUI/App/TScene.swift create mode 100644 Sources/SwiftTUI/Core/BorderStyle.swift create mode 100644 Sources/SwiftTUI/Core/Color.swift create mode 100644 Sources/SwiftTUI/Core/PrimitiveViews.swift create mode 100644 Sources/SwiftTUI/Core/TView.swift create mode 100644 Sources/SwiftTUI/Core/TViewBuilder.swift create mode 100644 Sources/SwiftTUI/Core/TupleViews.swift create mode 100644 Sources/SwiftTUI/Core/ViewModifier.swift create mode 100644 Sources/SwiftTUI/Modifiers/BackgroundModifier.swift create mode 100644 Sources/SwiftTUI/Modifiers/BorderModifier.swift create mode 100644 Sources/SwiftTUI/Modifiers/DimmedModifier.swift create mode 100644 Sources/SwiftTUI/Modifiers/FrameModifier.swift create mode 100644 Sources/SwiftTUI/Modifiers/OverlayModifier.swift create mode 100644 Sources/SwiftTUI/Modifiers/PaddingModifier.swift create mode 100644 Sources/SwiftTUI/Rendering/ANSIRenderer.swift create mode 100644 Sources/SwiftTUI/Rendering/FrameBuffer.swift create mode 100644 Sources/SwiftTUI/Rendering/Renderable.swift create mode 100644 Sources/SwiftTUI/Rendering/Terminal.swift create mode 100644 Sources/SwiftTUI/Rendering/ViewRenderer.swift create mode 100644 Sources/SwiftTUI/Views/Alert.swift create mode 100644 Sources/SwiftTUI/Views/Box.swift create mode 100644 Sources/SwiftTUI/Views/Card.swift create mode 100644 Sources/SwiftTUI/Views/Dialog.swift create mode 100644 Sources/SwiftTUI/Views/ForEach.swift create mode 100644 Sources/SwiftTUI/Views/Menu.swift create mode 100644 Sources/SwiftTUI/Views/Panel.swift create mode 100644 Sources/SwiftTUI/Views/Spacer.swift create mode 100644 Sources/SwiftTUI/Views/Stacks.swift create mode 100644 Sources/SwiftTUI/Views/Text.swift create mode 100644 Sources/SwiftTUIExample/main.swift create mode 100644 Tests/SwiftTUITests/TViewTests.swift diff --git a/Package.swift b/Package.swift index 99c0b4d..1bbfe0e 100644 --- a/Package.swift +++ b/Package.swift @@ -5,19 +5,30 @@ import PackageDescription let package = Package( name: "SwiftTUI", + platforms: [ + .macOS(.v10_15) + ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "SwiftTUI", targets: ["SwiftTUI"] ), + .executable( + name: "SwiftTUIExample", + targets: ["SwiftTUIExample"] + ), ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( name: "SwiftTUI" ), - + .executableTarget( + name: "SwiftTUIExample", + dependencies: ["SwiftTUI"] + ), + .testTarget( + name: "SwiftTUITests", + dependencies: ["SwiftTUI"] + ), ] ) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5557f9 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +![Swift 6.2](https://img.shields.io/badge/Swift-6.2-F05138?logo=swift&logoColor=white) +![macOS](https://img.shields.io/badge/Platform-macOS-000000?logo=apple&logoColor=white) +![License](https://img.shields.io/badge/License-MIT-blue) +![Status](https://img.shields.io/badge/Status-Work_in_Progress-yellow) + +# SwiftTUI + +A SwiftUI-like framework for building Terminal User Interfaces in Swift — no ncurses, no C dependencies, just pure Swift. + +## What is this? + +SwiftTUI lets you build TUI apps using the same declarative syntax you already know from SwiftUI. Define your UI with `TView`, compose views with `VStack`, `HStack`, and `ZStack`, style text with modifiers like `.bold()` and `.foregroundColor(.red)`, and run it all in your terminal. + +```swift +struct ContentView: TView { + var body: some TView { + VStack(spacing: 1) { + Text("Hello, SwiftTUI!") + .bold() + .foregroundColor(.cyan) + Divider() + HStack { + Text("Status:") + Text("Running").foregroundColor(.green) + } + } + } +} +``` + +## Features + +- **`TView` protocol** — the core building block, mirroring SwiftUI's `View` +- **`@TViewBuilder`** — result builder for declarative view composition (up to 10 children, conditionals, optionals, loops) +- **Primitive views** — `Text`, `EmptyView`, `Spacer`, `Divider` +- **Layout containers** — `VStack`, `HStack`, `ZStack` with alignment and spacing +- **`ForEach`** — iterate over collections, ranges, or `Identifiable` data +- **Text styling** — bold, italic, underline, strikethrough, dim, blink, inverted +- **Full color support** — 8 standard ANSI colors, bright variants, 256-color palette, 24-bit RGB, hex values +- **Terminal abstraction** — raw mode, cursor control, alternate screen buffer +- **`TApp` protocol** — app lifecycle with signal handling and run loop + +## Run the Example App + +```bash +swift run SwiftTUIExample +``` + +Press `q` or `ESC` to exit. + +## Developer Notes + +- **Swift 6.2** with strict concurrency is required (swift-tools-version 6.2) +- **macOS only** — this is a terminal framework, iOS/watchOS/tvOS don't apply +- The rendering engine uses **pure ANSI escape codes** — no external dependencies +- `TView` is a **protocol** (not a class), so views are value types by default +- Primitive views (`Text`, `Spacer`, `Divider`, stacks, etc.) conform to the internal `Renderable` protocol for direct terminal output +- Composite views just define a `body` and the renderer walks the tree recursively +- The `Terminal` class handles raw mode, screen buffer switching, and cursor control via POSIX `termios` +- Tests use Swift Testing (`@Test`, `#expect`) — run with `swift test` + +## Project Structure + +``` +Sources/ +├── SwiftTUI/ +│ ├── App/ TApp, TScene, WindowGroup +│ ├── Core/ TView, TViewBuilder, Color, TupleViews, PrimitiveViews +│ ├── Rendering/ Terminal, ANSIRenderer, ViewRenderer, Renderable +│ └── Views/ Text, Stacks, Spacer, Divider, ForEach +└── SwiftTUIExample/ Example app (executable target) +``` + +## License + +MIT diff --git a/Sources/SwiftTUI/App/TApp.swift b/Sources/SwiftTUI/App/TApp.swift new file mode 100644 index 0000000..8e01e97 --- /dev/null +++ b/Sources/SwiftTUI/App/TApp.swift @@ -0,0 +1,151 @@ +// +// TApp.swift +// SwiftTUI +// +// The base protocol for SwiftTUI applications. +// + +import Foundation + +/// The base protocol for SwiftTUI applications. +/// +/// `TApp` is the entry point for every SwiftTUI application, +/// similar to `App` in SwiftUI. +/// +/// # Example +/// +/// ```swift +/// @main +/// struct MyApp: TApp { +/// var body: some TScene { +/// WindowGroup { +/// ContentView() +/// } +/// } +/// } +/// ``` +public protocol TApp { + /// The type of the main scene. + associatedtype Body: TScene + + /// The main scene of the app. + @SceneBuilder + var body: Body { get } + + /// Initializes the app. + init() +} + +extension TApp { + /// Starts the app. + /// + /// This method is called by the `@main` attribute and starts + /// the main run loop of the application. + public static func main() { + let app = Self() + let runner = AppRunner(app: app) + runner.run() + } +} + +/// Flag set by the SIGWINCH signal handler to request a re-render. +/// Must be an atomic type safe for signal context. +private nonisolated(unsafe) var needsRerender = false + +/// Runs a TApp. +internal final class AppRunner { + let app: App + let terminal: Terminal + private var isRunning = false + + init(app: App) { + self.app = app + self.terminal = Terminal.shared + } + + func run() { + // Setup + setupSignalHandlers() + terminal.enterAlternateScreen() + terminal.hideCursor() + terminal.enableRawMode() + + isRunning = true + + // Initial render + render() + + // Main loop + while isRunning { + // Check if terminal was resized + if needsRerender { + needsRerender = false + render() + } + + if let char = terminal.readChar() { + handleInput(char) + } + } + + // Cleanup + cleanup() + } + + private func render() { + terminal.clear() + let renderer = ViewRenderer(terminal: terminal) + + // Extract the root view from the scene + let scene = app.body + renderScene(scene, with: renderer) + } + + private func renderScene(_ scene: S, with renderer: ViewRenderer) { + if let renderable = scene as? SceneRenderable { + renderable.renderScene(with: renderer) + } + } + + private func handleInput(_ char: Character) { + // Escape or 'q' exits the app + if char == "\u{1B}" || char == "q" || char == "Q" { + isRunning = false + } + } + + private func cleanup() { + terminal.disableRawMode() + terminal.showCursor() + terminal.exitAlternateScreen() + } + + private func setupSignalHandlers() { + // Catch SIGINT (Ctrl+C) + signal(SIGINT) { _ in + Terminal.shared.disableRawMode() + Terminal.shared.showCursor() + Terminal.shared.exitAlternateScreen() + exit(0) + } + + // Catch SIGWINCH (terminal size change) — sets a flag + // that the main loop picks up safely. + signal(SIGWINCH) { _ in + needsRerender = true + } + } +} + +// MARK: - Scene Rendering Protocol + +/// Internal protocol for renderable scenes. +internal protocol SceneRenderable { + func renderScene(with renderer: ViewRenderer) +} + +extension WindowGroup: SceneRenderable { + func renderScene(with renderer: ViewRenderer) { + renderer.render(content) + } +} diff --git a/Sources/SwiftTUI/App/TScene.swift b/Sources/SwiftTUI/App/TScene.swift new file mode 100644 index 0000000..4e8605f --- /dev/null +++ b/Sources/SwiftTUI/App/TScene.swift @@ -0,0 +1,48 @@ +// +// TScene.swift +// SwiftTUI +// +// Scene types for SwiftTUI applications. +// + +/// The base protocol for scenes in SwiftTUI. +/// +/// A scene represents a part of the app structure, +/// typically a window or a group of views. +public protocol TScene {} + +// MARK: - WindowGroup + +/// A scene that represents a single window (terminal). +/// +/// `WindowGroup` is the main scene for most TUI apps. +/// +/// # Example +/// +/// ```swift +/// WindowGroup { +/// ContentView() +/// } +/// ``` +public struct WindowGroup: TScene { + /// The content of the window. + public let content: Content + + /// Creates a WindowGroup with the specified content. + /// + /// - Parameter content: A ViewBuilder that defines the content. + public init(@TViewBuilder content: () -> Content) { + self.content = content() + } +} + +// MARK: - SceneBuilder + +/// A result builder for scene hierarchies. +@resultBuilder +public struct SceneBuilder { + /// Builds a single scene. + public static func buildBlock(_ content: Content) -> Content { + content + } +} diff --git a/Sources/SwiftTUI/Core/BorderStyle.swift b/Sources/SwiftTUI/Core/BorderStyle.swift new file mode 100644 index 0000000..7b3dbb8 --- /dev/null +++ b/Sources/SwiftTUI/Core/BorderStyle.swift @@ -0,0 +1,187 @@ +// +// BorderStyle.swift +// SwiftTUI +// +// Border styles and character sets for TUI borders. +// + +/// Defines the visual style of a border. +/// +/// Each style provides characters for all border components: +/// corners, edges, and optionally T-junctions for complex layouts. +public struct BorderStyle: Sendable, Equatable { + /// Top-left corner character. + public let topLeft: Character + + /// Top-right corner character. + public let topRight: Character + + /// Bottom-left corner character. + public let bottomLeft: Character + + /// Bottom-right corner character. + public let bottomRight: Character + + /// Horizontal edge character. + public let horizontal: Character + + /// Vertical edge character. + public let vertical: Character + + /// Creates a custom border style. + public init( + topLeft: Character, + topRight: Character, + bottomLeft: Character, + bottomRight: Character, + horizontal: Character, + vertical: Character + ) { + self.topLeft = topLeft + self.topRight = topRight + self.bottomLeft = bottomLeft + self.bottomRight = bottomRight + self.horizontal = horizontal + self.vertical = vertical + } + + // MARK: - Preset Styles + + /// Single line border (─ │ ┌ ┐ └ ┘). + /// + /// ``` + /// ┌────────┐ + /// │ Content│ + /// └────────┘ + /// ``` + public static let line = BorderStyle( + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│" + ) + + /// Double line border (═ ║ ╔ ╗ ╚ ╝). + /// + /// ``` + /// ╔════════╗ + /// ║ Content║ + /// ╚════════╝ + /// ``` + public static let doubleLine = BorderStyle( + topLeft: "╔", + topRight: "╗", + bottomLeft: "╚", + bottomRight: "╝", + horizontal: "═", + vertical: "║" + ) + + /// Rounded border with curved corners (─ │ ╭ ╮ ╰ ╯). + /// + /// ``` + /// ╭────────╮ + /// │ Content│ + /// ╰────────╯ + /// ``` + public static let rounded = BorderStyle( + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│" + ) + + /// Heavy/bold border (━ ┃ ┏ ┓ ┗ ┛). + /// + /// ``` + /// ┏━━━━━━━━┓ + /// ┃ Content┃ + /// ┗━━━━━━━━┛ + /// ``` + public static let heavy = BorderStyle( + topLeft: "┏", + topRight: "┓", + bottomLeft: "┗", + bottomRight: "┛", + horizontal: "━", + vertical: "┃" + ) + + /// Block/solid border using block characters (█). + /// + /// ``` + /// ██████████ + /// █ Content█ + /// ██████████ + /// ``` + public static let block = BorderStyle( + topLeft: "█", + topRight: "█", + bottomLeft: "█", + bottomRight: "█", + horizontal: "█", + vertical: "█" + ) + + /// ASCII-only border (- | + + + +). + /// + /// ``` + /// +--------+ + /// | Content| + /// +--------+ + /// ``` + public static let ascii = BorderStyle( + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|" + ) + + /// Dashed border (╌ ╎ ┌ ┐ └ ┘). + /// + /// ``` + /// ┌╌╌╌╌╌╌╌╌┐ + /// ╎ Content╎ + /// └╌╌╌╌╌╌╌╌┘ + /// ``` + public static let dashed = BorderStyle( + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "╌", + vertical: "╎" + ) + + /// Dotted border using dots. + /// + /// ``` + /// .......... + /// : Content: + /// .......... + /// ``` + public static let dotted = BorderStyle( + topLeft: ".", + topRight: ".", + bottomLeft: ".", + bottomRight: ".", + horizontal: ".", + vertical: ":" + ) + + /// No visible border (space characters). + public static let none = BorderStyle( + topLeft: " ", + topRight: " ", + bottomLeft: " ", + bottomRight: " ", + horizontal: " ", + vertical: " " + ) +} diff --git a/Sources/SwiftTUI/Core/Color.swift b/Sources/SwiftTUI/Core/Color.swift new file mode 100644 index 0000000..7abb70f --- /dev/null +++ b/Sources/SwiftTUI/Core/Color.swift @@ -0,0 +1,179 @@ +// +// Color.swift +// SwiftTUI +// +// Color definitions for terminal output with ANSI escape codes. +// + +/// A color for use in SwiftTUI views. +/// +/// `Color` represents standard ANSI colors as well as +/// extended 256-color palette and True Color (24-bit RGB). +/// +/// # Standard Colors +/// +/// ```swift +/// Text("Red").foregroundColor(.red) +/// Text("Green").foregroundColor(.green) +/// Text("Blue").foregroundColor(.blue) +/// ``` +/// +/// # RGB Colors +/// +/// ```swift +/// Text("Custom").foregroundColor(.rgb(255, 128, 0)) +/// ``` +public struct Color: Sendable, Equatable { + /// The internal color value. + let value: ColorValue + + /// Internal enum for different color types. + enum ColorValue: Sendable, Equatable { + case standard(ANSIColor) + case bright(ANSIColor) + case palette256(UInt8) + case rgb(red: UInt8, green: UInt8, blue: UInt8) + } + + // MARK: - Standard ANSI Colors + + /// Black (ANSI 30/40) + public static let black = Color(value: .standard(.black)) + + /// Red (ANSI 31/41) + public static let red = Color(value: .standard(.red)) + + /// Green (ANSI 32/42) + public static let green = Color(value: .standard(.green)) + + /// Yellow (ANSI 33/43) + public static let yellow = Color(value: .standard(.yellow)) + + /// Blue (ANSI 34/44) + public static let blue = Color(value: .standard(.blue)) + + /// Magenta (ANSI 35/45) + public static let magenta = Color(value: .standard(.magenta)) + + /// Cyan (ANSI 36/46) + public static let cyan = Color(value: .standard(.cyan)) + + /// White (ANSI 37/47) + public static let white = Color(value: .standard(.white)) + + /// Default color (terminal default) + public static let `default` = Color(value: .standard(.`default`)) + + // MARK: - Bright ANSI Colors + + /// Bright black (gray) + public static let brightBlack = Color(value: .bright(.black)) + + /// Bright red + public static let brightRed = Color(value: .bright(.red)) + + /// Bright green + public static let brightGreen = Color(value: .bright(.green)) + + /// Bright yellow + public static let brightYellow = Color(value: .bright(.yellow)) + + /// Bright blue + public static let brightBlue = Color(value: .bright(.blue)) + + /// Bright magenta + public static let brightMagenta = Color(value: .bright(.magenta)) + + /// Bright cyan + public static let brightCyan = Color(value: .bright(.cyan)) + + /// Bright white + public static let brightWhite = Color(value: .bright(.white)) + + // MARK: - Semantic Colors + + /// Primary color (default: blue) + public static let primary = Color.blue + + /// Secondary color (default: gray) + public static let secondary = Color.brightBlack + + /// Accent color (default: cyan) + public static let accent = Color.cyan + + /// Warning color + public static let warning = Color.yellow + + /// Error color + public static let error = Color.red + + /// Success color + public static let success = Color.green + + // MARK: - Custom Colors + + /// Creates a color from the 256-color palette. + /// + /// - Parameter index: The palette index (0-255). + /// - Returns: The corresponding color. + public static func palette(_ index: UInt8) -> Color { + Color(value: .palette256(index)) + } + + /// Creates a True Color RGB color. + /// + /// - Parameters: + /// - red: The red component (0-255). + /// - green: The green component (0-255). + /// - blue: The blue component (0-255). + /// - Returns: The RGB color. + public static func rgb(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> Color { + Color(value: .rgb(red: red, green: green, blue: blue)) + } + + /// Creates a color from a hex value. + /// + /// - Parameter hex: The hex value (e.g., 0xFF5500). + /// - Returns: The corresponding RGB color. + public static func hex(_ hex: UInt32) -> Color { + let red = UInt8((hex >> 16) & 0xFF) + let green = UInt8((hex >> 8) & 0xFF) + let blue = UInt8(hex & 0xFF) + return .rgb(red, green, blue) + } +} + +// MARK: - ANSIColor + +/// The 8 standard ANSI colors. +public enum ANSIColor: UInt8, Sendable { + case black = 0 + case red = 1 + case green = 2 + case yellow = 3 + case blue = 4 + case magenta = 5 + case cyan = 6 + case white = 7 + case `default` = 9 + + /// The ANSI code for foreground color (30-37, 39 for default). + public var foregroundCode: UInt8 { + 30 + rawValue + } + + /// The ANSI code for background color (40-47, 49 for default). + public var backgroundCode: UInt8 { + 40 + rawValue + } + + /// The ANSI code for bright foreground color (90-97). + public var brightForegroundCode: UInt8 { + 90 + rawValue + } + + /// The ANSI code for bright background color (100-107). + public var brightBackgroundCode: UInt8 { + 100 + rawValue + } +} diff --git a/Sources/SwiftTUI/Core/PrimitiveViews.swift b/Sources/SwiftTUI/Core/PrimitiveViews.swift new file mode 100644 index 0000000..5409537 --- /dev/null +++ b/Sources/SwiftTUI/Core/PrimitiveViews.swift @@ -0,0 +1,100 @@ +// +// PrimitiveViews.swift +// SwiftTUI +// +// Primitive view types that serve as leaves in the view tree. +// + +// MARK: - Never as TView + +/// `Never` conforms to TView for views that have no body. +/// +/// Primitive views like `Text` or containers like `TupleView` have no +/// body of their own - they are rendered directly. This extension allows +/// using `Never` as the body type. +extension Never: TView { + public var body: Never { + fatalError("Never.body should never be called") + } +} + +// MARK: - EmptyView + +/// A view that displays no content. +/// +/// `EmptyView` is useful for placeholders or when a view +/// should display nothing under certain conditions. +/// +/// ```swift +/// if showContent { +/// Text("Content") +/// } else { +/// EmptyView() +/// } +/// ``` +public struct EmptyView: TView { + /// Creates an empty view. + public init() {} + + public var body: Never { + fatalError("EmptyView has no body") + } +} + +// MARK: - ConditionalView + +/// A view that represents either the true or false branch of a conditional. +/// +/// This type is used internally by `TViewBuilder` for if-else statements. +public enum ConditionalView: TView { + /// The true branch was executed. + case trueContent(TrueContent) + + /// The false branch was executed. + case falseContent(FalseContent) + + public var body: Never { + fatalError("ConditionalView renders its children directly") + } +} + +// MARK: - TViewArray + +/// A view that contains an array of identical views. +/// +/// This type is used internally by `TViewBuilder` for for-in loops. +/// +/// ```swift +/// ForEach(items) { item in +/// Text(item.name) +/// } +/// ``` +public struct TViewArray: TView { + /// The contained views. + public let elements: [Element] + + /// Creates a TViewArray from an array of views. + /// + /// - Parameter elements: The views this container holds. + public init(_ elements: [Element]) { + self.elements = elements + } + + public var body: Never { + fatalError("TViewArray renders its children directly") + } +} + +// MARK: - Optional TView Conformance + +/// Optional views conform to TView when their Wrapped type does. +extension Optional: TView where Wrapped: TView { + public var body: some TView { + switch self { + case .some(let view): + view + case .none: + EmptyView() + } + } +} diff --git a/Sources/SwiftTUI/Core/TView.swift b/Sources/SwiftTUI/Core/TView.swift new file mode 100644 index 0000000..6d9a25d --- /dev/null +++ b/Sources/SwiftTUI/Core/TView.swift @@ -0,0 +1,37 @@ +// +// TView.swift +// SwiftTUI +// +// The base protocol for all SwiftTUI views. +// + +/// The base protocol for all SwiftTUI views. +/// +/// `TView` is the central protocol in SwiftTUI and works similarly to `View` in SwiftUI. +/// It defines how components declare their structure and content. +/// +/// Every TView defines a `body` composed of other TViews. +/// This enables a hierarchical, declarative UI description. +/// +/// # Example +/// +/// ```swift +/// struct MyView: TView { +/// var body: some TView { +/// Text("Hello, SwiftTUI!") +/// } +/// } +/// ``` +public protocol TView { + /// The type of the body view. + /// + /// Swift automatically infers this type from the `body` implementation. + associatedtype Body: TView + + /// The content and behavior of this view. + /// + /// Implement this property to define the structure of your view. + /// The body consists of other TViews that together form the UI. + @TViewBuilder + var body: Body { get } +} diff --git a/Sources/SwiftTUI/Core/TViewBuilder.swift b/Sources/SwiftTUI/Core/TViewBuilder.swift new file mode 100644 index 0000000..0b20517 --- /dev/null +++ b/Sources/SwiftTUI/Core/TViewBuilder.swift @@ -0,0 +1,192 @@ +// +// TViewBuilder.swift +// SwiftTUI +// +// Result builder for declarative view composition. +// + +/// A result builder for TView hierarchies. +/// +/// The `@TViewBuilder` enables a declarative syntax similar to SwiftUI: +/// +/// ```swift +/// VStack { +/// Text("Line 1") +/// Text("Line 2") +/// if showMore { +/// Text("Line 3") +/// } +/// } +/// ``` +/// +/// The builder supports: +/// - Single views +/// - Multiple views (up to 10) +/// - Conditionals (`if`, `if-else`) +/// - Optional views (`if let`) +/// - Arrays of views (`for-in`) +@resultBuilder +public struct TViewBuilder { + + // MARK: - Single View + + /// Builds a single view. + public static func buildBlock(_ content: Content) -> Content { + content + } + + // MARK: - Multiple Views (Tuple Views) + + /// Builds two views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1 + ) -> TupleView2 { + TupleView2(c0, c1) + } + + /// Builds three views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2 + ) -> TupleView3 { + TupleView3(c0, c1, c2) + } + + /// Builds four views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2, + _ c3: C3 + ) -> TupleView4 { + TupleView4(c0, c1, c2, c3) + } + + /// Builds five views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2, + _ c3: C3, + _ c4: C4 + ) -> TupleView5 { + TupleView5(c0, c1, c2, c3, c4) + } + + /// Builds six views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2, + _ c3: C3, + _ c4: C4, + _ c5: C5 + ) -> TupleView6 { + TupleView6(c0, c1, c2, c3, c4, c5) + } + + /// Builds seven views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2, + _ c3: C3, + _ c4: C4, + _ c5: C5, + _ c6: C6 + ) -> TupleView7 { + TupleView7(c0, c1, c2, c3, c4, c5, c6) + } + + /// Builds eight views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2, + _ c3: C3, + _ c4: C4, + _ c5: C5, + _ c6: C6, + _ c7: C7 + ) -> TupleView8 { + TupleView8(c0, c1, c2, c3, c4, c5, c6, c7) + } + + /// Builds nine views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2, + _ c3: C3, + _ c4: C4, + _ c5: C5, + _ c6: C6, + _ c7: C7, + _ c8: C8 + ) -> TupleView9 { + TupleView9(c0, c1, c2, c3, c4, c5, c6, c7, c8) + } + + /// Builds ten views into a TupleView. + public static func buildBlock( + _ c0: C0, + _ c1: C1, + _ c2: C2, + _ c3: C3, + _ c4: C4, + _ c5: C5, + _ c6: C6, + _ c7: C7, + _ c8: C8, + _ c9: C9 + ) -> TupleView10 { + TupleView10(c0, c1, c2, c3, c4, c5, c6, c7, c8, c9) + } + + // MARK: - Conditionals + + /// Supports the true branch of an if-else. + public static func buildEither( + first content: TrueContent + ) -> ConditionalView { + .trueContent(content) + } + + /// Supports the false branch of an if-else. + public static func buildEither( + second content: FalseContent + ) -> ConditionalView { + .falseContent(content) + } + + /// Supports optional views (if let, if without else). + public static func buildOptional(_ content: Content?) -> Content? { + content + } + + /// Supports availability limiting. + public static func buildLimitedAvailability(_ content: Content) -> Content { + content + } + + // MARK: - Arrays + + /// Supports for-in loops. + public static func buildArray(_ components: [Content]) -> TViewArray { + TViewArray(components) + } + + // MARK: - Expression + + /// Converts a single expression into a view. + public static func buildExpression(_ expression: Content) -> Content { + expression + } + + /// Supports optional expressions. + public static func buildExpression(_ expression: Content?) -> Content? { + expression + } +} diff --git a/Sources/SwiftTUI/Core/TupleViews.swift b/Sources/SwiftTUI/Core/TupleViews.swift new file mode 100644 index 0000000..9e2cb4b --- /dev/null +++ b/Sources/SwiftTUI/Core/TupleViews.swift @@ -0,0 +1,141 @@ +// +// TupleViews.swift +// SwiftTUI +// +// Container types for multiple views in ViewBuilder. +// + +// MARK: - TupleView2 + +/// A view that contains two child views. +public struct TupleView2: TView { + public let value: (V0, V1) + + public init(_ v0: V0, _ v1: V1) { + self.value = (v0, v1) + } + + public var body: Never { + fatalError("TupleView2 renders its children directly") + } +} + +// MARK: - TupleView3 + +/// A view that contains three child views. +public struct TupleView3: TView { + public let value: (V0, V1, V2) + + public init(_ v0: V0, _ v1: V1, _ v2: V2) { + self.value = (v0, v1, v2) + } + + public var body: Never { + fatalError("TupleView3 renders its children directly") + } +} + +// MARK: - TupleView4 + +/// A view that contains four child views. +public struct TupleView4: TView { + public let value: (V0, V1, V2, V3) + + public init(_ v0: V0, _ v1: V1, _ v2: V2, _ v3: V3) { + self.value = (v0, v1, v2, v3) + } + + public var body: Never { + fatalError("TupleView4 renders its children directly") + } +} + +// MARK: - TupleView5 + +/// A view that contains five child views. +public struct TupleView5: TView { + public let value: (V0, V1, V2, V3, V4) + + public init(_ v0: V0, _ v1: V1, _ v2: V2, _ v3: V3, _ v4: V4) { + self.value = (v0, v1, v2, v3, v4) + } + + public var body: Never { + fatalError("TupleView5 renders its children directly") + } +} + +// MARK: - TupleView6 + +/// A view that contains six child views. +public struct TupleView6: TView { + public let value: (V0, V1, V2, V3, V4, V5) + + public init(_ v0: V0, _ v1: V1, _ v2: V2, _ v3: V3, _ v4: V4, _ v5: V5) { + self.value = (v0, v1, v2, v3, v4, v5) + } + + public var body: Never { + fatalError("TupleView6 renders its children directly") + } +} + +// MARK: - TupleView7 + +/// A view that contains seven child views. +public struct TupleView7: TView { + public let value: (V0, V1, V2, V3, V4, V5, V6) + + public init(_ v0: V0, _ v1: V1, _ v2: V2, _ v3: V3, _ v4: V4, _ v5: V5, _ v6: V6) { + self.value = (v0, v1, v2, v3, v4, v5, v6) + } + + public var body: Never { + fatalError("TupleView7 renders its children directly") + } +} + +// MARK: - TupleView8 + +/// A view that contains eight child views. +public struct TupleView8: TView { + public let value: (V0, V1, V2, V3, V4, V5, V6, V7) + + public init(_ v0: V0, _ v1: V1, _ v2: V2, _ v3: V3, _ v4: V4, _ v5: V5, _ v6: V6, _ v7: V7) { + self.value = (v0, v1, v2, v3, v4, v5, v6, v7) + } + + public var body: Never { + fatalError("TupleView8 renders its children directly") + } +} + +// MARK: - TupleView9 + +/// A view that contains nine child views. +public struct TupleView9: TView { + public let value: (V0, V1, V2, V3, V4, V5, V6, V7, V8) + + public init(_ v0: V0, _ v1: V1, _ v2: V2, _ v3: V3, _ v4: V4, _ v5: V5, _ v6: V6, _ v7: V7, _ v8: V8) { + self.value = (v0, v1, v2, v3, v4, v5, v6, v7, v8) + } + + public var body: Never { + fatalError("TupleView9 renders its children directly") + } +} + +// MARK: - TupleView10 + +/// A view that contains ten child views. +public struct TupleView10: TView { + public let value: (V0, V1, V2, V3, V4, V5, V6, V7, V8, V9) + + public init(_ v0: V0, _ v1: V1, _ v2: V2, _ v3: V3, _ v4: V4, _ v5: V5, _ v6: V6, _ v7: V7, _ v8: V8, _ v9: V9) { + self.value = (v0, v1, v2, v3, v4, v5, v6, v7, v8, v9) + } + + public var body: Never { + fatalError("TupleView10 renders its children directly") + } +} diff --git a/Sources/SwiftTUI/Core/ViewModifier.swift b/Sources/SwiftTUI/Core/ViewModifier.swift new file mode 100644 index 0000000..d7d9fbc --- /dev/null +++ b/Sources/SwiftTUI/Core/ViewModifier.swift @@ -0,0 +1,71 @@ +// +// ViewModifier.swift +// SwiftTUI +// +// The view modifier system for transforming views. +// + +/// A modifier that transforms a view's rendered output. +/// +/// `TViewModifier` works on the `FrameBuffer` level: it takes a rendered +/// buffer and returns a transformed buffer. This allows modifiers like +/// `.padding()` and `.frame()` to manipulate layout after rendering. +/// +/// # Example +/// +/// ```swift +/// struct MyModifier: TViewModifier { +/// func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer { +/// // transform the buffer +/// return buffer +/// } +/// } +/// ``` +public protocol TViewModifier { + /// Transforms a rendered buffer. + /// + /// - Parameters: + /// - buffer: The rendered content of the wrapped view. + /// - context: The rendering context. + /// - Returns: The modified buffer. + func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer +} + +// MARK: - ModifiedView + +/// A view that wraps another view with a modifier. +/// +/// This is the return type of modifier methods like `.frame()` and `.padding()`. +/// It is created automatically — users don't instantiate this directly. +public struct ModifiedView: TView { + /// The original view. + public let content: Content + + /// The modifier to apply. + public let modifier: Modifier + + public var body: Never { + fatalError("ModifiedView renders via Renderable") + } +} + +// MARK: - ModifiedView Rendering + +extension ModifiedView: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let childBuffer = SwiftTUI.renderToBuffer(content, context: context) + return modifier.modify(buffer: childBuffer, context: context) + } +} + +// MARK: - TView Modifier Extension + +extension TView { + /// Applies a modifier to this view. + /// + /// - Parameter modifier: The modifier to apply. + /// - Returns: A modified view. + public func modifier(_ modifier: M) -> ModifiedView { + ModifiedView(content: self, modifier: modifier) + } +} diff --git a/Sources/SwiftTUI/Modifiers/BackgroundModifier.swift b/Sources/SwiftTUI/Modifiers/BackgroundModifier.swift new file mode 100644 index 0000000..4cc9439 --- /dev/null +++ b/Sources/SwiftTUI/Modifiers/BackgroundModifier.swift @@ -0,0 +1,81 @@ +// +// BackgroundModifier.swift +// SwiftTUI +// +// The .background() modifier for adding background colors to views. +// + +/// A modifier that fills the background of a view with a color. +public struct BackgroundModifier: TViewModifier { + /// The background color. + public let color: Color + + public func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer { + guard !buffer.isEmpty else { return buffer } + + let width = buffer.width + var lines: [String] = [] + + for line in buffer.lines { + // Pad the line to full width so background covers everything + let paddedLine = line.padToVisibleWidth(width) + + // Apply background color to the entire line + var style = TextStyle() + style.backgroundColor = color + + // We need to handle existing ANSI codes in the line + // For simplicity, we wrap the whole line with background + let colored = applyBackground(to: paddedLine, color: color) + lines.append(colored) + } + + return FrameBuffer(lines: lines) + } + + /// Applies background color to a string, preserving existing formatting. + private func applyBackground(to string: String, color: Color) -> String { + // Build the background escape sequence + let bgCodes: [String] + switch color.value { + case .standard(let ansi): + bgCodes = ["\(ansi.backgroundCode)"] + case .bright(let ansi): + bgCodes = ["\(ansi.brightBackgroundCode)"] + case .palette256(let index): + bgCodes = ["48", "5", "\(index)"] + case .rgb(let red, let green, let blue): + bgCodes = ["48", "2", "\(red)", "\(green)", "\(blue)"] + } + + let bgStart = "\u{1B}[\(bgCodes.joined(separator: ";"))m" + let reset = ANSIRenderer.reset + + return bgStart + string + reset + } +} + +// MARK: - TView Extension + +extension TView { + /// Adds a background color to this view. + /// + /// # Example + /// + /// ```swift + /// Text("Warning!") + /// .foregroundColor(.black) + /// .background(.yellow) + /// + /// VStack { + /// Text("Header") + /// } + /// .background(.blue) + /// ``` + /// + /// - Parameter color: The background color. + /// - Returns: A view with the background color applied. + public func background(_ color: Color) -> ModifiedView { + modifier(BackgroundModifier(color: color)) + } +} diff --git a/Sources/SwiftTUI/Modifiers/BorderModifier.swift b/Sources/SwiftTUI/Modifiers/BorderModifier.swift new file mode 100644 index 0000000..c2d91a1 --- /dev/null +++ b/Sources/SwiftTUI/Modifiers/BorderModifier.swift @@ -0,0 +1,106 @@ +// +// BorderModifier.swift +// SwiftTUI +// +// The .border() modifier for adding borders around views. +// + +/// A modifier that adds a border around a view. +public struct BorderModifier: TViewModifier { + /// The border style to use. + public let style: BorderStyle + + /// The color of the border (nil uses default terminal color). + public let color: Color? + + public func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer { + guard !buffer.isEmpty else { return buffer } + + let contentWidth = buffer.width + let innerWidth = max(contentWidth, 1) + + // Build the top border line + let topLine = buildBorderLine( + left: style.topLeft, + fill: style.horizontal, + right: style.topRight, + width: innerWidth + ) + + // Build the bottom border line + let bottomLine = buildBorderLine( + left: style.bottomLeft, + fill: style.horizontal, + right: style.bottomRight, + width: innerWidth + ) + + // Build the result + var lines: [String] = [] + + // Top border + lines.append(colorize(topLine)) + + // Content lines with side borders + for line in buffer.lines { + let paddedLine = line.padToVisibleWidth(innerWidth) + let borderedLine = colorize(String(style.vertical)) + + paddedLine + + colorize(String(style.vertical)) + lines.append(borderedLine) + } + + // Bottom border + lines.append(colorize(bottomLine)) + + return FrameBuffer(lines: lines) + } + + /// Builds a horizontal border line. + private func buildBorderLine( + left: Character, + fill: Character, + right: Character, + width: Int + ) -> String { + String(left) + String(repeating: fill, count: width) + String(right) + } + + /// Applies color to a string if a color is set. + private func colorize(_ string: String) -> String { + guard let color = color else { return string } + var style = TextStyle() + style.foregroundColor = color + return ANSIRenderer.render(string, with: style) + } +} + +// MARK: - TView Extension + +extension TView { + /// Adds a border around this view. + /// + /// # Example + /// + /// ```swift + /// Text("Hello") + /// .border() + /// + /// Text("Rounded") + /// .border(.rounded, color: .cyan) + /// + /// Text("Double") + /// .border(.doubleLine, color: .yellow) + /// ``` + /// + /// - Parameters: + /// - style: The border style (default: .line). + /// - color: The border color (default: nil, uses terminal default). + /// - Returns: A view with a border. + public func border( + _ style: BorderStyle = .line, + color: Color? = nil + ) -> ModifiedView { + modifier(BorderModifier(style: style, color: color)) + } +} diff --git a/Sources/SwiftTUI/Modifiers/DimmedModifier.swift b/Sources/SwiftTUI/Modifiers/DimmedModifier.swift new file mode 100644 index 0000000..0161610 --- /dev/null +++ b/Sources/SwiftTUI/Modifiers/DimmedModifier.swift @@ -0,0 +1,87 @@ +// +// DimmedModifier.swift +// SwiftTUI +// +// A modifier that applies a dimming effect to the entire view content. +// + +/// A modifier that applies the ANSI dim effect to the entire content. +/// +/// This is useful for de-emphasizing background content when showing +/// overlays, alerts, or dialogs. +public struct DimmedModifier: TView { + /// The content to dim. + let content: Content + + public var body: Never { + fatalError("DimmedModifier renders via Renderable") + } +} + +// MARK: - Renderable + +extension DimmedModifier: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let contentBuffer = SwiftTUI.renderToBuffer(content, context: context) + + guard !contentBuffer.isEmpty else { + return contentBuffer + } + + // Apply dim effect to each line + let dimmedLines = contentBuffer.lines.map { line -> String in + applyDim(to: line) + } + + return FrameBuffer(lines: dimmedLines) + } + + /// Applies the ANSI dim effect to a string. + /// + /// If the string already contains ANSI codes, this wraps the entire line. + /// The dim code (ESC[2m) reduces the intensity of the text. + /// + /// - Parameter text: The text to dim. + /// - Returns: The dimmed text with ANSI codes. + private func applyDim(to text: String) -> String { + guard !text.isEmpty else { return text } + + // ANSI dim code + let dimCode = "\u{1B}[2m" + let resetCode = "\u{1B}[0m" + + // If the line is empty (just spaces), keep it as is + if text.stripped.trimmingCharacters(in: .whitespaces).isEmpty { + return text + } + + // Wrap the entire line in dim codes + // Note: This adds dim at the start and reset at the end + // Any existing styles will still work, but will be dimmed + return dimCode + text + resetCode + } +} + +// MARK: - TView Extension + +extension TView { + /// Applies a dimming effect to the view content. + /// + /// This reduces the visual intensity of the content using the ANSI dim + /// escape code. Useful for background content when displaying overlays. + /// + /// # Example + /// + /// ```swift + /// VStack { + /// Text("This content will be dimmed") + /// Text("All text is affected") + /// } + /// .dimmed() + /// ``` + /// + /// - Returns: A view with the dimming effect applied. + public func dimmed() -> some TView { + DimmedModifier(content: self) + } +} diff --git a/Sources/SwiftTUI/Modifiers/FrameModifier.swift b/Sources/SwiftTUI/Modifiers/FrameModifier.swift new file mode 100644 index 0000000..0da9431 --- /dev/null +++ b/Sources/SwiftTUI/Modifiers/FrameModifier.swift @@ -0,0 +1,112 @@ +// +// FrameModifier.swift +// SwiftTUI +// +// The .frame() modifier for setting explicit size constraints. +// + +/// A modifier that constrains a view to a specific width and/or height. +/// +/// Content is aligned within the frame according to the specified alignment. +public struct FrameModifier: TViewModifier { + /// The desired width (nil means intrinsic width). + public let width: Int? + + /// The desired height (nil means intrinsic height). + public let height: Int? + + /// The alignment of the content within the frame. + public let alignment: Alignment + + public func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer { + let targetWidth = width ?? buffer.width + let targetHeight = height ?? buffer.height + + var result: [String] = [] + + // Calculate vertical offset for alignment + let verticalOffset: Int + switch alignment.vertical { + case .top: + verticalOffset = 0 + case .center: + verticalOffset = max(0, (targetHeight - buffer.height) / 2) + case .bottom: + verticalOffset = max(0, targetHeight - buffer.height) + } + + for row in 0..= 0 && contentRow < buffer.lines.count { + line = buffer.lines[contentRow] + } else { + line = "" + } + + // Align horizontally within the frame + let aligned = alignHorizontally( + line, + toWidth: targetWidth, + alignment: alignment.horizontal + ) + result.append(aligned) + } + + return FrameBuffer(lines: result) + } + + /// Aligns a single line within the given width. + private func alignHorizontally( + _ line: String, + toWidth targetWidth: Int, + alignment: HorizontalAlignment + ) -> String { + let visibleWidth = line.strippedLength + + if visibleWidth >= targetWidth { + return line + } + + let padding = targetWidth - visibleWidth + + switch alignment { + case .leading: + return line + String(repeating: " ", count: padding) + case .center: + let left = padding / 2 + let right = padding - left + return String(repeating: " ", count: left) + line + String(repeating: " ", count: right) + case .trailing: + return String(repeating: " ", count: padding) + line + } + } +} + +// MARK: - TView Extension + +extension TView { + /// Sets the frame size of this view. + /// + /// The content is aligned within the frame according to the specified alignment. + /// + /// # Example + /// + /// ```swift + /// Text("Hello") + /// .frame(width: 20, alignment: .center) + /// ``` + /// + /// - Parameters: + /// - width: The desired width in characters (nil preserves intrinsic width). + /// - height: The desired height in lines (nil preserves intrinsic height). + /// - alignment: The alignment within the frame (default: .topLeading). + /// - Returns: A view constrained to the specified frame. + public func frame( + width: Int? = nil, + height: Int? = nil, + alignment: Alignment = .topLeading + ) -> ModifiedView { + modifier(FrameModifier(width: width, height: height, alignment: alignment)) + } +} diff --git a/Sources/SwiftTUI/Modifiers/OverlayModifier.swift b/Sources/SwiftTUI/Modifiers/OverlayModifier.swift new file mode 100644 index 0000000..551d6a3 --- /dev/null +++ b/Sources/SwiftTUI/Modifiers/OverlayModifier.swift @@ -0,0 +1,104 @@ +// +// OverlayModifier.swift +// SwiftTUI +// +// A modifier that renders an overlay on top of the base view. +// + +/// Internal modifier that layers an overlay view on top of the base content. +/// +/// The overlay is rendered on top of the base content. Both views are rendered +/// to their natural size, and the overlay is positioned according to the +/// specified alignment within the base content's bounds. +public struct OverlayModifier: TView { + /// The base content. + let base: Base + + /// The overlay content. + let overlay: Overlay + + /// The alignment of the overlay within the base bounds. + let alignment: Alignment + + public var body: Never { + fatalError("OverlayModifier renders via Renderable") + } +} + +// MARK: - Renderable + +extension OverlayModifier: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + // Render both contents + let baseBuffer = SwiftTUI.renderToBuffer(base, context: context) + let overlayBuffer = SwiftTUI.renderToBuffer(overlay, context: context) + + guard !baseBuffer.isEmpty else { + return overlayBuffer + } + + guard !overlayBuffer.isEmpty else { + return baseBuffer + } + + // Calculate the position of the overlay based on alignment + let baseWidth = baseBuffer.width + let baseHeight = baseBuffer.height + let overlayWidth = overlayBuffer.width + let overlayHeight = overlayBuffer.height + + // Calculate horizontal position + let xOffset: Int + switch alignment.horizontal { + case .leading: + xOffset = 0 + case .center: + xOffset = max(0, (baseWidth - overlayWidth) / 2) + case .trailing: + xOffset = max(0, baseWidth - overlayWidth) + } + + // Calculate vertical position + let yOffset: Int + switch alignment.vertical { + case .top: + yOffset = 0 + case .center: + yOffset = max(0, (baseHeight - overlayHeight) / 2) + case .bottom: + yOffset = max(0, baseHeight - overlayHeight) + } + + // Composite the overlay onto the base + return baseBuffer.composited(with: overlayBuffer, at: (x: xOffset, y: yOffset)) + } +} + +// MARK: - TView Extension + +extension TView { + /// Layers the specified view on top of this view. + /// + /// The overlay is positioned according to the specified alignment + /// within the bounds of the base view. + /// + /// # Example + /// + /// ```swift + /// Text("Background content here") + /// .overlay(alignment: .center) { + /// Text("Centered overlay") + /// } + /// ``` + /// + /// - Parameters: + /// - alignment: The alignment of the overlay (default: .center). + /// - content: The overlay content. + /// - Returns: A view with the overlay applied. + public func overlay( + alignment: Alignment = .center, + @TViewBuilder content: () -> Overlay + ) -> some TView { + OverlayModifier(base: self, overlay: content(), alignment: alignment) + } +} diff --git a/Sources/SwiftTUI/Modifiers/PaddingModifier.swift b/Sources/SwiftTUI/Modifiers/PaddingModifier.swift new file mode 100644 index 0000000..07cc886 --- /dev/null +++ b/Sources/SwiftTUI/Modifiers/PaddingModifier.swift @@ -0,0 +1,164 @@ +// +// PaddingModifier.swift +// SwiftTUI +// +// The .padding() modifier for adding space around a view. +// + +/// Edge insets defining padding on each side. +public struct EdgeInsets: Sendable, Equatable { + /// Padding above the content. + public var top: Int + + /// Padding to the left of the content. + public var leading: Int + + /// Padding below the content. + public var bottom: Int + + /// Padding to the right of the content. + public var trailing: Int + + /// Creates edge insets with individual values. + public init(top: Int = 0, leading: Int = 0, bottom: Int = 0, trailing: Int = 0) { + self.top = top + self.leading = leading + self.bottom = bottom + self.trailing = trailing + } + + /// Creates uniform edge insets. + /// + /// - Parameter value: The padding on all four sides. + public init(all value: Int) { + self.top = value + self.leading = value + self.bottom = value + self.trailing = value + } + + /// Creates horizontal and vertical edge insets. + /// + /// - Parameters: + /// - horizontal: The padding on leading and trailing sides. + /// - vertical: The padding on top and bottom sides. + public init(horizontal: Int = 0, vertical: Int = 0) { + self.top = vertical + self.leading = horizontal + self.bottom = vertical + self.trailing = horizontal + } +} + +/// The edges of a view. +public struct Edge: OptionSet, Sendable { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + /// The top edge. + public static let top = Edge(rawValue: 1 << 0) + + /// The leading (left) edge. + public static let leading = Edge(rawValue: 1 << 1) + + /// The bottom edge. + public static let bottom = Edge(rawValue: 1 << 2) + + /// The trailing (right) edge. + public static let trailing = Edge(rawValue: 1 << 3) + + /// All edges. + public static let all: Edge = [.top, .leading, .bottom, .trailing] + + /// Horizontal edges (leading and trailing). + public static let horizontal: Edge = [.leading, .trailing] + + /// Vertical edges (top and bottom). + public static let vertical: Edge = [.top, .bottom] +} + +/// A modifier that adds padding around a view. +public struct PaddingModifier: TViewModifier { + /// The padding insets. + public let insets: EdgeInsets + + public func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer { + var result: [String] = [] + + let leadingPad = String(repeating: " ", count: insets.leading) + let trailingPad = String(repeating: " ", count: insets.trailing) + + // Top padding + let lineWidth = buffer.width + insets.leading + insets.trailing + let emptyLine = String(repeating: " ", count: lineWidth) + for _ in 0.. ModifiedView { + modifier(PaddingModifier(insets: EdgeInsets(all: amount))) + } + + /// Adds padding on specific edges. + /// + /// ```swift + /// Text("Hello") + /// .padding(.horizontal, 4) + /// ``` + /// + /// - Parameters: + /// - edges: The edges to pad. + /// - amount: The padding amount (default: 1). + /// - Returns: A padded view. + public func padding(_ edges: Edge, _ amount: Int = 1) -> ModifiedView { + let insets = EdgeInsets( + top: edges.contains(.top) ? amount : 0, + leading: edges.contains(.leading) ? amount : 0, + bottom: edges.contains(.bottom) ? amount : 0, + trailing: edges.contains(.trailing) ? amount : 0 + ) + return modifier(PaddingModifier(insets: insets)) + } + + /// Adds padding with explicit edge insets. + /// + /// ```swift + /// Text("Hello") + /// .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + /// ``` + /// + /// - Parameter insets: The edge insets. + /// - Returns: A padded view. + public func padding(_ insets: EdgeInsets) -> ModifiedView { + modifier(PaddingModifier(insets: insets)) + } +} diff --git a/Sources/SwiftTUI/Rendering/ANSIRenderer.swift b/Sources/SwiftTUI/Rendering/ANSIRenderer.swift new file mode 100644 index 0000000..44a7506 --- /dev/null +++ b/Sources/SwiftTUI/Rendering/ANSIRenderer.swift @@ -0,0 +1,203 @@ +// +// ANSIRenderer.swift +// SwiftTUI +// +// ANSI escape code generation for terminal output. +// + +/// Generates ANSI escape codes for terminal formatting. +/// +/// `ANSIRenderer` translates `TextStyle` and `Color` into the corresponding +/// ANSI escape sequences that are understood by most terminals. +public enum ANSIRenderer { + /// The escape character for ANSI sequences. + public static let escape = "\u{1B}" + + /// The Control Sequence Introducer (CSI). + public static let csi = "\(escape)[" + + /// Reset code that clears all formatting. + public static let reset = "\(csi)0m" + + // MARK: - Style Rendering + + /// Renders text with the specified style. + /// + /// - Parameters: + /// - text: The text to render. + /// - style: The TextStyle to apply. + /// - Returns: The formatted string with ANSI codes. + public static func render(_ text: String, with style: TextStyle) -> String { + let codes = buildStyleCodes(style) + + if codes.isEmpty { + return text + } + + let styleSequence = "\(csi)\(codes.joined(separator: ";"))m" + return "\(styleSequence)\(text)\(reset)" + } + + /// Builds the ANSI codes for a TextStyle. + /// + /// - Parameter style: The TextStyle to convert. + /// - Returns: An array of ANSI code strings. + private static func buildStyleCodes(_ style: TextStyle) -> [String] { + var codes: [String] = [] + + // Text attributes + if style.isBold { + codes.append("1") + } + if style.isDim { + codes.append("2") + } + if style.isItalic { + codes.append("3") + } + if style.isUnderlined { + codes.append("4") + } + if style.isBlink { + codes.append("5") + } + if style.isInverted { + codes.append("7") + } + if style.isStrikethrough { + codes.append("9") + } + + // Foreground color + if let fgColor = style.foregroundColor { + codes.append(contentsOf: foregroundCodes(for: fgColor)) + } + + // Background color + if let bgColor = style.backgroundColor { + codes.append(contentsOf: backgroundCodes(for: bgColor)) + } + + return codes + } + + // MARK: - Color Codes + + /// Generates the ANSI codes for a foreground color. + /// + /// - Parameter color: The color. + /// - Returns: The ANSI code strings. + private static func foregroundCodes(for color: Color) -> [String] { + switch color.value { + case .standard(let ansi): + return ["\(ansi.foregroundCode)"] + case .bright(let ansi): + return ["\(ansi.brightForegroundCode)"] + case .palette256(let index): + return ["38", "5", "\(index)"] + case .rgb(let red, let green, let blue): + return ["38", "2", "\(red)", "\(green)", "\(blue)"] + } + } + + /// Generates the ANSI codes for a background color. + /// + /// - Parameter color: The color. + /// - Returns: The ANSI code strings. + private static func backgroundCodes(for color: Color) -> [String] { + switch color.value { + case .standard(let ansi): + return ["\(ansi.backgroundCode)"] + case .bright(let ansi): + return ["\(ansi.brightBackgroundCode)"] + case .palette256(let index): + return ["48", "5", "\(index)"] + case .rgb(let red, let green, let blue): + return ["48", "2", "\(red)", "\(green)", "\(blue)"] + } + } + + // MARK: - Cursor Control + + /// Moves the cursor to the specified position. + /// + /// - Parameters: + /// - row: The row (1-based). + /// - column: The column (1-based). + /// - Returns: The ANSI escape sequence. + public static func moveCursor(toRow row: Int, column: Int) -> String { + "\(csi)\(row);\(column)H" + } + + /// Moves the cursor up by the specified number of lines. + /// + /// - Parameter lines: Number of lines. + /// - Returns: The ANSI escape sequence. + public static func cursorUp(_ lines: Int = 1) -> String { + "\(csi)\(lines)A" + } + + /// Moves the cursor down by the specified number of lines. + /// + /// - Parameter lines: Number of lines. + /// - Returns: The ANSI escape sequence. + public static func cursorDown(_ lines: Int = 1) -> String { + "\(csi)\(lines)B" + } + + /// Moves the cursor forward by the specified number of columns. + /// + /// - Parameter columns: Number of columns. + /// - Returns: The ANSI escape sequence. + public static func cursorForward(_ columns: Int = 1) -> String { + "\(csi)\(columns)C" + } + + /// Moves the cursor back by the specified number of columns. + /// + /// - Parameter columns: Number of columns. + /// - Returns: The ANSI escape sequence. + public static func cursorBack(_ columns: Int = 1) -> String { + "\(csi)\(columns)D" + } + + /// Hides the cursor. + public static let hideCursor = "\(csi)?25l" + + /// Shows the cursor. + public static let showCursor = "\(csi)?25h" + + /// Saves the current cursor position. + public static let saveCursor = "\(csi)s" + + /// Restores the saved cursor position. + public static let restoreCursor = "\(csi)u" + + // MARK: - Screen Control + + /// Clears the entire screen. + public static let clearScreen = "\(csi)2J" + + /// Clears from cursor to end of screen. + public static let clearToEnd = "\(csi)0J" + + /// Clears from cursor to beginning of screen. + public static let clearToBeginning = "\(csi)1J" + + /// Clears the current line. + public static let clearLine = "\(csi)2K" + + /// Clears from cursor to end of line. + public static let clearLineToEnd = "\(csi)0K" + + /// Clears from cursor to beginning of line. + public static let clearLineToBeginning = "\(csi)1K" + + // MARK: - Alternate Screen Buffer + + /// Enters the alternate screen buffer. + public static let enterAlternateScreen = "\(csi)?1049h" + + /// Exits the alternate screen buffer. + public static let exitAlternateScreen = "\(csi)?1049l" +} diff --git a/Sources/SwiftTUI/Rendering/FrameBuffer.swift b/Sources/SwiftTUI/Rendering/FrameBuffer.swift new file mode 100644 index 0000000..bf95558 --- /dev/null +++ b/Sources/SwiftTUI/Rendering/FrameBuffer.swift @@ -0,0 +1,247 @@ +// +// FrameBuffer.swift +// SwiftTUI +// +// A 2D text buffer for off-screen rendering before terminal output. +// + +/// A 2D text buffer that views render into before flushing to the terminal. +/// +/// `FrameBuffer` enables a two-pass rendering approach: +/// 1. Each view renders into its own buffer (measuring its size) +/// 2. Layout containers combine child buffers (horizontally, vertically, or layered) +/// 3. The final root buffer is flushed to the terminal +/// +/// Each line in the buffer is a string that may contain ANSI escape codes. +public struct FrameBuffer { + /// The lines of rendered content (may contain ANSI escape codes). + public var lines: [String] + + /// The width of the buffer (the length of the longest line in visible characters). + public var width: Int { + lines.map { $0.strippedLength }.max() ?? 0 + } + + /// The height of the buffer (number of lines). + public var height: Int { + lines.count + } + + /// Whether the buffer is empty. + public var isEmpty: Bool { + lines.isEmpty || lines.allSatisfy { $0.isEmpty } + } + + /// Creates an empty buffer. + public init() { + self.lines = [] + } + + /// Creates a buffer from an array of lines. + /// + /// - Parameter lines: The text lines. + public init(lines: [String]) { + self.lines = lines + } + + /// Creates a buffer containing a single line. + /// + /// - Parameter text: The text content. + public init(text: String) { + self.lines = [text] + } + + /// Creates an empty buffer with the specified height. + /// + /// - Parameter height: The number of empty lines. + public init(emptyWithHeight height: Int) { + self.lines = Array(repeating: "", count: height) + } + + // MARK: - Combining Buffers + + /// Stacks another buffer below this one with optional spacing. + /// + /// - Parameters: + /// - other: The buffer to append below. + /// - spacing: Number of empty lines between the two buffers. + public mutating func appendVertically(_ other: FrameBuffer, spacing: Int = 0) { + if !lines.isEmpty && !other.isEmpty && spacing > 0 { + lines.append(contentsOf: Array(repeating: "", count: spacing)) + } + lines.append(contentsOf: other.lines) + } + + /// Places another buffer to the right of this one with optional spacing. + /// + /// - Parameters: + /// - other: The buffer to append to the right. + /// - spacing: Number of space characters between the two buffers. + public mutating func appendHorizontally(_ other: FrameBuffer, spacing: Int = 0) { + let maxHeight = max(height, other.height) + let myWidth = width + let spacer = String(repeating: " ", count: spacing) + + var result: [String] = [] + for row in 0.. FrameBuffer { + guard !overlay.isEmpty else { return self } + + let resultWidth = max(width, position.x + overlay.width) + let resultHeight = max(height, position.y + overlay.height) + + var result: [String] = [] + + for row in 0..= 0 && overlayRow < overlay.lines.count { + let overlayLine = overlay.lines[overlayRow] + if !overlayLine.isEmpty { + // Insert overlay content at the x position + baseLine = insertOverlay( + base: baseLine, + overlay: overlayLine, + atColumn: position.x + ) + } + } + + result.append(baseLine) + } + + return FrameBuffer(lines: result) + } + + /// Inserts overlay text into base text at the specified column position. + /// + /// - Parameters: + /// - base: The base text line. + /// - overlay: The overlay text to insert. + /// - column: The column position (0-based). + /// - Returns: The composited line. + private func insertOverlay(base: String, overlay: String, atColumn column: Int) -> String { + // For ANSI-safe insertion, we work with visible characters + let baseChars = Array(base.stripped) + let overlayStripped = overlay.stripped + + // Build result: characters before overlay position + overlay + characters after + var result = "" + + // Add characters before the overlay position + if column > 0 { + let prefixEnd = min(column, baseChars.count) + result += String(baseChars[0.. String { + let currentWidth = strippedLength + if currentWidth >= targetWidth { + return self + } + return self + String(repeating: " ", count: targetWidth - currentWidth) + } +} diff --git a/Sources/SwiftTUI/Rendering/Renderable.swift b/Sources/SwiftTUI/Rendering/Renderable.swift new file mode 100644 index 0000000..74f185e --- /dev/null +++ b/Sources/SwiftTUI/Rendering/Renderable.swift @@ -0,0 +1,72 @@ +// +// Renderable.swift +// SwiftTUI +// +// Protocol for views that can render themselves directly. +// + +/// A protocol for views that can render themselves into a `FrameBuffer`. +/// +/// Primitive views implement this protocol to produce their text output +/// as a buffer. Layout containers then combine child buffers to produce +/// the final output. +public protocol Renderable { + /// Renders this view into a `FrameBuffer`. + /// + /// - Parameter context: The rendering context with available size info. + /// - Returns: A buffer containing the rendered output. + func renderToBuffer(context: RenderContext) -> FrameBuffer +} + +/// The context for rendering a view. +/// +/// Contains layout constraints and terminal information that views +/// need to determine their size and content. +public struct RenderContext { + /// The target terminal. + public let terminal: Terminal + + /// The available width in characters. + public var availableWidth: Int + + /// The available height in lines. + public var availableHeight: Int + + /// Creates a new RenderContext. + /// + /// - Parameters: + /// - terminal: The target terminal. + /// - availableWidth: The available width (defaults to terminal width). + /// - availableHeight: The available height (defaults to terminal height). + public init( + terminal: Terminal = .shared, + availableWidth: Int? = nil, + availableHeight: Int? = nil + ) { + self.terminal = terminal + self.availableWidth = availableWidth ?? terminal.width + self.availableHeight = availableHeight ?? terminal.height + } +} + +// MARK: - Rendering Helper + +/// Renders any TView into a FrameBuffer by checking for Renderable conformance +/// or recursively rendering the body. +/// +/// - Parameters: +/// - view: The view to render. +/// - context: The rendering context. +/// - Returns: A FrameBuffer with the rendered content. +public func renderToBuffer(_ view: V, context: RenderContext) -> FrameBuffer { + if let renderable = view as? Renderable { + return renderable.renderToBuffer(context: context) + } + + // Composite view: render its body + if V.Body.self != Never.self { + return renderToBuffer(view.body, context: context) + } + + return FrameBuffer() +} diff --git a/Sources/SwiftTUI/Rendering/Terminal.swift b/Sources/SwiftTUI/Rendering/Terminal.swift new file mode 100644 index 0000000..ea36ac9 --- /dev/null +++ b/Sources/SwiftTUI/Rendering/Terminal.swift @@ -0,0 +1,210 @@ +// +// Terminal.swift +// SwiftTUI +// +// Terminal abstraction for input and output. +// + +import Foundation + +#if os(Linux) +import Glibc +#else +import Darwin +#endif + +/// Represents the terminal and controls input and output. +/// +/// `Terminal` is the central interface to the terminal. It provides: +/// - Terminal size queries +/// - Raw mode configuration +/// - Safe input and output +public final class Terminal: @unchecked Sendable { + /// The shared terminal instance. + public static let shared = Terminal() + + /// The width of the terminal in characters. + public var width: Int { + getSize().width + } + + /// The height of the terminal in lines. + public var height: Int { + getSize().height + } + + /// Whether raw mode is active. + private var isRawMode = false + + /// The original terminal settings. + private var originalTermios: termios? + + /// Private initializer for singleton. + private init() {} + + /// Destructor ensures raw mode is disabled. + deinit { + if isRawMode { + disableRawMode() + } + } + + // MARK: - Terminal Size + + /// Returns the current terminal size. + /// + /// - Returns: A tuple with width and height in characters/lines. + public func getSize() -> (width: Int, height: Int) { + var windowSize = winsize() + + let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &windowSize) + + if result == 0 && windowSize.ws_col > 0 && windowSize.ws_row > 0 { + return (Int(windowSize.ws_col), Int(windowSize.ws_row)) + } + + // Fallback to environment variables + let cols = ProcessInfo.processInfo.environment["COLUMNS"].flatMap(Int.init) ?? 80 + let rows = ProcessInfo.processInfo.environment["LINES"].flatMap(Int.init) ?? 24 + + return (cols, rows) + } + + // MARK: - Raw Mode + + /// Enables raw mode for direct character handling. + /// + /// In raw mode: + /// - Each keystroke is reported immediately (without Enter) + /// - Echo is disabled + /// - Signals like Ctrl+C are not automatically processed + public func enableRawMode() { + guard !isRawMode else { return } + + var raw = termios() + tcgetattr(STDIN_FILENO, &raw) + originalTermios = raw + + // Disable: + // ECHO: Input is not displayed + // ICANON: Canonical mode (line by line) + // ISIG: Ctrl+C/Ctrl+Z signals + // IEXTEN: Ctrl+V + raw.c_lflag &= ~(UInt(ECHO | ICANON | ISIG | IEXTEN)) + + // Disable: + // IXON: Ctrl+S/Ctrl+Q software flow control + // ICRNL: CR to NL translation + // BRKINT: Break signal + // INPCK: Parity check + // ISTRIP: Strip 8th bit + raw.c_iflag &= ~(UInt(IXON | ICRNL | BRKINT | INPCK | ISTRIP)) + + // Disable output processing + raw.c_oflag &= ~(UInt(OPOST)) + + // Set character size to 8 bits + raw.c_cflag |= UInt(CS8) + + // Set timeouts: VMIN=0, VTIME=1 (100ms timeout) + // c_cc is a tuple in Swift, so we need to use withUnsafeMutablePointer + withUnsafeMutablePointer(to: &raw.c_cc) { pointer in + pointer.withMemoryRebound(to: cc_t.self, capacity: Int(NCCS)) { buffer in + buffer[Int(VMIN)] = 0 + buffer[Int(VTIME)] = 1 + } + } + + tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) + isRawMode = true + } + + /// Disables raw mode and restores normal terminal operation. + public func disableRawMode() { + guard isRawMode, var original = originalTermios else { return } + tcsetattr(STDIN_FILENO, TCSAFLUSH, &original) + isRawMode = false + } + + // MARK: - Output + + /// Writes a string to the terminal. + /// + /// - Parameter string: The string to write. + public func write(_ string: String) { + print(string, terminator: "") + fflush(stdout) + } + + /// Writes a string and moves to a new line. + /// + /// - Parameter string: The string to write. + public func writeLine(_ string: String = "") { + print(string) + fflush(stdout) + } + + /// Clears the screen and moves cursor to position (1,1). + public func clear() { + write(ANSIRenderer.clearScreen + ANSIRenderer.moveCursor(toRow: 1, column: 1)) + } + + /// Moves the cursor to the specified position. + /// + /// - Parameters: + /// - row: The row (1-based). + /// - column: The column (1-based). + public func moveCursor(toRow row: Int, column: Int) { + write(ANSIRenderer.moveCursor(toRow: row, column: column)) + } + + /// Hides the cursor. + public func hideCursor() { + write(ANSIRenderer.hideCursor) + } + + /// Shows the cursor. + public func showCursor() { + write(ANSIRenderer.showCursor) + } + + // MARK: - Alternate Screen + + /// Switches to the alternate screen buffer. + /// + /// The alternate buffer is useful for TUI apps, as the original + /// terminal content is restored when exiting. + public func enterAlternateScreen() { + write(ANSIRenderer.enterAlternateScreen) + } + + /// Exits the alternate screen buffer. + public func exitAlternateScreen() { + write(ANSIRenderer.exitAlternateScreen) + } + + // MARK: - Input + + /// Reads a single character from the terminal. + /// + /// Blocks until a character is available (if raw mode is active, + /// for a maximum of 100ms). + /// + /// - Returns: The read character or nil on timeout/error. + public func readChar() -> Character? { + var char: UInt8 = 0 + let bytesRead = read(STDIN_FILENO, &char, 1) + + if bytesRead == 1 { + return Character(UnicodeScalar(char)) + } + return nil + } + + /// Reads a complete line from the terminal. + /// + /// - Returns: The input line without newline. + public func readLine() -> String? { + Swift.readLine() + } +} diff --git a/Sources/SwiftTUI/Rendering/ViewRenderer.swift b/Sources/SwiftTUI/Rendering/ViewRenderer.swift new file mode 100644 index 0000000..35fffb7 --- /dev/null +++ b/Sources/SwiftTUI/Rendering/ViewRenderer.swift @@ -0,0 +1,403 @@ +// +// ViewRenderer.swift +// SwiftTUI +// +// Renders TViews to terminal output via FrameBuffer. +// + +import Foundation + +/// Renders TViews to terminal output. +/// +/// The `ViewRenderer` uses a two-pass approach: +/// 1. Render the entire view tree into a `FrameBuffer` +/// 2. Flush the buffer to the terminal at the correct position +public final class ViewRenderer { + /// The terminal to render to. + private let terminal: Terminal + + /// Creates a new ViewRenderer. + /// + /// - Parameter terminal: The target terminal. + public init(terminal: Terminal = .shared) { + self.terminal = terminal + } + + /// Renders a view to the terminal. + /// + /// - Parameters: + /// - view: The view to render. + /// - row: The starting row (1-based, default: 1). + /// - column: The starting column (1-based, default: 1). + public func render(_ view: V, atRow row: Int = 1, column: Int = 1) { + let context = RenderContext(terminal: terminal) + let buffer = renderToBuffer(view, context: context) + flush(buffer, atRow: row, column: column) + } + + /// Flushes a FrameBuffer to the terminal at the specified position. + private func flush(_ buffer: FrameBuffer, atRow row: Int, column: Int) { + for (index, line) in buffer.lines.enumerated() { + terminal.moveCursor(toRow: row + index, column: column) + terminal.write(line) + } + } +} + +// MARK: - Child Info + +/// Describes a child view within a stack for layout purposes. +struct ChildInfo { + /// The rendered buffer of this child (nil for spacers, computed later). + let buffer: FrameBuffer? + + /// Whether this child is a Spacer. + let isSpacer: Bool + + /// The minimum length of this spacer (only relevant if isSpacer is true). + let spacerMinLength: Int? +} + +// MARK: - Child Info Provider + +/// Internal protocol that allows stack containers to extract individual +/// child info from their content (which is typically a TupleView). +protocol ChildInfoProvider { + /// Returns an array of ChildInfo, one per child view. + func childInfos(context: RenderContext) -> [ChildInfo] +} + +/// Creates a ChildInfo for a single view. +func makeChildInfo(for view: V, context: RenderContext) -> ChildInfo { + if let spacer = view as? Spacer { + return ChildInfo(buffer: nil, isSpacer: true, spacerMinLength: spacer.minLength) + } + return ChildInfo( + buffer: renderToBuffer(view, context: context), + isSpacer: false, + spacerMinLength: nil + ) +} + +// MARK: - Text Rendering + +extension Text: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(text: ANSIRenderer.render(content, with: style)) + } +} + +// MARK: - EmptyView Rendering + +extension EmptyView: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer() + } +} + +// MARK: - Spacer Rendering + +extension Spacer: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + // Standalone spacer (outside a stack): render as empty lines + let count = minLength ?? 1 + return FrameBuffer(emptyWithHeight: count) + } +} + +// MARK: - Divider Rendering + +extension Divider: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let line = String(repeating: character, count: context.availableWidth) + return FrameBuffer(text: line) + } +} + +// MARK: - VStack Rendering + +extension VStack: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let infos = resolveChildInfos(from: content, context: context) + + // Count spacers and measure fixed children + let spacerCount = infos.filter(\.isSpacer).count + let fixedHeight = infos.compactMap(\.buffer).reduce(0) { $0 + $1.height } + let totalSpacing = max(0, infos.count - 1) * spacing + + let availableForSpacers = max(0, context.availableHeight - fixedHeight - totalSpacing) + let spacerHeight = spacerCount > 0 ? availableForSpacers / spacerCount : 0 + + var result = FrameBuffer() + for (index, info) in infos.enumerated() { + let spacingToApply = index > 0 ? spacing : 0 + if info.isSpacer { + let height = max(info.spacerMinLength ?? 0, spacerHeight) + result.appendVertically(FrameBuffer(emptyWithHeight: height), spacing: spacingToApply) + } else if let buffer = info.buffer { + result.appendVertically(buffer, spacing: spacingToApply) + } + } + return result + } +} + +// MARK: - HStack Rendering + +extension HStack: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let infos = resolveChildInfos(from: content, context: context) + + // Count spacers and measure fixed children + let spacerCount = infos.filter(\.isSpacer).count + let fixedWidth = infos.compactMap(\.buffer).reduce(0) { $0 + $1.width } + let totalSpacing = max(0, infos.count - 1) * spacing + + let availableForSpacers = max(0, context.availableWidth - fixedWidth - totalSpacing) + let spacerWidth = spacerCount > 0 ? availableForSpacers / spacerCount : 0 + + var result = FrameBuffer() + for (index, info) in infos.enumerated() { + let spacingToApply = index > 0 ? spacing : 0 + if info.isSpacer { + let width = max(info.spacerMinLength ?? 0, spacerWidth) + let maxHeight = infos.compactMap(\.buffer).map(\.height).max() ?? 1 + let spacerBuffer = FrameBuffer(lines: Array( + repeating: String(repeating: " ", count: width), + count: maxHeight + )) + result.appendHorizontally(spacerBuffer, spacing: spacingToApply) + } else if let buffer = info.buffer { + result.appendHorizontally(buffer, spacing: spacingToApply) + } + } + return result + } +} + +// MARK: - ZStack Rendering + +extension ZStack: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let infos = resolveChildInfos(from: content, context: context) + var result = FrameBuffer() + for info in infos { + if let buffer = info.buffer { + result.overlay(buffer) + } + } + return result + } +} + +// MARK: - TupleView Rendering + ChildInfoProvider + +extension TupleView2: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + ] + } +} + +extension TupleView3: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + ] + } +} + +extension TupleView4: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + makeChildInfo(for: value.3, context: context), + ] + } +} + +extension TupleView5: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + makeChildInfo(for: value.3, context: context), + makeChildInfo(for: value.4, context: context), + ] + } +} + +extension TupleView6: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + makeChildInfo(for: value.3, context: context), + makeChildInfo(for: value.4, context: context), + makeChildInfo(for: value.5, context: context), + ] + } +} + +extension TupleView7: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + makeChildInfo(for: value.3, context: context), + makeChildInfo(for: value.4, context: context), + makeChildInfo(for: value.5, context: context), + makeChildInfo(for: value.6, context: context), + ] + } +} + +extension TupleView8: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + makeChildInfo(for: value.3, context: context), + makeChildInfo(for: value.4, context: context), + makeChildInfo(for: value.5, context: context), + makeChildInfo(for: value.6, context: context), + makeChildInfo(for: value.7, context: context), + ] + } +} + +extension TupleView9: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + makeChildInfo(for: value.3, context: context), + makeChildInfo(for: value.4, context: context), + makeChildInfo(for: value.5, context: context), + makeChildInfo(for: value.6, context: context), + makeChildInfo(for: value.7, context: context), + makeChildInfo(for: value.8, context: context), + ] + } +} + +extension TupleView10: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + [ + makeChildInfo(for: value.0, context: context), + makeChildInfo(for: value.1, context: context), + makeChildInfo(for: value.2, context: context), + makeChildInfo(for: value.3, context: context), + makeChildInfo(for: value.4, context: context), + makeChildInfo(for: value.5, context: context), + makeChildInfo(for: value.6, context: context), + makeChildInfo(for: value.7, context: context), + makeChildInfo(for: value.8, context: context), + makeChildInfo(for: value.9, context: context), + ] + } +} + +// MARK: - ConditionalView Rendering + +extension ConditionalView: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + switch self { + case .trueContent(let content): + return SwiftTUI.renderToBuffer(content, context: context) + case .falseContent(let content): + return SwiftTUI.renderToBuffer(content, context: context) + } + } +} + +// MARK: - TViewArray Rendering + +extension TViewArray: Renderable, ChildInfoProvider { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + elements.map { makeChildInfo(for: $0, context: context) } + } +} + +// MARK: - Optional Rendering + +extension Optional: Renderable where Wrapped: TView { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + switch self { + case .some(let view): + return SwiftTUI.renderToBuffer(view, context: context) + case .none: + return FrameBuffer() + } + } +} + +// MARK: - Child Info Resolution + +/// Resolves child infos from a view's content. +/// +/// If the content conforms to `ChildInfoProvider` (e.g. TupleViews), +/// it returns individual child infos. Otherwise it returns the content +/// as a single-element array. +/// +/// - Parameters: +/// - content: The content view. +/// - context: The rendering context. +/// - Returns: An array of ChildInfo. +func resolveChildInfos(from content: V, context: RenderContext) -> [ChildInfo] { + if let provider = content as? ChildInfoProvider { + return provider.childInfos(context: context) + } + return [makeChildInfo(for: content, context: context)] +} diff --git a/Sources/SwiftTUI/SwiftTUI.swift b/Sources/SwiftTUI/SwiftTUI.swift index 08b22b8..e98ba6d 100644 --- a/Sources/SwiftTUI/SwiftTUI.swift +++ b/Sources/SwiftTUI/SwiftTUI.swift @@ -1,2 +1,40 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book +// +// SwiftTUI.swift +// SwiftTUI +// +// A SwiftUI-like framework for Terminal User Interfaces. +// +// SwiftTUI enables creating TUI applications with a declarative, +// SwiftUI-like syntax - without ncurses or other low-level libraries. +// + +/// The current version of SwiftTUI. +public let swiftTUIVersion = "0.1.0" + +/// Executes a view closure and renders it once. +/// +/// This is useful for simple CLI tools that don't need a full TApp. +/// +/// # Example +/// +/// ```swift +/// renderOnce { +/// VStack { +/// Text("Hello, SwiftTUI!") +/// .bold() +/// .foregroundColor(.cyan) +/// Divider() +/// Text("Version \(swiftTUIVersion)") +/// .dim() +/// } +/// } +/// ``` +/// +/// - Parameter content: A ViewBuilder closure that defines the view to render. +@discardableResult +public func renderOnce(@TViewBuilder content: () -> Content) -> Int { + let view = content() + let renderer = ViewRenderer() + renderer.render(view) + return 0 // TODO: Return actual line count +} diff --git a/Sources/SwiftTUI/Views/Alert.swift b/Sources/SwiftTUI/Views/Alert.swift new file mode 100644 index 0000000..7787d4b --- /dev/null +++ b/Sources/SwiftTUI/Views/Alert.swift @@ -0,0 +1,267 @@ +// +// Alert.swift +// SwiftTUI +// +// A modal alert view with title, message, and optional actions. +// + +/// A modal alert view that displays a title, message, and optional action buttons. +/// +/// `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 +/// +/// ```swift +/// // Simple alert +/// Alert(title: "Warning", message: "Are you sure?") +/// +/// // Alert with custom actions +/// Alert(title: "Confirm", message: "Delete this item?") { +/// Text("[Yes]") +/// Text("[No]") +/// } +/// +/// // Modal overlay pattern +/// mainContent +/// .dimmed() +/// .overlay { +/// Alert(title: "Notice", message: "Operation complete!") +/// } +/// ``` +public struct Alert: TView { + /// The alert title. + public let title: String + + /// The alert message. + public let message: String + + /// The border style for the alert box. + public let borderStyle: BorderStyle + + /// The border color. + public let borderColor: Color? + + /// The title color. + public let titleColor: Color? + + /// The action views (typically buttons or styled text). + public let actions: Actions + + /// Creates an alert with custom action views. + /// + /// - Parameters: + /// - title: The alert title. + /// - message: The alert message. + /// - borderStyle: The border style (default: .rounded). + /// - borderColor: The border color (default: nil). + /// - titleColor: The title color (default: nil). + /// - actions: The action views to display below the message. + public init( + title: String, + message: String, + borderStyle: BorderStyle = .rounded, + borderColor: Color? = nil, + titleColor: Color? = nil, + @TViewBuilder actions: () -> Actions + ) { + self.title = title + self.message = message + self.borderStyle = borderStyle + self.borderColor = borderColor + self.titleColor = titleColor + self.actions = actions() + } + + public var body: some TView { + VStack(spacing: 1) { + // Title + if let color = titleColor { + Text(title) + .bold() + .foregroundColor(color) + } else { + Text(title) + .bold() + } + + // Message + Text(message) + + // Spacer between message and actions + Spacer(minLength: 1) + + // Actions (if any) + actions + } + .padding(EdgeInsets(horizontal: 2, vertical: 1)) + .border(borderStyle, color: borderColor) + } +} + +// MARK: - Convenience Initializer (no actions) + +extension Alert where Actions == EmptyView { + /// Creates an alert without action buttons. + /// + /// - Parameters: + /// - title: The alert title. + /// - message: The alert message. + /// - borderStyle: The border style (default: .rounded). + /// - borderColor: The border color (default: nil). + /// - titleColor: The title color (default: nil). + public init( + title: String, + message: String, + borderStyle: BorderStyle = .rounded, + borderColor: Color? = nil, + titleColor: Color? = nil + ) { + self.title = title + self.message = message + self.borderStyle = borderStyle + self.borderColor = borderColor + self.titleColor = titleColor + self.actions = EmptyView() + } +} + +// MARK: - Preset Alert Styles + +extension Alert { + /// Creates a warning-style alert with yellow border. + /// + /// - Parameters: + /// - title: The alert title (default: "Warning"). + /// - message: The alert message. + /// - actions: The action views. + /// - Returns: A warning-styled alert. + public static func warning( + title: String = "Warning", + message: String, + @TViewBuilder actions: () -> A + ) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .yellow, + titleColor: .yellow, + actions: actions + ) + } + + /// Creates an error-style alert with red border. + /// + /// - Parameters: + /// - title: The alert title (default: "Error"). + /// - message: The alert message. + /// - actions: The action views. + /// - Returns: An error-styled alert. + public static func error( + title: String = "Error", + message: String, + @TViewBuilder actions: () -> A + ) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .red, + titleColor: .red, + actions: actions + ) + } + + /// Creates an info-style alert with cyan border. + /// + /// - Parameters: + /// - title: The alert title (default: "Info"). + /// - message: The alert message. + /// - actions: The action views. + /// - Returns: An info-styled alert. + public static func info( + title: String = "Info", + message: String, + @TViewBuilder actions: () -> A + ) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .cyan, + titleColor: .cyan, + actions: actions + ) + } + + /// Creates a success-style alert with green border. + /// + /// - Parameters: + /// - title: The alert title (default: "Success"). + /// - message: The alert message. + /// - actions: The action views. + /// - Returns: A success-styled alert. + public static func success( + title: String = "Success", + message: String, + @TViewBuilder actions: () -> A + ) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .green, + titleColor: .green, + actions: actions + ) + } +} + +// MARK: - Preset Alerts without Actions + +extension Alert where Actions == EmptyView { + /// Creates a warning-style alert without actions. + public static func warning(title: String = "Warning", message: String) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .yellow, + titleColor: .yellow + ) + } + + /// Creates an error-style alert without actions. + public static func error(title: String = "Error", message: String) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .red, + titleColor: .red + ) + } + + /// Creates an info-style alert without actions. + public static func info(title: String = "Info", message: String) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .cyan, + titleColor: .cyan + ) + } + + /// Creates a success-style alert without actions. + public static func success(title: String = "Success", message: String) -> Alert { + Alert( + title: title, + message: message, + borderStyle: .rounded, + borderColor: .green, + titleColor: .green + ) + } +} diff --git a/Sources/SwiftTUI/Views/Box.swift b/Sources/SwiftTUI/Views/Box.swift new file mode 100644 index 0000000..6bf5c3b --- /dev/null +++ b/Sources/SwiftTUI/Views/Box.swift @@ -0,0 +1,56 @@ +// +// Box.swift +// SwiftTUI +// +// A simple bordered container view. +// + +/// A simple bordered container view. +/// +/// `Box` wraps content in a border without additional styling. +/// Use `Card` if you need padding and background as well. +/// +/// # Example +/// +/// ```swift +/// Box { +/// Text("Boxed content") +/// } +/// +/// Box(.doubleLine, color: .yellow) { +/// VStack { +/// Text("Line 1") +/// Text("Line 2") +/// } +/// } +/// ``` +public struct Box: TView { + /// The content of the box. + public let content: Content + + /// The border style. + public let borderStyle: BorderStyle + + /// The border color. + public let borderColor: Color? + + /// Creates a box with the specified border. + /// + /// - Parameters: + /// - borderStyle: The border style (default: .line). + /// - color: The border color (default: nil). + /// - content: The content of the box. + public init( + _ borderStyle: BorderStyle = .line, + color: Color? = nil, + @TViewBuilder content: () -> Content + ) { + self.content = content() + self.borderStyle = borderStyle + self.borderColor = color + } + + public var body: some TView { + content.border(borderStyle, color: borderColor) + } +} diff --git a/Sources/SwiftTUI/Views/Card.swift b/Sources/SwiftTUI/Views/Card.swift new file mode 100644 index 0000000..94c716e --- /dev/null +++ b/Sources/SwiftTUI/Views/Card.swift @@ -0,0 +1,77 @@ +// +// Card.swift +// SwiftTUI +// +// A styled container view with border, background, and padding. +// + +/// 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. +/// +/// # Example +/// +/// ```swift +/// Card { +/// Text("Card Title") +/// .bold() +/// Text("Card content goes here") +/// } +/// +/// Card(borderStyle: .rounded, borderColor: .cyan) { +/// Text("Styled Card") +/// } +/// ``` +public struct Card: TView { + /// The content of the card. + public let content: Content + + /// The border style. + public let borderStyle: BorderStyle + + /// The border color. + public let borderColor: Color? + + /// The background color (nil for transparent). + public let backgroundColor: Color? + + /// The padding inside the card. + public let padding: EdgeInsets + + /// Creates a card with the specified styling. + /// + /// - Parameters: + /// - borderStyle: The border style (default: .rounded). + /// - borderColor: The border color (default: nil). + /// - backgroundColor: The background color (default: nil). + /// - padding: The inner padding (default: 1 on all sides). + /// - content: The content of the card. + public init( + borderStyle: BorderStyle = .rounded, + borderColor: Color? = nil, + backgroundColor: Color? = nil, + padding: EdgeInsets = EdgeInsets(all: 1), + @TViewBuilder content: () -> Content + ) { + self.content = content() + self.borderStyle = borderStyle + self.borderColor = borderColor + self.backgroundColor = backgroundColor + self.padding = padding + } + + public var body: some TView { + // Build the card by composing modifiers + if let bgColor = backgroundColor { + content + .padding(padding) + .background(bgColor) + .border(borderStyle, color: borderColor) + } else { + content + .padding(padding) + .border(borderStyle, color: borderColor) + } + } +} diff --git a/Sources/SwiftTUI/Views/Dialog.swift b/Sources/SwiftTUI/Views/Dialog.swift new file mode 100644 index 0000000..07a8fdc --- /dev/null +++ b/Sources/SwiftTUI/Views/Dialog.swift @@ -0,0 +1,178 @@ +// +// Dialog.swift +// SwiftTUI +// +// A modal dialog view with title and custom content. +// + +/// A modal dialog view with a title and customizable content. +/// +/// `Dialog` is more flexible than `Alert` — it accepts any content, +/// making it suitable for forms, selections, or complex interactions. +/// +/// # Example +/// +/// ```swift +/// // Simple dialog +/// Dialog(title: "Settings") { +/// Text("Option 1: Enabled") +/// Text("Option 2: Disabled") +/// } +/// +/// // Dialog with custom styling +/// Dialog(title: "User Profile", borderStyle: .doubleLine, titleColor: .cyan) { +/// Text("Name: John Doe") +/// Text("Email: john@example.com") +/// Divider() +/// Text("[Edit] [Close]") +/// } +/// +/// // Modal overlay pattern +/// mainContent +/// .dimmed() +/// .overlay { +/// Dialog(title: "Confirm Action") { +/// Text("Are you sure you want to proceed?") +/// HStack { +/// Text("[Yes]").foregroundColor(.green) +/// Spacer() +/// Text("[No]").foregroundColor(.red) +/// } +/// } +/// } +/// ``` +public struct Dialog: TView { + /// The dialog title. + public let title: String + + /// The dialog content. + public let content: Content + + /// The border style. + public let borderStyle: BorderStyle + + /// The border color. + public let borderColor: Color? + + /// The title color. + public let titleColor: Color? + + /// The inner padding. + public let padding: EdgeInsets + + /// Creates a dialog with the specified options. + /// + /// - Parameters: + /// - title: The dialog title. + /// - borderStyle: The border style (default: .rounded). + /// - borderColor: The border color (default: nil). + /// - titleColor: The title color (default: nil). + /// - padding: The inner padding (default: horizontal 2, vertical 1). + /// - content: The dialog content. + public init( + title: String, + borderStyle: BorderStyle = .rounded, + borderColor: Color? = nil, + titleColor: Color? = nil, + padding: EdgeInsets = EdgeInsets(horizontal: 2, vertical: 1), + @TViewBuilder content: () -> Content + ) { + self.title = title + self.borderStyle = borderStyle + self.borderColor = borderColor + self.titleColor = titleColor + self.padding = padding + self.content = content() + } + + public var body: some TView { + Panel( + title, + borderStyle: borderStyle, + borderColor: borderColor, + titleColor: titleColor, + padding: padding + ) { + content + } + } +} + +// MARK: - Convenience Extensions + +extension Dialog { + /// Creates a dialog with a double-line border style. + /// + /// - Parameters: + /// - title: The dialog title. + /// - borderColor: The border color (default: nil). + /// - titleColor: The title color (default: nil). + /// - content: The dialog content. + /// - Returns: A dialog with double-line borders. + public static func doubleLine( + title: String, + borderColor: Color? = nil, + titleColor: Color? = nil, + @TViewBuilder content: () -> C + ) -> Dialog { + Dialog( + title: title, + borderStyle: .doubleLine, + borderColor: borderColor, + titleColor: titleColor, + content: content + ) + } + + /// Creates a dialog with a heavy border style. + /// + /// - Parameters: + /// - title: The dialog title. + /// - borderColor: The border color (default: nil). + /// - titleColor: The title color (default: nil). + /// - content: The dialog content. + /// - Returns: A dialog with heavy borders. + public static func heavy( + title: String, + borderColor: Color? = nil, + titleColor: Color? = nil, + @TViewBuilder content: () -> C + ) -> Dialog { + Dialog( + title: title, + borderStyle: .heavy, + borderColor: borderColor, + titleColor: titleColor, + content: content + ) + } +} + +// MARK: - Modal Presentation Helper + +extension TView { + /// Presents this view as a modal dialog over dimmed content. + /// + /// This is a convenience method that combines `.dimmed()` and `.overlay()` + /// with center alignment. + /// + /// # Example + /// + /// ```swift + /// mainContent.modal { + /// Dialog(title: "Settings") { + /// Text("Setting 1") + /// Text("Setting 2") + /// } + /// } + /// ``` + /// + /// - Parameter content: The modal content to display. + /// - Returns: A view with the modal overlay. + public func modal( + @TViewBuilder content: () -> Modal + ) -> some TView { + self.dimmed() + .overlay(alignment: .center, content: content) + } +} diff --git a/Sources/SwiftTUI/Views/ForEach.swift b/Sources/SwiftTUI/Views/ForEach.swift new file mode 100644 index 0000000..3186250 --- /dev/null +++ b/Sources/SwiftTUI/Views/ForEach.swift @@ -0,0 +1,107 @@ +// +// ForEach.swift +// SwiftTUI +// +// Iteration over data collections for view generation. +// + +/// A view that generates views from a collection of data. +/// +/// `ForEach` iterates over a collection and creates a view for each +/// element. The collection elements must be `Identifiable` or an +/// explicit ID key path must be provided. +/// +/// # Example with Identifiable +/// +/// ```swift +/// struct Item: Identifiable { +/// let id: String +/// let name: String +/// } +/// +/// let items = [Item(id: "1", name: "One"), Item(id: "2", name: "Two")] +/// +/// VStack { +/// ForEach(items) { item in +/// Text(item.name) +/// } +/// } +/// ``` +/// +/// # Example with explicit ID key path +/// +/// ```swift +/// let names = ["Anna", "Bob", "Clara"] +/// +/// VStack { +/// ForEach(names, id: \.self) { name in +/// Text(name) +/// } +/// } +/// ``` +public struct ForEach: TView { + /// The underlying data collection. + public let data: Data + + /// The key path to the unique ID of each element. + public let idKeyPath: KeyPath + + /// The closure that creates a view for each element. + public let content: (Data.Element) -> Content + + /// Creates a ForEach with an explicit ID key path. + /// + /// - Parameters: + /// - data: The collection to iterate over. + /// - id: The key path to the unique ID of each element. + /// - content: The closure that creates the view for each element. + public init( + _ data: Data, + id: KeyPath, + @TViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.data = data + self.idKeyPath = id + self.content = content + } + + public var body: Never { + fatalError("ForEach renders its children directly") + } +} + +// MARK: - ForEach with Identifiable + +extension ForEach where Data.Element: Identifiable, ID == Data.Element.ID { + /// Creates a ForEach for Identifiable elements. + /// + /// - Parameters: + /// - data: The collection with Identifiable elements. + /// - content: The closure that creates the view for each element. + public init( + _ data: Data, + @TViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.data = data + self.idKeyPath = \Data.Element.id + self.content = content + } +} + +// MARK: - ForEach with Range + +extension ForEach where Data == Range, ID == Int { + /// Creates a ForEach over an integer range. + /// + /// - Parameters: + /// - data: The range, e.g., `0..<10`. + /// - content: The closure that creates the view for each index. + public init( + _ data: Range, + @TViewBuilder content: @escaping (Int) -> Content + ) { + self.data = data + self.idKeyPath = \.self + self.content = content + } +} diff --git a/Sources/SwiftTUI/Views/Menu.swift b/Sources/SwiftTUI/Views/Menu.swift new file mode 100644 index 0000000..e7bfa78 --- /dev/null +++ b/Sources/SwiftTUI/Views/Menu.swift @@ -0,0 +1,201 @@ +// +// Menu.swift +// SwiftTUI +// +// A menu view that displays a list of selectable items. +// + +/// A menu item representing a single selectable option. +public struct MenuItem: Identifiable { + /// The unique identifier. + public let id: String + + /// The display label. + public let label: String + + /// An optional keyboard shortcut (e.g., "1", "a", "q"). + public let shortcut: Character? + + /// Creates a menu item. + /// + /// - Parameters: + /// - id: The unique identifier (defaults to label). + /// - label: The display label. + /// - shortcut: An optional keyboard shortcut character. + public init(id: String? = nil, label: String, shortcut: Character? = nil) { + self.id = id ?? label + self.label = label + self.shortcut = shortcut + } +} + +/// A vertical menu displaying a list of selectable items. +/// +/// `Menu` renders items as a vertical list with optional shortcuts. +/// The currently selected item is highlighted. Since SwiftTUI doesn't +/// have state management yet, selection is passed in as a parameter. +/// +/// # Example +/// +/// ```swift +/// Menu( +/// title: "Main Menu", +/// items: [ +/// MenuItem(label: "Text Styles", shortcut: "1"), +/// MenuItem(label: "Colors", shortcut: "2"), +/// MenuItem(label: "Containers", shortcut: "3"), +/// MenuItem(label: "Quit", shortcut: "q") +/// ], +/// selectedIndex: 0 +/// ) +/// ``` +public struct Menu: TView { + /// The menu title (optional). + public let title: String? + + /// The menu items. + public let items: [MenuItem] + + /// The currently selected item index. + public let selectedIndex: Int + + /// The style for unselected items. + public let itemColor: Color? + + /// The style for the selected item. + public let selectedColor: Color? + + /// The indicator for the selected item. + public let selectionIndicator: String + + /// The border style (nil for no border). + public let borderStyle: BorderStyle? + + /// The border color. + public let borderColor: Color? + + /// Creates a menu with the specified options. + /// + /// - Parameters: + /// - title: The menu title (optional). + /// - items: The menu items. + /// - selectedIndex: The currently selected item index (default: 0). + /// - itemColor: The color for unselected items (default: nil). + /// - selectedColor: The color for the selected item (default: .cyan). + /// - selectionIndicator: The indicator shown before selected item (default: "▶ "). + /// - borderStyle: The border style (default: .rounded). + /// - borderColor: The border color (default: nil). + public init( + title: String? = nil, + items: [MenuItem], + selectedIndex: Int = 0, + itemColor: Color? = nil, + selectedColor: Color? = .cyan, + selectionIndicator: String = "▶ ", + borderStyle: BorderStyle? = .rounded, + borderColor: Color? = nil + ) { + self.title = title + self.items = items + self.selectedIndex = max(0, min(selectedIndex, items.count - 1)) + self.itemColor = itemColor + self.selectedColor = selectedColor + self.selectionIndicator = selectionIndicator + self.borderStyle = borderStyle + self.borderColor = borderColor + } + + public var body: some TView { + let menuContent = VStack(alignment: .leading, spacing: 0) { + // Title if present + if let menuTitle = title { + Text(menuTitle) + .bold() + .foregroundColor(selectedColor ?? .cyan) + Divider() + Spacer(minLength: 1) + } + + // Menu items + ForEach(items.indices, id: \.self) { index in + menuItemView(for: index) + } + } + .padding(EdgeInsets(horizontal: 1, vertical: 0)) + + // Apply border if specified + if let border = borderStyle { + return menuContent.border(border, color: borderColor).asAnyView() + } else { + return menuContent.asAnyView() + } + } + + /// Creates the view for a single menu item. + private func menuItemView(for index: Int) -> some TView { + let item = items[index] + let isSelected = index == selectedIndex + let prefix = isSelected ? selectionIndicator : String(repeating: " ", count: selectionIndicator.count) + + // Build the label with optional shortcut + let labelText: String + if let shortcut = item.shortcut { + labelText = "[\(shortcut)] \(item.label)" + } else { + labelText = " \(item.label)" + } + + let fullText = prefix + labelText + + if isSelected { + if let color = selectedColor { + return Text(fullText).bold().foregroundColor(color).asAnyView() + } else { + return Text(fullText).bold().asAnyView() + } + } else { + if let color = itemColor { + return Text(fullText).foregroundColor(color).asAnyView() + } else { + return Text(fullText).asAnyView() + } + } + } +} + +// MARK: - AnyView Helper + +/// A type-erased view for conditional returns. +/// +/// This is a temporary solution until we have proper `@ViewBuilder` +/// support for complex conditionals. +public struct AnyView: TView { + private let _render: (RenderContext) -> FrameBuffer + + /// Creates an AnyView wrapping the given view. + public init(_ view: V) { + self._render = { context in + SwiftTUI.renderToBuffer(view, context: context) + } + } + + public var body: Never { + fatalError("AnyView renders via Renderable") + } +} + +extension AnyView: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + _render(context) + } +} + +extension TView { + /// Wraps this view in an AnyView for type erasure. + /// + /// Use this when you need to return different view types from + /// conditional branches. + public func asAnyView() -> AnyView { + AnyView(self) + } +} diff --git a/Sources/SwiftTUI/Views/Panel.swift b/Sources/SwiftTUI/Views/Panel.swift new file mode 100644 index 0000000..1a89377 --- /dev/null +++ b/Sources/SwiftTUI/Views/Panel.swift @@ -0,0 +1,138 @@ +// +// Panel.swift +// SwiftTUI +// +// A titled container view with a header. +// + +/// A bordered container with a title in the top border. +/// +/// `Panel` is useful for grouping content with a visible label, +/// similar to a fieldset in HTML or a group box in desktop UIs. +/// +/// # Example +/// +/// ```swift +/// Panel("Settings") { +/// Text("Option 1") +/// Text("Option 2") +/// } +/// +/// Panel("User Info", borderStyle: .doubleLine, titleColor: .cyan) { +/// Text("Name: John") +/// Text("Age: 30") +/// } +/// ``` +public struct Panel: TView { + /// The title displayed in the top border. + public let title: String + + /// The content of the panel. + public let content: Content + + /// The border style. + public let borderStyle: BorderStyle + + /// The border color. + public let borderColor: Color? + + /// The title color. + public let titleColor: Color? + + /// The padding inside the panel. + public let padding: EdgeInsets + + /// Creates a panel with the specified options. + /// + /// - Parameters: + /// - title: The title to display in the top border. + /// - borderStyle: The border style (default: .line). + /// - borderColor: The border color (default: nil). + /// - titleColor: The title color (default: nil, same as border). + /// - padding: The inner padding (default: horizontal 1, vertical 0). + /// - content: The content of the panel. + public init( + _ title: String, + borderStyle: BorderStyle = .line, + borderColor: Color? = nil, + titleColor: Color? = nil, + padding: EdgeInsets = EdgeInsets(horizontal: 1, vertical: 0), + @TViewBuilder content: () -> Content + ) { + self.title = title + self.content = content() + self.borderStyle = borderStyle + self.borderColor = borderColor + self.titleColor = titleColor + self.padding = padding + } + + public var body: Never { + fatalError("Panel renders via Renderable") + } +} + +// MARK: - Panel Rendering + +extension Panel: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + // Render the content first + let paddedContent = content.padding(padding) + let contentBuffer = SwiftTUI.renderToBuffer(paddedContent, context: context) + + guard !contentBuffer.isEmpty else { + return FrameBuffer() + } + + let innerWidth = max(contentBuffer.width, title.count + 4) + + // Build top border with title + // Format: ┌─ Title ─────┐ + let titleText = " \(title) " + let titleStyled = colorize(titleText, with: titleColor ?? borderColor) + + let leftPart = colorize( + String(borderStyle.topLeft) + String(borderStyle.horizontal), + with: borderColor + ) + let rightPartLength = max(0, innerWidth - 2 - title.count - 2) + let rightPart = colorize( + String(repeating: borderStyle.horizontal, count: rightPartLength) + String(borderStyle.topRight), + with: borderColor + ) + let topLine = leftPart + titleStyled + rightPart + + // Build bottom border + let bottomLine = colorize( + String(borderStyle.bottomLeft) + + String(repeating: borderStyle.horizontal, count: innerWidth) + + String(borderStyle.bottomRight), + with: borderColor + ) + + // Build result + var lines: [String] = [] + lines.append(topLine) + + // Content lines with side borders + let leftBorder = colorize(String(borderStyle.vertical), with: borderColor) + let rightBorder = colorize(String(borderStyle.vertical), with: borderColor) + + for line in contentBuffer.lines { + let paddedLine = line.padToVisibleWidth(innerWidth) + lines.append(leftBorder + paddedLine + rightBorder) + } + + lines.append(bottomLine) + + return FrameBuffer(lines: lines) + } + + /// Applies color to a string if a color is set. + private func colorize(_ string: String, with color: Color?) -> String { + guard let color = color else { return string } + var style = TextStyle() + style.foregroundColor = color + return ANSIRenderer.render(string, with: style) + } +} diff --git a/Sources/SwiftTUI/Views/Spacer.swift b/Sources/SwiftTUI/Views/Spacer.swift new file mode 100644 index 0000000..eeb04a8 --- /dev/null +++ b/Sources/SwiftTUI/Views/Spacer.swift @@ -0,0 +1,89 @@ +// +// Spacer.swift +// SwiftTUI +// +// Flexible spacing elements for layout. +// + +/// A flexible spacer that fills available space. +/// +/// `Spacer` expands along the main axis of its container +/// and fills the available space between other views. +/// +/// # Example in HStack +/// +/// ```swift +/// HStack { +/// Text("Left") +/// Spacer() +/// Text("Right") +/// } +/// // Result: "Left Right" +/// ``` +/// +/// # Example in VStack +/// +/// ```swift +/// VStack { +/// Text("Top") +/// Spacer() +/// Text("Bottom") +/// } +/// ``` +public struct Spacer: TView { + /// The minimum length of the spacer (in characters/lines). + public let minLength: Int? + + /// Creates a spacer with optional minimum length. + /// + /// - Parameter minLength: The minimum length. If nil, the + /// spacer expands as much as possible. + public init(minLength: Int? = nil) { + self.minLength = minLength + } + + public var body: Never { + fatalError("Spacer is a primitive view") + } +} + +// MARK: - Divider + +/// A visual separator between views. +/// +/// `Divider` creates a horizontal or vertical line, +/// depending on the surrounding container. +/// +/// # Example +/// +/// ```swift +/// VStack { +/// Text("Section 1") +/// Divider() +/// Text("Section 2") +/// } +/// // Result: +/// // Section 1 +/// // ───────────── +/// // Section 2 +/// ``` +public struct Divider: TView { + /// The character used for the line. + public var character: Character + + /// Creates a divider with the default character (─). + public init() { + self.character = "─" + } + + /// Creates a divider with a custom character. + /// + /// - Parameter character: The character for the separator line. + public init(character: Character) { + self.character = character + } + + public var body: Never { + fatalError("Divider is a primitive view") + } +} diff --git a/Sources/SwiftTUI/Views/Stacks.swift b/Sources/SwiftTUI/Views/Stacks.swift new file mode 100644 index 0000000..80a49e3 --- /dev/null +++ b/Sources/SwiftTUI/Views/Stacks.swift @@ -0,0 +1,230 @@ +// +// Stacks.swift +// SwiftTUI +// +// Layout containers for vertical and horizontal arrangement. +// + +// MARK: - VStack + +/// A view that arranges its children vertically. +/// +/// `VStack` stacks its child views on top of each other, from top to bottom. +/// This corresponds to the default behavior in a terminal. +/// +/// # Example +/// +/// ```swift +/// VStack { +/// Text("Line 1") +/// Text("Line 2") +/// Text("Line 3") +/// } +/// ``` +/// +/// # Alignment +/// +/// ```swift +/// VStack(alignment: .center) { +/// Text("Short") +/// Text("Longer text") +/// } +/// ``` +public struct VStack: TView { + /// The horizontal alignment of the children. + public let alignment: HorizontalAlignment + + /// The vertical spacing between children. + public let spacing: Int + + /// The content of the stack. + public let content: Content + + /// Creates a vertical stack with the specified options. + /// + /// - Parameters: + /// - alignment: The horizontal alignment of children (default: .leading). + /// - spacing: The spacing between children in lines (default: 0). + /// - content: A ViewBuilder that defines the children. + public init( + alignment: HorizontalAlignment = .leading, + spacing: Int = 0, + @TViewBuilder content: () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + public var body: Never { + fatalError("VStack is a primitive container and renders its children directly") + } +} + +// MARK: - HStack + +/// A view that arranges its children horizontally. +/// +/// `HStack` arranges its child views side by side, from left to right. +/// +/// # Example +/// +/// ```swift +/// HStack { +/// Text("[OK]") +/// Text("[Cancel]") +/// } +/// ``` +/// +/// # Alignment +/// +/// ```swift +/// HStack(alignment: .top) { +/// Text("Left") +/// Text("Right") +/// } +/// ``` +public struct HStack: TView { + /// The vertical alignment of the children. + public let alignment: VerticalAlignment + + /// The horizontal spacing between children. + public let spacing: Int + + /// The content of the stack. + public let content: Content + + /// Creates a horizontal stack with the specified options. + /// + /// - Parameters: + /// - alignment: The vertical alignment of children (default: .center). + /// - spacing: The spacing between children in characters (default: 1). + /// - content: A ViewBuilder that defines the children. + public init( + alignment: VerticalAlignment = .center, + spacing: Int = 1, + @TViewBuilder content: () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + public var body: Never { + fatalError("HStack is a primitive container and renders its children directly") + } +} + +// MARK: - ZStack + +/// A view that stacks its children on top of each other (z-axis). +/// +/// `ZStack` layers views on top of each other, with later views +/// appearing above earlier ones. +/// +/// # Example +/// +/// ```swift +/// ZStack { +/// Text("████████████████") +/// Text(" Overlay ") +/// } +/// ``` +public struct ZStack: TView { + /// The alignment of the children. + public let alignment: Alignment + + /// The content of the stack. + public let content: Content + + /// Creates a z-stack with the specified options. + /// + /// - Parameters: + /// - alignment: The alignment of children (default: .center). + /// - content: A ViewBuilder that defines the children. + public init( + alignment: Alignment = .center, + @TViewBuilder content: () -> Content + ) { + self.alignment = alignment + self.content = content() + } + + public var body: Never { + fatalError("ZStack is a primitive container and renders its children directly") + } +} + +// MARK: - Alignment Types + +/// Horizontal alignment for VStack and similar containers. +public enum HorizontalAlignment: Sendable { + /// Align to the leading (left) edge. + case leading + + /// Align to the center. + case center + + /// Align to the trailing (right) edge. + case trailing +} + +/// Vertical alignment for HStack and similar containers. +public enum VerticalAlignment: Sendable { + /// Align to the top edge. + case top + + /// Align to the vertical center. + case center + + /// Align to the bottom edge. + case bottom +} + +/// Combined alignment for both axes. +public struct Alignment: Sendable { + /// The horizontal component. + public let horizontal: HorizontalAlignment + + /// The vertical component. + public let vertical: VerticalAlignment + + /// Creates a combined alignment. + /// + /// - Parameters: + /// - horizontal: The horizontal alignment. + /// - vertical: The vertical alignment. + public init(horizontal: HorizontalAlignment, vertical: VerticalAlignment) { + self.horizontal = horizontal + self.vertical = vertical + } + + // MARK: - Preset Alignments + + /// Top leading. + public static let topLeading = Alignment(horizontal: .leading, vertical: .top) + + /// Top center. + public static let top = Alignment(horizontal: .center, vertical: .top) + + /// Top trailing. + public static let topTrailing = Alignment(horizontal: .trailing, vertical: .top) + + /// Center leading. + public static let leading = Alignment(horizontal: .leading, vertical: .center) + + /// Center. + public static let center = Alignment(horizontal: .center, vertical: .center) + + /// Center trailing. + public static let trailing = Alignment(horizontal: .trailing, vertical: .center) + + /// Bottom leading. + public static let bottomLeading = Alignment(horizontal: .leading, vertical: .bottom) + + /// Bottom center. + public static let bottom = Alignment(horizontal: .center, vertical: .bottom) + + /// Bottom trailing. + public static let bottomTrailing = Alignment(horizontal: .trailing, vertical: .bottom) +} diff --git a/Sources/SwiftTUI/Views/Text.swift b/Sources/SwiftTUI/Views/Text.swift new file mode 100644 index 0000000..f5986b4 --- /dev/null +++ b/Sources/SwiftTUI/Views/Text.swift @@ -0,0 +1,174 @@ +// +// Text.swift +// SwiftTUI +// +// A view for displaying text in the terminal. +// + +/// A view that displays text in the terminal. +/// +/// `Text` is one of the most fundamental views in SwiftTUI. It displays +/// a string in the terminal and supports various formatting options. +/// +/// # Example +/// +/// ```swift +/// Text("Hello, World!") +/// +/// Text("Bold") +/// .bold() +/// +/// Text("Colored") +/// .foregroundColor(.red) +/// ``` +public struct Text: TView { + /// The text to display. + public let content: String + + /// The style of the text (color, formatting, etc.). + public var style: TextStyle + + /// Creates a text view with the specified string. + /// + /// - Parameter content: The text to display. + public init(_ content: String) { + self.content = content + self.style = TextStyle() + } + + /// Creates a text view with a verbatim string. + /// + /// - Parameter verbatim: The text to display verbatim. + public init(verbatim: String) { + self.content = verbatim + self.style = TextStyle() + } + + public var body: Never { + fatalError("Text is a primitive view and renders directly") + } +} + +// MARK: - Text Modifiers + +extension Text { + /// Sets the text color. + /// + /// - Parameter color: The desired foreground color. + /// - Returns: A new text with the applied color. + public func foregroundColor(_ color: Color) -> Text { + var copy = self + copy.style.foregroundColor = color + return copy + } + + /// Sets the background color. + /// + /// - Parameter color: The desired background color. + /// - Returns: A new text with the applied background color. + public func backgroundColor(_ color: Color) -> Text { + var copy = self + copy.style.backgroundColor = color + return copy + } + + /// Makes the text bold. + /// + /// - Returns: A new text with bold formatting. + public func bold() -> Text { + var copy = self + copy.style.isBold = true + return copy + } + + /// Makes the text italic. + /// + /// - Returns: A new text with italic formatting. + public func italic() -> Text { + var copy = self + copy.style.isItalic = true + return copy + } + + /// Underlines the text. + /// + /// - Returns: A new text with underline formatting. + public func underline() -> Text { + var copy = self + copy.style.isUnderlined = true + return copy + } + + /// Strikes through the text. + /// + /// - Returns: A new text with strikethrough formatting. + public func strikethrough() -> Text { + var copy = self + copy.style.isStrikethrough = true + return copy + } + + /// Dims the text (reduced intensity). + /// + /// - Returns: A new text with dimmed appearance. + public func dim() -> Text { + var copy = self + copy.style.isDim = true + return copy + } + + /// Makes the text blink (if supported by the terminal). + /// + /// - Returns: A new text with blink effect. + public func blink() -> Text { + var copy = self + copy.style.isBlink = true + return copy + } + + /// Inverts foreground and background colors. + /// + /// - Returns: A new text with inverted colors. + public func inverted() -> Text { + var copy = self + copy.style.isInverted = true + return copy + } +} + +// MARK: - TextStyle + +/// The style of a text view. +/// +/// Contains all formatting options like color, bold, etc. +public struct TextStyle: Sendable { + /// The foreground color of the text. + public var foregroundColor: Color? + + /// The background color of the text. + public var backgroundColor: Color? + + /// Whether the text is bold. + public var isBold: Bool = false + + /// Whether the text is italic. + public var isItalic: Bool = false + + /// Whether the text is underlined. + public var isUnderlined: Bool = false + + /// Whether the text is strikethrough. + public var isStrikethrough: Bool = false + + /// Whether the text is dimmed. + public var isDim: Bool = false + + /// Whether the text blinks. + public var isBlink: Bool = false + + /// Whether foreground and background colors are inverted. + public var isInverted: Bool = false + + /// Creates a default TextStyle with no formatting. + public init() {} +} diff --git a/Sources/SwiftTUIExample/main.swift b/Sources/SwiftTUIExample/main.swift new file mode 100644 index 0000000..4f5ffb1 --- /dev/null +++ b/Sources/SwiftTUIExample/main.swift @@ -0,0 +1,477 @@ +// +// main.swift +// SwiftTUIExample +// +// A comprehensive example app demonstrating SwiftTUI capabilities. +// Features a main menu with multiple demo pages. +// + +import SwiftTUI + +// MARK: - Demo Page Enum + +/// The available demo pages in the example app. +enum DemoPage: String, CaseIterable { + case menu = "Main Menu" + case textStyles = "Text Styles" + case colors = "Colors" + case containers = "Containers" + case overlays = "Overlays" + case layout = "Layout" +} + +// MARK: - Shared Components + +/// A styled header with title on the left and version on the right. +struct HeaderView: TView { + let title: String + let subtitle: String? + + init(title: String, subtitle: String? = nil) { + self.title = title + self.subtitle = subtitle + } + + var body: some TView { + VStack { + HStack { + Text(title) + .bold() + .foregroundColor(.cyan) + Spacer() + Text("SwiftTUI v\(swiftTUIVersion)") + .dim() + } + if let sub = subtitle { + Text(sub) + .dim() + .italic() + } + Divider(character: "═") + } + } +} + +/// A footer with navigation hints. +struct FooterView: TView { + let showBackHint: Bool + + var body: some TView { + VStack { + Divider(character: "─") + HStack { + if showBackHint { + Text("[B] Back to Menu") + .dim() + Text(" ") + } + Text("[Q] Quit") + .dim() + Spacer() + Text("SwiftTUI") + .dim() + .italic() + } + } + } +} + +/// A section with a title and content. +struct DemoSection: TView { + let title: String + let content: Content + + init(_ title: String, @TViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some TView { + VStack(alignment: .leading) { + Text(title) + .bold() + .underline() + .foregroundColor(.yellow) + content + } + } +} + +// MARK: - Main Menu Page + +struct MainMenuPage: TView { + var body: some TView { + VStack(spacing: 1) { + HeaderView( + title: "SwiftTUI Example App", + subtitle: "A SwiftUI-like framework for Terminal User Interfaces" + ) + + Spacer(minLength: 1) + + HStack { + Spacer() + Menu( + title: "Select a Demo", + items: [ + MenuItem(label: "Text Styles", shortcut: "1"), + MenuItem(label: "Colors", shortcut: "2"), + MenuItem(label: "Container Views", shortcut: "3"), + MenuItem(label: "Overlays & Modals", shortcut: "4"), + MenuItem(label: "Layout System", shortcut: "5"), + MenuItem(label: "Quit", shortcut: "q") + ], + selectedIndex: 0, + selectedColor: .cyan, + borderStyle: .rounded, + borderColor: .brightBlack + ) + Spacer() + } + + Spacer(minLength: 1) + + // Feature highlights + HStack(spacing: 3) { + featureBox("Pure Swift", "No ncurses") + featureBox("Declarative", "SwiftUI-like") + featureBox("Composable", "View protocol") + } + + Spacer() + + FooterView(showBackHint: false) + } + } + + private func featureBox(_ title: String, _ subtitle: String) -> some TView { + VStack { + Text(title) + .bold() + .foregroundColor(.green) + Text(subtitle) + .dim() + } + .padding(EdgeInsets(horizontal: 2, vertical: 1)) + .border(.rounded, color: .brightBlack) + } +} + +// MARK: - Text Styles Demo Page + +struct TextStylesPage: TView { + var body: some TView { + VStack(spacing: 1) { + HeaderView(title: "Text Styles Demo") + + DemoSection("Basic Styles") { + Text("Normal text - no styling applied") + Text("Bold text").bold() + Text("Italic text").italic() + Text("Underlined text").underline() + Text("Strikethrough text").strikethrough() + Text("Dimmed text").dim() + } + + DemoSection("Combined Styles") { + Text("Bold + Italic").bold().italic() + Text("Bold + Underline").bold().underline() + Text("Bold + Color").bold().foregroundColor(.cyan) + Text("Italic + Dim").italic().dim() + Text("All combined").bold().italic().underline().foregroundColor(.magenta) + } + + DemoSection("Special Effects") { + Text("Blinking text (if terminal supports)").blink() + Text("Inverted colors").inverted() + } + + Spacer() + FooterView(showBackHint: true) + } + } +} + +// MARK: - Colors Demo Page + +struct ColorsPage: TView { + var body: some TView { + VStack(spacing: 1) { + HeaderView(title: "Colors Demo") + + DemoSection("Standard ANSI Colors") { + HStack(spacing: 2) { + Text("Black").foregroundColor(.black).background(.white) + Text("Red").foregroundColor(.red) + Text("Green").foregroundColor(.green) + Text("Yellow").foregroundColor(.yellow) + } + HStack(spacing: 2) { + Text("Blue").foregroundColor(.blue) + Text("Magenta").foregroundColor(.magenta) + Text("Cyan").foregroundColor(.cyan) + Text("White").foregroundColor(.white) + } + } + + DemoSection("Bright Colors") { + HStack(spacing: 2) { + Text("Bright Red").foregroundColor(.brightRed) + Text("Bright Green").foregroundColor(.brightGreen) + Text("Bright Yellow").foregroundColor(.brightYellow) + Text("Bright Blue").foregroundColor(.brightBlue) + } + } + + DemoSection("RGB Colors (24-bit)") { + HStack(spacing: 2) { + Text("Orange").foregroundColor(.rgb(255, 128, 0)) + Text("Pink").foregroundColor(.rgb(255, 105, 180)) + Text("Teal").foregroundColor(.rgb(0, 128, 128)) + Text("Purple").foregroundColor(.rgb(128, 0, 128)) + } + } + + DemoSection("Hex Colors") { + HStack(spacing: 2) { + Text("#FF6B6B").foregroundColor(.hex(0xFF6B6B)) + Text("#4ECDC4").foregroundColor(.hex(0x4ECDC4)) + Text("#45B7D1").foregroundColor(.hex(0x45B7D1)) + Text("#96CEB4").foregroundColor(.hex(0x96CEB4)) + } + } + + DemoSection("Semantic Colors") { + HStack(spacing: 2) { + Text("Primary").foregroundColor(.primary) + Text("Secondary").foregroundColor(.secondary) + Text("Accent").foregroundColor(.accent) + } + HStack(spacing: 2) { + Text("Success").foregroundColor(.success) + Text("Warning").foregroundColor(.warning) + Text("Error").foregroundColor(.error) + } + } + + Spacer() + FooterView(showBackHint: true) + } + } +} + +// MARK: - Containers Demo Page + +struct ContainersPage: TView { + var body: some TView { + VStack(spacing: 1) { + HeaderView(title: "Container Views Demo") + + HStack(spacing: 2) { + // Card example + VStack(alignment: .leading) { + Text("Card").bold().foregroundColor(.yellow) + Card(borderStyle: .rounded, borderColor: .cyan) { + Text("A Card view") + Text("with padding").dim() + Text("and border") + } + } + + // Box example + VStack(alignment: .leading) { + Text("Box").bold().foregroundColor(.yellow) + Box(.doubleLine, color: .green) { + Text("Simple Box") + Text("Double line border") + } + } + } + + HStack(spacing: 2) { + // Panel example + VStack(alignment: .leading) { + Text("Panel").bold().foregroundColor(.yellow) + Panel("Settings", borderStyle: .line, titleColor: .magenta) { + Text("Title in border") + Text("Great for sections") + } + } + + // Nested containers + VStack(alignment: .leading) { + Text("Nested").bold().foregroundColor(.yellow) + Box(.rounded, color: .brightBlack) { + Card(borderColor: .cyan) { + Text("Box > Card") + } + } + } + } + + DemoSection("Border Styles") { + HStack(spacing: 1) { + Box(.line) { Text("line") } + Box(.rounded) { Text("rounded") } + Box(.doubleLine) { Text("double") } + Box(.heavy) { Text("heavy") } + } + HStack(spacing: 1) { + Box(.dashed) { Text("dashed") } + Box(.dotted) { Text("dotted") } + Box(.ascii) { Text("ascii") } + Box(.block) { Text("block") } + } + } + + Spacer() + FooterView(showBackHint: true) + } + } +} + +// MARK: - Overlays Demo Page + +struct OverlaysPage: TView { + var body: some TView { + // Background content with modal overlay + backgroundContent + .modal { + Alert( + title: "Modal Alert", + message: "This alert overlays dimmed content!", + borderStyle: .rounded, + borderColor: .yellow, + titleColor: .yellow + ) { + HStack { + Text("[OK]").bold().foregroundColor(.green) + Spacer() + Text("[Cancel]").foregroundColor(.red) + } + } + } + } + + var backgroundContent: some TView { + VStack(spacing: 1) { + HeaderView(title: "Overlays & Modals Demo") + + DemoSection("Overlay System Features") { + Text("• .overlay() modifier - layer content on top") + Text("• .dimmed() modifier - reduce visual emphasis") + Text("• .modal() helper - combines dimmed + centered overlay") + Text("• Character-level compositing in FrameBuffer") + } + + DemoSection("Alert Presets") { + HStack(spacing: 2) { + VStack { + Text("Warning").foregroundColor(.yellow) + Text("Yellow border") + } + VStack { + Text("Error").foregroundColor(.red) + Text("Red border") + } + VStack { + Text("Info").foregroundColor(.cyan) + Text("Cyan border") + } + VStack { + Text("Success").foregroundColor(.green) + Text("Green border") + } + } + } + + DemoSection("Dialog View") { + Text("Dialog is a flexible modal container") + Text("with a title bar (Panel-based)") + } + + Spacer() + FooterView(showBackHint: true) + } + } +} + +// MARK: - Layout Demo Page + +struct LayoutPage: TView { + var body: some TView { + VStack(spacing: 1) { + HeaderView(title: "Layout System Demo") + + DemoSection("VStack (Vertical)") { + Box(.rounded, color: .brightBlack) { + VStack(spacing: 0) { + Text("Item 1") + Text("Item 2") + Text("Item 3") + } + } + } + + DemoSection("HStack (Horizontal)") { + Box(.rounded, color: .brightBlack) { + HStack(spacing: 2) { + Text("Left") + Text("Center") + Text("Right") + } + } + } + + DemoSection("Spacer") { + Box(.rounded, color: .brightBlack) { + HStack { + Text("Start") + Spacer() + Text("End") + } + } + } + + DemoSection("Padding & Frame") { + HStack(spacing: 2) { + VStack { + Text(".padding()").dim() + Text("Padded") + .padding(EdgeInsets(all: 1)) + .border(.line) + } + VStack { + Text(".frame()").dim() + Text("Framed") + .frame(width: 15, alignment: .center) + .border(.line) + } + } + } + + Spacer() + FooterView(showBackHint: true) + } + } +} + +// MARK: - Main App + +/// The main example application. +/// +/// This demonstrates the Menu view and multiple demo pages. +/// In a real app with state management, you would switch pages +/// based on user input. +struct ExampleApp: TApp { + var body: some TScene { + WindowGroup { + // Show the main menu page + // In a real app, you'd switch between pages based on state + MainMenuPage() + } + } +} + +// Run the app +ExampleApp.main() diff --git a/Tests/SwiftTUITests/TViewTests.swift b/Tests/SwiftTUITests/TViewTests.swift new file mode 100644 index 0000000..402d7c7 --- /dev/null +++ b/Tests/SwiftTUITests/TViewTests.swift @@ -0,0 +1,618 @@ +// +// TViewTests.swift +// SwiftTUI +// +// Tests for the TView protocol and ViewBuilder. +// + +import Testing +@testable import SwiftTUI + +@Suite("TView Protocol Tests") +struct TViewTests { + + @Test("Text view can be created") + func textViewCreation() { + let text = Text("Hello, World!") + #expect(text.content == "Hello, World!") + } + + @Test("Text view with style") + func textViewWithStyle() { + let text = Text("Bold").bold().foregroundColor(.red) + #expect(text.style.isBold == true) + #expect(text.style.foregroundColor == .red) + } + + @Test("EmptyView has no content") + func emptyView() { + _ = EmptyView() + // EmptyView should just be able to exist + } + + @Test("Spacer can be created") + func spacerCreation() { + let spacer = Spacer() + #expect(spacer.minLength == nil) + + let spacerWithLength = Spacer(minLength: 5) + #expect(spacerWithLength.minLength == 5) + } + + @Test("Divider uses default character") + func dividerDefaultCharacter() { + let divider = Divider() + #expect(divider.character == "─") + } + + @Test("Divider with custom character") + func dividerCustomCharacter() { + let divider = Divider(character: "=") + #expect(divider.character == "=") + } +} + +@Suite("ViewBuilder Tests") +struct ViewBuilderTests { + + @Test("ViewBuilder with single view") + func singleView() { + @TViewBuilder + func buildView() -> some TView { + Text("Single") + } + + let view = buildView() + #expect(view is Text) + } + + @Test("ViewBuilder with two views") + func twoViews() { + @TViewBuilder + func buildViews() -> some TView { + Text("First") + Text("Second") + } + + let views = buildViews() + #expect(views is TupleView2) + } + + @Test("ViewBuilder with three views") + func threeViews() { + @TViewBuilder + func buildViews() -> some TView { + Text("One") + Text("Two") + Text("Three") + } + + let views = buildViews() + #expect(views is TupleView3) + } + + @Test("VStack can contain views") + func vstackWithViews() { + let stack = VStack { + Text("Line 1") + Text("Line 2") + } + + #expect(stack.alignment == .leading) + #expect(stack.spacing == 0) + } + + @Test("HStack can contain views") + func hstackWithViews() { + let stack = HStack { + Text("Left") + Text("Right") + } + + #expect(stack.alignment == .center) + #expect(stack.spacing == 1) + } + + @Test("VStack with alignment and spacing") + func vstackWithOptions() { + let stack = VStack(alignment: .center, spacing: 2) { + Text("Centered") + } + + #expect(stack.alignment == .center) + #expect(stack.spacing == 2) + } +} + +@Suite("Color Tests") +struct ColorTests { + + @Test("Standard colors are available") + func standardColors() { + let colors: [Color] = [ + .black, .red, .green, .yellow, + .blue, .magenta, .cyan, .white + ] + + #expect(colors.count == 8) + } + + @Test("Bright colors are available") + func brightColors() { + let colors: [Color] = [ + .brightBlack, .brightRed, .brightGreen, .brightYellow, + .brightBlue, .brightMagenta, .brightCyan, .brightWhite + ] + + #expect(colors.count == 8) + } + + @Test("RGB color can be created") + func rgbColor() { + let color = Color.rgb(255, 128, 64) + #expect(color == Color.rgb(255, 128, 64)) + } + + @Test("Hex color can be created") + func hexColor() { + let color = Color.hex(0xFF8040) + #expect(color == Color.rgb(255, 128, 64)) + } + + @Test("Palette color can be created") + func paletteColor() { + let color = Color.palette(196) + #expect(color == Color.palette(196)) + } + + @Test("Semantic colors are defined") + func semanticColors() { + _ = Color.primary + _ = Color.secondary + _ = Color.accent + _ = Color.warning + _ = Color.error + _ = Color.success + } +} + +@Suite("ANSI Renderer Tests") +struct ANSIRendererTests { + + @Test("Reset code is correct") + func resetCode() { + #expect(ANSIRenderer.reset == "\u{1B}[0m") + } + + @Test("Text without style is returned unchanged") + func plainText() { + let result = ANSIRenderer.render("Hello", with: TextStyle()) + #expect(result == "Hello") + } + + @Test("Bold text has correct code") + func boldText() { + var style = TextStyle() + style.isBold = true + let result = ANSIRenderer.render("Bold", with: style) + #expect(result.contains("\u{1B}[1m")) + #expect(result.contains("\u{1B}[0m")) + } + + @Test("Cursor movement generates correct codes") + func cursorMovement() { + let moveCode = ANSIRenderer.moveCursor(toRow: 5, column: 10) + #expect(moveCode == "\u{1B}[5;10H") + } + + @Test("Clear screen generates correct code") + func clearScreen() { + #expect(ANSIRenderer.clearScreen == "\u{1B}[2J") + } +} + +@Suite("Alignment Tests") +struct AlignmentTests { + + @Test("Preset alignments are correct") + func presetAlignments() { + #expect(Alignment.topLeading.horizontal == .leading) + #expect(Alignment.topLeading.vertical == .top) + + #expect(Alignment.center.horizontal == .center) + #expect(Alignment.center.vertical == .center) + + #expect(Alignment.bottomTrailing.horizontal == .trailing) + #expect(Alignment.bottomTrailing.vertical == .bottom) + } +} + +@Suite("FrameBuffer Tests") +struct FrameBufferTests { + + @Test("Empty buffer has zero dimensions") + func emptyBuffer() { + let buffer = FrameBuffer() + #expect(buffer.width == 0) + #expect(buffer.height == 0) + #expect(buffer.isEmpty) + } + + @Test("Single line buffer has correct dimensions") + func singleLine() { + let buffer = FrameBuffer(text: "Hello") + #expect(buffer.width == 5) + #expect(buffer.height == 1) + #expect(buffer.lines == ["Hello"]) + } + + @Test("Vertical append stacks lines") + func verticalAppend() { + var buffer = FrameBuffer(text: "Line 1") + buffer.appendVertically(FrameBuffer(text: "Line 2")) + #expect(buffer.height == 2) + #expect(buffer.lines == ["Line 1", "Line 2"]) + } + + @Test("Vertical append with spacing") + func verticalAppendWithSpacing() { + var buffer = FrameBuffer(text: "Top") + buffer.appendVertically(FrameBuffer(text: "Bottom"), spacing: 2) + #expect(buffer.height == 4) + #expect(buffer.lines == ["Top", "", "", "Bottom"]) + } + + @Test("Horizontal append places side by side") + func horizontalAppend() { + var buffer = FrameBuffer(text: "Left") + buffer.appendHorizontally(FrameBuffer(text: "Right"), spacing: 1) + #expect(buffer.height == 1) + #expect(buffer.lines == ["Left Right"]) + } + + @Test("Horizontal append with different heights pads correctly") + func horizontalAppendDifferentHeights() { + var left = FrameBuffer(lines: ["AB", "CD"]) + let right = FrameBuffer(text: "X") + left.appendHorizontally(right, spacing: 1) + #expect(left.height == 2) + #expect(left.lines[0] == "AB X") + // Row 1: "CD" padded to width 2, spacing " ", no right content + #expect(left.lines[1] == "CD ") + } + + @Test("ANSI codes are excluded from width calculation") + func ansiStrippedWidth() { + let styled = "\u{1B}[1mBold\u{1B}[0m" + let buffer = FrameBuffer(text: styled) + #expect(buffer.width == 4) // "Bold" is 4 chars + } + + @Test("Horizontal append with ANSI codes pads correctly") + func horizontalAppendWithAnsi() { + let styled = "\u{1B}[1mHi\u{1B}[0m" + var left = FrameBuffer(text: styled) + left.appendHorizontally(FrameBuffer(text: "There"), spacing: 1) + #expect(left.height == 1) + // "Hi" (styled) + " " (spacing) + "There" + #expect(left.lines[0].stripped == "Hi There") + } +} + +@Suite("Rendering Tests") +struct RenderingTests { + + @Test("Text renders to single line buffer") + func textBuffer() { + let text = Text("Hello") + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(text, context: context) + #expect(buffer.height == 1) + #expect(buffer.lines[0] == "Hello") + } + + @Test("EmptyView renders to empty buffer") + func emptyViewBuffer() { + let empty = EmptyView() + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(empty, context: context) + #expect(buffer.isEmpty) + } + + @Test("VStack renders children vertically") + func vstackBuffer() { + let stack = VStack { + Text("Line 1") + Text("Line 2") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(stack, context: context) + #expect(buffer.height == 2) + #expect(buffer.lines[0] == "Line 1") + #expect(buffer.lines[1] == "Line 2") + } + + @Test("VStack renders with spacing") + func vstackWithSpacing() { + let stack = VStack(spacing: 1) { + Text("A") + Text("B") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(stack, context: context) + #expect(buffer.height == 3) + #expect(buffer.lines[0] == "A") + #expect(buffer.lines[1] == "") + #expect(buffer.lines[2] == "B") + } + + @Test("HStack renders children horizontally") + func hstackBuffer() { + let stack = HStack { + Text("Left") + Text("Right") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(stack, context: context) + #expect(buffer.height == 1) + #expect(buffer.lines[0] == "Left Right") + } + + @Test("HStack renders with custom spacing") + func hstackCustomSpacing() { + let stack = HStack(spacing: 3) { + Text("A") + Text("B") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(stack, context: context) + #expect(buffer.height == 1) + #expect(buffer.lines[0] == "A B") + } + + @Test("Nested VStack in HStack works") + func nestedStacks() { + let layout = HStack(spacing: 2) { + Text("Label:") + Text("Value") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(layout, context: context) + #expect(buffer.height == 1) + #expect(buffer.lines[0] == "Label: Value") + } + + @Test("Composite view renders through body") + func compositeView() { + struct MyView: TView { + var body: some TView { + VStack { + Text("Hello") + Text("World") + } + } + } + + let view = MyView() + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(view, context: context) + #expect(buffer.height == 2) + #expect(buffer.lines[0] == "Hello") + #expect(buffer.lines[1] == "World") + } + + @Test("Divider renders to full width") + func dividerBuffer() { + let divider = Divider() + let context = RenderContext(availableWidth: 20, availableHeight: 24) + let buffer = renderToBuffer(divider, context: context) + #expect(buffer.height == 1) + #expect(buffer.lines[0] == String(repeating: "─", count: 20)) + } + + @Test("Spacer renders empty lines") + func spacerBuffer() { + let spacer = Spacer(minLength: 3) + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(spacer, context: context) + #expect(buffer.height == 3) + } +} + +@Suite("Overlay Tests") +struct OverlayTests { + + @Test("Overlay modifier renders overlay on top of base") + func overlayRendering() { + let view = Text("Base Content") + .overlay { + Text("Top") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(view, context: context) + // The overlay "Top" should be centered on "Base Content" + #expect(buffer.height >= 1) + #expect(!buffer.isEmpty) + } + + @Test("Dimmed modifier applies dim effect") + func dimmedRendering() { + let view = Text("Dimmed text").dimmed() + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(view, context: context) + #expect(buffer.height == 1) + // Check that the ANSI dim code is present + #expect(buffer.lines[0].contains("\u{1B}[2m")) + } + + @Test("Modal helper combines dimmed and overlay") + func modalRendering() { + let view = Text("Background") + .modal { + Text("Modal") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(view, context: context) + // The result should contain both the dimmed background and the modal + #expect(!buffer.isEmpty) + } + + @Test("FrameBuffer compositing places overlay at correct position") + func frameBufferCompositing() { + let base = FrameBuffer(lines: ["AAAA", "AAAA", "AAAA"]) + let overlay = FrameBuffer(text: "X") + + // Place overlay at position (1, 1) + let result = base.composited(with: overlay, at: (x: 1, y: 1)) + + #expect(result.height == 3) + #expect(result.lines[0] == "AAAA") + #expect(result.lines[1].contains("X")) + #expect(result.lines[2] == "AAAA") + } + + @Test("FrameBuffer compositing with offset") + func frameBufferCompositingOffset() { + let base = FrameBuffer(lines: ["1234567890"]) + let overlay = FrameBuffer(text: "XXX") + + // Place overlay at column 3 + let result = base.composited(with: overlay, at: (x: 3, y: 0)) + + #expect(result.lines[0].stripped == "123XXX7890") + } +} + +@Suite("Alert Tests") +struct AlertTests { + + @Test("Alert can be created with title and message") + func alertCreation() { + let alert = Alert(title: "Test", message: "Test message") + #expect(alert.title == "Test") + #expect(alert.message == "Test message") + } + + @Test("Alert renders with border") + func alertRendering() { + let alert = Alert(title: "Warning", message: "Something happened") + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(alert, context: context) + #expect(buffer.height > 2) + // Should have border characters + let allContent = buffer.lines.joined() + #expect(allContent.contains("Warning")) + #expect(allContent.contains("Something happened")) + } +} + +@Suite("Dialog Tests") +struct DialogTests { + + @Test("Dialog can be created with title and content") + func dialogCreation() { + let dialog = Dialog(title: "Settings") { + Text("Option 1") + Text("Option 2") + } + #expect(dialog.title == "Settings") + } + + @Test("Dialog renders with panel styling") + func dialogRendering() { + let dialog = Dialog(title: "Test Dialog") { + Text("Content here") + } + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(dialog, context: context) + #expect(buffer.height > 1) + // Should contain title and content + let allContent = buffer.lines.joined() + #expect(allContent.contains("Test Dialog")) + #expect(allContent.contains("Content here")) + } +} + +@Suite("Menu Tests") +struct MenuTests { + + @Test("MenuItem can be created with label") + func menuItemCreation() { + let item = MenuItem(label: "Option 1") + #expect(item.label == "Option 1") + #expect(item.id == "Option 1") + #expect(item.shortcut == nil) + } + + @Test("MenuItem can have shortcut") + func menuItemWithShortcut() { + let item = MenuItem(label: "Quit", shortcut: "q") + #expect(item.label == "Quit") + #expect(item.shortcut == "q") + } + + @Test("Menu can be created with items") + func menuCreation() { + let menu = Menu( + title: "Test Menu", + items: [ + MenuItem(label: "Option 1", shortcut: "1"), + MenuItem(label: "Option 2", shortcut: "2") + ], + selectedIndex: 0 + ) + #expect(menu.title == "Test Menu") + #expect(menu.items.count == 2) + #expect(menu.selectedIndex == 0) + } + + @Test("Menu renders with title and border") + func menuRendering() { + let menu = Menu( + title: "My Menu", + items: [ + MenuItem(label: "First"), + MenuItem(label: "Second") + ] + ) + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(menu, context: context) + #expect(!buffer.isEmpty) + let allContent = buffer.lines.joined() + // Title should be present + #expect(allContent.contains("My Menu")) + // Border characters should be present (rounded style) + #expect(allContent.contains("╭") || allContent.contains("│")) + // Note: Menu items via ForEach are not fully rendered yet (known limitation) + } + + @Test("Menu clamps selectedIndex to valid range") + func menuClampsIndex() { + let menu = Menu( + items: [MenuItem(label: "Only")], + selectedIndex: 99 + ) + #expect(menu.selectedIndex == 0) + } +} + +@Suite("AnyView Tests") +struct AnyViewTests { + + @Test("AnyView wraps view correctly") + func anyViewWrapping() { + let text = Text("Hello") + let anyView = AnyView(text) + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(anyView, context: context) + #expect(buffer.lines[0] == "Hello") + } + + @Test("asAnyView extension works") + func asAnyViewExtension() { + let anyView = Text("Test").bold().asAnyView() + let context = RenderContext(availableWidth: 80, availableHeight: 24) + let buffer = renderToBuffer(anyView, context: context) + #expect(!buffer.isEmpty) + } +}