Skip to content

Commit 09274f9

Browse files
Fix scroll jitter during text selection
Optimize scroll-to-selection logic in layout() to avoid unnecessary viewport jumps: - Skip scrolling if selection already intersects visible viewport - Scroll to nearest selection edge instead of entire range - Extract adjustedScrollRect() to deduplicate gutter-aware rect logic Based on PR krzyzanowskim#108 by brandonbloom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 322b809 commit 09274f9

2 files changed

Lines changed: 50 additions & 25 deletions

File tree

Sources/STTextViewAppKit/STTextView+Scrolling.swift

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,44 +10,42 @@ extension STTextView {
1010
contentView.scroll(point.applying(.init(translationX: -(gutterView?.frame.width ?? 0), y: 0)))
1111
}
1212

13+
/// Adjusts a rect for scrolling by adding line fragment padding and gutter offset.
14+
private func adjustedScrollRect(_ rect: CGRect) -> CGRect {
15+
var adjusted = rect
16+
if adjusted.width.isZero {
17+
adjusted = adjusted.inset(by: .init(top: 0, left: -textContainer.lineFragmentPadding, bottom: 0, right: -textContainer.lineFragmentPadding))
18+
}
19+
adjusted.origin.x -= gutterView?.frame.width ?? 0
20+
adjusted.size.width += gutterView?.frame.width ?? 0
21+
return adjusted
22+
}
23+
1324
@discardableResult
1425
func scrollToVisible(_ textRange: NSTextRange, type: NSTextLayoutManager.SegmentType) -> Bool {
15-
guard var rect = textLayoutManager.textSegmentFrame(in: textRange, type: type) else {
26+
guard let rect = textLayoutManager.textSegmentFrame(in: textRange, type: type) else {
1627
return false
1728
}
29+
return contentView.scrollToVisible(adjustedScrollRect(rect))
30+
}
1831

19-
if rect.width.isZero {
20-
// add padding around the point to ensure the visibility the segment
21-
// since the width of the segment is 0 for a selection
22-
rect = rect.inset(by: .init(top: 0, left: -textContainer.lineFragmentPadding, bottom: 0, right: -textContainer.lineFragmentPadding))
32+
@discardableResult
33+
func scrollToVisible(_ location: NSTextLocation, type: NSTextLayoutManager.SegmentType) -> Bool {
34+
guard let rect = textLayoutManager.textSegmentFrame(at: location, type: type) else {
35+
return false
2336
}
24-
25-
// scroll to visible IN clip view (ignoring gutter view overlay)
26-
// adjust rect to mimick it's size to include gutter overlay
27-
rect.origin.x -= gutterView?.frame.width ?? 0
28-
rect.size.width += gutterView?.frame.width ?? 0
29-
return contentView.scrollToVisible(rect)
37+
return contentView.scrollToVisible(adjustedScrollRect(rect))
3038
}
3139

3240
override open func centerSelectionInVisibleArea(_ sender: Any?) {
3341
guard let selectionTextRange = textLayoutManager.textSelections.last?.textRanges.last,
34-
var rect = textLayoutManager.textSegmentFrame(in: selectionTextRange, type: .selection) else {
42+
let rect = textLayoutManager.textSegmentFrame(in: selectionTextRange, type: .selection) else {
3543
return
3644
}
3745

38-
if rect.width.isZero {
39-
// add padding around the point to ensure the visibility the segment
40-
// since the width of the segment is 0 for a selection
41-
rect = rect.inset(by: .init(top: 0, left: -textContainer.lineFragmentPadding, bottom: 0, right: -textContainer.lineFragmentPadding))
42-
}
43-
44-
// scroll to visible IN clip view (ignoring gutter view overlay)
45-
// adjust rect to mimick it's size to include gutter overlay
46-
rect.origin.x -= gutterView?.frame.width ?? 0
47-
rect.size.width += gutterView?.frame.width ?? 0
48-
46+
let adjusted = adjustedScrollRect(rect)
4947
// put rect origin in the center
50-
contentView.scroll(rect.origin.applying(.init(translationX: 0, y: -visibleRect.height / 2)))
48+
contentView.scroll(adjusted.origin.applying(.init(translationX: 0, y: -visibleRect.height / 2)))
5149
}
5250

5351
override open func pageUp(_ sender: Any?) {

Sources/STTextViewAppKit/STTextView.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1340,12 +1340,39 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol {
13401340
layoutText()
13411341

13421342
if needsScrollToSelection, let textRange = textLayoutManager.textSelections.last?.textRanges.last {
1343-
scrollToVisible(textRange, type: .standard)
1343+
if let scrollLocation = textLocationForScrollingSelection(toVisible: textRange) {
1344+
scrollToVisible(scrollLocation, type: .standard)
1345+
}
13441346
}
13451347

13461348
needsScrollToSelection = false
13471349
}
13481350

1351+
/// Determines the optimal text location to scroll to for making a selection visible.
1352+
/// Returns nil if the selection is already visible, avoiding unnecessary scrolling.
1353+
/// When scrolling is needed, returns the nearest edge of the selection to minimize viewport disruption.
1354+
private func textLocationForScrollingSelection(toVisible textRange: NSTextRange) -> NSTextLocation? {
1355+
guard let selectionRect = textLayoutManager.textSegmentFrame(in: textRange, type: .standard) else {
1356+
return nil
1357+
}
1358+
1359+
let viewportRect = contentView.documentVisibleRect
1360+
1361+
// If the selection already intersects the viewport, no scroll needed
1362+
if viewportRect.intersects(selectionRect) {
1363+
return nil
1364+
}
1365+
1366+
// Scroll to the nearest edge of the selection
1367+
if selectionRect.minY < viewportRect.minY {
1368+
// Selection is above viewport — scroll to the start
1369+
return textRange.location
1370+
} else {
1371+
// Selection is below viewport — scroll to the end
1372+
return textRange.endLocation
1373+
}
1374+
}
1375+
13491376
/// Performs text layout including container sizing, viewport layout, and related updates.
13501377
private func layoutText() {
13511378
guard shouldUpdateLayout else { return }

0 commit comments

Comments
 (0)