From 2fc51b7c50a414cbc8c20edb2c393ee2eafe758e Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 02:24:44 +0200 Subject: [PATCH 01/16] Fix: harden terminal text parsing - measure complete grapheme clusters in terminal cells - neutralize unsupported CSI, OSC, DCS, and control input - cover Unicode width and injection boundaries --- .../Extensions/String+TerminalWidth.swift | 231 ++++++------------ .../Rendering/TerminalTextParser.swift | 201 +++++++++++++++ Tests/TUIkitCoreTests/TerminalTextTests.swift | 60 +++++ 3 files changed, 336 insertions(+), 156 deletions(-) create mode 100644 Sources/TUIkitCore/Rendering/TerminalTextParser.swift create mode 100644 Tests/TUIkitCoreTests/TerminalTextTests.swift diff --git a/Sources/TUIkitCore/Extensions/String+TerminalWidth.swift b/Sources/TUIkitCore/Extensions/String+TerminalWidth.swift index 88c763c5..96b0be49 100644 --- a/Sources/TUIkitCore/Extensions/String+TerminalWidth.swift +++ b/Sources/TUIkitCore/Extensions/String+TerminalWidth.swift @@ -13,61 +13,45 @@ extension Character { /// emoji) occupy 2 cells. Zero-width characters (combining marks, /// variation selectors, ZWJ) occupy 0 cells. public var terminalWidth: Int { - let scalars = unicodeScalars - guard let first = scalars.first else { return 0 } - let scalarValue = first.value + let visibleScalars = unicodeScalars.filter { !$0.isZeroWidthInTerminal } + guard !visibleScalars.isEmpty else { return 0 } - // Zero-width characters - if scalarValue == 0x200B || scalarValue == 0x200C || scalarValue == 0x200D || scalarValue == 0xFEFF { return 0 } // ZW space/NJ/J/BOM - if scalarValue == 0x00AD { return 0 } // soft hyphen - if (0xFE00...0xFE0F).contains(scalarValue) { return 0 } // variation selectors - if (0xE0100...0xE01EF).contains(scalarValue) { return 0 } // variation selectors supplement - if (0x0300...0x036F).contains(scalarValue) { return 0 } // combining diacritical marks - if (0x1AB0...0x1AFF).contains(scalarValue) { return 0 } // combining diacritical marks extended - if (0x1DC0...0x1DFF).contains(scalarValue) { return 0 } // combining diacritical marks supplement - if (0x20D0...0x20FF).contains(scalarValue) { return 0 } // combining marks for symbols - if (0xFE20...0xFE2F).contains(scalarValue) { return 0 } // combining half marks - if (0xE0000...0xE007F).contains(scalarValue) { return 0 } // tags block + return visibleScalars.contains(where: \.isWideInTerminal) ? 2 : 1 + } +} - // Multi-scalar grapheme clusters (emoji sequences with ZWJ, skin tones, - // flag sequences, keycap sequences) are typically 2 cells wide. - if scalars.count > 1 { - // If the only extra scalars are variation selectors (U+FE0F/U+FE0E), - // fall through to the base character width check. Many terminals - // don't widen characters just because of a presentation selector - // (e.g. ⚙️ = U+2699 + U+FE0F is still 1 cell in most terminals). - let hasNonVariationExtras = scalars.dropFirst().contains { scalar in - let sv = scalar.value - return !(0xFE00...0xFE0F).contains(sv) && !(0xE0100...0xE01EF).contains(sv) - } - if hasNonVariationExtras { - // True multi-character sequence (ZWJ, flags, keycaps, skin tones) - return 2 - } - // Just base + variation selector(s): fall through to base char width +private extension Unicode.Scalar { + var isZeroWidthInTerminal: Bool { + switch properties.generalCategory { + case .control, .format, .nonspacingMark, .spacingMark, .enclosingMark: + return true + default: + return value == 0x00AD || + (0xFE00...0xFE0F).contains(value) || + (0xE0100...0xE01EF).contains(value) || + (0xE0000...0xE007F).contains(value) } + } - // East Asian Wide and Fullwidth characters (2 cells) - if (0x1100...0x115F).contains(scalarValue) { return 2 } // Hangul Jamo - if (0x2329...0x232A).contains(scalarValue) { return 2 } // angle brackets - if (0x2E80...0x303E).contains(scalarValue) { return 2 } // CJK radicals, Kangxi, ideographic - if (0x3041...0x33BF).contains(scalarValue) { return 2 } // Hiragana, Katakana, Bopomofo, Hangul compat, Kanbun, CJK - if (0x33D0...0x33FF).contains(scalarValue) { return 2 } // CJK compatibility - if (0x3400...0x4DBF).contains(scalarValue) { return 2 } // CJK unified ext A - if (0x4E00...0x9FFF).contains(scalarValue) { return 2 } // CJK unified - if (0xA000...0xA4CF).contains(scalarValue) { return 2 } // Yi - if (0xA960...0xA97F).contains(scalarValue) { return 2 } // Hangul Jamo extended A - if (0xAC00...0xD7AF).contains(scalarValue) { return 2 } // Hangul syllables - if (0xF900...0xFAFF).contains(scalarValue) { return 2 } // CJK compatibility ideographs - if (0xFE10...0xFE19).contains(scalarValue) { return 2 } // vertical forms - if (0xFE30...0xFE6F).contains(scalarValue) { return 2 } // CJK compatibility forms, small forms - if (0xFF01...0xFF60).contains(scalarValue) { return 2 } // fullwidth forms - if (0xFFE0...0xFFE6).contains(scalarValue) { return 2 } // fullwidth signs - if (0x1F000...0x1FBFF).contains(scalarValue) { return 2 } // emoji and symbols (Mahjong, Dominos, Playing Cards, Emoji, etc.) - if (0x20000...0x2FA1F).contains(scalarValue) { return 2 } // CJK unified extensions B-F, compatibility supplement - if (0x30000...0x3134F).contains(scalarValue) { return 2 } // CJK unified extension G - - return 1 + var isWideInTerminal: Bool { + (0x1100...0x115F).contains(value) || + (0x2329...0x232A).contains(value) || + (0x2E80...0x303E).contains(value) || + (0x3041...0x33BF).contains(value) || + (0x33D0...0x33FF).contains(value) || + (0x3400...0x4DBF).contains(value) || + (0x4E00...0x9FFF).contains(value) || + (0xA000...0xA4CF).contains(value) || + (0xA960...0xA97F).contains(value) || + (0xAC00...0xD7AF).contains(value) || + (0xF900...0xFAFF).contains(value) || + (0xFE10...0xFE19).contains(value) || + (0xFE30...0xFE6F).contains(value) || + (0xFF01...0xFF60).contains(value) || + (0xFFE0...0xFFE6).contains(value) || + (0x1F000...0x1FBFF).contains(value) || + (0x20000...0x2FA1F).contains(value) || + (0x30000...0x3134F).contains(value) } } @@ -79,58 +63,24 @@ extension String { /// Accounts for wide characters (emoji, CJK) that occupy 2 terminal cells /// and zero-width characters (combining marks, variation selectors). public var strippedLength: Int { - var count = 0 - var index = startIndex - - while index < endIndex { - if self[index] == "\u{1B}" { - // Skip ANSI sequence: ESC [ params letter - index = self.index(after: index) - if index < endIndex && self[index] == "[" { - index = self.index(after: index) - // Skip parameter bytes (digits, semicolons) - while index < endIndex && (self[index].isNumber || self[index] == ";") { - index = self.index(after: index) - } - // Skip the final byte (letter) - if index < endIndex && self[index].isLetter { - index = self.index(after: index) - } - } - } else { - count += self[index].terminalWidth - index = self.index(after: index) + var width = 0 + TerminalTextParser.scan(self) { token in + if case .grapheme(let character) = token { + width += character.terminalWidth } } - - return count + return width } /// The string with all ANSI escape codes removed. public var stripped: String { var result = "" result.reserveCapacity(count) - var index = startIndex - - while index < endIndex { - if self[index] == "\u{1B}" { - // Skip ANSI sequence: ESC [ params letter - index = self.index(after: index) - if index < endIndex && self[index] == "[" { - index = self.index(after: index) - while index < endIndex && (self[index].isNumber || self[index] == ";") { - index = self.index(after: index) - } - if index < endIndex && self[index].isLetter { - index = self.index(after: index) - } - } - } else { - result.append(self[index]) - index = self.index(after: index) + TerminalTextParser.scan(self) { token in + if case .grapheme(let character) = token { + result.append(character) } } - return result } @@ -175,35 +125,22 @@ extension String { var result = "" var visible = 0 - var index = startIndex - - while index < endIndex && visible < visibleCount { - // Check if we're at the start of an ANSI escape sequence - if self[index] == "\u{1B}" { - // Consume the entire ANSI sequence (ESC [ ... letter) - let seqStart = index - index = self.index(after: index) - if index < endIndex && self[index] == "[" { - index = self.index(after: index) - // Skip parameter bytes (digits, semicolons) - while index < endIndex && (self[index].isNumber || self[index] == ";") { - index = self.index(after: index) - } - // Skip the final byte (letter) - if index < endIndex && self[index].isLetter { - index = self.index(after: index) - } + var reachedBoundary = false + TerminalTextParser.scan(self) { token in + guard !reachedBoundary, visible < visibleCount else { return } + switch token { + case .sgr(_, let sequence): + result += sequence + case .grapheme(let character): + let charWidth = character.terminalWidth + guard visible + charWidth <= visibleCount else { + reachedBoundary = true + return } - result += String(self[seqStart.. visibleCount { break } - result.append(self[index]) + result.append(character) visible += charWidth - index = self.index(after: index) } } - return result } @@ -216,29 +153,21 @@ extension String { /// - Returns: The remainder of the string with ANSI codes intact. public func ansiAwareSuffix(droppingVisible dropCount: Int) -> String { var visible = 0 - var index = startIndex - - while index < endIndex && visible < dropCount { - if self[index] == "\u{1B}" { - // Skip the entire ANSI sequence - index = self.index(after: index) - if index < endIndex && self[index] == "[" { - index = self.index(after: index) - while index < endIndex && (self[index].isNumber || self[index] == ";") { - index = self.index(after: index) - } - if index < endIndex && self[index].isLetter { - index = self.index(after: index) - } + var result = "" + TerminalTextParser.scan(self) { token in + switch token { + case .sgr(_, let sequence): + if visible >= dropCount { + result += sequence + } + case .grapheme(let character): + if visible >= dropCount { + result.append(character) } - } else { - visible += self[index].terminalWidth - index = self.index(after: index) + visible += character.terminalWidth } } - - guard index < endIndex else { return "" } - return String(self[index...]) + return result } // MARK: - ANSI State Extraction @@ -258,26 +187,16 @@ extension String { /// if the line starts with a visible character. public func leadingANSISequences() -> String { var result = "" - var index = startIndex - - while index < endIndex { - guard self[index] == "\u{1B}" else { break } - - // Consume the ANSI sequence (ESC [ params letter) - let seqStart = index - index = self.index(after: index) - if index < endIndex && self[index] == "[" { - index = self.index(after: index) - while index < endIndex && (self[index].isNumber || self[index] == ";") { - index = self.index(after: index) - } - if index < endIndex && self[index].isLetter { - index = self.index(after: index) - } + var foundGrapheme = false + TerminalTextParser.scan(self) { token in + guard !foundGrapheme else { return } + switch token { + case .sgr(_, let sequence): + result += sequence + case .grapheme: + foundGrapheme = true } - result += String(self[seqStart.. Void) { + var index = text.startIndex + + while index < text.endIndex { + let character = text[index] + guard let scalarValue = character.singleScalarValue else { + body(.grapheme(character)) + index = text.index(after: index) + continue + } + + switch scalarValue { + case 0x1B: + let result = consumeEscape(in: text, from: index) + if let sgr = result.sgr { + body(.sgr(parameters: sgr, sequence: String(text[index.. ParseResult { + let nextIndex = text.index(after: escapeIndex) + guard nextIndex < text.endIndex, let introducer = text[nextIndex].singleScalarValue else { + return ParseResult(endIndex: text.endIndex, sgr: nil) + } + + switch introducer { + case 0x5B: + return consumeCSI(in: text, after: text.index(after: nextIndex)) + case 0x50, 0x58, 0x5E, 0x5F: + let endIndex = consumeControlString( + in: text, + after: text.index(after: nextIndex), + allowsBellTerminator: false + ) + return ParseResult(endIndex: endIndex, sgr: nil) + case 0x5D: + let endIndex = consumeControlString( + in: text, + after: text.index(after: nextIndex), + allowsBellTerminator: true + ) + return ParseResult(endIndex: endIndex, sgr: nil) + default: + return ParseResult(endIndex: consumeEscapeCommand(in: text, after: nextIndex), sgr: nil) + } + } + + static func consumeCSI(in text: String, after introducer: String.Index) -> ParseResult { + var index = introducer + var finalValue: UInt32? + var finalIndex = text.endIndex + + while index < text.endIndex { + guard let value = text[index].singleScalarValue else { + return ParseResult(endIndex: text.index(after: index), sgr: nil) + } + + if (0x40...0x7E).contains(value) { + finalValue = value + finalIndex = index + index = text.index(after: index) + break + } + + guard (0x20...0x3F).contains(value) else { + return ParseResult(endIndex: text.index(after: index), sgr: nil) + } + index = text.index(after: index) + } + + guard finalValue == 0x6D else { + return ParseResult(endIndex: index, sgr: nil) + } + + let rawParameters = text[introducer.. String.Index { + var index = introducer + + while index < text.endIndex { + guard let value = text[index].singleScalarValue else { + index = text.index(after: index) + continue + } + + if allowsBellTerminator && value == 0x07 { + return text.index(after: index) + } + if value == 0x9C { + return text.index(after: index) + } + if value == 0x1B { + let nextIndex = text.index(after: index) + if nextIndex < text.endIndex && text[nextIndex].singleScalarValue == 0x5C { + return text.index(after: nextIndex) + } + } + index = text.index(after: index) + } + + return text.endIndex + } + + static func consumeEscapeCommand(in text: String, after escapeIndex: String.Index) -> String.Index { + var index = escapeIndex + + while index < text.endIndex { + guard let value = text[index].singleScalarValue else { + return text.index(after: index) + } + index = text.index(after: index) + + if (0x30...0x7E).contains(value) { + return index + } + guard (0x20...0x2F).contains(value) else { + return index + } + } + + return text.endIndex + } +} + +private extension Character { + var singleScalarValue: UInt32? { + guard unicodeScalars.count == 1 else { return nil } + return unicodeScalars.first?.value + } +} diff --git a/Tests/TUIkitCoreTests/TerminalTextTests.swift b/Tests/TUIkitCoreTests/TerminalTextTests.swift new file mode 100644 index 00000000..2dd807d4 --- /dev/null +++ b/Tests/TUIkitCoreTests/TerminalTextTests.swift @@ -0,0 +1,60 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// TerminalTextTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Testing + +@testable import TUIkitCore + +@Suite("Terminal Text Tests") +struct TerminalTextTests { + @Test("Combining marks stay attached to their base cell") + func combiningMarkWidth() { + let grapheme: Character = "e\u{301}" + + #expect(grapheme.terminalWidth == 1) + #expect(String(grapheme).strippedLength == 1) + } + + @Test("Emoji sequences and East Asian characters use two cells") + func wideGraphemeWidths() { + #expect(Character("👩‍💻").terminalWidth == 2) + #expect(Character("🇦🇹").terminalWidth == 2) + #expect(Character("界").terminalWidth == 2) + } + + @Test("Cell slicing never splits a wide grapheme") + func wideGraphemeSlicing() { + let text = "A界B" + + #expect(text.ansiAwarePrefix(visibleCount: 2) == "A") + #expect(text.ansiAwareSuffix(droppingVisible: 1) == "界B") + #expect(text.ansiAwareSuffix(droppingVisible: 2) == "B") + } + + @Test("SGR and non-SGR CSI sequences have zero display width") + func csiSequencesHaveZeroWidth() { + let text = "\u{1B}[31mred\u{1B}[0m\u{1B}[2J!" + + #expect(text.stripped == "red!") + #expect(text.strippedLength == 4) + } + + @Test("OSC hyperlinks cannot survive terminal sanitization") + func oscHyperlinksAreNeutralized() { + let hyperlink = "\u{1B}]8;;https://example.com\u{07}click\u{1B}]8;;\u{07}" + + #expect(hyperlink.stripped == "click") + #expect(hyperlink.sanitizedForTerminal == "click") + } + + @Test("DCS payloads and control characters are neutralized") + func deviceControlsAreNeutralized() { + let input = "A\u{1B}Pmalicious\u{1B}\\B\u{07}C" + + #expect(input.stripped == "ABC") + #expect(input.sanitizedForTerminal == "ABC") + } +} From 3e0db22c0cbc22f872485e88fb671feb92027edc Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 02:28:54 +0200 Subject: [PATCH 02/16] Feat: add terminal cell surface model - normalize SGR style state per grapheme-owned cell - track wide-cell continuations and explicit transparency - add cell-safe clipping and compositing primitives --- .../TUIkitCore/Rendering/TerminalStyle.swift | 170 +++++++++ .../Rendering/TerminalSurface.swift | 347 ++++++++++++++++++ .../TerminalSurfaceTests.swift | 71 ++++ 3 files changed, 588 insertions(+) create mode 100644 Sources/TUIkitCore/Rendering/TerminalStyle.swift create mode 100644 Sources/TUIkitCore/Rendering/TerminalSurface.swift create mode 100644 Tests/TUIkitCoreTests/TerminalSurfaceTests.swift diff --git a/Sources/TUIkitCore/Rendering/TerminalStyle.swift b/Sources/TUIkitCore/Rendering/TerminalStyle.swift new file mode 100644 index 00000000..0fcaa678 --- /dev/null +++ b/Sources/TUIkitCore/Rendering/TerminalStyle.swift @@ -0,0 +1,170 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// TerminalStyle.swift +// +// Created by LAYERED.work +// License: MIT + +/// A normalized terminal color from an SGR foreground or background sequence. +package enum TerminalColor: Sendable, Equatable { + case ansi(Int) + case indexed(Int) + case rgb(red: Int, green: Int, blue: Int) +} + +/// The complete visual state applied to one terminal cell. +package struct TerminalStyle: Sendable, Equatable { + package var isBold = false + package var isDim = false + package var isItalic = false + package var isUnderlined = false + package var isBlinking = false + package var isInverted = false + package var isHidden = false + package var isStrikethrough = false + package var foreground: TerminalColor? + package var background: TerminalColor? + + package init() {} +} + +// MARK: - SGR State + +extension TerminalStyle { + package var isDefault: Bool { + self == Self() + } + + package var ansiSequence: String { + let parameters = ansiParameters + guard !parameters.isEmpty else { return "" } + return "\u{1B}[\(parameters.map(String.init).joined(separator: ";"))m" + } + + mutating func apply(sgr parameters: [Int]) { + var index = 0 + + while index < parameters.count { + let parameter = parameters[index] + if parameter == 0 { + self = Self() + } else if applyEnablingAttribute(parameter) || applyDisablingAttribute(parameter) { + // Attribute state was updated by the helper. + } else { + applyColor(parameter, parameters: parameters, index: &index) + } + index += 1 + } + } +} + +// MARK: - SGR Attributes + +private extension TerminalStyle { + mutating func applyEnablingAttribute(_ parameter: Int) -> Bool { + switch parameter { + case 1: isBold = true + case 2: isDim = true + case 3: isItalic = true + case 4, 21: isUnderlined = true + case 5, 6: isBlinking = true + case 7: isInverted = true + case 8: isHidden = true + case 9: isStrikethrough = true + default: return false + } + return true + } + + mutating func applyDisablingAttribute(_ parameter: Int) -> Bool { + switch parameter { + case 22: + isBold = false + isDim = false + case 23: isItalic = false + case 24: isUnderlined = false + case 25: isBlinking = false + case 27: isInverted = false + case 28: isHidden = false + case 29: isStrikethrough = false + default: return false + } + return true + } + + mutating func applyColor(_ parameter: Int, parameters: [Int], index: inout Int) { + switch parameter { + case 30...37, 90...97: + foreground = .ansi(parameter) + case 38: + foreground = Self.extendedColor(in: parameters, at: &index) ?? foreground + case 39: + foreground = nil + case 40...47, 100...107: + background = .ansi(parameter) + case 48: + background = Self.extendedColor(in: parameters, at: &index) ?? background + case 49: + background = nil + default: + break + } + } +} + +// MARK: - Encoding + +private extension TerminalStyle { + var ansiParameters: [Int] { + var parameters: [Int] = [] + + if isBold { parameters.append(1) } + if isDim { parameters.append(2) } + if isItalic { parameters.append(3) } + if isUnderlined { parameters.append(4) } + if isBlinking { parameters.append(5) } + if isInverted { parameters.append(7) } + if isHidden { parameters.append(8) } + if isStrikethrough { parameters.append(9) } + if let foreground { + parameters.append(contentsOf: Self.parameters(for: foreground, foreground: true)) + } + if let background { + parameters.append(contentsOf: Self.parameters(for: background, foreground: false)) + } + + return parameters + } + + static func extendedColor(in parameters: [Int], at index: inout Int) -> TerminalColor? { + guard index + 1 < parameters.count else { return nil } + + switch parameters[index + 1] { + case 5 where index + 2 < parameters.count: + index += 2 + return .indexed(clampColor(parameters[index])) + case 2 where index + 4 < parameters.count: + let red = clampColor(parameters[index + 2]) + let green = clampColor(parameters[index + 3]) + let blue = clampColor(parameters[index + 4]) + index += 4 + return .rgb(red: red, green: green, blue: blue) + default: + return nil + } + } + + static func parameters(for color: TerminalColor, foreground: Bool) -> [Int] { + switch color { + case .ansi(let code): + return [code] + case .indexed(let index): + return [foreground ? 38 : 48, 5, index] + case .rgb(let red, let green, let blue): + return [foreground ? 38 : 48, 2, red, green, blue] + } + } + + static func clampColor(_ component: Int) -> Int { + min(255, max(0, component)) + } +} diff --git a/Sources/TUIkitCore/Rendering/TerminalSurface.swift b/Sources/TUIkitCore/Rendering/TerminalSurface.swift new file mode 100644 index 00000000..f58450e2 --- /dev/null +++ b/Sources/TUIkitCore/Rendering/TerminalSurface.swift @@ -0,0 +1,347 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// TerminalSurface.swift +// +// Created by LAYERED.work +// License: MIT + +/// One addressable cell in a terminal surface. +package struct TerminalCell: Sendable, Equatable { + enum Content: Sendable, Equatable { + case empty + case grapheme(String, width: Int) + case continuation + } + + let content: Content + let style: TerminalStyle + let isTransparent: Bool + + package var grapheme: String? { + guard case .grapheme(let grapheme, _) = content else { return nil } + return grapheme + } + + package var isContinuation: Bool { + content == .continuation + } +} + +/// A rectangular terminal render result expressed in display cells. +package struct TerminalSurface: Sendable, Equatable { + private(set) var rows: [[TerminalCell]] + package private(set) var width: Int + + package var height: Int { + rows.count + } + + package var isEmpty: Bool { + rows.isEmpty || rows.allSatisfy { row in + row.allSatisfy { cell in + if case .empty = cell.content { return true } + return false + } + } + } + + package init() { + rows = [] + width = 0 + } + + package init(lines: [String], width proposedWidth: Int? = nil) { + var parsedRows: [[TerminalCell]] = [] + parsedRows.reserveCapacity(lines.count) + var measuredWidth = 0 + + for line in lines { + let row = Self.parse(line) + parsedRows.append(row) + measuredWidth = max(measuredWidth, row.count) + } + + rows = parsedRows + width = max(measuredWidth, proposedWidth ?? 0) + } + + init(rows: [[TerminalCell]], width: Int) { + self.rows = rows + self.width = max(width, rows.map(\.count).max() ?? 0) + } +} + +// MARK: - Inspection and Encoding + +extension TerminalSurface { + package var plainLines: [String] { + rows.map(Self.plainText) + } + + package var ansiEncodedLines: [String] { + rows.map(Self.ansiEncoded) + } + + package func cell(atX column: Int, y row: Int) -> TerminalCell? { + guard row >= 0, row < rows.count, column >= 0, column < rows[row].count else { return nil } + return rows[row][column] + } +} + +// MARK: - Layout Operations + +extension TerminalSurface { + package init(verticallyStacking surfaces: [Self]) { + rows = [] + rows.reserveCapacity(surfaces.reduce(into: 0) { $0 += $1.height }) + width = surfaces.map(\.width).max() ?? 0 + + for surface in surfaces { + rows.append(contentsOf: surface.rows) + } + } + + package mutating func appendVertically(_ other: Self, spacing: Int = 0) { + guard !other.isEmpty else { return } + + if !rows.isEmpty && spacing > 0 { + rows.append(contentsOf: repeatElement([], count: spacing)) + } + rows.append(contentsOf: other.rows) + width = max(width, other.width) + } + + package mutating func appendHorizontally(_ other: Self, spacing: Int = 0) { + let leftWidth = width + let rowCount = max(height, other.height) + var combinedRows: [[TerminalCell]] = [] + combinedRows.reserveCapacity(rowCount) + + for rowIndex in 0.. 0 { + row.append(contentsOf: repeatElement(.empty, count: spacing)) + } + if rowIndex < other.rows.count { + row.append(contentsOf: other.rows[rowIndex]) + } + combinedRows.append(row) + } + + rows = combinedRows + width = leftWidth + max(0, spacing) + other.width + } + + package func clipped(toWidth targetWidth: Int, height targetHeight: Int) -> Self { + let clippedWidth = max(0, min(width, targetWidth)) + let clippedHeight = max(0, min(height, targetHeight)) + var clippedRows: [[TerminalCell]] = [] + clippedRows.reserveCapacity(clippedHeight) + + for rowIndex in 0.. Self { + guard !overlay.isEmpty else { return self } + + let resultWidth = max(width, max(0, column + overlay.width)) + let resultHeight = max(height, max(0, row + overlay.height)) + var resultRows = rows + if resultRows.count < resultHeight { + resultRows.append(contentsOf: repeatElement([], count: resultHeight - resultRows.count)) + } + + for overlayRowIndex in overlay.rows.indices { + let destinationY = row + overlayRowIndex + guard destinationY >= 0, destinationY < resultHeight else { continue } + + let overlayRow = overlay.rows[overlayRowIndex] + var sourceX = 0 + while sourceX < overlayRow.count { + let sourceCell = overlayRow[sourceX] + guard case .grapheme(_, let cellWidth) = sourceCell.content else { + sourceX += 1 + continue + } + defer { sourceX += cellWidth } + guard !sourceCell.isTransparent else { continue } + + let destinationX = column + sourceX + guard destinationX >= 0, destinationX + cellWidth <= resultWidth else { continue } + + Self.pad(&resultRows[destinationY], to: destinationX + cellWidth) + Self.clearGraphemes( + in: &resultRows[destinationY], + intersecting: destinationX..<(destinationX + cellWidth) + ) + for offset in 0.. [TerminalCell] { + var row: [TerminalCell] = [] + row.reserveCapacity(line.count) + var style = TerminalStyle() + + TerminalTextParser.scan(line) { token in + switch token { + case .sgr(let parameters, _): + style.apply(sgr: parameters) + case .grapheme(let character): + let cellWidth = character.terminalWidth + guard cellWidth > 0 else { return } + let grapheme = String(character) + let isTransparent = grapheme == " " && style.isDefault + row.append( + TerminalCell( + content: .grapheme(grapheme, width: cellWidth), + style: style, + isTransparent: isTransparent + ) + ) + if cellWidth > 1 { + row.append( + contentsOf: repeatElement( + TerminalCell(content: .continuation, style: style, isTransparent: isTransparent), + count: cellWidth - 1 + ) + ) + } + } + } + + return row + } +} + +// MARK: - Encoding + +private extension TerminalSurface { + static let ansiReset = "\u{1B}[0m" + + static func plainText(_ row: [TerminalCell]) -> String { + var result = "" + result.reserveCapacity(row.count) + + for cell in row { + switch cell.content { + case .empty: + result.append(" ") + case .grapheme(let grapheme, _): + result += grapheme + case .continuation: + continue + } + } + + return result + } + + static func ansiEncoded(_ row: [TerminalCell]) -> String { + var result = "" + result.reserveCapacity(row.count) + var activeStyle = TerminalStyle() + + for cell in row { + guard case .continuation = cell.content else { + if cell.style != activeStyle { + if !activeStyle.isDefault { + result += ansiReset + } + if !cell.style.isDefault { + result += cell.style.ansiSequence + } + activeStyle = cell.style + } + + switch cell.content { + case .empty: + result.append(" ") + case .grapheme(let grapheme, _): + result += grapheme + case .continuation: + break + } + continue + } + } + + if !activeStyle.isDefault { + result += ansiReset + } + return result + } +} + +// MARK: - Cell Integrity + +private extension TerminalSurface { + static func pad(_ row: inout [TerminalCell], to width: Int) { + guard row.count < width else { return } + row.append(contentsOf: repeatElement(.empty, count: width - row.count)) + } + + static func clearGraphemes(in row: inout [TerminalCell], intersecting range: Range) { + var ownerColumns = Set() + + for column in range where column < row.count { + switch row[column].content { + case .grapheme: + ownerColumns.insert(column) + case .continuation: + var owner = column - 1 + while owner >= 0 { + if case .grapheme = row[owner].content { + ownerColumns.insert(owner) + break + } + owner -= 1 + } + case .empty: + break + } + } + + for owner in ownerColumns { + guard case .grapheme(_, let cellWidth) = row[owner].content else { continue } + for column in owner.. Date: Mon, 20 Jul 2026 02:35:27 +0200 Subject: [PATCH 03/16] Refactor: back FrameBuffer with terminal cells - preserve the public lines adapter with sanitized cached encoding - make vertical stacking append-only over surface rows - composite transparency and wide graphemes without reparsing strings --- .../TUIkitCore/Rendering/FrameBuffer.swift | 349 +++++++----------- Tests/TUIkitCoreTests/FrameBufferTests.swift | 47 +++ 2 files changed, 178 insertions(+), 218 deletions(-) diff --git a/Sources/TUIkitCore/Rendering/FrameBuffer.swift b/Sources/TUIkitCore/Rendering/FrameBuffer.swift index e7453e0c..2c34f555 100644 --- a/Sources/TUIkitCore/Rendering/FrameBuffer.swift +++ b/Sources/TUIkitCore/Rendering/FrameBuffer.swift @@ -4,114 +4,145 @@ // Created by LAYERED.work // License: MIT -/// A 2D text buffer that views render into before flushing to the terminal. +/// A 2D terminal-cell buffer that views render into before flushing. /// /// `FrameBuffer` enables a two-pass rendering approach: -/// 1. Each view renders into its own buffer (measuring its size) -/// 2. Layout containers combine child buffers (horizontally, vertically, or layered) -/// 3. The final root buffer is flushed to the terminal +/// 1. Each view renders into its own buffer and measures its cell extent. +/// 2. Layout containers combine child buffers horizontally, vertically, or in layers. +/// 3. ANSI is encoded only when the final lines cross a terminal boundary. /// -/// Each line in the buffer is a string that may contain ANSI escape codes. +/// The public ``lines`` property remains a compatibility adapter for pre-styled +/// strings. Internally the buffer owns grapheme clusters, wide-cell continuations, +/// 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 /// with this type directly. public struct FrameBuffer: Sendable, Equatable { - /// The lines of rendered content (may contain ANSI escape codes). + private var surface: TerminalSurface + private var isSynchronizingLines: Bool + + package var terminalSurface: TerminalSurface { + get { surface } + set { + surface = newValue + replaceLinesWithoutParsing(newValue.ansiEncodedLines) + } + } + + /// The terminal-safe, ANSI-encoded lines of rendered content. /// - /// Mutating `lines` directly recomputes the cached ``width``. + /// Reading this property returns the cached terminal-safe encoding. Assigning + /// or mutating it reparses supported SGR style and neutralizes all other + /// terminal control sequences. public var lines: [String] { - didSet { recomputeWidth() } + didSet { + guard !isSynchronizingLines else { return } + surface = TerminalSurface(lines: lines) + replaceLinesWithoutParsing(surface.ansiEncodedLines) + } } - /// The width of the buffer (the length of the longest line in visible characters). - /// - /// This is a stored property, recomputed automatically whenever - /// ``lines`` is mutated. Accessing `width` is O(1) — the expensive - /// ANSI-stripping regex runs only once per mutation, not per access. - public private(set) var width: Int + /// The width of the buffer in terminal cells. + public var width: Int { + surface.width + } - /// The height of the buffer (number of lines). + /// The height of the buffer in terminal rows. public var height: Int { - lines.count + surface.height } - /// Whether the buffer is empty. + /// Whether the buffer contains no explicit grapheme content. public var isEmpty: Bool { - lines.isEmpty || lines.allSatisfy { $0.isEmpty } + surface.isEmpty } /// Creates an empty buffer. public init() { - self.lines = [] - self.width = 0 + surface = TerminalSurface() + isSynchronizingLines = false + lines = [] } - /// Creates a buffer from an array of lines. + /// Creates a buffer from ANSI-styled or plain text lines. + /// + /// Only SGR styling is retained. Embedded cursor, device, hyperlink, and + /// other control sequences are neutralized while parsing. /// - /// - Parameter lines: The text lines. + /// - Parameter lines: The text lines to parse into terminal cells. public init(lines: [String]) { - self.lines = lines - self.width = Self.computeWidth(lines) + let surface = TerminalSurface(lines: lines) + self.surface = surface + self.isSynchronizingLines = false + self.lines = surface.ansiEncodedLines } - /// Initializer that accepts pre-computed width. + /// Creates a buffer with an already known cell extent. /// - /// Use this when the width is already known to avoid redundant computation. + /// - Parameters: + /// - lines: The text lines to parse into terminal cells. + /// - width: The known minimum width of the buffer in terminal cells. public init(lines: [String], width: Int) { - self.lines = lines - self.width = width + let surface = TerminalSurface(lines: lines, width: width) + self.surface = surface + self.isSynchronizingLines = false + self.lines = surface.ansiEncodedLines } /// Creates a buffer containing a single line. /// - /// - Parameter text: The text content. + /// - Parameter text: The text content to parse into terminal cells. public init(text: String) { - self.lines = [text] - self.width = text.strippedLength + let surface = TerminalSurface(lines: [text]) + self.surface = surface + self.isSynchronizingLines = false + self.lines = surface.ansiEncodedLines } /// Creates a spacer buffer with the specified height. /// - /// The buffer contains lines with a single space character to ensure - /// it is not considered "empty" by layout algorithms. This is important - /// for Spacer views which need to occupy vertical space even without - /// visible content. + /// A visible space on every row keeps the spacer distinct from an empty view. /// - /// - Parameter height: The number of lines. + /// - Parameter height: The number of rows. public init(emptyWithHeight height: Int) { - // Use a single space instead of empty string so the buffer - // is not considered "empty" by appendVertically - self.lines = Array(repeating: " ", count: height) - self.width = 1 + let surface = TerminalSurface(lines: Array(repeating: " ", count: max(0, height))) + self.surface = surface + self.isSynchronizingLines = false + self.lines = surface.ansiEncodedLines } - /// Creates a buffer of empty spaces with the specified width and height. - /// - /// Used by horizontal stacks for Spacer views which need to occupy - /// horizontal space across multiple rows. + /// Creates a buffer of spaces with the specified dimensions. /// /// - Parameters: - /// - width: The width in characters. - /// - height: The number of lines. + /// - width: The width in terminal cells. + /// - height: The number of rows. public init(emptyWithWidth width: Int, height: Int) { - self.lines = Array(repeating: String(repeating: " ", count: width), count: height) - self.width = width + let clampedWidth = max(0, width) + let line = String(repeating: " ", count: clampedWidth) + let surface = TerminalSurface( + lines: Array(repeating: line, count: max(0, height)), + width: clampedWidth + ) + self.surface = surface + self.isSynchronizingLines = false + self.lines = surface.ansiEncodedLines } - // MARK: - Combining Arrays - - /// Creates a vertically stacked buffer from an array of buffers. - /// - /// TupleViews use this to combine their children vertically by default - /// (the parent stack then decides the actual layout direction). + /// Creates a buffer by stacking all supplied buffers in one linear pass. /// /// - Parameter buffers: The buffers to stack vertically. public init(verticallyStacking buffers: [Self]) { - self.init() - for buffer in buffers { - appendVertically(buffer) - } + let surface = TerminalSurface(verticallyStacking: buffers.map(\.surface)) + self.surface = surface + self.isSynchronizingLines = false + self.lines = surface.ansiEncodedLines + } + + package init(terminalSurface: TerminalSurface) { + self.surface = terminalSurface + self.isSynchronizingLines = false + self.lines = terminalSurface.ansiEncodedLines } } @@ -122,193 +153,75 @@ public extension FrameBuffer { /// /// - Parameters: /// - other: The buffer to append below. - /// - spacing: Number of empty lines between the two buffers. + /// - spacing: Number of empty rows between the buffers. mutating func appendVertically(_ other: Self, spacing: Int = 0) { - guard !other.isEmpty else { return } - - // Pre-compute the new width (avoids redundant computation in didSet) - let newWidth = max(width, other.width) - - // Build combined array - var combined = lines - if !combined.isEmpty && spacing > 0 { - combined.append(contentsOf: repeatElement("", count: spacing)) + let spacing = max(0, spacing) + let hadRows = !lines.isEmpty + surface.appendVertically(other.surface, spacing: spacing) + if !other.isEmpty { + isSynchronizingLines = true + if hadRows && spacing > 0 { + lines.append(contentsOf: repeatElement("", count: spacing)) + } + lines.append(contentsOf: other.lines) + isSynchronizingLines = false } - combined.append(contentsOf: other.lines) - - // Replace self with new buffer using pre-computed width - self = FrameBuffer(lines: combined, width: newWidth) } /// Places another buffer to the right of this one with optional spacing. /// /// - Parameters: /// - other: The buffer to append to the right. - /// - spacing: Number of space characters between the two buffers. + /// - spacing: Number of cells between the buffers. mutating func appendHorizontally(_ other: Self, spacing: Int = 0) { - let maxHeight = max(height, other.height) - let myWidth = width - let spacer = String(repeating: " ", count: spacing) - - // Pre-compute the new width - let newWidth = myWidth + spacing + other.width - - var result: [String] = [] - result.reserveCapacity(maxHeight) - - for row in 0.. Self { - guard !overlay.isEmpty else { return self } - - let resultWidth = max(width, position.x + overlay.width) - let resultHeight = max(height, position.y + overlay.height) - - var result: [String] = [] - - for row in 0..= 0 && overlayRow < overlay.lines.count { - let overlayLine = overlay.lines[overlayRow] - if !overlayLine.isEmpty { - // Insert overlay content at the x position - baseLine = insertOverlay( - base: baseLine, - overlay: overlayLine, - atColumn: position.x, - originalBase: originalLine - ) - } - } - - result.append(baseLine) - } - - return Self(lines: result) + Self( + terminalSurface: surface.composited( + with: overlay.surface, + atX: position.x, + y: position.y + ) + ) } } -// MARK: - Private Helpers +// MARK: - Package Rendering API -private extension FrameBuffer { - - /// ANSI SGR reset sequence. Inlined to avoid depending on ANSIRenderer. - static let ansiReset = "\u{1B}[0m" - /// Recomputes the cached ``width`` from the current ``lines``. - /// - /// Called automatically by the `didSet` observer on ``lines``. - mutating func recomputeWidth() { - width = Self.computeWidth(lines) +extension FrameBuffer { + package func clipped(toWidth width: Int, height: Int) -> Self { + Self(terminalSurface: surface.clipped(toWidth: width, height: height)) } +} - /// Computes the visible width of a set of lines. - /// - /// - Parameter lines: The lines to measure. - /// - Returns: The length of the longest line in visible characters. - static func computeWidth(_ lines: [String]) -> Int { - lines.map { $0.strippedLength }.max() ?? 0 - } - - /// Inserts overlay text into base text at the specified column position. - /// - /// Splits the base line at visible-character boundaries (ignoring ANSI codes) - /// and preserves the base's ANSI styling in the prefix and suffix regions. - /// The overlay replaces the base in its column range, with its own styling intact. - /// - /// After the overlay, the base's active ANSI state is restored before the - /// suffix so the dimmed background (or any other base styling) continues - /// seamlessly to the right of the overlay. - /// - /// - Parameters: - /// - base: The base text line (may contain ANSI codes). - /// - overlay: The overlay text to insert (may contain ANSI codes). - /// - column: The column position (0-based, in visible characters). - /// - originalBase: The original base line before padding, used to extract - /// the active ANSI state. If `nil`, the state is extracted from `base`. - /// - Returns: The composited line with base styling preserved around the overlay. - func insertOverlay( - base: String, - overlay: String, - atColumn column: Int, - originalBase: String? = nil - ) -> String { - let overlayVisibleWidth = overlay.strippedLength - let afterOverlayColumn = column + overlayVisibleWidth - - // Split the base into prefix (before overlay) and suffix (after overlay), - // preserving all ANSI codes in both segments. - let prefix = base.ansiAwarePrefix(visibleCount: column) - let suffix = base.ansiAwareSuffix(droppingVisible: afterOverlayColumn) - - // Extract the leading ANSI state from the original (unpadded) base line. - // The leading sequences contain the full styling setup (BG + FG + dim) - // before any visible text. This is more reliable than scanning the whole - // string, because applyPersistentBackground appends a lone BG code after - // the final reset that doesn't represent the full styling state. - let styleSource = originalBase ?? base - let baseStyle = styleSource.leadingANSISequences() - - // Build: [prefix] + [reset] + [overlay] + [reset + base style restore] + [suffix] - var result = prefix - result += Self.ansiReset - result += overlay - result += Self.ansiReset - result += baseStyle - result += suffix +// MARK: - Storage Synchronization - return result +private extension FrameBuffer { + mutating func replaceLinesWithoutParsing(_ newLines: [String]) { + isSynchronizingLines = true + lines = newLines + isSynchronizingLines = false } } diff --git a/Tests/TUIkitCoreTests/FrameBufferTests.swift b/Tests/TUIkitCoreTests/FrameBufferTests.swift index cb3245d3..107d76af 100644 --- a/Tests/TUIkitCoreTests/FrameBufferTests.swift +++ b/Tests/TUIkitCoreTests/FrameBufferTests.swift @@ -79,4 +79,51 @@ struct FrameBufferTests { // "Hi" (styled) + " " (spacing) + "There" #expect(left.lines[0].stripped == "Hi There") } + + @Test("Line mutation rebuilds cell width") + func lineMutationRebuildsSurface() { + var buffer = FrameBuffer(text: "A") + + buffer.lines[0] = "e\u{301}界" + + #expect(buffer.width == 3) + #expect(buffer.lines == ["e\u{301}界"]) + } + + @Test("FrameBuffer rejects embedded terminal commands") + func rejectsTerminalCommands() { + let buffer = FrameBuffer(text: "A\u{1B}]0;owned\u{07}B\u{1B}[2JC") + + #expect(buffer.lines == ["ABC"]) + #expect(buffer.width == 3) + } + + @Test("Transparent overlay spaces preserve base cells") + func transparentOverlaySpaces() { + let base = FrameBuffer(text: "ABC") + let overlay = FrameBuffer(text: " X ") + + let result = base.composited(with: overlay, at: (x: 0, y: 0)) + + #expect(result.lines == ["AXC"]) + } + + @Test("Overlay clears complete wide graphemes") + func overlayClearsWideGrapheme() { + let base = FrameBuffer(text: "A界B") + let overlay = FrameBuffer(text: "X") + + let result = base.composited(with: overlay, at: (x: 2, y: 0)) + + #expect(result.lines == ["A XB"]) + #expect(result.width == 4) + } + + @Test("RGB-styled compatibility lines remain valid strings") + func rgbStyledLineLifetime() { + let styled = "\u{1B}[38;2;229;229;229mOver\u{1B}[0m" + let buffer = FrameBuffer(text: styled) + + #expect(buffer.lines[0].stripped == "Over") + } } From 95511b4abf30a0f0dbaff8be45cc4a601dc5b024 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 02:36:19 +0200 Subject: [PATCH 04/16] Fix: preserve blank cells beneath foreground styling - keep foreground-only spaces transparent during overlays - treat backgrounds and line decorations as explicit cell paint --- Sources/TUIkitCore/Rendering/TerminalStyle.swift | 4 ++++ Sources/TUIkitCore/Rendering/TerminalSurface.swift | 2 +- Tests/TUIkitCoreTests/TerminalSurfaceTests.swift | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/TUIkitCore/Rendering/TerminalStyle.swift b/Sources/TUIkitCore/Rendering/TerminalStyle.swift index 0fcaa678..928a17a3 100644 --- a/Sources/TUIkitCore/Rendering/TerminalStyle.swift +++ b/Sources/TUIkitCore/Rendering/TerminalStyle.swift @@ -34,6 +34,10 @@ extension TerminalStyle { self == Self() } + var paintsBlankCell: Bool { + background != nil || isInverted || isUnderlined || isStrikethrough + } + package var ansiSequence: String { let parameters = ansiParameters guard !parameters.isEmpty else { return "" } diff --git a/Sources/TUIkitCore/Rendering/TerminalSurface.swift b/Sources/TUIkitCore/Rendering/TerminalSurface.swift index f58450e2..124d516b 100644 --- a/Sources/TUIkitCore/Rendering/TerminalSurface.swift +++ b/Sources/TUIkitCore/Rendering/TerminalSurface.swift @@ -222,7 +222,7 @@ private extension TerminalSurface { let cellWidth = character.terminalWidth guard cellWidth > 0 else { return } let grapheme = String(character) - let isTransparent = grapheme == " " && style.isDefault + let isTransparent = grapheme == " " && !style.paintsBlankCell row.append( TerminalCell( content: .grapheme(grapheme, width: cellWidth), diff --git a/Tests/TUIkitCoreTests/TerminalSurfaceTests.swift b/Tests/TUIkitCoreTests/TerminalSurfaceTests.swift index 9de88ef3..ebecdace 100644 --- a/Tests/TUIkitCoreTests/TerminalSurfaceTests.swift +++ b/Tests/TUIkitCoreTests/TerminalSurfaceTests.swift @@ -58,6 +58,16 @@ struct TerminalSurfaceTests { #expect(result.plainLines == ["AXC"]) } + @Test("Foreground styling does not make blank overlay cells opaque") + func foregroundStyledBlankOverlay() { + let base = TerminalSurface(lines: ["ABC"]) + let overlay = TerminalSurface(lines: ["\u{1B}[31m X \u{1B}[0m"]) + + let result = base.composited(with: overlay, atX: 0, y: 0) + + #expect(result.plainLines == ["AXC"]) + } + @Test("Styled spaces are opaque overlay cells") func styledSpaceOverlay() { let base = TerminalSurface(lines: ["ABC"]) From ecbca7ee9d9f0fcafa6794605b556bf2867c5cae Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 02:37:24 +0200 Subject: [PATCH 05/16] Fix: honor ZStack alignment on cell surfaces - size the shared surface from every rendered child - position children on both alignment axes - preserve transparent cells while layering later children --- Sources/TUIkit/Views/ZStack.swift | 52 +++++++++++++++++++++++---- Tests/TUIkitTests/ZStackTests.swift | 54 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 Tests/TUIkitTests/ZStackTests.swift diff --git a/Sources/TUIkit/Views/ZStack.swift b/Sources/TUIkit/Views/ZStack.swift index 57a991d2..7e3f8a47 100644 --- a/Sources/TUIkit/Views/ZStack.swift +++ b/Sources/TUIkit/Views/ZStack.swift @@ -56,15 +56,55 @@ private struct _ZStackCore: View, Renderable { } func renderToBuffer(context: RenderContext) -> FrameBuffer { - let infos = resolveChildInfos(from: content, context: context) - var result = FrameBuffer() - for info in infos { - if let buffer = info.buffer { - result.overlay(buffer) - } + let buffers = resolveChildInfos(from: content, context: context).compactMap(\.buffer) + guard !buffers.isEmpty else { return FrameBuffer() } + + let width = buffers.map(\.width).max() ?? 0 + let height = buffers.map(\.height).max() ?? 0 + var result = FrameBuffer(lines: Array(repeating: "", count: height), width: width) + + for buffer in buffers where !buffer.isEmpty { + let horizontalOffset = offset( + available: width, + content: buffer.width, + alignment: alignment.horizontal + ) + let verticalOffset = offset( + available: height, + content: buffer.height, + alignment: alignment.vertical + ) + result = result.composited( + with: buffer, + at: (x: horizontalOffset, y: verticalOffset) + ) } return result } + + private func offset( + available: Int, + content: Int, + alignment: HorizontalAlignment + ) -> Int { + switch alignment { + case .leading: return 0 + case .center: return max(0, (available - content) / 2) + case .trailing: return max(0, available - content) + } + } + + private func offset( + available: Int, + content: Int, + alignment: VerticalAlignment + ) -> Int { + switch alignment { + case .top: return 0 + case .center: return max(0, (available - content) / 2) + case .bottom: return max(0, available - content) + } + } } // MARK: - Equatable diff --git a/Tests/TUIkitTests/ZStackTests.swift b/Tests/TUIkitTests/ZStackTests.swift new file mode 100644 index 00000000..5294a2ad --- /dev/null +++ b/Tests/TUIkitTests/ZStackTests.swift @@ -0,0 +1,54 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// ZStackTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Testing + +@testable import TUIkit + +@MainActor +@Suite("ZStack Tests") +struct ZStackTests { + @Test("Default alignment centers every child in the shared surface") + func defaultCenterAlignment() { + let stack = ZStack { + Text("12345") + Text("X") + } + + let buffer = render(stack) + + #expect(buffer.width == 5) + #expect(buffer.height == 1) + #expect(buffer.lines[0].stripped == "12X45") + } + + @Test("Bottom-trailing alignment applies on both axes") + func bottomTrailingAlignment() { + let stack = ZStack(alignment: .bottomTrailing) { + Text("ABCD") + VStack(spacing: 0) { + Text("X") + Text("Y") + } + } + + let buffer = render(stack) + + #expect(buffer.width == 4) + #expect(buffer.height == 2) + #expect(buffer.lines[0].stripped == " X") + #expect(buffer.lines[1].stripped == "ABCY") + } + + private func render(_ view: V) -> FrameBuffer { + let context = RenderContext( + availableWidth: 40, + availableHeight: 10, + tuiContext: TUIContext() + ) + return renderToBuffer(view, context: context) + } +} From 6265690a814a1506a6b49b7e5a33690a7a580c62 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 02:41:49 +0200 Subject: [PATCH 06/16] Fix: lay out text in terminal cells - wrap and measure sanitized Text graphemes by display width - clip and pad text-field content without splitting wide cells - scroll focused fields on grapheme boundaries while preserving cursor indices --- .../Rendering/TextFieldContentRenderer.swift | 102 ++++++++++++------ Sources/TUIkit/Views/Text.swift | 80 +++++++++++--- Tests/TUIkitTests/TextCellLayoutTests.swift | 98 +++++++++++++++++ 3 files changed, 230 insertions(+), 50 deletions(-) create mode 100644 Tests/TUIkitTests/TextCellLayoutTests.swift diff --git a/Sources/TUIkit/Rendering/TextFieldContentRenderer.swift b/Sources/TUIkit/Rendering/TextFieldContentRenderer.swift index 535b0e28..1f6698ec 100644 --- a/Sources/TUIkit/Rendering/TextFieldContentRenderer.swift +++ b/Sources/TUIkit/Rendering/TextFieldContentRenderer.swift @@ -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, @@ -59,7 +60,7 @@ struct TextFieldContentRenderer { ) } else { return buildTextContent( - text: text, + text: sanitizedText, palette: palette, background: backgroundColor, width: contentWidth @@ -78,8 +79,8 @@ 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) } @@ -87,12 +88,18 @@ struct TextFieldContentRenderer { /// 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.. 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 visibleStart = visibleStart( + characters: characters, + cursorPosition: clampedPosition, + availableWidth: max(0, width - 1) + ) // Compute cursor visibility and color based on animation style let (cursorVisible, cursorColor) = computeCursorState( @@ -133,29 +135,35 @@ 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..