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
100 changes: 85 additions & 15 deletions Sources/Textual/Internal/TextFragment/TextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@ import SwiftUI
// Runs with attachments are converted to placeholder images sized by the attachment's
// sizeThatFits(_:in:) result. Placeholders are tagged with AttachmentAttribute so overlays
// can identify and render the actual attachment views at the resolved layout positions.
//
// MARK: - Recursion-safe Text construction
//
// Only attachment placeholders and link runs need to be standalone Text nodes — they
// rely on `customAttribute(AttachmentAttribute(...))` and `customAttribute(LinkAttribute(...))`
// markers that cannot live inside an AttributedString. Every other run carries standard
// AttributedString attributes (foregroundColor, font, presentationIntent, syntax-highlight
// theme tokens, etc.) which Text(_ attributedString:) preserves natively.
//
// Building one Text per run and then `Text("\(prev)\(next)")`-reducing them into a single
// value produces a LocalizedTextStorage tree whose depth equals the run count. SwiftUI
// resolves that tree recursively at layout time, so a code block with thousands of Prism
// tokens (or any AttributedString with thousands of runs) blows the main-thread stack at
// resolve time — the "Thread stack size exceeded due to excessive recursion" crash.
//
// Instead, consecutive plain runs are coalesced into a single Text(_ attributedString:)
// and concatenated with the `+` operator only across attachment / link boundaries. The
// resulting Text-node count is bounded by `attachments + links + 1` rather than the run
// count, and the concatenation uses ConcatenatedTextStorage instead of LocalizedTextStorage,
// which avoids the localization-machinery walk entirely.
//
// The final `+`-merge is balanced (pairwise) so the ConcatenatedTextStorage tree is
// O(log N) deep, not O(N). A left-fold reduce would re-introduce the same crash class
// in a different guise for content with thousands of attachments / links.

extension TextFragment {
@MainActor @Observable final class TextBuilder {
Expand Down Expand Up @@ -64,41 +88,87 @@ extension Text {
attachmentSizes: [AttachmentKey: CGSize],
in environment: TextEnvironmentValues
) {
let textValues = attributedString.runs.map { run in
var text: Text
var pieces: [Text] = []
var pending = AttributedString()

var runEnvironment = environment
runEnvironment.font = run.font ?? environment.font
func flushPending() {
guard !pending.runs.isEmpty else { return }
pieces.append(Text(pending))
pending = AttributedString()
}

let key = run.textual.attachment.map {
AttachmentKey(attachment: $0, font: runEnvironment.font)
for run in attributedString.runs {
let attachment = run.textual.attachment
let link = run.link

// Plain runs (no attachment, no link) carry only AttributedString attributes,
// so they round-trip cleanly through Text(_ attributedString:). Coalesce them
// into `pending` and only flush at attachment / link boundaries. The 99% case
// (every Prism token in a code block, every styled span in prose) lands here
// and never builds a runEnvironment or a separate Text node.
if attachment == nil, link == nil {
pending.append(AttributedString(attributedString[run.range]))
continue
}

if let key, let size = attachmentSizes[key] {
// Create placeholder
flushPending()

var runEnvironment = environment
runEnvironment.font = run.font ?? environment.font

var text: Text
if let attachment,
let size = attachmentSizes[AttachmentKey(attachment: attachment, font: runEnvironment.font)]
{
text = Text(placeholderSize: size)
.baselineOffset(key.attachment.baselineOffset(in: runEnvironment))
.baselineOffset(attachment.baselineOffset(in: runEnvironment))
.customAttribute(
AttachmentAttribute(
key.attachment,
attachment,
presentationIntent: run.presentationIntent
)
)
} else {
text = Text(AttributedString(attributedString[run.range]))
}

// Add link attribute for TextLinkInteraction
if let link = run.link {
if let link {
text = text.customAttribute(LinkAttribute(link))
}

return text
pieces.append(text)
}

self = textValues.reduce(Text(verbatim: "")) { partialResult, text in
Text("\(partialResult)\(text)")
flushPending()

self = Text.balancedConcatenation(of: pieces)
}

/// Pairwise merge of `pieces` using `+`, producing a `ConcatenatedTextStorage`
/// tree of O(log N) depth instead of the O(N) depth a left-fold would yield.
/// `+`-resolve still recurses, so a balanced tree is the difference between
/// safely rendering thousands of inline pieces and overflowing the stack at
/// layout time. The construction cost stays O(N).
///
/// Internal (rather than fileprivate) so unit tests can exercise the helper
/// directly without going through full `TextBuilder` construction.
static func balancedConcatenation(of pieces: [Text]) -> Text {
guard !pieces.isEmpty else { return Text(verbatim: "") }
var level = pieces
while level.count > 1 {
var next: [Text] = []
next.reserveCapacity((level.count + 1) / 2)
var index = 0
while index + 1 < level.count {
next.append(level[index] + level[index + 1])
index += 2
}
if index < level.count {
next.append(level[index])
}
level = next
}
return level[0]
}

private init(placeholderSize size: CGSize) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
// We need the selection model at text fragment level for the
// text selection background and selected attachment dimming
.environment(model)
// Resolve geometry before distributing to the NSView overlay. Without
// this, animated container resizes (e.g. NavigationSplitView sidebar
// collapse) can leave the NSTextInteractionView with a stale frame,
// causing it to intercept mouse events outside its visible bounds.
.geometryGroup()
.overlayPreferenceValue(OverflowFrameKey.self) { frames in
AppKitTextInteractionOverlay(model: model, overflowFrames: frames)
.onContinuousHover { phase in
Expand Down
2 changes: 1 addition & 1 deletion Sources/Textual/TextualNamespace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import Foundation
/// SwiftUI views get the namespace through the ``SwiftUICore/View/textual`` property.
/// Other types can opt into it by conforming to ``TextualCompatible``.
public struct TextualNamespace<Base> {
@usableFromInline let base: Base
@usableFromInline var base: Base
@inlinable public init(_ base: Base) { self.base = base }
}

Expand Down
118 changes: 118 additions & 0 deletions Tests/TextualTests/Internal/TextFragment/TextBuilderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import SwiftUI
import Testing

@testable import Textual

@MainActor
extension TextFragment<AttributedString> {
struct TextBuilderTests {
private static func makeAttributedString(
runCount: Int,
colors: [Color] = [.red, .blue, .green, .orange, .purple]
) -> AttributedString {
var result = AttributedString()
for index in 0 ..< runCount {
var piece = AttributedString("token-\(index) ")
piece.foregroundColor = colors[index % colors.count]
result.append(piece)
}
return result
}

@Test func empty() {
let builder = TextFragment<AttributedString>.TextBuilder(
AttributedString(),
environment: TextEnvironmentValues()
)
#expect(type(of: builder.text) == Text.self)
}

@Test func plainSingleRun() {
let builder = TextFragment<AttributedString>.TextBuilder(
AttributedString("hello world"),
environment: TextEnvironmentValues()
)
#expect(type(of: builder.text) == Text.self)
}

/// A pathologically large run count that would overflow the SwiftUI layout stack
/// under the previous `Text("\(prev)\(next)")` reduction. We can't probe the
/// resulting Text-tree depth from outside SwiftUI, but we can at least confirm that
/// construction itself remains O(N) and doesn't recurse N levels — when it does,
/// it shows up as `EXC_BAD_ACCESS` here, not a passing test.
@Test func largeRunCountConstructsCleanly() {
let attributed = Self.makeAttributedString(runCount: 5000)
let builder = TextFragment<AttributedString>.TextBuilder(
attributed,
environment: TextEnvironmentValues()
)
#expect(type(of: builder.text) == Text.self)
}

@Test func plainRunsAreCoalescedAcrossSizeChanges() {
let attributed = Self.makeAttributedString(runCount: 100)
let builder = TextFragment<AttributedString>.TextBuilder(
attributed,
environment: TextEnvironmentValues()
)
builder.sizeChanged(CGSize(width: 320, height: 480), environment: TextEnvironmentValues())
builder.sizeChanged(CGSize(width: 480, height: 320), environment: TextEnvironmentValues())
#expect(type(of: builder.text) == Text.self)
}

@Test func runsWithLinks() {
var attributed = AttributedString("Visit ")
var linkRun = AttributedString("the example site")
linkRun.link = URL(string: "https://example.com")
attributed.append(linkRun)
attributed.append(AttributedString(" for details. "))

var trailingLink = AttributedString("Or here")
trailingLink.link = URL(string: "https://example.org")
attributed.append(trailingLink)
attributed.append(AttributedString("."))

let builder = TextFragment<AttributedString>.TextBuilder(
attributed,
environment: TextEnvironmentValues()
)
#expect(type(of: builder.text) == Text.self)
}

/// Stress-tests the balanced concatenation path. Many link-bearing runs each force
/// a separate Text node; a left-fold concat would build a `ConcatenatedTextStorage`
/// tree of depth = link count and re-introduce the 2024-class recursion crash for
/// pathological link-list content. Pairwise merge keeps the tree O(log N) deep.
@Test func manyLinkRunsConstructCleanly() {
var attributed = AttributedString()
for index in 0 ..< 2000 {
var prose = AttributedString("see ")
prose.foregroundColor = .secondary
attributed.append(prose)

var link = AttributedString("link-\(index)")
link.link = URL(string: "https://example.com/\(index)")
attributed.append(link)

attributed.append(AttributedString(", "))
}

let builder = TextFragment<AttributedString>.TextBuilder(
attributed,
environment: TextEnvironmentValues()
)
#expect(type(of: builder.text) == Text.self)
}

@Test func balancedConcatenationOfEmptyArrayIsEmpty() {
let result = Text.balancedConcatenation(of: [])
#expect(type(of: result) == Text.self)
}

@Test func balancedConcatenationOfSingleElementReturnsThatElement() {
let only = Text("only")
let result = Text.balancedConcatenation(of: [only])
#expect(type(of: result) == Text.self)
}
}
}