Skip to content
Draft
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
9 changes: 5 additions & 4 deletions Contributor Documentation/System Overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,11 @@ This stage must happen before SwiftUI resolves geometry, so attachments receive
### Overlaying

After SwiftUI resolves `Text.Layout`, modifiers applied at the fragment level use this geometry
to render overlays. `AttachmentOverlay` positions attachment views at their run locations.
`TextLinkInteraction` handles taps on URLs. Links are re-attached while building `Text` so the
resolved layout can be used for hit testing. Text selection is supported on macOS, iOS, and
visionOS; tvOS and watchOS don't provide a selection experience.
to render overlays. `TextFragmentOverlay` combines attachment rendering and link interaction
into a single overlay modifier, reading `Text.Layout` once to position attachment views at
their run locations and handle taps on URLs. Links are re-attached while building `Text` so
the resolved layout can be used for hit testing. Text selection is supported on macOS, iOS,
and visionOS; tvOS and watchOS don't provide a selection experience.

Text selection captures `Text.Layout` geometry and handles gestures through platform-native views.
To keep code blocks with scrollable overflow interactive, these blocks emit their frames via
Expand Down
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 0 additions & 33 deletions Sources/Textual/Internal/Attachment/AttachmentOverlay.swift

This file was deleted.

1 change: 1 addition & 0 deletions Sources/Textual/Internal/InlineText/WithInlineStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ struct WithInlineStyle<Content: View>: View {
output[run.range].mergeAttributes(attributes, mergePolicy: .keepNew)
}

guard self.output != output else { return }
self.output = output
}
}
4 changes: 3 additions & 1 deletion Sources/Textual/Internal/StructuredText/BlockVStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ extension StructuredText {
content
.onPreferenceChange(BlockSpacingKey.self) { @MainActor value in
// Override with the resolved list item spacing if enabled
blockSpacing = listItemSpacingEnabled ? resolvedListItemSpacing : value
let newValue = listItemSpacingEnabled ? resolvedListItemSpacing : value
guard blockSpacing != newValue else { return }
blockSpacing = newValue
}
.layoutValue(key: BlockSpacingKey.self, value: blockSpacing)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Textual/Internal/StructuredText/OrderedList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ extension StructuredText {
}
}
.onPreferenceChange(MarkerWidthKey.self) { @MainActor in
guard markerWidth != $0 else { return }
markerWidth = $0
}
.environment(\.resolvedListItemSpacing, listItemSpacing.resolve(in: textEnvironment))
Expand Down
10 changes: 9 additions & 1 deletion Sources/Textual/Internal/TextFragment/TextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ extension TextFragment {

@ObservationIgnored private let content: Content
@ObservationIgnored private let cache: NSCache<KeyBox<[AttachmentKey: CGSize]>, Box<Text>>
@ObservationIgnored private var currentAttachmentSizes: [AttachmentKey: CGSize]

init(_ content: Content, environment: TextEnvironmentValues) {
let attachmentSizes = content.attachmentSizes(for: .unspecified, in: environment)
Expand All @@ -35,11 +36,18 @@ extension TextFragment {
self.cache = NSCache()
self.cache.countLimit = 10

self.currentAttachmentSizes = attachmentSizes

self.cache.setObject(Box(self.text), forKey: KeyBox(attachmentSizes))
}

func sizeChanged(_ size: CGSize, environment: TextEnvironmentValues) {
let attachmentSizes = content.attachmentSizes(for: .init(size), in: environment)

// Skip if attachment sizes haven't changed — avoids redundant Observable mutations during scroll
guard attachmentSizes != currentAttachmentSizes else { return }
currentAttachmentSizes = attachmentSizes

let cacheKey = KeyBox(attachmentSizes)

if let text = cache.object(forKey: cacheKey) {
Expand Down Expand Up @@ -88,7 +96,7 @@ extension Text {
text = Text(AttributedString(attributedString[run.range]))
}

// Add link attribute for TextLinkInteraction
// Add link attribute for TextFragmentOverlay link interaction
if let link = run.link {
text = text.customAttribute(LinkAttribute(link))
}
Expand Down
9 changes: 4 additions & 5 deletions Sources/Textual/Internal/TextFragment/TextFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import SwiftUI
//
// Attachments are represented as placeholder images tagged with AttachmentAttribute. The
// actual attachment views are rendered in an overlay using the resolved Text.Layout
// geometry. Three modifiers are applied at the fragment level:
// geometry. Two modifiers are applied at the fragment level:
//
// - TextSelectionBackground renders selection highlights on macOS
// - AttachmentOverlay draws attachments at their run locations with selection-aware dimming
// - TextLinkInteraction handles tap gestures on links
// - TextFragmentOverlay combines attachment rendering and link interaction into a single
// overlay, reading Text.Layout once to position attachments and handle link taps
//
// These overlays use backgroundPreferenceValue and overlayPreferenceValue to access
// Text.Layout and render in fragment-local coordinates. Fragment-level overlays enable
Expand Down Expand Up @@ -46,8 +46,7 @@ struct TextFragment<Content: AttributedStringProtocol>: View {
self.textBuilder = TextBuilder(newValue, environment: textEnvironment)
}
.modifier(TextSelectionBackground())
.modifier(AttachmentOverlay(attachments: content.attachments()))
.modifier(TextLinkInteraction())
.modifier(TextFragmentOverlay(attachments: content.attachments()))
}

private var text: Text {
Expand Down
64 changes: 64 additions & 0 deletions Sources/Textual/Internal/TextFragment/TextFragmentOverlay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import SwiftUI

// MARK: - Overview
//
// `TextFragmentOverlay` combines attachment rendering and link interaction into a single
// overlay modifier, reducing the number of GeometryReaders and preference subscriptions
// per text fragment from 2 to 1.
//
// Previously, `AttachmentOverlay` and `TextLinkInteraction` each independently subscribed
// to `Text.LayoutKey` and created their own `GeometryReader`. This combined modifier reads
// the preference once and resolves geometry once, then renders both attachments and link
// tap handling in the same overlay.

struct TextFragmentOverlay: ViewModifier {
#if TEXTUAL_ENABLE_LINKS
@Environment(\.openURL) private var openURL
#endif

private let attachments: Set<AnyAttachment>

init(attachments: Set<AnyAttachment>) {
self.attachments = attachments
}

func body(content: Content) -> some View {
content
.overlayPreferenceValue(Text.LayoutKey.self) { value in
if let anchoredLayout = value.first {
GeometryReader { geometry in
let origin = geometry[anchoredLayout.origin]
let layout = anchoredLayout.layout

AttachmentView(
attachments: attachments,
origin: origin,
layout: layout
)

#if TEXTUAL_ENABLE_LINKS
Color.clear
.contentShape(.rect)
.gesture(
SpatialTapGesture()
.onEnded { value in
let localPoint = CGPoint(
x: value.location.x - origin.x,
y: value.location.y - origin.y
)
let runs = layout.flatMap(\.self)
let run = runs.first { run in
run.typographicBounds.rect.contains(localPoint)
}
guard let url = run?.url else {
return
}
openURL(url)
}
)
#endif
}
}
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,11 @@

@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: self)
guard let url = model.url(for: location) else {
return
if let url = model.url(for: location) {
openURL(url)
} else {
model.selectedRange = nil
}
openURL(url)
}

@objc private func share(_ sender: Any?) {
Expand Down