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
This commit is contained in:
phranck
2026-01-28 15:35:55 +01:00
parent dac288a5e4
commit 7ddac7067d
35 changed files with 5638 additions and 6 deletions
+15 -4
View File
@@ -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"]
),
]
)
+76
View File
@@ -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
+151
View File
@@ -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<App: TApp> {
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<S: TScene>(_ 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)
}
}
+48
View File
@@ -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<Content: TView>: 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: TScene>(_ content: Content) -> Content {
content
}
}
+187
View File
@@ -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: " "
)
}
+179
View File
@@ -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
}
}
+100
View File
@@ -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<TrueContent: TView, FalseContent: TView>: 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<Element: TView>: 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()
}
}
}
+37
View File
@@ -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 }
}
+192
View File
@@ -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: TView>(_ content: Content) -> Content {
content
}
// MARK: - Multiple Views (Tuple Views)
/// Builds two views into a TupleView.
public static func buildBlock<C0: TView, C1: TView>(
_ c0: C0,
_ c1: C1
) -> TupleView2<C0, C1> {
TupleView2(c0, c1)
}
/// Builds three views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2
) -> TupleView3<C0, C1, C2> {
TupleView3(c0, c1, c2)
}
/// Builds four views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView, C3: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2,
_ c3: C3
) -> TupleView4<C0, C1, C2, C3> {
TupleView4(c0, c1, c2, c3)
}
/// Builds five views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView, C3: TView, C4: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2,
_ c3: C3,
_ c4: C4
) -> TupleView5<C0, C1, C2, C3, C4> {
TupleView5(c0, c1, c2, c3, c4)
}
/// Builds six views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView, C3: TView, C4: TView, C5: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2,
_ c3: C3,
_ c4: C4,
_ c5: C5
) -> TupleView6<C0, C1, C2, C3, C4, C5> {
TupleView6(c0, c1, c2, c3, c4, c5)
}
/// Builds seven views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView, C3: TView, C4: TView, C5: TView, C6: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2,
_ c3: C3,
_ c4: C4,
_ c5: C5,
_ c6: C6
) -> TupleView7<C0, C1, C2, C3, C4, C5, C6> {
TupleView7(c0, c1, c2, c3, c4, c5, c6)
}
/// Builds eight views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView, C3: TView, C4: TView, C5: TView, C6: TView, C7: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2,
_ c3: C3,
_ c4: C4,
_ c5: C5,
_ c6: C6,
_ c7: C7
) -> TupleView8<C0, C1, C2, C3, C4, C5, C6, C7> {
TupleView8(c0, c1, c2, c3, c4, c5, c6, c7)
}
/// Builds nine views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView, C3: TView, C4: TView, C5: TView, C6: TView, C7: TView, C8: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2,
_ c3: C3,
_ c4: C4,
_ c5: C5,
_ c6: C6,
_ c7: C7,
_ c8: C8
) -> TupleView9<C0, C1, C2, C3, C4, C5, C6, C7, C8> {
TupleView9(c0, c1, c2, c3, c4, c5, c6, c7, c8)
}
/// Builds ten views into a TupleView.
public static func buildBlock<C0: TView, C1: TView, C2: TView, C3: TView, C4: TView, C5: TView, C6: TView, C7: TView, C8: TView, C9: TView>(
_ c0: C0,
_ c1: C1,
_ c2: C2,
_ c3: C3,
_ c4: C4,
_ c5: C5,
_ c6: C6,
_ c7: C7,
_ c8: C8,
_ c9: C9
) -> TupleView10<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9> {
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<TrueContent: TView, FalseContent: TView>(
first content: TrueContent
) -> ConditionalView<TrueContent, FalseContent> {
.trueContent(content)
}
/// Supports the false branch of an if-else.
public static func buildEither<TrueContent: TView, FalseContent: TView>(
second content: FalseContent
) -> ConditionalView<TrueContent, FalseContent> {
.falseContent(content)
}
/// Supports optional views (if let, if without else).
public static func buildOptional<Content: TView>(_ content: Content?) -> Content? {
content
}
/// Supports availability limiting.
public static func buildLimitedAvailability<Content: TView>(_ content: Content) -> Content {
content
}
// MARK: - Arrays
/// Supports for-in loops.
public static func buildArray<Content: TView>(_ components: [Content]) -> TViewArray<Content> {
TViewArray(components)
}
// MARK: - Expression
/// Converts a single expression into a view.
public static func buildExpression<Content: TView>(_ expression: Content) -> Content {
expression
}
/// Supports optional expressions.
public static func buildExpression<Content: TView>(_ expression: Content?) -> Content? {
expression
}
}
+141
View File
@@ -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<V0: TView, V1: TView>: 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<V0: TView, V1: TView, V2: TView>: 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<V0: TView, V1: TView, V2: TView, V3: TView>: 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<V0: TView, V1: TView, V2: TView, V3: TView, V4: TView>: 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<V0: TView, V1: TView, V2: TView, V3: TView, V4: TView, V5: TView>: 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<V0: TView, V1: TView, V2: TView, V3: TView, V4: TView, V5: TView, V6: TView>: 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<V0: TView, V1: TView, V2: TView, V3: TView, V4: TView, V5: TView, V6: TView, V7: TView>: 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<V0: TView, V1: TView, V2: TView, V3: TView, V4: TView, V5: TView, V6: TView, V7: TView, V8: TView>: 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<V0: TView, V1: TView, V2: TView, V3: TView, V4: TView, V5: TView, V6: TView, V7: TView, V8: TView, V9: TView>: 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")
}
}
+71
View File
@@ -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<Content: TView, Modifier: TViewModifier>: 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<M: TViewModifier>(_ modifier: M) -> ModifiedView<Self, M> {
ModifiedView(content: self, modifier: modifier)
}
}
@@ -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<Self, BackgroundModifier> {
modifier(BackgroundModifier(color: color))
}
}
@@ -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<Self, BorderModifier> {
modifier(BorderModifier(style: style, color: color))
}
}
@@ -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<Content: TView>: 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)
}
}
@@ -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..<targetHeight {
let contentRow = row - verticalOffset
let line: String
if contentRow >= 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<Self, FrameModifier> {
modifier(FrameModifier(width: width, height: height, alignment: alignment))
}
}
@@ -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<Base: TView, Overlay: TView>: 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<Overlay: TView>(
alignment: Alignment = .center,
@TViewBuilder content: () -> Overlay
) -> some TView {
OverlayModifier(base: self, overlay: content(), alignment: alignment)
}
}
@@ -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..<insets.top {
result.append(emptyLine)
}
// Content lines with horizontal padding
for line in buffer.lines {
result.append(leadingPad + line + trailingPad)
}
// Bottom padding
for _ in 0..<insets.bottom {
result.append(emptyLine)
}
return FrameBuffer(lines: result)
}
}
// MARK: - TView Extension
extension TView {
/// Adds padding on all sides.
///
/// ```swift
/// Text("Hello")
/// .padding(2)
/// ```
///
/// - Parameter amount: The padding amount on all sides (default: 1).
/// - Returns: A padded view.
public func padding(_ amount: Int = 1) -> ModifiedView<Self, PaddingModifier> {
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<Self, PaddingModifier> {
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<Self, PaddingModifier> {
modifier(PaddingModifier(insets: insets))
}
}
@@ -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"
}
@@ -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..<maxHeight {
let left = row < lines.count ? lines[row] : ""
let right = row < other.lines.count ? other.lines[row] : ""
// Pad the left side to consistent visible width
let leftPadded = left.padToVisibleWidth(myWidth)
result.append(leftPadded + spacer + right)
}
lines = result
}
/// Layers another buffer on top of this one (ZStack behavior).
///
/// Non-empty characters in the overlay replace characters in the base.
/// For simplicity, this just overlays line by line.
///
/// - Parameter overlay: The buffer to overlay on top.
public mutating func overlay(_ overlay: FrameBuffer) {
let maxHeight = max(height, overlay.height)
var result: [String] = []
for row in 0..<maxHeight {
if row < overlay.lines.count && !overlay.lines[row].isEmpty {
result.append(overlay.lines[row])
} else if row < lines.count {
result.append(lines[row])
} else {
result.append("")
}
}
lines = result
}
/// Creates a new buffer with another buffer composited on top at the specified position.
///
/// This performs character-level compositing: overlay characters replace base characters
/// only where the overlay has visible content (non-space characters).
///
/// - Parameters:
/// - overlay: The buffer to composite on top.
/// - position: The (x, y) offset where the overlay should be placed.
/// - Returns: A new buffer with the overlay composited.
public func composited(with overlay: FrameBuffer, at position: (x: Int, y: Int)) -> 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..<resultHeight {
// Get the base line (padded to result width)
var baseLine: String
if row < lines.count {
baseLine = lines[row].padToVisibleWidth(resultWidth)
} else {
baseLine = String(repeating: " ", count: resultWidth)
}
// Check if this row has overlay content
let overlayRow = row - position.y
if overlayRow >= 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..<prefixEnd])
// Pad if needed
if prefixEnd < column {
result += String(repeating: " ", count: column - prefixEnd)
}
}
// Add the overlay (with its ANSI codes intact)
result += overlay
// Add characters after the overlay
let afterOverlayColumn = column + overlayStripped.count
if afterOverlayColumn < baseChars.count {
result += String(baseChars[afterOverlayColumn...])
}
return result
}
// MARK: - Combining Arrays
/// Creates a vertically stacked buffer from an array of buffers.
///
/// TupleViews use this to combine their children vertically by default
/// (the parent stack then decides the actual layout direction).
///
/// - Parameter buffers: The buffers to stack vertically.
public init(verticallyStacking buffers: [FrameBuffer]) {
self.init()
for buffer in buffers {
appendVertically(buffer)
}
}
}
// MARK: - String Helpers
extension String {
/// The visible length of the string, excluding ANSI escape codes.
var strippedLength: Int {
stripped.count
}
/// The string with all ANSI escape codes removed.
var stripped: String {
replacingOccurrences(
of: "\u{1B}\\[[0-9;]*[a-zA-Z]",
with: "",
options: .regularExpression
)
}
/// Pads the string to the specified visible width using spaces.
///
/// ANSI codes are excluded from the width calculation.
///
/// - Parameter targetWidth: The desired visible width.
/// - Returns: The padded string.
func padToVisibleWidth(_ targetWidth: Int) -> String {
let currentWidth = strippedLength
if currentWidth >= targetWidth {
return self
}
return self + String(repeating: " ", count: targetWidth - currentWidth)
}
}
@@ -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<V: TView>(_ 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()
}
+210
View File
@@ -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()
}
}
@@ -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<V: TView>(_ 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<V: TView>(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<V: TView>(from content: V, context: RenderContext) -> [ChildInfo] {
if let provider = content as? ChildInfoProvider {
return provider.childInfos(context: context)
}
return [makeChildInfo(for: content, context: context)]
}
+40 -2
View File
@@ -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<Content: TView>(@TViewBuilder content: () -> Content) -> Int {
let view = content()
let renderer = ViewRenderer()
renderer.render(view)
return 0 // TODO: Return actual line count
}
+267
View File
@@ -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<Actions: TView>: 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<A: TView>(
title: String = "Warning",
message: String,
@TViewBuilder actions: () -> A
) -> Alert<A> {
Alert<A>(
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<A: TView>(
title: String = "Error",
message: String,
@TViewBuilder actions: () -> A
) -> Alert<A> {
Alert<A>(
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<A: TView>(
title: String = "Info",
message: String,
@TViewBuilder actions: () -> A
) -> Alert<A> {
Alert<A>(
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<A: TView>(
title: String = "Success",
message: String,
@TViewBuilder actions: () -> A
) -> Alert<A> {
Alert<A>(
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<EmptyView> {
Alert<EmptyView>(
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<EmptyView> {
Alert<EmptyView>(
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<EmptyView> {
Alert<EmptyView>(
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<EmptyView> {
Alert<EmptyView>(
title: title,
message: message,
borderStyle: .rounded,
borderColor: .green,
titleColor: .green
)
}
}
+56
View File
@@ -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<Content: TView>: 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)
}
}
+77
View File
@@ -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<Content: TView>: 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)
}
}
}
+178
View File
@@ -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<Content: TView>: 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<C: TView>(
title: String,
borderColor: Color? = nil,
titleColor: Color? = nil,
@TViewBuilder content: () -> C
) -> Dialog<C> {
Dialog<C>(
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<C: TView>(
title: String,
borderColor: Color? = nil,
titleColor: Color? = nil,
@TViewBuilder content: () -> C
) -> Dialog<C> {
Dialog<C>(
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<Modal: TView>(
@TViewBuilder content: () -> Modal
) -> some TView {
self.dimmed()
.overlay(alignment: .center, content: content)
}
}
+107
View File
@@ -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<Data: RandomAccessCollection, ID: Hashable, Content: TView>: TView {
/// The underlying data collection.
public let data: Data
/// The key path to the unique ID of each element.
public let idKeyPath: KeyPath<Data.Element, ID>
/// 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<Data.Element, ID>,
@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<Int>, 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<Int>,
@TViewBuilder content: @escaping (Int) -> Content
) {
self.data = data
self.idKeyPath = \.self
self.content = content
}
}
+201
View File
@@ -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<V: TView>(_ 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)
}
}
+138
View File
@@ -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<Content: TView>: 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)
}
}
+89
View File
@@ -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")
}
}
+230
View File
@@ -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<Content: TView>: 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<Content: TView>: 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<Content: TView>: 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)
}
+174
View File
@@ -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() {}
}
+477
View File
@@ -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<Content: TView>: 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()
+618
View File
@@ -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<Text, Text>)
}
@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<Text, Text, Text>)
}
@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)
}
}