Skip to content

Commit 48cef38

Browse files
Fix Cursor Lagging - Update Cursors in TextView.layout (#109)
### Description Addresses an issue described by @nkleemann in the linked issue. The issue was a bug where cursors would lag behind an edit. This is due to the edited line's layout information being invalidated by the edit, meaning the selection manager cannot get valid layout information to position cursors. This change puts a selection position update in the text view's `layout` call, after the layout manager has done it's layout pass. This ensures the selection manage is using valid layout information, fixing the lagging issue. ### Related Issues * CodeEditApp/CodeEditSourceEditor#317 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots On v0.11.2: https://github.com/user-attachments/assets/f4f23e02-58e5-410b-ba1a-0ea5e449dce4 With this change: https://github.com/user-attachments/assets/663fdecb-c1dd-43ec-9990-5f8c7da07205
1 parent 7d63a64 commit 48cef38

File tree

2 files changed

+59
-50
lines changed

2 files changed

+59
-50
lines changed

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

Lines changed: 58 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -136,72 +136,80 @@ public class TextSelectionManager: NSObject {
136136

137137
// MARK: - Selection Views
138138

139-
/// Update all selection cursors. Placing them in the correct position for each text selection and reseting the
140-
/// blink timer.
141-
func updateSelectionViews(force: Bool = false) {
139+
/// Update all selection cursors. Placing them in the correct position for each text selection and
140+
/// optionally reseting the blink timer.
141+
func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) {
142142
guard textView?.isFirstResponder ?? false else { return }
143143
var didUpdate: Bool = false
144144

145145
for textSelection in textSelections {
146146
if textSelection.range.isEmpty {
147-
guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else {
148-
continue
149-
}
150-
151-
var doesViewNeedReposition: Bool
152-
153-
// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
154-
// approximate equals in that case to avoid extra updates.
155-
if useSystemCursor, #available(macOS 14.0, *) {
156-
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin)
157-
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
158-
} else {
159-
doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin
160-
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
161-
}
162-
163-
if textSelection.view == nil || doesViewNeedReposition {
164-
let cursorView: NSView
147+
didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection)
148+
} else if !textSelection.range.isEmpty && textSelection.view != nil {
149+
textSelection.view?.removeFromSuperview()
150+
textSelection.view = nil
151+
didUpdate = true
152+
}
153+
}
165154

166-
if let existingCursorView = textSelection.view {
167-
cursorView = existingCursorView
168-
} else {
169-
textSelection.view?.removeFromSuperview()
170-
textSelection.view = nil
155+
if didUpdate || force {
156+
delegate?.setNeedsDisplay()
157+
if !skipTimerReset {
158+
cursorTimer.resetTimer()
159+
resetSystemCursorTimers()
160+
}
161+
}
162+
}
171163

172-
if useSystemCursor, #available(macOS 14.0, *) {
173-
let systemCursorView = NSTextInsertionIndicator(frame: .zero)
174-
cursorView = systemCursorView
175-
systemCursorView.displayMode = .automatic
176-
} else {
177-
let internalCursorView = CursorView(color: insertionPointColor)
178-
cursorView = internalCursorView
179-
cursorTimer.register(internalCursorView)
180-
}
164+
private func repositionCursorSelection(textSelection: TextSelection) -> Bool {
165+
guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else {
166+
return false
167+
}
181168

182-
textView?.addSubview(cursorView, positioned: .above, relativeTo: nil)
183-
}
169+
var doesViewNeedReposition: Bool
184170

185-
cursorView.frame.origin = cursorRect.origin
186-
cursorView.frame.size.height = cursorRect.height
171+
// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
172+
// approximate equals in that case to avoid extra updates.
173+
if useSystemCursor, #available(macOS 14.0, *) {
174+
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin)
175+
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
176+
} else {
177+
doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin
178+
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
179+
}
187180

188-
textSelection.view = cursorView
189-
textSelection.boundingRect = cursorView.frame
181+
if textSelection.view == nil || doesViewNeedReposition {
182+
let cursorView: NSView
190183

191-
didUpdate = true
192-
}
193-
} else if !textSelection.range.isEmpty && textSelection.view != nil {
184+
if let existingCursorView = textSelection.view {
185+
cursorView = existingCursorView
186+
} else {
194187
textSelection.view?.removeFromSuperview()
195188
textSelection.view = nil
196-
didUpdate = true
189+
190+
if useSystemCursor, #available(macOS 14.0, *) {
191+
let systemCursorView = NSTextInsertionIndicator(frame: .zero)
192+
cursorView = systemCursorView
193+
systemCursorView.displayMode = .automatic
194+
} else {
195+
let internalCursorView = CursorView(color: insertionPointColor)
196+
cursorView = internalCursorView
197+
cursorTimer.register(internalCursorView)
198+
}
199+
200+
textView?.addSubview(cursorView, positioned: .above, relativeTo: nil)
197201
}
198-
}
199202

200-
if didUpdate || force {
201-
delegate?.setNeedsDisplay()
202-
cursorTimer.resetTimer()
203-
resetSystemCursorTimers()
203+
cursorView.frame.origin = cursorRect.origin
204+
cursorView.frame.size.height = cursorRect.height
205+
206+
textSelection.view = cursorView
207+
textSelection.boundingRect = cursorView.frame
208+
209+
return true
204210
}
211+
212+
return false
205213
}
206214

207215
private func resetSystemCursorTimers() {

Sources/CodeEditTextView/TextView/TextView+Layout.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extension TextView {
1111
override public func layout() {
1212
super.layout()
1313
layoutManager.layoutLines()
14+
selectionManager.updateSelectionViews(skipTimerReset: true)
1415
}
1516

1617
open override class var isCompatibleWithResponsiveScrolling: Bool {

0 commit comments

Comments
 (0)