From 51fe876f2b3de5e6864d593e167c651f765ff240 Mon Sep 17 00:00:00 2001 From: Saad Date: Thu, 12 Mar 2026 15:32:00 +1100 Subject: [PATCH 1/7] fix: clear text selection on tap in iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS, tapping a non-URL area while text is selected does nothing — the selection persists until the user double-taps another word. The macOS equivalent (mouseDown) correctly calls resetSelection(). Clear model.selectedRange on non-URL taps to match macOS behavior. Co-Authored-By: Claude Opus 4.6 --- .../TextInteraction/UIKit/UITextInteractionView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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?) { From 969bbdadb9818f79283908a8531b90f13397e762 Mon Sep 17 00:00:00 2001 From: Alex Duckmanton Date: Mon, 30 Mar 2026 13:38:28 +1100 Subject: [PATCH 2/7] perf: add equality guard to BlockSpacing state writes Skip redundant @State writes in BlockLayoutView.onPreferenceChange when the resolved block spacing value hasn't changed. Eliminates ~295K unnecessary SwiftUI state invalidations during scroll. Co-Authored-By: Claude Opus 4.6 --- Sources/Textual/Internal/StructuredText/BlockVStack.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) } From 7f09143096000b076bd61dd87880ae5bee08473a Mon Sep 17 00:00:00 2001 From: Alex Duckmanton Date: Mon, 30 Mar 2026 13:44:08 +1100 Subject: [PATCH 3/7] perf: deduplicate TextBuilder size-change mutations Track currentAttachmentSizes with @ObservationIgnored and skip sizeChanged when the computed sizes match. Eliminates ~204K redundant @Observable mutations during scroll that cascaded into TextFragment body re-evaluations. Co-Authored-By: Claude Opus 4.6 --- Sources/Textual/Internal/TextFragment/TextBuilder.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/Textual/Internal/TextFragment/TextBuilder.swift b/Sources/Textual/Internal/TextFragment/TextBuilder.swift index 1910ba2..367e164 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) { From a95cdcd8be85c9ef7fd196ed65a8f99f94c9f89e Mon Sep 17 00:00:00 2001 From: Alex Duckmanton Date: Mon, 30 Mar 2026 13:48:24 +1100 Subject: [PATCH 4/7] perf: add equality guard to WithInlineStyle output writes Skip redundant @State writes when the resolved AttributedString output hasn't changed. Eliminates ~162K unnecessary state invalidations that cascaded into TextFragment rebuilds during scroll. Co-Authored-By: Claude Opus 4.6 --- Sources/Textual/Internal/InlineText/WithInlineStyle.swift | 1 + 1 file changed, 1 insertion(+) 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 } } From d8d4d4dbde52c752d3fefa70022ba91248e41630 Mon Sep 17 00:00:00 2001 From: Alex Duckmanton Date: Mon, 30 Mar 2026 13:50:21 +1100 Subject: [PATCH 5/7] perf: add equality guard to OrderedList markerWidth writes Skip redundant @State writes when the marker width preference value hasn't changed. Prevents cascading preference updates in ordered lists during scroll. Co-Authored-By: Claude Opus 4.6 --- Sources/Textual/Internal/StructuredText/OrderedList.swift | 1 + 1 file changed, 1 insertion(+) 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)) From 6ddfdda52488d05b80bf8e47fa8323419f5f6f66 Mon Sep 17 00:00:00 2001 From: Alex Duckmanton Date: Mon, 30 Mar 2026 16:50:58 +1100 Subject: [PATCH 6/7] perf: consolidate GeometryReaders in TextFragment overlay Replace separate AttachmentOverlay + TextLinkInteraction modifiers with a single TextFragmentOverlay that uses one overlayPreferenceValue subscription and one GeometryReader per text fragment instead of two of each. This halves the GeometryReader count and should significantly reduce the update cascade during scrolling (72K SecondaryChild updates in trace). Co-Authored-By: Claude Opus 4.6 --- .../Internal/TextFragment/TextFragment.swift | 3 +- .../TextFragment/TextFragmentOverlay.swift | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 Sources/Textual/Internal/TextFragment/TextFragmentOverlay.swift diff --git a/Sources/Textual/Internal/TextFragment/TextFragment.swift b/Sources/Textual/Internal/TextFragment/TextFragment.swift index 62a534f..4ea29f7 100644 --- a/Sources/Textual/Internal/TextFragment/TextFragment.swift +++ b/Sources/Textual/Internal/TextFragment/TextFragment.swift @@ -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 + } + } + } + } +} From 6038bcc94c844495c0582b8399aff4eb927a97f6 Mon Sep 17 00:00:00 2001 From: Alex Duckmanton Date: Tue, 31 Mar 2026 10:01:30 +1100 Subject: [PATCH 7/7] chore: remove dead AttachmentOverlay and TextLinkInteraction files These modifiers were superseded by the consolidated TextFragmentOverlay. Updates stale comments in TextFragment, TextBuilder, and System Overview to reference the new modifier. Co-Authored-By: Claude Opus 4.6 --- Contributor Documentation/System Overview.md | 9 +-- Package.resolved | 11 +++- .../Attachment/AttachmentOverlay.swift | 33 ----------- .../Internal/TextFragment/TextBuilder.swift | 2 +- .../Internal/TextFragment/TextFragment.swift | 6 +- .../Shared/TextLinkInteraction.swift | 56 ------------------- 6 files changed, 19 insertions(+), 98 deletions(-) delete mode 100644 Sources/Textual/Internal/Attachment/AttachmentOverlay.swift delete mode 100644 Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift 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/TextFragment/TextBuilder.swift b/Sources/Textual/Internal/TextFragment/TextBuilder.swift index 367e164..5e86c0b 100644 --- a/Sources/Textual/Internal/TextFragment/TextBuilder.swift +++ b/Sources/Textual/Internal/TextFragment/TextBuilder.swift @@ -96,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 4ea29f7..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 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 -}