Skip to content
9 changes: 4 additions & 5 deletions Sources/CodeEditTextView/CodeEditTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
/// - text: The text content
/// - language: The language for syntax highlighting
/// - theme: The theme for syntax highlighting
/// - useThemeBackground: Whether CodeEditTextView uses theme background color or is transparent
/// - font: The default font
/// - tabWidth: The tab width
/// - lineHeight: The line height multiplier (e.g. `1.2`)
Expand All @@ -37,7 +36,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
lineHeight: Binding<Double>,
wrapLines: Binding<Bool>,
editorOverscroll: Binding<Double> = .constant(0.0),
cursorPosition: Published<(Int, Int)>.Publisher? = nil,
cursorPosition: Binding<(Int, Int)>,
useThemeBackground: Bool = true,
highlightProvider: HighlightProviding? = nil,
contentInsets: NSEdgeInsets? = nil,
Expand All @@ -52,7 +51,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
self._lineHeight = lineHeight
self._wrapLines = wrapLines
self._editorOverscroll = editorOverscroll
self.cursorPosition = cursorPosition
self._cursorPosition = cursorPosition
self.highlightProvider = highlightProvider
self.contentInsets = contentInsets
self.isEditable = isEditable
Expand All @@ -66,7 +65,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
@Binding private var lineHeight: Double
@Binding private var wrapLines: Bool
@Binding private var editorOverscroll: Double
private var cursorPosition: Published<(Int, Int)>.Publisher?
@Binding private var cursorPosition: (Int, Int)
private var useThemeBackground: Bool
private var highlightProvider: HighlightProviding?
private var contentInsets: NSEdgeInsets?
Expand All @@ -82,7 +81,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
theme: theme,
tabWidth: tabWidth,
wrapLines: wrapLines,
cursorPosition: cursorPosition,
cursorPosition: $cursorPosition,
editorOverscroll: editorOverscroll,
useThemeBackground: useThemeBackground,
highlightProvider: highlightProvider,
Expand Down
103 changes: 103 additions & 0 deletions Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// STTextViewController+Cursor.swift
//
//
// Created by Elias Wahl on 15.03.23.
//

import Foundation
import AppKit

extension STTextViewController {
func setCursorPosition(_ position: (Int, Int)) {
guard let provider = textView.textLayoutManager.textContentManager else {
return
}

var (line, column) = position
let string = textView.string
if line > 0 {
if string.isEmpty {
// If the file is blank, automatically place the cursor in the first index.
let range = NSRange(string.startIndex..<string.endIndex, in: string)
if let newRange = NSTextRange(range, provider: provider) {
_ = self.textView.becomeFirstResponder()
self.textView.setSelectedRange(newRange)
return
}
}

string.enumerateSubstrings(in: string.startIndex..<string.endIndex) { _, lineRange, _, done in
line -= 1
if line < 1 {
// If `column` exceeds the line length, set cursor to the end of the line.
let index = min(lineRange.upperBound, string.index(lineRange.lowerBound, offsetBy: column - 1))
if let newRange = NSTextRange(NSRange(index..<index, in: string), provider: provider) {
self.textView.setSelectedRange(newRange)
}
done = true
} else {
done = false
}
}
}
}

func updateCursorPosition() {
guard let textLayoutManager = textView.textLayoutManager as NSTextLayoutManager?,
let textContentManager = textLayoutManager.textContentManager as NSTextContentManager?,
let insertionPointLocation = textLayoutManager.insertionPointLocation,
let documentStartLocation = textLayoutManager.documentRange.location as NSTextLocation?,
let documentEndLocation = textLayoutManager.documentRange.endLocation as NSTextLocation?
else {
return
}

let textElements = textContentManager.textElements(
for: NSTextRange(location: textLayoutManager.documentRange.location, end: insertionPointLocation)!)
var line = textElements.count

textLayoutManager.enumerateTextSegments(
in: NSTextRange(location: insertionPointLocation),
type: .standard,
options: [.rangeNotRequired, .upstreamAffinity]
) { _, textSegmentFrame, _, _ -> Bool
in
var col = 1
/// If the cursor is at the end of the document:
if textLayoutManager.offset(from: insertionPointLocation, to: documentEndLocation) == 0 {
/// If document is empty:
if textLayoutManager.offset(from: documentStartLocation, to: documentEndLocation) == 0 {
self.cursorPosition.wrappedValue = (1, 1)
return false
}
guard let cursorTextFragment = textLayoutManager.textLayoutFragment(for: textSegmentFrame.origin),
let cursorTextLineFragment = cursorTextFragment.textLineFragments.last
else { return false }

col = cursorTextLineFragment.characterRange.length + 1
if col == 1 { line += 1 }
} else {
guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation)
else { return false }

/// +1, because we start with the first character with 1
let tempCol = cursorTextLineFragment.characterIndex(for: textSegmentFrame.origin)
let result = tempCol.addingReportingOverflow(1)

if !result.overflow { col = result.partialValue }
/// If cursor is at end of line add 1:
if cursorTextLineFragment.characterRange.length != 1 &&
(cursorTextLineFragment.typographicBounds.width == (textSegmentFrame.maxX + 5.0)) {
col += 1
}

/// If cursor is at first character of line, the current line is not being included
if col == 1 { line += 1 }
}

self.cursorPosition.wrappedValue = (line, col)
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
/// The font to use in the `textView`
public var font: NSFont

/// The current cursor position e.g. (1, 1)
public var cursorPosition: Binding<(Int, Int)>

/// The editorOverscroll to use for the textView over scroll
public var editorOverscroll: Double

Expand Down Expand Up @@ -80,7 +83,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
theme: EditorTheme,
tabWidth: Int,
wrapLines: Bool,
cursorPosition: Published<(Int, Int)>.Publisher? = nil,
cursorPosition: Binding<(Int, Int)>,
editorOverscroll: Double,
useThemeBackground: Bool,
highlightProvider: HighlightProviding? = nil,
Expand Down Expand Up @@ -175,9 +178,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
setHighlightProvider(self.highlightProvider)
setUpTextFormation()

self.cursorPositionCancellable = self.cursorPosition?.sink(receiveValue: { value in
self.setCursorPosition(value)
})
self.setCursorPosition(self.cursorPosition.wrappedValue)
}

public override func viewDidLoad() {
Expand All @@ -189,6 +190,14 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
guard let self = self else { return }
(self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets
}

NotificationCenter.default.addObserver(
forName: STTextView.didChangeSelectionNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.updateCursorPosition()
}
}

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

// MARK: Cursor Position

private var cursorPosition: Published<(Int, Int)>.Publisher?
private var cursorPositionCancellable: AnyCancellable?

private func setCursorPosition(_ position: (Int, Int)) {
guard let provider = textView.textLayoutManager.textContentManager else {
return
}

var (line, column) = position
let string = textView.string
if line > 0 {
if string.isEmpty {
// If the file is blank, automatically place the cursor in the first index.
let range = NSRange(string.startIndex..<string.endIndex, in: string)
if let newRange = NSTextRange(range, provider: provider) {
_ = self.textView.becomeFirstResponder()
self.textView.setSelectedRange(newRange)
return
}
}

string.enumerateSubstrings(in: string.startIndex..<string.endIndex) { _, lineRange, _, done in
line -= 1
if line < 1 {
// If `column` exceeds the line length, set cursor to the end of the line.
let index = min(lineRange.upperBound, string.index(lineRange.lowerBound, offsetBy: column - 1))
if let newRange = NSTextRange(NSRange(index..<index, in: string), provider: provider) {
self.textView.setSelectedRange(newRange)
}
done = true
} else {
done = false
}
}
}
}

deinit {
textView = nil
highlighter = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class STTextViewControllerTests: XCTestCase {
theme: theme,
tabWidth: 4,
wrapLines: true,
cursorPosition: .constant((1, 1)),
editorOverscroll: 0.5,
useThemeBackground: true,
isEditable: true
Expand Down