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
37 changes: 20 additions & 17 deletions Sources/TUIkit/Modifiers/BackgroundModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,27 @@ public struct BackgroundModifier: ViewModifier {
guard !buffer.isEmpty else { return buffer }

let resolvedColor = color.resolve(with: context.environment.palette)
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)

// 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: resolvedColor)
lines.append(colored)
}

return FrameBuffer(lines: lines)
return FrameBuffer(
terminalSurface: buffer.terminalSurface.applyingBackground(
resolvedColor.terminalBackgroundColor
)
)
}
}

/// Applies background color to a string, preserving existing formatting.
private func applyBackground(to string: String, color: Color) -> String {
ANSIRenderer.backgroundCode(for: color) + string + ANSIRenderer.reset
private extension Color {
var terminalBackgroundColor: TerminalColor {
switch value {
case .standard(let color):
.ansi(Int(color.backgroundCode))
case .bright(let color):
.ansi(Int(color.brightBackgroundCode))
case .palette256(let index):
.indexed(Int(index))
case .rgb(let red, let green, let blue):
.rgb(red: Int(red), green: Int(green), blue: Int(blue))
case .semantic:
preconditionFailure("Semantic color must be resolved before rendering")
}
}
}
11 changes: 6 additions & 5 deletions Sources/TUIkit/Notification/NotificationTiming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,28 @@ enum NotificationTiming {
return 0.0
}

/// Wraps text into lines that fit a maximum character width.
/// Wraps text into lines that fit a maximum terminal-cell width.
///
/// Splits on word boundaries (spaces). Words longer than `maxWidth`
/// are placed on their own line without further splitting.
///
/// - Parameters:
/// - text: The text to wrap.
/// - maxWidth: Maximum characters per line.
/// - maxWidth: Maximum terminal cells per line.
/// - Returns: An array of wrapped lines (never empty).
static func wordWrap(_ text: String, maxWidth: Int) -> [String] {
guard maxWidth > 0 else { return [text] }
let sanitizedText = text.sanitizedForTerminal
guard maxWidth > 0 else { return [sanitizedText] }

let words = text.split(separator: " ", omittingEmptySubsequences: false)
let words = sanitizedText.split(separator: " ", omittingEmptySubsequences: false)
var lines: [String] = []
var currentLine = ""

for word in words {
let wordStr = String(word)
if currentLine.isEmpty {
currentLine = wordStr
} else if currentLine.count + 1 + wordStr.count <= maxWidth {
} else if currentLine.strippedLength + 1 + wordStr.strippedLength <= maxWidth {
currentLine += " " + wordStr
} else {
lines.append(currentLine)
Expand Down
15 changes: 9 additions & 6 deletions Sources/TUIkit/Rendering/FrameDiffWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,12 @@ extension FrameDiffWriter {
bgCode: String,
reset: String
) -> [String] {
let outputWidth = max(0, terminalWidth)
let outputHeight = max(0, terminalHeight)
let clippedBuffer = buffer.clipped(toWidth: outputWidth, height: outputHeight)
let clippedLines = clippedBuffer.lines
var lines: [String] = []
lines.reserveCapacity(terminalHeight)
lines.reserveCapacity(outputHeight)

// ESC[2K erases the entire line using the current background color.
// Placed after bgCode so the erase uses the app background, not the
Expand All @@ -77,11 +81,10 @@ extension FrameDiffWriter {
let eraseLine = "\u{1B}[2K"
let emptyLine = bgCode + eraseLine + reset

for row in 0..<terminalHeight {
if row < buffer.height {
let line = buffer.lines[row]
let visibleWidth = line.strippedLength
let padding = max(0, terminalWidth - visibleWidth)
for row in 0..<outputHeight {
if row < clippedBuffer.height {
let line = clippedLines[row]
let padding = max(0, outputWidth - clippedBuffer.width)
let lineWithBg = line.replacingOccurrences(of: reset, with: reset + bgCode)
let paddedLine = bgCode + eraseLine + lineWithBg + String(repeating: " ", count: padding) + reset
lines.append(paddedLine)
Expand Down
158 changes: 110 additions & 48 deletions Sources/TUIkit/Rendering/TextFieldContentRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ struct TextFieldContentRenderer {
/// Whether the field is disabled.
let isDisabled: Bool

/// Returns the display character for a given index in the text.
/// For TextField: the actual character. For SecureField: a bullet.
let displayCharacter: (_ index: Int, _ text: String) -> Character
/// Maps one source grapheme to its displayed grapheme.
/// For TextField: the original character. For SecureField: a bullet.
let displayCharacter: (_ character: Character) -> Character

// MARK: - Content Building

Expand All @@ -41,14 +41,15 @@ struct TextFieldContentRenderer {
cursorTimer: CursorTimer?,
contentWidth: Int
) -> String {
let isEmpty = text.isEmpty
let sanitizedText = text.sanitizedForTerminal
let isEmpty = sanitizedText.isEmpty
let backgroundColor = palette.accent.opacity(ViewConstants.focusBorderDim)

if isEmpty && !isFocused && prompt != nil {
return buildPromptContent(palette: palette, background: backgroundColor, width: contentWidth)
} else if isFocused {
return buildTextWithCursor(
text: text,
text: sanitizedText,
cursorPosition: cursorPosition,
selectionRange: selectionRange,
palette: palette,
Expand All @@ -59,7 +60,7 @@ struct TextFieldContentRenderer {
)
} else {
return buildTextContent(
text: text,
text: sanitizedText,
palette: palette,
background: backgroundColor,
width: contentWidth
Expand All @@ -78,21 +79,27 @@ struct TextFieldContentRenderer {
} else {
promptText = ""
}
let truncated = String(promptText.prefix(width))
let paddedPrompt = truncated.padding(toLength: width, withPad: " ", startingAt: 0)
let truncated = promptText.ansiAwarePrefix(visibleCount: width)
let paddedPrompt = truncated.padToVisibleWidth(width)
return ANSIRenderer.colorize(paddedPrompt, foreground: palette.foregroundTertiary, background: background)
}

// MARK: - Unfocused Text

/// Builds text content without cursor (unfocused state).
private func buildTextContent(text: String, palette: any Palette, background: Color, width: Int) -> String {
let visibleCount = min(text.count, width)
let characters = displayCharacters(for: text)
var displayText = ""
for characterIndex in 0..<visibleCount {
displayText.append(displayCharacter(characterIndex, text))
var displayWidth = 0

for character in characters {
let characterWidth = character.terminalWidth
guard displayWidth + characterWidth <= width else { break }
displayText.append(character)
displayWidth += characterWidth
}
let paddedText = displayText.padding(toLength: width, withPad: " ", startingAt: 0)

let paddedText = displayText.padToVisibleWidth(width)
let foreground = isDisabled ? palette.foregroundTertiary : palette.foreground
return ANSIRenderer.colorize(paddedText, foreground: foreground, background: background)
}
Expand All @@ -112,18 +119,19 @@ struct TextFieldContentRenderer {
background: Color,
width: Int
) -> String {
let clampedPosition = max(0, min(cursorPosition, text.count))

// Calculate scroll offset to keep cursor visible
let visibleTextWidth = width - 1 // Reserve 1 char for cursor
let scrollOffset: Int
if clampedPosition <= visibleTextWidth {
scrollOffset = 0
} else {
scrollOffset = clampedPosition - visibleTextWidth
}

let visibleStart = scrollOffset
let characters = displayCharacters(for: text)
let clampedPosition = max(0, min(cursorPosition, characters.count))
let cursorCharacter = cursorStyle.shape.character
let cursorCharacterWidth = max(1, cursorCharacter.terminalWidth)
let underlyingCursorWidth = clampedPosition < characters.count
? max(1, characters[clampedPosition].terminalWidth)
: 1
let cursorSlotWidth = max(cursorCharacterWidth, underlyingCursorWidth)
let visibleStart = visibleStart(
characters: characters,
cursorPosition: clampedPosition,
availableWidth: max(0, width - min(max(0, width), cursorSlotWidth))
)

// Compute cursor visibility and color based on animation style
let (cursorVisible, cursorColor) = computeCursorState(
Expand All @@ -133,29 +141,34 @@ struct TextFieldContentRenderer {
cursorTimer: cursorTimer
)

// Build output character by character
// Build output one complete grapheme at a time.
var result = ""
var outputWidth = 0
var textIndex = visibleStart
var cursorRendered = false

for visibleIndex in 0..<width {
let textIndex = visibleStart + visibleIndex

if textIndex == clampedPosition {
if cursorVisible {
let cursorChar = cursorStyle.shape.character
result += ANSIRenderer.colorize(String(cursorChar), foreground: cursorColor, background: background)
} else {
// Cursor hidden (blink off): show underlying character or space
if textIndex < text.count {
let char = displayCharacter(textIndex, text)
result += ANSIRenderer.colorize(String(char), foreground: palette.foreground, background: background)
} else {
result += ANSIRenderer.colorize(" ", foreground: palette.foreground, background: background)
}
while outputWidth < width {
if !cursorRendered && textIndex == clampedPosition {
let renderedSlotWidth = min(cursorSlotWidth, width - outputWidth)
let underlyingCharacter = textIndex < characters.count ? characters[textIndex] : nil
result += renderCursorSlot(
underlyingCharacter: underlyingCharacter,
cursorCharacter: cursorCharacter,
isVisible: cursorVisible,
width: renderedSlotWidth,
foreground: palette.foreground,
cursorColor: cursorColor,
background: background
)
outputWidth += renderedSlotWidth
cursorRendered = true
if textIndex < characters.count {
textIndex += 1
}
outputWidth += 1
} else if textIndex < text.count && visibleIndex < width - (textIndex >= clampedPosition ? 0 : 1) {
let char = displayCharacter(textIndex, text)
} else if textIndex < characters.count {
let char = characters[textIndex]
let characterWidth = char.terminalWidth
guard outputWidth + characterWidth <= width else { break }

// Check if this character is in the selection
let isSelected = selectionRange.map { textIndex >= $0.lowerBound && textIndex < $0.upperBound } ?? false
Expand All @@ -169,20 +182,69 @@ struct TextFieldContentRenderer {
} else {
result += ANSIRenderer.colorize(String(char), foreground: palette.foreground, background: background)
}
outputWidth += 1
} else if outputWidth < width {
outputWidth += characterWidth
textIndex += 1
} else {
result += ANSIRenderer.colorize(" ", foreground: palette.foreground, background: background)
outputWidth += 1
}
}

if outputWidth >= width {
break
}
if outputWidth < width {
let padding = String(repeating: " ", count: width - outputWidth)
result += ANSIRenderer.colorize(padding, foreground: palette.foreground, background: background)
}

return result
}

private func renderCursorSlot(
underlyingCharacter: Character?,
cursorCharacter: Character,
isVisible: Bool,
width: Int,
foreground: Color,
cursorColor: Color,
background: Color
) -> String {
let character = isVisible ? cursorCharacter : underlyingCharacter
let content = cellFittedText(character, width: width)
return ANSIRenderer.colorize(
content,
foreground: isVisible ? cursorColor : foreground,
background: background
)
}

private func cellFittedText(_ character: Character?, width: Int) -> String {
guard let character, character.terminalWidth <= width else {
return String(repeating: " ", count: width)
}
return String(character) + String(repeating: " ", count: width - character.terminalWidth)
}

private func displayCharacters(for text: String) -> [Character] {
text.map(displayCharacter)
}

private func visibleStart(
characters: [Character],
cursorPosition: Int,
availableWidth: Int
) -> Int {
var start = cursorPosition
var usedWidth = 0

while start > 0 {
let characterWidth = characters[start - 1].terminalWidth
guard usedWidth + characterWidth <= availableWidth else { break }
usedWidth += characterWidth
start -= 1
}

return start
}

// MARK: - Cursor State

/// Computes the cursor visibility and color based on the animation style and cursor timer.
Expand Down
10 changes: 5 additions & 5 deletions Sources/TUIkit/TUIkit.docc/Articles/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ 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 `apply(to:context:)` transforms the rendered ``FrameBuffer`` (e.g. adding padding, borders, backgrounds).
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).

```swift
Text("Hello")
Expand All @@ -63,10 +63,10 @@ Text("Hello")

The rendering pipeline converts the view tree into terminal output:

1. **View tree traversal**: Each view produces a ``FrameBuffer``
2. **Modifier application**: Modifiers transform buffers
3. **ANSI rendering**: The `ANSIRenderer` converts colors and styles to escape codes
4. **Terminal output**: The ``FrameBuffer`` lines are written to the terminal
1. **View tree traversal**: Each view produces a ``FrameBuffer`` backed by terminal cells
2. **Modifier application**: Modifiers transform or style cell surfaces
3. **Layout and compositing**: Containers position, clip, and layer complete grapheme cells
4. **Terminal output**: Final rows are encoded into normalized ANSI SGR strings and diffed at the terminal boundary

## Package Boundaries

Expand Down
Loading
Loading