Skip to content

fix(editor): rebuild text selection around an anchor and the rect being drawn - #2542

Merged
datlechin merged 1 commit into
mainfrom
fix/editor-selection-model-and-drawing
Aug 26, 2026
Merged

fix(editor): rebuild text selection around an anchor and the rect being drawn#2542
datlechin merged 1 commit into
mainfrom
fix/editor-selection-model-and-drawing

Conversation

@datlechin

Copy link
Copy Markdown
Member

Selecting a long stretch of text downward past the viewport and then scrolling back up left a band
near the top with no highlight. Executing still ran the whole selection, so the model was right and
only the drawing was wrong.

Tracing that turned up a subsystem with three separate structural problems, so this rebuilds it
rather than patching the symptom.

Root cause

Selection was modelled as a bare NSRange with no anchor and no granularity, its geometry was
produced as a side effect of drawing, and its drawing read the scroll position. So the model could
not say which end was live, the view could not scroll to it, and the paint went stale.

The reported bug. getFillRects clipped the selection to the current viewport instead of to the
rect draw(_:) was handed. The text view opts into responsive scrolling and is layer-backed, and
AppKit documents that draw(_:)'s dirtyRect "can also represent a nonvisible portion of the view
that AppKit wants to cache". Forcing that exact case with cacheDisplay(in:to:):

dirtyRect visibleRect selection painted into the band
y 1000..1300 y 3000..3600 nothing
y 1000..1300 y 1000..1600 y 1000..1300, 44 rects

Same rect, same selection, same layout; only the scroll position differs. The blank band is then
cached, and nothing on the scroll path repainted the text view, because layoutLines marked only
the LineFragmentView subviews. The text came back on the way up and the highlight could not.

That line is verbatim upstream CodeEditTextView, not a TablePro modification, and upstream has not
fixed it.

What else this fixes

Measured in a real key window, the editor never scrolled to follow a ranged selection, only a
caret, because it scrolled to the selection's whole bounding rect and scrollToVisible stops once
that rect is already on screen:

before after
Caret, Down x60 462 462
Shift+Down x60 0 462
Range near the end of the document 0 8037
Caret at that same offset 8037 8037
  • rectsFor(range:) rebased a document-absolute range against each line, so the offset went negative
    on every line after the first and the fragment lookup matched nothing. A three-line range returned
    one rect. That truncated multi-line find highlights, the statement run band and VoiceOver bounds.
  • setSelectedRange was the only range setter that never invalidated. Five call sites patched around
    it; Option+Shift+Down statement extension and accessibility-set selections did not, and changed
    the selection without repainting.
  • Mouse gestures never recorded an anchor, so Shift+arrow after a drag guessed the live end from
    the arrow direction and moved the wrong one.
  • The drag anchor was taken from the first delivered drag event rather than the press, so a quick
    sweep started the selection several characters from where the pointer went down.
  • NSPressGestureRecognizer turns on delaysPrimaryMouseButtonEvents for itself, which withheld
    every primary mouse-down inside a selection for the press duration. Measured on the live
    recogniser: 167ms of dead pointer. A press inside a selection now defers its caret to mouse up the
    way NSTextView does, so the delay is no longer needed.
  • Autoscroll ran from two places, mouseDragged and the timer, plus once per real drag event, so its
    speed tracked the input device's event rate. It was also scheduled in .default, which stops
    firing inside the tracking loop a drag runs in.
  • Double-clicking =, <, >, +, |, ~, ^ or $ selected nothing: they are Unicode symbols,
    not punctuation, and fell through to the zero-length return. In a SQL editor.
  • keyDown bailed on isEditable, so the read-only JSON, DDL and preview editors had no arrow keys
    and no Shift+arrow at all.
  • A right-click never moved the insertion point, so Explain with AI acted on the previous selection.
  • The inactive selection was keyed on first responder alone and used a luma collapse of the accent
    colour rather than unemphasizedSelectedTextBackgroundColor.
  • Nothing posted selectedTextChanged, so assistive clients never heard the selection move.
  • A pivot surviving an edit could drive an NSRange to length -1.

Shape of the change

  • Geometry is a pure function of layout and selection. New
    TextLayoutManager.textRange(covering:) answers "what text is in this rect"; getFillRects clips
    to that, and the two hand-copied visibleTextRange computations collapse onto it. Drawing no longer
    publishes boundingRect.
  • The model carries an anchor, written by every path that establishes a selection, and one sink
    announces a change to both the internal notification and accessibility.
  • Input anchors at mouse down as offsets, unions the anchor range with the range under the pointer
    so both ends round out to the current granularity, and drives autoscroll from one source at one call
    per tick.

Verification

  • swift test --package-path LocalPackages/CodeEditTextView: 188 passed, 17 of them new. The new
    guards were checked against the unfixed source and fail there.
  • TableProTests: 76 cases across the Vim and editor-coordinator suites, all passing.
  • App target builds; SwiftLint reports 0 violations; verify.sh docs passes.
  • Fill-rect cost on the draw path is unchanged: 0.183ms before and 0.184ms after at 1,000 lines,
    0.203ms and 0.209ms at 20,000, flat in document size either way.

UI automation is not included: the behaviours here are pointer gestures, autoscroll timing and
painted pixels, none of which XCUITest drives deterministically. They are covered by unit tests
against the layout and selection managers instead.

@mintlify

mintlify Bot commented Aug 26, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🔴 Failed Aug 26, 2026, 6:20 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@datlechin
datlechin merged commit 44d4e0e into main Aug 26, 2026
3 of 4 checks passed
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin deleted the fix/editor-selection-model-and-drawing branch August 26, 2026 18:20
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.

1 participant