├── .gitignore
├── .vscode
└── launch.json
├── Package.swift
├── README.md
└── Sources
├── Common
├── Alignment.swift
├── CommonUtility.swift
├── Edge.swift
└── IsDebug.swift
├── Components
├── AnyView.swift
├── Button.swift
├── ClientDefinedViewNodeBuilder.swift
├── ConditionalView.swift
├── EmptyView.swift
├── HStack.swift
├── Screen.swift
├── Text.swift
├── TupleView.swift
├── VStack.swift
└── ZStack.swift
├── Core
├── App.swift
├── AppEngine.swift
├── Environment.swift
├── FocusEngine.swift
├── Identifier.swift
├── Node.swift
├── RenderContext.swift
├── RenderEngine.swift
├── State.swift
├── Terminal.swift
├── TreeEngine.swift
├── View.swift
└── ViewBuilder.swift
├── ViewModifierComponents
├── Border.swift
├── Frame.swift
├── Padding.swift
└── SetEnvironment.swift
└── main.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "configurations": [
3 | {
4 | "type": "lldb",
5 | "request": "launch",
6 | "args": ["debug"],
7 | "cwd": "${workspaceFolder:BlinkUI}",
8 | "name": "Debug BlinkUI",
9 | "program": "${workspaceFolder:BlinkUI}/.build/debug/BlinkUI",
10 | "preLaunchTask": "swift: Build Debug BlinkUI"
11 | },
12 | {
13 | "type": "swift",
14 | "request": "launch",
15 | "args": [],
16 | "cwd": "${workspaceFolder:BlinkUI}",
17 | "name": "Release BlinkUI",
18 | "program": "${workspaceFolder:BlinkUI}/.build/release/BlinkUI",
19 | "preLaunchTask": "swift: Build Release BlinkUI"
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "BlinkUI",
8 | platforms: [.macOS(.v14)],
9 | targets: [
10 | // Targets are the basic building blocks of a package, defining a module or a test suite.
11 | // Targets can depend on other targets in this package and products from dependencies.
12 | .executableTarget(
13 | name: "BlinkUI")
14 | ]
15 | )
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # BlinkUI: A SwiftUI-Inspired Terminal UI Framework
2 |
3 | BlinkUI is an experimental terminal UI framework that brings SwiftUI's declarative syntax and component-based architecture to terminal applications. Build beautiful, interactive terminal UIs using familiar SwiftUI-like patterns.
4 |
5 | 
6 |
7 | > [!WARNING]
8 | > This is an experimental project, currently in active development. While functional, it's not yet recommended for production use. Feedback and contributions are welcome!
9 |
10 | ## Features
11 |
12 | - **SwiftUI-like Syntax**: Write terminal UIs using familiar declarative syntax
13 | - **Rich Component Library**: Built-in components like Text, Button, HStack, VStack, and ZStack
14 | - **State Management**: Support for @State, @Binding, and @Environment property wrappers
15 | - **Focus Engine**: Keyboard navigation between interactive elements (Tab/Shift+Tab)
16 | - **View Modifiers**: Padding, borders, and frame customization
17 | - **Layout System**: Flexible layout engine for component positioning
18 |
19 | ## Quick Example
20 |
21 | ```swift
22 | import BlinkUI
23 |
24 | struct Example: App {
25 | @State var isSelected: Bool = false
26 |
27 | var body: some View {
28 | Text("This is your last chance. After this, there is no turning back.")
29 | if !isSelected {
30 | HStack {
31 | Button(action: { isSelected = true }) { Text("Blue pill") }
32 | Button(action: { isSelected = true }) { Text("Red pill") }
33 | }
34 | } else {
35 | Text("The Matrix is everywhere. It is all around us.")
36 | }
37 | }
38 | }
39 |
40 | // Run the app
41 | let engine = AppEngine(app: Example())
42 | engine.run()
43 | ```
44 |
45 | ## Components and Features
46 |
47 | ### Base Views
48 |
49 | #### Text
50 | Text with simple word wrapping logic
51 |
52 |
53 |
54 |
55 | #### Button
56 | Interactive buttons with customizable labels and actions.
57 |
58 |
59 |
60 |
61 |
62 | #### HStack
63 |
68 |
69 | #### VStack
70 |
75 |
76 | #### ZStack
77 |
78 |
79 |
80 |
81 | ### View Modifiers
82 |
83 | Powerful modifiers to customize your components:
84 |
85 | #### Frame
86 | Control the dimensions and alignment of your components.
87 |
88 |
89 |
90 |
91 | #### Padding
92 | Add space around your components for better layout control.
93 |
94 |
95 |
96 |
97 | #### Border
98 | Add beautiful borders with different styles.
99 |
100 |
101 |
102 |
103 | ### Focus Engine
104 | Navigate through interactive elements using keyboard (Tab/Shift+Tab).
105 | 
106 |
107 | ### State Management
108 | Reactive state management with property wrappers (@State, @Binding, @Environment).
109 |
110 |
111 |
112 |
113 | ## Under Development
114 | - 🚧 Word wrapping
115 | - 🚧 Advanced layouts
116 | - 🚧 Performance optimization
117 | - 🚧 Testing framework
118 | - 🚧 Buffer management
119 | - 🚧 Cross-platform support (Windows/Linux)
120 | - 🚧 Hyperlink support
121 | - 🚧 Documentation and examples
122 |
123 | ## Getting Started
124 |
125 | > Coming Soon: Installation and usage instructions will be added as the project stabilizes.
126 |
127 | ## Contributing
128 |
129 | This project is open for experimentation and learning. If you're interested in terminal UI frameworks or SwiftUI internals, feel free to take a look at the code and provide feedback.
130 |
131 | ## Technical Details
132 |
133 | The framework consists of over 2,000 lines of code implementing:
134 | - Custom ViewBuilder for declarative syntax
135 | - Node-based layout system
136 | - State management system
137 | - Focus engine for accessibility
138 | - Terminal rendering engine
139 |
140 | > TODO: A detailed technical blog post about the implementation and learnings is planned.
141 |
142 | ## Why This Project?
143 |
144 | BlinkUI started as a deep dive into understanding SwiftUI's architecture. Instead of just reading about SwiftUI or building another todo app, I decided to challenge myself by recreating its core concepts for terminal applications. This hands-on approach provided invaluable insights into:
145 |
146 | - How declarative UI frameworks actually work under the hood
147 | - The complexities of state management and data flow
148 | - The challenges of building a layout engine from scratch
149 | - Real-world application of property wrappers and function builders
150 |
151 |
--------------------------------------------------------------------------------
/Sources/Common/Alignment.swift:
--------------------------------------------------------------------------------
1 | public enum Alignment: Sendable {
2 | case topLeading, top, topTrailing
3 | case leading, center, trailing
4 | case bottomLeading, bottom, bottomTrailing
5 | }
6 | public struct HorizontalAlignment: Sendable, Equatable {
7 | let alignment: Alignment
8 | public static let leading: HorizontalAlignment = .init(alignment: .leading)
9 | public static let center: HorizontalAlignment = .init(alignment: .center)
10 | public static let trailing: HorizontalAlignment = .init(alignment: .trailing)
11 | }
12 | public struct VerticalAlignment: Sendable, Equatable {
13 | let alignment: Alignment
14 | public static let top: VerticalAlignment = .init(alignment: .top)
15 | public static let center: VerticalAlignment = .init(alignment: .center)
16 | public static let bottom: VerticalAlignment = .init(alignment: .bottom)
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/Common/CommonUtility.swift:
--------------------------------------------------------------------------------
1 | public typealias Size = (width: Int, height: Int)
2 | public typealias Point = (x: Int, y: Int)
3 |
4 | extension Int {
5 | public static var infinity: Int {
6 | return Int.max
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Common/Edge.swift:
--------------------------------------------------------------------------------
1 | public enum Edge {
2 | case top
3 | case bottom
4 | case leading
5 | case trailing
6 | }
7 | public enum EdgeSet {
8 | case all
9 | case top
10 | case bottom
11 | case leading
12 | case trailing
13 | case horizontal
14 | case vertical
15 |
16 | var set: Set {
17 | switch self {
18 | case .all:
19 | return [.top, .bottom, .leading, .trailing]
20 | case .top:
21 | return [.top]
22 | case .bottom:
23 | return [.bottom]
24 | case .leading:
25 | return [.leading]
26 | case .trailing:
27 | return [.trailing]
28 | case .horizontal:
29 | return [.leading, .trailing]
30 | case .vertical:
31 | return [.top, .bottom]
32 | }
33 | }
34 |
35 | func contains(_ edge: Edge) -> Bool {
36 | return set.contains(edge)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/Common/IsDebug.swift:
--------------------------------------------------------------------------------
1 | // Is debug i.e. launched via VS Code
2 | func IsDebug() -> Bool {
3 | return CommandLine.arguments.contains("debug")
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/Components/AnyView.swift:
--------------------------------------------------------------------------------
1 | public struct AnyView: View {
2 | let content: any View
3 |
4 | init(@ViewBuilder content: () -> any View) {
5 | self.content = content()
6 | }
7 | }
8 | extension AnyView: NodeBuilder {
9 | func childViews() -> [any View] {
10 | return [content]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/Components/Button.swift:
--------------------------------------------------------------------------------
1 | public struct Button: View where Label: View {
2 | let label: Label
3 | let action: () -> Void
4 |
5 | init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) {
6 | self.action = action
7 | self.label = label()
8 | }
9 | }
10 | extension Button: NodeBuilder {
11 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
12 | return ButtonNode(view: self, viewIdentifier: viewIdentifier)
13 | }
14 |
15 | func childViews() -> [any View] {
16 | return [VStack { label }]
17 | }
18 | }
19 |
20 | class ButtonNode: Node {
21 | var buttonView: Button {
22 | guard let view = view as? Button else {
23 | fatalError("ButtonNode can only be used with Button")
24 | }
25 | return view
26 | }
27 |
28 | init(view: Button, viewIdentifier: ViewIdentifier) {
29 | super.init(view: view, viewIdentifier: viewIdentifier)
30 | focusable = true
31 | }
32 |
33 | override func activate() {
34 | buttonView.action()
35 | }
36 | }
37 | extension ButtonNode: RenderableNode {
38 | func proposeViewSize(inSize: Size) -> Size {
39 | assert(renderableChildren.count == 1)
40 | let labelNode = renderableChildren[0]
41 | let labelSize = labelNode.proposeViewSize(inSize: inSize)
42 |
43 | // Add 1 for borders
44 | return (width: labelSize.width + 2, height: labelSize.height + 2)
45 | }
46 |
47 | func render(context: RenderContext, start: Point, size: Size) {
48 | // TODO: Make this more flexible ???
49 | // THe focusable rendering should not be responsibility of the element
50 | // Render border
51 | context.terminal.draw(x: start.x, y: start.y, symbol: focused ? "╔" : "┌")
52 | context.terminal.draw(x: start.x + size.width - 1, y: start.y, symbol: focused ? "╗" : "┐")
53 | context.terminal.draw(x: start.x, y: start.y + size.height - 1, symbol: focused ? "╚" : "└")
54 | context.terminal.draw(
55 | x: start.x + size.width - 1, y: start.y + size.height - 1, symbol: focused ? "╝" : "┘")
56 |
57 | for x in (start.x + 1)..<(start.x + size.width - 1) {
58 | context.terminal.draw(x: x, y: start.y, symbol: focused ? "═" : "─")
59 | context.terminal.draw(x: x, y: start.y + size.height - 1, symbol: focused ? "═" : "─")
60 | }
61 | for y in (start.y + 1)..<(start.y + size.height - 1) {
62 | context.terminal.draw(x: start.x, y: y, symbol: focused ? "║" : "│")
63 | context.terminal.draw(x: start.x + size.width - 1, y: y, symbol: focused ? "║" : "│")
64 | }
65 |
66 | // Render label
67 | self.renderableChildren.first?.render(
68 | context: context, start: (start.x + 1, start.y + 1),
69 | size: (size.width - 2, size.height - 2))
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/Components/ClientDefinedViewNodeBuilder.swift:
--------------------------------------------------------------------------------
1 | struct ClientDefinedViewNodeBuilder: NodeBuilder {
2 | let clientDefinedView: any View
3 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
4 | return Node(view: clientDefinedView, viewIdentifier: viewIdentifier)
5 | }
6 | func childViews() -> [any View] {
7 | return [clientDefinedView.body]
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Sources/Components/ConditionalView.swift:
--------------------------------------------------------------------------------
1 | struct ConditionalView: View where T: View, F: View {
2 | enum ViewCase {
3 | case truthy, falsy
4 | }
5 | let viewCase: ViewCase
6 | let content: any View
7 |
8 | private init(viewCase: ViewCase, content: any View) {
9 | self.viewCase = viewCase
10 | self.content = content
11 | }
12 | static func truthy(@ViewBuilder content: () -> T) -> Self {
13 | return ConditionalView(viewCase: .truthy, content: content())
14 | }
15 |
16 | static func falsy(@ViewBuilder content: () -> F) -> Self {
17 | return ConditionalView(viewCase: .falsy, content: content())
18 | }
19 | }
20 | extension ConditionalView: NodeBuilder {
21 | func childViews() -> [any View] {
22 | if viewCase == .truthy {
23 | return [content, EmptyView()]
24 | } else if viewCase == .falsy {
25 | return [EmptyView(), content]
26 | }
27 | return []
28 | }
29 | }
30 |
31 | class ConditionalNode: Node where T: View, F: View {
32 | static func == (lhs: ConditionalNode, rhs: ConditionalNode) -> Bool {
33 | guard let lhsConditionalView = lhs.view as? ConditionalView,
34 | let rhsConditionalView = rhs.view as? ConditionalView
35 | else {
36 | fatalError("Expected conditional view for \(lhs) and \(rhs)")
37 | }
38 |
39 | return lhs.viewIdentifier == rhs.viewIdentifier
40 | && lhsConditionalView.viewCase == rhsConditionalView.viewCase
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/Components/EmptyView.swift:
--------------------------------------------------------------------------------
1 | public struct EmptyView: View {}
2 | extension EmptyView: NodeBuilder {
3 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
4 | return EmptyNode(view: self, viewIdentifier: viewIdentifier)
5 | }
6 |
7 | func childViews() -> [any View] {
8 | return []
9 | }
10 | }
11 |
12 | class EmptyNode: Node {}
13 |
--------------------------------------------------------------------------------
/Sources/Components/HStack.swift:
--------------------------------------------------------------------------------
1 | public struct HStack: View where Content: View {
2 | let content: Content
3 | let alignment: VerticalAlignment
4 | let spacing: Int
5 |
6 | public init(
7 | alignment: VerticalAlignment = .center, spacing: Int = 1,
8 | @ViewBuilder content: () -> Content
9 | ) {
10 | self.alignment = alignment
11 | self.spacing = spacing
12 | self.content = content()
13 | }
14 | }
15 |
16 | extension HStack: NodeBuilder {
17 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
18 | return HStackNode(view: self, viewIdentifier: viewIdentifier)
19 | }
20 |
21 | func childViews() -> [any View] {
22 | return [content]
23 | }
24 | }
25 |
26 | class HStackNode: Node {
27 | var hStackView: HStack {
28 | guard let view = view as? HStack else {
29 | fatalError("HStackNode can only be used with HStack")
30 | }
31 | return view
32 | }
33 |
34 | init(view: HStack, viewIdentifier: ViewIdentifier) {
35 | super.init(view: view, viewIdentifier: viewIdentifier)
36 | }
37 | }
38 |
39 | extension HStackNode: RenderableNode {
40 | func proposeViewSize(inSize: Size) -> Size {
41 | guard renderableChildren.count > 0 else {
42 | // No elements to render
43 | return (0, 0)
44 | }
45 |
46 | var height = 0
47 | var width = 0
48 | var availableWidth = inSize.width
49 |
50 | for (index, child) in self.renderableChildren.enumerated() {
51 | let spacing = index < renderableChildren.count - 1 ? hStackView.spacing : 0
52 | let childSize = child.proposeViewSize(
53 | inSize: (width: availableWidth, height: inSize.height))
54 | height = max(height, childSize.height)
55 | width += childSize.width + spacing
56 | availableWidth -= childSize.width + spacing
57 | }
58 |
59 | return (width: width, height: height)
60 | }
61 |
62 | func render(context: RenderContext, start: Point, size: Size) {
63 | var x = start.x
64 | var availableWidth = size.width
65 |
66 | for child in self.renderableChildren {
67 | let childIntrinsicSize = child.proposeViewSize(
68 | inSize: (width: availableWidth, height: size.height))
69 | let childStartY: Int =
70 | switch hStackView.alignment {
71 | case .top:
72 | start.y
73 | case .center:
74 | start.y + (size.height - childIntrinsicSize.height) / 2
75 | case .bottom:
76 | start.y + (size.height - childIntrinsicSize.height)
77 | default:
78 | start.y
79 | }
80 | let childStart: Point = (
81 | x: x,
82 | y: childStartY
83 | )
84 | child.render(context: context, start: childStart, size: childIntrinsicSize)
85 | x += childIntrinsicSize.width + hStackView.spacing
86 | availableWidth -= childIntrinsicSize.height + hStackView.spacing
87 |
88 | if availableWidth <= 0 {
89 | break // Stop rendering if no more space is available
90 | }
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Sources/Components/Screen.swift:
--------------------------------------------------------------------------------
1 | struct Screen: View where Content: App {
2 | let content: VStack
3 |
4 | init(@ViewBuilder content: () -> Content) {
5 | self.content = VStack { content() }
6 | }
7 | }
8 |
9 | extension Screen: NodeBuilder {
10 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
11 | let node = ScreenNode(view: self, viewIdentifier: viewIdentifier)
12 | node.environmentValues = EnvironmentValues()
13 | return node
14 | }
15 |
16 | func childViews() -> [any View] {
17 | return [content]
18 | }
19 | }
20 |
21 | class ScreenNode: Node {}
22 | extension ScreenNode: RenderableNode {
23 | func proposeViewSize(inSize: Size) -> Size {
24 | assert(self.renderableChildren.count == 1)
25 | return self.renderableChildren.first?.proposeViewSize(inSize: inSize) ?? (0, 0)
26 | }
27 |
28 | func render(context: RenderContext, start: Point, size: Size) {
29 | assert(self.renderableChildren.count == 1)
30 | self.renderableChildren.first?.render(context: context, start: start, size: size)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Components/Text.swift:
--------------------------------------------------------------------------------
1 | private let Ellipses: String = "…"
2 |
3 | public struct Text: View {
4 | let text: String
5 |
6 | init(_ text: String) {
7 | self.text = text
8 | }
9 | }
10 |
11 | extension Text: NodeBuilder {
12 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
13 | TextNode(view: self, viewIdentifier: viewIdentifier)
14 | }
15 |
16 | func childViews() -> [any View] {
17 | return []
18 | }
19 | }
20 |
21 | class TextNode: Node {
22 | var textView: Text {
23 | guard let textView = view as? Text else {
24 | fatalError("TextNode can only be used with Text views")
25 | }
26 | return textView
27 | }
28 | lazy var text: String = { textView.text }()
29 |
30 | init(view: Text, viewIdentifier: ViewIdentifier) {
31 | super.init(view: view, viewIdentifier: viewIdentifier)
32 | }
33 | }
34 | extension TextNode: RenderableNode {
35 | // TODO: Better word wrapping algorithm
36 | func proposeViewSize(inSize: Size) -> Size {
37 | guard inSize.height > 0 && inSize.width > 0 else {
38 | return (width: 0, height: 0) // Cant render so return empty frames
39 | }
40 |
41 | if inSize.width >= textView.text.count { // Enough width to have the text in one height
42 | return (width: textView.text.count, height: 1)
43 | }
44 |
45 | let words: [String] = text.split(separator: " ").map { String($0) }
46 |
47 | if words.count == 1 {
48 | // If the text is a single word and doesn't fit within the available width,
49 | // return the maximum width and handle truncation with ellipses during rendering.
50 | return (width: inSize.width, height: 1)
51 | }
52 |
53 | var maxWidth = 0
54 | var height = 1
55 | var currentWidth = 0
56 | for (index, word) in words.enumerated() {
57 | let space = index == 0 ? 0 : 1 // No space for first word
58 | let nextWordWidth = word.count + space
59 | if currentWidth + nextWordWidth > inSize.width {
60 | height += 1
61 | maxWidth = max(maxWidth, currentWidth)
62 | currentWidth = nextWordWidth // Fill next row with the width -- TODO: case where the word width is larger than the current width
63 | } else {
64 | currentWidth += nextWordWidth
65 | }
66 |
67 | if height > inSize.height {
68 | currentWidth = 1
69 | break
70 | }
71 | }
72 |
73 | maxWidth = max(maxWidth, currentWidth)
74 | return (width: min(maxWidth, inSize.width), height: min(height, inSize.height))
75 | }
76 |
77 | func render(context: RenderContext, start: Point, size: Size) {
78 | guard size.height > 0 && size.width > 0 else {
79 | return // Cant render
80 | }
81 |
82 | if size.width >= textView.text.count { // Enough width to have the text in one height
83 | for (index, char) in textView.text.enumerated() {
84 | context.terminal.draw(x: start.x + index, y: start.y, symbol: char)
85 | }
86 | return
87 | }
88 |
89 | let words: [String] = text.split(separator: " ").map { String($0) }
90 |
91 | if words.count == 1 {
92 | for (index, char) in textView.text.enumerated() {
93 | context.terminal.draw(x: start.x + index, y: start.y, symbol: char)
94 | }
95 | return
96 | }
97 |
98 | var currentWidth = 0
99 | var currentHeight = 0
100 | for (index, word) in words.enumerated() {
101 | let space = index == 0 ? 0 : 1 // No space for the first word
102 | let nextWordWidth = word.count + space
103 | if currentWidth + nextWordWidth > size.width {
104 | currentHeight += 1
105 | currentWidth = 0
106 | if currentHeight >= size.height {
107 | // Truncate with ellipses if height exceeds available space
108 | let truncatedWord = word.prefix(size.width - 1) + Ellipses
109 | for (charIndex, char) in truncatedWord.enumerated() {
110 | context.terminal.draw(
111 | x: start.x + charIndex, y: start.y + currentHeight, symbol: char)
112 | }
113 | break
114 | }
115 | }
116 |
117 | if currentHeight < size.height {
118 | for (charIndex, char) in word.enumerated() {
119 | context.terminal.draw(
120 | x: start.x + currentWidth + charIndex, y: start.y + currentHeight,
121 | symbol: char)
122 | }
123 | currentWidth += word.count
124 | // Draw the word
125 | if space > 0 {
126 | context.terminal.draw(
127 | x: start.x + currentWidth, y: start.y + currentHeight, symbol: " ")
128 | currentWidth += 1
129 | }
130 | }
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/Sources/Components/TupleView.swift:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | private let logger = Logger(subsystem: "com.rational.blinkui", category: "TupleView")
4 |
5 | struct TupleView: View {
6 | let content: (repeat each T)
7 |
8 | init(_ content: (repeat each T)) {
9 | self.content = content
10 | }
11 | }
12 |
13 | extension TupleView: NodeBuilder {
14 | func childViews() -> [any View] {
15 | var childViews = [any View]()
16 | for child in repeat each content {
17 | if let childView = child as? (any View) {
18 | childViews.append(childView)
19 | }
20 | }
21 |
22 | return childViews
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/Components/VStack.swift:
--------------------------------------------------------------------------------
1 | public struct VStack: View where Content: View {
2 | let content: Content
3 | let alignment: HorizontalAlignment
4 | let spacing: Int
5 |
6 | public init(
7 | alignment: HorizontalAlignment = .center, spacing: Int = 0,
8 | @ViewBuilder content: () -> Content
9 | ) {
10 | self.alignment = alignment
11 | self.spacing = spacing
12 | self.content = content()
13 | }
14 | }
15 |
16 | extension VStack: NodeBuilder {
17 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
18 | return VStackNode(view: self, viewIdentifier: viewIdentifier)
19 | }
20 |
21 | func childViews() -> [any View] {
22 | return [content]
23 | }
24 | }
25 |
26 | class VStackNode: Node {
27 | var vStackView: VStack {
28 | guard let view = view as? VStack else {
29 | fatalError("VStackNode can only be used with VStack")
30 | }
31 | return view
32 | }
33 |
34 | init(view: VStack, viewIdentifier: ViewIdentifier) {
35 | super.init(view: view, viewIdentifier: viewIdentifier)
36 | }
37 | }
38 |
39 | extension VStackNode: RenderableNode {
40 | func proposeViewSize(inSize: Size) -> Size {
41 | guard renderableChildren.count > 0 else {
42 | // No elements to render
43 | return (0, 0)
44 | }
45 |
46 | var height = 0
47 | var width = 0
48 | var availableHeight = inSize.height
49 |
50 | for (index, child) in self.renderableChildren.enumerated() {
51 | let spacing = index < renderableChildren.count - 1 ? vStackView.spacing : 0
52 | let childSize = child.proposeViewSize(
53 | inSize: (width: inSize.width, height: availableHeight))
54 | height += childSize.height + spacing
55 | width = max(width, childSize.width)
56 | availableHeight -= childSize.height + spacing
57 | }
58 | return (width: width, height: height)
59 | }
60 |
61 | func render(context: RenderContext, start: Point, size: Size) {
62 | var y = start.y
63 | var availableHeight = size.height
64 |
65 | for child in self.renderableChildren {
66 | let childIntrinsicSize = child.proposeViewSize(
67 | inSize: (width: size.width, height: availableHeight))
68 | let childStartX: Int =
69 | switch vStackView.alignment {
70 | case .leading:
71 | start.x
72 | case .center:
73 | start.x + (size.width - childIntrinsicSize.width) / 2
74 | case .trailing:
75 | start.x + (size.width - childIntrinsicSize.width)
76 | default:
77 | start.x
78 | }
79 | let childStart: Point = (
80 | x: childStartX,
81 | y: y
82 | )
83 | child.render(context: context, start: childStart, size: childIntrinsicSize)
84 | y += childIntrinsicSize.height + vStackView.spacing
85 | availableHeight -= childIntrinsicSize.height + vStackView.spacing
86 |
87 | if availableHeight <= 0 {
88 | break // Stop rendering if no more space is available
89 | }
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/Components/ZStack.swift:
--------------------------------------------------------------------------------
1 | public struct ZStack: View where Content: View {
2 | let content: Content
3 | let alignment: Alignment
4 |
5 | public init(
6 | alignment: Alignment = .center,
7 | @ViewBuilder content: () -> Content
8 | ) {
9 | self.alignment = alignment
10 | self.content = content()
11 | }
12 | }
13 |
14 | extension ZStack: NodeBuilder {
15 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
16 | return ZStackNode(view: self, viewIdentifier: viewIdentifier)
17 | }
18 |
19 | func childViews() -> [any View] {
20 | return [content]
21 | }
22 | }
23 |
24 | class ZStackNode: Node {
25 | var zStackView: ZStack {
26 | guard let view = view as? ZStack else {
27 | fatalError("ZStackNode can only be used with ZStack")
28 | }
29 | return view
30 | }
31 |
32 | init(view: ZStack, viewIdentifier: ViewIdentifier) {
33 | super.init(view: view, viewIdentifier: viewIdentifier)
34 | }
35 | }
36 |
37 | extension ZStackNode: RenderableNode {
38 | func proposeViewSize(inSize: Size) -> Size {
39 | guard renderableChildren.count > 0 else {
40 | // No elements to render
41 | return (0, 0)
42 | }
43 |
44 | var height = 0
45 | var width = 0
46 | for child in self.renderableChildren {
47 | let childSize = child.proposeViewSize(inSize: inSize)
48 | height = max(height, childSize.height)
49 | width = max(width, childSize.width)
50 | }
51 | return (width: width, height: height)
52 | }
53 |
54 | func render(context: RenderContext, start: Point, size: Size) {
55 | for child in self.renderableChildren {
56 | let childIntrinsicSize = child.proposeViewSize(inSize: size)
57 | let offset = AlignmentUtility.calculateAlignmentOffset(
58 | parentSize: size, childSize: childIntrinsicSize, alignment: zStackView.alignment)
59 | let childStart: Point = (
60 | x: start.x + offset.x,
61 | y: start.y + offset.y
62 | )
63 | child.render(context: context, start: childStart, size: childIntrinsicSize)
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Sources/Core/App.swift:
--------------------------------------------------------------------------------
1 | public protocol App: View {
2 | @ViewBuilder var body: Self.Body { get }
3 | }
4 |
--------------------------------------------------------------------------------
/Sources/Core/AppEngine.swift:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | public class AppEngine {
4 | let app: any App
5 |
6 | let renderer: RenderEngine
7 | lazy var focusEngine = FocusEngine()
8 | lazy var treeEngine = TreeEngine(app: self.app)
9 |
10 | init(app: any App) {
11 | self.app = app
12 | renderer = RenderEngine()
13 | }
14 |
15 | func run() {
16 | // Listen to user input and execute it on different thread
17 | DispatchQueue.global(qos: .userInteractive).async { [weak self] in
18 | while let self {
19 | if let key = Terminal.getKeyPress() {
20 | self.focusEngine.processInput(key)
21 | }
22 | }
23 | }
24 |
25 | // Since currently we are not updating the tree -- this can be called only once
26 | while true {
27 | focusEngine.calculateFocusableNodes(fromNode: treeEngine.rootNode)
28 | renderer.render(fromNode: treeEngine.renderableRootNode)
29 |
30 | // TODO: Not good idea
31 | usleep(100_000) // Sleep for 100ms
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/Core/Environment.swift:
--------------------------------------------------------------------------------
1 | public protocol EnvironmentKey {
2 | associatedtype Value
3 | static var defaultValue: Value { get }
4 | }
5 |
6 | public struct EnvironmentValues {
7 | var values: [ObjectIdentifier: Any] = [:]
8 | public subscript(key: K.Type) -> K.Value {
9 | get { values[ObjectIdentifier(key)] as? K.Value ?? K.defaultValue }
10 | set { values[ObjectIdentifier(key)] = newValue }
11 | }
12 | }
13 |
14 | protocol AnyEnvironment {
15 | var environmentReference: EnvironmentReference { get }
16 | }
17 |
18 | @propertyWrapper
19 | public struct Environment: AnyEnvironment {
20 | let keyPath: KeyPath
21 |
22 | public init(_ keyPath: KeyPath) {
23 | self.keyPath = keyPath
24 | }
25 |
26 | public var wrappedValue: Value {
27 | guard
28 | let environmentValues = environmentReference.environmentProvider?.getEnvironmentValues()
29 | else {
30 | assertionFailure("Unable to get environment values")
31 | return EnvironmentValues()[keyPath: keyPath]
32 | }
33 |
34 | return environmentValues[keyPath: keyPath]
35 | }
36 |
37 | // We can update this value internally
38 | let environmentReference: EnvironmentReference = EnvironmentReference()
39 | }
40 |
41 | class EnvironmentReference {
42 | weak var environmentProvider: EnvironmentProvidable?
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/Core/FocusEngine.swift:
--------------------------------------------------------------------------------
1 | class FocusEngine {
2 | private(set) var focusedNode: Node? {
3 | willSet {
4 | focusedNode?.focused = false
5 | }
6 | didSet {
7 | focusedNode?.focused = true
8 | }
9 | }
10 | private var focusableNodes: [Node] = [] // Strong, really?
11 |
12 | func processInput(_ key: String) {
13 | // is tab
14 | if key == "\t" {
15 | jumpToNextFocusableNode()
16 | } else if key == " " {
17 | activateFocusedNode()
18 | } else if key == "\u{1B}[Z" { // "shift + tab"
19 | jumpToPreviousFocusableNode()
20 | }
21 | }
22 |
23 | private func jumpToNextFocusableNode() {
24 | guard !focusableNodes.isEmpty else { return }
25 |
26 | if let currentFocusedIndex = focusableNodes.firstIndex(where: { $0 === focusedNode }) {
27 | let nextIndex = (currentFocusedIndex + 1) % focusableNodes.count
28 | focusedNode = focusableNodes[nextIndex]
29 | } else {
30 | focusedNode = focusableNodes.first
31 | }
32 | }
33 |
34 | private func jumpToPreviousFocusableNode() {
35 | guard !focusableNodes.isEmpty else { return }
36 |
37 | if let currentFocusedIndex = focusableNodes.firstIndex(where: { $0 === focusedNode }) {
38 | let previousIndex =
39 | (currentFocusedIndex - 1 + focusableNodes.count) % focusableNodes.count
40 | focusedNode = focusableNodes[previousIndex]
41 | } else {
42 | focusedNode = focusableNodes.last
43 | }
44 | }
45 |
46 | private func activateFocusedNode() {
47 | focusedNode?.activate()
48 | }
49 |
50 | // Empties the focusable nodes
51 | // Do this after update to render tree is expected
52 | func purge() {
53 | focusableNodes = []
54 | }
55 |
56 | // Do this when render tree is complete
57 | func calculateFocusableNodes(fromNode: Node) {
58 | let previouslyFocusedViewId = focusedNode?.viewIdentifier
59 | purge()
60 | _calculateFocusableNodes(fromNode: fromNode)
61 | let focusedNodeFromNewTree = focusableNodes.first {
62 | $0.viewIdentifier == previouslyFocusedViewId
63 | }
64 | self.focusedNode = focusedNodeFromNewTree
65 | }
66 |
67 | // Doing DFS which is not idea
68 | // We should search according to screen layout i.e. how user is seeing them
69 | private func _calculateFocusableNodes(fromNode node: Node) {
70 | if node.focusable {
71 | focusableNodes.append(node)
72 | }
73 |
74 | for childNode in node.children {
75 | _calculateFocusableNodes(fromNode: childNode)
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Sources/Core/Identifier.swift:
--------------------------------------------------------------------------------
1 | /// A simple view identifier that uniquely identifies a view using its hierarchical positions.
2 | /// The `ViewIdentifier` remains consistent across render cycles, ensuring stable identification.
3 | struct ViewIdentifier {
4 | var positions: [Int] = []
5 |
6 | func isDescendent(ofViewIdentifier: ViewIdentifier) -> Bool {
7 | guard positions.count >= ofViewIdentifier.positions.count else {
8 | return false
9 | }
10 | return ofViewIdentifier.positions
11 | == Array(positions.prefix(ofViewIdentifier.positions.count))
12 | }
13 |
14 | func withPosition(_ position: Int) -> ViewIdentifier {
15 | return ViewIdentifier(positions: positions + [position])
16 | }
17 |
18 | func withState(_ stateName: String) -> StateIdentifier {
19 | return StateIdentifier(viewIdentifier: self, stateIdentifier: stateName)
20 | }
21 | }
22 | extension ViewIdentifier: Hashable {}
23 | extension ViewIdentifier: CustomStringConvertible {
24 | var description: String {
25 | return positions.map(String.init).joined(separator: ".")
26 | }
27 | }
28 |
29 | struct StateIdentifier {
30 | var viewIdentifier: ViewIdentifier
31 | var stateIdentifier: String
32 | }
33 | extension StateIdentifier: Hashable {}
34 | extension StateIdentifier: CustomStringConvertible {
35 | var description: String {
36 | return "\(viewIdentifier)->\(stateIdentifier)"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/Core/Node.swift:
--------------------------------------------------------------------------------
1 | protocol NodeBuilder {
2 | // Build node for the view
3 | func buildNode(viewIdentifier: ViewIdentifier) -> Node
4 |
5 | // Return array of child view of this node / view
6 | func childViews() -> [any View]
7 | }
8 |
9 | extension NodeBuilder where Self: View {
10 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
11 | return Node(view: self, viewIdentifier: viewIdentifier)
12 | }
13 | func childViews() -> [any View] { [] }
14 | }
15 |
16 | protocol RenderableNode {
17 | func proposeViewSize(inSize: Size) -> Size
18 | func render(context: RenderContext, start: Point, size: Size)
19 | }
20 |
21 | protocol EnvironmentProvidable: AnyObject {
22 | // Gets the environment values for the given node
23 | func getEnvironmentValues() -> EnvironmentValues
24 | }
25 |
26 | class Node: EnvironmentProvidable {
27 | weak var parent: Node?
28 | let viewIdentifier: ViewIdentifier
29 | var view: any View
30 | var children: [Node] = []
31 | var renderableChildren: [RenderableNode] {
32 | children.reduce(into: []) { partialResult, child in
33 | if let renderableChild = child as? RenderableNode {
34 | partialResult.append(contentsOf: [renderableChild])
35 | } else {
36 | partialResult.append(contentsOf: child.renderableChildren)
37 | }
38 | }
39 | }
40 |
41 | var environmentValues: EnvironmentValues?
42 |
43 | init(view: any View, viewIdentifier: ViewIdentifier) {
44 | self.view = view
45 | self.viewIdentifier = viewIdentifier
46 | }
47 |
48 | func addChild(_ child: Node) {
49 | child.parent = self
50 | children.append(child)
51 | }
52 |
53 | // Focus related
54 | var focusable: Bool = false
55 | var focused: Bool = false
56 | func activate() {
57 |
58 | }
59 |
60 | func getEnvironmentValues() -> EnvironmentValues {
61 | if let environmentValues = environmentValues ?? parent?.getEnvironmentValues() {
62 | return environmentValues
63 | }
64 |
65 | assertionFailure("Failed to get environment values")
66 | return EnvironmentValues()
67 | }
68 | }
69 |
70 | // TODO: Add Equatable to all Nodes
71 | extension Node: Equatable {
72 | static func == (lhs: Node, rhs: Node) -> Bool {
73 | return type(of: lhs) == type(of: rhs) && lhs.viewIdentifier == rhs.viewIdentifier
74 | }
75 | }
76 | extension Node: CustomStringConvertible {
77 | var description: String {
78 | var result = "Node (View: \(view), ViewIdentifier: \(viewIdentifier))\n"
79 | // for child in children {
80 | // let childDescription = child.description
81 | // .split(separator: "\n")
82 | // .map { " \($0)" }
83 | // .joined(separator: "\n")
84 | // result += "\(childDescription)\n"
85 | // }
86 | return result.trimmingCharacters(in: .whitespacesAndNewlines)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/Core/RenderContext.swift:
--------------------------------------------------------------------------------
1 | class RenderContext {
2 | let terminal: Terminal
3 |
4 | init(terminal: Terminal) {
5 | self.terminal = terminal
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/Core/RenderEngine.swift:
--------------------------------------------------------------------------------
1 | class RenderEngine {
2 | lazy var terminal: Terminal = Terminal()
3 | lazy var context: RenderContext = RenderContext(terminal: terminal)
4 |
5 | func render(fromNode node: RenderableNode) {
6 | terminal.update() // Update terminal canvas
7 | defer {
8 | // Flush output on terminal
9 | terminal.render()
10 | }
11 |
12 | let size = node.proposeViewSize(
13 | inSize: (width: terminal.canvasWidth, height: terminal.canvasHeight))
14 |
15 | let start = Point(
16 | x: (terminal.canvasWidth - size.width) / 2, y: (terminal.canvasHeight - size.height) / 2
17 | )
18 |
19 | node.render(context: context, start: start, size: size)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/Core/State.swift:
--------------------------------------------------------------------------------
1 | protocol AnyState {
2 | var stateReference: StateReference { get }
3 | }
4 |
5 | @propertyWrapper
6 | public struct State: AnyState {
7 | private let initialValue: Value
8 |
9 | public init(wrappedValue initialValue: Value) {
10 | self.initialValue = initialValue
11 | }
12 |
13 | public var wrappedValue: Value {
14 | get {
15 | guard let stateManager = stateReference.stateManager,
16 | let stateIdentifier = stateReference.stateIdentifier
17 | else {
18 | assertionFailure("Attempted to use @State before initialization")
19 | return initialValue
20 | }
21 | if let value = stateManager[stateIdentifier] as? Value {
22 | return value
23 | }
24 | return initialValue
25 | }
26 | nonmutating set {
27 | guard let stateManager = stateReference.stateManager,
28 | let stateIdentifier = stateReference.stateIdentifier
29 | else {
30 | assertionFailure("Attempted to use @State before initialization")
31 | return
32 | }
33 |
34 | stateManager[stateIdentifier] = newValue
35 | }
36 | }
37 |
38 | public var projectedValue: Binding {
39 | return Binding(get: { wrappedValue }, set: { (newValue) in wrappedValue = newValue })
40 | }
41 |
42 | // We can update this value internally
43 | let stateReference = StateReference()
44 | }
45 |
46 | class StateReference {
47 | var stateIdentifier: StateIdentifier?
48 | weak var stateManager: StateManager?
49 | }
50 |
51 | @propertyWrapper
52 | public struct Binding {
53 | private let getFromState: () -> Value
54 | private let setToState: (Value) -> Void
55 |
56 | public init(_ bindingValue: Binding) {
57 | getFromState = bindingValue.getFromState
58 | setToState = bindingValue.setToState
59 | }
60 |
61 | public init(get: @escaping () -> Value, set: @escaping (Value) -> Void) {
62 | getFromState = get
63 | setToState = set
64 | }
65 |
66 | public var wrappedValue: Value {
67 | get { getFromState() }
68 | nonmutating set { setToState(newValue) }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Sources/Core/Terminal.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import os
3 |
4 | private let logger = Logger(subsystem: "com.rational.blinkui", category: "Terminal")
5 |
6 | class Terminal {
7 |
8 | // MARK: - Canvas
9 | private(set) var canvas: [[Character]] = TerminalHelper.makeCanvas()
10 | var canvasWidth: Int {
11 | canvas[0].count
12 | }
13 | var canvasHeight: Int {
14 | canvas.count
15 | }
16 |
17 | init() {
18 | TerminalHelper.enableAlternateScreen()
19 | TerminalHelper.hideCursor()
20 | TerminalHelper.enableRawMode()
21 | }
22 |
23 | func stop() {
24 | TerminalHelper.disableRawMode()
25 | TerminalHelper.showCursor()
26 | TerminalHelper.disableAlternateScreen()
27 | }
28 |
29 | func update() {
30 | // Re-creating the canvas each time which does not seem like a good idea
31 | canvas = TerminalHelper.makeCanvas()
32 | }
33 |
34 | // Renders the canvas / frame on the terminal
35 | func render() {
36 | TerminalHelper.moveCursor(x: 0, y: 0)
37 | for row in canvas {
38 | print(String(row))
39 | }
40 | fflush(stdout)
41 | }
42 |
43 | // Draws the symbol at the given position
44 | func draw(x: Int, y: Int, symbol: Character) {
45 | guard x >= 0, x < canvasWidth, y >= 0, y < canvasHeight else {
46 | logger.error("Invalid coordinates: \(x), \(y)")
47 | return
48 | }
49 | canvas[y][x] = symbol
50 | }
51 | }
52 |
53 | extension Terminal {
54 | static func getKeyPress() -> String? {
55 | var buffer = [UInt8](repeating: 0, count: 3)
56 | let readBytes = read(STDIN_FILENO, &buffer, 3)
57 |
58 | if readBytes == 1 {
59 | return String(UnicodeScalar(buffer[0]))
60 | } else if readBytes > 1 {
61 | return buffer.prefix(readBytes).map { String(UnicodeScalar($0)) }.joined()
62 | }
63 | return nil
64 | }
65 | }
66 |
67 | private struct TerminalHelper {
68 | static func windowSize() -> (width: Int, height: Int) {
69 | if IsDebug() {
70 | return (30, 15)
71 | } else {
72 | var ws = winsize()
73 | _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws)
74 | return (Int(ws.ws_col), Int(ws.ws_row) - 1)
75 | }
76 | }
77 |
78 | // Create blank 2d buffer of blank characters
79 | static func makeCanvas() -> [[Character]] {
80 | let (width, height) = windowSize()
81 | return Array(repeating: Array(repeating: " ", count: width), count: height)
82 | }
83 |
84 | // Enable alternate screen buffer (prevents scrolling & flickering)
85 | static func enableAlternateScreen() {
86 | print("\u{001B}[?1049h", terminator: "")
87 | }
88 |
89 | // Disable alternate screen buffer on exit
90 | static func disableAlternateScreen() {
91 | print("\u{001B}[?1049l", terminator: "")
92 | }
93 |
94 | // Hide cursor for better visual experience
95 | static func hideCursor() {
96 | print("\u{001B}[?25l", terminator: "")
97 | }
98 |
99 | // Show cursor again when exiting
100 | static func showCursor() {
101 | print("\u{001B}[?25h", terminator: "")
102 | }
103 |
104 | // Move the cursor to a specific position
105 | static func moveCursor(x: Int, y: Int) {
106 | print("\u{001B}[\(y);\(x)H", terminator: "")
107 | }
108 |
109 | // Clear the screen
110 | static func clearScreen() {
111 | print("\u{001B}[2J", terminator: "")
112 | }
113 |
114 | // Enable non-blocking input
115 | static func enableRawMode() {
116 | var raw = termios()
117 | tcgetattr(STDIN_FILENO, &raw)
118 | raw.c_lflag &= ~tcflag_t(ECHO | ICANON) // Disable echo & line buffering
119 | raw.c_cc.0 = 1 // Min character read
120 | raw.c_cc.1 = 0 // No timeout
121 | tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw)
122 | }
123 |
124 | // Disable non-blocking input
125 | static func disableRawMode() {
126 | var term = termios()
127 | tcgetattr(STDIN_FILENO, &term)
128 | term.c_lflag |= tcflag_t(ECHO | ICANON)
129 | tcsetattr(STDIN_FILENO, TCSAFLUSH, &term)
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/Sources/Core/TreeEngine.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import os
3 |
4 | private let logger = Logger(subsystem: "com.rational.blinkui", category: "TreeEngine")
5 |
6 | // Storing state separate from node because state should persist while node creation
7 | class StateManager {
8 | // TODO: Store it in key:ViewIdentifier with [StateIdnetifer: Value]
9 | var stateStorage = [StateIdentifier: Any]()
10 | let nodeStateDidUpdate: (ViewIdentifier) -> Void
11 | init(nodeStateDidUpdate: @escaping (ViewIdentifier) -> Void) {
12 | self.nodeStateDidUpdate = nodeStateDidUpdate // check if we can do something better
13 | }
14 |
15 | subscript(key: StateIdentifier) -> Any? {
16 | get {
17 | return stateStorage[key]
18 | }
19 | set {
20 | stateStorage[key] = newValue
21 | // Maybe schedule this -- and do it in process
22 | nodeStateDidUpdate(key.viewIdentifier)
23 | }
24 | }
25 |
26 | func removeStateStorage(forViewIdentifier: ViewIdentifier) {
27 | // Too costly
28 | let statesToRemove = stateStorage.compactMap { (stateIdentifier, _) in
29 | // Check if
30 | if stateIdentifier.viewIdentifier.isDescendent(ofViewIdentifier: forViewIdentifier) {
31 | return stateIdentifier
32 | }
33 | return nil
34 | }
35 | for stateToRemove in statesToRemove {
36 | stateStorage.remove(at: stateStorage.index(forKey: stateToRemove)!)
37 | }
38 | }
39 | }
40 |
41 | class TreeEngine {
42 | let app: any App
43 | lazy var rootNode = buildTree(fromRootView: app)
44 | var renderableRootNode: RenderableNode { rootNode as! RenderableNode }
45 | lazy var stateManager = StateManager(nodeStateDidUpdate: { [weak self] (viewIdentifier) in
46 | DispatchQueue.global(qos: .userInitiated).async {
47 | guard let self else {
48 | return
49 | }
50 | self.rootNode = self.buildTree(fromRootView: self.app)
51 | }
52 | })
53 |
54 | var mapOfNodes: [ViewIdentifier: Node]
55 |
56 | init(app: any App) {
57 | self.app = app
58 | self.mapOfNodes = [ViewIdentifier: Node]()
59 | }
60 |
61 | // func renderTree(fromRootView rootView)
62 |
63 | func buildTree(fromRootView rootView: some App) -> Node {
64 | // Special parent node
65 | let viewIdentifier = ViewIdentifier().withPosition(0)
66 | let screen = Screen { rootView }
67 | let screenNode = screen.buildNode(viewIdentifier: viewIdentifier)
68 |
69 | mapOfNodes[viewIdentifier] = screenNode
70 |
71 | for (position, childView) in screen.childViews().enumerated() {
72 | let nextViewIdentifier = viewIdentifier.withPosition(position)
73 | _buildTree(
74 | fromView: childView, parentNode: screenNode,
75 | currentViewIdentifier: nextViewIdentifier)
76 | }
77 |
78 | return screenNode
79 | }
80 |
81 | // currentViewIdentifier: Identifier that this node will have
82 | private func _buildTree(
83 | fromView view: any View, parentNode: Node, currentViewIdentifier: ViewIdentifier
84 | ) {
85 |
86 | let nodeBuilder =
87 | (view as? NodeBuilder) ?? ClientDefinedViewNodeBuilder(clientDefinedView: view)
88 | let node = nodeBuilder.buildNode(viewIdentifier: currentViewIdentifier)
89 |
90 | parentNode.addChild(node)
91 | // Node has changes so purge the sub tree
92 | if let cachedNode = mapOfNodes[currentViewIdentifier], cachedNode != node {
93 | purgeSubTree(fromNode: cachedNode)
94 | }
95 |
96 | mapOfNodes[currentViewIdentifier] = node
97 |
98 | populateInternals(fromView: view, node: node)
99 |
100 | for (position, childView) in nodeBuilder.childViews().enumerated() {
101 | let nextViewIdentifier = currentViewIdentifier.withPosition(position)
102 | _buildTree(
103 | fromView: childView, parentNode: node,
104 | currentViewIdentifier: nextViewIdentifier)
105 | }
106 | }
107 |
108 | private func __printViewTree(fromView view: any View, level: Int) {
109 | let indentation = String(repeating: " ", count: level)
110 | print("\(indentation)- \(type(of:view))")
111 |
112 | let children = (view as? NodeBuilder)?.childViews() ?? [view.body]
113 | for child in children {
114 | __printViewTree(fromView: child, level: level + 1)
115 | }
116 | }
117 |
118 | // Populates State and Environment wrappers
119 | private func populateInternals(fromView view: any View, node: Node) {
120 | let mirror = Mirror(reflecting: view)
121 | mirror.children.forEach { (label, child) in
122 | if let state = child as? AnyState {
123 | state.stateReference.stateManager = self.stateManager
124 | state.stateReference.stateIdentifier = node.viewIdentifier.withState(label ?? "")
125 | } else if let environment = child as? AnyEnvironment {
126 | environment.environmentReference.environmentProvider = node
127 | }
128 | }
129 | }
130 |
131 | // Purge subtree from node
132 | // The node has changed so purge the whole tree
133 | // - Remove the node from mapOfNodes
134 | // - Remove the state related to that node
135 | // - Call same on children
136 | private func purgeSubTree(fromNode node: Node) {
137 | if let indexToPurge = mapOfNodes.index(forKey: node.viewIdentifier) {
138 | mapOfNodes.remove(at: indexToPurge)
139 | stateManager.removeStateStorage(forViewIdentifier: node.viewIdentifier)
140 | }
141 |
142 | node.children.forEach { child in
143 | purgeSubTree(fromNode: child)
144 | }
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/Sources/Core/View.swift:
--------------------------------------------------------------------------------
1 | public protocol View {
2 | associatedtype Body: View = Never
3 | @ViewBuilder var body: Self.Body { get }
4 | }
5 | extension View where Body == Never {
6 | public var body: Body { fatalError() }
7 | }
8 |
9 | extension Never: View {}
10 |
11 | public protocol ViewModifier {
12 | typealias Content = AnyView
13 | associatedtype Body: View
14 |
15 | @ViewBuilder func body(content: Content) -> Body
16 | }
17 |
18 | public struct ModifiedContent: View
19 | where Modifier: ViewModifier, Content: View {
20 | let content: Content
21 | let modifier: Modifier
22 |
23 | public var body: some View {
24 | modifier.body(content: AnyView { content })
25 | }
26 | }
27 |
28 | extension View {
29 | func modifier(_ modifier: M) -> ModifiedContent {
30 | ModifiedContent(content: self, modifier: modifier)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Core/ViewBuilder.swift:
--------------------------------------------------------------------------------
1 | @resultBuilder
2 | struct ViewBuilder {
3 | static func buildEither(first component: T) -> ConditionalView
4 | where T: View, F: View {
5 | ConditionalView.truthy {
6 | component
7 | }
8 | }
9 |
10 | static func buildEither(second component: F) -> ConditionalView
11 | where T: View, F: View {
12 | ConditionalView.falsy {
13 | component
14 | }
15 | }
16 |
17 | public static func buildExpression(_ content: Content) -> Content where Content: View {
18 | content
19 | }
20 |
21 | public static func buildBlock() -> EmptyView {
22 | EmptyView()
23 | }
24 |
25 | public static func buildBlock(_ content: Content) -> Content where Content: View {
26 | return content
27 | }
28 |
29 | public static func buildBlock(_ content: repeat each Content) -> TupleView<
30 | repeat each Content
31 | > where repeat each Content: View {
32 | TupleView((repeat each content))
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/ViewModifierComponents/Border.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 | public func border(style: BorderStyle = .solid) -> some View {
5 | modifier(BorderModifier(style: style))
6 | }
7 | }
8 |
9 | public enum BorderStyle {
10 | case solid
11 | case dashed
12 | case dotted
13 | case rounded
14 | case double
15 | }
16 |
17 | private struct BorderModifier: ViewModifier {
18 | let style: BorderStyle
19 |
20 | func body(content: Content) -> some View {
21 | BorderView(content: content, style: style)
22 | }
23 | }
24 |
25 | private struct BorderView: View where Content: View {
26 | let content: Content
27 | let style: BorderStyle
28 | }
29 | extension BorderView: NodeBuilder {
30 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
31 | BorderNode(view: self, viewIdentifier: viewIdentifier)
32 | }
33 |
34 | func childViews() -> [any View] {
35 | [VStack { content }]
36 | }
37 | }
38 |
39 | private class BorderNode: Node where Content: View {
40 | var borderView: BorderView {
41 | guard let borderView = view as? BorderView else {
42 | fatalError("BorderNode can only be used with Border views")
43 | }
44 | return borderView
45 | }
46 |
47 | init(view: BorderView, viewIdentifier: ViewIdentifier) {
48 | super.init(view: view, viewIdentifier: viewIdentifier)
49 | }
50 | }
51 |
52 | extension BorderNode: RenderableNode {
53 | func proposeViewSize(inSize: Size) -> Size {
54 | assert(renderableChildren.count == 1, "There should be only one renderable child")
55 | let renderableChild = renderableChildren[0]
56 |
57 | let proposedChildViewSize = renderableChild.proposeViewSize(inSize: inSize)
58 |
59 | let additionalWidth = 2
60 | let additionalHeight = 2
61 | let proposedSize = (
62 | width: proposedChildViewSize.width + additionalWidth,
63 | height: proposedChildViewSize.height + additionalHeight
64 | )
65 |
66 | return (width: max(proposedSize.width, 0), height: max(proposedSize.height, 0))
67 | }
68 |
69 | func render(context: RenderContext, start: Point, size: Size) {
70 | assert(renderableChildren.count == 1, "There should be only one renderable child")
71 | let renderableChild = renderableChildren[0]
72 |
73 | let borderSymbols = symbols(for: borderView.style)
74 |
75 | context.terminal.draw(x: start.x, y: start.y, symbol: borderSymbols.topLeft)
76 | context.terminal.draw(
77 | x: start.x + size.width - 1, y: start.y, symbol: borderSymbols.topRight)
78 | context.terminal.draw(
79 | x: start.x, y: start.y + size.height - 1, symbol: borderSymbols.bottomLeft)
80 | context.terminal.draw(
81 | x: start.x + size.width - 1, y: start.y + size.height - 1,
82 | symbol: borderSymbols.bottomRight)
83 |
84 | for x in (start.x + 1)..<(start.x + size.width - 1) {
85 | context.terminal.draw(x: x, y: start.y, symbol: borderSymbols.horizontal)
86 | context.terminal.draw(
87 | x: x, y: start.y + size.height - 1, symbol: borderSymbols.horizontal)
88 | }
89 |
90 | for y in (start.y + 1)..<(start.y + size.height - 1) {
91 | context.terminal.draw(x: start.x, y: y, symbol: borderSymbols.vertical)
92 | context.terminal.draw(x: start.x + size.width - 1, y: y, symbol: borderSymbols.vertical)
93 | }
94 |
95 | let adjustedStart = Point(x: start.x + 1, y: start.y + 1)
96 | let adjustedSize = Size(width: size.width - 2, height: size.height - 2)
97 |
98 | renderableChild.render(context: context, start: adjustedStart, size: adjustedSize)
99 | }
100 |
101 | private func symbols(for style: BorderStyle) -> (
102 | topLeft: Character, topRight: Character, bottomLeft: Character, bottomRight: Character,
103 | horizontal: Character, vertical: Character
104 | ) {
105 | switch style {
106 | case .solid:
107 | return ("┌", "┐", "└", "┘", "─", "│")
108 | case .dashed:
109 | return ("┌", "┐", "└", "┘", "╌", "╎")
110 | case .dotted:
111 | return ("·", "·", "·", "·", "·", "·")
112 | case .rounded:
113 | return ("╭", "╮", "╰", "╯", "─", "│")
114 | case .double:
115 | return ("╔", "╗", "╚", "╝", "═", "║")
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/Sources/ViewModifierComponents/Frame.swift:
--------------------------------------------------------------------------------
1 | extension View {
2 | func frame(width: Int? = nil, height: Int? = nil, alignment: Alignment = .center) -> some View {
3 | modifier(FrameModifier(width: width, height: height, alignment: alignment))
4 | }
5 | }
6 |
7 | private struct FrameModifier: ViewModifier {
8 | let width: Int?
9 | let height: Int?
10 | let alignment: Alignment
11 |
12 | func body(content: Content) -> some View {
13 | FrameView(content: content, width: width, height: height, alignment: alignment)
14 | }
15 | }
16 |
17 | private struct FrameView: View where Content: View {
18 | let content: Content
19 | let width: Int?
20 | let height: Int?
21 | let alignment: Alignment
22 | }
23 |
24 | extension FrameView: NodeBuilder {
25 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
26 | FrameNode(view: self, viewIdentifier: viewIdentifier)
27 | }
28 |
29 | func childViews() -> [any View] {
30 | [VStack { content }]
31 | }
32 | }
33 |
34 | private class FrameNode: Node where Content: View {
35 | var frameView: FrameView {
36 | guard let frameView = view as? FrameView else {
37 | fatalError("FrameNode can only be used with Frame views")
38 | }
39 | return frameView
40 | }
41 |
42 | init(view: FrameView, viewIdentifier: ViewIdentifier) {
43 | super.init(view: view, viewIdentifier: viewIdentifier)
44 | }
45 | }
46 |
47 | extension FrameNode: RenderableNode {
48 | func proposeViewSize(inSize: Size) -> Size {
49 | assert(renderableChildren.count == 1, "There should be only one renderable child")
50 | let renderableChild = renderableChildren[0]
51 |
52 | // Get the proposed size from the child
53 | let proposedChildViewSize = renderableChild.proposeViewSize(inSize: inSize)
54 |
55 | // Adjust the proposed size based on the frame's width and height
56 | let proposedSize = (
57 | width: frameView.width == .infinity
58 | ? inSize.width : (frameView.width ?? proposedChildViewSize.width),
59 | height: frameView.height == .infinity
60 | ? inSize.height : (frameView.height ?? proposedChildViewSize.height)
61 | )
62 |
63 | return (width: max(proposedSize.width, 0), height: max(proposedSize.height, 0))
64 | }
65 |
66 | func render(context: RenderContext, start: Point, size: Size) {
67 | assert(renderableChildren.count == 1, "There should be only one renderable child")
68 | let renderableChild = renderableChildren[0]
69 |
70 | // Calculate the alignment offset
71 | let childSize = renderableChild.proposeViewSize(inSize: size)
72 | let offset = AlignmentUtility.calculateAlignmentOffset(
73 | parentSize: size, childSize: childSize, alignment: frameView.alignment)
74 |
75 | // Render the child with the given size and alignment offset
76 | renderableChild.render(
77 | context: context, start: Point(x: start.x + offset.x, y: start.y + offset.y),
78 | size: childSize)
79 | }
80 | }
81 |
82 | struct AlignmentUtility {
83 | static func calculateAlignmentOffset(parentSize: Size, childSize: Size, alignment: Alignment)
84 | -> Point
85 | {
86 | let xOffset: Int
87 | let yOffset: Int
88 |
89 | switch alignment {
90 | case .topLeading:
91 | xOffset = 0
92 | yOffset = 0
93 | case .top:
94 | xOffset = (parentSize.width - childSize.width) / 2
95 | yOffset = 0
96 | case .topTrailing:
97 | xOffset = parentSize.width - childSize.width
98 | yOffset = 0
99 | case .leading:
100 | xOffset = 0
101 | yOffset = (parentSize.height - childSize.height) / 2
102 | case .center:
103 | xOffset = (parentSize.width - childSize.width) / 2
104 | yOffset = (parentSize.height - childSize.height) / 2
105 | case .trailing:
106 | xOffset = parentSize.width - childSize.width
107 | yOffset = (parentSize.height - childSize.height) / 2
108 | case .bottomLeading:
109 | xOffset = 0
110 | yOffset = parentSize.height - childSize.height
111 | case .bottom:
112 | xOffset = (parentSize.width - childSize.width) / 2
113 | yOffset = parentSize.height - childSize.height
114 | case .bottomTrailing:
115 | xOffset = parentSize.width - childSize.width
116 | yOffset = parentSize.height - childSize.height
117 | }
118 |
119 | return Point(x: xOffset, y: yOffset)
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/Sources/ViewModifierComponents/Padding.swift:
--------------------------------------------------------------------------------
1 | extension View {
2 | func padding(_ edges: EdgeSet = .all, _ length: Int = 2) -> some View {
3 | modifier(PaddingModifier(edges: edges, length: length))
4 | }
5 | }
6 |
7 | private struct PaddingModifier: ViewModifier {
8 | let edges: EdgeSet
9 | let length: Int
10 |
11 | func body(content: Content) -> some View {
12 | PaddingView(content: content, edges: edges, length: length)
13 | }
14 | }
15 |
16 | private struct PaddingView: View where Content: View {
17 | let content: Content
18 | let edges: EdgeSet
19 | let length: Int
20 | }
21 | extension PaddingView: NodeBuilder {
22 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
23 | PaddingNode(view: self, viewIdentifier: viewIdentifier)
24 | }
25 |
26 | func childViews() -> [any View] {
27 | [VStack { content }]
28 | }
29 | }
30 |
31 | private class PaddingNode: Node where Content: View {
32 | var paddingView: PaddingView {
33 | guard let paddingView = view as? PaddingView else {
34 | fatalError("PaddingNode can only be used with Padding views")
35 | }
36 | return paddingView
37 | }
38 |
39 | init(view: PaddingView, viewIdentifier: ViewIdentifier) {
40 | super.init(view: view, viewIdentifier: viewIdentifier)
41 | }
42 | }
43 | extension PaddingNode: RenderableNode {
44 | func proposeViewSize(inSize: Size) -> Size {
45 | assert(renderableChildren.count == 1, "There should be only one renderable child")
46 | let renderableChild = renderableChildren[0]
47 |
48 | // Adjust the input size by subtracting padding
49 | let adjustedInSize = (
50 | width: inSize.width
51 | - (paddingView.edges.contains(.leading) ? paddingView.length : 0)
52 | - (paddingView.edges.contains(.trailing) ? paddingView.length : 0),
53 | height: inSize.height
54 | - (paddingView.edges.contains(.top) ? paddingView.length : 0)
55 | - (paddingView.edges.contains(.bottom) ? paddingView.length : 0)
56 | )
57 |
58 | // Get the proposed size from the child
59 | let proposedChildViewSize = renderableChild.proposeViewSize(inSize: adjustedInSize)
60 |
61 | // Adjust the proposed size by adding padding
62 | let proposedSize = (
63 | width: proposedChildViewSize.width
64 | + (paddingView.edges.contains(.leading) ? paddingView.length : 0)
65 | + (paddingView.edges.contains(.trailing) ? paddingView.length : 0),
66 | height: proposedChildViewSize.height
67 | + (paddingView.edges.contains(.top) ? paddingView.length : 0)
68 | + (paddingView.edges.contains(.bottom) ? paddingView.length : 0)
69 | )
70 |
71 | return (width: max(proposedSize.width, 0), height: max(proposedSize.height, 0))
72 | }
73 |
74 | func render(context: RenderContext, start: Point, size: Size) {
75 | assert(renderableChildren.count == 1, "There should be only one renderable child")
76 | let renderableChild = renderableChildren[0]
77 |
78 | let adjustedStart = Point(
79 | x: start.x + (paddingView.edges.contains(.leading) ? paddingView.length : 0),
80 | y: start.y + (paddingView.edges.contains(.top) ? paddingView.length : 0)
81 | )
82 |
83 | let adjustedSize = Size(
84 | width: size.width
85 | - (paddingView.edges.contains(.leading) ? paddingView.length : 0)
86 | - (paddingView.edges.contains(.trailing) ? paddingView.length : 0),
87 | height: size.height
88 | - (paddingView.edges.contains(.top) ? paddingView.length : 0)
89 | - (paddingView.edges.contains(.bottom) ? paddingView.length : 0)
90 | )
91 |
92 | renderableChild.render(context: context, start: adjustedStart, size: adjustedSize)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Sources/ViewModifierComponents/SetEnvironment.swift:
--------------------------------------------------------------------------------
1 | extension View {
2 | func environment(_ keyPath: WritableKeyPath, _ value: V) -> some View {
3 | modifier(SetEnvironmentModifier(keyPath: keyPath, value: value))
4 | }
5 | }
6 |
7 | private struct SetEnvironmentModifier: ViewModifier {
8 | let keyPath: WritableKeyPath
9 | let value: V
10 |
11 | func body(content: Content) -> some View {
12 | SetEnvironmentView(content: content, keyPath: keyPath, value: value)
13 | }
14 | }
15 |
16 | private struct SetEnvironmentView: View where Content: View {
17 | let content: Content
18 | let keyPath: WritableKeyPath
19 | let value: V
20 | }
21 |
22 | extension SetEnvironmentView: NodeBuilder {
23 | func buildNode(viewIdentifier: ViewIdentifier) -> Node {
24 | return SetEnvironmentNode(view: self, viewIdentifier: viewIdentifier)
25 | }
26 |
27 | func childViews() -> [any View] {
28 | [content]
29 | }
30 | }
31 |
32 | private class SetEnvironmentNode: Node where Content: View {
33 | var environmentView: SetEnvironmentView {
34 | guard let environmentView = view as? SetEnvironmentView else {
35 | fatalError("SetEnvironmentNode can only be used with environment views")
36 | }
37 | return environmentView
38 | }
39 |
40 | init(view: SetEnvironmentView, viewIdentifier: ViewIdentifier) {
41 | super.init(view: view, viewIdentifier: viewIdentifier)
42 | }
43 |
44 | override func getEnvironmentValues() -> EnvironmentValues {
45 | if let environmentValues {
46 | return environmentValues
47 | }
48 |
49 | var environmentValues = super.getEnvironmentValues()
50 | environmentValues[keyPath: environmentView.keyPath] = environmentView.value
51 | self.environmentValues = environmentValues
52 | return environmentValues
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/main.swift:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | struct Example: App {
4 | @State var isSelected: Bool = false
5 |
6 | var body: some View {
7 | // HStack(alignment: .top) {
8 | // Text("Hello")
9 | // Text("World")
10 | // VStack {
11 | // Text("Is this")
12 | // Text("the")
13 | // Text("Matrix?")
14 | // }
15 |
16 | // }
17 |
18 | // HStack {
19 | // Text("Hello")
20 | // Text("World")
21 | // VStack {
22 | // Text("Is this")
23 | // Text("the")
24 | // Text("Matrix?")
25 | // }
26 |
27 | // }
28 | HStack(alignment: .bottom) {
29 | Text("Hello")
30 | Text("World")
31 | VStack {
32 | Text("Is this")
33 | Text("the")
34 | Text("Matrix?")
35 | }
36 |
37 | }
38 | }
39 | }
40 |
41 | let engine = AppEngine(app: Example())
42 | engine.run()
43 |
--------------------------------------------------------------------------------