Render adjacent headings and paragraphs in one text view for cross-block selection - #176
Open
Joe Li (joeliai) wants to merge 4 commits into
Open
Joe Li (joeliai) wants to merge 4 commits into
Joe Li (joeliai) wants to merge 4 commits into
Conversation
Each heading and paragraph rendered in its own UITextView/NSTextView, so a text selection could not extend past the block it started in. Pre-render now merges each run of adjacent top-level headings and paragraphs into a single `.textGroup` renderable, shown in one text view, so users can select and copy across those blocks. Blocks in a group are joined by paragraph breaks spaced to match `blockSpacing`, accounting for TextKit 2 (UIKit) applying the next block's line spacing at a break and TextKit 1 (AppKit) the previous block's. Paragraph line spacing now lives in the attributed content, so the view-level `lineSpacing` parameter is removed. Grouping is skipped when `blockSpacing` is below the paragraph line spacing. A group keeps its first block's ID and all text blocks render in one `BlockView` branch, so streaming reuses the same text view. Paragraph views append streamed text instead of replacing it, which preserves the user's selection. Groups expose one accessibility element per block, keeping heading traits and citation actions, and `plainText` still separates blocks with blank lines for the "Select more text" modal. Re-record the two iPhone `testMarkdownLists_uikit` references, which shift by at most 2px from rounding the group's height once instead of per paragraph. macOS renders are pixel-identical. Refs microsoft#133 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address review feedback on the text-group change: - Streaming updates that rewrite text already shown (completing a link, or a lone paragraph becoming a group) replace the whole text, which reset the selection. Both `ParagraphUIView` and `ParagraphNSView` now restore the selection after a full replacement when the text through its end is unchanged; a selection of changed text is still dropped. - UIKit withholds Select All from non-editable text views. Offer it in `ParagraphUIView` while the text is nonempty and not fully selected, keeping UIKit's own `selectAll(_:)`. `NSTextView` already offers it. - Group headings and paragraphs regardless of `blockSpacing`. Below the line spacing, grouped paragraphs keep the native minimum gap instead of negative spacing, rather than losing cross-paragraph selection. Refs microsoft#133 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Joe Li (joeliai)
requested review from
a team
and
a balanced review from Copilot
September 29, 2026 16:38
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Empty headings lose inter-block spacing, and streaming replaces accessibility elements for unchanged blocks.
Review effort: Balanced
Findings: 3
Open (3)
What changed in this PR
This PR groups adjacent headings and paragraphs in the SwiftUI Markdown renderer so users can select across them natively, while retaining the full-document selection modal for other block types.
Changes:
- Adds text groups with per-block spacing and stable view identity during streaming.
- Updates iOS and macOS text views to preserve selection and expose accessibility elements for grouped blocks.
- Adds tests and updates documentation.
| File | Description |
|---|---|
| Tests/MarkdownTextTests/TextGroupTests.swift | Tests grouping, layout, streaming, selection, and accessibility. |
| Tests/MarkdownTextTests/ParagraphViewTests.swift | Updates the paragraph view initializer in tests. |
| Tests/MarkdownTextTests/MarkdownTextTests.swift | Inspects heading and paragraph blocks inside a group. |
| Sources/MarkdownText/Utilities/NSAttributedString+.swift | Adds text comparison and paragraph-style helpers. |
| Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift | Removes the view-level line-spacing parameter. |
| Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift | Updates streaming, selection, accessibility, and Select All behavior. |
| Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift | Removes line spacing from view creation. |
| Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift | Removes the view-level line-spacing parameter. |
| Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift | Updates streaming, selection, and accessibility behavior. |
| Sources/MarkdownText/UI/BlockView.swift | Renders individual and grouped text through one branch. |
| Sources/MarkdownText/Models/RenderableDocument.swift | Updates paragraph layout and grouped plain-text output. |
| Sources/MarkdownText/Models/MarkdownRenderConfig.swift | Documents the minimum gap between grouped paragraphs. |
| Sources/MarkdownText/Models/MarkdownRenderable+TextGroup.swift | Builds adjacent text groups and their spacing. |
| Sources/MarkdownText/Models/MarkdownRenderable.swift | Adds the text-group renderable. |
| Sources/MarkdownText/Block/Paragraph+.swift | Stores paragraph spacing in attributed content. |
| Sources/MarkdownText/Block/Document+.swift | Groups text blocks during conversion. |
| README.md | Documents cross-block selection for adjacent text. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Author
|
@microsoft-github-policy-service agree company="Microsoft" |
CI's runner now ships SwiftLint 0.65.1, whose new `legacy_swiftui_aspect_ratio` rule flags `.aspectRatio(contentMode: .fit)` and fails the SwiftLint job on every PR. `scaledToFit()` is the same modifier (`aspectRatio(nil, contentMode: .fit)`), so rendering is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- An empty heading or paragraph has no line to space from its neighbors, so its gap collapsed inside a group. Empty text blocks now end groups and keep their own view and `blockSpacing`; the runs on either side still group. - Find each block's first line in the block's own content instead of in the whole group built so far. - Keep each block's accessibility element across streaming updates on iOS and macOS, updating its range, label, role and actions in place, so VoiceOver focus isn't replaced while text arrives. Refs microsoft#133 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Each heading and paragraph rendered in its own
UITextView/NSTextView, so native text selection stopped at every block boundary (#133 added the "Select more text" modal as a workaround). This PR renders each run of adjacent top-level headings and paragraphs in one text view, so users can select and copy across them natively. Rendered output is unchanged.Models/MarkdownRenderable+TextGroup.swift(new) —groupingAdjacentTextBlocks(config:)runs in the existing pre-render conversion (Document.convert(with:)) and merges each run of adjacent headings and paragraphs into a new.textGroup(id:blocks:content:)renderable. Blocks are joined by paragraph breaks whoseparagraphSpacingBeforereproducesblockSpacing, accounting for TextKit 2 (UIKit) adding the next block's line spacing at a break and TextKit 1 (AppKit) the previous block's. AblockSpacingbelow the 5pt paragraph line spacing falls back to that native minimum gap instead of negative spacing, so grouping never depends on the spacing setting.UI/BlockView.swift— headings, paragraphs and groups share oneswitchbranch, and a group keeps its first block's ID, so a block that grows into a group while streaming keeps its text view (and its fade-in animation).Block/Paragraph+.swift,RenderableDocument(plainText:config:)), so each block in a group keeps its own spacing. The internal view-levellineSpacingparameter is removed fromParagraphView,ParagraphUIView,ParagraphNSViewandParagraphViewCache.ParagraphUIView/ParagraphNSViewselectAll(_:).NSTextViewalready offers it.RenderableDocument.plainTextkeeps a blank line between grouped blocks for the "Select more text" modal.blockSpacingDocC comment.testMarkdownLists_uikit: text lines move by at most 2px (0.67pt) because the group's height is rounded once rather than per paragraph. All other references are unchanged; local macOS renders are pixel-identical tomain, so no macOS references change.Out of scope: lists, tables, code blocks and block quotes still render as separate views, so selecting across them still goes through "Select more text".
Refs #133
Validation
Run on the PR head (
39e961a):make ci— passed: SwiftLint 0 violations in 142 files; 135 XCTest + 9 Swift Testing tests pass (default destination: iPhone 17, iOS 27.0); sample app build succeeded.xcodebuild test -scheme SwiftStreamingMarkdown -destination "platform=iOS Simulator,OS=26.4.1,name=iPhone 17" -skipMacroValidation(CI's destination) — passed: 135 XCTest + 9 Swift Testing tests, including all snapshot tests.xcodebuild test -scheme SwiftStreamingMarkdown -destination "platform=macOS" -skipMacroValidation— every non-snapshot test passes, including the new ones. 28 snapshot tests fail locally: the same set fails on unmodifiedmain, because local macOS rendering differs from the CI runner's. All 56 macOS snapshot renders are pixel-identical tomain's (ImageMagickcompare -metric AE).xcodebuild build -project Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample.xcodeproj -scheme SwiftStreamingMarkdownSampleMac -destination "platform=macOS" -skipMacroValidation CODE_SIGNING_ALLOWED=NO— succeeded.The new
TextGroupTests(16 on iOS, 15 on macOS) cover grouping; spacing for every heading/paragraph break and for ablockSpacingbelow the line spacing; text-view reuse while streaming; selection across appends and rewrites; fade cleanup; per-block accessibility; and Select All with empty, partial and full selections (iOS). Each new safeguard was disabled in turn to confirm its test fails. The snapshot change was reviewed by comparing per-line vertical positions in the old and newtestMarkdownLists_uikitimages.Screen.Recording.2026-09-28.at.4.26.40.PM.mov
Screen.Recording.2026-09-29.at.9.43.55.AM.mov
OSS readiness
blockSpacingDocC, snapshot references)