From 867bdb17ea7b81b38a5c8dc083b3faf566c9dd9d Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 22 Jun 2026 19:26:12 +0100 Subject: [PATCH] backport --- Package.swift | 10 ++-- .../Textual/Attachment/AttachmentLoader.swift | 19 ++++++- Sources/Textual/EmojiProperties.swift | 9 +++- Sources/Textual/InlineText/InlineStyle.swift | 9 +++- .../Attachment/AttachmentAttribute.swift | 2 + .../Attachment/AttachmentOverlay.swift | 24 +++++---- .../Internal/Attachment/AttachmentView.swift | 1 + .../Backporting/CGSize+Hashable.swift | 13 +++++ .../Internal/Backporting/SubviewGroup.swift | 33 ++++++++++++ .../Internal/StructuredText/BlockVStack.swift | 15 ++++-- .../StructuredText/ListEnvironment.swift | 29 +++++++++-- .../Internal/TextFragment/TextBuilder.swift | 8 ++- .../Internal/TextFragment/TextFragment.swift | 16 ++++-- .../AppKit/AppKitTextInteractionOverlay.swift | 1 + .../AppKitTextSelectionInteraction.swift | 5 +- .../AppKit/AppKitTextSelectionView.swift | 1 + .../AppKit/NSTextInteractionView.swift | 2 + .../Shared/LinkAttribute.swift | 2 + .../EmptyTextLayoutCollection.swift | 1 + .../Shared/TextLayout/Layout+Internals.swift | 5 ++ .../TextLayout/LiveTextLayoutCollection.swift | 7 +++ ...outCollection+AttributedStringAccess.swift | 6 +-- .../TextLayoutCollection+Geometry.swift | 6 +++ .../TextLayoutCollection+Positioning.swift | 4 +- .../TextLayoutCollection+Ranges.swift | 2 +- ...xtLayoutCollection+RunSliceTraversal.swift | 2 + .../TextLayoutCollection+SelectionRects.swift | 5 ++ .../TextLayout/TextLayoutCollection.swift | 11 +++- .../View+TextLayoutCollection.swift | 1 + .../Shared/TextLinkInteraction.swift | 29 ++++++----- .../Shared/TextSelectionBackground.swift | 22 ++++---- .../Shared/TextSelectionCoordinator.swift | 22 ++++++-- .../Shared/TextSelectionInteraction.swift | 36 +++++++++---- .../Shared/TextSelectionModel.swift | 5 +- .../UIKit/TextSelectionRectBox.swift | 1 + .../UIKit/UIKitTextInteractionOverlay.swift | 1 + .../UIKit/UIKitTextSelectionInteraction.swift | 4 +- .../UITextInteractionView+UITextInput.swift | 1 + ...InteractionView+UITextInputTokenizer.swift | 1 + .../UIKit/UITextInteractionView.swift | 2 + Sources/Textual/MathProperties.swift | 9 +++- .../HighlighterTheme/HighlighterTheme.swift | 9 +++- .../Style/BlockQuoteStyle.swift | 9 +++- .../StructuredText/Style/CodeBlockStyle.swift | 9 +++- .../StructuredText/Style/HeadingStyle.swift | 9 +++- .../StructuredText/Style/ListItemStyle.swift | 9 +++- .../Style/OrderedListMarker.swift | 9 +++- .../StructuredText/Style/Overflow.swift | 52 ++++++++++++++++--- .../StructuredText/Style/ParagraphStyle.swift | 9 +++- .../StructuredText/Style/TableCellStyle.swift | 9 +++- .../StructuredText/Style/TableStyle.swift | 9 +++- .../Style/ThematicBreakStyle.swift | 9 +++- .../Style/UnorderedListMarker.swift | 9 +++- 53 files changed, 431 insertions(+), 102 deletions(-) create mode 100644 Sources/Textual/Internal/Backporting/CGSize+Hashable.swift create mode 100644 Sources/Textual/Internal/Backporting/SubviewGroup.swift diff --git a/Package.swift b/Package.swift index 85ff5367..b57c58d0 100644 --- a/Package.swift +++ b/Package.swift @@ -5,11 +5,11 @@ import PackageDescription let package = Package( name: "textual", platforms: [ - .macOS(.v15), - .iOS(.v18), - .tvOS(.v18), - .watchOS(.v11), - .visionOS(.v2), + .macOS(.v14), + .iOS(.v17), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), ], products: [ .library(name: "Textual", targets: ["Textual"]) diff --git a/Sources/Textual/Attachment/AttachmentLoader.swift b/Sources/Textual/Attachment/AttachmentLoader.swift index 78eeb8d4..13106c00 100644 --- a/Sources/Textual/Attachment/AttachmentLoader.swift +++ b/Sources/Textual/Attachment/AttachmentLoader.swift @@ -56,7 +56,22 @@ public protocol AttachmentLoader: Sendable { ) async throws -> Attachment } +private struct ImageAttachmentLoaderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any AttachmentLoader = .image() +} + +private struct EmojiAttachmentLoaderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any AttachmentLoader = .emoji() +} + extension EnvironmentValues { - @Entry var imageAttachmentLoader: any AttachmentLoader = .image() - @Entry var emojiAttachmentLoader: any AttachmentLoader = .emoji() + var imageAttachmentLoader: any AttachmentLoader { + get { self[ImageAttachmentLoaderKey.self] } + set { self[ImageAttachmentLoaderKey.self] = newValue } + } + + var emojiAttachmentLoader: any AttachmentLoader { + get { self[EmojiAttachmentLoaderKey.self] } + set { self[EmojiAttachmentLoaderKey.self] = newValue } + } } diff --git a/Sources/Textual/EmojiProperties.swift b/Sources/Textual/EmojiProperties.swift index 330bd641..6def2c97 100644 --- a/Sources/Textual/EmojiProperties.swift +++ b/Sources/Textual/EmojiProperties.swift @@ -25,6 +25,13 @@ public struct EmojiProperties: Sendable, Hashable { } } +private struct EmojiPropertiesKey: EnvironmentKey { + static let defaultValue = EmojiProperties() +} + extension EnvironmentValues { - @Entry var emojiProperties = EmojiProperties() + var emojiProperties: EmojiProperties { + get { self[EmojiPropertiesKey.self] } + set { self[EmojiPropertiesKey.self] = newValue } + } } diff --git a/Sources/Textual/InlineText/InlineStyle.swift b/Sources/Textual/InlineText/InlineStyle.swift index 8c369b55..619cf7e2 100644 --- a/Sources/Textual/InlineText/InlineStyle.swift +++ b/Sources/Textual/InlineText/InlineStyle.swift @@ -77,7 +77,14 @@ public struct InlineStyle: Sendable, Hashable { } } +private struct InlineStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: InlineStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var inlineStyle: InlineStyle = .default + var inlineStyle: InlineStyle { + get { self[InlineStyleKey.self] } + set { self[InlineStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/Internal/Attachment/AttachmentAttribute.swift b/Sources/Textual/Internal/Attachment/AttachmentAttribute.swift index e8f80959..5f58ca42 100644 --- a/Sources/Textual/Internal/Attachment/AttachmentAttribute.swift +++ b/Sources/Textual/Internal/Attachment/AttachmentAttribute.swift @@ -13,6 +13,7 @@ import SwiftUI // `Text.Layout.Run` exposes lightweight accessors so overlay code can discover attachments without // reaching back into the original attributed string. +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) struct AttachmentAttribute: TextAttribute { var attachment: AnyAttachment var presentationIntent: PresentationIntent? @@ -23,6 +24,7 @@ struct AttachmentAttribute: TextAttribute { } } +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Text.Layout.Run { var attachment: AnyAttachment? { self[AttachmentAttribute.self]?.attachment diff --git a/Sources/Textual/Internal/Attachment/AttachmentOverlay.swift b/Sources/Textual/Internal/Attachment/AttachmentOverlay.swift index 02704580..48fbe23d 100644 --- a/Sources/Textual/Internal/Attachment/AttachmentOverlay.swift +++ b/Sources/Textual/Internal/Attachment/AttachmentOverlay.swift @@ -17,17 +17,21 @@ struct AttachmentOverlay: ViewModifier { } 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 - ) + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + 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 + ) + } } } - } + } else { + content + } } } diff --git a/Sources/Textual/Internal/Attachment/AttachmentView.swift b/Sources/Textual/Internal/Attachment/AttachmentView.swift index 5cb50ee8..55eadb2b 100644 --- a/Sources/Textual/Internal/Attachment/AttachmentView.swift +++ b/Sources/Textual/Internal/Attachment/AttachmentView.swift @@ -12,6 +12,7 @@ import SwiftUI // On macOS, when text selection is enabled, object-style attachments are dimmed when they fall // inside the selected range. Inline-style attachments (for example, emoji) are not dimmed. +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) struct AttachmentView: View { #if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) @Environment(TextSelectionModel.self) private var textSelectionModel: TextSelectionModel? diff --git a/Sources/Textual/Internal/Backporting/CGSize+Hashable.swift b/Sources/Textual/Internal/Backporting/CGSize+Hashable.swift new file mode 100644 index 00000000..88377e26 --- /dev/null +++ b/Sources/Textual/Internal/Backporting/CGSize+Hashable.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@available(iOS, introduced: 13.0, obsoleted: 18.0, message: "CGSize is Hashable by default in iOS 18") +@available(macOS, introduced: 10.15, obsoleted: 15.0, message: "CGSize is Hashable by default in macOS 15") +@available(watchOS, introduced: 6.0, obsoleted: 11.0, message: "CGSize is Hashable by default in watchOS 11") +@available(tvOS, introduced: 13.0, obsoleted: 18.0, message: "CGSize is Hashable by default in tvOS 18") +@available(visionOS, introduced: 1.0, obsoleted: 2.0, message: "CGSize is Hashable by default in visionOS 2") +extension CGSize: @retroactive Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(width) + hasher.combine(height) + } +} diff --git a/Sources/Textual/Internal/Backporting/SubviewGroup.swift b/Sources/Textual/Internal/Backporting/SubviewGroup.swift new file mode 100644 index 00000000..40e3f171 --- /dev/null +++ b/Sources/Textual/Internal/Backporting/SubviewGroup.swift @@ -0,0 +1,33 @@ +import SwiftUI + +public struct SubviewGroup: View { + private let content: AnyView + private let transform: (_VariadicView.Children) -> Result + + public init( + subviews content: Content, + @ViewBuilder transform: @escaping (_VariadicView.Children) -> Result + ) { + self.content = AnyView(content) + self.transform = transform + } + + public var body: some View { + _VariadicView.Tree( + VariadicRoot(transform: transform) + ) { + content + } + } +} + +private struct VariadicRoot: _VariadicView + .MultiViewRoot +{ + let transform: (_VariadicView.Children) -> Result + + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { + transform(children) + } +} diff --git a/Sources/Textual/Internal/StructuredText/BlockVStack.swift b/Sources/Textual/Internal/StructuredText/BlockVStack.swift index fa680126..d83d5d5f 100644 --- a/Sources/Textual/Internal/StructuredText/BlockVStack.swift +++ b/Sources/Textual/Internal/StructuredText/BlockVStack.swift @@ -20,7 +20,7 @@ extension StructuredText { } var body: some View { - Group(subviews: content) { children in + SubviewGroup(subviews: content) { children in BlockVStackLayout(textAlignment: textAlignment) { ForEach(children) { BlockLayoutView($0) @@ -53,7 +53,8 @@ extension StructuredText { content .onPreferenceChange(BlockSpacingKey.self) { @MainActor value in // Override with the resolved list item spacing if enabled - blockSpacing = listItemSpacingEnabled ? resolvedListItemSpacing : value + blockSpacing = + listItemSpacingEnabled ? resolvedListItemSpacing : value } .layoutValue(key: BlockSpacingKey.self, value: blockSpacing) } @@ -83,7 +84,8 @@ extension StructuredText { func sizeThatFits( proposal: ProposedViewSize, - subviews: Subviews, cache: inout Cache + subviews: Subviews, + cache: inout Cache ) -> CGSize { if let width = proposal.width, width <= 0 { return .zero @@ -92,7 +94,9 @@ extension StructuredText { var size = CGSize.zero for view in subviews { - let viewSize = view.sizeThatFits(.init(width: proposal.width, height: nil)) + let viewSize = view.sizeThatFits( + .init(width: proposal.width, height: nil) + ) size.height += viewSize.height size.width = max(size.width, viewSize.width) } @@ -105,7 +109,8 @@ extension StructuredText { func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, - subviews: Subviews, cache: inout Cache + subviews: Subviews, + cache: inout Cache ) { var currentY: CGFloat = 0 diff --git a/Sources/Textual/Internal/StructuredText/ListEnvironment.swift b/Sources/Textual/Internal/StructuredText/ListEnvironment.swift index 55eff84a..a0e04fc3 100644 --- a/Sources/Textual/Internal/StructuredText/ListEnvironment.swift +++ b/Sources/Textual/Internal/StructuredText/ListEnvironment.swift @@ -1,7 +1,30 @@ import SwiftUI +private struct ListItemSpacingKey: EnvironmentKey { + static let defaultValue: FontScaled = .fontScaled(top: 0.25) +} + +private struct ResolvedListItemSpacingKey: EnvironmentKey { + static let defaultValue: StructuredText.BlockSpacing = .init() +} + +private struct ListItemSpacingEnabledKey: EnvironmentKey { + static let defaultValue: Bool = false +} + extension EnvironmentValues { - @Entry var listItemSpacing: FontScaled = .fontScaled(top: 0.25) - @Entry var resolvedListItemSpacing: StructuredText.BlockSpacing = .init() - @Entry var listItemSpacingEnabled: Bool = false + var listItemSpacing: FontScaled { + get { self[ListItemSpacingKey.self] } + set { self[ListItemSpacingKey.self] = newValue } + } + + var resolvedListItemSpacing: StructuredText.BlockSpacing { + get { self[ResolvedListItemSpacingKey.self] } + set { self[ResolvedListItemSpacingKey.self] = newValue } + } + + var listItemSpacingEnabled: Bool { + get { self[ListItemSpacingEnabledKey.self] } + set { self[ListItemSpacingEnabledKey.self] = newValue } + } } diff --git a/Sources/Textual/Internal/TextFragment/TextBuilder.swift b/Sources/Textual/Internal/TextFragment/TextBuilder.swift index 15aef01a..bd4eb6c1 100644 --- a/Sources/Textual/Internal/TextFragment/TextBuilder.swift +++ b/Sources/Textual/Internal/TextFragment/TextBuilder.swift @@ -78,19 +78,23 @@ extension Text { // Create placeholder text = Text(placeholderSize: size) .baselineOffset(key.attachment.baselineOffset(in: runEnvironment)) - .customAttribute( + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + text = text.customAttribute( AttachmentAttribute( key.attachment, presentationIntent: run.presentationIntent ) ) + } } else { text = Text(AttributedString(attributedString[run.range])) } // Add link attribute for TextLinkInteraction if let link = run.link { - text = text.customAttribute(LinkAttribute(link)) + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + text = text.customAttribute(LinkAttribute(link)) + } } return text diff --git a/Sources/Textual/Internal/TextFragment/TextFragment.swift b/Sources/Textual/Internal/TextFragment/TextFragment.swift index 62a534f3..9e11e053 100644 --- a/Sources/Textual/Internal/TextFragment/TextFragment.swift +++ b/Sources/Textual/Internal/TextFragment/TextFragment.swift @@ -36,8 +36,7 @@ struct TextFragment: View { } var body: some View { - text - .customAttribute(TextFragmentAttribute()) + taggedText .onGeometryChange(for: CGSize?.self, of: \.textContainerSize) { size in guard let size, let textBuilder else { return } textBuilder.sizeChanged(size, environment: textEnvironment) @@ -50,14 +49,23 @@ struct TextFragment: View { .modifier(TextLinkInteraction()) } + @ViewBuilder private var taggedText: some View { + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + text.customAttribute(TextFragmentAttribute()) + } else { + text + } + } + private var text: Text { textBuilder?.text ?? Text(verbatim: "") } } -struct TextFragmentAttribute: TextAttribute { -} +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) +struct TextFragmentAttribute: TextAttribute {} +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Text.Layout { var isTextFragment: Bool { first?.first?[TextFragmentAttribute.self] != nil diff --git a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift index d6e8e39d..6c9883ef 100644 --- a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift +++ b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift @@ -10,6 +10,7 @@ // manages selection gestures, keyboard-driven updates, and context menus while SwiftUI renders // the text. + @available(macOS 15, *) struct AppKitTextInteractionOverlay: NSViewRepresentable { private let model: TextSelectionModel private let overflowFrames: [CGRect] diff --git a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift index 9af28716..d23d5ece 100644 --- a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift +++ b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift @@ -10,8 +10,10 @@ // `NSView` that handles selection gestures and context menus. The modifier also manages cursor updates, // switching between I-beam and pointing hand based on hover position over text or links. + @available(macOS 15, *) typealias PlatformTextSelectionInteraction = AppKitTextSelectionInteraction + @available(macOS 15, *) struct AppKitTextSelectionInteraction: ViewModifier { @State private var cursorPushed = false @@ -34,7 +36,8 @@ } } - private func updateCursor(for phase: HoverPhase, model: TextSelectionModel) { + private func updateCursor(for phase: HoverPhase, model: TextSelectionModel) + { switch phase { case .active(let location): let cursor = diff --git a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift index 2c793a89..e2a833fe 100644 --- a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift +++ b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift @@ -9,6 +9,7 @@ // `TextSelectionModel` from the environment, computes selection rectangles for the current // range within this layout, and paints them in a `Canvas` behind the text. + @available(macOS 15, *) struct AppKitTextSelectionView: View { @Environment(TextSelectionModel.self) private var textSelectionModel: TextSelectionModel? @State private var selectionRects: [TextSelectionRect] = [] diff --git a/Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift b/Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift index f1b06f7a..efded8c1 100644 --- a/Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift +++ b/Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift @@ -10,6 +10,7 @@ // so embedded scrollable regions continue to receive input events. Link taps are forwarded to // `openURL`. + @available(macOS 15, *) final class NSTextInteractionView: NSView { var model: TextSelectionModel var exclusionRects: [CGRect] @@ -290,6 +291,7 @@ } } + @available(macOS 15, *) extension NSTextInteractionView: NSUserInterfaceValidations { func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { switch item.action { diff --git a/Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift b/Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift index 20d73e3c..907a2512 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift @@ -1,5 +1,6 @@ import SwiftUI +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) struct LinkAttribute: TextAttribute { var url: URL @@ -8,6 +9,7 @@ struct LinkAttribute: TextAttribute { } } +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) extension Text.Layout.Run { var url: URL? { self[LinkAttribute.self]?.url diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift index c8fee006..1bf43c21 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import SwiftUI + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) struct EmptyTextLayoutCollection: TextLayoutCollection { var layouts: [any TextLayout] { [] diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift index a23a05a3..78039c7e 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift @@ -2,6 +2,7 @@ import CoreText import SwiftUI + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Text.Layout { struct Contents { let lineFragments: [NSTextLineFragment] @@ -35,6 +36,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Text.Layout.Line { var lineFragment: NSTextLineFragment? { let mirror = Mirror(reflecting: self) @@ -47,6 +49,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Text.Layout.Run { var characterRanges: [Range] { guard let ctRun else { return [] } @@ -110,6 +113,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension NSAttributedString { fileprivate func applyingAttachments(in lines: [Text.Layout.Line]) -> NSAttributedString { guard lines.containsAttachments else { @@ -138,6 +142,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Array where Element == Text.Layout.Line { fileprivate var containsAttachments: Bool { self.contains { line in diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift index 30988f32..6c08e956 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import SwiftUI + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) final class LiveTextLayoutCollection: TextLayoutCollection { private(set) lazy var layouts: [any TextLayout] = makeLayouts() @@ -40,6 +41,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) final class LiveTextLayout: TextLayout { var attributedString: NSAttributedString { joinedAttributedString.joined @@ -95,6 +97,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) final class LiveTextLine: TextLine { var origin: CGPoint { base.origin @@ -134,6 +137,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) final class LiveTextRun: TextRun { var layoutDirection: LayoutDirection { base.layoutDirection @@ -167,6 +171,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) struct EmptyRun: TextRun { let layoutDirection: LayoutDirection = .localeBased() let typographicBounds: CGRect @@ -178,6 +183,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) final class LiveTextRunSlice: TextRunSlice { var typographicBounds: CGRect { base.typographicBounds.rect @@ -192,6 +198,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) struct EmptyRunSlice: TextRunSlice { let typographicBounds: CGRect let characterRange: Range diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift index fdcac4d6..c383430a 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import SwiftUI + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { var stringLength: Int { layouts.map(\.attributedString.length).reduce(0, +) @@ -38,8 +39,8 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayout { - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func wordRange(containing characterIndex: Int) -> NSRange? { @@ -57,8 +58,8 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension NSAttributedString { - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func nextWord(from characterIndex: Int) -> Int { @@ -69,7 +70,6 @@ #endif } - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func previousWord(from characterIndex: Int) -> Int { diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift index 45c83a49..abb4e71c 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import SwiftUI + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { func url(for point: CGPoint) -> URL? { guard let layout = layouts.first(where: { $0.frame.contains(point) }) else { @@ -219,6 +220,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { fileprivate func closestPosition( to x: CGFloat, @@ -278,6 +280,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayout { fileprivate func lineIndex(closestToY y: CGFloat) -> Int { var closestIndex = 0 @@ -293,6 +296,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLine { fileprivate func runIndex(closestToX x: CGFloat) -> Int { var closestIndex = 0 @@ -308,6 +312,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextRun { fileprivate func sliceIndex(closestToX x: CGFloat) -> Int { var closestIndex = 0 @@ -323,6 +328,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension CGRect { fileprivate func leadingEdgeX(for layoutDirection: LayoutDirection) -> CGFloat { layoutDirection == .leftToRight ? minX : maxX diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift index 1a7090d3..b6acd640 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import SwiftUI + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { var startPosition: TextPosition { TextPosition( @@ -159,7 +160,6 @@ return TextRange(start: start, end: end) } - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func nextWord(from position: TextPosition) -> TextPosition? { @@ -188,7 +188,6 @@ ) } - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func previousWord(from position: TextPosition) -> TextPosition? { @@ -296,6 +295,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { fileprivate func reconcilePosition( _ position: TextPosition, diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift index fe7bc9a0..d287a3bd 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift @@ -1,8 +1,8 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import SwiftUI + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func wordRange(for position: TextPosition) -> TextRange? { diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift index 1e83a112..37f00abd 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import Foundation + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { func indexPathsForRunSlices(in range: TextRange) -> some Sequence { IndexPathSequence( @@ -11,6 +12,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { fileprivate func indexPathForRunSlice(after indexPath: IndexPath) -> IndexPath? { let layout = layouts[indexPath.layout] diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift index 4c262afe..0cfc47ff 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift @@ -11,6 +11,7 @@ // and trailing spans to caret positions. Finally, it “inflates” per-line rectangles to fill any // vertical gaps between lines so selection highlights appear as a single block. + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayoutCollection { func selectionRects(for range: TextRange, layout: Text.Layout) -> [TextSelectionRect] { guard @@ -76,6 +77,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextSelectionRect { fileprivate struct Builder { let start: IndexPath? @@ -149,6 +151,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Array where Element == [TextSelectionRect] { fileprivate mutating func inflate() { var previousMaxY: CGFloat? = nil @@ -174,6 +177,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension Array where Element == TextSelectionRect { fileprivate func index(containing caretX: CGFloat) -> Int? { firstIndex { @@ -182,6 +186,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextSelectionRect { fileprivate mutating func trimLeading(to caretX: CGFloat) { if layoutDirection == .leftToRight { diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift index f10ffa67..652869d6 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift @@ -1,7 +1,8 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION import SwiftUI - protocol TextLayoutCollection { +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) +protocol TextLayoutCollection { var layouts: [any TextLayout] { get } func isEqual(to other: any TextLayoutCollection) -> Bool @@ -9,7 +10,8 @@ func index(of layout: Text.Layout) -> Int? } - struct AnyTextLayoutCollection: TextLayoutCollection, Equatable { +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) +struct AnyTextLayoutCollection: TextLayoutCollection, Equatable { private let base: any TextLayoutCollection init(_ base: any TextLayoutCollection) { @@ -37,6 +39,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) protocol TextLayout { var attributedString: NSAttributedString { get } var origin: CGPoint { get } @@ -44,6 +47,7 @@ var lines: [any TextLine] { get } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextLayout { var frame: CGRect { bounds.offsetBy(dx: origin.x, dy: origin.y) @@ -54,12 +58,14 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) protocol TextLine { var origin: CGPoint { get } var typographicBounds: CGRect { get } var runs: [any TextRun] { get } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) protocol TextRun { var layoutDirection: LayoutDirection { get } var typographicBounds: CGRect { get } @@ -67,6 +73,7 @@ var slices: [any TextRunSlice] { get } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) protocol TextRunSlice { var typographicBounds: CGRect { get } var characterRange: Range { get } diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift index a1a8d067..2e03eeb7 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift @@ -10,6 +10,7 @@ // concrete origins. Platform interactions and selection rendering use the collection for hit // testing, position mapping, and selection rectangle computation. + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension View { func overlayTextLayoutCollection( @ViewBuilder content: @escaping (any TextLayoutCollection) -> some View diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift index 4db5ea65..fd6b078d 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift @@ -14,27 +14,32 @@ struct TextLinkInteraction: ViewModifier { 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 + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + 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 + } #else content #endif } #if TEXTUAL_ENABLE_LINKS + @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) private func tap(origin: CGPoint, layout: Text.Layout) -> some Gesture { SpatialTapGesture() .onEnded { value in diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift index 37f3698e..772d2118 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift @@ -11,17 +11,21 @@ import SwiftUI struct TextSelectionBackground: ViewModifier { func body(content: Content) -> some View { #if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) && !targetEnvironment(macCatalyst) - content - .backgroundPreferenceValue(Text.LayoutKey.self) { value in - if let anchoredLayout = value.first { - GeometryReader { geometry in - AppKitTextSelectionView( - layout: anchoredLayout.layout, - origin: geometry[anchoredLayout.origin] - ) + if #available(macOS 15, *) { + content + .backgroundPreferenceValue(Text.LayoutKey.self) { value in + if let anchoredLayout = value.first { + GeometryReader { geometry in + AppKitTextSelectionView( + layout: anchoredLayout.layout, + origin: geometry[anchoredLayout.origin] + ) + } } } - } + } else { + content + } #else content #endif diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift index 49304374..ef249a23 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift @@ -13,6 +13,7 @@ import SwiftUI // local and non-scrollable regions. #if TEXTUAL_ENABLE_TEXT_SELECTION + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) @Observable final class TextSelectionCoordinator { private var models: [WeakBox] = [] @@ -39,15 +40,26 @@ import SwiftUI #endif struct TextSelectionCoordination: ViewModifier { - #if TEXTUAL_ENABLE_TEXT_SELECTION - @State private var coordinator = TextSelectionCoordinator() - #endif - func body(content: Content) -> some View { #if TEXTUAL_ENABLE_TEXT_SELECTION - content.environment(coordinator) + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + content.modifier(TextSelectionCoordinationBody()) + } else { + content + } #else content #endif } } + +#if TEXTUAL_ENABLE_TEXT_SELECTION + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + private struct TextSelectionCoordinationBody: ViewModifier { + @State private var coordinator = TextSelectionCoordinator() + + func body(content: Content) -> some View { + content.environment(coordinator) + } + } +#endif diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift index 6fe2eb27..36e93caa 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift @@ -12,15 +12,28 @@ import SwiftUI // remain independent. struct TextSelectionInteraction: ViewModifier { - #if TEXTUAL_ENABLE_TEXT_SELECTION + func body(content: Content) -> some View { + #if TEXTUAL_ENABLE_TEXT_SELECTION + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + content.modifier(TextSelectionInteractionBody()) + } else { + content + } + #else + content + #endif + } +} + +#if TEXTUAL_ENABLE_TEXT_SELECTION + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) + private struct TextSelectionInteractionBody: ViewModifier { @Environment(\.textSelection) private var textSelection @Environment(TextSelectionCoordinator.self) private var coordinator: TextSelectionCoordinator? @State private var model = TextSelectionModel() - #endif - func body(content: Content) -> some View { - #if TEXTUAL_ENABLE_TEXT_SELECTION + func body(content: Content) -> some View { if textSelection.allowsSelection { content .overlayTextLayoutCollection { layoutCollection in @@ -34,17 +47,20 @@ struct TextSelectionInteraction: ViewModifier { } else { content } - #else - content - #endif + } + } + + private struct TextSelectionKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any TextSelectability.Type = DisabledTextSelectability.self } -} -#if TEXTUAL_ENABLE_TEXT_SELECTION extension EnvironmentValues { @available(tvOS, unavailable) @available(watchOS, unavailable) @usableFromInline - @Entry var textSelection: any TextSelectability.Type = DisabledTextSelectability.self + var textSelection: any TextSelectability.Type { + get { self[TextSelectionKey.self] } + set { self[TextSelectionKey.self] = newValue } + } } #endif diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift index b7baae00..573585ce 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift @@ -11,6 +11,7 @@ // changes, the model attempts to reconcile the current selection into the new layout so the // selection stays stable across updates. + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) @Observable final class TextSelectionModel { var selectedRange: TextRange? { @@ -85,6 +86,7 @@ } } + @available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) extension TextSelectionModel { var hasText: Bool { layoutCollection.stringLength > 0 @@ -169,21 +171,18 @@ layoutCollection.blockRange(for: position) } - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func wordRange(for position: TextPosition) -> TextRange? { layoutCollection.wordRange(for: position) } - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func nextWord(from position: TextPosition) -> TextPosition? { layoutCollection.nextWord(from: position) } - @available(macOS 10.0, *) @available(iOS, unavailable) @available(visionOS, unavailable) func previousWord(from position: TextPosition) -> TextPosition? { diff --git a/Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift b/Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift index d65f07cf..afe217c6 100644 --- a/Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift +++ b/Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) import UIKit + @available(iOS 18, *) final class TextSelectionRectBox: UITextSelectionRect { private let wrappedValue: TextSelectionRect diff --git a/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift b/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift index e1f19123..320de366 100644 --- a/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift +++ b/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift @@ -10,6 +10,7 @@ // a `UITextInteraction` configured for selection and implements the `UITextInput` surface that // UIKit uses for selection behavior. + @available(iOS 18, *) struct UIKitTextInteractionOverlay: UIViewRepresentable { private let model: TextSelectionModel private let overflowFrames: [CGRect] diff --git a/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift b/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift index 03313f21..ce6fb500 100644 --- a/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift +++ b/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift @@ -10,8 +10,10 @@ // integrates with system edit actions (copy/share). SwiftUI continues to render the text while // UIKit manages the selection interaction. - typealias PlatformTextSelectionInteraction = UIKitTextSelectionInteraction +@available(iOS 18, *) +typealias PlatformTextSelectionInteraction = UIKitTextSelectionInteraction + @available(iOS 18, *) struct UIKitTextSelectionInteraction: ViewModifier { private let model: TextSelectionModel diff --git a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift index 1d9cfb77..7fd9d8b4 100644 --- a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift +++ b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift @@ -1,6 +1,7 @@ #if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) import SwiftUI + @available(iOS 18, *) extension UITextInteractionView: UITextInput { var hasText: Bool { model.hasText diff --git a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift index 59ffbbad..952ca923 100644 --- a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift +++ b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift @@ -13,6 +13,7 @@ // By clamping word and sentence operations to the current block range reported by the model, the // selection behavior matches the document’s structure. + @available(iOS 18, *) extension UITextInteractionView: UITextInputTokenizer { func rangeEnclosingPosition( _ position: UITextPosition, diff --git a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift index 8bf49609..fa672b01 100644 --- a/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift +++ b/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift @@ -12,6 +12,7 @@ // respects `exclusionRects` so embedded scrollable regions can continue to handle gestures. // Selection UI is provided by `UITextInteraction` configured for non-editable content. + @available(iOS 18, *) final class UITextInteractionView: UIView { override var canBecomeFirstResponder: Bool { true @@ -145,6 +146,7 @@ } } + @available(iOS 18, *) extension UITextInteractionView: UITextInteractionDelegate { func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { logger.debug("interactionShouldBegin(at: \(point.logDescription)) -> true") diff --git a/Sources/Textual/MathProperties.swift b/Sources/Textual/MathProperties.swift index b0d2ae9b..0fbba982 100644 --- a/Sources/Textual/MathProperties.swift +++ b/Sources/Textual/MathProperties.swift @@ -50,6 +50,13 @@ extension MathProperties.FontName { public static let leteSans: Self = .init(rawValue: Math.Font.Name.leteSans.rawValue) } +private struct MathPropertiesKey: EnvironmentKey { + static let defaultValue = MathProperties() +} + extension EnvironmentValues { - @Entry var mathProperties = MathProperties() + var mathProperties: MathProperties { + get { self[MathPropertiesKey.self] } + set { self[MathPropertiesKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift b/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift index 2ecf4130..d2036f55 100644 --- a/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift +++ b/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift @@ -31,7 +31,14 @@ extension StructuredText { } } +private struct HighlighterThemeKey: EnvironmentKey { + static let defaultValue: StructuredText.HighlighterTheme = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var highlighterTheme: StructuredText.HighlighterTheme = .default + var highlighterTheme: StructuredText.HighlighterTheme { + get { self[HighlighterThemeKey.self] } + set { self[HighlighterThemeKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift b/Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift index ea118f01..adcfa481 100644 --- a/Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift +++ b/Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift @@ -15,7 +15,14 @@ extension StructuredText { } } +private struct BlockQuoteStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.BlockQuoteStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var blockQuoteStyle: any StructuredText.BlockQuoteStyle = .default + var blockQuoteStyle: any StructuredText.BlockQuoteStyle { + get { self[BlockQuoteStyleKey.self] } + set { self[BlockQuoteStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/CodeBlockStyle.swift b/Sources/Textual/StructuredText/Style/CodeBlockStyle.swift index ca1406f1..04edd8ef 100644 --- a/Sources/Textual/StructuredText/Style/CodeBlockStyle.swift +++ b/Sources/Textual/StructuredText/Style/CodeBlockStyle.swift @@ -37,7 +37,14 @@ extension StructuredText { } } +private struct CodeBlockStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.CodeBlockStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var codeBlockStyle: any StructuredText.CodeBlockStyle = .default + var codeBlockStyle: any StructuredText.CodeBlockStyle { + get { self[CodeBlockStyleKey.self] } + set { self[CodeBlockStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/HeadingStyle.swift b/Sources/Textual/StructuredText/Style/HeadingStyle.swift index 60fbb433..28a1a87d 100644 --- a/Sources/Textual/StructuredText/Style/HeadingStyle.swift +++ b/Sources/Textual/StructuredText/Style/HeadingStyle.swift @@ -33,7 +33,14 @@ extension StructuredText { } } +private struct HeadingStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.HeadingStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var headingStyle: any StructuredText.HeadingStyle = .default + var headingStyle: any StructuredText.HeadingStyle { + get { self[HeadingStyleKey.self] } + set { self[HeadingStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/ListItemStyle.swift b/Sources/Textual/StructuredText/Style/ListItemStyle.swift index 1e4d1f8d..927e3eb3 100644 --- a/Sources/Textual/StructuredText/Style/ListItemStyle.swift +++ b/Sources/Textual/StructuredText/Style/ListItemStyle.swift @@ -45,7 +45,14 @@ extension StructuredText { } } +private struct ListItemStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.ListItemStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var listItemStyle: any StructuredText.ListItemStyle = .default + var listItemStyle: any StructuredText.ListItemStyle { + get { self[ListItemStyleKey.self] } + set { self[ListItemStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/OrderedListMarker.swift b/Sources/Textual/StructuredText/Style/OrderedListMarker.swift index 30bdaeb7..11098db6 100644 --- a/Sources/Textual/StructuredText/Style/OrderedListMarker.swift +++ b/Sources/Textual/StructuredText/Style/OrderedListMarker.swift @@ -23,9 +23,16 @@ extension StructuredText { } } +private struct OrderedListMarkerKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.OrderedListMarker = .decimal +} + extension EnvironmentValues { @usableFromInline - @Entry var orderedListMarker: any StructuredText.OrderedListMarker = .decimal + var orderedListMarker: any StructuredText.OrderedListMarker { + get { self[OrderedListMarkerKey.self] } + set { self[OrderedListMarkerKey.self] = newValue } + } } // MARK: - Decimal diff --git a/Sources/Textual/StructuredText/Style/Overflow.swift b/Sources/Textual/StructuredText/Style/Overflow.swift index e5a4e6e5..8aea3d9b 100644 --- a/Sources/Textual/StructuredText/Style/Overflow.swift +++ b/Sources/Textual/StructuredText/Style/Overflow.swift @@ -1,7 +1,7 @@ import SwiftUI /// Controls how content behaves when it overflows horizontally. -public enum OverflowMode: Hashable { +public enum OverflowMode: Hashable, Sendable { /// Wraps content to fit the available width. case wrap /// Allows horizontal scrolling. @@ -71,14 +71,10 @@ public struct Overflow: View { } // Make text selection local in scrollable regions .modifier(TextSelectionInteraction()) - .transformPreference(Text.LayoutKey.self) { value in - value = [] - } + .modifier(SuppressTextLayoutPreferenceModifier()) } } - .onScrollGeometryChange(for: CGFloat.self, of: \.containerSize.width) { - containerWidth = $1 - } + .modifier(ScrollContainerWidthModifier(containerWidth: $containerWidth)) // Propagate gesture exclusion area .background( GeometryReader { geometry in @@ -93,7 +89,47 @@ public struct Overflow: View { } } +private struct OverflowModeKey: EnvironmentKey { + static let defaultValue: OverflowMode = .scroll +} + +private struct ScrollContainerWidthModifier: ViewModifier { + @Binding var containerWidth: CGFloat? + + func body(content: Content) -> some View { + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + content.onScrollGeometryChange(for: CGFloat.self, of: \.containerSize.width) { + containerWidth = $1 + } + } else { + content + } + } +} + +private struct SuppressTextLayoutPreferenceModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { + content.modifier(SuppressTextLayoutPreferenceModifierBody()) + } else { + content + } + } +} + +@available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) +private struct SuppressTextLayoutPreferenceModifierBody: ViewModifier { + func body(content: Content) -> some View { + content.transformPreference(Text.LayoutKey.self) { value in + value = [] + } + } +} + extension EnvironmentValues { @usableFromInline - @Entry var overflowMode = OverflowMode.scroll + var overflowMode: OverflowMode { + get { self[OverflowModeKey.self] } + set { self[OverflowModeKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/ParagraphStyle.swift b/Sources/Textual/StructuredText/Style/ParagraphStyle.swift index 33f09a79..5a5f56ee 100644 --- a/Sources/Textual/StructuredText/Style/ParagraphStyle.swift +++ b/Sources/Textual/StructuredText/Style/ParagraphStyle.swift @@ -15,7 +15,14 @@ extension StructuredText { } } +private struct ParagraphStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.ParagraphStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var paragraphStyle: any StructuredText.ParagraphStyle = .default + var paragraphStyle: any StructuredText.ParagraphStyle { + get { self[ParagraphStyleKey.self] } + set { self[ParagraphStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/TableCellStyle.swift b/Sources/Textual/StructuredText/Style/TableCellStyle.swift index 52c343cf..c8fbc780 100644 --- a/Sources/Textual/StructuredText/Style/TableCellStyle.swift +++ b/Sources/Textual/StructuredText/Style/TableCellStyle.swift @@ -35,7 +35,14 @@ extension StructuredText { } } +private struct TableCellStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.TableCellStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var tableCellStyle: any StructuredText.TableCellStyle = .default + var tableCellStyle: any StructuredText.TableCellStyle { + get { self[TableCellStyleKey.self] } + set { self[TableCellStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/TableStyle.swift b/Sources/Textual/StructuredText/Style/TableStyle.swift index 1d84d43b..5c7a8400 100644 --- a/Sources/Textual/StructuredText/Style/TableStyle.swift +++ b/Sources/Textual/StructuredText/Style/TableStyle.swift @@ -33,7 +33,14 @@ extension StructuredText { } } +private struct TableStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.TableStyle = .default +} + extension EnvironmentValues { @usableFromInline - @Entry var tableStyle: any StructuredText.TableStyle = .default + var tableStyle: any StructuredText.TableStyle { + get { self[TableStyleKey.self] } + set { self[TableStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift b/Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift index 1b6c9430..5625c309 100644 --- a/Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift +++ b/Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift @@ -15,7 +15,14 @@ extension StructuredText { } } +private struct ThematicBreakStyleKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.ThematicBreakStyle = .divider +} + extension EnvironmentValues { @usableFromInline - @Entry var thematicBreakStyle: any StructuredText.ThematicBreakStyle = .divider + var thematicBreakStyle: any StructuredText.ThematicBreakStyle { + get { self[ThematicBreakStyleKey.self] } + set { self[ThematicBreakStyleKey.self] = newValue } + } } diff --git a/Sources/Textual/StructuredText/Style/UnorderedListMarker.swift b/Sources/Textual/StructuredText/Style/UnorderedListMarker.swift index a6e51fe7..31db9597 100644 --- a/Sources/Textual/StructuredText/Style/UnorderedListMarker.swift +++ b/Sources/Textual/StructuredText/Style/UnorderedListMarker.swift @@ -21,9 +21,16 @@ extension StructuredText { } } +private struct UnorderedListMarkerKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: any StructuredText.UnorderedListMarker = .disc +} + extension EnvironmentValues { @usableFromInline - @Entry var unorderedListMarker: any StructuredText.UnorderedListMarker = .disc + var unorderedListMarker: any StructuredText.UnorderedListMarker { + get { self[UnorderedListMarkerKey.self] } + set { self[UnorderedListMarkerKey.self] = newValue } + } } // MARK: - Symbol