Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ type_name:
excluded:
- ID
# Internal _Core views follow SwiftUI pattern (e.g. _AlertCore, _DialogCore)
# _ConditionalContent and _ViewModifier_Content match SwiftUI's public type names
- _ConditionalContent
- _ViewModifier_Content
- _AlertCore
- _DialogCore
- _PanelCore
Expand Down
8 changes: 4 additions & 4 deletions Sources/TUIkit/Extensions/View+Layout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ extension View {
/// - Parameter color: The background color.
/// - Returns: A view with the background color applied.
public func background(_ color: Color) -> some View {
modifier(BackgroundModifier(color: color))
BufferModifiedView(content: self, modifier: BackgroundModifier(color: color))
}
}

Expand Down Expand Up @@ -235,7 +235,7 @@ extension View {
/// - Parameter amount: The padding amount on all sides (default: 1).
/// - Returns: A padded view.
public func padding(_ amount: Int = 1) -> some View {
modifier(PaddingModifier(insets: EdgeInsets(all: amount)))
BufferModifiedView(content: self, modifier: PaddingModifier(insets: EdgeInsets(all: amount)))
}

/// Adds padding on specific edges.
Expand All @@ -261,7 +261,7 @@ extension View {
bottom: edges.contains(.bottom) ? amount : 0,
trailing: edges.contains(.trailing) ? amount : 0
)
return modifier(PaddingModifier(insets: insets))
return BufferModifiedView(content: self, modifier: PaddingModifier(insets: insets))
}

/// Adds padding with explicit edge insets.
Expand All @@ -274,6 +274,6 @@ extension View {
/// - Parameter insets: The edge insets.
/// - Returns: A padded view.
public func padding(_ insets: EdgeInsets) -> some View {
modifier(PaddingModifier(insets: insets))
BufferModifiedView(content: self, modifier: PaddingModifier(insets: insets))
}
}
10 changes: 5 additions & 5 deletions Sources/TUIkit/Extensions/View+Modifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
// MARK: - View Modifier Extension

extension View {
/// Applies a modifier to this view.
/// Applies a modifier to this view and returns a new view.
///
/// - Parameter modifier: The modifier to apply.
/// - Returns: A modified view.
public func modifier<M: ViewModifier>(_ modifier: M) -> ModifiedView<Self, M> {
ModifiedView(content: self, modifier: modifier)
/// - Parameter modifier: The modifier to apply to this view.
/// - Returns: A new view with the modifier applied.
public func modifier<M>(_ modifier: M) -> ModifiedContent<Self, M> {
ModifiedContent(content: self, modifier: modifier)
}
}
8 changes: 4 additions & 4 deletions Sources/TUIkit/Modifiers/BackgroundModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@

/// A modifier that fills the background of a view with a color.
///
/// - Important: This is framework infrastructure. Use `.background()` on any
/// ``View`` instead of instantiating this type directly.
public struct BackgroundModifier: ViewModifier {
/// Framework infrastructure behind `.background()`; operates on rendered
/// buffers through the internal buffer-modifier layer.
struct BackgroundModifier: BufferViewModifier {
/// The background color.
let color: Color

public func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer {
func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer {
guard !buffer.isEmpty else { return buffer }

let resolvedColor = color.resolve(with: context.environment.palette)
Expand Down
10 changes: 5 additions & 5 deletions Sources/TUIkit/Modifiers/PaddingModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,20 @@ public struct Edge: OptionSet, Sendable {

/// A modifier that adds padding around a view.
///
/// - Important: This is framework infrastructure. Use `.padding()` on any
/// ``View`` instead of instantiating this type directly.
public struct PaddingModifier: ViewModifier {
/// Framework infrastructure behind `.padding()`; operates on rendered
/// buffers through the internal buffer-modifier layer.
struct PaddingModifier: BufferViewModifier {
/// The padding insets.
let insets: EdgeInsets

public func adjustContext(_ context: RenderContext) -> RenderContext {
func adjustContext(_ context: RenderContext) -> RenderContext {
var adjusted = context
adjusted.availableWidth = max(0, context.availableWidth - insets.leading - insets.trailing)
adjusted.availableHeight = max(0, context.availableHeight - insets.top - insets.bottom)
return adjusted
}

public func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer {
func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer {
var result: [String] = []

let leadingPad = String(repeating: " ", count: insets.leading)
Expand Down
8 changes: 7 additions & 1 deletion Sources/TUIkit/TUIkit.docc/Articles/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ This enables spacers, flexible text fields, and proportional sizing. See <doc:La

### 4. Modifier Layer

View modifiers implement the ``ViewModifier`` protocol. They operate in two phases: `adjustContext(_:)` modifies the ``RenderContext`` before children render (e.g. setting environment values), and `modify(buffer:context:)` transforms the rendered ``FrameBuffer`` (e.g. adding padding, borders, backgrounds).
Public view modifiers implement the ``ViewModifier`` protocol with SwiftUI's
`body(content:)` contract: the modifier composes a replacement view around a
placeholder for the modified content, and `.modifier(_:)` wraps both in a
``ModifiedContent`` value. Terminal-specific buffer transformations (padding
rows, background fills) run behind an internal buffer-modifier layer that
adjusts the ``RenderContext`` before rendering and transforms the rendered
``FrameBuffer`` afterwards.

```swift
Text("Hello")
Expand Down
49 changes: 24 additions & 25 deletions Sources/TUIkit/TUIkit.docc/Articles/CustomViews.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,30 +119,30 @@ SettingsGroup("Network") {

## View Modifiers

A ``ViewModifier`` transforms an already-rendered ``FrameBuffer``. Use this when your transformation operates on the output of any view:
A ``ViewModifier`` composes a replacement view around the content it is
applied to — exactly like SwiftUI. Implement `body(content:)` and place the
received `content` placeholder wherever the modified view should appear:

```swift
struct IndentModifier: ViewModifier {
let columns: Int
struct SectionCard: ViewModifier {
let title: String

func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer {
var result = FrameBuffer(
emptyWithWidth: max(0, columns),
height: buffer.height
)
if !buffer.isEmpty {
result.appendHorizontally(buffer)
func body(content: Content) -> some View {
VStack {
Text(title).bold()
content
}
return result
.padding()
.border()
}
}
```

Apply it using `.modifier(_:)`:
Apply it using `.modifier(_:)`, which produces a ``ModifiedContent`` value:

```swift
Text("Indented")
.modifier(IndentModifier(columns: 2))
Text("Details")
.modifier(SectionCard(title: "Info"))
```

### Convenience Extensions
Expand All @@ -151,24 +151,23 @@ For a cleaner API, add a `View` extension:

```swift
extension View {
func indented(_ columns: Int = 2) -> some View {
modifier(IndentModifier(columns: columns))
func sectionCard(_ title: String) -> some View {
modifier(SectionCard(title: title))
}
}

// Usage
Text("Indented").indented(4)
Text("Details").sectionCard("Info")
```

### When to Use ViewModifier

Use ``ViewModifier`` when your transformation is a pure buffer-to-buffer operation: adding visual effects, changing backgrounds, or adjusting layout after rendering. The ``RenderContext`` gives you access to:

| Property | Description |
|----------|-------------|
| `availableWidth` | Maximum width in columns for this view |
| `availableHeight` | Maximum height in rows for this view |
| `environment` | Current ``EnvironmentValues`` (palette, focus manager, etc.) |
Use ``ViewModifier`` whenever a reusable decoration or wrapping applies to
arbitrary content: cards, frames, badges, spacing conventions, environment
tweaks. Everything the modifier body sets (environment values, padding,
borders) flows into the wrapped content exactly as if it were written
inline. Procedural buffer transformations remain framework-internal; custom
modifiers compose existing views and modifiers instead.

## Type Erasure with AnyView

Expand Down Expand Up @@ -209,7 +208,7 @@ let view = Text("Hello").asAnyView()
### Supporting Types

- ``ViewBuilder``
- ``ModifiedView``
- ``ModifiedContent``
- ``AnyView``
- ``RenderContext``
- ``FrameBuffer``
27 changes: 20 additions & 7 deletions Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ This path is used by:
- **Layout containers**: `VStack`, `HStack`, `ZStack`
- **Interactive views**: ``Button``, ``ButtonRow``, ``Menu``
- **Container views**: ``Panel``, ``Card``, ``Alert``, ``Dialog``
- **Modifier wrappers**: `ModifiedView`, `DimmedModifier`, `OverlayModifier`, `EnvironmentModifier`, ``EquatableView``, and all lifecycle modifiers
- **Modifier wrappers**: ``ModifiedContent``, `BufferModifiedView`, `DimmedModifier`, `OverlayModifier`, `EnvironmentModifier`, ``EquatableView``, and all lifecycle modifiers

### Path 2: Composition (body)

Expand Down Expand Up @@ -338,20 +338,33 @@ traversal, and never for unchanged values.

## ViewModifier Pipeline

TUIkit has two modifier architectures:
TUIkit has three modifier architectures:

### Buffer Modifiers (ViewModifier protocol)
### Public ViewModifier (body composition)

These transform a ``FrameBuffer`` after the content has rendered:
The public ``ViewModifier`` protocol follows SwiftUI's contract: the modifier
composes a replacement view around a placeholder for the modified content.

```swift
public protocol ViewModifier {
func modify(buffer: FrameBuffer, context: RenderContext) -> FrameBuffer
func adjustContext(_ context: RenderContext) -> RenderContext // default: returns context unchanged
associatedtype Body: View
typealias Content = _ViewModifier_Content<Self>
@ViewBuilder func body(content: Self.Content) -> Self.Body
}
```

`ModifiedView` wraps a view and a modifier. It first calls `adjustContext(_:)` to let the modifier reduce available space (e.g. padding), then renders the content, then calls `modify(buffer:context:)`. Examples:
`.modifier(_:)` wraps the view and the modifier in a ``ModifiedContent``
value. Rendering evaluates `body(content:)` with a placeholder that resolves
to the wrapped view at its position in the modifier body — with the
environment and layout constraints active there, exactly as if the content
had been written inline.

### Buffer Modifiers (internal)

Terminal-specific transformations that cannot be expressed as composition
run behind the internal `BufferViewModifier` layer. `BufferModifiedView`
first lets the modifier adjust the context (e.g. padding reduces available
space), then renders the content, then transforms the rendered buffer:

- **`PaddingModifier`**: Adds empty lines (top/bottom) and spaces (leading/trailing) around the buffer
- **`BackgroundModifier`**: Paints the background style directly onto cells and fills each row to the surface width
Expand Down
2 changes: 1 addition & 1 deletion Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ from earlier view values become inert. Registrations are removed when their iden
### Garbage Collection

Views that disappear from the tree (e.g., a conditional branch switches) have their state
automatically cleaned up at the end of each render pass. `ConditionalView` also immediately
automatically cleaned up at the end of each render pass. `_ConditionalContent` also immediately
invalidates the inactive branch's state to prevent stale values.

This is simple and predictable: the view tree is fully re-evaluated each frame (no virtual DOM), with persistent state. Terminal output is then diffed at the line level: only changed lines are written. See <doc:RenderCycle> for details on the output optimization pipeline.
2 changes: 1 addition & 1 deletion Sources/TUIkit/TUIkit.docc/TUIkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ struct MyApp: App {

- ``ViewBuilder``
- ``ViewModifier``
- ``ModifiedView``
- ``ModifiedContent``
- ``EquatableView``

### Focus System
Expand Down
9 changes: 2 additions & 7 deletions Sources/TUIkit/Views/Alert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,8 @@ extension EmptyView: ButtonProvider {

extension TupleView: ButtonProvider {
func extractButtons() -> [Button] {
var buttons: [Button] = []
func collect<T: View>(_ view: T) {
if let provider = view as? ButtonProvider {
buttons.append(contentsOf: provider.extractButtons())
}
resolvedChildren().flatMap { child in
(child as? ButtonProvider)?.extractButtons() ?? []
}
repeat collect(each children)
return buttons
}
}
2 changes: 1 addition & 1 deletion Sources/TUIkitCore/Rendering/FrameBuffer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/// normalized style state, and transparency explicitly.
///
/// - Important: This is framework infrastructure used as the rendering primitive in
/// ``ViewModifier/modify(buffer:context:)``. Most developers don't need to interact
/// the internal buffer-modifier layer. Most developers don't need to interact
/// with this type directly.
public struct FrameBuffer: Sendable, Equatable {
private var surface: TerminalSurface
Expand Down
8 changes: 4 additions & 4 deletions Sources/TUIkitCore/Rendering/ViewIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@
/// ```
///
/// Container views (`VStack`, `HStack`, `TupleView`, `ViewArray`) append
/// their child index. Leaf views append their type name. `ConditionalView`
/// their child index. Leaf views append their type name. `_ConditionalContent`
/// appends a branch label (`"true"` or `"false"`).
///
/// ## Stability
///
/// The identity is **stable across render passes** as long as the view tree
/// structure does not change. If a `ConditionalView` switches branches, the
/// structure does not change. If a `_ConditionalContent` switches branches, the
/// old branch's state is invalidated.
public struct ViewIdentity: Hashable, CustomStringConvertible, Sendable {
/// The structural path from root to this view.
Expand Down Expand Up @@ -85,7 +85,7 @@ public extension ViewIdentity {

/// Returns a child identity by appending a branch label.
///
/// Used by ``ConditionalView`` to distinguish between the
/// Used by `_ConditionalContent` to distinguish between the
/// `true` and `false` branches of an `if-else`.
///
/// - Parameter label: The branch label (`"true"` or `"false"`).
Expand All @@ -97,7 +97,7 @@ public extension ViewIdentity {
/// Whether the given path is a descendant of this identity.
///
/// Used by `StateStorage` to invalidate all state under a branch
/// when a `ConditionalView` switches.
/// when a `_ConditionalContent` switches.
///
/// - Parameter descendant: The path to check.
/// - Returns: `true` if `descendant` starts with this identity's path.
Expand Down
64 changes: 64 additions & 0 deletions Sources/TUIkitView/Core/ModifiedContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 🖥️ TUIKit — Terminal UI Kit for Swift
// ModifiedContent.swift
//
// Created by LAYERED.work
// License: MIT

import TUIkitCore

// MARK: - ModifiedContent

/// A value with a modifier applied to it.
///
/// `ModifiedContent` matches SwiftUI's public shape: an unconstrained pair of
/// content and modifier that conforms to ``View`` when its content is a view
/// and its modifier is a ``ViewModifier``. It is the return type of
/// ``View/modifier(_:)``; users normally never construct it directly.
public struct ModifiedContent<Content, Modifier> {
/// The content that the modifier transforms.
public var content: Content

/// The view modifier applied to the content.
public var modifier: Modifier

/// Creates a modified content value from a content value and a modifier.
///
/// - Parameters:
/// - content: The content that the modifier changes.
/// - modifier: The modifier to apply.
public init(content: Content, modifier: Modifier) {
self.content = content
self.modifier = modifier
}
}

// MARK: - Equatable Conformance

extension ModifiedContent: Equatable where Content: Equatable, Modifier: Equatable {}

// MARK: - View Conformance

extension ModifiedContent: View where Content: View, Modifier: ViewModifier {
/// Never called — rendering is handled by `Renderable` conformance.
public var body: Never {
fatalError("ModifiedContent renders via Renderable")
}
}

// MARK: - Rendering

extension ModifiedContent: Renderable where Content: View, Modifier: ViewModifier {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
// The placeholder resolves to the stored content at whatever tree
// position (and with whatever environment) the modifier body placed
// it. The modifier body itself renders under a child identity so
// state and lifecycle inside the body stay structurally stable.
let content = self.content
let placeholder = _ViewModifier_Content<Modifier>(renderContent: { placeholderContext in
TUIkitView.renderToBuffer(content, context: placeholderContext)
})
let bodyView = modifier.body(content: placeholder)
let bodyContext = context.withChildIdentity(type: Modifier.Body.self)
return TUIkitView.renderToBuffer(bodyView, context: bodyContext)
}
}
Loading
Loading