diff --git a/README.md b/README.md index efae63a8..af9e2204 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL ### Supported - [x] Headings (`#` … `######`) -- [x] Paragraphs with soft and hard line breaks +- [x] Paragraphs with soft and hard line breaks (adjacent headings and paragraphs share one text view, so a text selection can span them) - [x] Images (`![alt](url)`) — block-level, opt-in via the experimental `ImageConfig` (remote-allowlist, asset-catalog, and bundled-resource sources; tap to open the built-in fullscreen viewer) - [x] **Bold**, *italic*, ***bold-italic***, ~~strikethrough~~ - [x] `Inline code` diff --git a/Sources/MarkdownText/Block/Document+.swift b/Sources/MarkdownText/Block/Document+.swift index c3b4a777..a740fb1e 100644 --- a/Sources/MarkdownText/Block/Document+.swift +++ b/Sources/MarkdownText/Block/Document+.swift @@ -13,5 +13,6 @@ extension Markdown.Document { return self .blockConvertibleChildren .map { $0.convert(attributeContainer: NSAttributeContainer(), config: config) } + .groupingAdjacentTextBlocks(config: config) } } diff --git a/Sources/MarkdownText/Block/Paragraph+.swift b/Sources/MarkdownText/Block/Paragraph+.swift index d1162c04..d6f30f0e 100644 --- a/Sources/MarkdownText/Block/Paragraph+.swift +++ b/Sources/MarkdownText/Block/Paragraph+.swift @@ -26,6 +26,7 @@ extension Paragraph: BlockConvertible { } container[.foregroundColor] = MDColor(config.paragraphStyle.textColor) let paragraphContent: NSMutableAttributedString = self.buildParagraphContent(container: container, config: config) + paragraphContent.applyParagraphLayout() return MarkdownRenderable.paragraph(id: self.id, content: paragraphContent) } diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift index 71feecc7..85c9d487 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift @@ -35,7 +35,9 @@ public struct MarkdownRenderConfig: Hashable, Sendable { /// Configuration that controls code-block syntax-highlighting styling. public let codeBlockConfig: CodeBlockConfig /// Vertical spacing between adjacent blocks (paragraphs, headings, - /// code blocks, lists, etc.). Defaults to 30. + /// code blocks, lists, etc.). Defaults to 30. Adjacent top-level + /// paragraphs share one text view, so they're never spaced closer than + /// their 5-point line spacing. public let blockSpacing: CGFloat /// Configuration for the built-in "Select more text" edit-menu action and the /// modal it presents. Enabled by default. diff --git a/Sources/MarkdownText/Models/MarkdownRenderable+TextGroup.swift b/Sources/MarkdownText/Models/MarkdownRenderable+TextGroup.swift new file mode 100644 index 00000000..b32d326a --- /dev/null +++ b/Sources/MarkdownText/Models/MarkdownRenderable+TextGroup.swift @@ -0,0 +1,127 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +extension NSAttributedString.Key { + /// Library-internal: marks each block of a `.textGroup`, so its text view can + /// expose one accessibility element per block. + static let textBlock = NSAttributedString.Key("markdown.textBlock") +} + +/// The value of `.textBlock`: the block's ID, and its level if it's a heading. +struct TextBlock: Hashable { + let id: String + let headingLevel: Int? +} + +extension MarkdownRenderable { + /// Line spacing paragraphs render with; headings have none. + static let paragraphLineSpacing: CGFloat = 5 + + /// The text of headings and paragraphs: the blocks that can share a text view. + fileprivate var textBlockContent: NSMutableAttributedString? { + switch self { + case .paragraph(_, let content), .heading(_, _, let content): return content + default: return nil + } + } + + fileprivate var headingLevel: Int? { + guard case .heading(_, let level, _) = self else { return nil } + return level + } + + /// The line spacing a text block renders with. + fileprivate var lineSpacing: CGFloat { + isHeading ? 0 : Self.paragraphLineSpacing + } +} + +extension NSMutableAttributedString { + /// Applies the line spacing and alignment paragraphs render with. + func applyParagraphLayout() { + updateParagraphStyle { + $0.lineSpacing = MarkdownRenderable.paragraphLineSpacing + $0.alignment = .left + } + } +} + +extension Array where Element == MarkdownRenderable { + + /// Merges each run of adjacent non-empty headings and paragraphs into a + /// single `.textGroup`, so the run renders in one text view and a text + /// selection can span it. + /// + /// A group keeps its first block's ID, so as streaming appends blocks + /// SwiftUI keeps updating the same text view. + func groupingAdjacentTextBlocks(config: MarkdownRenderConfig) -> [MarkdownRenderable] { + var result: [MarkdownRenderable] = [] + var run: [MarkdownRenderable] = [] + + func flushRun() { + if run.count > 1 { + result.append(.textGroup(id: run[0].id, blocks: run, content: run.joinedAsTextBlocks(config: config))) + } else { + result += run + } + run.removeAll() + } + + for renderable in self { + // An empty block has no line to space from its neighbors, so it keeps its own view. + if let content = renderable.textBlockContent, content.length > 0 { + run.append(renderable) + } else { + flushRun() + result.append(renderable) + } + } + flushRun() + return result + } + + /// Joins text blocks with paragraph breaks, spaced to match the + /// `blockSpacing` gap that `BlockView` puts between separate blocks, but + /// never closer than the line spacing TextKit puts at a break. + private func joinedAsTextBlocks(config: MarkdownRenderConfig) -> NSMutableAttributedString { + let separator = NSAttributedString(string: "\n", attributes: [.font: config.paragraphStyle.textFonts.normal]) + let result = NSMutableAttributedString() + var previousLineSpacing: CGFloat = 0 + for block in self { + guard let content = block.textBlockContent else { continue } + if result.length > 0 { + result.append(separator) + } + let start = result.length + result.append(content) + result.addAttribute(.textBlock, value: TextBlock(id: block.id, headingLevel: block.headingLevel), range: NSRange(location: start, length: content.length)) + if start > 0 { + // Only the block's first line: later lines come from soft/hard breaks. + let firstLine = (content.string as NSString).paragraphRange(for: NSRange(location: 0, length: 0)) + let spacing = Swift.max(0, config.blockSpacing - lineSpacingAtBreak(after: previousLineSpacing, before: block.lineSpacing)) + result.updateParagraphStyle(in: NSRange(location: start + firstLine.location, length: firstLine.length)) { $0.paragraphSpacingBefore = spacing } + } + previousLineSpacing = block.lineSpacing + } + return result + } + + /// The line spacing TextKit already puts at a break between two blocks: the + /// next block's in TextKit 2 (UITextView), the previous block's in TextKit 1 (NSTextView). + private func lineSpacingAtBreak(after previous: CGFloat, before next: CGFloat) -> CGFloat { + #if canImport(UIKit) + return next + #else + return previous + #endif + } +} diff --git a/Sources/MarkdownText/Models/MarkdownRenderable.swift b/Sources/MarkdownText/Models/MarkdownRenderable.swift index 397f3c99..ef8edf30 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderable.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderable.swift @@ -18,6 +18,12 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { /// To be rendered as a paragraph case paragraph(id: String, content: NSMutableAttributedString) + /// Adjacent top-level headings and paragraphs rendered in a single text view + /// so a text selection can span them. `content` joins the `blocks` with + /// paragraph breaks spaced by `blockSpacing`, or by the line spacing where + /// that's larger. See `groupingAdjacentTextBlocks(config:)`. + case textGroup(id: String, blocks: [MarkdownRenderable], content: NSMutableAttributedString) + /// To be rendered as LaTeX (Math formatting) case latex(id: String, content: String) @@ -49,6 +55,7 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { var id: String { switch self { case .paragraph(let id, _): return id + case .textGroup(let id, _, _): return id case .latex(let id, _): return id case .heading(let id, _, _): return id case .orderedList(let id, _): return id @@ -74,6 +81,13 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { default: return false } } + + var isHeading: Bool { + switch self { + case .heading: return true + default: return false + } + } } struct MarkdownListItem: Equatable { diff --git a/Sources/MarkdownText/Models/RenderableDocument.swift b/Sources/MarkdownText/Models/RenderableDocument.swift index 2ef23d75..e18818c6 100644 --- a/Sources/MarkdownText/Models/RenderableDocument.swift +++ b/Sources/MarkdownText/Models/RenderableDocument.swift @@ -50,6 +50,7 @@ public struct RenderableDocument: Equatable, Sendable { attributes[.kern] = kern } let content = NSMutableAttributedString(string: plainText, attributes: attributes) + content.applyParagraphLayout() self.init(renderables: [.paragraph(id: UUID().uuidString, content: content)]) } @@ -84,6 +85,8 @@ extension MarkdownRenderable { switch self { case .paragraph(_, let content), .heading(_, _, let content): return content.string + case .textGroup(_, let blocks, _): + return blocks.compactMap { $0.plainText }.joined(separator: "\n\n") case .latex(_, let content): return content case .orderedList(_, let items): @@ -129,7 +132,7 @@ private extension BlockQuoteType { extension MarkdownRenderable { func extractAttributedStrings() -> [NSAttributedString] { switch self { - case .paragraph(_, let str): + case .paragraph(_, let str), .textGroup(_, _, let str): return [str] case .orderedList(_, let items): return items.flatMap { $0.attributedStrings() } diff --git a/Sources/MarkdownText/UI/BlockView.swift b/Sources/MarkdownText/UI/BlockView.swift index b7fa300d..79bc0317 100644 --- a/Sources/MarkdownText/UI/BlockView.swift +++ b/Sources/MarkdownText/UI/BlockView.swift @@ -38,14 +38,12 @@ struct SingleBlockView: View { var body: some View { Group { switch renderable { - case .heading(_, _, let contents): + case .heading(_, _, let contents), .paragraph(_, let contents), .textGroup(_, _, let contents): + // One branch for all text blocks, so a block that becomes a group while streaming keeps its text view. ParagraphView(contents: contents) - .transition(.opacity) - .accessibilityAddTraits(.isHeader) - case .paragraph(_, let contents): - ParagraphView(contents: contents, lineSpacing: 5) .fixedSize(horizontal: false, vertical: true) .transition(.opacity) + .accessibilityAddTraits(renderable.isHeading ? .isHeader : []) case .latex(_, let latexString): ScrollView(.horizontal) { HStack(spacing: 0) { diff --git a/Sources/MarkdownText/UI/Images/PinchZoomView.swift b/Sources/MarkdownText/UI/Images/PinchZoomView.swift index 01c232e1..ae3a5341 100644 --- a/Sources/MarkdownText/UI/Images/PinchZoomView.swift +++ b/Sources/MarkdownText/UI/Images/PinchZoomView.swift @@ -38,7 +38,7 @@ struct PinchZoomView: View { GeometryReader { geometry in image .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() .scaleEffect(scale * activeScale) .offset(offset) .background( diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift index c4327bd0..10c881df 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -20,8 +20,9 @@ class ParagraphNSView: NSTextView { static let animationDuration: CFTimeInterval = ParagraphAnimationConstants.fadeInDuration private(set) var paragraphContents: NSMutableAttributedString = NSMutableAttributedString() - private(set) var lineSpacing: CGFloat? private var activeAnimations: [FadeAnimationData] = [] + private var blockAccessibilityElements: [NSAccessibilityElement]? + private var blockElementsByID: [String: TextBlockAccessibilityElement] = [:] private var fadeAnimationDisplayLink: CADisplayLink? private var cachedSize: CachedParagraphNSViewSize? @@ -114,28 +115,35 @@ class ParagraphNSView: NSTextView { // MARK: - Content Update - func setParagraphContents(_ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, animatedByWord: Bool) { + func setParagraphContents(_ newContents: NSMutableAttributedString, animatedByWord: Bool) { AppAppearance.update(appearance: effectiveAppearance) - guard paragraphContents != newContents || self.lineSpacing != lineSpacing else { + guard paragraphContents != newContents else { return } + // Streamed text usually only grows: appending just the tail keeps the user's + // selection and the layout of the text already shown. Not when in-flight fades + // must be cleared, as their partial alpha lives in the text storage. + let isAppending = (animatedByWord || activeAnimations.isEmpty) && newContents.hasPrefix(paragraphContents) + let oldContents = paragraphContents self.paragraphContents = newContents - self.lineSpacing = lineSpacing let oldLength = textStorage?.length ?? 0 - let finalString: NSMutableAttributedString - if lineSpacing != nil { - finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) - } else { - finalString = newContents - } tearDownDisplayLink() invalidateCachedSize() - textStorage?.setAttributedString(finalString) + if isAppending { + textStorage?.append(newContents.attributedSubstring(from: NSRange(location: oldLength, length: newContents.length - oldLength))) + } else { + let selection = selectedRange() + textStorage?.setAttributedString(newContents) + // Replacing the text resets the selection; keep it if the text up to its end is unchanged. + if selection.length > 0, oldContents.hasSameText(through: selection, as: newContents) { + setSelectedRange(selection) + } + } - configureAccessibility(for: finalString) + configureAccessibility(for: newContents) invalidateIntrinsicContentSize() @@ -143,7 +151,7 @@ class ParagraphNSView: NSTextView { if animatedByWord, newContentLength > 0 { let newContentRange = NSRange(location: oldLength, length: newContentLength) - let wordRanges = finalString.splitIntoWords(withIn: newContentRange) + let wordRanges = newContents.splitIntoWords(withIn: newContentRange) let wordCount = wordRanges.count let delayBetweenWords: Double = ParagraphAnimationConstants.delayBetweenWordsRatio / Double(max(wordCount, 1)) let baseStartTime = CACurrentMediaTime() @@ -166,19 +174,6 @@ class ParagraphNSView: NSTextView { } } - // MARK: - Line Spacing - - private func applyLineSpacing(to attributedString: NSMutableAttributedString, lineSpacing: CGFloat?) -> NSMutableAttributedString { - let result = NSMutableAttributedString(attributedString: attributedString) - if let lineSpacing { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = lineSpacing - paragraphStyle.alignment = .left - result.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: result.length)) - } - return result - } - // MARK: - View Setup private func setupView() { @@ -232,6 +227,13 @@ class ParagraphNSView: NSTextView { } private func configureAccessibility(for attributedString: NSAttributedString) { + // A text group exposes one element per block, as separate views did, so + // VoiceOver still moves block by block and finds headings. + blockAccessibilityElements = updateBlockAccessibilityElements(for: attributedString) + guard blockAccessibilityElements == nil else { + setAccessibilityLabel(nil) + return + } if let content = generateAccessibilityContent(from: attributedString) { setAccessibilityLabel(content.label) } else { @@ -239,6 +241,46 @@ class ParagraphNSView: NSTextView { } } + /// One element per `.textBlock`, or `nil` when the text isn't a text group. + /// A block keeps its element across updates, so streaming doesn't replace + /// the element VoiceOver is focused on. + private func updateBlockAccessibilityElements(for attributedString: NSAttributedString) -> [NSAccessibilityElement]? { + var elementsByID: [String: TextBlockAccessibilityElement] = [:] + var elements: [NSAccessibilityElement] = [] + attributedString.enumerateAttribute(.textBlock, in: NSRange(location: 0, length: attributedString.length)) { value, range, _ in + guard let block = value as? TextBlock else { return } + let text = attributedString.attributedSubstring(from: range) + let element = blockElementsByID[block.id] ?? TextBlockAccessibilityElement(textView: self) + element.range = range + // The raw value of NSAccessibilityHeadingRole, which is only declared from macOS 26. + element.setAccessibilityRole(block.headingLevel == nil ? .staticText : NSAccessibility.Role(rawValue: "AXHeading")) + element.setAccessibilityLabel(generateAccessibilityContent(from: text)?.label ?? text.string) + element.setAccessibilityParent(self) + elementsByID[block.id] = element + elements.append(element) + } + blockElementsByID = elementsByID + return elements.isEmpty ? nil : elements + } + + override func isAccessibilityElement() -> Bool { + blockAccessibilityElements == nil && super.isAccessibilityElement() + } + + override func accessibilityChildren() -> [Any]? { + blockAccessibilityElements ?? super.accessibilityChildren() + } + + /// The area the characters in `range` are laid out in, in this view's coordinates. + func boundingRect(forCharacterRange range: NSRange) -> CGRect { + guard let layoutManager, let textContainer else { + return .zero + } + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + return rect.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) + } + // MARK: - Fade Animation @objc private func updateFadeAnimation() { @@ -414,4 +456,20 @@ private struct ContextMenuAction { let selectedText: String } +/// A block of a text group, framed by where its text is laid out. +private final class TextBlockAccessibilityElement: NSAccessibilityElement { + private weak var textView: ParagraphNSView? + var range = NSRange(location: 0, length: 0) + + init(textView: ParagraphNSView) { + self.textView = textView + super.init() + } + + override func accessibilityFrame() -> NSRect { + guard let textView else { return .zero } + return NSAccessibility.screenRect(fromView: textView, rect: textView.boundingRect(forCharacterRange: range)) + } +} + #endif diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift index befa77fd..210a322e 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift @@ -12,7 +12,6 @@ struct ParagraphView: NSViewRepresentable { @Environment(\.markdownController) var markdownController: MarkdownController? var contents: NSMutableAttributedString - var lineSpacing: CGFloat? func makeCoordinator() -> Coordinator { Coordinator() @@ -26,7 +25,7 @@ struct ParagraphView: NSViewRepresentable { // paragraph gets its own view instead. let view = ParagraphNSView() view.onUrlTap = openUrlFunction - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false) + view.setParagraphContents(contents, animatedByWord: false) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -42,9 +41,9 @@ struct ParagraphView: NSViewRepresentable { } func updateNSView(_ view: ParagraphNSView, context: Context) { - if view.paragraphContents != contents || view.lineSpacing != lineSpacing { + if view.paragraphContents != contents { let shouldAnimate = view.window != nil && config.shouldAnimateText - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: shouldAnimate) + view.setParagraphContents(contents, animatedByWord: shouldAnimate) } view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -55,10 +54,9 @@ struct ParagraphView: NSViewRepresentable { return nil } - if contents != context.coordinator.lastContents || lineSpacing != context.coordinator.lastLineSpacing { + if contents != context.coordinator.lastContents { context.coordinator.sizeCache.removeAll() context.coordinator.lastContents = contents - context.coordinator.lastLineSpacing = lineSpacing } let cacheKey = (width * 10).rounded() / 10 @@ -76,13 +74,12 @@ struct ParagraphView: NSViewRepresentable { class Coordinator { var sizeCache: [CGFloat: CGSize] = [:] var lastContents: NSMutableAttributedString? - var lastLineSpacing: CGFloat? } } extension ParagraphView: Equatable { static func == (lhs: ParagraphView, rhs: ParagraphView) -> Bool { - lhs.contents.isEqual(to: rhs.contents) && lhs.lineSpacing == rhs.lineSpacing + lhs.contents.isEqual(to: rhs.contents) } } #endif diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift index 6d357dc9..f57681b7 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift @@ -14,7 +14,7 @@ class ParagraphViewCache { static let shared: ParagraphViewCache = .init() - func createOrReuseView(contents: NSMutableAttributedString, lineSpacing: CGFloat?) -> MDParagraphView { + func createOrReuseView(contents: NSMutableAttributedString) -> MDParagraphView { if let availableView = findAvailableCachedView() { return availableView } diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift index 56f3f10c..53c85667 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -24,8 +24,8 @@ class ParagraphUIView: UITextView { static let animationDuration: CFTimeInterval = ParagraphAnimationConstants.fadeInDuration private(set) var paragraphContents: NSMutableAttributedString = NSMutableAttributedString() - private(set) var lineSpacing: CGFloat? private var activeAnimations: [FadeAnimationData] = [] + private var blockElementsByID: [String: TextBlockAccessibilityElement] = [:] private var fadeAnimationDisplayLink: CADisplayLink? private var cachedSize: CachedParagraphUIViewSize? @@ -68,6 +68,16 @@ class ParagraphUIView: UITextView { return result } + // swiftlint:disable:next no_any + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + // UIKit withholds Select All from non-editable text. Offer it until all the + // text is selected, so a partial selection can grow to the whole group. + if action == #selector(UIResponderStandardEditActions.selectAll(_:)) { + return textStorage.length > 0 && selectedRange != NSRange(location: 0, length: textStorage.length) + } + return super.canPerformAction(action, withSender: sender) + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { @@ -99,35 +109,42 @@ class ParagraphUIView: UITextView { invalidateIntrinsicContentSize() } - func setParagraphContents(_ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, animatedByWord: Bool) { + func setParagraphContents(_ newContents: NSMutableAttributedString, animatedByWord: Bool) { // Keep the cached interface style up to date for citation preview rendering. // This runs on the main thread so it's safe to read traitCollection here. AppAppearance.update(style: traitCollection.userInterfaceStyle) - guard paragraphContents != newContents || self.lineSpacing != lineSpacing else { + guard paragraphContents != newContents else { return } + // Streamed text usually only grows: appending just the tail keeps the user's + // selection and the layout of the text already shown. Not when in-flight fades + // must be cleared, as their partial alpha lives in the text storage. + let isAppending = (animatedByWord || activeAnimations.isEmpty) && newContents.hasPrefix(paragraphContents) self.paragraphContents = newContents - self.lineSpacing = lineSpacing let oldAttributedString: NSAttributedString = attributedText - let finalString: NSMutableAttributedString - if lineSpacing != nil { - finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) - } else { - finalString = newContents - } - guard finalString != oldAttributedString else { + guard newContents != oldAttributedString else { return } // Stop display link update before updating the attributed string tearDownDisplayLink() invalidateCachedSize() - attributedText = finalString + if isAppending { + let appendedRange = NSRange(location: textStorage.length, length: newContents.length - textStorage.length) + textStorage.append(newContents.attributedSubstring(from: appendedRange)) + } else { + let selection = selectedRange + attributedText = newContents + // Replacing the text resets the selection; keep it if the text up to its end is unchanged. + if selection.length > 0, oldAttributedString.hasSameText(through: selection, as: newContents) { + selectedRange = selection + } + } - configureAccessibility(for: finalString) + configureAccessibility(for: newContents) invalidateIntrinsicContentSize() @@ -161,14 +178,6 @@ class ParagraphUIView: UITextView { } } - private func applyLineSpacing(to attributedString: NSMutableAttributedString, lineSpacing: CGFloat?) -> NSMutableAttributedString { - let result = NSMutableAttributedString(attributedString: attributedString) - if let lineSpacing { - result.setLineSpacing(lineSpacing) - } - return result - } - private func setupView() { // Only register if not already registered to prevent conflicts if NSTextAttachment.textAttachmentViewProviderClass(forFileType: UTType.data.identifier) == nil { @@ -249,6 +258,16 @@ class ParagraphUIView: UITextView { /// Configure accessibility properties for the text view private func configureAccessibility(for attributedString: NSAttributedString) { + // A text group exposes one element per block, as separate views did, so + // VoiceOver still moves block by block and finds headings. + let blockElements = updateBlockAccessibilityElements(for: attributedString) + isAccessibilityElement = blockElements == nil + accessibilityElements = blockElements + guard blockElements == nil else { + accessibilityLabel = nil + accessibilityCustomActions = nil + return + } // Generate the full accessibility content directly if let accessibilityContent = generateAccessibilityContent(from: attributedString) { // We have citations, use the generated content @@ -261,6 +280,39 @@ class ParagraphUIView: UITextView { } } + /// One element per `.textBlock`, or `nil` when the text isn't a text group. + /// A block keeps its element across updates, so streaming doesn't replace + /// the element VoiceOver is focused on. + private func updateBlockAccessibilityElements(for attributedString: NSAttributedString) -> [UIAccessibilityElement]? { + var elementsByID: [String: TextBlockAccessibilityElement] = [:] + var elements: [UIAccessibilityElement] = [] + attributedString.enumerateAttribute(.textBlock, in: NSRange(location: 0, length: attributedString.length)) { value, range, _ in + guard let block = value as? TextBlock else { return } + let text = attributedString.attributedSubstring(from: range) + let content = generateAccessibilityContent(from: text) + let element = blockElementsByID[block.id] ?? TextBlockAccessibilityElement(accessibilityContainer: self) + element.range = range + element.accessibilityLabel = content?.label ?? text.string + element.accessibilityCustomActions = content?.actions + element.accessibilityTraits = block.headingLevel == nil ? .staticText : .header + elementsByID[block.id] = element + elements.append(element) + } + blockElementsByID = elementsByID + return elements.isEmpty ? nil : elements + } + + /// The area the characters in `range` are laid out in, in this view's coordinates. + func boundingRect(forCharacterRange range: NSRange) -> CGRect { + guard let start = position(from: beginningOfDocument, offset: range.location), + let end = position(from: start, offset: range.length), + let textRange = textRange(from: start, to: end) else { + return .zero + } + let rect = selectionRects(for: textRange).reduce(CGRect.null) { $0.union($1.rect) } + return rect.isNull ? .zero : rect + } + @objc private func updateFadeAnimation() { let currentTime = CACurrentMediaTime() var completedAnimations: [UUID] = [] @@ -379,12 +431,13 @@ extension ParagraphUIView: UITextViewDelegate { } } -fileprivate extension NSMutableAttributedString { - func setLineSpacing(_ lineSpacing: CGFloat) { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = lineSpacing - paragraphStyle.alignment = .left - addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: length)) +/// A block of a text group, framed by where its text is laid out. +private final class TextBlockAccessibilityElement: UIAccessibilityElement { + var range = NSRange(location: 0, length: 0) + + override var accessibilityFrameInContainerSpace: CGRect { + get { (accessibilityContainer as? ParagraphUIView)?.boundingRect(forCharacterRange: range) ?? .zero } + set { super.accessibilityFrameInContainerSpace = newValue } } } #endif diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift index c1fdd341..f1459750 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift @@ -12,7 +12,6 @@ struct ParagraphView: UIViewRepresentable { @Environment(\.markdownController) var markdownController: MarkdownController? var contents: NSMutableAttributedString - var lineSpacing: CGFloat? func makeCoordinator() -> Coordinator { Coordinator() @@ -20,9 +19,9 @@ struct ParagraphView: UIViewRepresentable { func makeUIView(context: Context) -> ParagraphUIView { let openUrlFunction = openURL.callAsFunction(_:) - let view = ParagraphViewCache.shared.createOrReuseView(contents: contents, lineSpacing: lineSpacing) + let view = ParagraphViewCache.shared.createOrReuseView(contents: contents) view.onUrlTap = openUrlFunction - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false) + view.setParagraphContents(contents, animatedByWord: false) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -37,9 +36,9 @@ struct ParagraphView: UIViewRepresentable { } func updateUIView(_ view: ParagraphUIView, context: Context) { - if view.paragraphContents != contents || view.lineSpacing != lineSpacing { + if view.paragraphContents != contents { let shouldAnimate = view.window != nil && config.shouldAnimateText // only animate when visible - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: shouldAnimate) + view.setParagraphContents(contents, animatedByWord: shouldAnimate) } view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -51,11 +50,10 @@ struct ParagraphView: UIViewRepresentable { return nil } - // Check if content or lineSpacing changed - if so, clear the cache - if contents != context.coordinator.lastContents || lineSpacing != context.coordinator.lastLineSpacing { + // Check if content changed - if so, clear the cache + if contents != context.coordinator.lastContents { context.coordinator.sizeCache.removeAll() context.coordinator.lastContents = contents - context.coordinator.lastLineSpacing = lineSpacing } // Round width to avoid cache misses from floating point precision issues @@ -80,13 +78,12 @@ struct ParagraphView: UIViewRepresentable { // Cache all calculated sizes keyed by width var sizeCache: [CGFloat: CGSize] = [:] var lastContents: NSMutableAttributedString? - var lastLineSpacing: CGFloat? } } extension ParagraphView: Equatable { static func == (lhs: ParagraphView, rhs: ParagraphView) -> Bool { - lhs.contents == rhs.contents && lhs.lineSpacing == rhs.lineSpacing + lhs.contents == rhs.contents } } #endif diff --git a/Sources/MarkdownText/Utilities/NSAttributedString+.swift b/Sources/MarkdownText/Utilities/NSAttributedString+.swift index d3af12e0..7b4e5ed8 100644 --- a/Sources/MarkdownText/Utilities/NSAttributedString+.swift +++ b/Sources/MarkdownText/Utilities/NSAttributedString+.swift @@ -11,6 +11,19 @@ import AppKit #endif extension NSAttributedString { + func hasPrefix(_ prefix: NSAttributedString) -> Bool { + length >= prefix.length && attributedSubstring(from: NSRange(location: 0, length: prefix.length)).isEqual(to: prefix) + } + + /// Whether `other` has the same text as this string from the start through + /// the end of `range`, so a selection of `range` survives changing this text to `other`. + func hasSameText(through range: NSRange, as other: NSAttributedString) -> Bool { + let end = NSMaxRange(range) + guard end <= length, end <= other.length else { return false } + let prefix = NSRange(location: 0, length: end) + return (string as NSString).substring(with: prefix) == (other.string as NSString).substring(with: prefix) + } + func splitIntoWords(withIn range: NSRange) -> [NSRange] { var words: [NSRange] = [] let string = self.string as NSString @@ -67,3 +80,15 @@ extension NSAttributedString { return words } } + +extension NSMutableAttributedString { + /// Updates the paragraph style of every run in `range` (the whole string by + /// default), keeping properties set by earlier passes. + func updateParagraphStyle(in range: NSRange? = nil, _ update: (NSMutableParagraphStyle) -> Void) { + enumerateAttribute(.paragraphStyle, in: range ?? NSRange(location: 0, length: length)) { value, runRange, _ in + let paragraphStyle = (value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + update(paragraphStyle) + addAttribute(.paragraphStyle, value: paragraphStyle, range: runRange) + } + } +} diff --git a/Tests/MarkdownTextTests/MarkdownTextTests.swift b/Tests/MarkdownTextTests/MarkdownTextTests.swift index 2f81132c..2ed8961e 100644 --- a/Tests/MarkdownTextTests/MarkdownTextTests.swift +++ b/Tests/MarkdownTextTests/MarkdownTextTests.swift @@ -255,7 +255,11 @@ final class MarkdownTextTests: XCTestCase { let document = await parser.parse(text: text) let renderableDoc = await RenderableDocument(document: document, config: .default) - let renderables = renderableDoc.renderables + // The heading and paragraph share a text group; inspect its blocks. + guard case .textGroup(_, let renderables, _) = renderableDoc.renderables.first else { + XCTFail("Heading and paragraph should be grouped") + return + } // Verify it parses without error XCTAssertEqual(renderables.count, 2) diff --git a/Tests/MarkdownTextTests/ParagraphViewTests.swift b/Tests/MarkdownTextTests/ParagraphViewTests.swift index d1d932fc..7f068dc9 100644 --- a/Tests/MarkdownTextTests/ParagraphViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphViewTests.swift @@ -41,8 +41,7 @@ struct ParagraphViewTests { private func createParagraphView(from attributedString: NSAttributedString) -> ParagraphView { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) return ParagraphView( - contents: mutableAttributedString, - lineSpacing: nil + contents: mutableAttributedString ) } diff --git a/Tests/MarkdownTextTests/TextGroupTests.swift b/Tests/MarkdownTextTests/TextGroupTests.swift new file mode 100644 index 00000000..5b0c6928 --- /dev/null +++ b/Tests/MarkdownTextTests/TextGroupTests.swift @@ -0,0 +1,438 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import Markdown +@testable import SwiftStreamingMarkdown +import SwiftUI +import XCTest + +@MainActor +final class TextGroupTests: XCTestCase { + + private let parser = MarkdownParserImpl() + + private func renderableDocument(for text: String, config: MarkdownRenderConfig = .default) async -> RenderableDocument { + let document = await parser.parse(text: text) + return await RenderableDocument(document: document, config: config) + } + + private func textGroup(_ text: String, config: MarkdownRenderConfig = .default) async -> (blocks: [MarkdownRenderable], content: NSMutableAttributedString)? { + guard case .textGroup(_, let blocks, let content) = await renderableDocument(for: text, config: config).renderables.first else { + XCTFail("Expected a text group") + return nil + } + return (blocks, content) + } + + /// The content of the document's first block, whether or not it's a group. + private func textContent(_ text: String) async -> NSMutableAttributedString { + switch await renderableDocument(for: text).renderables.first { + case .paragraph(_, let content), .heading(_, _, let content), .textGroup(_, _, let content): + return content + default: + XCTFail("Expected a text block") + return NSMutableAttributedString() + } + } + + private func paragraphSpacingBefore(_ text: String, in content: NSAttributedString) -> CGFloat? { + let location = (content.string as NSString).range(of: text).location + let style = content.attribute(.paragraphStyle, at: location, effectiveRange: nil) as? NSParagraphStyle + return style?.paragraphSpacingBefore + } + + // MARK: - Grouping + + func test_adjacentHeadingsAndParagraphs_areGroupedIntoOneRenderable() async { + let renderables = await renderableDocument(for: "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n\n## Section").renderables + + XCTAssertEqual(renderables.count, 1) + guard case .textGroup(_, let blocks, let content) = renderables.first else { + return XCTFail("Expected adjacent text blocks to be grouped") + } + XCTAssertEqual(blocks.compactMap { $0.plainText }, ["Title", "First paragraph.", "Second paragraph.", "Section"]) + XCTAssertEqual(content.string, "Title\nFirst paragraph.\nSecond paragraph.\nSection") + } + + func test_textBlocksSeparatedByOtherBlocks_areNotGrouped() async { + let renderables = await renderableDocument(for: "# Intro\n\n- item\n\nOutro.").renderables + + XCTAssertEqual(renderables.count, 3) + guard case .heading = renderables[0], case .unorderedList = renderables[1], case .paragraph = renderables[2] else { + return XCTFail("Expected heading, list, paragraph") + } + } + + /// An empty text block has no line to space from its neighbors, so it keeps + /// its own view and `blockSpacing`, and the blocks on either side group. + func test_emptyTextBlocks_endGroups() async { + let renderables = await renderableDocument(for: "First.\n\nSecond.\n\n#\n\nThird.\n\nFourth.").renderables + + guard renderables.count == 3, + case .textGroup(_, let before, _) = renderables[0], + case .heading(_, _, let empty) = renderables[1], + case .textGroup(_, let after, _) = renderables[2] else { + return XCTFail("Expected a group, the empty heading, then a group; got \(renderables.count) blocks") + } + XCTAssertEqual([before.count, empty.length, after.count], [2, 0, 2]) + + let leading = await renderableDocument(for: "#\n\nBody.").renderables + XCTAssertEqual(leading.count, 2, "A leading empty heading keeps its own view and spacing") + } + + /// Grouping doesn't depend on `blockSpacing`: below the line spacing, grouped + /// paragraphs keep the native minimum gap rather than negative spacing. + func test_blockSpacingBelowLineSpacing_keepsParagraphsGroupedAtLineSpacingGap() async { + let config = MarkdownRenderConfig.default.withBlockSpacing(value: 2) + guard let group = await textGroup("A first paragraph long enough to wrap onto a second line.\n\nA second paragraph.", config: config) else { return } + + let stacked = group.blocks.map(measuredHeight(ofBlock:)).reduce(MarkdownRenderable.paragraphLineSpacing, +) + + XCTAssertEqual(paragraphSpacingBefore("A second paragraph.", in: group.content), 0, "Spacing below the line spacing must not go negative") + XCTAssertEqual(measuredHeight(of: group.content), stacked, accuracy: measurementAccuracy, "Paragraphs keep their line spacing apart") + } + + func test_group_keepsFirstBlockID_asBlocksStreamIn() async { + let before = await renderableDocument(for: "# Title").renderables + let after = await renderableDocument(for: "# Title\n\nBody").renderables + + XCTAssertEqual(before.map(\.id), after.map(\.id), "A stable ID keeps SwiftUI updating the same text view") + } + + func test_blockSpacing_appliesOnlyToEachBlocksFirstLine() async { + let config = MarkdownRenderConfig.default + guard let group = await textGroup("First.\n\nSecond, line one\nSecond, line two", config: config) else { return } + + XCTAssertEqual(paragraphSpacingBefore("First.", in: group.content), 0) + XCTAssertEqual(paragraphSpacingBefore("Second, line one", in: group.content), config.blockSpacing - MarkdownRenderable.paragraphLineSpacing) + XCTAssertEqual(paragraphSpacingBefore("Second, line two", in: group.content), 0, "Soft-break lines belong to the same block") + } + + func test_plainText_keepsBlankLineBetweenGroupedBlocks() async { + let document = await renderableDocument(for: "# Title\n\nFirst.\n\nSecond.") + + XCTAssertEqual(document.plainText, "Title\n\nFirst.\n\nSecond.") + } + + // MARK: - Rendering + + /// A group must lay out exactly like its blocks stacked `blockSpacing` apart, + /// so grouping is visually a no-op. Pairs keep one break's error from + /// cancelling out another's. + func test_groupHeight_matchesStackedBlocks_forEveryKindOfBreak() async { + let heading = "# A heading long enough to wrap onto a second line" + let paragraph = "A paragraph that is long enough to wrap across several lines at this width." + let pairs = [(heading, paragraph), (paragraph, heading), (paragraph, paragraph), (heading, heading)] + for blockSpacing: CGFloat in [MarkdownRenderConfig.defaultBlockSpacing, 10] { + let config = MarkdownRenderConfig.default.withBlockSpacing(value: blockSpacing) + for (first, second) in pairs { + guard let group = await textGroup("\(first)\n\n\(second)", config: config) else { return } + + let stacked = group.blocks.map(measuredHeight(ofBlock:)).reduce(blockSpacing, +) + + XCTAssertEqual(measuredHeight(of: group.content), stacked, accuracy: measurementAccuracy, "'\(first)' then '\(second)', blockSpacing \(blockSpacing)") + } + } + } + + func test_adjacentTextBlocks_renderInOneTextView() async { + let document = await renderableDocument(for: "# Title\n\nFirst paragraph.\n\n```\ncode\n```\n\nLast paragraph.") + + let textViews = renderedTextViews(for: [document])[0] + + XCTAssertEqual(textViews.map(\.paragraphContents.string), ["Title\nFirst paragraph.", "Last paragraph."]) + } + + /// When streaming turns a lone heading into a group, SwiftUI must update the + /// same text view rather than recreate it, so appended words can fade in. + func test_blockBecomingGroup_keepsItsTextView() async { + let single = await renderableDocument(for: "# Title") + let grouped = await renderableDocument(for: "# Title\n\nBody") + + let renders = renderedTextViews(for: [single, grouped]) + + XCTAssertEqual(renders.map(\.count), [1, 1]) + XCTAssertTrue(renders[0].first === renders[1].first, "Expected the text view to be reused") + XCTAssertEqual(renders[1].first?.paragraphContents.string, "Title\nBody") + } + + // MARK: - Streaming updates + + func test_streamingAppend_keepsSelection() async { + guard let first = await textGroup("# Title\n\nFirst"), let second = await textGroup("# Title\n\nFirst paragraph") else { return } + + withHostedParagraphView { view in + view.setParagraphContents(first.content, animatedByWord: false) + view.selection = NSRange(location: 0, length: 5) + + view.setParagraphContents(second.content, animatedByWord: true) + + XCTAssertEqual(view.displayedText.string, "Title\nFirst paragraph") + XCTAssertEqual(view.selection, NSRange(location: 0, length: 5), "Appended text must not reset the selection") + } + } + + func test_nonAnimatedUpdate_clearsInFlightFades() async { + guard let first = await textGroup("# Title\n\nFirst"), let second = await textGroup("# Title\n\nFirst paragraph") else { return } + + withHostedParagraphView { view in + view.setParagraphContents(first.content, animatedByWord: true) + view.setParagraphContents(second.content, animatedByWord: false) + + let displayed = view.displayedText.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? MDColor + let source = second.content.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? MDColor + XCTAssertEqual(displayed, source, "A fade cut short must not leave text translucent") + } + } + + /// Some updates rewrite text already shown, so they replace the text instead + /// of appending to it; a selection of text they leave unchanged must survive. + func test_rewritingUpdate_keepsSelectionOfUnchangedText() async { + let updates = [ + // Completing a link turns "[the docs" into link text. + ("First paragraph.\n\nRead [the docs", "First paragraph.\n\nRead [the docs](https://example.com)"), + // A paragraph that becomes a group takes on the group's attributes. + ("First paragraph.", "First paragraph.\n\nRead") + ] + for (before, after) in updates { + let oldContent = await textContent(before) + let newContent = await textContent(after) + + withHostedParagraphView { view in + view.setParagraphContents(oldContent, animatedByWord: false) + view.selection = NSRange(location: 0, length: 16) + + view.setParagraphContents(newContent, animatedByWord: true) + + XCTAssertEqual(view.displayedText.string, newContent.string) + XCTAssertEqual(view.selection, NSRange(location: 0, length: 16), "Selection lost updating '\(before)' to '\(after)'") + } + } + } + + func test_rewritingUpdate_dropsSelectionOfChangedText() async { + let oldContent = await textContent("First paragraph.\n\nRead [the docs") + let newContent = await textContent("First paragraph.\n\nRead [the docs](https://example.com)") + + withHostedParagraphView { view in + view.setParagraphContents(oldContent, animatedByWord: false) + view.selection = NSRange(location: 17, length: 9) // "Read [the" + + view.setParagraphContents(newContent, animatedByWord: true) + + XCTAssertEqual(view.selection.length, 0, "The selected text changed, so the selection can't carry over") + } + } + + #if canImport(UIKit) + func test_selectAll_isOfferedUntilAllTextIsSelected() async { + let content = await textContent("# Title\n\nBody text.") + let selectAll = #selector(UIResponderStandardEditActions.selectAll(_:)) + let all = NSRange(location: 0, length: content.length) + + withHostedParagraphView { view in + view.setParagraphContents(content, animatedByWord: false) + for (selection, isOffered) in [(NSRange(location: 0, length: 0), true), (NSRange(location: 0, length: 5), true), (all, false)] { + view.selectedRange = selection + XCTAssertEqual(view.canPerformAction(selectAll, withSender: nil), isOffered, "Selection \(selection)") + } + + view.selectedRange = NSRange(location: 0, length: 5) + view.selectAll(nil) + XCTAssertEqual(view.selectedRange, all, "UIKit's Select All must select the whole group") + + view.setParagraphContents(NSMutableAttributedString(), animatedByWord: false) + XCTAssertFalse(view.canPerformAction(selectAll, withSender: nil), "Empty text has nothing to select") + } + } + #endif + + // MARK: - Accessibility + + func test_group_exposesOneAccessibilityElementPerBlock() async { + guard let group = await textGroup("# Title\n\nBody text.") else { return } + + withHostedParagraphView { view in + view.setParagraphContents(group.content, animatedByWord: false) + let elements = view.blockElements + + XCTAssertFalse(view.isOneAccessibilityElement, "The blocks are the elements, not the whole text view") + XCTAssertEqual(elements.map { $0.label }, ["Title", "Body text."]) + XCTAssertEqual(elements.map { $0.isHeading }, [true, false]) + guard elements.count == 2 else { return } + XCTAssertFalse(elements[0].frame.isEmpty) + XCTAssertTrue(isAbove(elements[0].frame, elements[1].frame), "Each element must be framed on its own block") + } + } + + /// Replacing the element VoiceOver is focused on can interrupt reading, so a + /// streaming update must keep the elements of blocks already shown. + func test_streamingUpdate_keepsAccessibilityElementsOfExistingBlocks() async { + guard let first = await textGroup("# Title\n\nBody"), let second = await textGroup("# Title\n\nBody text.\n\nMore") else { return } + + withHostedParagraphView { view in + view.setParagraphContents(first.content, animatedByWord: false) + let before = view.blockElements + + view.setParagraphContents(second.content, animatedByWord: true) + let after = view.blockElements + + XCTAssertEqual(after.map { $0.label }, ["Title", "Body text.", "More"], "Existing elements must reflect the new text") + guard before.count == 2, after.count == 3 else { return XCTFail("Expected 2 then 3 elements") } + XCTAssertTrue(before[0].object === after[0].object && before[1].object === after[1].object, "Existing blocks must keep their elements") + XCTAssertTrue(isAbove(after[1].frame, after[2].frame), "A kept element must be framed on its block's new text") + } + } + + func test_singleBlock_staysOneAccessibilityElement() async { + guard case .paragraph(_, let content) = await renderableDocument(for: "Just one paragraph.").renderables.first else { + return XCTFail("Expected a paragraph") + } + + withHostedParagraphView { view in + view.setParagraphContents(content, animatedByWord: false) + + XCTAssertTrue(view.isOneAccessibilityElement) + XCTAssertTrue(view.blockElements.isEmpty) + } + } + + // MARK: - Helpers + + private func measuredHeight(ofBlock block: MarkdownRenderable) -> CGFloat { + switch block { + case .heading(_, _, let content), .paragraph(_, let content): return measuredHeight(of: content) + default: return 0 + } + } + + private func measuredHeight(of content: NSMutableAttributedString, width: CGFloat = 300) -> CGFloat { + let view = MDParagraphView() + view.setParagraphContents(content, animatedByWord: false) + #if canImport(UIKit) + return view.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height + #else + return view.measureSize(fittingWidth: width).height + #endif + } + + #if canImport(UIKit) + private let measurementAccuracy: CGFloat = 0.01 + + /// Whether `upper` sits above `lower`, in view coordinates that grow downwards. + private func isAbove(_ upper: CGRect, _ lower: CGRect) -> Bool { + upper.maxY <= lower.minY + } + + /// Renders each document in turn in the same host and returns the paragraph + /// text views present after each render. + private func renderedTextViews(for documents: [RenderableDocument]) -> [[ParagraphUIView]] { + let host = UIHostingController(rootView: DocumentView(renderableDocument: documents[0])) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { window.isHidden = true } + return documents.map { document in + host.rootView = DocumentView(renderableDocument: document) + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + return host.view.descendants(ofType: ParagraphUIView.self) + } + } + + private func withHostedParagraphView(_ body: (ParagraphUIView) -> Void) { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let view = ParagraphUIView(frame: CGRect(x: 0, y: 0, width: 350, height: 400)) + window.addSubview(view) + window.makeKeyAndVisible() + defer { window.isHidden = true } + _ = view.becomeFirstResponder() + body(view) + } + #elseif canImport(AppKit) + /// Separate views each round their height up to a whole point. + private let measurementAccuracy: CGFloat = 1 + + /// Whether `upper` sits above `lower`, in screen coordinates that grow upwards. + private func isAbove(_ upper: CGRect, _ lower: CGRect) -> Bool { + upper.minY >= lower.maxY + } + + private func renderedTextViews(for documents: [RenderableDocument]) -> [[ParagraphNSView]] { + let host = NSHostingController(rootView: DocumentView(renderableDocument: documents[0])) + let window = NSWindow(contentViewController: host) + window.setContentSize(CGSize(width: 390, height: 844)) + return documents.map { document in + host.rootView = DocumentView(renderableDocument: document) + host.view.layoutSubtreeIfNeeded() + return host.view.descendants(ofType: ParagraphNSView.self) + } + } + + private func withHostedParagraphView(_ body: (ParagraphNSView) -> Void) { + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 390, height: 844), styleMask: [.titled], backing: .buffered, defer: false) + let view = ParagraphNSView() + view.frame = CGRect(x: 0, y: 0, width: 350, height: 400) + window.contentView?.addSubview(view) + window.makeFirstResponder(view) + body(view) + } + #endif +} + +/// What a text view exposes to accessibility for one block. +private struct BlockElement { + let object: AnyObject + let label: String? + let isHeading: Bool + let frame: CGRect +} + +#if canImport(UIKit) +private extension ParagraphUIView { + var selection: NSRange { + get { selectedRange } + set { selectedRange = newValue } + } + var displayedText: NSAttributedString { attributedText } + var isOneAccessibilityElement: Bool { isAccessibilityElement } + var blockElements: [BlockElement] { + (accessibilityElements as? [UIAccessibilityElement] ?? []).map { + BlockElement(object: $0, label: $0.accessibilityLabel, isHeading: $0.accessibilityTraits.contains(.header), frame: $0.accessibilityFrameInContainerSpace) + } + } +} + +private extension UIView { + func descendants(ofType type: T.Type) -> [T] { + subviews.flatMap { subview -> [T] in + ((subview as? T).map { [$0] } ?? []) + subview.descendants(ofType: type) + } + } +} +#elseif canImport(AppKit) +private extension ParagraphNSView { + var selection: NSRange { + get { selectedRange() } + set { setSelectedRange(newValue) } + } + var displayedText: NSAttributedString { attributedString() } + var isOneAccessibilityElement: Bool { isAccessibilityElement() } + var blockElements: [BlockElement] { + guard !isAccessibilityElement() else { return [] } + return (accessibilityChildren() as? [NSAccessibilityElement] ?? []).map { + BlockElement(object: $0, label: $0.accessibilityLabel(), isHeading: $0.accessibilityRole()?.rawValue == "AXHeading", frame: $0.accessibilityFrame()) + } + } +} + +private extension NSView { + func descendants(ofType type: T.Type) -> [T] { + subviews.flatMap { subview -> [T] in + ((subview as? T).map { [$0] } ?? []) + subview.descendants(ofType: type) + } + } +} +#endif diff --git a/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-dark-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-dark-US-en.png index 8464ae9b..fd9fbc36 100755 Binary files a/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-dark-US-en.png and b/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-dark-US-en.png differ diff --git a/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-light-US-en.png b/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-light-US-en.png index 87a5d278..1fe91356 100755 Binary files a/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-light-US-en.png and b/Tests/MarkdownTextTests/__Snapshots__/MarkdownTextSnapshotTests/testMarkdownLists_uikit.iPhone16-light-US-en.png differ