mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
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:
+15
-4
@@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: " "
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user