Skip to content
Open
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
10 changes: 5 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
19 changes: 17 additions & 2 deletions Sources/Textual/Attachment/AttachmentLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
9 changes: 8 additions & 1 deletion Sources/Textual/EmojiProperties.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
9 changes: 8 additions & 1 deletion Sources/Textual/InlineText/InlineStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
2 changes: 2 additions & 0 deletions Sources/Textual/Internal/Attachment/AttachmentAttribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down
24 changes: 14 additions & 10 deletions Sources/Textual/Internal/Attachment/AttachmentOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
1 change: 1 addition & 0 deletions Sources/Textual/Internal/Attachment/AttachmentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
13 changes: 13 additions & 0 deletions Sources/Textual/Internal/Backporting/CGSize+Hashable.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
33 changes: 33 additions & 0 deletions Sources/Textual/Internal/Backporting/SubviewGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import SwiftUI

public struct SubviewGroup<Result: View>: View {
private let content: AnyView
private let transform: (_VariadicView.Children) -> Result

public init<Content: View>(
Comment on lines +3 to +7
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<Result: View>: _VariadicView
.MultiViewRoot
{
let transform: (_VariadicView.Children) -> Result

@ViewBuilder
func body(children: _VariadicView.Children) -> some View {
transform(children)
}
}
15 changes: 10 additions & 5 deletions Sources/Textual/Internal/StructuredText/BlockVStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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

Expand Down
29 changes: 26 additions & 3 deletions Sources/Textual/Internal/StructuredText/ListEnvironment.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
import SwiftUI

private struct ListItemSpacingKey: EnvironmentKey {
static let defaultValue: FontScaled<StructuredText.BlockSpacing> = .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<StructuredText.BlockSpacing> = .fontScaled(top: 0.25)
@Entry var resolvedListItemSpacing: StructuredText.BlockSpacing = .init()
@Entry var listItemSpacingEnabled: Bool = false
var listItemSpacing: FontScaled<StructuredText.BlockSpacing> {
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 }
}
}
8 changes: 6 additions & 2 deletions Sources/Textual/Internal/TextFragment/TextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions Sources/Textual/Internal/TextFragment/TextFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ struct TextFragment<Content: AttributedStringProtocol>: 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)
Expand All @@ -50,14 +49,23 @@ struct TextFragment<Content: AttributedStringProtocol>: 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -290,6 +291,7 @@
}
}

@available(macOS 15, *)
extension NSTextInteractionView: NSUserInterfaceValidations {
func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool {
switch item.action {
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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] {
[]
Expand Down
Loading