Skip to content

Fix text selection for single-frame (short) layouts - #71

Open
peterkrueck wants to merge 24 commits into
gonzalezreal:mainfrom
GigaDuckAI:fix/selection-model-single-frame-layout
Open

Fix text selection for single-frame (short) layouts#71
peterkrueck wants to merge 24 commits into
gonzalezreal:mainfrom
GigaDuckAI:fix/selection-model-single-frame-layout

Conversation

@peterkrueck

Copy link
Copy Markdown

Problem

Text selection fails for StructuredText content 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

TextSelectionInteraction populates the TextSelectionModel from the Text.LayoutKey preference via:

.overlayTextLayoutCollection { layoutCollection in
  Color.clear
    .onChange(of: AnyTextLayoutCollection(layoutCollection), initial: true) {
      model.setCoordinator(coordinator)
      model.setLayoutCollection(layoutCollection)
    }
}

For short content, all of the progressive Text.LayoutKey preference updates land in one SwiftUI frame. SwiftUI drops an onChange action's repeated same-frame invocations (this is the source of the runtime warning onChange(of:) action tried to update multiple times per frame), so the populated layout collection's onChange action is never delivered. The model is left on an empty layout collection, closestPosition(to:) returns nil, and NSTextInteractionView.mouseDown therefore sets no dragStart — selection never begins. Taller content spreads its layout across frames, so a later (non-coalesced) onChange delivers the populated collection and selection works.

This was confirmed by instrumenting NSTextInteractionView: on a failing short bubble the overlay frame is correct and mouseDown fires (so geometry/hit-testing are fine), but model.hasText == false and closestPosition == nil at click time, long after layout settled. Deferring the model update inside the onChange (e.g. via DispatchQueue.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:

  • A tiny NSViewRepresentable / UIViewRepresentable (TextLayoutModelUpdater) calls model.setCoordinator(_:) + model.setLayoutCollection(_:) from updateNSView / updateUIView.
  • update*View runs on every SwiftUI 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 hosted platform view is hit-test transparent (hitTest returns nil / isUserInteractionEnabled = false), so the real selection overlay and any excluded scroll regions (code blocks) keep receiving their events.
  • The model's layoutCollection and coordinator are @ObservationIgnored, so writing them from update*View does not trigger SwiftUI invalidation.

This also eliminates the onChange(of:) action tried to update multiple times per frame warning, since the onChange is 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

gonzalezreal and others added 24 commits December 27, 2025 16:57
* 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
* 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
peterkrueck force-pushed the fix/selection-model-single-frame-layout branch from a1365b4 to 251b3b7 Compare July 22, 2026 15:13
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.

9 participants