diff --git a/Contributor Documentation/System Overview.md b/Contributor Documentation/System Overview.md index 3848650..3d201de 100644 --- a/Contributor Documentation/System Overview.md +++ b/Contributor Documentation/System Overview.md @@ -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 diff --git a/Package.resolved b/Package.resolved index 52c609c..e63a624 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6830735cf1642029fd8d0de11c899e009d69df72c78d3306a3f5a4e839b891b9", + "originHash" : "1605161967b67e06e264000aeba72e2864de1d101dcd102f8736dda2baaafcf8", "pins" : [ { "identity" : "swift-concurrency-extras", @@ -37,6 +37,15 @@ "version" : "600.0.1" } }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Sources/Textual/Internal/Attachment/AttachmentOverlay.swift b/Sources/Textual/Internal/Attachment/AttachmentOverlay.swift deleted file mode 100644 index 0270458..0000000 --- a/Sources/Textual/Internal/Attachment/AttachmentOverlay.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI - -// MARK: - Overview -// -// `AttachmentOverlay` renders attachment views using resolved `Text.Layout` geometry. -// -// It’s applied at the text fragment level. The fragment exposes the resolved layout through the -// `Text.LayoutKey` preference; this modifier reads the anchored layout, converts its anchor to a -// concrete origin using `GeometryReader`, and installs an `AttachmentView` that draws attachments -// at their run bounds. - -struct AttachmentOverlay: ViewModifier { - private let attachments: Set - - init(attachments: Set) { - self.attachments = attachments - } - - func body(content: Content) -> some View { - content - .overlayPreferenceValue(Text.LayoutKey.self) { value in - if let anchoredLayout = value.first { - GeometryReader { geometry in - AttachmentView( - attachments: attachments, - origin: geometry[anchoredLayout.origin], - layout: anchoredLayout.layout - ) - } - } - } - } -} diff --git a/Sources/Textual/Internal/InlineText/WithInlineStyle.swift b/Sources/Textual/Internal/InlineText/WithInlineStyle.swift index bce84cb..1b49ebe 100644 --- a/Sources/Textual/Internal/InlineText/WithInlineStyle.swift +++ b/Sources/Textual/Internal/InlineText/WithInlineStyle.swift @@ -78,6 +78,7 @@ struct WithInlineStyle: View { output[run.range].mergeAttributes(attributes, mergePolicy: .keepNew) } + guard self.output != output else { return } self.output = output } } diff --git a/Sources/Textual/Internal/StructuredText/BlockVStack.swift b/Sources/Textual/Internal/StructuredText/BlockVStack.swift index fa68012..b9b03bc 100644 --- a/Sources/Textual/Internal/StructuredText/BlockVStack.swift +++ b/Sources/Textual/Internal/StructuredText/BlockVStack.swift @@ -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) } diff --git a/Sources/Textual/Internal/StructuredText/OrderedList.swift b/Sources/Textual/Internal/StructuredText/OrderedList.swift index 720b687..7438d2b 100644 --- a/Sources/Textual/Internal/StructuredText/OrderedList.swift +++ b/Sources/Textual/Internal/StructuredText/OrderedList.swift @@ -36,6 +36,7 @@ extension StructuredText { } } .onPreferenceChange(MarkerWidthKey.self) { @MainActor in + guard markerWidth != $0 else { return } markerWidth = $0 } .environment(\.resolvedListItemSpacing, listItemSpacing.resolve(in: textEnvironment)) diff --git a/Sources/Textual/Internal/TextFragment/TextBuilder.swift b/Sources/Textual/Internal/TextFragment/TextBuilder.swift index 1910ba2..5e86c0b 100644 --- a/Sources/Textual/Internal/TextFragment/TextBuilder.swift +++ b/Sources/Textual/Internal/TextFragment/TextBuilder.swift @@ -22,6 +22,7 @@ extension TextFragment { @ObservationIgnored private let content: Content @ObservationIgnored private let cache: NSCache, Box> + @ObservationIgnored private var currentAttachmentSizes: [AttachmentKey: CGSize] init(_ content: Content, environment: TextEnvironmentValues) { let attachmentSizes = content.attachmentSizes(for: .unspecified, in: environment) @@ -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) { @@ -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)) } diff --git a/Sources/Textual/Internal/TextFragment/TextFragment.swift b/Sources/Textual/Internal/TextFragment/TextFragment.swift index 62a534f..8a07f68 100644 --- a/Sources/Textual/Internal/TextFragment/TextFragment.swift +++ b/Sources/Textual/Internal/TextFragment/TextFragment.swift @@ -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 @@ -46,8 +46,7 @@ struct TextFragment: 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 { diff --git a/Sources/Textual/Internal/TextFragment/TextFragmentOverlay.swift b/Sources/Textual/Internal/TextFragment/TextFragmentOverlay.swift new file mode 100644 index 0000000..9a4c190 --- /dev/null +++ b/Sources/Textual/Internal/TextFragment/TextFragmentOverlay.swift @@ -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 + + init(attachments: Set) { + 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 + } + } + } + } +} diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift deleted file mode 100644 index 4db5ea6..0000000 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift +++ /dev/null @@ -1,56 +0,0 @@ -import SwiftUI - -// MARK: - Overview -// -// `TextLinkInteraction` adds lightweight link tapping to a `Text` fragment. -// -// SwiftUI resolves a `Text.Layout` for each fragment and publishes it through the `Text.LayoutKey` -// preference. This modifier reads the anchored layout, converts tap locations to layout-local -// coordinates, and looks for the first run whose typographic bounds contains the tap. When a run -// has a `url`, the modifier invokes the environment’s `openURL` action. - -struct TextLinkInteraction: ViewModifier { - @Environment(\.openURL) private var openURL - - func body(content: Content) -> some View { - #if TEXTUAL_ENABLE_LINKS - content - .overlayPreferenceValue(Text.LayoutKey.self) { value in - if let anchoredLayout = value.first { - GeometryReader { geometry in - Color.clear - .contentShape(.rect) - .gesture( - tap( - origin: geometry[anchoredLayout.origin], - layout: anchoredLayout.layout - ) - ) - } - } - } - #else - content - #endif - } - - #if TEXTUAL_ENABLE_LINKS - private func tap(origin: CGPoint, layout: Text.Layout) -> some 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 -} diff --git a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift index d342c42..393f6f0 100644 --- a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift +++ b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift @@ -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?) {