Fix text selection for single-frame (short) layouts - #71
Open
peterkrueck wants to merge 24 commits into
Open
Conversation
* Optimize CI workflow with path filtering * Add issue template * Update README
* Revert "Add Swift 5 compatibility (gonzalezreal#4)" This reverts commit 2b65f32. * Delete Swift 5 package manifest * Exclude snapshots * Exclude snapshots * Fix Swift 6.0 build errors
* Update gitignore * Fix documentation link * Update package manifest * Update Makefile * Add math block and math inline tokenization * Refactor pattern substitution * Update EmojiURLAttribute * Add math attachments and properties * Rename attachment selection inline to text * Formatting * Add math processing option and rule * Add math expressions demo * Add math block alignment configuration * Render math fenced code blocks as attachments * Add math expression snapshot tests * Return the attachment description if the png is not available * Update snapshot tests * Update documentation * Update SwiftUIMath dependency
…onzalezreal#14) * Remove overflow frame state loop * Be explicit about ignored properties * Fix table layout cycle * Initialize text selection model once to reduce layout warnings
* Replace table style overlays with modifiers * Add overflow table style * Add table overflow demo * Renaming
…yntax-extensions
* Fixed build for Mac Catalyst. * Added platform support for Mac Catalyst. * Added Catalyst support to demo app.
The macOS mouseDown handler already clears selection when the user clicks on a non-URL area (via resetSelection). The iOS handleTap handler was missing equivalent behavior, causing selection to persist indefinitely until the user selected text in another block. Set model.selectedRange to nil when a tap lands on a non-URL area, matching the macOS behavior.
* Fix format workflow permissions * Fix CI simulator selection and snapshots * Test Mac Catalyst in CI Add a Mac Catalyst test target to the full Makefile matrix. Keep iOS snapshot and fixture-backed selection tests out of Catalyst because they depend on UIKit snapshots or iOS-recorded fixtures. * Use macOS 26 for CI workflows Run the current Xcode CI, demo, and format jobs on macOS 26 with Xcode 26.5. Keep the Xcode 16.4 compatibility job on macOS 15 because that image still provides the older Xcode.
@inlinable public init(_ base: Base) { self.base = base } fails under
-enable-library-evolution because the let property's layout is resilient.
Marking the wrapper struct @Frozen freezes its layout, which is sound for
a transparent single-property wrapper and unlocks library-evolution
consumers (e.g. modules built with BUILD_LIBRARY_FOR_DISTRIBUTION=YES).
clojure is added to the jvm list, and lisp and scheme are listed under Lisp dialects
The GeometryReader used for table overlay and background rendering was blocking taps on links
The selection model is populated from the Text.LayoutKey preference via an onChange. For short content all progressive preference updates land in one SwiftUI frame; onChange coalesces same-frame repeats (the "action tried to update multiple times per frame" guard) and drops the populated value, leaving the model on an empty layout collection. closestPosition then returns nil so a macOS trackpad drag-select (and the iOS equivalent) never starts. Taller content settled across frames and slipped through. Deliver the layout collection through a non-interactive representable's updateNSView/updateUIView instead. That path runs every update cycle with the latest inputs and is not subject to the onChange same-frame drop, so the final populated collection always reaches the model. The model's layoutCollection and coordinator are @ObservationIgnored, so writing them from update*View does not trigger SwiftUI invalidation; the hosted view is hit-test transparent.
peterkrueck
force-pushed
the
fix/selection-model-single-frame-layout
branch
from
July 22, 2026 15:13
a1365b4 to
251b3b7
Compare
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.
Problem
Text selection fails for
StructuredTextcontent that is short enough to lay out within a single SwiftUI frame (a reply/fragment that fits without scrolling). On macOS a trackpad drag never starts a selection; the model is never populated. Taller content that settles its layout across multiple frames works fine — so the symptom presents as "selection works on long text, fails on short text."Root cause
TextSelectionInteractionpopulates theTextSelectionModelfrom theText.LayoutKeypreference via:For short content, all of the progressive
Text.LayoutKeypreference updates land in one SwiftUI frame. SwiftUI drops anonChangeaction's repeated same-frame invocations (this is the source of the runtime warningonChange(of:) action tried to update multiple times per frame), so the populated layout collection'sonChangeaction is never delivered. The model is left on an empty layout collection,closestPosition(to:)returnsnil, andNSTextInteractionView.mouseDowntherefore sets nodragStart— selection never begins. Taller content spreads its layout across frames, so a later (non-coalesced)onChangedelivers the populated collection and selection works.This was confirmed by instrumenting
NSTextInteractionView: on a failing short bubble the overlay frame is correct andmouseDownfires (so geometry/hit-testing are fine), butmodel.hasText == falseandclosestPosition == nilat click time, long after layout settled. Deferring the model update inside theonChange(e.g. viaDispatchQueue.main.async) does not help, because the dropped action never runs to schedule the deferred work.Fix
Deliver the layout collection to the model through a non-interactive representable's update path instead of
onChange:NSViewRepresentable/UIViewRepresentable(TextLayoutModelUpdater) callsmodel.setCoordinator(_:)+model.setLayoutCollection(_:)fromupdateNSView/updateUIView.update*Viewruns on every SwiftUI update cycle with the latest inputs and is not subject to theonChangesame-frame drop, so the final populated collection always reaches the model.hitTestreturnsnil/isUserInteractionEnabled = false), so the real selection overlay and any excluded scroll regions (code blocks) keep receiving their events.layoutCollectionandcoordinatorare@ObservationIgnored, so writing them fromupdate*Viewdoes not trigger SwiftUI invalidation.This also eliminates the
onChange(of:) action tried to update multiple times per framewarning, since theonChangeis gone.Verified on macOS (AppKit) on real hardware: short replies that previously could not be selected now select on the first drag; taller replies are unaffected. The change is platform-symmetric for the UIKit side.
🤖 Generated with Claude Code