Skip to content

Render adjacent headings and paragraphs in one text view for cross-block selection - #176

Open
Joe Li (joeliai) wants to merge 4 commits into
microsoft:mainfrom
joeliai:text-group-selection
Open

Joe Li (joeliai) wants to merge 4 commits into
microsoft:mainfrom
joeliai:text-group-selection

Conversation

@joeliai

@joeliai Joe Li (joeliai) commented Sep 29, 2026 •

Copy link
Copy Markdown

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 whose paragraphSpacingBefore reproduces blockSpacing, accounting for TextKit 2 (UIKit) adding the next block's line spacing at a break and TextKit 1 (AppKit) the previous block's. A blockSpacing below 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 one switch branch, 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).
  • Paragraph line spacing lives in the attributed content (Block/Paragraph+.swift, RenderableDocument(plainText:config:)), so each block in a group keeps its own spacing. The internal view-level lineSpacing parameter is removed from ParagraphView, ParagraphUIView, ParagraphNSView and ParagraphViewCache.
  • ParagraphUIView / ParagraphNSView
    • Append-only streaming updates append just the new tail instead of replacing all the text, keeping the user's selection and the layout already shown.
    • Updates that rewrite text already shown (e.g. completing a link, or a paragraph becoming a group) restore the selection when the text through its end is unchanged; a selection of changed text is dropped.
    • A group exposes one accessibility element per block, so VoiceOver still moves block by block. Headings keep the header trait (iOS) or heading role (macOS), and citation actions stay on their block.
    • iOS: Select All is offered while the text is nonempty and not fully selected (UIKit withholds it from non-editable text); the selecting itself is UIKit's selectAll(_:). NSTextView already offers it.
  • RenderableDocument.plainText keeps a blank line between grouped blocks for the "Select more text" modal.
  • Docs — README feature list and the blockSpacing DocC comment.
  • Snapshots — re-recorded the two iPhone 16 references of 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 to main, 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 unmodified main, because local macOS rendering differs from the CI runner's. All 56 macOS snapshot renders are pixel-identical to main's (ImageMagick compare -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 a blockSpacing below 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 new testMarkdownLists_uikit images.

Screen.Recording.2026-09-28.at.4.26.40.PM.mov
Screen.Recording.2026-09-29.at.9.43.55.AM.mov

OSS readiness

  • No secrets, internal URLs, private identifiers, or product-only service names were added.
  • Public docs, fixtures, or notices were updated if behavior or dependencies changed. (README, blockSpacing DocC, snapshot references)
  • Third-party dependency changes (adds, removes, version bumps) are intentional and reviewed. (no dependency changes)
  • Streaming/incomplete markdown behavior remains covered by fixtures or tests. (new streaming append, rewrite and grouping tests)

Joe Li and others added 2 commits September 28, 2026 14:32
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>
@joeliai
Joe Li (joeliai) requested review from a team and a balanced review from Copilot September 29, 2026 16:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Empty headings lose inter-block spacing, and streaming replaces accessibility elements for unchanged blocks.

Review effort: Balanced
Findings: 3 Medium severity

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.

Comment thread Sources/MarkdownText/Models/MarkdownRenderable+TextGroup.swift Outdated
Comment thread Sources/MarkdownText/Models/MarkdownRenderable+TextGroup.swift Outdated
Comment thread Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift Outdated
@joeliai

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Microsoft"

Joe Li and others added 2 commits September 29, 2026 10:12
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants