Skip to content

Commit 0e3ca03

Browse files
EliulmLukas Pistrol
andauthored
New Feature: Compute the cursor position (#134)
In essence, the line is calculated by dividing the y-position of the text segment with the cursor by the line height: ```swift var line = Int(textSegmentFrame.maxY / textSegmentFrame.height) ``` However, this counts the preceding line wraps as lines too. As a result, I have to count the preceding line wraps with: ```swift textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.location, options: [.ensuresLayout, .ensuresExtraLineFragment] ) { textLayoutFragment in guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation) else { return false } /// Check whether the textLayoutFragment has line wraps if textLayoutFragment.textLineFragments.count > 1 { for lineFragment in textLayoutFragment.textLineFragments { lineWrapsCount += 1 /// Do not count lineFragments after the lineFragment where the cursor is placed if lineFragment == cursorTextLineFragment { break } } /// The first lineFragment will be counted as an actual line lineWrapsCount -= 1 } if textLayoutFragment.textLineFragments.contains(cursorTextLineFragment) { return false } return true } ``` Unfortunately, this does scale with the line count of the file. So we might want to change that if we can come up with a better alternative in the future. As a first implementation, I think it works. --------- Co-authored-by: Lukas Pistrol <[email protected]>
1 parent c611a62 commit 0e3ca03

File tree

4 files changed

+121
-48
lines changed

4 files changed

+121
-48
lines changed

Sources/CodeEditTextView/CodeEditTextView.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
1717
/// - text: The text content
1818
/// - language: The language for syntax highlighting
1919
/// - theme: The theme for syntax highlighting
20-
/// - useThemeBackground: Whether CodeEditTextView uses theme background color or is transparent
2120
/// - font: The default font
2221
/// - tabWidth: The tab width
2322
/// - lineHeight: The line height multiplier (e.g. `1.2`)
@@ -37,7 +36,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
3736
lineHeight: Binding<Double>,
3837
wrapLines: Binding<Bool>,
3938
editorOverscroll: Binding<Double> = .constant(0.0),
40-
cursorPosition: Published<(Int, Int)>.Publisher? = nil,
39+
cursorPosition: Binding<(Int, Int)>,
4140
useThemeBackground: Bool = true,
4241
highlightProvider: HighlightProviding? = nil,
4342
contentInsets: NSEdgeInsets? = nil,
@@ -52,7 +51,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
5251
self._lineHeight = lineHeight
5352
self._wrapLines = wrapLines
5453
self._editorOverscroll = editorOverscroll
55-
self.cursorPosition = cursorPosition
54+
self._cursorPosition = cursorPosition
5655
self.highlightProvider = highlightProvider
5756
self.contentInsets = contentInsets
5857
self.isEditable = isEditable
@@ -66,7 +65,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
6665
@Binding private var lineHeight: Double
6766
@Binding private var wrapLines: Bool
6867
@Binding private var editorOverscroll: Double
69-
private var cursorPosition: Published<(Int, Int)>.Publisher?
68+
@Binding private var cursorPosition: (Int, Int)
7069
private var useThemeBackground: Bool
7170
private var highlightProvider: HighlightProviding?
7271
private var contentInsets: NSEdgeInsets?
@@ -82,7 +81,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
8281
theme: theme,
8382
tabWidth: tabWidth,
8483
wrapLines: wrapLines,
85-
cursorPosition: cursorPosition,
84+
cursorPosition: $cursorPosition,
8685
editorOverscroll: editorOverscroll,
8786
useThemeBackground: useThemeBackground,
8887
highlightProvider: highlightProvider,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//
2+
// STTextViewController+Cursor.swift
3+
//
4+
//
5+
// Created by Elias Wahl on 15.03.23.
6+
//
7+
8+
import Foundation
9+
import AppKit
10+
11+
extension STTextViewController {
12+
func setCursorPosition(_ position: (Int, Int)) {
13+
guard let provider = textView.textLayoutManager.textContentManager else {
14+
return
15+
}
16+
17+
var (line, column) = position
18+
let string = textView.string
19+
if line > 0 {
20+
if string.isEmpty {
21+
// If the file is blank, automatically place the cursor in the first index.
22+
let range = NSRange(string.startIndex..<string.endIndex, in: string)
23+
if let newRange = NSTextRange(range, provider: provider) {
24+
_ = self.textView.becomeFirstResponder()
25+
self.textView.setSelectedRange(newRange)
26+
return
27+
}
28+
}
29+
30+
string.enumerateSubstrings(in: string.startIndex..<string.endIndex) { _, lineRange, _, done in
31+
line -= 1
32+
if line < 1 {
33+
// If `column` exceeds the line length, set cursor to the end of the line.
34+
let index = min(lineRange.upperBound, string.index(lineRange.lowerBound, offsetBy: column - 1))
35+
if let newRange = NSTextRange(NSRange(index..<index, in: string), provider: provider) {
36+
self.textView.setSelectedRange(newRange)
37+
}
38+
done = true
39+
} else {
40+
done = false
41+
}
42+
}
43+
}
44+
}
45+
46+
func updateCursorPosition() {
47+
guard let textLayoutManager = textView.textLayoutManager as NSTextLayoutManager?,
48+
let textContentManager = textLayoutManager.textContentManager as NSTextContentManager?,
49+
let insertionPointLocation = textLayoutManager.insertionPointLocation,
50+
let documentStartLocation = textLayoutManager.documentRange.location as NSTextLocation?,
51+
let documentEndLocation = textLayoutManager.documentRange.endLocation as NSTextLocation?
52+
else {
53+
return
54+
}
55+
56+
let textElements = textContentManager.textElements(
57+
for: NSTextRange(location: textLayoutManager.documentRange.location, end: insertionPointLocation)!)
58+
var line = textElements.count
59+
60+
textLayoutManager.enumerateTextSegments(
61+
in: NSTextRange(location: insertionPointLocation),
62+
type: .standard,
63+
options: [.rangeNotRequired, .upstreamAffinity]
64+
) { _, textSegmentFrame, _, _ -> Bool
65+
in
66+
var col = 1
67+
/// If the cursor is at the end of the document:
68+
if textLayoutManager.offset(from: insertionPointLocation, to: documentEndLocation) == 0 {
69+
/// If document is empty:
70+
if textLayoutManager.offset(from: documentStartLocation, to: documentEndLocation) == 0 {
71+
self.cursorPosition.wrappedValue = (1, 1)
72+
return false
73+
}
74+
guard let cursorTextFragment = textLayoutManager.textLayoutFragment(for: textSegmentFrame.origin),
75+
let cursorTextLineFragment = cursorTextFragment.textLineFragments.last
76+
else { return false }
77+
78+
col = cursorTextLineFragment.characterRange.length + 1
79+
if col == 1 { line += 1 }
80+
} else {
81+
guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation)
82+
else { return false }
83+
84+
/// +1, because we start with the first character with 1
85+
let tempCol = cursorTextLineFragment.characterIndex(for: textSegmentFrame.origin)
86+
let result = tempCol.addingReportingOverflow(1)
87+
88+
if !result.overflow { col = result.partialValue }
89+
/// If cursor is at end of line add 1:
90+
if cursorTextLineFragment.characterRange.length != 1 &&
91+
(cursorTextLineFragment.typographicBounds.width == (textSegmentFrame.maxX + 5.0)) {
92+
col += 1
93+
}
94+
95+
/// If cursor is at first character of line, the current line is not being included
96+
if col == 1 { line += 1 }
97+
}
98+
99+
self.cursorPosition.wrappedValue = (line, col)
100+
return false
101+
}
102+
}
103+
}

Sources/CodeEditTextView/STTextViewController.swift renamed to Sources/CodeEditTextView/Controller/STTextViewController.swift

Lines changed: 13 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
4646
/// The font to use in the `textView`
4747
public var font: NSFont
4848

49+
/// The current cursor position e.g. (1, 1)
50+
public var cursorPosition: Binding<(Int, Int)>
51+
4952
/// The editorOverscroll to use for the textView over scroll
5053
public var editorOverscroll: Double
5154

@@ -80,7 +83,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
8083
theme: EditorTheme,
8184
tabWidth: Int,
8285
wrapLines: Bool,
83-
cursorPosition: Published<(Int, Int)>.Publisher? = nil,
86+
cursorPosition: Binding<(Int, Int)>,
8487
editorOverscroll: Double,
8588
useThemeBackground: Bool,
8689
highlightProvider: HighlightProviding? = nil,
@@ -175,9 +178,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
175178
setHighlightProvider(self.highlightProvider)
176179
setUpTextFormation()
177180

178-
self.cursorPositionCancellable = self.cursorPosition?.sink(receiveValue: { value in
179-
self.setCursorPosition(value)
180-
})
181+
self.setCursorPosition(self.cursorPosition.wrappedValue)
181182
}
182183

183184
public override func viewDidLoad() {
@@ -189,6 +190,14 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
189190
guard let self = self else { return }
190191
(self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets
191192
}
193+
194+
NotificationCenter.default.addObserver(
195+
forName: STTextView.didChangeSelectionNotification,
196+
object: nil,
197+
queue: .main
198+
) { [weak self] _ in
199+
self?.updateCursorPosition()
200+
}
192201
}
193202

194203
public override func viewDidAppear() {
@@ -323,45 +332,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
323332
// TODO: - This should be uncessecary
324333
}
325334

326-
// MARK: Cursor Position
327-
328-
private var cursorPosition: Published<(Int, Int)>.Publisher?
329-
private var cursorPositionCancellable: AnyCancellable?
330-
331-
private func setCursorPosition(_ position: (Int, Int)) {
332-
guard let provider = textView.textLayoutManager.textContentManager else {
333-
return
334-
}
335-
336-
var (line, column) = position
337-
let string = textView.string
338-
if line > 0 {
339-
if string.isEmpty {
340-
// If the file is blank, automatically place the cursor in the first index.
341-
let range = NSRange(string.startIndex..<string.endIndex, in: string)
342-
if let newRange = NSTextRange(range, provider: provider) {
343-
_ = self.textView.becomeFirstResponder()
344-
self.textView.setSelectedRange(newRange)
345-
return
346-
}
347-
}
348-
349-
string.enumerateSubstrings(in: string.startIndex..<string.endIndex) { _, lineRange, _, done in
350-
line -= 1
351-
if line < 1 {
352-
// If `column` exceeds the line length, set cursor to the end of the line.
353-
let index = min(lineRange.upperBound, string.index(lineRange.lowerBound, offsetBy: column - 1))
354-
if let newRange = NSTextRange(NSRange(index..<index, in: string), provider: provider) {
355-
self.textView.setSelectedRange(newRange)
356-
}
357-
done = true
358-
} else {
359-
done = false
360-
}
361-
}
362-
}
363-
}
364-
365335
deinit {
366336
textView = nil
367337
highlighter = nil

Tests/CodeEditTextViewTests/STTextViewControllerTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ final class STTextViewControllerTests: XCTestCase {
3434
theme: theme,
3535
tabWidth: 4,
3636
wrapLines: true,
37+
cursorPosition: .constant((1, 1)),
3738
editorOverscroll: 0.5,
3839
useThemeBackground: true,
3940
isEditable: true

0 commit comments

Comments
 (0)